nntp-proxy 0.5.1

NNTP proxy server with per-command backend multiplexing, caching, metrics, and TUI dashboard
Documentation
//! Tests for `UnifiedCache` and related cache functionality
//!
//! This test suite covers:
//! - `UnifiedCache` enum dispatch (memory vs hybrid)
//! - `CacheStatsProvider` trait implementations
//! - `CachedArticle::cached_response_for()` including STAT synthesis
//! - `DiskCache` configuration defaults and validation

use nntp_proxy::cache::{ArticleCache, AvailabilityIndex, UnifiedCache};
use nntp_proxy::protocol::RequestKind;
use nntp_proxy::types::{BackendId, MessageId};
use std::time::Duration;

use super::article_response_bytes;
// UnifiedCache Tests
// ============================================================================

#[tokio::test]
async fn test_unified_cache_memory_construction() {
    let cache = UnifiedCache::memory(1000, Duration::from_secs(60));
    assert!(!cache.is_hybrid());
    assert_eq!(cache.capacity(), 1000);
    assert_eq!(cache.entry_count(), 0);
}

#[tokio::test]
async fn test_unified_cache_availability_construction() {
    let cache = UnifiedCache::availability(std::time::Duration::MAX);
    assert!(!cache.is_hybrid());
    assert_eq!(cache.capacity(), AvailabilityIndex::new().capacity_bytes());
    assert_eq!(cache.entry_count(), 0);
}

#[tokio::test]
async fn test_unified_cache_memory_get_miss() {
    let cache = UnifiedCache::memory(1000, Duration::from_secs(60));
    let msg_id = MessageId::from_str_or_wrap("test@example.com").unwrap();

    let result = cache.get(&msg_id).await;
    assert!(result.is_none());
}

#[tokio::test]
async fn test_unified_cache_availability_get_miss() {
    let cache = UnifiedCache::availability(std::time::Duration::MAX);
    let msg_id = MessageId::from_str_or_wrap("test@example.com").unwrap();

    let result = cache.get(&msg_id).await;
    assert!(result.is_none());
}

#[tokio::test]
async fn test_unified_cache_memory_upsert_and_get() {
    let cache = UnifiedCache::memory(100_000, Duration::from_secs(60));
    let msg_id = MessageId::from_str_or_wrap("test@example.com").unwrap();
    let buffer = b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".to_vec();
    let backend_id = BackendId::from_index(0);

    cache
        .upsert_ingest(msg_id.clone(), buffer.clone(), backend_id, 0.into())
        .await;

    let result = cache.get(&msg_id).await;
    assert!(result.is_some());
    let entry = result.unwrap();
    assert_eq!(
        article_response_bytes(&entry, RequestKind::Article, &msg_id).unwrap(),
        buffer
    );
}

#[tokio::test]
async fn test_unified_cache_memory_record_missing() {
    let cache = UnifiedCache::memory(100_000, Duration::from_secs(60));
    let msg_id = MessageId::from_str_or_wrap("missing@example.com").unwrap();
    let backend_id = BackendId::from_index(0);

    cache
        .record_backend_missing(msg_id.clone(), backend_id)
        .await;

    let result = cache.get(&msg_id).await;
    assert!(result.is_some());
    let entry = result.unwrap();
    assert!(!entry.should_try_backend(backend_id));
}

#[tokio::test]
async fn test_unified_cache_availability_record_missing() {
    let cache = UnifiedCache::availability(std::time::Duration::MAX);
    let msg_id = MessageId::from_str_or_wrap("missing@example.com").unwrap();
    let backend_id = BackendId::from_index(0);

    cache
        .record_backend_missing(msg_id.clone(), backend_id)
        .await;

    let entry = cache.get(&msg_id).await.expect("availability entry");
    assert!(!entry.should_try_backend(backend_id));
    assert_eq!(entry.availability().missing_bits(), 0b0000_0001);
}

#[tokio::test]
async fn test_unified_cache_record_missing_preserves_existing_article() {
    let cache = UnifiedCache::memory(100_000, Duration::from_secs(60));
    let msg_id = MessageId::from_str_or_wrap("test@example.com").unwrap();
    let buffer = b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".to_vec();
    let backend_id = BackendId::from_index(0);

    // First insert an article
    cache
        .upsert_ingest(msg_id.clone(), buffer.clone(), backend_id, 0.into())
        .await;

    cache
        .record_backend_missing(msg_id.clone(), BackendId::from_index(1))
        .await;

    // Verify backend 1 is now marked as missing
    let result = cache.get(&msg_id).await.unwrap();
    assert!(!result.should_try_backend(BackendId::from_index(1)));
    // Backend 0 should still be OK (it has the article)
    assert!(result.should_try_backend(backend_id));
}

#[tokio::test]
async fn test_unified_cache_availability_records_only_missing_facts() {
    let cache = UnifiedCache::availability(std::time::Duration::MAX);
    let msg_id = MessageId::from_str_or_wrap("test@example.com").unwrap();
    cache
        .record_backend_missing(msg_id.clone(), BackendId::from_index(1))
        .await;

    let result = cache.get(&msg_id).await.expect("availability entry");
    assert!(!result.should_try_backend(BackendId::from_index(1)));
    assert!(result.should_try_backend(BackendId::from_index(2)));
    assert_eq!(result.availability().missing_bits(), 0b0000_0010);
    assert_eq!(result.availability().missing_bits(), 0b0000_0010);
}

#[tokio::test]
async fn test_unified_cache_is_hybrid_false_for_memory() {
    let cache = UnifiedCache::memory(1000, Duration::from_secs(60));
    assert!(!cache.is_hybrid());
}

#[tokio::test]
async fn test_unified_cache_hit_rate_initially_zero() {
    let cache = UnifiedCache::memory(1000, Duration::from_secs(60));
    // Hit rate should be 0 (or NaN which displays as 0) when no operations
    let hit_rate = cache.hit_rate();
    assert!(hit_rate == 0.0 || hit_rate.is_nan());
}

#[tokio::test]
async fn test_unified_cache_weighted_size() {
    let cache = UnifiedCache::memory(100_000, Duration::from_secs(60));
    let msg_id = MessageId::from_str_or_wrap("test@example.com").unwrap();
    let buffer = b"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n".to_vec();

    cache
        .upsert_ingest(
            msg_id.clone(),
            buffer,
            nntp_proxy::types::BackendId::from_index(0),
            0.into(),
        )
        .await;

    // Run pending tasks to ensure moka updates its internal state
    cache.sync().await;

    // Verify the entry exists
    assert_eq!(cache.entry_count(), 1);

    // Weighted size should now be > 0
    assert!(cache.weighted_size() > 0);
}

// ============================================================================
// CacheStatsProvider Tests
// ============================================================================

#[tokio::test]
async fn test_cache_stats_provider_article_cache() {
    use nntp_proxy::cache::CacheStatsProvider;

    let cache = ArticleCache::new(10000, Duration::from_secs(60));
    let stats = cache.display_stats();

    assert_eq!(stats.entry_count, 0);
    assert_eq!(stats.size_bytes, 0);
    assert!(stats.disk.is_none()); // Memory-only cache has no disk stats
}

#[tokio::test]
async fn test_cache_stats_provider_unified_cache_memory() {
    use nntp_proxy::cache::CacheStatsProvider;

    let cache = UnifiedCache::memory(10000, Duration::from_secs(60));
    let stats = cache.display_stats();

    assert_eq!(stats.entry_count, 0);
    assert!(stats.disk.is_none()); // Memory-only cache has no disk stats
}

// ============================================================================
// DiskCache Configuration Tests
// ============================================================================

#[test]
fn test_disk_cache_config_defaults() {
    use nntp_proxy::config::DiskCache;

    let config = DiskCache::default();

    assert_eq!(
        config.path,
        std::path::PathBuf::from("/var/cache/nntp-proxy")
    );
    assert!(config.capacity.as_u64() > 0); // 10 GB default
    assert_eq!(
        config.compression,
        nntp_proxy::config::CompressionCodec::Lz4
    ); // Default is lz4
    assert_eq!(config.shards, 4);
}

#[test]
fn test_disk_cache_config_serde_roundtrip() {
    use nntp_proxy::config::DiskCache;

    let config = DiskCache {
        path: std::path::PathBuf::from("/tmp/test-cache"),
        capacity: nntp_proxy::types::CacheCapacity::try_new(1024 * 1024 * 1024).unwrap(),
        compression: nntp_proxy::config::CompressionCodec::None,
        shards: 8,
    };

    let toml_str = toml::to_string(&config).unwrap();
    let parsed: DiskCache = toml::from_str(&toml_str).unwrap();

    assert_eq!(parsed.path, config.path);
    assert_eq!(parsed.capacity.as_u64(), config.capacity.as_u64());
    assert_eq!(parsed.compression, config.compression);
    assert_eq!(parsed.shards, config.shards);
}

#[test]
fn test_cache_config_with_disk() {
    use nntp_proxy::config::Cache;

    let toml_str = r#"
        max_capacity = "256mb"
        ttl = 3600
        cache_articles = true
        adaptive_precheck = false

        [disk]
        path = "/tmp/nntp-cache"
        capacity = "5gb"
        compression = "lz4"
        shards = 4
    "#;

    let config: Cache = toml::from_str(toml_str).unwrap();

    assert!(config.disk.is_some());
    let disk = config.disk.unwrap();
    assert_eq!(disk.path, std::path::PathBuf::from("/tmp/nntp-cache"));
    assert_eq!(disk.compression, nntp_proxy::config::CompressionCodec::Lz4);
}

#[test]
fn test_cache_config_without_disk() {
    use nntp_proxy::config::Cache;

    let toml_str = r#"
        max_capacity = "256mb"
        ttl = 3600
        cache_articles = true
        adaptive_precheck = false
    "#;

    let config: Cache = toml::from_str(toml_str).unwrap();

    assert!(config.disk.is_none());
}

// ============================================================================