use oxcache::cache::Cache;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
#[tokio::test]
async fn test_null_sentinel_get_returns_none() {
let cache: Cache<String, String> = Cache::builder()
.null_cache_ttl(Duration::from_secs(30))
.build()
.await
.unwrap();
let result = cache
.get_or_option(&"null-key".to_string(), || async { Ok(None) })
.await
.unwrap();
assert!(result.is_none(), "get_or_option should return None");
let get_result = cache.get(&"null-key".to_string()).await.unwrap();
assert!(
get_result.is_none(),
"get() should return None when null sentinel is cached"
);
}
#[tokio::test]
async fn test_get_or_option_caches_null_on_none_fallback() {
let cache: Cache<String, String> = Cache::builder()
.null_cache_ttl(Duration::from_secs(30))
.build()
.await
.unwrap();
let result = cache
.get_or_option(&"missing-key".to_string(), || async { Ok(None) })
.await
.unwrap();
assert!(result.is_none(), "get_or_option should return None");
let call_count = AtomicU32::new(0);
let call_count_ref = &call_count;
let result2 = cache
.get_or_option(&"missing-key".to_string(), || async move {
call_count_ref.fetch_add(1, Ordering::SeqCst);
Ok(None)
})
.await
.unwrap();
assert!(result2.is_none());
assert_eq!(
call_count.load(Ordering::SeqCst),
0,
"fallback should NOT be called when null sentinel is cached"
);
}
#[tokio::test]
async fn test_get_or_option_returns_some_on_real_value() {
let cache: Cache<String, String> = Cache::builder()
.null_cache_ttl(Duration::from_secs(30))
.build()
.await
.unwrap();
let result = cache
.get_or_option(&"real-key".to_string(), || async { Ok(Some("real-value".to_string())) })
.await
.unwrap();
assert_eq!(result, Some("real-value".to_string()));
let cached = cache.get(&"real-key".to_string()).await.unwrap();
assert_eq!(cached, Some("real-value".to_string()));
}
#[tokio::test]
async fn test_get_or_option_no_null_cache_when_disabled() {
let cache: Cache<String, String> = Cache::builder().build().await.unwrap();
let result = cache
.get_or_option(&"no-null-key".to_string(), || async { Ok(None) })
.await
.unwrap();
assert!(result.is_none());
let call_count = AtomicU32::new(0);
let call_count_ref = &call_count;
let _ = cache
.get_or_option(&"no-null-key".to_string(), || async move {
call_count_ref.fetch_add(1, Ordering::SeqCst);
Ok(None)
})
.await
.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"fallback should be called again when null_cache_ttl is not configured"
);
}
#[tokio::test]
async fn test_null_sentinel_expires_after_ttl() {
let cache: Cache<String, String> = Cache::builder()
.null_cache_ttl(Duration::from_millis(100))
.build()
.await
.unwrap();
let result = cache
.get_or_option(&"expiring-null".to_string(), || async { Ok(None) })
.await
.unwrap();
assert!(result.is_none());
let count1 = AtomicU32::new(0);
let count1_ref = &count1;
let _ = cache
.get_or_option(&"expiring-null".to_string(), || async move {
count1_ref.fetch_add(1, Ordering::SeqCst);
Ok(None)
})
.await
.unwrap();
assert_eq!(
count1.load(Ordering::SeqCst),
0,
"sentinel should block fallback immediately"
);
tokio::time::sleep(Duration::from_millis(200)).await;
let count2 = AtomicU32::new(0);
let count2_ref = &count2;
let _ = cache
.get_or_option(&"expiring-null".to_string(), || async move {
count2_ref.fetch_add(1, Ordering::SeqCst);
Ok(None)
})
.await
.unwrap();
assert_eq!(
count2.load(Ordering::SeqCst),
1,
"fallback should be called again after null sentinel expires"
);
}
#[tokio::test]
async fn test_get_or_option_error_propagates() {
let cache: Cache<String, String> = Cache::builder()
.null_cache_ttl(Duration::from_secs(30))
.build()
.await
.unwrap();
let result: oxcache::error::OxCacheResult<Option<String>> = cache
.get_or_option(&"error-key".to_string(), || async {
Err(oxcache::error::OxCacheError::Operation("db down".to_string()))
})
.await;
assert!(result.is_err());
let call_count = AtomicU32::new(0);
let call_count_ref = &call_count;
let _ = cache
.get_or_option(&"error-key".to_string(), || async move {
call_count_ref.fetch_add(1, Ordering::SeqCst);
Ok(Some("recovered".to_string()))
})
.await
.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"fallback should be called again after previous error"
);
}
#[tokio::test]
async fn test_builder_null_cache_ttl_chained() {
let cache: Cache<String, String> = Cache::builder()
.null_cache_ttl(Duration::from_secs(60))
.ttl(Duration::from_secs(300))
.capacity(1000)
.build()
.await
.unwrap();
let result = cache
.get_or_option(&"k".to_string(), || async { Ok(Some("v".to_string())) })
.await
.unwrap();
assert_eq!(result, Some("v".to_string()));
}
#[tokio::test]
async fn test_get_or_option_concurrent_single_flight() {
let cache = Arc::new(
Cache::<String, String>::builder()
.null_cache_ttl(Duration::from_secs(30))
.build()
.await
.unwrap(),
);
let call_count = Arc::new(AtomicU32::new(0));
let mut handles = Vec::new();
for _ in 0..16 {
let cache = cache.clone();
let call_count = call_count.clone();
handles.push(tokio::spawn(async move {
cache
.get_or_option(&"concurrent-null".to_string(), || {
let cc = call_count.clone();
async move {
cc.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(50)).await;
Ok(None)
}
})
.await
.unwrap()
}));
}
for h in handles {
let result = h.await.unwrap();
assert!(result.is_none());
}
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"single-flight should ensure fallback runs exactly once"
);
}
#[tokio::test]
async fn test_ttl_jitter_applied() {
let cache: Cache<String, String> = Cache::builder().ttl_jitter(0.5).build().await.unwrap();
let base_ttl = Duration::from_secs(100);
let mut ttls = Vec::new();
for i in 0..20 {
let key = format!("jitter-key-{i}");
cache
.set_with_ttl(&key, &"value".to_string(), Some(base_ttl))
.await
.unwrap();
if let Some(ttl) = cache.ttl(&key).await.unwrap() {
ttls.push(ttl);
}
}
for ttl in &ttls {
assert!(
*ttl >= Duration::from_secs(49) && *ttl <= Duration::from_secs(151),
"TTL {:?} should be within jitter range [50s, 150s]",
ttl
);
}
let unique_ttls: std::collections::HashSet<_> = ttls.iter().map(|t| t.as_millis()).collect();
assert!(
unique_ttls.len() > 1,
"jitter should produce varied TTLs, got {} unique values from {} samples",
unique_ttls.len(),
ttls.len()
);
}
#[tokio::test]
async fn test_ttl_jitter_zero_no_change() {
let cache: Cache<String, String> = Cache::builder().build().await.unwrap();
let base_ttl = Duration::from_secs(60);
cache
.set_with_ttl(&"stable-key".to_string(), &"v".to_string(), Some(base_ttl))
.await
.unwrap();
let remaining = cache
.ttl(&"stable-key".to_string())
.await
.unwrap()
.expect("key should exist");
assert!(
remaining > Duration::from_secs(58) && remaining <= Duration::from_secs(60),
"with zero jitter, TTL should remain close to base: {:?}",
remaining
);
}
#[tokio::test]
async fn test_null_sentinel_no_jitter() {
let cache: Cache<String, String> = Cache::builder()
.null_cache_ttl(Duration::from_millis(200))
.ttl_jitter(0.5)
.build()
.await
.unwrap();
let result = cache
.get_or_option(&"null-no-jitter".to_string(), || async { Ok(None) })
.await
.unwrap();
assert!(result.is_none());
tokio::time::sleep(Duration::from_millis(300)).await;
let call_count = AtomicU32::new(0);
let call_count_ref = &call_count;
let _ = cache
.get_or_option(&"null-no-jitter".to_string(), || async move {
call_count_ref.fetch_add(1, Ordering::SeqCst);
Ok(Some("back".to_string()))
})
.await
.unwrap();
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"null sentinel should expire at fixed TTL (no jitter)"
);
}
#[tokio::test]
async fn test_builder_ttl_jitter_clamped() {
let cache: Cache<String, String> = Cache::builder().ttl_jitter(2.0).build().await.unwrap();
let base_ttl = Duration::from_secs(100);
cache
.set_with_ttl(&"clamped".to_string(), &"v".to_string(), Some(base_ttl))
.await
.unwrap();
let remaining = cache
.ttl(&"clamped".to_string())
.await
.unwrap()
.expect("key should exist");
assert!(
remaining <= Duration::from_secs(201),
"TTL should be within clamped jitter range: {:?}",
remaining
);
}