Move runtime cache tests out of source
Deploy / deploy (push) Successful in 1m33s
CI / Rust Checks (push) Failing after 4m52s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Deployment Manifests (push) Has been skipped

This commit is contained in:
github-ops
2026-06-20 20:49:34 +00:00
parent 11d5493a9f
commit 80cbb9cda1
3 changed files with 300 additions and 322 deletions
-322
View File
@@ -579,325 +579,3 @@ pub enum RuntimeCacheStoreInitError {
details: String, 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
}
);
}
}
+2
View File
@@ -0,0 +1,2 @@
#[path = "unit/cache.rs"]
mod cache;
+298
View File
@@ -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
}
);
}