import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { apiClient as ApiClientType } from '../../src/services/api-client'; // Create a fresh ApiClient for each test by re-importing let apiClient: typeof ApiClientType; beforeEach(async () => { vi.restoreAllMocks(); // Reset module to get a fresh singleton vi.resetModules(); const mod = await import('../../src/services/api-client'); apiClient = mod.apiClient; }); function mockFetch(body: unknown, status = 200) { return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: status >= 200 && status < 300, status, statusText: status === 200 ? 'OK' : 'Error', json: () => Promise.resolve(body), } as Response); } describe('ApiClient', () => { describe('token management', () => { it('hasToken returns false initially', () => { expect(apiClient.hasToken).toBe(false); }); it('hasToken returns true after setting token', () => { apiClient.accessToken = 'test-token'; expect(apiClient.hasToken).toBe(true); }); it('includes Authorization header when token is set', async () => { const spy = mockFetch({ data: [] }); apiClient.accessToken = 'my-jwt'; await apiClient.get('/test'); const headers = spy.mock.calls[0][1]?.headers as Record; expect(headers['Authorization']).toBe('Bearer my-jwt'); }); it('omits Authorization header when no token', async () => { const spy = mockFetch({ data: [] }); await apiClient.get('/test'); const headers = spy.mock.calls[0][1]?.headers as Record; expect(headers['Authorization']).toBeUndefined(); }); }); describe('baseUrl', () => { it('returns the configured API base URL', () => { expect(apiClient.baseUrl).toBeDefined(); }); }); describe('get', () => { it('makes GET request to correct URL', async () => { const spy = mockFetch({ id: '1' }); await apiClient.get('/households/hh1/stores'); expect(spy).toHaveBeenCalledWith( expect.stringContaining('/households/hh1/stores'), expect.objectContaining({ headers: expect.any(Object) }), ); }); it('returns parsed JSON body', async () => { mockFetch({ id: '1', name: 'CVS' }); const result = await apiClient.get('/test'); expect(result).toEqual({ id: '1', name: 'CVS' }); }); }); describe('post', () => { it('makes POST request with JSON body', async () => { const spy = mockFetch({ id: '1' }); const body = { name: 'Walgreens' }; await apiClient.post('/stores', body); expect(spy).toHaveBeenCalledWith( expect.stringContaining('/stores'), expect.objectContaining({ method: 'POST', body: JSON.stringify(body), }), ); }); it('handles post without body', async () => { const spy = mockFetch({ id: '1' }); await apiClient.post('/test'); expect(spy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ body: undefined }), ); }); }); describe('patch', () => { it('makes PATCH request with JSON body', async () => { const spy = mockFetch({ id: '1' }); const body = { name: 'Updated' }; await apiClient.patch('/stores/1', body); expect(spy).toHaveBeenCalledWith( expect.stringContaining('/stores/1'), expect.objectContaining({ method: 'PATCH', body: JSON.stringify(body), }), ); }); }); describe('delete', () => { it('makes DELETE request', async () => { const spy = mockFetch(undefined, 204); await apiClient.delete('/stores/1'); expect(spy).toHaveBeenCalledWith( expect.stringContaining('/stores/1'), expect.objectContaining({ method: 'DELETE' }), ); }); it('includes Authorization header in DELETE request when token is set', async () => { const spy = mockFetch(undefined, 204); apiClient.accessToken = 'delete-jwt'; await apiClient.delete('/stores/1'); const headers = spy.mock.calls[0][1]?.headers as Record; expect(headers['Authorization']).toBe('Bearer delete-jwt'); }); }); describe('error handling', () => { it('throws with message from error response body', async () => { mockFetch({ message: 'Store not found' }, 404); await expect(apiClient.get('/test')).rejects.toThrow('Store not found'); }); it('throws with status code when body has no message', async () => { mockFetch({}, 500); await expect(apiClient.get('/test')).rejects.toThrow('Request failed: 500'); }); it('throws with status text when JSON parsing fails', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, status: 502, statusText: 'Bad Gateway', json: () => Promise.reject(new Error('parse error')), } as Response); await expect(apiClient.get('/test')).rejects.toThrow('Request failed: 502 Bad Gateway'); }); it('returns undefined for 204 No Content', async () => { mockFetch(null, 204); const result = await apiClient.get('/test'); expect(result).toBeUndefined(); }); }); });