import { beforeEach, describe, expect, it, vi } from 'vitest'; import { subscribeUnauthorized } from './auth'; import { ApiError, request, requestWithMeta } from './client'; // Reads the X-Csrf header off the object handed to fetch (the client sets it post-normalization). function csrfHeaderFrom(fetchMock: ReturnType): unknown { const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; return (init?.headers as Record | undefined)?.['X-Csrf']; } describe('API request client', () => { beforeEach(() => { window.localStorage.clear(); vi.restoreAllMocks(); }); it.each(['POST', 'PUT', 'PATCH', 'DELETE'] as const)( 'attaches the X-CSRF header on %s (session-auth mutation, #295)', async (method) => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); await request('/api/v1/channels/1', { method }); expect(fetchMock).toHaveBeenCalledWith( '/api/v1/channels/1', expect.objectContaining({ headers: expect.objectContaining({ 'X-Csrf': '1' }), method }) ); } ); it('does not attach the X-CSRF header on a GET', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue( new Response(JSON.stringify([]), { headers: { 'Content-Type': 'application/json' }, status: 200 }) ); await request('/api/v1/channels'); expect(fetchMock).toHaveBeenCalledWith( '/api/v1/channels', expect.objectContaining({ headers: expect.not.objectContaining({ 'X-Csrf': expect.anything() }) }) ); expect(csrfHeaderFrom(fetchMock)).toBeUndefined(); }); it('never sends an X-Api-Key header, even with a legacy ctv-api-key in localStorage', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); window.localStorage.setItem('ctv-api-key', 'legacy-secret'); await request('/api/v1/channels'); await request('/api/v1/channels/1', { method: 'DELETE' }); for (const call of fetchMock.mock.calls) { const headers = (call[1] as RequestInit | undefined)?.headers as Record | undefined; expect(headers).not.toHaveProperty('X-Api-Key'); } }); it('throws an ApiError with problem details for non-OK responses', async () => { vi.spyOn(window, 'fetch').mockResolvedValue( new Response(JSON.stringify({ status: 422, title: 'Validation failed', detail: 'Number exists' }), { headers: { 'Content-Type': 'application/json' }, status: 422 }) ); await expect(request('/api/v1/channels', { method: 'POST', body: { name: 'News' } })).rejects.toMatchObject({ detail: 'Number exists', message: 'Validation failed', status: 422 } satisfies Partial); }); it('returns undefined for successful responses without a JSON body', async () => { vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); await expect(request('/api/v1/channels', { method: 'POST' })).resolves.toBeUndefined(); }); it('signals unauthorized subscribers on a 401 response', async () => { vi.spyOn(window, 'fetch').mockResolvedValue( new Response(JSON.stringify({ status: 401, title: 'Unauthorized' }), { headers: { 'Content-Type': 'application/problem+json' }, status: 401 }) ); const onUnauthorized = vi.fn(); const unsubscribe = subscribeUnauthorized(onUnauthorized); await expect(request('/api/v1/channels')).rejects.toMatchObject({ status: 401 }); expect(onUnauthorized).toHaveBeenCalledTimes(1); unsubscribe(); }); it('does NOT signal unauthorized subscribers on a 401 when suppressUnauthorizedSignal is set', async () => { vi.spyOn(window, 'fetch').mockResolvedValue( new Response(JSON.stringify({ status: 401, title: 'Unauthorized' }), { headers: { 'Content-Type': 'application/problem+json' }, status: 401 }) ); const onUnauthorized = vi.fn(); const unsubscribe = subscribeUnauthorized(onUnauthorized); await expect( request('/api/v1/auth/login', { body: { username: 'x', password: 'y' }, method: 'POST', suppressUnauthorizedSignal: true }) ).rejects.toMatchObject({ status: 401 }); expect(onUnauthorized).not.toHaveBeenCalled(); unsubscribe(); }); it('does not signal unauthorized subscribers on other error statuses', async () => { vi.spyOn(window, 'fetch').mockResolvedValue( new Response(JSON.stringify({ status: 403, title: 'Forbidden' }), { headers: { 'Content-Type': 'application/problem+json' }, status: 403 }) ); const onUnauthorized = vi.fn(); const unsubscribe = subscribeUnauthorized(onUnauthorized); await expect(request('/api/v1/channels')).rejects.toMatchObject({ status: 403 }); expect(onUnauthorized).not.toHaveBeenCalled(); unsubscribe(); }); it('does not add a duplicate JSON content type when callers provide lowercase content-type', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); await request('/api/v1/channels', { body: { name: 'News' }, headers: { 'content-type': 'application/merge-patch+json' }, method: 'PATCH' }); expect(fetchMock).toHaveBeenCalledWith( '/api/v1/channels', expect.objectContaining({ headers: expect.not.objectContaining({ 'Content-Type': 'application/json' }) }) ); }); it('requestWithMeta returns the response ETag alongside the parsed body', async () => { vi.spyOn(window, 'fetch').mockResolvedValue( new Response(JSON.stringify([{ id: 1 }]), { headers: { ETag: '"7"', 'Content-Type': 'application/json' }, status: 200 }) ); const result = await requestWithMeta<{ id: number }[]>('/api/v1/blocks/1/items'); expect(result.etag).toBe('"7"'); expect(result.data).toEqual([{ id: 1 }]); }); it('requestWithMeta returns a null ETag when the response has none', async () => { vi.spyOn(window, 'fetch').mockResolvedValue( new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' }, status: 200 }) ); const result = await requestWithMeta('/api/v1/blocks/1/items'); expect(result.etag).toBeNull(); }); it('requestWithMeta surfaces the ETag on a 204 (no body) response', async () => { vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { headers: { ETag: '"3"' }, status: 204 })); const result = await requestWithMeta('/api/v1/blocks/1', { method: 'PUT' }); expect(result.data).toBeUndefined(); expect(result.etag).toBe('"3"'); }); });