Files
crank/crates/crank-runtime/tests/unit/cache.rs
T

343 lines
9.0 KiB
Rust

use std::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;
#[test]
fn defaults_to_in_memory_cache_without_url() {
let config = RuntimeCacheConfig::default();
assert_eq!(config.backend, CacheBackend::Memory);
assert_eq!(config.url, None);
}
#[test]
fn accepts_validated_valkey_values() {
let config = RuntimeCacheConfig::try_new(
CacheBackend::Valkey,
Some("redis://cache:6379/0".to_owned()),
)
.unwrap();
assert_eq!(config.backend, CacheBackend::Valkey);
assert_eq!(config.url.as_deref(), Some("redis://cache:6379/0"));
}
#[test]
fn rejects_external_backend_without_url() {
let error = RuntimeCacheConfig::try_new(CacheBackend::Redis, None).unwrap_err();
assert!(matches!(
error,
RuntimeCacheConfigError::MissingUrl {
backend: CacheBackend::Redis
}
));
}
#[test]
fn rejects_url_for_memory_backend() {
let error =
RuntimeCacheConfig::try_new(CacheBackend::Memory, Some("redis://cache:6379".to_owned()))
.unwrap_err();
assert!(matches!(error, RuntimeCacheConfigError::UnexpectedUrl));
}
#[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,
});
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
}
);
}