import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createApiClient } from '@/utilities/api-client'; describe('api-client', () => { let fetchMock; beforeEach(() => { fetchMock = vi.fn(); global.fetch = fetchMock; }); afterEach(() => { vi.clearAllTimers(); vi.unstubAllGlobals(); }); describe('createApiClient', () => { it('should create client with default config', () => { const client = createApiClient(); expect(client).toHaveProperty('get'); expect(client).toHaveProperty('post'); expect(client).toHaveProperty('put'); expect(client).toHaveProperty('patch'); expect(client).toHaveProperty('delete'); }); it('should merge baseURL with request path', async () => { const client = createApiClient({ baseURL: '/api' }); fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({ success: true }) }); await client.get('/users'); expect(fetchMock).toHaveBeenCalledWith( '/api/users', expect.objectContaining({ method: 'GET' }) ); }); }); describe('GET requests', () => { it('should make successful GET request', async () => { const client = createApiClient(); const mockData = { id: 1, name: 'Test' }; fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => mockData }); const result = await client.get('/test'); expect(result).toEqual(mockData); expect(fetchMock).toHaveBeenCalledWith( '/test', expect.objectContaining({ method: 'GET', credentials: 'include' }) ); }); it('should handle non-JSON responses', async () => { const client = createApiClient(); const textResponse = 'Plain text response'; fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'text/plain' }), text: async () => textResponse }); const result = await client.get('/text'); expect(result).toBe(textResponse); }); }); describe('POST requests', () => { it('should make POST request with data', async () => { const client = createApiClient(); const postData = { name: 'Test' }; const mockResponse = { id: 1, ...postData }; fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => mockResponse }); const result = await client.post('/users', postData); expect(result).toEqual(mockResponse); expect(fetchMock).toHaveBeenCalledWith( '/users', expect.objectContaining({ method: 'POST', body: JSON.stringify(postData) }) ); }); }); describe('error handling', () => { it('should throw error on HTTP error status', async () => { const client = createApiClient(); const errorResponse = { message: 'Not found' }; fetchMock.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found', headers: new Headers({ 'content-type': 'application/json' }), json: async () => errorResponse }); await expect(client.get('/missing')).rejects.toThrow('HTTP 404'); }); it('should not retry on 4xx errors', async () => { const client = createApiClient({ maxRetries: 3 }); fetchMock.mockResolvedValueOnce({ ok: false, status: 400, statusText: 'Bad Request', headers: new Headers(), json: async () => ({}) }); await expect(client.get('/bad')).rejects.toThrow(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('should retry on 5xx errors', async () => { vi.useFakeTimers(); const client = createApiClient({ maxRetries: 2, retryDelay: 100 }); fetchMock .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error', headers: new Headers(), json: async () => ({}) }) .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error', headers: new Headers(), json: async () => ({}) }) .mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({ success: true }) }); const promise = client.get('/retry'); // Advance timers for retries await vi.runAllTimersAsync(); const result = await promise; expect(result).toEqual({ success: true }); expect(fetchMock).toHaveBeenCalledTimes(3); vi.useRealTimers(); }); }); describe('request deduplication', () => { it('should deduplicate identical concurrent requests', async () => { const client = createApiClient(); const mockData = { data: 'test' }; fetchMock.mockResolvedValue({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => mockData }); // Make two identical concurrent requests const [result1, result2] = await Promise.all([ client.get('/test'), client.get('/test') ]); expect(result1).toEqual(mockData); expect(result2).toEqual(mockData); expect(fetchMock).toHaveBeenCalledTimes(1); // Only one actual fetch }); it('should allow disabling deduplication', async () => { const client = createApiClient(); fetchMock.mockResolvedValue({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({ data: 'test' }) }); await Promise.all([ client.get('/test', { deduplicate: false }), client.get('/test', { deduplicate: false }) ]); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); describe('interceptors', () => { it('should call onRequest interceptor', async () => { const onRequest = vi.fn((url, options) => options); const client = createApiClient({ onRequest }); fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({}) }); await client.get('/test'); expect(onRequest).toHaveBeenCalledWith( '/test', expect.objectContaining({ method: 'GET' }) ); }); it('should call onResponse interceptor', async () => { const onResponse = vi.fn(); const client = createApiClient({ onResponse }); fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({}), clone: function () { return this; } }); await client.get('/test'); expect(onResponse).toHaveBeenCalled(); }); it('should call onError interceptor', async () => { vi.useFakeTimers(); const onError = vi.fn(); const client = createApiClient({ onError, maxRetries: 0 }); fetchMock.mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Server Error', headers: new Headers(), json: async () => ({}) }); await expect(client.get('/error')).rejects.toThrow(); expect(onError).toHaveBeenCalled(); vi.useRealTimers(); }); }); });