use std::collections::HashMap;
use std::sync::Arc;
use crate::lockorder::RankedMutex;
use crate::profile::DEFAULT_REFRESH_INTERVAL_MS as REFRESH_INTERVAL_MS;
use super::{
ActivityStore, EpochMs, LastFetchedAt, ProfileActivity, TokenEntry, clear_activity,
mark_activity, partition_due, window_lapsed,
};
fn token(name: &str) -> TokenEntry {
TokenEntry {
name: name.to_string(),
access_token: "access".to_string(),
refresh_token: Some("refresh".to_string()),
auto_start: false,
access_expires_at: None,
}
}
#[test]
fn partition_due_uses_fixed_interval() {
let last_fetched: LastFetchedAt = Arc::new(RankedMutex::new(HashMap::new()));
let activity: ActivityStore = Arc::new(RankedMutex::new(HashMap::new()));
let snapshot = vec![token("a")];
let base = 1_700_000_000_000u64;
let (due, next) = partition_due(
&snapshot,
base,
&last_fetched,
&activity,
REFRESH_INTERVAL_MS,
);
assert_eq!(due.len(), 1, "a never-fetched profile is due");
assert_eq!(next.get("a").copied(), Some(REFRESH_INTERVAL_MS));
last_fetched
.lock()
.unwrap()
.insert("a".to_string(), EpochMs::from_millis(base));
let (due, next) = partition_due(
&snapshot,
base + 1,
&last_fetched,
&activity,
REFRESH_INTERVAL_MS,
);
assert!(due.is_empty(), "not due one ms after a fetch");
assert_eq!(next.get("a").copied(), Some(base + REFRESH_INTERVAL_MS));
let (due, _) = partition_due(
&snapshot,
base + REFRESH_INTERVAL_MS,
&last_fetched,
&activity,
REFRESH_INTERVAL_MS,
);
assert_eq!(due.len(), 1, "due once the fixed interval has elapsed");
}
#[test]
fn partition_due_excludes_refreshing() {
let last_fetched: LastFetchedAt = Arc::new(RankedMutex::new(HashMap::new()));
let activity: ActivityStore = Arc::new(RankedMutex::new(HashMap::new()));
let snapshot = vec![token("a")];
mark_activity(&activity, "a", ProfileActivity::Refreshing);
let (due, next) = partition_due(
&snapshot,
REFRESH_INTERVAL_MS + 1,
&last_fetched,
&activity,
REFRESH_INTERVAL_MS,
);
assert!(due.is_empty(), "refreshing profiles are excluded from due");
assert!(
next.contains_key("a"),
"countdown still publishes for excluded profiles"
);
}
#[test]
fn activity_cleared_on_worker_panic() {
let activity: ActivityStore = Arc::new(RankedMutex::new(HashMap::new()));
let name = "test-profile";
mark_activity(&activity, name, ProfileActivity::Fetching);
assert!(
!activity.lock().unwrap().is_empty(),
"slot must be set after mark_activity"
);
let h = std::thread::spawn(|| -> () { panic!("simulated worker panic") });
match h.join() {
Ok(_) => panic!("expected panic in worker"),
Err(_) => clear_activity(&activity, name),
}
assert!(
activity.lock().unwrap().is_empty(),
"activity slot must be cleared after worker panic"
);
}
#[test]
fn cached_fallback_does_not_clobber_store() {
use super::{FetchOutcome, FetchStatus, StatusStore, apply_outcome};
use crate::usage::{UsageInfo, UsageWindow};
let store: super::UsageStore = Arc::new(RankedMutex::new(HashMap::new()));
let status: StatusStore = Arc::new(RankedMutex::new(HashMap::new()));
let last_fetched: LastFetchedAt = Arc::new(RankedMutex::new(HashMap::new()));
let live = UsageInfo {
five_hour: Some(UsageWindow {
utilization: 1.0,
resets_at: Some("2999-01-01T00:00:00+00:00".to_string()),
}),
..Default::default()
};
store.lock().unwrap().insert("a".to_string(), live);
let stale_windowless = UsageInfo::default();
apply_outcome(
FetchOutcome {
name: "a".to_string(),
info: Some(stale_windowless.clone()),
status: FetchStatus::RateLimited,
rotated: None,
from_fetch: false,
retry_after: None,
},
&store,
&status,
&last_fetched,
REFRESH_INTERVAL_MS,
);
assert!(
store.lock().unwrap().get("a").unwrap().five_hour.is_some(),
"a cache fallback must not overwrite a newer store entry"
);
assert_eq!(
status.lock().unwrap().get("a").copied(),
Some(FetchStatus::RateLimited),
"the RateLimited status still surfaces"
);
apply_outcome(
FetchOutcome {
name: "b".to_string(),
info: Some(stale_windowless),
status: FetchStatus::Cached,
rotated: None,
from_fetch: false,
retry_after: None,
},
&store,
&status,
&last_fetched,
REFRESH_INTERVAL_MS,
);
assert!(
store.lock().unwrap().contains_key("b"),
"a cache fallback still cold-fills an absent entry"
);
}
#[test]
fn mark_window_open_synthesizes_only_when_not_live() {
use super::mark_window_open;
use crate::usage::{UsageInfo, UsageWindow, iso_to_epoch_secs};
let store: super::UsageStore = Arc::new(RankedMutex::new(HashMap::new()));
let now = 1_780_000_000i64;
mark_window_open(&store, "a", now);
let resets = store.lock().unwrap()["a"]
.five_hour
.as_ref()
.and_then(|w| w.resets_at.as_deref())
.and_then(iso_to_epoch_secs);
assert_eq!(
resets,
Some(now + 5 * 3600),
"synthetic window opens at +5h"
);
let live_resets = "2999-01-01T00:00:00+00:00";
store.lock().unwrap().insert(
"b".to_string(),
UsageInfo {
five_hour: Some(UsageWindow {
utilization: 42.0,
resets_at: Some(live_resets.to_string()),
}),
..Default::default()
},
);
mark_window_open(&store, "b", now);
let kept = store.lock().unwrap()["b"].five_hour.clone().unwrap();
assert_eq!(kept.resets_at.as_deref(), Some(live_resets));
assert_eq!(kept.utilization, 42.0);
store.lock().unwrap().insert(
"c".to_string(),
UsageInfo {
five_hour: Some(UsageWindow {
utilization: 88.0,
resets_at: Some("2020-01-01T00:00:00+00:00".to_string()),
}),
..Default::default()
},
);
mark_window_open(&store, "c", now);
let replaced = store.lock().unwrap()["c"].five_hour.clone().unwrap();
assert_eq!(
replaced.resets_at.as_deref().and_then(iso_to_epoch_secs),
Some(now + 5 * 3600)
);
assert_eq!(replaced.utilization, 0.0, "fresh window starts at zero");
}
#[test]
fn window_lapsed_only_fires_on_a_fetched_expired_window() {
use super::UsageStore;
use crate::usage::{UsageInfo, UsageWindow};
let store: UsageStore = Arc::new(RankedMutex::new(HashMap::new()));
let now = 1_780_000_000i64;
assert!(
!window_lapsed(&store, "a", now),
"an absent entry must not kick — fetch first, kick next tick"
);
store
.lock()
.unwrap()
.insert("a".to_string(), UsageInfo::default());
assert!(
window_lapsed(&store, "a", now),
"a fetched entry with no live window is lapsed"
);
store.lock().unwrap().insert(
"a".to_string(),
UsageInfo {
five_hour: Some(UsageWindow {
utilization: 0.0,
resets_at: Some("2020-01-01T00:00:00+00:00".to_string()),
}),
..Default::default()
},
);
assert!(
window_lapsed(&store, "a", now),
"a past resets_at is lapsed"
);
store.lock().unwrap().insert(
"a".to_string(),
UsageInfo {
five_hour: Some(UsageWindow {
utilization: 0.0,
resets_at: Some("2999-01-01T00:00:00+00:00".to_string()),
}),
..Default::default()
},
);
assert!(
!window_lapsed(&store, "a", now),
"a future resets_at is a live window — no kick"
);
}
#[test]
fn retry_after_defers_next_fetch_slot() {
use std::time::Duration;
use super::{
FetchOutcome, FetchStatus, MAX_RETRY_AFTER_MS, RATE_LIMIT_MIN_BACKOFF_MS, StatusStore,
apply_outcome, now_ms,
};
let store: super::UsageStore = Arc::new(RankedMutex::new(HashMap::new()));
let status: StatusStore = Arc::new(RankedMutex::new(HashMap::new()));
let last_fetched: LastFetchedAt = Arc::new(RankedMutex::new(HashMap::new()));
let outcome = |name: &str, retry_after: Option<Duration>| FetchOutcome {
name: name.to_string(),
info: None,
status: FetchStatus::RateLimited,
rotated: None,
from_fetch: false,
retry_after,
};
let stamp = |name: &str| {
last_fetched
.lock()
.unwrap()
.get(name)
.copied()
.expect("stamp present")
.as_millis()
};
let before = now_ms();
apply_outcome(
outcome("a", Some(Duration::from_secs(300))),
&store,
&status,
&last_fetched,
REFRESH_INTERVAL_MS,
);
let after = now_ms();
let extra = 300_000 - REFRESH_INTERVAL_MS;
let a = stamp("a");
assert!(
(before + extra..=after + extra).contains(&a),
"deferred stamp must sit retry_after - interval ahead of now"
);
let snapshot = vec![token("a")];
let activity: ActivityStore = Arc::new(RankedMutex::new(HashMap::new()));
let (due, next) = partition_due(
&snapshot,
a + REFRESH_INTERVAL_MS - 1,
&last_fetched,
&activity,
REFRESH_INTERVAL_MS,
);
assert!(due.is_empty(), "not due before the deferred slot");
assert_eq!(
next.get("a").copied(),
Some(a + REFRESH_INTERVAL_MS),
"countdown publishes the deferred slot"
);
let (due, _) = partition_due(
&snapshot,
a + REFRESH_INTERVAL_MS,
&last_fetched,
&activity,
REFRESH_INTERVAL_MS,
);
assert_eq!(due.len(), 1, "due once the deferred slot arrives");
let before = now_ms();
apply_outcome(
outcome("b", None),
&store,
&status,
&last_fetched,
REFRESH_INTERVAL_MS,
);
let after = now_ms();
let floor = RATE_LIMIT_MIN_BACKOFF_MS;
assert!(
(before + floor..=after + floor).contains(&stamp("b")),
"a 429 with no retry-after defers a flat 10s past now"
);
let before = now_ms();
apply_outcome(
outcome("c", Some(Duration::from_secs(5))),
&store,
&status,
&last_fetched,
REFRESH_INTERVAL_MS,
);
let after = now_ms();
assert!((before..=after).contains(&stamp("c")));
let before = now_ms();
apply_outcome(
outcome("d", Some(Duration::from_secs(86_400))),
&store,
&status,
&last_fetched,
REFRESH_INTERVAL_MS,
);
let after = now_ms();
let capped = MAX_RETRY_AFTER_MS - REFRESH_INTERVAL_MS;
assert!(
(before + capped..=after + capped).contains(&stamp("d")),
"huge retry-after clamps to MAX_RETRY_AFTER_MS"
);
}
#[test]
fn try_seed_recent_cache_seeds_fresh_only() {
use std::time::{Duration, SystemTime};
use super::{FetchStatus, StatusStore, now_ms, try_seed_recent_cache};
use crate::profile::profile_subpath;
use crate::testutil::{HomeSandbox, set_mtime};
use crate::usage::{UsageInfo, write_disk_cache};
let _home = HomeSandbox::new();
let store: super::UsageStore = Arc::new(RankedMutex::new(HashMap::new()));
let status: StatusStore = Arc::new(RankedMutex::new(HashMap::new()));
let last_fetched: LastFetchedAt = Arc::new(RankedMutex::new(HashMap::new()));
write_disk_cache("fresh", &UsageInfo::default());
let fresh_path = profile_subpath("fresh", "usage_cache.json").expect("fresh cache path");
set_mtime(&fresh_path, SystemTime::now());
write_disk_cache("stale", &UsageInfo::default());
let stale_path = profile_subpath("stale", "usage_cache.json").expect("stale cache path");
set_mtime(
&stale_path,
SystemTime::now() - Duration::from_millis(REFRESH_INTERVAL_MS * 10),
);
let before = now_ms();
assert!(
try_seed_recent_cache(
&store,
&status,
&last_fetched,
"fresh",
before,
REFRESH_INTERVAL_MS
),
"a cache younger than one interval seeds without a fetch"
);
assert!(
!try_seed_recent_cache(
&store,
&status,
&last_fetched,
"stale",
before,
REFRESH_INTERVAL_MS
),
"a stale cache is left for the network fetch"
);
assert!(
!try_seed_recent_cache(
&store,
&status,
&last_fetched,
"missing",
before,
REFRESH_INTERVAL_MS
),
"a missing cache is left for the network fetch"
);
assert!(store.lock().unwrap().contains_key("fresh"));
assert!(!store.lock().unwrap().contains_key("stale"));
assert_eq!(
status.lock().unwrap().get("fresh").copied(),
Some(FetchStatus::Fresh),
"recent cache surfaces as Fresh, not a staleness warning"
);
let stamp = last_fetched.lock().unwrap().get("fresh").copied().unwrap();
assert!(
(before.saturating_sub(REFRESH_INTERVAL_MS)..=before).contains(&stamp.as_millis()),
"last_fetched is stamped at the cache mtime, not now"
);
}
#[test]
fn deadline_spread_is_bounded_per_profile_and_per_cycle() {
use super::deadline_spread;
let interval = REFRESH_INTERVAL_MS;
let span = interval / 4;
let now = EpochMs::from_millis(1_700_000_000_000);
let sp = |name: &str, t: EpochMs| deadline_spread(name, t, interval).0;
assert!(sp("alpha", now) < span, "spread stays under interval/4");
assert_eq!(
sp("alpha", now),
sp("alpha", now),
"deterministic per (name, now)"
);
let names = ["a", "b", "c", "d", "e", "f", "g", "h"];
let by_name: Vec<u64> = names.iter().map(|n| sp(n, now)).collect();
assert!(
by_name.iter().any(|&s| s != by_name[0]),
"distinct profiles get distinct phase offsets"
);
let by_cycle: Vec<u64> = (0..8)
.map(|i| sp("alpha", EpochMs::from_millis(1_700_000_000_000 + i * 7_000)))
.collect();
assert!(
by_cycle.iter().any(|&s| s != by_cycle[0]),
"the jitter re-rolls as the cycle advances"
);
assert_eq!(deadline_spread("alpha", now, 0).0, 0);
}