nntp-proxy 0.5.1

NNTP proxy server with per-command backend multiplexing, caching, metrics, and TUI dashboard
Documentation
//! Tests for cache helper functions and functional style refactoring
//!
//! Tests for:
//! - Cache path optimizations
//! - Cache hit/miss behavior
//! - Command filtering for cache
//! - TTL and capacity limits

use anyhow::Result;
use nntp_proxy::cache::ArticleCache;
use nntp_proxy::protocol::RequestKind;
use nntp_proxy::types::{BackendId, MessageId};
use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;

use super::article_response_bytes;

async fn wait_until_cache_miss(cache: &ArticleCache, msgid: &MessageId<'_>, within: Duration) {
    timeout(within, async {
        loop {
            if cache.get(msgid).await.is_none() {
                return;
            }
            tokio::task::yield_now().await;
        }
    })
    .await
    .unwrap_or_else(|_| panic!("Timed out waiting for {msgid} to expire from cache"));
}

/// Test cache hit serves cached content
#[tokio::test]
async fn test_cache_hit() -> Result<()> {
    let cache = Arc::new(ArticleCache::new(20000, Duration::from_secs(300)));

    let msgid = MessageId::from_borrowed("<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();

    // Insert
    cache
        .upsert_ingest(
            msgid.clone(),
            buffer.clone(),
            BackendId::from_index(0),
            0.into(),
        )
        .await;

    // Retrieve - should hit
    let retrieved = cache.get(&msgid).await;
    assert!(retrieved.is_some());
    assert_eq!(
        article_response_bytes(&retrieved.unwrap(), RequestKind::Article, &msgid).unwrap(),
        buffer
    );

    Ok(())
}

/// Test cache miss returns None
#[tokio::test]
async fn test_cache_miss() -> Result<()> {
    let cache = Arc::new(ArticleCache::new(20000, Duration::from_secs(300)));

    let msgid = MessageId::from_borrowed("<nonexistent@example.com>").unwrap();
    let result = cache.get(&msgid).await;

    assert!(result.is_none());
    Ok(())
}

/// Test multiple message IDs can be cached
#[tokio::test]
async fn test_multiple_cache_entries() -> Result<()> {
    let cache = Arc::new(ArticleCache::new(20000, Duration::from_secs(300)));

    for i in 1..=5 {
        let msg_str = format!("<test{i}@example.com>");
        let msgid = MessageId::from_borrowed(&msg_str).unwrap();
        let buffer = format!("220 Body {i}\r\n.\r\n").into_bytes();
        cache
            .upsert_ingest(msgid, buffer, BackendId::from_index(0), 0.into())
            .await;
    }

    // Sync cache to ensure all inserts are processed
    cache.sync().await;

    let stats = cache.stats();
    assert_eq!(stats.entry_count, 5);

    // Verify each is retrievable
    for i in 1..=5 {
        let msg_str = format!("<test{i}@example.com>");
        let msgid = MessageId::from_borrowed(&msg_str).unwrap();
        assert!(cache.get(&msgid).await.is_some());
    }

    Ok(())
}

/// Test cache respects TTL
#[tokio::test]
async fn test_cache_ttl_expiration() -> Result<()> {
    let cache = Arc::new(ArticleCache::new(20000, Duration::from_millis(100)));

    let msgid = MessageId::from_borrowed("<test@example.com>").unwrap();
    let buffer = b"220 0 <test@example.com>\r\nBody\r\n.\r\n".to_vec();

    // Insert article
    cache
        .upsert_ingest(msgid.clone(), buffer, BackendId::from_index(0), 0.into())
        .await;

    // Should be in cache immediately
    assert!(cache.get(&msgid).await.is_some());

    wait_until_cache_miss(&cache, &msgid, Duration::from_secs(1)).await;

    Ok(())
}

/// Test cache capacity limits (LRU eviction)
#[tokio::test]
async fn test_cache_capacity_limit() -> Result<()> {
    let cache = Arc::new(ArticleCache::new(2, Duration::from_secs(300))); // Only 2 entries

    // Insert 3 articles; the cache should evict back to its configured capacity.
    for i in 1..=3 {
        let msg_str = format!("<test{i}@example.com>");
        let msgid = MessageId::from_borrowed(&msg_str).unwrap();
        let buffer = format!("220 Body {i}\r\n.\r\n").into_bytes();
        cache
            .upsert_ingest(msgid, buffer, BackendId::from_index(0), 0.into())
            .await;
    }

    cache.sync().await;

    let stats = cache.stats();
    assert!(
        stats.entry_count <= 2,
        "Cache should respect capacity limit, got {}",
        stats.entry_count
    );

    Ok(())
}

/// Test cache with different `MessageId` formats
#[tokio::test]
async fn test_cache_different_message_id_formats() -> Result<()> {
    let cache = Arc::new(ArticleCache::new(20000, Duration::from_secs(300)));

    // Different valid message-ID formats
    let test_cases = vec![
        "<simple@example.com>",
        "<complex+tag@sub.domain.com>",
        "<123456@news.server>",
        "<$uuid@host>",
    ];

    for msg_str in test_cases {
        let msgid = MessageId::from_borrowed(msg_str).unwrap();
        let buffer = format!("220 {msg_str}\r\n.\r\n").into_bytes();
        cache
            .upsert_ingest(msgid.clone(), buffer, BackendId::from_index(0), 0.into())
            .await;

        // Verify retrievable
        assert!(cache.get(&msgid).await.is_some());
    }

    Ok(())
}

/// Test cache stats reporting
#[tokio::test]
async fn test_cache_stats() -> Result<()> {
    // With MOKA_OVERHEAD=2000 and 2.5x multiplier for small entries:
    // Each 1KB entry weighs approx (1024 + 68 + 40 + 2000) * 2.5 ≈ 7830 bytes
    // So 10 entries need ~78KB capacity
    let cache = Arc::new(ArticleCache::new(100_000, Duration::from_secs(300)));

    // Initially empty
    let stats = cache.stats();
    assert_eq!(stats.entry_count, 0);

    // Add some entries
    for i in 1..=10 {
        let msg_str = format!("<test{i}@example.com>");
        let msgid = MessageId::from_borrowed(&msg_str).unwrap();
        let mut buffer = vec![0u8; 1024];
        // Put valid status code at start
        buffer[0..3].copy_from_slice(b"220");
        cache
            .upsert_ingest(msgid, buffer, BackendId::from_index(0), 0.into())
            .await;
    }

    // Run pending background tasks to ensure cache is fully updated
    cache.sync().await;

    let stats = cache.stats();
    assert_eq!(stats.entry_count, 10);
    assert!(stats.weighted_size > 0);

    Ok(())
}

/// Test Arc<str> borrow lookup (zero-allocation cache get)
#[tokio::test]
async fn test_zero_allocation_lookup() -> Result<()> {
    let cache = Arc::new(ArticleCache::new(20000, Duration::from_secs(300)));

    let msgid1 = MessageId::from_borrowed("<test@example.com>").unwrap();
    let buffer = b"220 Body\r\n.\r\n".to_vec();

    cache
        .upsert_ingest(msgid1, buffer, BackendId::from_index(0), 0.into())
        .await;

    // Create different MessageId instance with same content
    let msgid2 = MessageId::from_borrowed("<test@example.com>").unwrap();

    // Should find via Borrow<str> trait (zero allocation)
    assert!(cache.get(&msgid2).await.is_some());

    Ok(())
}