// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {apiFetch, clientFetch, logout} from './api.js'; const response = (status) => ({ok: status >= 200 && status < 300, status, json: async () => ({})}); const isRefresh = (url) => url.endsWith('/api/auth/refresh'); const dataCalls = () => fetch.mock.calls.filter(([url]) => !isRefresh(url)); const refreshCalls = () => fetch.mock.calls.filter(([url]) => isRefresh(url)); beforeEach(() => { localStorage.setItem('tbp_session', '1'); }); afterEach(() => { localStorage.clear(); }); describe('clientFetch', () => { it('sends the cookies and the client header and keeps the other options', async () => { globalThis.fetch = vi.fn(async () => response(204)); await clientFetch('/api/x', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: '{}'}); expect(fetch).toHaveBeenCalledWith('/api/x', { method: 'PUT', headers: {'Content-Type': 'application/json', 'X-TBP-Client': 'web'}, body: '{}', credentials: 'include', }); }); }); describe('apiFetch', () => { it('renews the session once on a 401 and sends the request again', async () => { let dataAnswers = [401, 200]; globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : dataAnswers.shift())); const result = await apiFetch('/api/x', {method: 'PUT', body: '{"a":1}'}); expect(result.status).toBe(200); expect(refreshCalls()).toHaveLength(1); expect(dataCalls()).toHaveLength(2); expect(dataCalls()[1][1]).toMatchObject({method: 'PUT', body: '{"a":1}'}); expect(localStorage.getItem('tbp_session')).toBe('1'); }); it('asks only once when parallel calls get a 401', async () => { const seen = new Set(); globalThis.fetch = vi.fn(async (url) => { if (isRefresh(url)) return response(204); if (seen.has(url)) return response(200); seen.add(url); return response(401); }); const results = await Promise.all(['/a', '/b', '/c'].map((url) => apiFetch(url))); expect(results.map((r) => r.status)).toEqual([200, 200, 200]); expect(refreshCalls()).toHaveLength(1); }); it('returns the 401 when the refresh fails, without a loop, and leaves ending the session to the caller', async () => { globalThis.fetch = vi.fn(async () => response(401)); const result = await apiFetch('/api/x'); expect(result.status).toBe(401); expect(refreshCalls()).toHaveLength(1); expect(dataCalls()).toHaveLength(1); expect(localStorage.getItem('tbp_session')).toBe('1'); }); it('keeps the session and rejects when the refresh has no network', async () => { globalThis.fetch = vi.fn(async (url) => { if (isRefresh(url)) throw new TypeError('offline'); return response(401); }); await expect(apiFetch('/api/x')).rejects.toThrow('offline'); expect(localStorage.getItem('tbp_session')).toBe('1'); }); it('keeps the session and rejects when Auth is down during a deploy', async () => { globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 502 : 401)); await expect(apiFetch('/api/x')).rejects.toThrow('502'); expect(localStorage.getItem('tbp_session')).toBe('1'); }); it('tries the refresh again on the next 401 after a failed one', async () => { const refreshAnswers = [502, 204]; const dataAnswers = [401, 401, 200]; globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? refreshAnswers.shift() : dataAnswers.shift())); await expect(apiFetch('/api/x')).rejects.toThrow(); expect((await apiFetch('/api/x')).status).toBe(200); }); it('passes other errors through without a refresh', async () => { globalThis.fetch = vi.fn(async () => response(403)); expect((await apiFetch('/api/x')).status).toBe(403); expect(refreshCalls()).toHaveLength(0); }); it('gives up on a refresh that hangs, so the lock does not block every tab', async () => { globalThis.fetch = vi.fn(async (url, init) => { if (!isRefresh(url)) return response(401); expect(init.signal).toBeInstanceOf(AbortSignal); throw new DOMException('timed out', 'TimeoutError'); }); await expect(apiFetch('/api/x')).rejects.toThrow('timed out'); }); it('runs the refresh under a lock that other tabs share', async () => { const request = vi.fn((name, callback) => callback()); vi.stubGlobal('navigator', {...navigator, locks: {request}}); let dataAnswers = [401, 200]; globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : dataAnswers.shift())); await apiFetch('/api/x'); expect(request).toHaveBeenCalledWith('tbp-refresh', expect.any(Function)); vi.unstubAllGlobals(); }); }); describe('logout', () => { it('posts to Auth and ignores a network error', async () => { globalThis.fetch = vi.fn(async () => { throw new TypeError('offline'); }); await expect(logout()).resolves.toBeUndefined(); expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/logout'), expect.objectContaining({method: 'POST'})); }); });