rust-mc-status 3.0.0

High-performance asynchronous Rust library for querying Minecraft server status (Java & Bedrock)
Documentation
//! Unit tests for `ResponseCache` — TTL, LRU eviction, disabled mode,
//! cloned sharing, and in-flight deduplication.

use rust_mc_status::core::cache::ResponseCache;
use rust_mc_status::models::{ServerData, ServerStatus, JavaStatus,
                              JavaVersion, JavaPlayers};
use std::time::Duration;
use tokio::time::sleep;

// ─── Helpers ──────────────────────────────────────────────────────────────────

fn make_status(hostname: &str, latency: f64) -> ServerStatus {
    ServerStatus {
        online:   true,
        ip:       "1.2.3.4".into(),
        port:     25565,
        hostname: hostname.into(),
        latency,
        dns:      None,
        cached:   false,
        meta:     Default::default(),
        data:     ServerData::Java(JavaStatus {
            version:     JavaVersion { name: "1.21".into(), protocol: 767 },
            players:     JavaPlayers { online: 10, max: 100, sample: None },
            description: "Test".into(),
            favicon:     None,
            map:         None,
            gamemode:    None,
            software:    None,
            plugins:     None,
            mods:        None,
            raw_data:    serde_json::Value::Null,
        }),
    }
}

// ─── Disabled cache ───────────────────────────────────────────────────────────

#[tokio::test]
async fn disabled_cache_get_always_returns_none() {
    let cache = ResponseCache::disabled();
    cache.insert("key".into(), make_status("host", 100.0)).await;
    assert!(cache.get("key").await.is_none());
}

#[tokio::test]
async fn disabled_cache_insert_is_noop() {
    let cache = ResponseCache::disabled();
    cache.insert("key".into(), make_status("host", 100.0)).await;
    assert_eq!(cache.len().await, 0);
}

#[tokio::test]
async fn disabled_cache_join_or_register_returns_none() {
    let cache = ResponseCache::disabled();
    assert!(cache.join_or_register("key").await.is_none());
}

#[tokio::test]
async fn disabled_cache_is_enabled_returns_false() {
    assert!(!ResponseCache::disabled().is_enabled());
}

// ─── Basic get/insert ─────────────────────────────────────────────────────────

#[tokio::test]
async fn insert_then_get_returns_cached_status() {
    let cache = ResponseCache::new(Duration::from_secs(60), 10);
    let s = make_status("mc.hypixel.net", 42.0);
    cache.insert("key".into(), s.clone()).await;

    let hit = cache.get("key").await.expect("should be a cache hit");
    assert!(hit.cached,     "cached flag should be true");
    assert_eq!(hit.latency, 0.0, "latency should be zeroed");
    assert_eq!(hit.hostname, "mc.hypixel.net");
}

#[tokio::test]
async fn miss_on_unknown_key() {
    let cache = ResponseCache::new(Duration::from_secs(60), 10);
    assert!(cache.get("nonexistent").await.is_none());
}

#[tokio::test]
async fn len_reflects_inserted_entries() {
    let cache = ResponseCache::new(Duration::from_secs(60), 10);
    assert_eq!(cache.len().await, 0);
    cache.insert("a".into(), make_status("a", 1.0)).await;
    assert_eq!(cache.len().await, 1);
    cache.insert("b".into(), make_status("b", 2.0)).await;
    assert_eq!(cache.len().await, 2);
}

#[tokio::test]
async fn clear_removes_all_entries() {
    let cache = ResponseCache::new(Duration::from_secs(60), 10);
    cache.insert("a".into(), make_status("a", 1.0)).await;
    cache.insert("b".into(), make_status("b", 2.0)).await;
    cache.clear().await;
    assert_eq!(cache.len().await, 0);
    assert!(cache.get("a").await.is_none());
    assert!(cache.get("b").await.is_none());
}

// ─── TTL expiry ───────────────────────────────────────────────────────────────

#[tokio::test]
async fn entry_expires_after_ttl() {
    let cache = ResponseCache::new(Duration::from_millis(50), 10);
    cache.insert("key".into(), make_status("host", 10.0)).await;

    // Should be present immediately
    assert!(cache.get("key").await.is_some(), "should be a hit before TTL");

    // Wait for TTL to expire
    sleep(Duration::from_millis(60)).await;
    assert!(cache.get("key").await.is_none(), "should be a miss after TTL");
}

#[tokio::test]
async fn entry_still_valid_before_ttl() {
    let cache = ResponseCache::new(Duration::from_secs(10), 10);
    cache.insert("key".into(), make_status("host", 10.0)).await;
    sleep(Duration::from_millis(20)).await;
    assert!(cache.get("key").await.is_some(), "should still be valid");
}

// ─── LRU eviction ────────────────────────────────────────────────────────────

#[tokio::test]
async fn lru_evicts_oldest_when_full() {
    // Capacity = 2, insert 3 entries — first should be evicted
    let cache = ResponseCache::new(Duration::from_secs(60), 2);
    cache.insert("a".into(), make_status("a", 1.0)).await;
    cache.insert("b".into(), make_status("b", 2.0)).await;
    cache.insert("c".into(), make_status("c", 3.0)).await;

    // LRU evicts "a" — last two should survive
    assert!(cache.get("a").await.is_none(), "a should have been evicted");
    assert!(cache.get("b").await.is_some(), "b should survive");
    assert!(cache.get("c").await.is_some(), "c should survive");
}

#[tokio::test]
async fn lru_access_refreshes_order() {
    let cache = ResponseCache::new(Duration::from_secs(60), 2);
    cache.insert("a".into(), make_status("a", 1.0)).await;
    cache.insert("b".into(), make_status("b", 2.0)).await;

    // Access "a" so it becomes most-recently-used
    let _ = cache.get("a").await;

    // Insert "c" — "b" should be evicted (now LRU), not "a"
    cache.insert("c".into(), make_status("c", 3.0)).await;
    assert!(cache.get("a").await.is_some(), "a should survive (was accessed)");
    assert!(cache.get("b").await.is_none(), "b should be evicted (was LRU)");
    assert!(cache.get("c").await.is_some(), "c should survive (just inserted)");
}

// ─── Clone shares state ───────────────────────────────────────────────────────

#[tokio::test]
async fn cloned_cache_shares_entries() {
    let cache1 = ResponseCache::new(Duration::from_secs(60), 10);
    let cache2 = cache1.clone();

    cache1.insert("key".into(), make_status("host", 5.0)).await;
    assert!(cache2.get("key").await.is_some(), "clone should see inserted entry");
}

#[tokio::test]
async fn clone_clear_affects_original() {
    let cache1 = ResponseCache::new(Duration::from_secs(60), 10);
    let cache2 = cache1.clone();

    cache1.insert("key".into(), make_status("host", 5.0)).await;
    cache2.clear().await;
    assert!(cache1.get("key").await.is_none(), "clear on clone should affect original");
}

// ─── In-flight deduplication ──────────────────────────────────────────────────

#[tokio::test]
async fn first_caller_gets_none_from_join_or_register() {
    let cache = ResponseCache::new(Duration::from_secs(60), 10);
    // First caller should get None — meaning "you are the pinger"
    let result = cache.join_or_register("key").await;
    assert!(result.is_none(), "first caller should get None");
}

#[tokio::test]
async fn second_caller_gets_receiver() {
    let cache = ResponseCache::new(Duration::from_secs(60), 10);
    // First caller registers
    let _ = cache.join_or_register("key").await;
    // Second caller should get a receiver to wait on
    let result = cache.join_or_register("key").await;
    assert!(result.is_some(), "second caller should get a receiver");
}

#[tokio::test]
async fn subscriber_receives_result_after_insert() {
    let cache = ResponseCache::new(Duration::from_secs(60), 10);

    // Task A registers as the pinger
    let _ = cache.join_or_register("key").await;

    // Task B subscribes
    let mut rx = cache.join_or_register("key").await
        .expect("second caller should get receiver");

    // Task A completes and inserts
    let status = make_status("host", 99.0);
    cache.insert("key".into(), status.clone()).await;

    // Task B receives the result
    let received = rx.recv().await.expect("should receive Ok(status)");
    assert!(received.is_ok());
    assert_eq!(received.unwrap().hostname, "host");
}

#[tokio::test]
async fn subscriber_receives_error_after_insert_error() {
    let cache = ResponseCache::new(Duration::from_secs(60), 10);

    let _ = cache.join_or_register("key").await;
    let mut rx = cache.join_or_register("key").await.unwrap();

    cache.insert_error("key", &rust_mc_status::McError::timeout()).await;

    let received = rx.recv().await.expect("should receive Err");
    assert!(received.is_err(), "should receive an error");
}

#[tokio::test]
async fn inflight_entry_removed_after_insert() {
    let cache = ResponseCache::new(Duration::from_secs(60), 10);
    let _ = cache.join_or_register("key").await;
    cache.insert("key".into(), make_status("host", 1.0)).await;

    // After insert, the in-flight slot is freed — next caller gets None again
    let result = cache.join_or_register("key").await;
    // Could be None (new registration) because the entry is now in the LRU cache
    // and get() would return it before join_or_register is reached in ping_with.
    // The in-flight map should be empty now.
    // We just verify no panic and the cache has an entry:
    assert!(cache.get("key").await.is_some());
    let _ = result; // None = new registration, Some = shouldn't happen after clear
}

#[tokio::test]
async fn concurrent_subscribers_all_receive_result() {
    use std::sync::Arc;
    use tokio::task::JoinSet;

    let cache = Arc::new(ResponseCache::new(Duration::from_secs(60), 10));

    // Register the pinger
    let _ = cache.join_or_register("key").await;

    // Spawn 10 subscribers
    let mut set = JoinSet::new();
    for _ in 0..10 {
        let c = Arc::clone(&cache);
        set.spawn(async move {
            let mut rx = c.join_or_register("key").await
                .expect("should get receiver");
            rx.recv().await
        });
    }

    // Small yield to let subscribers register
    sleep(Duration::from_millis(5)).await;

    // Insert the result
    cache.insert("key".into(), make_status("host", 7.0)).await;

    // All subscribers should receive Ok
    let mut count = 0;
    while let Some(Ok(result)) = set.join_next().await {
        assert!(result.is_ok(), "subscriber should receive Ok");
        count += 1;
    }
    assert_eq!(count, 10, "all 10 subscribers should have received the result");
}