diff --git a/code/websites/pokedex.online/tests/unit/utilities/api-client.test.js b/code/websites/pokedex.online/tests/unit/utilities/api-client.test.js new file mode 100644 index 0000000..053b81a --- /dev/null +++ b/code/websites/pokedex.online/tests/unit/utilities/api-client.test.js @@ -0,0 +1,274 @@ +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', + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }) + }) + ); + }); + + 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(); + }); + }); +});