/** * Tests for API client */ import { afterEach, beforeEach, describe, expect, test, vi, } from 'vitest'; import { ApiError, api, } from './api'; describe('api', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); describe('GET requests', () => { test('should return data and status on successful request', async () => { const mockData = { id: 1, name: 'Test' }; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockData, } as Response); const result = await api.get<{ id: number; name: string }>('/api/test'); expect(result.data).toEqual(mockData); expect(result.status).toBe(200); expect(fetch).toHaveBeenCalledWith( '/api/test', expect.objectContaining({ method: 'GET', headers: { 'Content-Type': 'application/json' }, }), ); }); }); describe('POST requests', () => { test('should send JSON body and return response', async () => { const requestBody = { name: 'Alice', email: 'alice@example.com' }; const mockResponse = { id: 1, ...requestBody }; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 201, json: async () => mockResponse, } as Response); const result = await api.post<{ id: number; name: string; email: string }>( '/api/users', requestBody, ); expect(result.data).toEqual(mockResponse); expect(result.status).toBe(201); expect(fetch).toHaveBeenCalledWith( '/api/users', expect.objectContaining({ method: 'POST', body: JSON.stringify(requestBody), headers: { 'Content-Type': 'application/json' }, }), ); }); test('should handle POST with undefined body', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}), } as Response); await api.post('/api/users', undefined); expect(fetch).toHaveBeenCalledWith( '/api/users', expect.objectContaining({ method: 'POST', body: undefined, }), ); }); }); describe('PUT requests', () => { test('should send JSON body and return response', async () => { const requestBody = { id: 1, name: 'Updated' }; const mockResponse = { ...requestBody }; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResponse, } as Response); const result = await api.put<{ id: number; name: string }>('/api/users/1', requestBody); expect(result.data).toEqual(mockResponse); expect(result.status).toBe(200); expect(fetch).toHaveBeenCalledWith( '/api/users/1', expect.objectContaining({ method: 'PUT', body: JSON.stringify(requestBody), headers: { 'Content-Type': 'application/json' }, }), ); }); }); describe('DELETE requests', () => { test('should return data and status on successful deletion', async () => { const mockData = { message: 'Deleted successfully' }; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockData, } as Response); const result = await api.delete<{ message: string }>('/api/users/1'); expect(result.data).toEqual(mockData); expect(result.status).toBe(200); expect(fetch).toHaveBeenCalledWith( '/api/users/1', expect.objectContaining({ method: 'DELETE', headers: { 'Content-Type': 'application/json' }, }), ); }); }); describe('error handling', () => { test('should throw ApiError on non-OK response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found', json: async () => ({ error: 'Resource not found' }), } as Response); await expect(api.get('/api/not-found')).rejects.toThrow(ApiError); }); test('should include status code in ApiError', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error', json: async () => ({}), } as Response); try { await api.get('/api/error'); expect.fail('Should have thrown ApiError'); } catch (error) { expect(error).toBeInstanceOf(ApiError); expect((error as ApiError).status).toBe(500); } }); test('should include message in ApiError', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden', json: async () => ({}), } as Response); try { await api.get('/api/forbidden'); expect.fail('Should have thrown ApiError'); } catch (error) { expect(error).toBeInstanceOf(ApiError); expect((error as ApiError).message).toBe('Request failed: Forbidden'); } }); test('should include response object in ApiError', async () => { const mockResponse = { ok: false, status: 401, statusText: 'Unauthorized', json: async () => ({}), } as Response; vi.mocked(fetch).mockResolvedValueOnce(mockResponse); try { await api.get('/api/unauthorized'); expect.fail('Should have thrown ApiError'); } catch (error) { expect(error).toBeInstanceOf(ApiError); expect((error as ApiError).response).toBe(mockResponse); } }); test('should have correct error name', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 400, statusText: 'Bad Request', json: async () => ({}), } as Response); try { await api.get('/api/bad-request'); expect.fail('Should have thrown ApiError'); } catch (error) { expect(error).toBeInstanceOf(ApiError); expect((error as ApiError).name).toBe('ApiError'); } }); }); describe('headers', () => { test('should accept custom headers (replaces defaults)', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}), } as Response); await api.get('/api/test', { headers: { 'X-Custom-Header': 'custom-value' }, }); expect(fetch).toHaveBeenCalledWith( '/api/test', expect.objectContaining({ headers: { 'X-Custom-Header': 'custom-value', }, }), ); }); test('should allow overriding default Content-Type', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}), } as Response); await api.get('/api/test', { headers: { 'Content-Type': 'text/plain' }, }); expect(fetch).toHaveBeenCalledWith( '/api/test', expect.objectContaining({ headers: { 'Content-Type': 'text/plain' }, }), ); }); }); describe('empty response handling', () => { test('should handle empty JSON response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204, json: async () => null, } as Response); const result = await api.get('/api/empty'); expect(result.data).toBeNull(); expect(result.status).toBe(204); }); }); });