use super::*;
use std::cell::{Cell, RefCell};
use std::io;
use jiff::SignedDuration as ChronoDuration;
use tempfile::TempDir;
use crate::data_context::cache::{CacheStore, CachedUsage, Lock, LockStore};
use crate::data_context::credentials::Credentials;
use crate::data_context::error::CredentialError;
use crate::data_context::fetcher::{HttpResponse, UsageTransport};
use crate::data_context::jsonl::{
FiveHourBlock, JsonlAggregate, SevenDayWindow as JsonlSevenDayWindow, TokenCounts,
};
struct FakeTransport {
response: RefCell<io::Result<HttpResponse>>,
calls: Cell<u32>,
}
impl FakeTransport {
fn ok(status: u16, body: &str, retry_after: Option<&str>) -> Self {
Self {
response: RefCell::new(Ok(HttpResponse {
status,
body: body.as_bytes().to_vec(),
retry_after: retry_after.map(String::from),
})),
calls: Cell::new(0),
}
}
fn err(kind: io::ErrorKind) -> Self {
Self {
response: RefCell::new(Err(io::Error::new(kind, "fake"))),
calls: Cell::new(0),
}
}
}
impl UsageTransport for FakeTransport {
fn get(&self, _url: &str, _token: &str, _timeout: Duration) -> io::Result<HttpResponse> {
self.calls.set(self.calls.get() + 1);
match &*self.response.borrow() {
Ok(r) => Ok(HttpResponse {
status: r.status,
body: r.body.clone(),
retry_after: r.retry_after.clone(),
}),
Err(e) => Err(io::Error::new(e.kind(), e.to_string())),
}
}
}
const SAMPLE_BODY: &str = r#"{
"five_hour": { "utilization": 42.0, "resets_at": "2026-04-19T05:00:00Z" },
"seven_day": { "utilization": 33.0, "resets_at": "2026-04-23T19:00:00Z" }
}"#;
fn sample_response() -> UsageApiResponse {
serde_json::from_str(SAMPLE_BODY).unwrap()
}
fn config() -> UsageCascadeConfig {
UsageCascadeConfig::default()
}
fn now_fn() -> impl Fn() -> Timestamp {
let ts = Timestamp::now();
move || ts
}
fn ok_creds() -> Arc<Result<Credentials, CredentialError>> {
Arc::new(Ok(Credentials::for_testing("test-token")))
}
fn no_creds() -> Arc<Result<Credentials, CredentialError>> {
Arc::new(Err(CredentialError::NoCredentials))
}
fn jsonl_empty() -> Result<JsonlAggregate, JsonlError> {
Err(JsonlError::NoEntries)
}
fn jsonl_ok() -> Result<JsonlAggregate, JsonlError> {
Ok(JsonlAggregate {
five_hour: None,
seven_day: JsonlSevenDayWindow {
window_start: Timestamp::now() - ChronoDuration::from_hours(7 * 24),
token_counts: TokenCounts::from_parts(1_000_000, 200_000, 0, 0),
},
source_paths: Vec::new(),
})
}
fn jsonl_ok_with_active_block() -> Result<JsonlAggregate, JsonlError> {
let now = Timestamp::now();
let start = now - ChronoDuration::from_hours(1);
Ok(JsonlAggregate {
five_hour: Some(FiveHourBlock {
start,
actual_last_activity: now,
token_counts: TokenCounts::from_parts(400_000, 20_000, 0, 0),
models: vec!["claude-opus-4-7".into()],
usage_limit_reset: None,
}),
seven_day: JsonlSevenDayWindow {
window_start: now - ChronoDuration::from_hours(7 * 24),
token_counts: TokenCounts::from_parts(1_000_000, 200_000, 0, 0),
},
source_paths: Vec::new(),
})
}
fn stale_cache_entry(age: ChronoDuration) -> CachedUsage {
let mut entry = CachedUsage::with_data(sample_response());
entry.cached_at = Timestamp::now() - age;
entry
}
fn assert_jsonl_matches_ok_fixture(data: &UsageData) {
let UsageData::Jsonl(j) = data else {
panic!("expected UsageData::Jsonl, got {data:?}");
};
assert!(
j.five_hour.is_none(),
"jsonl_ok fixture has no active 5h block",
);
assert_eq!(
j.seven_day.tokens.total(),
1_200_000,
"7d total must match jsonl_ok fixture (1M input + 200k output)",
);
}
#[test]
fn fresh_disk_cache_short_circuits_without_reading_credentials() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&CachedUsage::with_data(sample_response()))
.unwrap();
let cred_calls = Cell::new(0u32);
let jsonl_calls = Cell::new(0u32);
let credentials = || {
cred_calls.set(cred_calls.get() + 1);
ok_creds()
};
let jsonl = || {
jsonl_calls.set(jsonl_calls.get() + 1);
jsonl_empty()
};
let transport = FakeTransport::ok(200, "", None);
let data = resolve_usage(
Some(&cache),
None,
&transport,
&credentials,
&jsonl,
&now_fn(),
&config(),
)
.expect("ok");
let UsageData::Endpoint(endpoint) = &data else {
panic!("expected endpoint variant, got {data:?}");
};
assert_eq!(endpoint.five_hour.unwrap().utilization.value(), 42.0);
assert_eq!(cred_calls.get(), 0, "credentials must not be called");
assert_eq!(jsonl_calls.get(), 0, "jsonl must not be called");
assert_eq!(transport.calls.get(), 0, "no HTTP on cache hit");
}
#[test]
fn stale_cache_without_lock_triggers_fetch_and_overwrites() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
let data = resolve_usage(
Some(&cache),
Some(&lock),
&transport,
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("ok");
assert!(matches!(data, UsageData::Endpoint(_)));
assert_eq!(transport.calls.get(), 1);
let refreshed = cache.read().unwrap().unwrap();
let age = Timestamp::now().duration_since(refreshed.cached_at);
assert!(age.as_secs() < 5, "cache must be re-stamped on success");
}
#[test]
fn stale_cache_with_active_lock_serves_stale_without_credentials() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
lock.write(&Lock {
blocked_until: Timestamp::now().as_second() + 60,
error: Some("rate-limited".into()),
})
.unwrap();
let cred_calls = Cell::new(0u32);
let credentials = || {
cred_calls.set(cred_calls.get() + 1);
ok_creds()
};
let transport = FakeTransport::ok(200, "", None);
let data = resolve_usage(
Some(&cache),
Some(&lock),
&transport,
&credentials,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("ok");
assert!(matches!(data, UsageData::Endpoint(_)));
assert_eq!(
cred_calls.get(),
0,
"active lock must short-circuit before credentials read",
);
assert_eq!(transport.calls.get(), 0, "no HTTP when lock + stale cache");
}
#[test]
fn no_credentials_surfaces_nocredentials_not_timeout() {
let transport = FakeTransport::err(io::ErrorKind::TimedOut);
let err = resolve_usage(
None,
None,
&transport,
&no_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::NoCredentials));
assert_eq!(transport.calls.get(), 0, "no HTTP when credentials missing",);
}
#[test]
fn no_credentials_falls_through_to_jsonl_when_available() {
let data = resolve_usage(
None,
None,
&FakeTransport::ok(200, "", None),
&no_creds,
&jsonl_ok,
&now_fn(),
&config(),
)
.expect("ok");
assert_jsonl_matches_ok_fixture(&data);
}
#[test]
fn no_credentials_with_empty_jsonl_still_surfaces_nocredentials() {
let err = resolve_usage(
None,
None,
&FakeTransport::ok(200, "", None),
&no_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::NoCredentials));
}
#[test]
fn endpoint_200_writes_cache_and_lock() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
let lock = LockStore::new(tmp.path().to_path_buf());
let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
let data = resolve_usage(
Some(&cache),
Some(&lock),
&transport,
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("ok");
assert!(matches!(data, UsageData::Endpoint(_)));
assert!(cache.read().unwrap().is_some(), "cache must be populated");
let persisted_lock = lock.read().unwrap().unwrap();
let expected_blocked_until =
Timestamp::now().as_second() + config().cache_duration.as_secs() as i64;
assert!(
(persisted_lock.blocked_until - expected_blocked_until).abs() < 5,
"lock blocked_until = {}, expected near {}",
persisted_lock.blocked_until,
expected_blocked_until,
);
}
#[test]
fn endpoint_401_falls_through_to_jsonl_when_available() {
let transport = FakeTransport::ok(401, "", None);
let data = resolve_usage(
None,
None,
&transport,
&ok_creds,
&jsonl_ok,
&now_fn(),
&config(),
)
.expect("ok");
assert_jsonl_matches_ok_fixture(&data);
assert_eq!(transport.calls.get(), 1);
}
#[test]
fn jsonl_fallback_clamps_future_dated_block_start_to_now() {
let now = Timestamp::now();
let skewed_start = now + ChronoDuration::from_hours(2);
let skewed: Result<JsonlAggregate, JsonlError> = Ok(JsonlAggregate {
five_hour: Some(FiveHourBlock {
start: skewed_start,
actual_last_activity: now + ChronoDuration::from_mins(30),
token_counts: TokenCounts::from_parts(100, 0, 0, 0),
models: vec!["claude-opus-4-7".into()],
usage_limit_reset: None,
}),
seven_day: JsonlSevenDayWindow {
window_start: now - ChronoDuration::from_hours(7 * 24),
token_counts: TokenCounts::from_parts(100, 0, 0, 0),
},
source_paths: Vec::new(),
});
let skewed_closure = || match &skewed {
Ok(agg) => Ok(agg.clone()),
Err(_) => Err(JsonlError::NoEntries),
};
let now_clock = move || now;
let data = resolve_usage(
None,
None,
&FakeTransport::err(io::ErrorKind::TimedOut),
&ok_creds,
&skewed_closure,
&now_clock,
&config(),
)
.expect("ok");
let UsageData::Jsonl(j) = &data else {
panic!("expected jsonl variant, got {data:?}");
};
let window = j
.five_hour
.as_ref()
.expect("active block should populate five_hour window");
assert!(
window.ends_at() <= now + ChronoDuration::from_hours(5),
"ends_at={:?} must be clamped at/before now + 5h ({:?})",
window.ends_at(),
now + ChronoDuration::from_hours(5),
);
}
#[test]
fn jsonl_fallback_surfaces_five_hour_window_with_ends_at() {
let data = resolve_usage(
None,
None,
&FakeTransport::err(io::ErrorKind::TimedOut),
&ok_creds,
&jsonl_ok_with_active_block,
&now_fn(),
&config(),
)
.expect("ok");
let UsageData::Jsonl(j) = &data else {
panic!("expected jsonl variant, got {data:?}");
};
let window = j
.five_hour
.as_ref()
.expect("active block should populate five_hour window");
let expected_ends_at = Timestamp::now() + ChronoDuration::from_hours(4);
let drift = window
.ends_at()
.duration_since(expected_ends_at)
.as_secs()
.abs();
assert!(
drift < 5,
"ends_at={:?} drifted {drift}s from expected",
window.ends_at(),
);
assert_eq!(window.tokens.total(), 420_000);
}
#[test]
fn endpoint_401_with_empty_jsonl_surfaces_unauthorized() {
let err = resolve_usage(
None,
None,
&FakeTransport::ok(401, "", None),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::Unauthorized));
}
#[test]
fn endpoint_401_does_not_serve_stale_cache() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let err = resolve_usage(
Some(&cache),
None,
&FakeTransport::ok(401, "", None),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::Unauthorized));
}
#[test]
fn endpoint_401_clears_cache_so_peers_skip_fresh_short_circuit() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
assert!(
cache.read().unwrap().is_some(),
"fixture wrote a cache entry"
);
let err = resolve_usage(
Some(&cache),
Some(&lock),
&FakeTransport::ok(401, "", None),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::Unauthorized));
assert!(
cache.read().unwrap().is_none(),
"401 must clear the cache so peers fall through to the lock-active 401 guard",
);
let active_lock = lock.read().unwrap().expect("failure lock written");
assert_eq!(
active_lock.error.as_deref(),
Some("Unauthorized"),
"failure lock still records the 401 reason for the lock-active path",
);
}
#[test]
fn endpoint_non_401_failure_preserves_cache_for_stale_serve() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
let data = resolve_usage(
Some(&cache),
Some(&lock),
&FakeTransport::ok(429, "", Some("60")),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("429 with stale cache serves the cached data");
assert!(
matches!(data, UsageData::Endpoint(_)),
"stale-serve path must return the cached endpoint data: got {data:?}",
);
assert!(
cache.read().unwrap().is_some(),
"429 must NOT clear the cache — the stale-serve path needs the data",
);
}
#[test]
fn invocation_after_401_does_not_serve_stale_cache_via_lock_active() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
let transport_a = FakeTransport::ok(401, "", None);
let err_a = resolve_usage(
Some(&cache),
Some(&lock),
&transport_a,
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err_a, UsageError::Unauthorized));
let transport_b = FakeTransport::ok(200, SAMPLE_BODY, None);
let err_b = resolve_usage(
Some(&cache),
Some(&lock),
&transport_b,
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err_b, UsageError::Unauthorized));
assert_eq!(
transport_b.calls.get(),
0,
"active lock must still gate the endpoint on invocation B",
);
}
#[test]
fn invocation_after_401_falls_through_to_jsonl_when_available() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
let data_a = resolve_usage(
Some(&cache),
Some(&lock),
&FakeTransport::ok(401, "", None),
&ok_creds,
&jsonl_ok,
&now_fn(),
&config(),
)
.expect("A falls through to JSONL with jsonl_ok");
assert_jsonl_matches_ok_fixture(&data_a);
let transport_b = FakeTransport::ok(200, SAMPLE_BODY, None);
let data_b = resolve_usage(
Some(&cache),
Some(&lock),
&transport_b,
&ok_creds,
&jsonl_ok,
&now_fn(),
&config(),
)
.expect("B returns JSONL on lock-active path");
assert_jsonl_matches_ok_fixture(&data_b);
assert_eq!(transport_b.calls.get(), 0);
}
#[test]
fn active_unauthorized_lock_rejects_stale_cached_data() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
lock.write(&Lock {
blocked_until: Timestamp::now().as_second() + 30,
error: Some("Unauthorized".into()),
})
.unwrap();
let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
let err = resolve_usage(
Some(&cache),
Some(&lock),
&transport,
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::Unauthorized));
assert_eq!(transport.calls.get(), 0);
}
#[test]
fn endpoint_429_writes_lock_with_retry_after_backoff() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
let lock = LockStore::new(tmp.path().to_path_buf());
let _ = resolve_usage(
Some(&cache),
Some(&lock),
&FakeTransport::ok(429, "", Some("120")),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
);
let persisted = lock.read().unwrap().expect("lock must be written");
let expected = Timestamp::now().as_second() + 120;
assert!(
(persisted.blocked_until - expected).abs() < 5,
"blocked_until={}, expected near {}",
persisted.blocked_until,
expected,
);
assert_eq!(persisted.error.as_deref(), Some("RateLimited"));
}
#[test]
fn endpoint_timeout_writes_lock_with_error_ttl() {
let tmp = TempDir::new().unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
let _ = resolve_usage(
None,
Some(&lock),
&FakeTransport::err(io::ErrorKind::TimedOut),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
);
let persisted = lock.read().unwrap().expect("lock must be written");
let expected = Timestamp::now().as_second() + DEFAULT_ERROR_TTL.as_secs() as i64;
assert!(
(persisted.blocked_until - expected).abs() < 5,
"blocked_until={}, expected near {}",
persisted.blocked_until,
expected,
);
assert_eq!(persisted.error.as_deref(), Some("Timeout"));
}
#[test]
fn lock_written_on_429_blocks_next_process_from_hitting_endpoint() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
let lock = LockStore::new(tmp.path().to_path_buf());
let transport_a = FakeTransport::ok(429, "", Some("120"));
let _ = resolve_usage(
Some(&cache),
Some(&lock),
&transport_a,
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
);
let transport_b = FakeTransport::ok(200, SAMPLE_BODY, None);
let result_b = resolve_usage(
Some(&cache),
Some(&lock),
&transport_b,
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
);
assert!(matches!(result_b, Err(UsageError::RateLimited { .. })));
assert_eq!(
transport_b.calls.get(),
0,
"process B must not hit endpoint"
);
}
#[test]
fn endpoint_401_writes_lock_so_peers_skip_the_stale_token() {
let tmp = TempDir::new().unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
let _ = resolve_usage(
None,
Some(&lock),
&FakeTransport::ok(401, "", None),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
);
let persisted = lock.read().unwrap().expect("lock must be written");
assert_eq!(persisted.error.as_deref(), Some("Unauthorized"));
}
#[test]
fn endpoint_429_with_stale_cache_serves_stale() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let data = resolve_usage(
Some(&cache),
None,
&FakeTransport::ok(429, "", Some("120")),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("ok");
let UsageData::Endpoint(endpoint) = &data else {
panic!("expected endpoint variant, got {data:?}");
};
assert_eq!(endpoint.five_hour.unwrap().utilization.value(), 42.0);
}
#[test]
fn endpoint_429_with_empty_jsonl_surfaces_ratelimited() {
let err = resolve_usage(
None,
None,
&FakeTransport::ok(429, "", None),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::RateLimited { .. }));
}
#[test]
fn endpoint_429_falls_through_to_jsonl_when_available() {
let transport = FakeTransport::ok(429, "", None);
let data = resolve_usage(
None,
None,
&transport,
&ok_creds,
&jsonl_ok,
&now_fn(),
&config(),
)
.expect("ok");
assert_jsonl_matches_ok_fixture(&data);
assert_eq!(transport.calls.get(), 1);
}
#[test]
fn endpoint_timeout_with_stale_cache_serves_stale() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let data = resolve_usage(
Some(&cache),
None,
&FakeTransport::err(io::ErrorKind::TimedOut),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("ok");
assert!(matches!(data, UsageData::Endpoint(_)));
}
#[test]
fn endpoint_timeout_without_stale_falls_through_to_jsonl() {
let transport = FakeTransport::err(io::ErrorKind::TimedOut);
let data = resolve_usage(
None,
None,
&transport,
&ok_creds,
&jsonl_ok,
&now_fn(),
&config(),
)
.expect("ok");
assert_jsonl_matches_ok_fixture(&data);
assert_eq!(
transport.calls.get(),
1,
"endpoint must be attempted before JSONL fallback",
);
}
#[test]
fn endpoint_timeout_without_stale_or_jsonl_surfaces_original_error() {
let err = resolve_usage(
None,
None,
&FakeTransport::err(io::ErrorKind::TimedOut),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::Timeout));
}
#[test]
fn endpoint_network_error_falls_through_same_as_timeout() {
let err = resolve_usage(
None,
None,
&FakeTransport::err(io::ErrorKind::ConnectionRefused),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::NetworkError));
}
#[test]
fn endpoint_malformed_response_falls_through_to_jsonl() {
let err = resolve_usage(
None,
None,
&FakeTransport::ok(200, "{ not valid", None),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::ParseError));
}
#[test]
fn cascade_tolerates_missing_cache_and_lock_stores() {
let data = resolve_usage(
None,
None,
&FakeTransport::ok(200, SAMPLE_BODY, None),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("ok");
assert!(matches!(data, UsageData::Endpoint(_)));
}
#[test]
fn expired_lock_does_not_gate_fetch() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&stale_cache_entry(ChronoDuration::from_mins(10)))
.unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
lock.write(&Lock {
blocked_until: Timestamp::now().as_second() - 60,
error: None,
})
.unwrap();
let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
let _ = resolve_usage(
Some(&cache),
Some(&lock),
&transport,
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("ok");
assert_eq!(
transport.calls.get(),
1,
"expired lock must not block fetch"
);
}
#[test]
fn active_lock_with_no_cached_data_does_not_hit_endpoint() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
let lock = LockStore::new(tmp.path().to_path_buf());
lock.write(&Lock {
blocked_until: Timestamp::now().as_second() + 60,
error: Some("RateLimited".into()),
})
.unwrap();
let cred_calls = Cell::new(0u32);
let credentials = || {
cred_calls.set(cred_calls.get() + 1);
ok_creds()
};
let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
let err = resolve_usage(
Some(&cache),
Some(&lock),
&transport,
&credentials,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::RateLimited { .. }));
assert_eq!(cred_calls.get(), 0, "must not resolve credentials");
assert_eq!(transport.calls.get(), 0, "must not hit endpoint");
}
#[test]
fn active_lock_falls_through_to_jsonl_when_available() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
let lock = LockStore::new(tmp.path().to_path_buf());
lock.write(&Lock {
blocked_until: Timestamp::now().as_second() + 60,
error: Some("RateLimited".into()),
})
.unwrap();
let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
let data = resolve_usage(
Some(&cache),
Some(&lock),
&transport,
&ok_creds,
&jsonl_ok,
&now_fn(),
&config(),
)
.expect("ok");
assert_jsonl_matches_ok_fixture(&data);
assert_eq!(
transport.calls.get(),
0,
"active lock must still gate the endpoint even with JSONL data"
);
}
#[test]
fn active_lock_serves_cached_error_without_hitting_endpoint() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&CachedUsage::with_error("Unauthorized"))
.unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
lock.write(&Lock {
blocked_until: Timestamp::now().as_second() + 60,
error: Some("RateLimited".into()),
})
.unwrap();
let transport = FakeTransport::ok(200, "", None);
let err = resolve_usage(
Some(&cache),
Some(&lock),
&transport,
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(matches!(err, UsageError::Unauthorized));
assert_eq!(transport.calls.get(), 0);
}
#[test]
fn active_lock_with_cached_error_falls_through_to_jsonl_when_available() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&CachedUsage::with_error("Unauthorized"))
.unwrap();
let lock = LockStore::new(tmp.path().to_path_buf());
lock.write(&Lock {
blocked_until: Timestamp::now().as_second() + 60,
error: Some("RateLimited".into()),
})
.unwrap();
let transport = FakeTransport::ok(200, "", None);
let data = resolve_usage(
Some(&cache),
Some(&lock),
&transport,
&ok_creds,
&jsonl_ok,
&now_fn(),
&config(),
)
.expect("ok");
assert_jsonl_matches_ok_fixture(&data);
assert_eq!(transport.calls.get(), 0);
}
#[test]
fn credential_failure_other_than_missing_preserves_variant_tag() {
let creds_err: Arc<Result<Credentials, CredentialError>> =
Arc::new(Err(CredentialError::MissingField {
path: std::path::PathBuf::from("/x"),
}));
let credentials = || creds_err.clone();
let err = resolve_usage(
None,
None,
&FakeTransport::err(io::ErrorKind::TimedOut),
&credentials,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert!(
matches!(
err,
UsageError::Credentials(CredentialError::MissingField { .. })
),
"expected Credentials(MissingField), got {err:?}",
);
assert_eq!(err.code(), "MissingField", "variant tag must round-trip");
}
#[test]
fn subprocess_failed_cred_preserves_subprocess_tag() {
let creds_err: Arc<Result<Credentials, CredentialError>> = Arc::new(Err(
CredentialError::SubprocessFailed(io::Error::new(io::ErrorKind::PermissionDenied, "x")),
));
let credentials = || creds_err.clone();
let err = resolve_usage(
None,
None,
&FakeTransport::err(io::ErrorKind::TimedOut),
&credentials,
&jsonl_empty,
&now_fn(),
&config(),
)
.unwrap_err();
assert_eq!(err.code(), "SubprocessFailed");
}
#[test]
fn credential_variant_falls_through_to_jsonl_when_available() {
let creds_err: Arc<Result<Credentials, CredentialError>> = Arc::new(Err(
CredentialError::SubprocessFailed(io::Error::new(io::ErrorKind::PermissionDenied, "x")),
));
let credentials = || creds_err.clone();
let data = resolve_usage(
None,
None,
&FakeTransport::err(io::ErrorKind::TimedOut),
&credentials,
&jsonl_ok,
&now_fn(),
&config(),
)
.expect("ok");
assert_jsonl_matches_ok_fixture(&data);
}
#[test]
fn cache_write_failure_does_not_block_returned_data() {
let tmp = TempDir::new().unwrap();
let blocking_file = tmp.path().join("blocked");
std::fs::write(&blocking_file, "x").unwrap();
let cache = CacheStore::new(blocking_file.join("nested"));
let data = resolve_usage(
Some(&cache),
None,
&FakeTransport::ok(200, SAMPLE_BODY, None),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("ok");
assert!(matches!(data, UsageData::Endpoint(_)));
}
#[test]
fn fresh_cache_is_source_endpoint_not_jsonl() {
let tmp = TempDir::new().unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
cache
.write(&CachedUsage::with_data(sample_response()))
.unwrap();
let data = resolve_usage(
Some(&cache),
None,
&FakeTransport::ok(200, "", None),
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("ok");
assert!(matches!(data, UsageData::Endpoint(_)));
}
#[test]
fn clock_skew_future_cached_at_treats_entry_as_stale() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("usage.json");
let mut entry = CachedUsage::with_data(sample_response());
entry.cached_at = Timestamp::now() + ChronoDuration::from_hours(1);
std::fs::write(&path, serde_json::to_string(&entry).unwrap()).unwrap();
let cache = CacheStore::new(tmp.path().to_path_buf());
let transport = FakeTransport::ok(200, SAMPLE_BODY, None);
let _ = resolve_usage(
Some(&cache),
None,
&transport,
&ok_creds,
&jsonl_empty,
&now_fn(),
&config(),
)
.expect("ok");
assert_eq!(transport.calls.get(), 1);
}
fn make_io_error(kind: io::ErrorKind) -> CacheError {
CacheError::Io {
path: std::path::PathBuf::from("/test/path"),
cause: io::Error::new(kind, "test"),
}
}
fn make_persist_error(kind: io::ErrorKind) -> CacheError {
CacheError::Persist {
path: std::path::PathBuf::from("/test/path"),
cause: io::Error::new(kind, "test"),
}
}
#[test]
fn classify_persist_error_routes_io_failure_to_error() {
let (class, msg) = classify_persist_error("cache", &make_io_error(io::ErrorKind::NotFound));
assert_eq!(class, PersistLogClass::Error);
assert!(
msg.contains("cascade: cache write failed:"),
"expected loud-signal prefix, got {msg:?}"
);
}
#[test]
fn classify_persist_error_routes_lock_kind_into_message() {
let (class, msg) =
classify_persist_error("lock", &make_persist_error(io::ErrorKind::OutOfMemory));
assert_eq!(class, PersistLogClass::Error);
assert!(
msg.contains("cascade: lock write failed:"),
"kind label must thread through, got {msg:?}"
);
}
#[cfg(unix)]
#[test]
fn classify_persist_error_routes_permission_denied_to_error_on_unix() {
let (class, msg) = classify_persist_error(
"cache",
&make_persist_error(io::ErrorKind::PermissionDenied),
);
assert_eq!(class, PersistLogClass::Error);
assert!(msg.contains("cascade: cache write failed:"));
}
#[cfg(windows)]
#[test]
fn classify_persist_error_routes_persist_permission_denied_to_debug_on_windows() {
let (class, msg) = classify_persist_error(
"cache",
&make_persist_error(io::ErrorKind::PermissionDenied),
);
assert_eq!(class, PersistLogClass::Debug);
assert!(
msg.contains("race-loser") && msg.contains("Windows MoveFileEx"),
"expected race-loser framing, got {msg:?}"
);
}
#[cfg(windows)]
#[test]
fn classify_persist_error_routes_io_permission_denied_to_error_on_windows() {
let (class, _msg) =
classify_persist_error("cache", &make_io_error(io::ErrorKind::PermissionDenied));
assert_eq!(class, PersistLogClass::Error);
}
#[test]
fn route_persist_error_dispatches_debug_class_to_debug_closure_only() {
let mut debug_calls = 0;
let mut error_calls = 0;
route_persist_error(
PersistLogClass::Debug,
"msg",
|_| debug_calls += 1,
|_| error_calls += 1,
);
assert_eq!((debug_calls, error_calls), (1, 0));
}
#[test]
fn route_persist_error_dispatches_error_class_to_error_closure_only() {
let mut debug_calls = 0;
let mut error_calls = 0;
route_persist_error(
PersistLogClass::Error,
"msg",
|_| debug_calls += 1,
|_| error_calls += 1,
);
assert_eq!((debug_calls, error_calls), (0, 1));
}
#[test]
fn route_persist_error_passes_msg_through_unchanged() {
let mut received: Option<String> = None;
route_persist_error(
PersistLogClass::Error,
"cascade: cache write failed: disk full",
|_| {},
|s| received = Some(s.to_string()),
);
assert_eq!(
received.as_deref(),
Some("cascade: cache write failed: disk full")
);
}