From 80cbb9cda16b6bb268da01f6c7d1285875dbdc47 Mon Sep 17 00:00:00 2001 From: github-ops Date: Sat, 20 Jun 2026 20:49:34 +0000 Subject: [PATCH] Move runtime cache tests out of source --- crates/crank-runtime/src/cache.rs | 322 ----------------------- crates/crank-runtime/tests/unit.rs | 2 + crates/crank-runtime/tests/unit/cache.rs | 298 +++++++++++++++++++++ 3 files changed, 300 insertions(+), 322 deletions(-) create mode 100644 crates/crank-runtime/tests/unit.rs create mode 100644 crates/crank-runtime/tests/unit/cache.rs diff --git a/crates/crank-runtime/src/cache.rs b/crates/crank-runtime/src/cache.rs index 62eb8eb..789676c 100644 --- a/crates/crank-runtime/src/cache.rs +++ b/crates/crank-runtime/src/cache.rs @@ -579,325 +579,3 @@ pub enum RuntimeCacheStoreInitError { details: String, }, } - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use crank_core::CacheBackend; - use serde_json::json; - - use super::{ - InMemoryCoordinationStateStore, InMemoryRateLimitStateStore, InMemoryReplayGuardStore, - InMemoryResponseCacheStore, RedisCacheStore, RuntimeCacheConfig, RuntimeCacheConfigError, - RuntimeCacheStoreInitError, RuntimeCacheStores, - }; - use crank_core::{ - CacheScope, CacheStoreError, CachedHeader, CachedResponse, CoordinationStateStore, - CoordinationStateValue, RateLimitBucketState, RateLimitStateStore, ReplayGuardStatus, - ReplayGuardStore, ResponseCacheStore, - }; - - #[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_scopes_keys_by_kind() { - let key = "agent:primary"; - - let response_key = format!("crank:{}:{}", "response", key); - let coordination_key = format!( - "crank:{}:{}", - RedisCacheStore::coordination_kind(CacheScope::Coordination), - key - ); - - assert_eq!(response_key, "crank:response:agent:primary"); - assert_eq!( - coordination_key, - "crank:coordination:coordination:agent:primary" - ); - } - - #[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 - } - ); - } -} diff --git a/crates/crank-runtime/tests/unit.rs b/crates/crank-runtime/tests/unit.rs new file mode 100644 index 0000000..5c08764 --- /dev/null +++ b/crates/crank-runtime/tests/unit.rs @@ -0,0 +1,2 @@ +#[path = "unit/cache.rs"] +mod cache; diff --git a/crates/crank-runtime/tests/unit/cache.rs b/crates/crank-runtime/tests/unit/cache.rs new file mode 100644 index 0000000..db90993 --- /dev/null +++ b/crates/crank-runtime/tests/unit/cache.rs @@ -0,0 +1,298 @@ +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 + } + ); +}