npm-cli-gh-issue-preparator 1.0.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,841 @@
1
+ const mockExistsSync = jest.fn();
2
+ const mockReadFileSync = jest.fn();
3
+ const mockReaddirSync = jest.fn();
4
+ const mockCopyFileSync = jest.fn();
5
+ const mockHomedir = jest.fn();
6
+
7
+ jest.mock('fs', () => ({
8
+ existsSync: mockExistsSync,
9
+ readFileSync: mockReadFileSync,
10
+ readdirSync: mockReaddirSync,
11
+ copyFileSync: mockCopyFileSync,
12
+ }));
13
+
14
+ jest.mock('os', () => ({
15
+ homedir: mockHomedir,
16
+ }));
17
+
18
+ import { OauthAPIClaudeRepository } from './OauthAPIClaudeRepository';
19
+ import * as path from 'path';
20
+
21
+ describe('OauthAPIClaudeRepository', () => {
22
+ let repository: OauthAPIClaudeRepository;
23
+ let mockFetch: jest.Mock;
24
+
25
+ beforeEach(() => {
26
+ mockHomedir.mockReturnValue('/home/testuser');
27
+ mockFetch = jest.fn();
28
+ global.fetch = mockFetch;
29
+ jest.clearAllMocks();
30
+ });
31
+
32
+ const credentialsPath = path.join(
33
+ '/home/testuser',
34
+ '.claude',
35
+ '.credentials.json',
36
+ );
37
+
38
+ describe('getUsage', () => {
39
+ it('should fetch usage data from Claude API', async () => {
40
+ mockExistsSync.mockReturnValue(true);
41
+ mockReadFileSync.mockReturnValue(
42
+ JSON.stringify({
43
+ claudeAiOauth: {
44
+ accessToken: 'test-access-token',
45
+ },
46
+ }),
47
+ );
48
+
49
+ mockFetch.mockResolvedValueOnce({
50
+ ok: true,
51
+ json: jest.fn().mockResolvedValue({
52
+ five_hour: {
53
+ utilization: 25.5,
54
+ resets_at: '2026-01-12T10:00:00Z',
55
+ },
56
+ seven_day: {
57
+ utilization: 50.0,
58
+ resets_at: '2026-01-15T00:00:00Z',
59
+ },
60
+ }),
61
+ });
62
+
63
+ repository = new OauthAPIClaudeRepository();
64
+ const usages = await repository.getUsage();
65
+
66
+ expect(usages).toHaveLength(2);
67
+ expect(usages[0]).toEqual({
68
+ hour: 5,
69
+ utilizationPercentage: 25.5,
70
+ resetsAt: new Date('2026-01-12T10:00:00Z'),
71
+ });
72
+ expect(usages[1]).toEqual({
73
+ hour: 168,
74
+ utilizationPercentage: 50.0,
75
+ resetsAt: new Date('2026-01-15T00:00:00Z'),
76
+ });
77
+
78
+ expect(mockFetch).toHaveBeenCalledWith(
79
+ 'https://api.anthropic.com/api/oauth/usage',
80
+ expect.objectContaining({
81
+ method: 'GET',
82
+ headers: {
83
+ Accept: 'application/json, text/plain, */*',
84
+ 'Content-Type': 'application/json',
85
+ 'User-Agent': 'claude-code/2.0.32',
86
+ Authorization: 'Bearer test-access-token',
87
+ 'anthropic-beta': 'oauth-2025-04-20',
88
+ },
89
+ }),
90
+ );
91
+ });
92
+
93
+ it('should include opus and sonnet usage when available', async () => {
94
+ mockExistsSync.mockReturnValue(true);
95
+ mockReadFileSync.mockReturnValue(
96
+ JSON.stringify({
97
+ claudeAiOauth: {
98
+ accessToken: 'test-access-token',
99
+ },
100
+ }),
101
+ );
102
+
103
+ mockFetch.mockResolvedValueOnce({
104
+ ok: true,
105
+ json: jest.fn().mockResolvedValue({
106
+ five_hour: {
107
+ utilization: 10.0,
108
+ resets_at: '2026-01-12T10:00:00Z',
109
+ },
110
+ seven_day: {
111
+ utilization: 20.0,
112
+ resets_at: '2026-01-15T00:00:00Z',
113
+ },
114
+ seven_day_opus: {
115
+ utilization: 30.0,
116
+ resets_at: '2026-01-16T00:00:00Z',
117
+ },
118
+ seven_day_sonnet: {
119
+ utilization: 40.0,
120
+ resets_at: '2026-01-17T00:00:00Z',
121
+ },
122
+ }),
123
+ });
124
+
125
+ repository = new OauthAPIClaudeRepository();
126
+ const usages = await repository.getUsage();
127
+
128
+ expect(usages).toHaveLength(4);
129
+ expect(usages[2]).toEqual({
130
+ hour: 168,
131
+ utilizationPercentage: 30.0,
132
+ resetsAt: new Date('2026-01-16T00:00:00Z'),
133
+ });
134
+ expect(usages[3]).toEqual({
135
+ hour: 168,
136
+ utilizationPercentage: 40.0,
137
+ resetsAt: new Date('2026-01-17T00:00:00Z'),
138
+ });
139
+ });
140
+
141
+ it('should throw error when credentials file not found', async () => {
142
+ mockExistsSync.mockReturnValue(false);
143
+
144
+ repository = new OauthAPIClaudeRepository();
145
+
146
+ await expect(repository.getUsage()).rejects.toThrow(
147
+ `Claude credentials file not found at ${credentialsPath}`,
148
+ );
149
+ });
150
+
151
+ it('should throw error when access token is missing', async () => {
152
+ mockExistsSync.mockReturnValue(true);
153
+ mockReadFileSync.mockReturnValue(
154
+ JSON.stringify({
155
+ claudeAiOauth: {},
156
+ }),
157
+ );
158
+
159
+ repository = new OauthAPIClaudeRepository();
160
+
161
+ await expect(repository.getUsage()).rejects.toThrow(
162
+ 'No access token found in credentials file',
163
+ );
164
+ });
165
+
166
+ it('should throw error when credentials file has invalid format', async () => {
167
+ mockExistsSync.mockReturnValue(true);
168
+ mockReadFileSync.mockReturnValue('null');
169
+
170
+ repository = new OauthAPIClaudeRepository();
171
+
172
+ await expect(repository.getUsage()).rejects.toThrow(
173
+ 'Invalid credentials file format',
174
+ );
175
+ });
176
+
177
+ it('should throw error when API response is not ok', async () => {
178
+ mockExistsSync.mockReturnValue(true);
179
+ mockReadFileSync.mockReturnValue(
180
+ JSON.stringify({
181
+ claudeAiOauth: {
182
+ accessToken: 'test-access-token',
183
+ },
184
+ }),
185
+ );
186
+
187
+ mockFetch.mockResolvedValueOnce({
188
+ ok: false,
189
+ text: jest.fn().mockResolvedValue('API Error'),
190
+ });
191
+
192
+ repository = new OauthAPIClaudeRepository();
193
+
194
+ await expect(repository.getUsage()).rejects.toThrow(
195
+ 'Claude API error: API Error',
196
+ );
197
+ });
198
+
199
+ it('should throw error when API returns error in response', async () => {
200
+ mockExistsSync.mockReturnValue(true);
201
+ mockReadFileSync.mockReturnValue(
202
+ JSON.stringify({
203
+ claudeAiOauth: {
204
+ accessToken: 'test-access-token',
205
+ },
206
+ }),
207
+ );
208
+
209
+ mockFetch.mockResolvedValueOnce({
210
+ ok: true,
211
+ json: jest.fn().mockResolvedValue({
212
+ error: 'Invalid token',
213
+ }),
214
+ });
215
+
216
+ repository = new OauthAPIClaudeRepository();
217
+
218
+ await expect(repository.getUsage()).rejects.toThrow(
219
+ 'API error: Invalid token',
220
+ );
221
+ });
222
+
223
+ it('should throw error when API response format is invalid', async () => {
224
+ mockExistsSync.mockReturnValue(true);
225
+ mockReadFileSync.mockReturnValue(
226
+ JSON.stringify({
227
+ claudeAiOauth: {
228
+ accessToken: 'test-access-token',
229
+ },
230
+ }),
231
+ );
232
+
233
+ mockFetch.mockResolvedValueOnce({
234
+ ok: true,
235
+ json: jest.fn().mockResolvedValue(null),
236
+ });
237
+
238
+ repository = new OauthAPIClaudeRepository();
239
+
240
+ await expect(repository.getUsage()).rejects.toThrow(
241
+ 'Invalid API response format',
242
+ );
243
+ });
244
+
245
+ it('should return empty array when no usage data available', async () => {
246
+ mockExistsSync.mockReturnValue(true);
247
+ mockReadFileSync.mockReturnValue(
248
+ JSON.stringify({
249
+ claudeAiOauth: {
250
+ accessToken: 'test-access-token',
251
+ },
252
+ }),
253
+ );
254
+
255
+ mockFetch.mockResolvedValueOnce({
256
+ ok: true,
257
+ json: jest.fn().mockResolvedValue({}),
258
+ });
259
+
260
+ repository = new OauthAPIClaudeRepository();
261
+ const usages = await repository.getUsage();
262
+
263
+ expect(usages).toHaveLength(0);
264
+ });
265
+
266
+ it('should handle missing resets_at by using current date', async () => {
267
+ mockExistsSync.mockReturnValue(true);
268
+ mockReadFileSync.mockReturnValue(
269
+ JSON.stringify({
270
+ claudeAiOauth: {
271
+ accessToken: 'test-access-token',
272
+ },
273
+ }),
274
+ );
275
+
276
+ const beforeTest = new Date();
277
+
278
+ mockFetch.mockResolvedValueOnce({
279
+ ok: true,
280
+ json: jest.fn().mockResolvedValue({
281
+ five_hour: {
282
+ utilization: 25.5,
283
+ },
284
+ }),
285
+ });
286
+
287
+ repository = new OauthAPIClaudeRepository();
288
+ const usages = await repository.getUsage();
289
+
290
+ const afterTest = new Date();
291
+
292
+ expect(usages).toHaveLength(1);
293
+ expect(usages[0].hour).toBe(5);
294
+ expect(usages[0].utilizationPercentage).toBe(25.5);
295
+ expect(usages[0].resetsAt.getTime()).toBeGreaterThanOrEqual(
296
+ beforeTest.getTime(),
297
+ );
298
+ expect(usages[0].resetsAt.getTime()).toBeLessThanOrEqual(
299
+ afterTest.getTime(),
300
+ );
301
+ });
302
+
303
+ it('should handle missing resets_at for all window types', async () => {
304
+ mockExistsSync.mockReturnValue(true);
305
+ mockReadFileSync.mockReturnValue(
306
+ JSON.stringify({
307
+ claudeAiOauth: {
308
+ accessToken: 'test-access-token',
309
+ },
310
+ }),
311
+ );
312
+
313
+ const beforeTest = new Date();
314
+
315
+ mockFetch.mockResolvedValueOnce({
316
+ ok: true,
317
+ json: jest.fn().mockResolvedValue({
318
+ five_hour: {
319
+ utilization: 10.0,
320
+ },
321
+ seven_day: {
322
+ utilization: 20.0,
323
+ },
324
+ seven_day_opus: {
325
+ utilization: 30.0,
326
+ },
327
+ seven_day_sonnet: {
328
+ utilization: 40.0,
329
+ },
330
+ }),
331
+ });
332
+
333
+ repository = new OauthAPIClaudeRepository();
334
+ const usages = await repository.getUsage();
335
+
336
+ const afterTest = new Date();
337
+
338
+ expect(usages).toHaveLength(4);
339
+
340
+ for (const usage of usages) {
341
+ expect(usage.resetsAt.getTime()).toBeGreaterThanOrEqual(
342
+ beforeTest.getTime(),
343
+ );
344
+ expect(usage.resetsAt.getTime()).toBeLessThanOrEqual(
345
+ afterTest.getTime(),
346
+ );
347
+ }
348
+ });
349
+ });
350
+
351
+ describe('isClaudeAvailable', () => {
352
+ const claudeDir = path.join('/home/testuser', '.claude');
353
+
354
+ it('should return false when claude directory does not exist', async () => {
355
+ mockExistsSync.mockReturnValue(false);
356
+
357
+ repository = new OauthAPIClaudeRepository();
358
+ const result = await repository.isClaudeAvailable(80);
359
+
360
+ expect(result).toBe(false);
361
+ });
362
+
363
+ it('should return false when no credential files exist', async () => {
364
+ mockExistsSync.mockReturnValue(true);
365
+ mockReaddirSync.mockReturnValue(['.credentials.json']);
366
+
367
+ repository = new OauthAPIClaudeRepository();
368
+ const result = await repository.isClaudeAvailable(80);
369
+
370
+ expect(result).toBe(false);
371
+ });
372
+
373
+ it('should return true and copy credential file when usage is under threshold', async () => {
374
+ mockExistsSync.mockReturnValue(true);
375
+ mockReaddirSync.mockReturnValue([
376
+ '.credentials.json',
377
+ '.credentials.json.dev1.1',
378
+ ]);
379
+ mockReadFileSync.mockReturnValue(
380
+ JSON.stringify({
381
+ claudeAiOauth: {
382
+ accessToken: 'test-token-dev1',
383
+ },
384
+ }),
385
+ );
386
+
387
+ mockFetch.mockResolvedValueOnce({
388
+ ok: true,
389
+ json: jest.fn().mockResolvedValue({
390
+ five_hour: {
391
+ utilization: 50.0,
392
+ resets_at: '2026-01-12T10:00:00Z',
393
+ },
394
+ }),
395
+ });
396
+
397
+ repository = new OauthAPIClaudeRepository();
398
+ const result = await repository.isClaudeAvailable(80);
399
+
400
+ expect(result).toBe(true);
401
+ expect(mockCopyFileSync).toHaveBeenCalledWith(
402
+ path.join(claudeDir, '.credentials.json.dev1.1'),
403
+ path.join(claudeDir, '.credentials.json'),
404
+ );
405
+ });
406
+
407
+ it('should return false when all credentials are over threshold', async () => {
408
+ mockExistsSync.mockReturnValue(true);
409
+ mockReaddirSync.mockReturnValue([
410
+ '.credentials.json',
411
+ '.credentials.json.dev1.1',
412
+ ]);
413
+ mockReadFileSync.mockReturnValue(
414
+ JSON.stringify({
415
+ claudeAiOauth: {
416
+ accessToken: 'test-token-dev1',
417
+ },
418
+ }),
419
+ );
420
+
421
+ mockFetch.mockResolvedValueOnce({
422
+ ok: true,
423
+ json: jest.fn().mockResolvedValue({
424
+ five_hour: {
425
+ utilization: 90.0,
426
+ resets_at: '2026-01-12T10:00:00Z',
427
+ },
428
+ }),
429
+ });
430
+
431
+ repository = new OauthAPIClaudeRepository();
432
+ const result = await repository.isClaudeAvailable(80);
433
+
434
+ expect(result).toBe(false);
435
+ expect(mockCopyFileSync).not.toHaveBeenCalled();
436
+ });
437
+
438
+ it('should sort credentials by priority and try lower priority first', async () => {
439
+ mockExistsSync.mockReturnValue(true);
440
+ mockReaddirSync.mockReturnValue([
441
+ '.credentials.json',
442
+ '.credentials.json.dev2.5',
443
+ '.credentials.json.dev1.1',
444
+ ]);
445
+
446
+ const credentialContents: Record<string, string> = {
447
+ [path.join(claudeDir, '.credentials.json.dev1.1')]: JSON.stringify({
448
+ claudeAiOauth: { accessToken: 'token-dev1' },
449
+ }),
450
+ [path.join(claudeDir, '.credentials.json.dev2.5')]: JSON.stringify({
451
+ claudeAiOauth: { accessToken: 'token-dev2' },
452
+ }),
453
+ };
454
+
455
+ mockReadFileSync.mockImplementation((filePath: string) => {
456
+ return credentialContents[filePath] || '';
457
+ });
458
+
459
+ mockFetch.mockResolvedValueOnce({
460
+ ok: true,
461
+ json: jest.fn().mockResolvedValue({
462
+ five_hour: { utilization: 30.0 },
463
+ }),
464
+ });
465
+
466
+ repository = new OauthAPIClaudeRepository();
467
+ const result = await repository.isClaudeAvailable(80);
468
+
469
+ expect(result).toBe(true);
470
+ expect(mockFetch).toHaveBeenCalledWith(
471
+ 'https://api.anthropic.com/api/oauth/usage',
472
+ expect.objectContaining({
473
+ method: 'GET',
474
+ headers: {
475
+ Accept: 'application/json, text/plain, */*',
476
+ 'Content-Type': 'application/json',
477
+ 'User-Agent': 'claude-code/2.0.32',
478
+ Authorization: 'Bearer token-dev1',
479
+ 'anthropic-beta': 'oauth-2025-04-20',
480
+ },
481
+ }),
482
+ );
483
+ expect(mockCopyFileSync).toHaveBeenCalledWith(
484
+ path.join(claudeDir, '.credentials.json.dev1.1'),
485
+ path.join(claudeDir, '.credentials.json'),
486
+ );
487
+ });
488
+
489
+ it('should skip to next credential when API call fails', async () => {
490
+ mockExistsSync.mockReturnValue(true);
491
+ mockReaddirSync.mockReturnValue([
492
+ '.credentials.json',
493
+ '.credentials.json.dev1.1',
494
+ '.credentials.json.dev2.2',
495
+ ]);
496
+
497
+ const credentialContents: Record<string, string> = {
498
+ [path.join(claudeDir, '.credentials.json.dev1.1')]: JSON.stringify({
499
+ claudeAiOauth: { accessToken: 'token-dev1' },
500
+ }),
501
+ [path.join(claudeDir, '.credentials.json.dev2.2')]: JSON.stringify({
502
+ claudeAiOauth: { accessToken: 'token-dev2' },
503
+ }),
504
+ };
505
+
506
+ mockReadFileSync.mockImplementation((filePath: string) => {
507
+ return credentialContents[filePath] || '';
508
+ });
509
+
510
+ mockFetch
511
+ .mockResolvedValueOnce({
512
+ ok: false,
513
+ text: jest.fn().mockResolvedValue('API Error'),
514
+ })
515
+ .mockResolvedValueOnce({
516
+ ok: true,
517
+ json: jest.fn().mockResolvedValue({
518
+ five_hour: { utilization: 30.0 },
519
+ }),
520
+ });
521
+
522
+ repository = new OauthAPIClaudeRepository();
523
+ const result = await repository.isClaudeAvailable(80);
524
+
525
+ expect(result).toBe(true);
526
+ expect(mockFetch).toHaveBeenCalledTimes(2);
527
+ expect(mockCopyFileSync).toHaveBeenCalledWith(
528
+ path.join(claudeDir, '.credentials.json.dev2.2'),
529
+ path.join(claudeDir, '.credentials.json'),
530
+ );
531
+ });
532
+
533
+ it('should skip credential files with invalid format', async () => {
534
+ mockExistsSync.mockReturnValue(true);
535
+ mockReaddirSync.mockReturnValue([
536
+ '.credentials.json',
537
+ '.credentials.json.invalid',
538
+ '.credentials.json.dev1.1',
539
+ ]);
540
+
541
+ mockReadFileSync.mockReturnValue(
542
+ JSON.stringify({
543
+ claudeAiOauth: { accessToken: 'token-dev1' },
544
+ }),
545
+ );
546
+
547
+ mockFetch.mockResolvedValueOnce({
548
+ ok: true,
549
+ json: jest.fn().mockResolvedValue({
550
+ five_hour: { utilization: 30.0 },
551
+ }),
552
+ });
553
+
554
+ repository = new OauthAPIClaudeRepository();
555
+ const result = await repository.isClaudeAvailable(80);
556
+
557
+ expect(result).toBe(true);
558
+ expect(mockCopyFileSync).toHaveBeenCalledWith(
559
+ path.join(claudeDir, '.credentials.json.dev1.1'),
560
+ path.join(claudeDir, '.credentials.json'),
561
+ );
562
+ });
563
+
564
+ it('should skip credential files without access token', async () => {
565
+ mockExistsSync.mockReturnValue(true);
566
+ mockReaddirSync.mockReturnValue([
567
+ '.credentials.json',
568
+ '.credentials.json.dev1.1',
569
+ '.credentials.json.dev2.2',
570
+ ]);
571
+
572
+ const credentialContents: Record<string, string> = {
573
+ [path.join(claudeDir, '.credentials.json.dev1.1')]: JSON.stringify({
574
+ claudeAiOauth: {},
575
+ }),
576
+ [path.join(claudeDir, '.credentials.json.dev2.2')]: JSON.stringify({
577
+ claudeAiOauth: { accessToken: 'token-dev2' },
578
+ }),
579
+ };
580
+
581
+ mockReadFileSync.mockImplementation((filePath: string) => {
582
+ return credentialContents[filePath] || '';
583
+ });
584
+
585
+ mockFetch.mockResolvedValueOnce({
586
+ ok: true,
587
+ json: jest.fn().mockResolvedValue({
588
+ five_hour: { utilization: 30.0 },
589
+ }),
590
+ });
591
+
592
+ repository = new OauthAPIClaudeRepository();
593
+ const result = await repository.isClaudeAvailable(80);
594
+
595
+ expect(result).toBe(true);
596
+ expect(mockFetch).toHaveBeenCalledTimes(1);
597
+ expect(mockCopyFileSync).toHaveBeenCalledWith(
598
+ path.join(claudeDir, '.credentials.json.dev2.2'),
599
+ path.join(claudeDir, '.credentials.json'),
600
+ );
601
+ });
602
+
603
+ it('should check all usage windows against threshold', async () => {
604
+ mockExistsSync.mockReturnValue(true);
605
+ mockReaddirSync.mockReturnValue([
606
+ '.credentials.json',
607
+ '.credentials.json.dev1.1',
608
+ ]);
609
+
610
+ mockReadFileSync.mockReturnValue(
611
+ JSON.stringify({
612
+ claudeAiOauth: { accessToken: 'token-dev1' },
613
+ }),
614
+ );
615
+
616
+ mockFetch.mockResolvedValueOnce({
617
+ ok: true,
618
+ json: jest.fn().mockResolvedValue({
619
+ five_hour: { utilization: 30.0 },
620
+ seven_day: { utilization: 90.0 },
621
+ }),
622
+ });
623
+
624
+ repository = new OauthAPIClaudeRepository();
625
+ const result = await repository.isClaudeAvailable(80);
626
+
627
+ expect(result).toBe(false);
628
+ expect(mockCopyFileSync).not.toHaveBeenCalled();
629
+ });
630
+
631
+ it('should return true when usage equals threshold minus 1', async () => {
632
+ mockExistsSync.mockReturnValue(true);
633
+ mockReaddirSync.mockReturnValue([
634
+ '.credentials.json',
635
+ '.credentials.json.dev1.1',
636
+ ]);
637
+
638
+ mockReadFileSync.mockReturnValue(
639
+ JSON.stringify({
640
+ claudeAiOauth: { accessToken: 'token-dev1' },
641
+ }),
642
+ );
643
+
644
+ mockFetch.mockResolvedValueOnce({
645
+ ok: true,
646
+ json: jest.fn().mockResolvedValue({
647
+ five_hour: { utilization: 79.0 },
648
+ }),
649
+ });
650
+
651
+ repository = new OauthAPIClaudeRepository();
652
+ const result = await repository.isClaudeAvailable(80);
653
+
654
+ expect(result).toBe(true);
655
+ });
656
+
657
+ it('should return false when usage equals threshold', async () => {
658
+ mockExistsSync.mockReturnValue(true);
659
+ mockReaddirSync.mockReturnValue([
660
+ '.credentials.json',
661
+ '.credentials.json.dev1.1',
662
+ ]);
663
+
664
+ mockReadFileSync.mockReturnValue(
665
+ JSON.stringify({
666
+ claudeAiOauth: { accessToken: 'token-dev1' },
667
+ }),
668
+ );
669
+
670
+ mockFetch.mockResolvedValueOnce({
671
+ ok: true,
672
+ json: jest.fn().mockResolvedValue({
673
+ five_hour: { utilization: 80.0 },
674
+ }),
675
+ });
676
+
677
+ repository = new OauthAPIClaudeRepository();
678
+ const result = await repository.isClaudeAvailable(80);
679
+
680
+ expect(result).toBe(false);
681
+ });
682
+
683
+ it('should skip credential files with non-numeric priority', async () => {
684
+ mockExistsSync.mockReturnValue(true);
685
+ mockReaddirSync.mockReturnValue([
686
+ '.credentials.json',
687
+ '.credentials.json.dev.abc',
688
+ '.credentials.json.dev1.1',
689
+ ]);
690
+
691
+ mockReadFileSync.mockReturnValue(
692
+ JSON.stringify({
693
+ claudeAiOauth: { accessToken: 'token-dev1' },
694
+ }),
695
+ );
696
+
697
+ mockFetch.mockResolvedValueOnce({
698
+ ok: true,
699
+ json: jest.fn().mockResolvedValue({
700
+ five_hour: { utilization: 30.0 },
701
+ }),
702
+ });
703
+
704
+ repository = new OauthAPIClaudeRepository();
705
+ const result = await repository.isClaudeAvailable(80);
706
+
707
+ expect(result).toBe(true);
708
+ expect(mockCopyFileSync).toHaveBeenCalledWith(
709
+ path.join(claudeDir, '.credentials.json.dev1.1'),
710
+ path.join(claudeDir, '.credentials.json'),
711
+ );
712
+ });
713
+
714
+ it('should skip to next credential when API returns invalid response format', async () => {
715
+ mockExistsSync.mockReturnValue(true);
716
+ mockReaddirSync.mockReturnValue([
717
+ '.credentials.json',
718
+ '.credentials.json.dev1.1',
719
+ '.credentials.json.dev2.2',
720
+ ]);
721
+
722
+ const credentialContents: Record<string, string> = {
723
+ [path.join(claudeDir, '.credentials.json.dev1.1')]: JSON.stringify({
724
+ claudeAiOauth: { accessToken: 'token-dev1' },
725
+ }),
726
+ [path.join(claudeDir, '.credentials.json.dev2.2')]: JSON.stringify({
727
+ claudeAiOauth: { accessToken: 'token-dev2' },
728
+ }),
729
+ };
730
+
731
+ mockReadFileSync.mockImplementation((filePath: string) => {
732
+ return credentialContents[filePath] || '';
733
+ });
734
+
735
+ mockFetch
736
+ .mockResolvedValueOnce({
737
+ ok: true,
738
+ json: jest.fn().mockResolvedValue(null),
739
+ })
740
+ .mockResolvedValueOnce({
741
+ ok: true,
742
+ json: jest.fn().mockResolvedValue({
743
+ five_hour: { utilization: 30.0 },
744
+ }),
745
+ });
746
+
747
+ repository = new OauthAPIClaudeRepository();
748
+ const result = await repository.isClaudeAvailable(80);
749
+
750
+ expect(result).toBe(true);
751
+ expect(mockFetch).toHaveBeenCalledTimes(2);
752
+ expect(mockCopyFileSync).toHaveBeenCalledWith(
753
+ path.join(claudeDir, '.credentials.json.dev2.2'),
754
+ path.join(claudeDir, '.credentials.json'),
755
+ );
756
+ });
757
+
758
+ it('should skip to next credential when API returns error in response', async () => {
759
+ mockExistsSync.mockReturnValue(true);
760
+ mockReaddirSync.mockReturnValue([
761
+ '.credentials.json',
762
+ '.credentials.json.dev1.1',
763
+ '.credentials.json.dev2.2',
764
+ ]);
765
+
766
+ const credentialContents: Record<string, string> = {
767
+ [path.join(claudeDir, '.credentials.json.dev1.1')]: JSON.stringify({
768
+ claudeAiOauth: { accessToken: 'token-dev1' },
769
+ }),
770
+ [path.join(claudeDir, '.credentials.json.dev2.2')]: JSON.stringify({
771
+ claudeAiOauth: { accessToken: 'token-dev2' },
772
+ }),
773
+ };
774
+
775
+ mockReadFileSync.mockImplementation((filePath: string) => {
776
+ return credentialContents[filePath] || '';
777
+ });
778
+
779
+ mockFetch
780
+ .mockResolvedValueOnce({
781
+ ok: true,
782
+ json: jest.fn().mockResolvedValue({
783
+ error: 'Token expired',
784
+ }),
785
+ })
786
+ .mockResolvedValueOnce({
787
+ ok: true,
788
+ json: jest.fn().mockResolvedValue({
789
+ five_hour: { utilization: 30.0 },
790
+ }),
791
+ });
792
+
793
+ repository = new OauthAPIClaudeRepository();
794
+ const result = await repository.isClaudeAvailable(80);
795
+
796
+ expect(result).toBe(true);
797
+ expect(mockFetch).toHaveBeenCalledTimes(2);
798
+ expect(mockCopyFileSync).toHaveBeenCalledWith(
799
+ path.join(claudeDir, '.credentials.json.dev2.2'),
800
+ path.join(claudeDir, '.credentials.json'),
801
+ );
802
+ });
803
+
804
+ it('should skip credential files with invalid JSON format', async () => {
805
+ mockExistsSync.mockReturnValue(true);
806
+ mockReaddirSync.mockReturnValue([
807
+ '.credentials.json',
808
+ '.credentials.json.dev1.1',
809
+ '.credentials.json.dev2.2',
810
+ ]);
811
+
812
+ const credentialContents: Record<string, string> = {
813
+ [path.join(claudeDir, '.credentials.json.dev1.1')]: 'null',
814
+ [path.join(claudeDir, '.credentials.json.dev2.2')]: JSON.stringify({
815
+ claudeAiOauth: { accessToken: 'token-dev2' },
816
+ }),
817
+ };
818
+
819
+ mockReadFileSync.mockImplementation((filePath: string) => {
820
+ return credentialContents[filePath] || '';
821
+ });
822
+
823
+ mockFetch.mockResolvedValueOnce({
824
+ ok: true,
825
+ json: jest.fn().mockResolvedValue({
826
+ five_hour: { utilization: 30.0 },
827
+ }),
828
+ });
829
+
830
+ repository = new OauthAPIClaudeRepository();
831
+ const result = await repository.isClaudeAvailable(80);
832
+
833
+ expect(result).toBe(true);
834
+ expect(mockFetch).toHaveBeenCalledTimes(1);
835
+ expect(mockCopyFileSync).toHaveBeenCalledWith(
836
+ path.join(claudeDir, '.credentials.json.dev2.2'),
837
+ path.join(claudeDir, '.credentials.json'),
838
+ );
839
+ });
840
+ });
841
+ });