|
| 1 | +/** |
| 2 | + * Covers the transport: the Bearer token, the single-host base URL, and the |
| 3 | + * 429 retry. Network access is mocked, so this runs in CI. |
| 4 | + */ |
| 5 | +import { makeCollegeFootballDataRequest } from './client'; |
| 6 | + |
| 7 | +type Captured = { |
| 8 | + url: string; |
| 9 | + method: string; |
| 10 | + headers: Record<string, string>; |
| 11 | +}; |
| 12 | + |
| 13 | +type MockResponse = { |
| 14 | + ok?: boolean; |
| 15 | + status?: number; |
| 16 | + body?: unknown; |
| 17 | + headers?: Record<string, string>; |
| 18 | +}; |
| 19 | + |
| 20 | +let captured: Captured | undefined; |
| 21 | +let attempts = 0; |
| 22 | + |
| 23 | +/** |
| 24 | + * Installs a fetch stub that answers each call with the next response in the |
| 25 | + * list, repeating the last one once the list is exhausted. |
| 26 | + */ |
| 27 | +function mockFetchSequence(responses: MockResponse[]) { |
| 28 | + captured = undefined; |
| 29 | + attempts = 0; |
| 30 | + // `url` is `unknown` rather than `RequestInfo | URL` because the stub only |
| 31 | + // ever needs `String(url)`. |
| 32 | + global.fetch = (async (url: unknown, init?: RequestInit) => { |
| 33 | + const headers: Record<string, string> = {}; |
| 34 | + const raw = init?.headers; |
| 35 | + if (raw instanceof Headers) { |
| 36 | + raw.forEach((value, key) => { |
| 37 | + headers[key.toLowerCase()] = value; |
| 38 | + }); |
| 39 | + } else { |
| 40 | + for (const [key, value] of Object.entries( |
| 41 | + (raw ?? {}) as Record<string, string>, |
| 42 | + )) { |
| 43 | + headers[key.toLowerCase()] = value; |
| 44 | + } |
| 45 | + } |
| 46 | + captured = { url: String(url), method: init?.method ?? 'GET', headers }; |
| 47 | + |
| 48 | + const response = |
| 49 | + responses[Math.min(attempts, responses.length - 1)] ?? |
| 50 | + ({} as MockResponse); |
| 51 | + attempts++; |
| 52 | + |
| 53 | + const status = response.status ?? 200; |
| 54 | + const payload = response.body ?? []; |
| 55 | + return { |
| 56 | + ok: response.ok ?? status < 400, |
| 57 | + status, |
| 58 | + statusText: 'OK', |
| 59 | + url: String(url), |
| 60 | + headers: new Headers({ |
| 61 | + 'Content-Type': 'application/json', |
| 62 | + ...response.headers, |
| 63 | + }), |
| 64 | + json: async () => payload, |
| 65 | + text: async () => JSON.stringify(payload), |
| 66 | + // Partial `Response` stub; only what the shared request helper reads. |
| 67 | + }; |
| 68 | + }) as unknown as typeof global.fetch; |
| 69 | +} |
| 70 | + |
| 71 | +function mockFetch(response: MockResponse) { |
| 72 | + mockFetchSequence([response]); |
| 73 | +} |
| 74 | + |
| 75 | +describe('makeCollegeFootballDataRequest', () => { |
| 76 | + it('sends the bearer token', async () => { |
| 77 | + mockFetch({ body: [] }); |
| 78 | + |
| 79 | + await makeCollegeFootballDataRequest('/teams', 'test-key'); |
| 80 | + |
| 81 | + expect(captured?.headers.authorization).toBe('Bearer test-key'); |
| 82 | + }); |
| 83 | + |
| 84 | + it('targets the single api.collegefootballdata.com host', async () => { |
| 85 | + mockFetch({ body: [] }); |
| 86 | + |
| 87 | + await makeCollegeFootballDataRequest('/teams', 'test-key'); |
| 88 | + |
| 89 | + expect(captured?.url).toContain('https://api.collegefootballdata.com/'); |
| 90 | + expect(captured?.url).toContain('/teams'); |
| 91 | + }); |
| 92 | + |
| 93 | + it('is always a GET - this API has no writes', async () => { |
| 94 | + mockFetch({ body: [] }); |
| 95 | + |
| 96 | + await makeCollegeFootballDataRequest('/games', 'test-key', { |
| 97 | + query: { year: 2023 }, |
| 98 | + }); |
| 99 | + |
| 100 | + expect(captured?.method).toBe('GET'); |
| 101 | + }); |
| 102 | + |
| 103 | + it('serializes query parameters', async () => { |
| 104 | + mockFetch({ body: [] }); |
| 105 | + |
| 106 | + await makeCollegeFootballDataRequest('/games', 'test-key', { |
| 107 | + query: { year: 2023, team: 'Alabama' }, |
| 108 | + }); |
| 109 | + |
| 110 | + expect(captured?.url).toContain('year=2023'); |
| 111 | + expect(captured?.url).toContain('team=Alabama'); |
| 112 | + }); |
| 113 | + |
| 114 | + /** |
| 115 | + * `Retry-After: 3` is deliberately different from `client.ts`'s |
| 116 | + * `initialRetryDelay: 1000` (1s): if the retry actually waited on the |
| 117 | + * fallback backoff instead of the header, this test would still pass at |
| 118 | + * 1s of fake-timer advancement, hiding the bug. Advancing by only the |
| 119 | + * header's 3s and asserting the second request has landed by then (but |
| 120 | + * not before) proves the header value, not the fallback, drove the wait. |
| 121 | + */ |
| 122 | + it('retries once the provider answers 429, waiting exactly the Retry-After duration', async () => { |
| 123 | + jest.useFakeTimers(); |
| 124 | + try { |
| 125 | + mockFetchSequence([ |
| 126 | + { status: 429, body: {}, headers: { 'Retry-After': '3' } }, |
| 127 | + { status: 200, body: [{ id: 1 }] }, |
| 128 | + ]); |
| 129 | + |
| 130 | + const pending = makeCollegeFootballDataRequest<unknown[]>( |
| 131 | + '/teams', |
| 132 | + 'test-key', |
| 133 | + ); |
| 134 | + |
| 135 | + await jest.advanceTimersByTimeAsync(2999); |
| 136 | + expect(attempts).toBe(1); |
| 137 | + |
| 138 | + await jest.advanceTimersByTimeAsync(1); |
| 139 | + expect(attempts).toBe(2); |
| 140 | + |
| 141 | + expect(await pending).toEqual([{ id: 1 }]); |
| 142 | + } finally { |
| 143 | + jest.useRealTimers(); |
| 144 | + } |
| 145 | + }); |
| 146 | +}); |
0 commit comments