use std::time::Duration; use crank_core::{ CacheBackend, CacheScope, CacheStoreError, CachedHeader, CachedResponse, CoordinationStateStore, CoordinationStateValue, RateLimitBucketState, RateLimitStateStore, ReplayGuardStatus, ReplayGuardStore, ResponseCacheStore, }; use crank_runtime::{ InMemoryCoordinationStateStore, InMemoryRateLimitStateStore, InMemoryReplayGuardStore, InMemoryResponseCacheStore, RuntimeCacheConfig, RuntimeCacheConfigError, RuntimeCacheStoreInitError, RuntimeCacheStores, }; use serde_json::json; #[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 loads_valkey_config_from_env() { 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)); unsafe { std::env::remove_var("CRANK_CACHE_BACKEND"); std::env::remove_var("CRANK_CACHE_URL"); std::env::remove_var("CRANK_CACHE_DEFAULT_TTL_MS"); } } #[test] fn rejects_external_backend_without_url() { 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 } )); unsafe { std::env::remove_var("CRANK_CACHE_BACKEND"); } } #[test] fn rejects_zero_ttl() { 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" } )); unsafe { std::env::remove_var("CRANK_CACHE_DEFAULT_TTL_MS"); } } #[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_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 } ); }