clauth 0.4.0

Simple Claude Code account switcher and usage monitor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
//! Behaviour tests for `rotation_candidates` — the filter that decides which
//! profiles `refresh_all` will attempt to rotate.
//!
//! These tests never touch the network. They assert on the candidate list
//! returned by `rotation_candidates`, which is the only part of `refresh_all`
//! that `force` affects.

use super::*;
use crate::profile::{AppState, ClaudeCredentials, OAuthToken, Profile, profile_dir};
use crate::runtime::open_pid_file;
use crate::usage::{LastRotatedWindow, is_idle};

// Build a minimal AppConfig with one OAuth profile named `name`.
fn single_profile_config(name: &str, refresh_token: &str) -> AppConfig {
    use std::collections::BTreeMap;
    let profile = Profile {
        name: name.to_string(),
        base_url: None,
        api_key: None,
        auto_start: false,
        env: BTreeMap::new(),
        fallback_threshold: None,
        credentials: Some(ClaudeCredentials {
            claude_ai_oauth: Some(OAuthToken {
                access_token: "at".to_string(),
                refresh_token: Some(refresh_token.to_string()),
                expires_at: None,
                scopes: None,
                subscription_type: None,
            }),
        }),
        usage: None,
        fetch_status: None,
    };
    let mut config = AppConfig {
        state: AppState::default(),
        profiles: vec![profile],
    };
    config.state.profiles.push(name.to_string());
    config
}

#[test]
fn no_live_session_included_with_force_false() {
    // A unique name that has no sessions dir on disk — has_live_session returns false.
    let config = single_profile_config("test-oauth-no-session-force-false", "rt-abc");
    let candidates = rotation_candidates(&config, false);
    assert_eq!(candidates.len(), 1);
    assert_eq!(candidates[0].0, "test-oauth-no-session-force-false");
    assert_eq!(candidates[0].1, "rt-abc");
}

#[test]
fn no_live_session_included_with_force_true() {
    let config = single_profile_config("test-oauth-no-session-force-true", "rt-def");
    let candidates = rotation_candidates(&config, true);
    assert_eq!(candidates.len(), 1);
    assert_eq!(candidates[0].0, "test-oauth-no-session-force-true");
}

#[test]
fn live_session_excluded_when_force_false() {
    // Create a real locked pid file so has_live_session returns true.
    let name = "test-oauth-live-session-guard";
    let sessions = profile_dir(name).expect("profile_dir").join("sessions");
    std::fs::create_dir_all(&sessions).expect("create sessions dir");
    let pid_file = sessions.join("test-pid");
    let file = open_pid_file(&pid_file).expect("open pid file");
    file.lock().expect("lock pid file");

    let config = single_profile_config(name, "rt-ghi");
    let candidates = rotation_candidates(&config, false);
    assert!(
        candidates.is_empty(),
        "force=false should exclude a profile with a live session"
    );

    // Release lock — sessions dir and file left behind but harmless.
    drop(file);
}

#[test]
fn live_session_included_when_force_true() {
    // Same setup: locked pid file makes has_live_session return true.
    let name = "test-oauth-live-session-force";
    let sessions = profile_dir(name).expect("profile_dir").join("sessions");
    std::fs::create_dir_all(&sessions).expect("create sessions dir");
    let pid_file = sessions.join("test-pid");
    let file = open_pid_file(&pid_file).expect("open pid file");
    file.lock().expect("lock pid file");

    let config = single_profile_config(name, "rt-jkl");
    let candidates = rotation_candidates(&config, true);
    assert_eq!(
        candidates.len(),
        1,
        "force=true should include a profile with a live session"
    );
    assert_eq!(candidates[0].0, name);

    drop(file);
}

#[test]
fn force_true_bypasses_diverged_active_when_no_active_profile() {
    // When active_profile is None, active_link_diverged returns false, so even
    // force=false would not skip. This test verifies the force=true path includes
    // the profile — and that the old `skip_active = active_link_diverged(config)`
    // (which ignored force) is now `!force && active_link_diverged(config)`.
    // With no active profile, diverged is always false and the behavior matches
    // regardless of force; the meaningful contract change is that force=true
    // with a diverged active now also includes that profile (tested here with
    // no active so it compiles without filesystem side effects).
    let config = single_profile_config("test-oauth-force-diverged", "rt-xyz");
    // active_profile is None → active_link_diverged returns false → both
    // force values include the profile.
    let force_false = rotation_candidates(&config, false);
    let force_true = rotation_candidates(&config, true);
    assert_eq!(force_false.len(), 1);
    assert_eq!(force_true.len(), 1);
    assert_eq!(force_true[0].0, "test-oauth-force-diverged");
}

/// `rotate_one` must NOT stamp `Refreshing` when the profile has no refresh
/// token — the short-circuit `let Some(rt) = token else { return false }` runs
/// before any HTTP, so the activity slot should remain clean (Idle).
#[test]
fn rotate_one_no_stamp_when_no_refresh_token() {
    use std::collections::BTreeMap;
    use std::sync::mpsc;

    // Profile with OAuth block but no refresh token.
    let profile = Profile {
        name: "test-rotate-one-no-rt".to_string(),
        base_url: None,
        api_key: None,
        auto_start: false,
        env: BTreeMap::new(),
        fallback_threshold: None,
        credentials: Some(ClaudeCredentials {
            claude_ai_oauth: Some(OAuthToken {
                access_token: "at".to_string(),
                refresh_token: None,
                expires_at: None,
                scopes: None,
                subscription_type: None,
            }),
        }),
        usage: None,
        fetch_status: None,
    };
    let mut config = AppConfig {
        state: AppState::default(),
        profiles: vec![profile],
    };
    config
        .state
        .profiles
        .push("test-rotate-one-no-rt".to_string());

    let config = Arc::new(Mutex::new(config));
    let activity: ActivityStore = Arc::new(Mutex::new(std::collections::HashMap::new()));
    let (tx, _rx) = mpsc::channel();

    let result = rotate_one(&config, "test-rotate-one-no-rt", &activity, &tx);

    assert!(
        !result,
        "rotate_one should return false when no refresh token"
    );
    assert!(
        is_idle(&activity, "test-rotate-one-no-rt"),
        "activity slot must remain Idle when rotate_one short-circuits at no-token"
    );
}

#[test]
fn profile_without_refresh_token_excluded() {
    use std::collections::BTreeMap;
    let profile = Profile {
        name: "test-oauth-no-rt".to_string(),
        base_url: None,
        api_key: None,
        auto_start: false,
        env: BTreeMap::new(),
        fallback_threshold: None,
        // OAuth block exists but no refresh token.
        credentials: Some(ClaudeCredentials {
            claude_ai_oauth: Some(OAuthToken {
                access_token: "at".to_string(),
                refresh_token: None,
                expires_at: None,
                scopes: None,
                subscription_type: None,
            }),
        }),
        usage: None,
        fetch_status: None,
    };
    let mut config = AppConfig {
        state: AppState::default(),
        profiles: vec![profile],
    };
    config.state.profiles.push("test-oauth-no-rt".to_string());
    // No refresh token → excluded regardless of force.
    assert!(rotation_candidates(&config, false).is_empty());
    assert!(rotation_candidates(&config, true).is_empty());
}

/// Switch paths must call `rotate_one` only for the outgoing active and
/// incoming target, not every profile. This test pins the selection logic by
/// setting up three profiles with no refresh tokens (so `rotate_one` returns
/// false immediately, no HTTP), then verifying that a bystander profile's
/// activity slot is never stamped — i.e., it is never passed to `rotate_one`.
///
/// The observable proxy: only profiles passed to `rotate_one` can have their
/// activity slot touched (Refreshing then cleared). A profile never passed
/// always remains Idle.
#[test]
fn switch_rotate_targets_only_active_and_target() {
    use std::collections::BTreeMap;
    use std::sync::mpsc;

    fn make_profile(name: &str) -> Profile {
        Profile {
            name: name.to_string(),
            base_url: None,
            api_key: None,
            auto_start: false,
            env: BTreeMap::new(),
            fallback_threshold: None,
            credentials: Some(ClaudeCredentials {
                claude_ai_oauth: Some(OAuthToken {
                    access_token: "at".to_string(),
                    refresh_token: None,
                    expires_at: None,
                    scopes: None,
                    subscription_type: None,
                }),
            }),
            usage: None,
            fetch_status: None,
        }
    }

    let active_name = "switch-test-active";
    let target_name = "switch-test-target";
    let bystander_name = "switch-test-bystander";

    let config = AppConfig {
        state: AppState::default(),
        profiles: vec![
            make_profile(active_name),
            make_profile(target_name),
            make_profile(bystander_name),
        ],
    };
    let config = Arc::new(Mutex::new(config));
    let activity: ActivityStore = Arc::new(Mutex::new(std::collections::HashMap::new()));
    let (tx, _rx) = mpsc::channel();

    // Simulate the new switch logic: rotate active then target (dedup skipped
    // here since they differ), never the bystander.
    rotate_one(&config, active_name, &activity, &tx);
    rotate_one(&config, target_name, &activity, &tx);

    // All three should be Idle: active and target have no refresh token so
    // rotate_one short-circuits before stamping; bystander was never called.
    assert!(
        is_idle(&activity, active_name),
        "active must be Idle after no-token short-circuit"
    );
    assert!(
        is_idle(&activity, target_name),
        "target must be Idle after no-token short-circuit"
    );
    assert!(
        is_idle(&activity, bystander_name),
        "bystander must never be stamped — only active+target are passed to rotate_one"
    );
}

/// `rotate_one_for_window` must NOT stamp `LastRotatedWindow` when the profile
/// has no refresh token. The function short-circuits before HTTP, so LRW is
/// untouched and the caller can rely on re-enqueue behaviour from the scheduler.
#[test]
fn rotate_one_for_window_no_stamp_when_no_refresh_token() {
    use std::collections::{BTreeMap, HashMap};
    use std::sync::mpsc;

    let name = "test-rotate-window-no-rt";
    let profile = Profile {
        name: name.to_string(),
        base_url: None,
        api_key: None,
        auto_start: false,
        env: BTreeMap::new(),
        fallback_threshold: None,
        credentials: Some(ClaudeCredentials {
            claude_ai_oauth: Some(OAuthToken {
                access_token: "at".to_string(),
                refresh_token: None,
                expires_at: None,
                scopes: None,
                subscription_type: None,
            }),
        }),
        usage: None,
        fetch_status: None,
    };
    let config = Arc::new(Mutex::new(AppConfig {
        state: AppState::default(),
        profiles: vec![profile],
    }));
    let activity: ActivityStore = Arc::new(Mutex::new(HashMap::new()));
    let lrw: LastRotatedWindow = Arc::new(Mutex::new(HashMap::new()));
    let (tx, _rx) = mpsc::channel();
    let resets_at: i64 = 9999;

    let rotated = rotate_one_for_window(&config, name, &activity, &tx, &lrw, resets_at);

    assert!(!rotated, "should return false with no refresh token");
    assert!(
        lrw.lock().unwrap().is_empty(),
        "LRW must not be stamped when rotation short-circuits at no-token"
    );
    assert!(
        is_idle(&activity, name),
        "activity slot must remain Idle when short-circuiting"
    );
}

/// `rotate_one_for_window` must NOT stamp `LastRotatedWindow` when a live
/// `clauth start` session holds the chain. The scheduler will re-enqueue the
/// same window on the next tick; `has_live_session` returns early without HTTP.
#[test]
fn rotate_one_for_window_no_stamp_when_live_session() {
    use std::collections::{BTreeMap, HashMap};
    use std::sync::mpsc;

    let name = "test-rotate-window-live-session";
    let sessions = profile_dir(name).expect("profile_dir").join("sessions");
    std::fs::create_dir_all(&sessions).expect("create sessions dir");
    let pid_file = sessions.join("test-pid-window");
    let file = open_pid_file(&pid_file).expect("open pid file");
    file.lock().expect("lock pid file");

    let profile = Profile {
        name: name.to_string(),
        base_url: None,
        api_key: None,
        auto_start: false,
        env: BTreeMap::new(),
        fallback_threshold: None,
        credentials: Some(ClaudeCredentials {
            claude_ai_oauth: Some(OAuthToken {
                access_token: "at".to_string(),
                refresh_token: Some("rt-live".to_string()),
                expires_at: None,
                scopes: None,
                subscription_type: None,
            }),
        }),
        usage: None,
        fetch_status: None,
    };
    let config = Arc::new(Mutex::new(AppConfig {
        state: AppState::default(),
        profiles: vec![profile],
    }));
    let activity: ActivityStore = Arc::new(Mutex::new(HashMap::new()));
    let lrw: LastRotatedWindow = Arc::new(Mutex::new(HashMap::new()));
    let (tx, _rx) = mpsc::channel();
    let resets_at: i64 = 8888;

    let rotated = rotate_one_for_window(&config, name, &activity, &tx, &lrw, resets_at);

    assert!(
        !rotated,
        "should return false when live session holds the chain"
    );
    assert!(
        lrw.lock().unwrap().is_empty(),
        "LRW must not be stamped when skipped due to live session"
    );

    drop(file);
}

/// When active == target (re-switch), the switch paths deduplicate and call
/// `rotate_one` at most once for that profile.
#[test]
fn switch_dedup_active_equals_target() {
    use std::collections::BTreeMap;
    use std::sync::mpsc;

    let name = "switch-dedup-same";
    let profile = Profile {
        name: name.to_string(),
        base_url: None,
        api_key: None,
        auto_start: false,
        env: BTreeMap::new(),
        fallback_threshold: None,
        credentials: Some(ClaudeCredentials {
            claude_ai_oauth: Some(OAuthToken {
                access_token: "at".to_string(),
                refresh_token: None,
                expires_at: None,
                scopes: None,
                subscription_type: None,
            }),
        }),
        usage: None,
        fetch_status: None,
    };
    let config = Arc::new(Mutex::new(AppConfig {
        state: AppState::default(),
        profiles: vec![profile],
    }));
    let activity: ActivityStore = Arc::new(Mutex::new(std::collections::HashMap::new()));
    let (tx, _rx) = mpsc::channel();

    // active == target: the dedup condition `active != target` is false, so only
    // one rotate_one call is made (for target). Verify the slot stays Idle.
    let active = Some(name.to_string());
    let target = name.to_string();
    if let Some(ref a) = active
        && a != &target
    {
        rotate_one(&config, a, &activity, &tx);
    }
    rotate_one(&config, &target, &activity, &tx);

    assert!(
        is_idle(&activity, name),
        "slot must stay Idle after single no-token rotate_one call"
    );
}