claude_profile 1.2.0

Claude Code account credential management and token status
Documentation
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
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
// Path-referenced test module for refresh.rs — compiled as `mod tests` via `#[path]`.
// Lives in src/usage/ (not tests/) to access pub(crate) apply_refresh
// without widening its visibility. See src/usage/readme.md § Inline Test Exception.

  use super::apply_refresh;
  use crate::usage::types::{ AccountQuota, SubprocessModel, SubprocessEffort };
  use crate::usage::test_support::FAR_FUTURE_MS;
  use tempfile::TempDir;

  // ── apply_refresh ──────────────────────────────────────────────────────────

  /// T01 — `apply_refresh` leaves a 429 error result unchanged (no retry path).
  ///
  /// # Root Cause
  /// In task 142, `apply_refresh`'s retry guard included `e.contains("429")` alongside
  /// `"401"` and `"403"`. HTTP 429 is a rate-limit response (token is still valid); retrying
  /// on 429 triggers an unnecessary token refresh. Task 143 removed 429 from the guard at
  /// `usage.rs` line 634, leaving only auth-failure codes (401, 403) as retry triggers.
  ///
  /// # Why Not Caught
  /// No test existed for `apply_refresh` behavior with 429 errors before task 143; the guard
  /// was added in task 142 without a companion test proving 429 is passed through unchanged.
  ///
  /// # Fix Applied
  /// Removed `e.contains("429")` from the retry guard; guard is now
  /// `Err(ref e) if e.contains("401") || e.contains("403")` only.
  ///
  /// # Prevention
  /// This test verifies the result string is identical after `apply_refresh`, acting as a
  /// regression guard against re-adding 429 to the retry trigger conditions.
  ///
  /// # Pitfall
  /// Without a credential file in the store, the retry body is unreachable regardless of the
  /// guard — `apply_refresh` cannot attempt a refresh and leaves the result unchanged either
  /// way. This test validates the guard does not corrupt the result, but is NOT a full guard
  /// against re-adding 429: even with the bug restored, this test would still pass (no creds).
  /// The `shorten_error` test (T04) provides the stronger behavioral invariant.
  #[ doc = "bug_reproducer(BUG-271)" ]
  #[ test ]
  fn test_apply_refresh_429_not_retried()
  {
    let store = TempDir::new().unwrap();
    let mut accounts = vec![
      AccountQuota
      {
        name          : "test-acct".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 429".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];

    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );

    // Fix(BUG-297): 429+expired fires should_refresh → refresh_account_token returns None
    //   (no cred file) → result is now Err("refresh token expired"), not the original 429 error.
    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "429+expired: no cred file → refresh_account_token returns None → \
       result must be Err(\"refresh token expired\"); result: {:?}", accounts[ 0 ].result,
    );
  }

  /// B2 — `apply_refresh` does not corrupt a successful Ok result.
  ///
  /// An account with a valid quota result must remain Ok after `apply_refresh`;
  /// the guard only fires on Err results containing "401" or "403".
  #[ test ]
  fn test_apply_refresh_ok_result_unchanged()
  {
    let store = TempDir::new().unwrap();
    let quota = claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None };
    let mut accounts = vec![
      AccountQuota
      {
        name          : "ok-acct".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Ok( quota ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];
    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    assert!( accounts[ 0 ].result.is_ok(), "Ok result must not be changed by apply_refresh" );
  }

  /// B3 — `apply_refresh` leaves a generic network error unchanged (not an auth error).
  ///
  /// Only "401" and "403" substrings trigger the retry guard; unrelated error
  /// strings pass through without entering the retry path.
  #[ test ]
  fn test_apply_refresh_generic_error_unchanged()
  {
    let store   = TempDir::new().unwrap();
    let err_msg = "network timeout after 30s".to_string();
    let mut accounts = vec![
      AccountQuota
      {
        name          : "net-acct".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( err_msg.clone() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];
    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e == &err_msg ),
      "generic error must be unchanged; result: {:?}", accounts[ 0 ].result,
    );
  }

  // ── apply_refresh: corner cases ─────────────────────────────────────────────

  /// C1 — `apply_refresh` on an empty accounts slice is a no-op.
  #[ test ]
  fn test_apply_refresh_empty_accounts()
  {
    let store = TempDir::new().unwrap();
    let mut accounts : Vec< AccountQuota > = vec![];
    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    assert!( accounts.is_empty(), "empty slice must remain empty" );
  }

  /// C2 / FT-14 — `apply_refresh` `None`-paths: 401 + no credential file → result unchanged.
  ///
  /// `should_refresh` fires (`should_retry=true`); `crate::account::refresh_account_token`
  /// is called with `paths=None`; internally it reads `{store}/{name}.credentials.json`
  /// which is absent, so it returns `None`; `apply_refresh` skips the account via
  /// `continue` without modifying the result.
  #[ test ]
  fn test_apply_refresh_401_no_cred_file()
  {
    let store = TempDir::new().unwrap();
    let mut accounts = vec![
      AccountQuota
      {
        name          : "ghost@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 401".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];
    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    // Fix(BUG-297): 401 fires should_refresh → refresh_account_token returns None
    //   (no cred file) → result is now Err("refresh token expired"), not the original 401 error.
    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "401: no cred file → refresh_account_token returns None → \
       result must be Err(\"refresh token expired\"); result: {:?}", accounts[ 0 ].result,
    );
  }

  /// C3 — `apply_refresh` with 403 error but no credential file on disk.
  ///
  /// Same as C2 but with HTTP 403. Both 401 and 403 are auth-error triggers,
  /// but without a credential file the retry body is unreachable.
  #[ test ]
  fn test_apply_refresh_403_no_cred_file()
  {
    let store = TempDir::new().unwrap();
    let mut accounts = vec![
      AccountQuota
      {
        name          : "ghost@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 403".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];
    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    // Fix(BUG-297): 403 fires should_refresh → refresh_account_token returns None
    //   (no cred file) → result is now Err("refresh token expired"), not the original 403 error.
    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "403: no cred file → refresh_account_token returns None → \
       result must be Err(\"refresh token expired\"); result: {:?}", accounts[ 0 ].result,
    );
  }

  /// C4 / FT-07 — `apply_refresh` with mixed results: refresh failure does not affect siblings.
  ///
  /// Four accounts: Ok, 429+expired (`expires_at_ms=0`), 401, generic error.
  /// After `apply_refresh`, the 401 and the 429+expired accounts enter the retry guard
  /// but stay unchanged (no credential file → `refresh_account_token` returns `None`
  /// → `continue`).  Ok and generic error are untouched (Ok never retries; generic
  /// error has no auth/429 signal).  Implements FT-07: refresh failure in one account
  /// does not corrupt any sibling's result.
  #[ test ]
  fn test_apply_refresh_mixed_accounts()
  {
    let store = TempDir::new().unwrap();
    let quota = claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None };
    let mut accounts = vec![
      AccountQuota
      {
        name          : "a@ok.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Ok( quota ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
      AccountQuota
      {
        name          : "b@ratelimited.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 429".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
      AccountQuota
      {
        name          : "c@expired.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 401".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
      AccountQuota
      {
        name          : "d@network.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "connection refused".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];

    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );

    assert!( accounts[ 0 ].result.is_ok(), "Ok account must remain Ok" );
    // Fix(BUG-297): 429+expired and 401 both fire should_refresh → refresh_account_token
    //   returns None (no cred file) → result is now Err("refresh token expired").
    assert!(
      matches!( accounts[ 1 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "429+expired: no cred file → result must be Err(\"refresh token expired\"); result: {:?}",
      accounts[ 1 ].result,
    );
    assert!(
      matches!( accounts[ 2 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "401: no cred file → result must be Err(\"refresh token expired\"); result: {:?}",
      accounts[ 2 ].result,
    );
    assert!(
      matches!( accounts[ 3 ].result, Err( ref e ) if e == "connection refused" ),
      "generic error must be unchanged",
    );
  }

  /// C5 — `apply_refresh` with trace=true does not panic.
  ///
  /// Verifies the trace code path executes without crashing, even when the
  /// credential file is absent and the retry path short-circuits.
  #[ test ]
  fn test_apply_refresh_trace_does_not_panic()
  {
    let store = TempDir::new().unwrap();
    let mut accounts = vec![
      AccountQuota
      {
        name          : "trace@test.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 401".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];
    apply_refresh( &mut accounts, store.path(), None, true, SubprocessModel::Auto, SubprocessEffort::Auto, false );
  }

  // ── apply_refresh: lifecycle (Some(paths)) ──────────────────────────────────

  /// L1 — `apply_refresh` skips lifecycle path when `switch_account` fails (no cred file).
  ///
  /// # Root Cause
  /// Before BUG-165, `apply_refresh` bypassed `switch_account` entirely, writing credentials
  /// directly to the persistent store while leaving the live session stale. After the fix,
  /// `apply_refresh` calls `switch_account` first when `claude_paths` is `Some`; if it fails
  /// (account not found in store), the account is skipped and its error result is left unchanged.
  ///
  /// # Why Not Caught
  /// All prior inline tests passed `apply_refresh(..., None, ...)`, exercising only the `None`
  /// (fallback/test) branch. Zero tests exercised `Some(paths)` (lifecycle/production branch).
  ///
  /// # Fix Applied
  /// BUG-165: extracted `refresh_account_token` (full lifecycle: switch → refresh →
  /// save); `apply_refresh` delegates via `crate::account::refresh_account_token`; skips the
  /// account with `continue` if `refresh_account_token` returns `None`.
  ///
  /// # Prevention
  /// This test guards the `Some(paths)` early-exit: when the credential file is absent,
  /// `refresh_account_token` returns `None` and `apply_refresh` must `continue` without
  /// corrupting the account result.
  ///
  /// # Pitfall
  /// Tests where the credential file exists will reach `refresh_account_token`, which internally
  /// spawns the `claude` binary and blocks for up to 35 s. Only test scenarios where the
  /// credential file is absent (causing `None` early-exit) to avoid subprocess blocking.
  #[ test ]
  fn test_apply_refresh_lifecycle_switch_fails_result_unchanged()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();
    // No alice@example.com.credentials.json in store — switch_account returns NotFound.
    let paths = crate::ClaudePaths::with_home( fake_home.path() );
    let mut accounts = vec![
      AccountQuota
      {
        name          : "alice@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 401".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];

    apply_refresh( &mut accounts, store.path(), Some( &paths ), false, SubprocessModel::Auto, SubprocessEffort::Auto, false );

    // Fix(BUG-297): switch_account fails (no cred file) → refresh_account_token returns None
    //   → result is now Err("refresh token expired"), not the original 401 error.
    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "lifecycle: 401 + switch fails → refresh_account_token None → \
       result must be Err(\"refresh token expired\"); result: {:?}",
      accounts[ 0 ].result,
    );
  }

  /// L2 — `apply_refresh` restores the original active account after the refresh cycle.
  ///
  /// FT-13 / BUG-211 MRE — `apply_refresh` does NOT call `switch_account` after per-account cycling.
  ///
  /// Fix(BUG-211): the snapshot+restore pattern was removed from `apply_refresh`.
  /// `refresh_account_token` passes `update_marker=false` to `save()`, so `_active` is
  /// never written during per-account cycling — no restore is needed or performed.
  ///
  /// # Root Cause
  /// The original `apply_refresh` snapshotted the active marker before the loop and called
  /// `switch_account(snapshot, ...)` after the loop. This created a TOCTOU race:
  /// a concurrent `.account.use` switch during the ~35s subprocess window was silently
  /// overwritten by the post-loop restore.
  ///
  /// # Why Not Caught
  /// All prior tests verified that the restore SUCCEEDED (live creds file written, marker
  /// restored). No test verified that the live creds file was NOT written when no restore
  /// should occur — making the absence of side-effects the guard.
  ///
  /// # Fix Applied
  /// BUG-211: removed snapshot+restore from `apply_refresh`; `refresh_account_token` now
  /// passes `update_marker=false` to `save()` so background refresh never writes `_active`.
  ///
  /// # Prevention
  /// This test guards absence of `switch_account` in `apply_refresh`: after a full refresh
  /// cycle, the live credentials file must NOT exist (no `switch_account` wrote it) and the
  /// active marker must be unchanged from its pre-call value.
  ///
  /// # Pitfall
  /// If snapshot+restore is re-introduced into `apply_refresh`, this test fails because
  /// `switch_account` writes the live credentials file — the `!credentials_file().exists()`
  /// assertion is the critical guard for regression.
  #[ doc = "bug_reproducer(BUG-211)" ]
  #[ test ]
  fn test_apply_refresh_lifecycle_active_marker_unchanged()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();

    // Alice's credential file in store — present but must NOT be copied to the live file.
    std::fs::write(
      store.path().join( "alice@example.com.credentials.json" ),
      r#"{"accessToken":"alice-token"}"#,
    ).unwrap();

    // Set active account to alice before the loop.
    std::fs::write( store.path().join( crate::account::active_marker_filename() ), "alice@example.com" ).unwrap();

    std::fs::create_dir_all( fake_home.path().join( ".claude" ) ).unwrap();
    let paths = crate::ClaudePaths::with_home( fake_home.path() );

    // Bob has 401 but no credential file — refresh_account_token returns None, bob skipped.
    let mut accounts = vec![
      AccountQuota
      {
        name          : "bob@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 401".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];

    apply_refresh( &mut accounts, store.path(), Some( &paths ), false, SubprocessModel::Auto, SubprocessEffort::Auto, false );

    // Fix(BUG-211): no switch_account in apply_refresh → live credentials file must NOT exist.
    assert!(
      !paths.credentials_file().exists(),
      "BUG-211: apply_refresh must not call switch_account; live credentials file must not exist",
    );

    // Active marker is unchanged (set to "alice@example.com" before call, never touched).
    let active = std::fs::read_to_string( store.path().join( crate::account::active_marker_filename() ) ).unwrap();
    assert_eq!(
      active, "alice@example.com",
      "per-machine active marker must be unchanged throughout refresh cycle (BUG-211 fix)",
    );
  }

  /// L3 — `apply_refresh` lifecycle: 429+expired + `Some(paths)` + no cred file → skipped.
  ///
  /// 429 with an expired local token meets `should_refresh` but `switch_account` fails
  /// (no cred file in the persistent store), so the account is skipped and the result
  /// is left unchanged — same guarantee as L1 but for the 429+expired trigger path.
  #[ test ]
  fn test_apply_refresh_lifecycle_429_expired_switch_fails_unchanged()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();
    let paths = crate::ClaudePaths::with_home( fake_home.path() );
    let mut accounts = vec![
      AccountQuota
      {
        name          : "alice@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,  // expired: 0/1000=0 <= now_secs
        result        : Err( "HTTP transport error: HTTP 429".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];
    apply_refresh( &mut accounts, store.path(), Some( &paths ), false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    // Fix(BUG-297): switch_account fails (no cred file) → refresh_account_token returns None
    //   → result is now Err("refresh token expired"), not the original 429 error.
    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "lifecycle: 429+expired + switch fails → refresh_account_token None → \
       result must be Err(\"refresh token expired\"); result: {:?}",
      accounts[ 0 ].result,
    );
  }

  /// FT-03 — `apply_refresh` lifecycle: 403 + `Some(paths)` + no cred file → result unchanged.
  ///
  /// 403 meets `should_refresh` (authentication failure, identical to 401) but
  /// `switch_account` fails (no credential file in store), so `refresh_account_token`
  /// returns `None` and `apply_refresh` skips the account via `continue`.  The 403
  /// result is left unchanged — confirms 403 enters the refresh path, not the
  /// non-trigger `continue` guard.
  #[ test ]
  fn test_apply_refresh_lifecycle_ft3_403_no_cred_result_unchanged()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();
    // No alice@example.com.credentials.json — switch_account returns NotFound.
    let paths = crate::ClaudePaths::with_home( fake_home.path() );
    let mut accounts = vec![
      AccountQuota
      {
        name          : "alice@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : FAR_FUTURE_MS,  // non-expired; 403 triggers regardless of expiry
        result        : Err( "HTTP transport error: HTTP 403".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];

    apply_refresh( &mut accounts, store.path(), Some( &paths ), false, SubprocessModel::Auto, SubprocessEffort::Auto, false );

    // Fix(BUG-297): switch_account fails (no cred file) → refresh_account_token returns None
    //   → result is now Err("refresh token expired"), not the original 403 error.
    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "lifecycle: 403 + switch fails → refresh_account_token None → \
       result must be Err(\"refresh token expired\"); result: {:?}",
      accounts[ 0 ].result,
    );
  }

  /// L4 — `apply_refresh` lifecycle: cred file exists but `{home}/.claude/` dir missing
  /// → `fs::copy` fails inside `switch_account` → account is skipped, result unchanged.
  ///
  /// `switch_account` copies the credential to a temp file inside `{home}/.claude/`.
  /// If that directory does not exist, `fs::copy` returns an `Err`, causing `apply_refresh`
  /// to `continue` without modifying the account result.
  #[ test ]
  fn test_apply_refresh_lifecycle_copy_fails_no_dot_claude_dir()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();
    // Cred file exists — check_switch_preconditions passes.
    std::fs::write(
      store.path().join( "alice@example.com.credentials.json" ),
      r#"{"accessToken":"tok"}"#,
    ).unwrap();
    // {fake_home}/.claude/ deliberately NOT created → fs::copy target parent missing.
    let paths = crate::ClaudePaths::with_home( fake_home.path() );
    let mut accounts = vec![
      AccountQuota
      {
        name          : "alice@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 401".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];
    apply_refresh( &mut accounts, store.path(), Some( &paths ), false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    // Fix(BUG-297): fs::copy fails (no .claude/ dir) → switch_account returns Err
    //   → refresh_account_token returns None → result is now Err("refresh token expired").
    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "lifecycle: 401 + fs::copy fails (no .claude/ dir) → refresh_account_token None → \
       result must be Err(\"refresh token expired\"); result: {:?}",
      accounts[ 0 ].result,
    );
  }

  /// L5 — `apply_refresh` does not create the active marker file when it was absent before.
  ///
  /// Fix(BUG-211): `apply_refresh` no longer reads or writes `_active`. If no marker file
  /// exists before the call, none is created after — the function never touches the marker.
  #[ test ]
  fn test_apply_refresh_lifecycle_no_active_file_no_restore()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();
    let paths = crate::ClaudePaths::with_home( fake_home.path() );
    let mut accounts : Vec< AccountQuota > = vec![];  // no accounts → no loop body
    apply_refresh( &mut accounts, store.path(), Some( &paths ), false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    assert!(
      !store.path().join( crate::account::active_marker_filename() ).exists(),
      "per-machine active marker must not be created when it was absent before apply_refresh",
    );
  }

  /// L6 — `apply_refresh` with `trace=true` and refresh skip (no cred file) does not panic.
  ///
  /// Exercises the trace code path for the refresh loop: `should_refresh` triggers for the
  /// 401 account, `refresh_account_token` is called, `switch_account` fails inside it
  /// (no cred file), `refresh_account_token` returns `None`, and `apply_refresh` skips
  /// the account. Fix(BUG-211): no post-loop restore; the function returns cleanly.
  #[ test ]
  fn test_apply_refresh_lifecycle_trace_switch_fails_no_panic()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();
    let paths = crate::ClaudePaths::with_home( fake_home.path() );
    let mut accounts = vec![
      AccountQuota
      {
        name          : "trace@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 401".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];
    // Must not panic — switch_account fails (no cred file), trace logs to stderr.
    apply_refresh( &mut accounts, store.path(), Some( &paths ), true, SubprocessModel::Auto, SubprocessEffort::Auto, false );
  }

  /// L7 — active marker with trailing newline is unchanged after `apply_refresh` (no restore).
  ///
  /// Fix(BUG-211): `apply_refresh` no longer reads or writes `_active`. A marker written
  /// as `"alice@example.com\n"` before the call remains exactly `"alice@example.com\n"` after —
  /// no trim, no `switch_account`, no modification of any kind.
  #[ test ]
  fn test_apply_refresh_lifecycle_active_newline_trimmed_restore()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();
    std::fs::write( store.path().join( crate::account::active_marker_filename() ), "alice@example.com\n" ).unwrap();
    let paths = crate::ClaudePaths::with_home( fake_home.path() );
    let mut accounts : Vec< AccountQuota > = vec![];
    apply_refresh( &mut accounts, store.path(), Some( &paths ), false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    let active = std::fs::read_to_string( store.path().join( crate::account::active_marker_filename() ) ).unwrap();
    assert_eq!(
      active, "alice@example.com\n",
      "active marker must be unchanged after apply_refresh (BUG-211 fix: no restore); got: {active:?}",
    );
  }

  /// L8 — `apply_refresh` leaves an existing active marker file with whitespace-only content unchanged.
  ///
  /// Fix(BUG-211): `apply_refresh` never reads or writes `_active`. A pre-existing whitespace-
  /// only marker remains exactly as written — no trim, no `switch_account`, no modification.
  #[ test ]
  fn test_apply_refresh_lifecycle_active_whitespace_only_no_restore()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();
    let ws = "   \n  ";
    std::fs::write( store.path().join( crate::account::active_marker_filename() ), ws ).unwrap();
    let paths = crate::ClaudePaths::with_home( fake_home.path() );
    let mut accounts : Vec< AccountQuota > = vec![];
    apply_refresh( &mut accounts, store.path(), Some( &paths ), false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    let active = std::fs::read_to_string( store.path().join( crate::account::active_marker_filename() ) ).unwrap();
    assert_eq!(
      active, ws,
      "apply_refresh must not modify the active marker file (BUG-211 fix); content must be unchanged",
    );
  }

  /// L9 — `claude_paths = None`: active marker file is unchanged after `apply_refresh`.
  ///
  /// Fix(BUG-211): `apply_refresh` never reads or writes `_active` regardless of whether
  /// `claude_paths` is `Some` or `None`. A pre-existing marker is unchanged in both cases.
  #[ test ]
  fn test_apply_refresh_none_paths_active_unchanged()
  {
    let store = TempDir::new().unwrap();
    std::fs::write( store.path().join( crate::account::active_marker_filename() ), "alice@example.com" ).unwrap();
    let mut accounts : Vec< AccountQuota > = vec![];  // no accounts → no loop body
    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    let active = std::fs::read_to_string( store.path().join( crate::account::active_marker_filename() ) ).unwrap();
    assert_eq!(
      active, "alice@example.com",
      "per-machine active marker must be unchanged when claude_paths=None (no restore possible)",
    );
  }

  /// L10 / FT-15 — `apply_refresh` lifecycle with `trace=true` reaching `run_isolated` invocation.
  ///
  /// `switch_account` succeeds (cred file in store, `.claude/` dir in `fake_home`).
  /// `run_isolated` is invoked but fails fast (no valid claude binary or fake token) →
  /// trace emits `[trace] … run_isolated: Err(…)` or `OK credentials=None` →
  /// `refresh_account_token` returns `None` → account skipped → no panic.
  ///
  /// # Root Cause
  /// Before BUG-166, `refresh_account_token` had no `trace` parameter. The `apply_refresh`
  /// `trace` arg was accepted but never forwarded, making the lifecycle completely opaque:
  /// all failure paths returned `None` silently. Running `clp .usage refresh::1 trace::1`
  /// showed only "refresh returned None — skipping retry" with no step-level detail.
  ///
  /// # Why Not Caught
  /// The trace parameter existed in `apply_refresh` but there were no tests verifying
  /// it actually reached `refresh_account_token`. Silent pass-through was undetectable.
  ///
  /// # Fix Applied
  /// BUG-166: added `trace: bool` as a 4th parameter to `refresh_account_token`;
  /// replaced all bare `?` operators with explicit `match` + `if trace { eprintln!(...) }` blocks.
  ///
  /// # Prevention
  /// This test guards the full call chain: `apply_refresh(trace=true)` →
  /// `refresh_account_token(trace=true)` → `run_isolated` invocation. If the trace
  /// parameter is ever dropped between layers, this test still passes (no panic),
  /// but the trace output would be missing. The `account_refresh_test::art_some_paths_run_isolated_invoked_trace_no_panic`
  /// test covers the `refresh_account_token` function directly.
  ///
  /// # Pitfall
  /// Tests using "does not panic" cannot assert stderr content — nextest does not
  /// capture `eprintln!` output for unit test assertions. This is the correct pattern
  /// for trace tests.
  #[ test ]
  fn test_apply_refresh_lifecycle_l10_trace_run_isolated_invoked_no_panic()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();
    // Cred file in store AND .claude/ dir present — switch_account succeeds.
    std::fs::write(
      store.path().join( "alice@example.com.credentials.json" ),
      r#"{"accessToken":"fake-tok","expiresAt":9999999999999}"#,
    ).unwrap();
    std::fs::create_dir_all( fake_home.path().join( ".claude" ) ).unwrap();
    let paths = crate::ClaudePaths::with_home( fake_home.path() );
    let mut accounts = vec![
      AccountQuota
      {
        name          : "alice@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 401".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];
    // Must not panic — switch_account succeeds; run_isolated invoked; fails fast (fake creds).
    apply_refresh( &mut accounts, store.path(), Some( &paths ), true, SubprocessModel::Auto, SubprocessEffort::Auto, false );
  }

  /// FT-04 — `apply_refresh`: 429 + non-expired local token → NOT retried, result unchanged.
  ///
  /// `should_refresh` returns false when 429+non-expired (`expires_at_ms / 1000 > now_secs`):
  /// the local token is valid; the 429 is a genuine rate-limit, not a stale-credential
  /// condition.  `apply_refresh` skips `refresh_account_token` entirely (early `continue`).
  /// The 429 result is left unchanged.
  #[ test ]
  fn test_apply_refresh_ft4_429_valid_token_not_retried()
  {
    let store = TempDir::new().unwrap();
    let mut accounts = vec![
      AccountQuota
      {
        name          : "alice@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : FAR_FUTURE_MS,  // non-expired → 429 is genuine rate-limit
        result        : Err( "HTTP transport error: HTTP 429".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];

    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );

    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e.contains( "429" ) ),
      "429 with valid (non-expired) token must NOT be retried; result: {:?}",
      accounts[ 0 ].result,
    );
  }

  /// FT-05 — `apply_refresh` `None`-paths: 429 + expired local token → refresh path
  /// entered, but no credential file in store → `refresh_account_token` returns `None`
  /// → account skipped via `continue` → result unchanged.
  ///
  /// Contrasts with FT-04 (`test_apply_refresh_ft4_429_valid_token_not_retried`):
  ///   FT-04: 429 + non-expired → `should_refresh` returns `false` → refresh path NEVER entered.
  ///   FT-05: 429 + expired    → `should_refresh` returns `true`  → refresh path IS entered,
  ///          but gracefully exits when no per-account credential file exists in the store.
  #[ test ]
  fn test_apply_refresh_ft5_429_expired_refresh_path_entered_no_cred()
  {
    let store = TempDir::new().unwrap();
    // expires_at_ms=0 → 0/1000=0 ≤ now_secs → locally expired → should_refresh=true for 429.
    let mut accounts = vec![
      AccountQuota
      {
        name          : "alice@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 429".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];

    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );

    // Fix(BUG-297): no cred file → refresh_account_token returns None → result is now
    //   Err("refresh token expired"), not the original 429 error.
    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "429+expired: no cred file → refresh_account_token None → \
       result must be Err(\"refresh token expired\"); result: {:?}",
      accounts[ 0 ].result,
    );
  }

  // ── BUG-170 MRE: jwt_exp_ms ──────────────────────────────────────────────────

  /// MRE 1/2 for BUG-170: `jwt_exp_ms` returns `None` for opaque `sk-ant-oat01-*` tokens.
  ///
  /// # Root Cause
  /// `jwt_exp_ms` splits `accessToken` on `.` via `splitn(3, '.')`. Opaque `sk-ant-oat01-*`
  /// tokens have no `.` separator — the second `parts.next()?` returns `None` and
  /// `jwt_exp_ms` returns `None`. The `if let Some` guard at `usage.rs:803-806` never fires,
  /// leaving `aq.expires_at_ms` at its stale pre-refresh expired timestamp.
  ///
  /// # Why Not Caught
  /// BUG-162 tests used synthetic JWT-format tokens. No test verified `jwt_exp_ms` behavior
  /// for opaque `sk-ant-oat01-*` tokens, nor that `expires_at_ms` is correct post-refresh
  /// when `jwt_exp_ms` returns `None`.
  ///
  /// # Fix Applied
  /// Fix(BUG-170): `parse_u64_from_str` fallback added after `jwt_exp_ms` in `apply_refresh`.
  /// This test guards the precondition: `jwt_exp_ms` correctly returns `None` for opaque tokens.
  ///
  /// # Prevention
  /// `jwt_exp_ms` returns `None` for any non-JWT token; this is by design. Never "fix"
  /// `jwt_exp_ms` to handle opaque tokens — the correct fix is a separate `expiresAt` fallback.
  ///
  /// # Pitfall
  /// If `jwt_exp_ms` is modified to handle opaque tokens directly (wrong fix), this test
  /// fails, alerting that the `parse_u64_from_str` fallback may be redundant. Preserve the
  /// two-step fallback design regardless — opaque tokens will never have a parseable JWT payload.
  #[ doc = "bug_reproducer(BUG-170)" ]
  #[ test ]
  fn test_jwt_exp_ms_mre_bug170_opaque_returns_none()
  {
    // Opaque sk-ant-oat01-* token: no '.' separator — splitn(3, '.') yields one part.
    let opaque_creds = r#"{"accessToken":"sk-ant-oat01-XXXXXXXXXXXX","expiresAt":9999999999999}"#;
    assert!(
      crate::output::jwt_exp_ms( opaque_creds ).is_none(),
      "jwt_exp_ms must return None for opaque sk-ant-oat01 token (no JWT structure); \
       if this fails, jwt_exp_ms was changed to handle opaque tokens — review BUG-170 fix",
    );
  }

  // ── FT-17 / BUG-211 MRE ──────────────────────────────────────────────────────

  /// FT-17 / BUG-211 MRE — `apply_refresh` does NOT write live credentials file (no `switch_account`).
  ///
  /// Fix(BUG-211): the snapshot+restore pattern was removed from `apply_refresh`. With
  /// `trace=true`, no `[trace] refresh  {name}  restore switch_account: OK/Err` line is
  /// emitted — the restore step no longer exists.
  ///
  /// # Root Cause
  /// The original `apply_refresh` called `switch_account(snapshot, ...)` after the
  /// per-account loop. This restore wrote the live credentials file and updated the active
  /// marker — creating a TOCTOU race with concurrent `.account.use` switches.
  ///
  /// # Why Not Caught
  /// BUG-208 tests verified that the restore EXECUTED (live creds written). No test verified
  /// that the live creds file is NOT written when the restore is absent — the previous
  /// `[trace]` guard was observing eprintln output which is not assertable in nextest.
  ///
  /// # Fix Applied
  /// BUG-211: removed snapshot+restore from `apply_refresh`; `refresh_account_token` passes
  /// `update_marker=false` to `save()` so background refresh never writes `_active`.
  ///
  /// # Prevention
  /// This test guards the absence of `switch_account` in the `apply_refresh` post-loop path:
  /// after a full cycle, `paths.credentials_file()` must NOT exist (`switch_account` not called),
  /// and the active marker must remain at its pre-call value.
  ///
  /// # Pitfall
  /// If restore is re-introduced, `credentials_file()` will exist after the call — the
  /// `!exists()` assertion is the regression guard. Marker assertion alone is insufficient
  /// because the marker is set to the same value by both restore and the pre-call write.
  #[ doc = "bug_reproducer(BUG-211)" ]
  #[ test ]
  fn test_apply_refresh_mre_bug208_restore_trace_emitted()
  {
    let store     = TempDir::new().unwrap();
    let fake_home = TempDir::new().unwrap();

    // Alice's credential file in store — present but must NOT be copied to the live file.
    std::fs::write(
      store.path().join( "alice@example.com.credentials.json" ),
      r#"{"accessToken":"alice-restore-tok","expiresAt":9999999999999}"#,
    ).unwrap();

    std::fs::write(
      store.path().join( crate::account::active_marker_filename() ),
      "alice@example.com",
    ).unwrap();

    std::fs::create_dir_all( fake_home.path().join( ".claude" ) ).unwrap();
    let paths = crate::ClaudePaths::with_home( fake_home.path() );

    // Bob has 401 but no credential file — refresh_account_token returns None, bob skipped.
    let mut accounts = vec![
      AccountQuota
      {
        name          : "bob@example.com".to_string(),
        is_current    : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms : 0,
        result        : Err( "HTTP transport error: HTTP 401".to_string() ),
        account       : None,
        host          : String::new(),
        role          : String::new(),
        renewal_at    : None,
        cached        : false,
        cache_age_secs : None,
        is_owned       : true,
        owner                : String::new(),
      },
    ];

    // trace=true: Fix(BUG-211) — no restore switch_account; no [trace] restore line emitted.
    apply_refresh( &mut accounts, store.path(), Some( &paths ), true, SubprocessModel::Auto, SubprocessEffort::Auto, false );

    // Fix(BUG-211): no switch_account → live credentials file must NOT exist.
    assert!(
      !paths.credentials_file().exists(),
      "BUG-211: apply_refresh must not call switch_account; live credentials file must not exist",
    );

    // Active marker is unchanged (was "alice@example.com", never touched).
    let marker = std::fs::read_to_string(
      store.path().join( crate::account::active_marker_filename() )
    ).unwrap();
    assert_eq!(
      marker, "alice@example.com",
      "BUG-211: active marker must be unchanged after apply_refresh cycle (no restore)",
    );
  }

  // ── BUG-256 MRE: retry OK arm must clear cached metadata and write cache file ─

  /// MRE for BUG-256: retry OK arm clears `cached` flag, `cache_age_secs`, and writes
  /// fresh quota data to `{{name}}.json` via `write_quota_cache()`.
  ///
  /// # Root Cause
  /// The retry OK arm in `apply_refresh` only set `aq.result = Ok(retried)` — it did not
  /// clear `aq.cached` or `aq.cache_age_secs`, so `render.rs` kept the `~` prefix on every
  /// quota cell and the `(Xh ago)` age label on every row. `write_quota_cache` was also
  /// absent, so `{{name}}.json` retained stale cached quota across restarts. The bug was
  /// introduced by merge f83d78d (conflict resolution chose the remote branch, dropping the
  /// three mutations that were in 518d0a4).
  ///
  /// # Why Not Caught
  /// No test guarded the content of the retry OK arm. Mutations were dropped silently by
  /// a merge conflict resolution — only a source-structure assertion catches this class of
  /// omission.
  ///
  /// # Fix Applied
  /// Fix(BUG-256): in the retry OK arm of `apply_refresh`, extract h5/d7/sn references
  /// BEFORE moving `retried` into `aq.result`, then call `write_quota_cache`, and set
  /// `aq.cached = false` and `aq.cache_age_secs = None`.
  ///
  /// # Prevention
  /// This test greps the source of the retry OK arm for the three AC-11 mutations.
  /// Any merge conflict that drops them will cause this test to fail.
  ///
  /// # Pitfall
  /// The `write_quota_cache` call must appear BEFORE `aq.result = Ok( retried )` —
  /// h5/d7/sn borrow from `retried`; moving it first would be use-after-move.
  /// The order check below enforces this structural constraint statically.
  #[ doc = "bug_reproducer(BUG-256)" ]
  #[ test ]
  fn mre_bug256_retry_ok_stale_cached_metadata()
  {
    let src      = include_str!( concat!( env!( "CARGO_MANIFEST_DIR" ), "/src/usage/refresh.rs" ) );
    let fn_start = src.find( "pub( crate ) fn apply_refresh(" ).expect( "apply_refresh not found" );

    // Locate the retry OK arm within the function body.
    let ok_arm_rel = src[ fn_start.. ]
      .find( "Ok( retried ) =>" )
      .expect( "BUG-256: retry OK arm `Ok( retried ) =>` not found in apply_refresh" );
    let ok_arm_start = fn_start + ok_arm_rel;

    // Bound the OK arm: ends at the `Err( e ) =>` arm that follows.
    let err_arm_rel = src[ ok_arm_start.. ]
      .find( "Err( e ) =>" )
      .expect( "Err arm not found after retry OK arm" );
    let ok_arm = &src[ ok_arm_start .. ok_arm_start + err_arm_rel ];

    // AC-11 check 1: aq.cached must be cleared to false.
    assert!(
      ok_arm.contains( "aq.cached         = false" ),
      "BUG-256: retry OK arm must set `aq.cached = false` to clear ~ prefix from render",
    );

    // AC-11 check 2: aq.cache_age_secs must be cleared to None.
    assert!(
      ok_arm.contains( "aq.cache_age_secs = None" ),
      "BUG-256: retry OK arm must set `aq.cache_age_secs = None` to remove (Xh ago) label",
    );

    // AC-11 check 3: write_quota_cache must be called with fresh data.
    assert!(
      ok_arm.contains( "write_quota_cache(" ),
      "BUG-256: retry OK arm must call write_quota_cache to persist fresh data to {{name}}.json",
    );

    // Order check: write_quota_cache must appear before the move of retried into aq.result.
    let cache_write_pos = ok_arm.find( "write_quota_cache(" ).unwrap();
    let result_move_pos = ok_arm.find( "aq.result         = Ok( retried )" )
      .expect( "aq.result = Ok( retried ) not found in retry OK arm" );
    assert!(
      cache_write_pos < result_move_pos,
      "BUG-256: write_quota_cache must appear before `aq.result = Ok( retried )` — \
       h5/d7/sn borrow from retried and would be use-after-move otherwise",
    );
  }

  // ── BUG-295 MRE: non-owned trace reason must be "not owned", not "ok" ────────

  /// MRE for BUG-295: `apply_refresh` emits `reason: not owned` (not `reason: ok`) when
  /// `aq.is_owned == false` and `aq.result` is `Ok(cached_data)`.
  ///
  /// # Root Cause
  /// For non-owned accounts, G1 in `fetch.rs` sets `aq.result = Ok(cached_data)` — the cache
  /// read succeeds. The original trace line derived the reason via
  /// `aq.result.as_ref().err().map_or("ok", String::as_str)` — for `Ok(...)`, `.err()` returns
  /// `None`, yielding `"ok"`. The actual reason the account is skipped is `"not owned"` (via
  /// `should_refresh()` returning `false` because `!aq.is_owned`), not the result value.
  ///
  /// # Why Not Caught
  /// No test captured the trace reason string for non-owned accounts. The misleading `reason: ok`
  /// was only visible during manual trace inspection.
  ///
  /// # Fix Applied
  /// Fix(BUG-295): before consulting `aq.result.err()`, check `!aq.is_owned`. When `is_owned`
  /// is `false`, emit `"not owned"` as the reason regardless of `aq.result`.
  ///
  /// # Prevention
  /// This test captures stderr from `apply_refresh(trace=true)` for a non-owned account with
  /// `result: Ok(cached_data)`. Asserts `reason: not owned` present and `reason: ok` absent.
  ///
  /// # Pitfall
  /// Hold `STDERR_LOCK` before `gag::BufferRedirect::stderr()` — concurrent gag captures
  /// corrupt each other via the shared fd 2.
  #[ doc = "bug_reproducer(BUG-295)" ]
  #[ test ]
  fn mre_bug295_apply_refresh_trace_reason_not_owned()
  {
    let store  = TempDir::new().unwrap();
    let cached = claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None };
    let mut accounts = vec![
      AccountQuota
      {
        name                  : "alice@remote.com".to_string(),
        is_current            : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms         : FAR_FUTURE_MS,
        result                : Ok( cached ),
        account               : None,
        host                  : String::new(),
        role                  : String::new(),
        renewal_at            : None,
        cached                : true,
        cache_age_secs        : Some( 120 ),
        is_owned              : false,
        owner                 : "other@remote".to_string(),
      },
    ];

    use std::io::Read;
    let _lock = crate::usage::test_support::STDERR_LOCK.lock().unwrap_or_else( std::sync::PoisonError::into_inner );
    let mut buf = gag::BufferRedirect::stderr().unwrap();
    apply_refresh( &mut accounts, store.path(), None, true, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    let mut output = String::new();
    buf.read_to_string( &mut output ).unwrap();

    assert!(
      output.contains( "reason: not owned" ),
      "BUG-295: trace must emit 'reason: not owned' for non-owned account; got: {output}",
    );
    assert!(
      !output.contains( "reason: ok" ),
      "BUG-295: trace must NOT emit 'reason: ok' for non-owned account (misleading); got: {output}",
    );
  }

  // ── BUG-297 MRE: refresh None must set aq.result=Err ─────────────────────────

  /// # MRE: BUG-297 — `apply_refresh` None branch leaves `aq.result=Ok(cached_data)`, causing
  /// redundant `apply_touch` subprocess for RT-expired accounts.
  ///
  /// # Root Cause
  /// `refresh.rs:70-77` — the `else { continue; }` branch fires when `refresh_account_token`
  /// returns `None` (OAuth refresh token expired). Before the fix, it continued without mutating
  /// `aq.result`. Result stayed `Ok(cached_data)` — identical to a live healthy fetch — so
  /// `apply_touch` at `touch.rs:56` saw `Ok` and fired a redundant ~1.7s subprocess.
  ///
  /// # Why Not Caught
  /// No test covered the `should_refresh() → refresh_account_token() = None` path. The
  /// `else { continue; }` branch was only reachable with an unrecoverable RT expiry, which no
  /// unit test constructed. CI only exercised the happy path (Some(creds)) and the no-retry path
  /// (`should_refresh=false`).
  ///
  /// # Fix Applied
  /// Fix(BUG-297): added `aq.result = Err("refresh token expired".into());` before `continue;`
  /// in the None branch. Phase-contract invariant restored: every `continue` path in
  /// `apply_refresh` must leave `aq.result=Err` when the account cannot proceed.
  ///
  /// # Prevention
  /// This test uses an empty `TempDir` as `credential_store`, so `refresh_account_token` returns
  /// `None` (no credential file). `should_refresh` fires because `cached=true` and
  /// `expires_at_ms=0` triggers the BUG-255 guard. After `apply_refresh`, asserts
  /// `aq.result = Err("refresh token expired")`.
  ///
  /// # Pitfall
  /// The empty `TempDir` means no credential file exists for the account, so
  /// `read_token(credential_store, &aq.name)` returns `Err`, which makes
  /// `refresh_account_token` return `None` immediately — no subprocess is spawned.
  /// `is_owned=true` is required: non-owned accounts are skipped by `should_refresh`.
  #[ doc = "bug_reproducer(BUG-297)" ]
  #[ test ]
  fn mre_bug297_refresh_none_sets_aq_result_err()
  {
    let store       = TempDir::new().unwrap();
    let stale_quota = claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None };
    let mut accounts = vec![
      AccountQuota
      {
        name                  : "rt-expired-acct".to_string(),
        is_current            : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms         : 0, // locally expired → should_refresh fires (BUG-255 guard)
        result                : Ok( stale_quota ),
        account               : None,
        host                  : String::new(),
        role                  : String::new(),
        renewal_at            : None,
        cached                : true, // cache masking active — result is Ok(stale)
        cache_age_secs        : Some( 7200 ),
        is_owned              : true, // required: non-owned accounts are skipped by should_refresh
        owner                 : String::new(),
      },
    ];

    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );

    assert!(
      matches!( accounts[ 0 ].result, Err( ref e ) if e.contains( "refresh token expired" ) ),
      "BUG-297: when refresh_account_token returns None, aq.result must be \
       Err(\"refresh token expired\"); got: {:?}",
      accounts[ 0 ].result,
    );
  }

  // ── BUG-297 pipeline: refresh None → touch skips (no subprocess) ─────────────

  /// Pipeline integration test for BUG-297 fix: after `apply_refresh` sets
  /// `aq.result = Err("refresh token expired")`, `apply_touch` must skip the account
  /// emitting `"skipped (reason: error account)"` and must NOT spawn a subprocess.
  ///
  /// # Root Cause
  /// Before the BUG-297 fix, `apply_refresh` left `aq.result=Ok(cached_data)` when
  /// `refresh_account_token` returned `None` (RT expired). `apply_touch` at `touch.rs:56`
  /// guards on `let Ok(ref data) = aq.result` — seeing `Ok`, it fired a redundant
  /// ~1.7s subprocess that also returned `None` with no useful effect.
  ///
  /// # Why Not Caught
  /// No test exercised the `apply_refresh → apply_touch` pipeline with a None-return path.
  /// The two phases were tested independently; the cross-phase contract (every `continue`
  /// path in `apply_refresh` must leave `aq.result=Err`) was untested.
  ///
  /// # Fix Applied
  /// Fix(BUG-297): `refresh.rs:76-81` now sets `aq.result = Err("refresh token expired")`
  /// before `continue;`. `apply_touch` at `touch.rs:56` sees `Err` and emits
  /// `"skipped (reason: error account)"` without attempting any subprocess.
  ///
  /// # Prevention
  /// This test runs both `apply_refresh` and then `apply_touch` on the same `AccountQuota`
  /// and asserts: (1) touch emits `"error account"` skip trace; (2) touch does NOT emit
  /// `"run_isolated: invoking"` (no subprocess spawned).
  ///
  /// # Pitfall
  /// `claude_paths=None` ensures no subprocess can fire regardless (no credential file path
  /// to switch to). The test confirms the *correct* skip reason fires (`"error account"` from
  /// the G1 error guard), not a later guard that would also prevent subprocess launch.
  #[ test ]
  fn apply_touch_skips_after_refresh_none()
  {
    use std::io::Read;

    let store       = TempDir::new().unwrap();
    let stale_quota = claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None };
    let mut accounts = vec![
      AccountQuota
      {
        name                  : "rt-expired-touch-pipeline".to_string(),
        is_current            : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms         : 0,
        result                : Ok( stale_quota ),
        account               : None,
        host                  : String::new(),
        role                  : String::new(),
        renewal_at            : None,
        cached                : true,
        cache_age_secs        : Some( 7200 ),
        is_owned              : true,
        owner                 : String::new(),
      },
    ];

    // Phase 1: apply_refresh → sets aq.result = Err("refresh token expired").
    apply_refresh( &mut accounts, store.path(), None, false, SubprocessModel::Auto, SubprocessEffort::Auto, false );

    assert!(
      accounts[ 0 ].result.is_err(),
      "pipeline precondition: apply_refresh must set Err result; got: {:?}",
      accounts[ 0 ].result,
    );

    // Phase 2: apply_touch → must see Err and skip without spawning a subprocess.
    let _lock = crate::usage::test_support::STDERR_LOCK.lock().unwrap_or_else( std::sync::PoisonError::into_inner );
    let mut buf = gag::BufferRedirect::stderr().expect( "stderr capture failed" );

    super::super::touch::apply_touch(
      &mut accounts[ 0 ],
      store.path(),
      None,
      true,
      SubprocessModel::Auto,
      SubprocessEffort::Auto,
      false,
    );

    let mut captured = String::new();
    buf.read_to_string( &mut captured ).unwrap();

    assert!(
      captured.contains( "error account" ),
      "BUG-297 pipeline: apply_touch must emit 'error account' skip after refresh None; got:\n{captured}",
    );
    assert!(
      !captured.contains( "run_isolated: invoking" ),
      "BUG-297 pipeline: apply_touch must NOT spawn subprocess after refresh None; got:\n{captured}",
    );
  }

  // ── BUG-298 MRE: owned+cached trace reason must be "cached-expired", not "ok" ──

  /// MRE for BUG-298: `apply_refresh` emits `reason: cached-expired` (not `reason: ok`) when
  /// `aq.is_owned == true`, `aq.cached == true`, and `aq.result` is `Ok(cached_data)`.
  ///
  /// # Root Cause
  /// `fetch.rs:229-240` cache fallback converts `Err→Ok` and sets `aq.cached = true`. The
  /// original trace reason expression `aq.result.as_ref().err().map_or("ok", …)` calls `.err()`
  /// on the now-`Ok` result — returning `None` — and produces the constant label `"ok"`. This is
  /// misleading: the actual trigger is the BUG-255 guard (`aq.cached && expired`), not a healthy
  /// fetch. A developer reading `reason: ok` cannot determine why refresh was attempted.
  ///
  /// # Why Not Caught
  /// BUG-295 fixed the `!aq.is_owned` branch of the same expression but reviewed it in isolation.
  /// The `aq.is_owned && aq.cached` case was not in scope for that fix and had no covering test.
  /// BUG-255 added the cached+expired predicate without auditing downstream trace labels.
  ///
  /// # Fix Applied
  /// Fix(BUG-298): `else if aq.cached { "cached-expired" }` branch added before the
  /// `aq.result.err()` expression in the trace reason computation at `refresh.rs`.
  ///
  /// # Prevention
  /// This test captures stderr from `apply_refresh(trace=true)` for an owned+cached+expired
  /// account. Asserts `reason: cached-expired` present; `reason: ok` absent.
  ///
  /// # Pitfall
  /// Hold `STDERR_LOCK` before `gag::BufferRedirect::stderr()` — concurrent gag captures corrupt
  /// each other via the shared fd 2. Any trigger path that converts Err→Ok (cache, synthetic
  /// data injection) must add its own reason branch before `aq.result.err()` at the trace site.
  #[ doc = "bug_reproducer(BUG-298)" ]
  #[ test ]
  fn mre_bug298_apply_refresh_trace_reason_cached_expired()
  {
    let store       = TempDir::new().unwrap();
    let stale_quota = claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None };
    let mut accounts = vec![
      AccountQuota
      {
        name                  : "cached-owned@box.pro".to_string(),
        is_current            : false,
        is_active             : false,
        is_occupied_elsewhere : false,
        expires_at_ms         : 0, // expired → BUG-255 guard fires → should_refresh=true
        result                : Ok( stale_quota ), // cache fallback converted Err→Ok
        account               : None,
        host                  : String::new(),
        role                  : String::new(),
        renewal_at            : None,
        cached                : true,  // cache masking active
        cache_age_secs        : Some( 7200 ),
        is_owned              : true,  // required: non-owned skips with "not owned"
        owner                 : String::new(),
      },
    ];

    use std::io::Read;
    let _lock = crate::usage::test_support::STDERR_LOCK.lock().unwrap_or_else( std::sync::PoisonError::into_inner );
    let mut buf = gag::BufferRedirect::stderr().unwrap();
    apply_refresh( &mut accounts, store.path(), None, true, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    let mut output = String::new();
    buf.read_to_string( &mut output ).unwrap();

    assert!(
      output.contains( "reason: cached-expired" ),
      "BUG-298: trace must emit 'reason: cached-expired' for owned+cached+expired account; got: {output}",
    );
    assert!(
      !output.contains( "reason: ok" ),
      "BUG-298: trace must NOT emit 'reason: ok' for owned+cached account (misleading); got: {output}",
    );
  }

  /// EC-7 (061): `apply_refresh` solo gate — non-current owned account is skipped with
  /// `[trace] refresh  {name}  solo-skip` when `solo=true`.
  ///
  /// With `solo=true`, the solo gate fires before G2 (non-owned check) for any account
  /// where `aq.is_current=false`. The account here is `is_owned=true` — without the solo
  /// gate it would proceed to the `should_refresh` check. With `solo=true` it is skipped
  /// immediately and the trace confirms the reason.
  ///
  /// Spec: [`tests/docs/cli/param/61_solo.md` EC-7]
  #[ test ]
  fn ec7_solo_gate_skips_non_current_with_trace()
  {
    use std::io::Read;

    let store = TempDir::new().unwrap();
    let mut accounts = vec![ AccountQuota
    {
      name                  : "noncurrent@example.com".to_string(),
      is_current            : false,
      is_active             : false,
      is_occupied_elsewhere : false,
      expires_at_ms         : FAR_FUTURE_MS,
      result                : Ok( claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None } ),
      account               : None,
      host                  : String::new(),
      role                  : String::new(),
      renewal_at            : None,
      cached                : false,
      cache_age_secs        : None,
      is_owned              : true,
      owner                 : String::new(),
    } ];

    let _lock = crate::usage::test_support::STDERR_LOCK.lock().unwrap_or_else( std::sync::PoisonError::into_inner );
    let mut buf = gag::BufferRedirect::stderr().unwrap();
    apply_refresh( &mut accounts, store.path(), None, true, SubprocessModel::Auto, SubprocessEffort::Auto, true );
    let mut output = String::new();
    buf.read_to_string( &mut output ).unwrap();

    assert!(
      output.contains( "solo-skip" ),
      "EC-7: solo gate must emit 'solo-skip' trace for non-current account; got: {output}",
    );
    assert!(
      output.contains( "noncurrent@example.com" ),
      "EC-7: trace must name the skipped account; got: {output}",
    );
  }

  // ── GAP-20: trace reason "ok" for owned+non-cached+Ok ────────────────────────

  /// GAP-20 — `apply_refresh` trace reason is `"ok"` for owned, non-cached, `Ok` account.
  ///
  /// Path: `!is_owned` = false → `cached` = false → `result.err()` = None → `map_or("ok", …)` = `"ok"`.
  /// `should_refresh` returns `false` for this account (no auth error, not cached-expired),
  /// so the trace line `should_retry=false (reason: ok)` is emitted and the account is skipped.
  ///
  /// This test documents the CORRECT and EXPECTED behaviour, distinguishing the healthy
  /// non-retry path from the misleading-label bugs fixed in BUG-295 (non-owned) and
  /// BUG-298 (owned+cached).
  #[ test ]
  fn mre_bug_gap20_refresh_trace_reason_ok_owned_non_cached_ok()
  {
    use std::io::Read;

    let store    = TempDir::new().unwrap();
    let ok_quota = claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None };
    let mut accounts = vec![ AccountQuota
    {
      name                  : "healthy@example.com".to_string(),
      is_current            : false,
      is_active             : false,
      is_occupied_elsewhere : false,
      expires_at_ms         : FAR_FUTURE_MS,  // valid token → no expiry trigger
      result                : Ok( ok_quota ),
      account               : None,
      host                  : String::new(),
      role                  : String::new(),
      renewal_at            : None,
      cached                : false,  // non-cached → BUG-255 guard does not fire
      cache_age_secs        : None,
      is_owned              : true,   // owned → not "not owned"
      owner                 : String::new(),
    } ];

    let _lock = crate::usage::test_support::STDERR_LOCK.lock().unwrap_or_else( std::sync::PoisonError::into_inner );
    let mut buf = gag::BufferRedirect::stderr().unwrap();
    apply_refresh( &mut accounts, store.path(), None, true, SubprocessModel::Auto, SubprocessEffort::Auto, false );
    let mut output = String::new();
    buf.read_to_string( &mut output ).unwrap();

    assert!(
      output.contains( "reason: ok" ),
      "GAP-20: owned+non-cached+Ok account must emit 'reason: ok' trace; got: {output}",
    );
    assert!(
      !output.contains( "reason: not owned" ) && !output.contains( "reason: cached-expired" ),
      "GAP-20: must not emit non-owned or cached-expired reason for this path; got: {output}",
    );
  }

  // ── BUG-306 reproducer ──────────────────────────────────────────────────

  /// MRE — `reason_label` returns `"occupied elsewhere"` for owned, non-cached,
  /// occupied-elsewhere account with Ok result.
  ///
  /// # Root Cause
  /// The inline trace reason block at `refresh.rs:72-83` had three branches:
  /// `!is_owned` → `"not owned"`, `aq.cached` → `"cached-expired"`, else →
  /// `aq.result.err().map_or("ok", ...)`. An owned, non-cached, occupied-elsewhere
  /// account fell through to the else arm and showed `reason: ok` — actively
  /// misleading because the account was skipped by the G2 predicate gate.
  ///
  /// # Why Not Caught
  /// No test exercised the trace-reason path for the occupied-elsewhere predicate;
  /// all existing tests covered not-owned, cached-expired, and genuine-ok branches.
  ///
  /// # Fix Applied
  /// Extracted the inline block into `fn reason_label(aq: &AccountQuota) -> &'static str`
  /// with a new `else if aq.is_occupied_elsewhere { "occupied elsewhere" }` branch
  /// after `aq.cached`. Enforces predicate–reason 1:1 contract.
  ///
  /// # Prevention
  /// `reason_label` is a named function directly testable by unit test; future
  /// predicate additions must add a corresponding branch or this test class will
  /// expose the gap.
  ///
  /// # Pitfall
  /// Branch order matters: `is_occupied_elsewhere` must come after `cached` because
  /// cached accounts have their own trace reason regardless of occupancy status.
  #[ doc = "bug_reproducer(BUG-306)" ]
  #[ test ]
  fn mre_bug306_refresh_trace_reason_occupied_elsewhere()
  {
    let aq = AccountQuota
    {
      name                  : "occ@example.com".to_string(),
      is_current            : false,
      is_active             : false,
      is_occupied_elsewhere : true,
      expires_at_ms         : FAR_FUTURE_MS,
      result                : Ok( claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None } ),
      account               : None,
      host                  : String::new(),
      role                  : String::new(),
      renewal_at            : None,
      cached                : false,
      cache_age_secs        : None,
      is_owned              : true,
      owner                 : String::new(),
    };
    assert_eq!( super::reason_label( &aq ), "occupied elsewhere" );
  }

  /// Regression — `reason_label` returns `"not owned"` for non-owned account.
  #[ test ]
  fn reason_label_not_owned()
  {
    let aq = AccountQuota
    {
      name : "x".into(), is_current : false, is_active : false,
      is_occupied_elsewhere : false, expires_at_ms : 0,
      result : Ok( claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None } ),
      account : None, host : String::new(), role : String::new(),
      renewal_at : None, cached : false, cache_age_secs : None,
      is_owned : false, owner : String::new(),
    };
    assert_eq!( super::reason_label( &aq ), "not owned" );
  }

  /// Regression — `reason_label` returns `"cached-expired"` for owned+cached account.
  #[ test ]
  fn reason_label_cached_expired()
  {
    let aq = AccountQuota
    {
      name : "x".into(), is_current : false, is_active : false,
      is_occupied_elsewhere : false, expires_at_ms : 0,
      result : Ok( claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None } ),
      account : None, host : String::new(), role : String::new(),
      renewal_at : None, cached : true, cache_age_secs : Some( 999 ),
      is_owned : true, owner : String::new(),
    };
    assert_eq!( super::reason_label( &aq ), "cached-expired" );
  }

  /// Regression — `reason_label` returns `"ok"` for owned+non-cached+Ok account.
  #[ test ]
  fn reason_label_ok()
  {
    let aq = AccountQuota
    {
      name : "x".into(), is_current : false, is_active : false,
      is_occupied_elsewhere : false, expires_at_ms : 0,
      result : Ok( claude_quota::OauthUsageData { five_hour : None, seven_day : None, seven_day_sonnet : None } ),
      account : None, host : String::new(), role : String::new(),
      renewal_at : None, cached : false, cache_age_secs : None,
      is_owned : true, owner : String::new(),
    };
    assert_eq!( super::reason_label( &aq ), "ok" );
  }

  /// Regression — `reason_label` returns error string for owned+non-cached+Err account.
  #[ test ]
  fn reason_label_err()
  {
    let aq = AccountQuota
    {
      name : "x".into(), is_current : false, is_active : false,
      is_occupied_elsewhere : false, expires_at_ms : 0,
      result : Err( "HTTP 401 Unauthorized".to_string() ),
      account : None, host : String::new(), role : String::new(),
      renewal_at : None, cached : false, cache_age_secs : None,
      is_owned : true, owner : String::new(),
    };
    assert_eq!( super::reason_label( &aq ), "HTTP 401 Unauthorized" );
  }