use std::sync::atomic::Ordering;
use std::time::{Duration, SystemTime};
use crate::profile::{
AppConfig, AppState, ClaudeCredentials, OAuthToken, Profile, app_state_mtime, claude_dir,
clauth_dir, save_app_state, save_profile,
};
use crate::testutil::{HomeSandbox, blank_profile, set_mtime};
use crate::usage::{ProfileActivity, clear_activity, mark_activity, now_ms};
use super::Daemon;
fn stage_switch(d: &Daemon, target: &str) {
d.pending_switch
.lock()
.expect("pending_switch")
.insert(target.into());
}
fn queued_targets(d: &Daemon) -> Vec<String> {
let mut v: Vec<String> = d
.pending_switch
.lock()
.expect("pending_switch")
.iter()
.cloned()
.collect();
v.sort();
v
}
fn future_expiry() -> i64 {
crate::usage::now_ms() as i64 + 3_600_000
}
fn oauth_creds(access: &str) -> ClaudeCredentials {
ClaudeCredentials {
claude_ai_oauth: Some(OAuthToken {
access_token: access.to_string(),
refresh_token: Some(format!("rt-{access}")),
expires_at: Some(future_expiry()),
scopes: None,
subscription_type: None,
}),
}
}
fn profile_with_creds(name: &str, access: &str) -> Profile {
let mut p = blank_profile(name);
p.credentials = Some(oauth_creds(access));
p
}
fn persist(profiles: Vec<Profile>, active: Option<&str>, refresh_interval_ms: u64) -> AppConfig {
let mut state = AppState {
active_profile: active.map(Into::into),
profiles: profiles.iter().map(|p| p.name.clone()).collect(),
refresh_interval_ms,
..AppState::default()
};
state.fallback_chain.clear();
for p in &profiles {
save_profile(p).expect("persist profile");
}
save_app_state(&state).expect("persist app state");
AppConfig { state, profiles }
}
fn daemon_for(config: AppConfig) -> Daemon {
let status_path = clauth_dir().expect("clauth dir").join("status.json");
Daemon::new(config, status_path)
}
fn link_active_clean(name: &str) {
crate::claude::force_link_profile_credentials(name).expect("link active credentials");
}
fn diverge_active(diff_access: &str) {
let dir = claude_dir().expect("claude dir");
std::fs::create_dir_all(&dir).expect("mkdir ~/.claude");
let live = dir.join(".credentials.json");
let bytes = serde_json::to_vec(&oauth_creds(diff_access)).expect("serialize live");
std::fs::write(&live, bytes).expect("write live credentials");
}
fn active_of(d: &Daemon) -> Option<String> {
d.config
.lock()
.expect("config")
.state
.active_profile
.as_deref()
.map(str::to_string)
}
#[test]
fn tick_with_empty_queues_writes_status_and_leaves_active_unchanged() {
let _home = HomeSandbox::new();
let config = persist(
vec![profile_with_creds("alpha", "at-alpha")],
Some("alpha"),
90_000,
);
link_active_clean("alpha");
let mut daemon = daemon_for(config);
let status_path = daemon.status_path.clone();
daemon.tick();
assert!(
status_path.exists(),
"tick must (re)write status.json each iteration"
);
assert_eq!(
active_of(&daemon).as_deref(),
Some("alpha"),
"no queued switch → active profile is unchanged by a tick"
);
}
#[test]
fn drain_pending_switch_executes_when_idle_and_clean() {
let _home = HomeSandbox::new();
let config = persist(
vec![
profile_with_creds("alpha", "at-alpha"),
profile_with_creds("beta", "at-beta"),
],
Some("alpha"),
90_000,
);
link_active_clean("alpha");
let mut daemon = daemon_for(config);
stage_switch(&daemon, "beta");
daemon.drain_pending_switch();
assert_eq!(
active_of(&daemon).as_deref(),
Some("beta"),
"an idle, clean, installable target must be switched to"
);
}
#[test]
fn drain_pending_switch_skips_on_active_divergence() {
let _home = HomeSandbox::new();
let config = persist(
vec![
profile_with_creds("alpha", "at-alpha"),
profile_with_creds("beta", "at-beta"),
],
Some("alpha"),
90_000,
);
diverge_active("at-alpha-ROTATED");
let mut daemon = daemon_for(config);
stage_switch(&daemon, "beta");
daemon.drain_pending_switch();
assert_eq!(
active_of(&daemon).as_deref(),
Some("alpha"),
"a diverged, unsaved active must block the switch (no daemon prompt)"
);
assert_eq!(
queued_targets(&daemon),
vec!["beta".to_string()],
"a switch blocked by divergence is re-queued for retry, not silently dropped"
);
}
#[test]
fn drain_pending_switch_drops_a_vanished_target() {
let _home = HomeSandbox::new();
let config = persist(
vec![
profile_with_creds("alpha", "at-alpha"),
profile_with_creds("beta", "at-beta"),
],
Some("alpha"),
90_000,
);
link_active_clean("alpha");
let mut daemon = daemon_for(config);
stage_switch(&daemon, "ghost");
daemon.drain_pending_switch();
assert_eq!(
active_of(&daemon).as_deref(),
Some("alpha"),
"the active profile must be untouched"
);
assert!(
queued_targets(&daemon).is_empty(),
"a vanished target is dropped, not re-queued — retrying can't resurrect it"
);
assert!(
crate::profile::claude_dir()
.unwrap()
.join(".credentials.json")
.exists(),
"the live credentials file survives"
);
let stored = crate::profile::profile_dir("alpha")
.unwrap()
.join("credentials.json");
assert!(stored.exists(), "alpha's stored credentials survive");
}
#[test]
fn busy_target_requeued_not_dropped() {
let _home = HomeSandbox::new();
let config = persist(
vec![
profile_with_creds("alpha", "at-alpha"),
profile_with_creds("beta", "at-beta"),
],
Some("alpha"),
90_000,
);
link_active_clean("alpha");
let mut daemon = daemon_for(config);
stage_switch(&daemon, "beta");
mark_activity(&daemon.activity, "beta", ProfileActivity::Fetching);
daemon.drain_pending_switch();
assert_eq!(
active_of(&daemon).as_deref(),
Some("alpha"),
"a busy target cannot switch this tick"
);
assert_eq!(
queued_targets(&daemon),
vec!["beta".to_string()],
"the busy switch is re-queued, not dropped after one attempt"
);
clear_activity(&daemon.activity, "beta");
daemon.drain_pending_switch();
assert_eq!(
active_of(&daemon).as_deref(),
Some("beta"),
"once idle, the re-queued switch executes"
);
}
#[test]
fn reload_if_changed_fires_on_external_mtime_change() {
let _home = HomeSandbox::new();
let config = persist(
vec![profile_with_creds("alpha", "at-alpha")],
Some("alpha"),
90_000,
);
let mut daemon = daemon_for(config);
let external = AppState {
active_profile: Some("alpha".into()),
profiles: vec!["alpha".into()],
refresh_interval_ms: 45_000,
..AppState::default()
};
save_app_state(&external).expect("external app-state write");
let state_path = clauth_dir().unwrap().join("profiles.toml");
set_mtime(&state_path, SystemTime::now() + Duration::from_secs(5));
daemon.reload_if_changed();
assert_eq!(
daemon.refresh_interval.load(Ordering::Relaxed),
45_000,
"an external state change with a newer mtime must be reloaded"
);
assert_eq!(
app_state_mtime(),
daemon.last_state_mtime,
"reload adopts the on-disk mtime so it won't reload its own read again"
);
}
#[test]
fn rmw_switch_adopts_own_write_mtime_then_reloads_external() {
let _home = HomeSandbox::new();
let config = persist(
vec![
profile_with_creds("alpha", "at-alpha"),
profile_with_creds("beta", "at-beta"),
],
Some("alpha"),
90_000,
);
link_active_clean("alpha");
let mut daemon = daemon_for(config);
stage_switch(&daemon, "beta");
daemon.drain_pending_switch();
assert_eq!(
daemon.last_state_mtime,
app_state_mtime(),
"daemon adopts its own switch write's mtime"
);
daemon.reload_if_changed();
assert_eq!(
daemon.refresh_interval.load(Ordering::Relaxed),
90_000,
"no external change → the daemon does not reload its own write"
);
let external = AppState {
active_profile: Some("beta".into()),
profiles: vec!["alpha".into(), "beta".into()],
refresh_interval_ms: 30_000,
..AppState::default()
};
save_app_state(&external).expect("external write");
let state_path = clauth_dir().unwrap().join("profiles.toml");
set_mtime(&state_path, SystemTime::now() + Duration::from_secs(5));
daemon.reload_if_changed();
assert_eq!(
daemon.refresh_interval.load(Ordering::Relaxed),
30_000,
"a later external write is still reloaded (no over-adoption)"
);
}
#[test]
fn switch_backoff_ms_grows_exponentially_and_caps() {
use super::switch_backoff_ms;
assert_eq!(switch_backoff_ms(0), 0);
assert_eq!(switch_backoff_ms(1), 0);
assert_eq!(switch_backoff_ms(2), 0, "first attempts retry immediately");
assert_eq!(switch_backoff_ms(3), 2_000);
assert_eq!(switch_backoff_ms(4), 4_000);
assert_eq!(switch_backoff_ms(5), 8_000);
assert_eq!(switch_backoff_ms(50), 60_000, "capped at the ceiling");
}
#[test]
fn switch_failure_backoff_dedups_log_over_many_ticks() {
let _home = HomeSandbox::new();
let config = persist(
vec![
profile_with_creds("alpha", "at-alpha"),
profile_with_creds("beta", "at-beta"),
],
Some("alpha"),
90_000,
);
link_active_clean("alpha");
let mut daemon = daemon_for(config);
stage_switch(&daemon, "beta");
mark_activity(&daemon.activity, "beta", ProfileActivity::Fetching);
for _ in 0..30 {
daemon.drain_pending_switch();
}
assert!(
daemon.switch_failure_logs <= 2,
"a stuck switch dedups its log — got {} emissions over 30 ticks",
daemon.switch_failure_logs
);
assert_eq!(
queued_targets(&daemon),
vec!["beta".to_string()],
"the stuck switch stays queued (re-queued within its TTL), not dropped"
);
assert!(
daemon
.switch_backoff
.as_ref()
.is_some_and(|b| b.target == "beta" && b.attempts >= 3),
"backoff engaged for the repeatedly-failing target"
);
}
#[cfg(unix)]
#[test]
fn clauth_tree_migrated_to_0700_on_boot() {
use std::os::unix::fs::PermissionsExt;
let _home = HomeSandbox::new();
let clauth = clauth_dir().unwrap();
let profiles = clauth.join("profiles");
std::fs::create_dir_all(&profiles).unwrap();
std::fs::set_permissions(&clauth, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&profiles, std::fs::Permissions::from_mode(0o755)).unwrap();
let log = clauth.join("daemon.log");
std::fs::write(&log, b"boot\n").unwrap();
std::fs::set_permissions(&log, std::fs::Permissions::from_mode(0o644)).unwrap();
super::migrate_clauth_perms_700(&clauth);
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&clauth), 0o700, "~/.clauth tightened to 0o700");
assert_eq!(
mode(&profiles),
0o700,
"~/.clauth/profiles tightened to 0o700"
);
assert_eq!(mode(&log), 0o600, "daemon.log tightened to 0o600");
}
#[test]
fn backoff_gate_gives_up_when_the_retry_window_closes() {
let _home = HomeSandbox::new();
let config = persist(
vec![
profile_with_creds("home", "at-home"),
profile_with_creds("beta", "at-beta"),
],
Some("home"),
90_000,
);
link_active_clean("home");
let mut daemon = daemon_for(config);
let now = now_ms();
daemon.switch_backoff = Some(super::SwitchBackoff {
target: "beta".into(),
attempts: 9,
not_before: now + 60_000,
reason: "target is mid-fetch".into(),
retry_until: now.saturating_sub(1),
});
stage_switch(&daemon, "beta");
daemon.drain_pending_switch();
assert!(
daemon.switch_backoff.is_none(),
"a closed retry window clears the backoff state"
);
assert!(
queued_targets(&daemon).is_empty(),
"the expired target is dropped, not requeued"
);
assert_eq!(
active_of(&daemon).as_deref(),
Some("home"),
"no switch is attempted past the window"
);
}