use std::{ ffi::OsString, sync::{Mutex, MutexGuard}, time::Duration, }; use crank_core::{ CacheBackend, CacheScope, CacheStoreError, CachedHeader, CachedResponse, CoordinationStateReservation, CoordinationStateStore, CoordinationStateValue, RateLimitBucketState, RateLimitStateStore, ReplayGuardStatus, ReplayGuardStore, ResponseCacheStore, }; use crank_runtime::{ InMemoryCoordinationStateStore, InMemoryRateLimitStateStore, InMemoryReplayGuardStore, InMemoryResponseCacheStore, RuntimeCacheConfig, RuntimeCacheConfigError, RuntimeCacheStoreInitError, RuntimeCacheStores, }; use serde_json::json; const CACHE_ENV_NAMES: [&str; 3] = [ "CRANK_CACHE_BACKEND", "CRANK_CACHE_URL", "CRANK_CACHE_DEFAULT_TTL_MS", ]; static CACHE_ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn defaults_to_in_memory_cache_without_url() { let config = RuntimeCacheConfig::default(); assert_eq!(config.backend, CacheBackend::Memory); assert_eq!(config.url, None); assert_eq!(config.default_ttl_ms, None); } #[test] fn treats_blank_optional_cache_values_as_unset() { let _env = IsolatedCacheEnv::new(); unsafe { std::env::set_var("CRANK_CACHE_URL", " "); std::env::set_var("CRANK_CACHE_DEFAULT_TTL_MS", " "); } let config = RuntimeCacheConfig::from_env().unwrap(); assert_eq!(config.backend, CacheBackend::Memory); assert_eq!(config.url, None); assert_eq!(config.default_ttl_ms, None); } #[test] fn loads_valkey_config_from_env() { let _env = IsolatedCacheEnv::new(); unsafe { std::env::set_var("CRANK_CACHE_BACKEND", "valkey"); std::env::set_var("CRANK_CACHE_URL", "redis://cache:6379/0"); std::env::set_var("CRANK_CACHE_DEFAULT_TTL_MS", "15000"); } let config = RuntimeCacheConfig::from_env().unwrap(); assert_eq!(config.backend, CacheBackend::Valkey); assert_eq!(config.url.as_deref(), Some("redis://cache:6379/0")); assert_eq!(config.default_ttl_ms, Some(15_000)); } #[test] fn rejects_external_backend_without_url() { let _env = IsolatedCacheEnv::new(); unsafe { std::env::set_var("CRANK_CACHE_BACKEND", "redis"); std::env::remove_var("CRANK_CACHE_URL"); } let error = RuntimeCacheConfig::from_env().unwrap_err(); assert!(matches!( error, RuntimeCacheConfigError::MissingUrl { backend: CacheBackend::Redis } )); } #[test] fn rejects_zero_ttl() { let _env = IsolatedCacheEnv::new(); unsafe { std::env::set_var("CRANK_CACHE_DEFAULT_TTL_MS", "0"); } let error = RuntimeCacheConfig::from_env().unwrap_err(); assert!(matches!( error, RuntimeCacheConfigError::ZeroTtl { name: "CRANK_CACHE_DEFAULT_TTL_MS" } )); } struct IsolatedCacheEnv { _lock: MutexGuard<'static, ()>, previous: Vec<(&'static str, Option)>, } impl IsolatedCacheEnv { fn new() -> Self { let lock = CACHE_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let previous = CACHE_ENV_NAMES .iter() .map(|name| (*name, std::env::var_os(name))) .collect(); for name in CACHE_ENV_NAMES { unsafe { std::env::remove_var(name); } } Self { _lock: lock, previous, } } } impl Drop for IsolatedCacheEnv { fn drop(&mut self) { for (name, value) in &self.previous { unsafe { match value { Some(value) => std::env::set_var(name, value), None => std::env::remove_var(name), } } } } } #[tokio::test] async fn in_memory_response_cache_roundtrips_values() { let store = InMemoryResponseCacheStore::default(); let value = CachedResponse { status: 200, headers: vec![CachedHeader { name: "content-type".to_owned(), value: "application/json".to_owned(), }], body: br#"{"ok":true}"#.to_vec(), data: br#"{"ok":true}"#.to_vec(), }; store .put("response:crm:list", value.clone(), Duration::from_secs(60)) .await .unwrap(); assert_eq!( store.get("response:crm:list").await.unwrap(), Some(value.clone()) ); store.delete("response:crm:list").await.unwrap(); assert_eq!(store.get("response:crm:list").await.unwrap(), None); } #[tokio::test] async fn in_memory_rate_limit_store_roundtrips_bucket_state() { let store = InMemoryRateLimitStateStore::default(); let bucket = RateLimitBucketState { tokens_micros: 1_500_000, last_refill_unix_ms: 1_735_689_000_000, }; store .put_bucket("workspace:alpha", bucket, Duration::from_secs(30)) .await .unwrap(); assert_eq!( store.get_bucket("workspace:alpha").await.unwrap(), Some(bucket) ); store.delete_bucket("workspace:alpha").await.unwrap(); assert_eq!(store.get_bucket("workspace:alpha").await.unwrap(), None); } #[tokio::test] async fn in_memory_replay_guard_marks_key_only_once_until_cleared() { let store = InMemoryReplayGuardStore::default(); assert_eq!( store .mark_seen("token:nonce:1", Duration::from_secs(10)) .await .unwrap(), ReplayGuardStatus::Fresh ); assert_eq!( store .mark_seen("token:nonce:1", Duration::from_secs(10)) .await .unwrap(), ReplayGuardStatus::AlreadySeen ); store.clear("token:nonce:1").await.unwrap(); assert_eq!( store .mark_seen("token:nonce:1", Duration::from_secs(10)) .await .unwrap(), ReplayGuardStatus::Fresh ); } #[tokio::test] async fn in_memory_coordination_store_scopes_keys() { let store = InMemoryCoordinationStateStore::default(); let response_value = CoordinationStateValue { payload: json!({ "cursor": "abc" }), }; let session_value = CoordinationStateValue { payload: json!({ "cursor": "xyz" }), }; store .put_value( CacheScope::Response, "job-1", response_value.clone(), Duration::from_secs(20), ) .await .unwrap(); store .put_value( CacheScope::Coordination, "job-1", session_value.clone(), Duration::from_secs(20), ) .await .unwrap(); assert_eq!( store .get_value(CacheScope::Response, "job-1") .await .unwrap(), Some(response_value) ); assert_eq!( store .get_value(CacheScope::Coordination, "job-1") .await .unwrap(), Some(session_value) ); } #[tokio::test] async fn in_memory_coordination_store_atomically_takes_and_reserves_values() { let store = InMemoryCoordinationStateStore::default(); let value = CoordinationStateValue { payload: json!({ "state": "pending" }), }; store .put_value( CacheScope::Coordination, "atomic-job", value.clone(), Duration::from_secs(20), ) .await .unwrap(); let (first, second) = tokio::join!( store.take_value(CacheScope::Coordination, "atomic-job"), store.take_value(CacheScope::Coordination, "atomic-job") ); assert_eq!( usize::from(first.unwrap().is_some()) + usize::from(second.unwrap().is_some()), 1 ); assert_eq!( store .reserve_value( CacheScope::Coordination, "reservation", value.clone(), Duration::from_secs(20), ) .await .unwrap(), CoordinationStateReservation::Reserved ); assert_eq!( store .reserve_value( CacheScope::Coordination, "reservation", CoordinationStateValue { payload: json!({ "state": "other" }), }, Duration::from_secs(20), ) .await .unwrap(), CoordinationStateReservation::Existing(value.clone()) ); let completed = CoordinationStateValue { payload: json!({ "state": "completed" }), }; assert!( store .compare_and_set_value( CacheScope::Coordination, "reservation", &value, completed.clone(), Duration::from_secs(20), ) .await .unwrap() ); assert_eq!( store .get_value(CacheScope::Coordination, "reservation") .await .unwrap(), Some(completed) ); } #[tokio::test] async fn in_memory_stores_reject_empty_keys() { let response_store = InMemoryResponseCacheStore::default(); let replay_store = InMemoryReplayGuardStore::default(); let error = response_store.get("").await.unwrap_err(); assert!(matches!(error, CacheStoreError::InvalidKey { .. })); let error = replay_store .mark_seen("", Duration::from_secs(1)) .await .unwrap_err(); assert!(matches!(error, CacheStoreError::InvalidKey { .. })); } #[tokio::test] async fn runtime_cache_stores_default_to_memory_backend() { let stores = RuntimeCacheStores::from_config(&RuntimeCacheConfig::default()) .await .unwrap(); assert_eq!(stores.backend, CacheBackend::Memory); stores .response .put( "response:health", CachedResponse { status: 200, headers: vec![], body: b"ok".to_vec(), data: br#"null"#.to_vec(), }, Duration::from_secs(5), ) .await .unwrap(); assert!( stores .response .get("response:health") .await .unwrap() .is_some() ); } #[test] fn redis_cache_store_roundtrips_serialized_values() { let response = CachedResponse { status: 202, headers: vec![CachedHeader { name: "x-cache".to_owned(), value: "hit".to_owned(), }], body: br#"{"queued":true}"#.to_vec(), data: br#"null"#.to_vec(), }; let encoded = serde_json::to_vec(&response).unwrap(); let decoded: CachedResponse = serde_json::from_slice(&encoded).unwrap(); assert_eq!(decoded, response); } #[test] fn runtime_cache_stores_report_missing_external_url() { let future = RuntimeCacheStores::from_config(&RuntimeCacheConfig { backend: CacheBackend::Valkey, url: None, default_ttl_ms: None, }); let runtime = tokio::runtime::Runtime::new().unwrap(); let error = match runtime.block_on(future) { Ok(_) => panic!("expected missing external cache url error"), Err(error) => error, }; assert_eq!( error, RuntimeCacheStoreInitError::MissingUrl { backend: CacheBackend::Valkey } ); }