|
9 | 9 | clearGitHubApiCacheForTests, |
10 | 10 | GITHUB_CACHE_TTL_MS, |
11 | 11 | validateGitHubUsername, |
| 12 | + fetchWithRetry, |
12 | 13 | } from './github'; |
13 | 14 | import type { ContributionCalendar } from '../types'; |
14 | 15 |
|
@@ -475,3 +476,73 @@ describe('validateGitHubUsername', () => { |
475 | 476 | expect(validateGitHubUsername('in--valid')).toBe(false); |
476 | 477 | }); |
477 | 478 | }); |
| 479 | +describe('fetchWithRetry', () => { |
| 480 | + beforeEach(() => { |
| 481 | + vi.spyOn(global, 'fetch'); |
| 482 | + }); |
| 483 | + |
| 484 | + afterEach(() => { |
| 485 | + vi.restoreAllMocks(); |
| 486 | + }); |
| 487 | + |
| 488 | + it('resolves immediately on first attempt when fetch returns 200', async () => { |
| 489 | + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ ok: true }, 200)); |
| 490 | + |
| 491 | + const res = await fetchWithRetry('https://api.github.com/rest', {}); |
| 492 | + |
| 493 | + expect(res.status).toBe(200); |
| 494 | + expect(fetch).toHaveBeenCalledTimes(1); |
| 495 | + }); |
| 496 | + |
| 497 | + it('retries once on 429 (rate limit) and resolves on second attempt', async () => { |
| 498 | + vi.useFakeTimers(); |
| 499 | + vi.mocked(fetch) |
| 500 | + .mockResolvedValueOnce(mockResponse({}, 429)) |
| 501 | + .mockResolvedValueOnce(mockResponse({}, 200)); |
| 502 | + |
| 503 | + const promise = fetchWithRetry('https://api.github.com/rest', {}); |
| 504 | + await vi.runAllTimersAsync(); |
| 505 | + const res = await promise; |
| 506 | + |
| 507 | + expect(res.status).toBe(200); |
| 508 | + expect(fetch).toHaveBeenCalledTimes(2); |
| 509 | + vi.useRealTimers(); |
| 510 | + }); |
| 511 | + |
| 512 | + it('retries once on 500 (server error) and resolves on second attempt', async () => { |
| 513 | + vi.useFakeTimers(); |
| 514 | + vi.mocked(fetch) |
| 515 | + .mockResolvedValueOnce(mockResponse({}, 500)) |
| 516 | + .mockResolvedValueOnce(mockResponse({}, 200)); |
| 517 | + |
| 518 | + const promise = fetchWithRetry('https://api.github.com/rest', {}); |
| 519 | + await vi.runAllTimersAsync(); |
| 520 | + const res = await promise; |
| 521 | + |
| 522 | + expect(res.status).toBe(200); |
| 523 | + expect(fetch).toHaveBeenCalledTimes(2); |
| 524 | + vi.useRealTimers(); |
| 525 | + }); |
| 526 | + |
| 527 | + it('returns the bad response after exhausting all retries on 429', async () => { |
| 528 | + vi.useFakeTimers(); |
| 529 | + vi.mocked(fetch).mockResolvedValue(mockResponse({}, 429)); |
| 530 | + |
| 531 | + const promise = fetchWithRetry('https://api.github.com/rest', {}); |
| 532 | + await vi.runAllTimersAsync(); |
| 533 | + const res = await promise; |
| 534 | + |
| 535 | + expect(res.status).toBe(429); |
| 536 | + vi.useRealTimers(); |
| 537 | + }); |
| 538 | + |
| 539 | + it('propagates network error after exhausting all retries', async () => { |
| 540 | + // Use real timers so retry delays actually fire and resolve cleanly |
| 541 | + vi.useRealTimers(); |
| 542 | + vi.mocked(fetch).mockRejectedValue(new Error('network failure')); |
| 543 | + |
| 544 | + await expect(fetchWithRetry('https://api.github.com/rest', {})).rejects.toThrow( |
| 545 | + 'network failure' |
| 546 | + ); |
| 547 | + }); |
| 548 | +}); |
0 commit comments