import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { BaseApiClient, ApiError } from './BaseApiClient' function mockFetch(response: { ok: boolean status: number statusText: string headers?: Record body?: string json?: unknown }) { const headers = new Headers(response.headers) return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: response.ok, status: response.status, statusText: response.statusText, headers, text: async () => response.body ?? '', json: async () => response.json ?? JSON.parse(response.body ?? '{}'), } as Response) } describe('BaseApiClient', () => { let client: BaseApiClient beforeEach(() => { client = new BaseApiClient('https://api.example.com/') }) afterEach(() => { vi.restoreAllMocks() }) // ── Constructor ────────────────────────────────────────────────── it('strips trailing slash from baseUrl', async () => { const spy = mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{}' }) await client.get('/test') expect(spy).toHaveBeenCalledWith('https://api.example.com/test', expect.anything()) }) // ── setAccessToken ─────────────────────────────────────────────── it('returns this for chaining', () => { const result = client.setAccessToken('tok123') expect(result).toBe(client) }) it('includes Bearer token in Authorization header after setAccessToken', async () => { client.setAccessToken('tok123') const spy = mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{}' }) await client.get('/test') const headers = spy.mock.calls[0]?.[1]?.headers as Record expect(headers['Authorization']).toBe('Bearer tok123') }) it('does not include Authorization header when no token is set', async () => { const spy = mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{}' }) await client.get('/test') const headers = spy.mock.calls[0]?.[1]?.headers as Record expect(headers['Authorization']).toBeUndefined() }) // ── URL construction ───────────────────────────────────────────── it('strips leading slash from endpoint', async () => { const spy = mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{}' }) await client.get('/users') expect(spy).toHaveBeenCalledWith('https://api.example.com/users', expect.anything()) }) it('handles endpoint without leading slash', async () => { const spy = mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{}' }) await client.get('users') expect(spy).toHaveBeenCalledWith('https://api.example.com/users', expect.anything()) }) // ── HTTP methods ───────────────────────────────────────────────── it.each(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const)( '%s calls fetch with correct method', async (method) => { const spy = mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{}' }) const fn = method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete' await (client[fn] as (ep: string) => Promise)('/test') expect(spy.mock.calls[0]?.[1]?.method).toBe(method) }, ) it('sends JSON body for POST', async () => { const spy = mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{}' }) await client.post('/items', { name: 'foo' }) expect(spy.mock.calls[0]?.[1]?.body).toBe(JSON.stringify({ name: 'foo' })) }) it('does not send body for GET', async () => { const spy = mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{}' }) await client.get('/items') expect(spy.mock.calls[0]?.[1]?.body).toBeUndefined() }) it('does not send body for DELETE', async () => { const spy = mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{}' }) await client.delete('/items/1') expect(spy.mock.calls[0]?.[1]?.body).toBeUndefined() }) it('merges custom headers', async () => { const spy = mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{}' }) await client.get('/test', { 'X-Custom': 'hello' }) const headers = spy.mock.calls[0]?.[1]?.headers as Record expect(headers['X-Custom']).toBe('hello') expect(headers['Content-Type']).toBe('application/json') }) // ── Successful responses ───────────────────────────────────────── it('parses JSON response body', async () => { mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{"id":1}' }) const result = await client.get<{ id: number }>('/test') expect(result).toEqual({ id: 1 }) }) it('returns undefined for empty response body', async () => { mockFetch({ ok: true, status: 204, statusText: 'No Content', body: '' }) const result = await client.delete('/items/1') expect(result).toBeUndefined() }) it('returns undefined for empty body even when Content-Type is application/json', async () => { // Regression: some endpoints (e.g. POST /me/takeover) reply 200 with the default // application/json Content-Type and an empty body. `response.json()` would throw // "SyntaxError: Unexpected end of input". mockFetch({ ok: true, status: 200, statusText: 'OK', headers: { 'Content-Type': 'application/json' }, body: '', }) const result = await client.post('/me/takeover') expect(result).toBeUndefined() }) it('returns undefined for 204 No Content regardless of Content-Type', async () => { mockFetch({ ok: true, status: 204, statusText: 'No Content', headers: { 'Content-Type': 'application/json' }, body: '', }) const result = await client.post('/me/takeover') expect(result).toBeUndefined() }) it('returns plain text when response is not JSON', async () => { mockFetch({ ok: true, status: 200, statusText: 'OK', body: 'OK' }) const result = await client.post('/action') expect(result).toBe('OK') }) it('parses JSON body even without Content-Type header', async () => { mockFetch({ ok: true, status: 200, statusText: 'OK', body: '{"key":"value"}' }) const result = await client.get<{ key: string }>('/test') expect(result).toEqual({ key: 'value' }) }) it('parses JSON response using Content-Type header when present', async () => { mockFetch({ ok: true, status: 200, statusText: 'OK', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: '{"id":42}', }) const result = await client.get<{ id: number }>('/test') expect(result).toEqual({ id: 42 }) }) it('returns plain text for text/plain Content-Type', async () => { mockFetch({ ok: true, status: 200, statusText: 'OK', headers: { 'Content-Type': 'text/plain' }, body: 'Created', }) const result = await client.post('/action') expect(result).toBe('Created') }) // ── Error handling ─────────────────────────────────────────────── it('throws ApiError with status for non-JSON error response', async () => { mockFetch({ ok: false, status: 500, statusText: 'Internal Server Error' }) await expect(client.get('/fail')).rejects.toThrow(ApiError) await expect(client.get('/fail')).rejects.toMatchObject({ message: 'HTTP Error 500: Internal Server Error', status: 500, }) }) it('throws ApiError with parsed message from JSON error', async () => { mockFetch({ ok: false, status: 422, statusText: 'Unprocessable Entity', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Validation failed', error: 'ValidationError' }), }) await expect(client.get('/fail')).rejects.toMatchObject({ message: 'Validation failed', errorClass: 'ValidationError', status: 422, }) }) it('includes validationErrors from JSON error response', async () => { const validationErrors = [{ path: 'name', message: 'required', code: 'REQUIRED' }] mockFetch({ ok: false, status: 422, statusText: 'Unprocessable Entity', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Bad input', validationErrors }), }) await expect(client.get('/fail')).rejects.toMatchObject({ validationErrors, }) }) it('falls back to default message when JSON parsing fails', async () => { mockFetch({ ok: false, status: 400, statusText: 'Bad Request', headers: { 'Content-Type': 'application/json' }, body: 'not-json', json: (() => { throw new Error('parse error') }) as unknown, }) await expect(client.get('/fail')).rejects.toMatchObject({ message: 'HTTP Error 400: Bad Request', status: 400, }) }) it('does not throw when error response has Content-Type application/json but empty body', async () => { mockFetch({ ok: false, status: 502, statusText: 'Bad Gateway', headers: { 'Content-Type': 'application/json' }, body: '', }) await expect(client.get('/fail')).rejects.toMatchObject({ message: 'HTTP Error 502: Bad Gateway', status: 502, }) }) it('uses error field as message when message is absent', async () => { mockFetch({ ok: false, status: 403, statusText: 'Forbidden', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'AccessDenied' }), }) await expect(client.get('/fail')).rejects.toMatchObject({ message: 'AccessDenied', errorClass: 'AccessDenied', }) }) })