/** * Comprehensive Unit Tests for api-helper.ts * * 테스트 범위: * 1. 기본 GET/POST 요청 * 2. HTTP 에러 처리 (4xx, 5xx) * 3. Zod 스키마 검증 * 4. 타임아웃 처리 * 5. 재시도 로직 통합 * 6. 커스텀 헤더 및 옵션 */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { z } from 'zod'; import { callApi } from '../src/utils/api-helper'; // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch as any; describe('callApi', () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); describe('기본 GET 요청', () => { it('should perform GET request with default options', async () => { const mockData = { status: 'ok', message: 'success' }; mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockData, }); const promise = callApi('https://api.example.com/health'); // 재시도 타이머 진행 await vi.runAllTimersAsync(); const result = await promise; expect(result).toEqual(mockData); expect(mockFetch).toHaveBeenCalledWith( 'https://api.example.com/health', expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ 'Accept': 'application/json', 'Content-Type': 'application/json', }), }) ); }); it('should parse JSON response correctly', async () => { const mockData = { id: 123, name: 'Test User', email: 'test@example.com', }; mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockData, }); const promise = callApi('https://api.example.com/user/123'); await vi.runAllTimersAsync(); const result = await promise; expect(result).toEqual(mockData); expect(result.id).toBe(123); expect(result.name).toBe('Test User'); }); }); describe('POST 요청', () => { it('should serialize body to JSON', async () => { const mockResponse = { success: true }; mockFetch.mockResolvedValueOnce({ ok: true, status: 201, json: async () => mockResponse, }); const requestBody = { name: 'New Item', value: 42 }; const promise = callApi('https://api.example.com/items', { method: 'POST', body: requestBody, }); await vi.runAllTimersAsync(); await promise; expect(mockFetch).toHaveBeenCalledWith( 'https://api.example.com/items', expect.objectContaining({ method: 'POST', body: JSON.stringify(requestBody), }) ); }); it('should set Content-Type header for POST', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}), }); const promise = callApi('https://api.example.com/data', { method: 'POST', body: { test: 'data' }, }); await vi.runAllTimersAsync(); await promise; expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ 'Content-Type': 'application/json', }), }) ); }); }); describe('HTTP 에러 처리', () => { it('should throw error on 4xx client error', async () => { mockFetch.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found', json: async () => ({ error: 'Resource not found' }), }); const promise = callApi('https://api.example.com/missing', { retries: 0, // 재시도 없음 }); const expectPromise = expect(promise).rejects.toThrow(); await vi.runAllTimersAsync(); await expectPromise; }); it('should throw error on 5xx server error', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', json: async () => ({ error: 'Server crashed' }), }); const promise = callApi('https://api.example.com/error', { retries: 0, }); const expectPromise = expect(promise).rejects.toThrow(); await vi.runAllTimersAsync(); await expectPromise; }); it('should include status code in error message', async () => { mockFetch.mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden', json: async () => ({}), }); const promise = callApi('https://api.example.com/forbidden', { retries: 0, }); const expectPromise = expect(promise).rejects.toThrow(/403/); await vi.runAllTimersAsync(); await expectPromise; }); }); describe('Zod 스키마 검증', () => { const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), }); it('should validate response with Zod schema (valid)', async () => { const validData = { id: 1, name: 'John Doe', email: 'john@example.com', }; mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validData, }); const promise = callApi('https://api.example.com/user', { schema: UserSchema, }); await vi.runAllTimersAsync(); const result = await promise; expect(result).toEqual(validData); expect(UserSchema.safeParse(result).success).toBe(true); }); it('should throw error on invalid schema (missing field)', async () => { const invalidData = { id: 1, name: 'John Doe', // email 누락 }; mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => invalidData, }); const promise = callApi('https://api.example.com/user', { schema: UserSchema, retries: 0, }); const expectPromise = expect(promise).rejects.toThrow(); await vi.runAllTimersAsync(); await expectPromise; }); it('should throw error on invalid schema (wrong type)', async () => { const invalidData = { id: '123', // 숫자가 아닌 문자열 name: 'John Doe', email: 'john@example.com', }; mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => invalidData, }); const promise = callApi('https://api.example.com/user', { schema: UserSchema, retries: 0, }); const expectPromise = expect(promise).rejects.toThrow(); await vi.runAllTimersAsync(); await expectPromise; }); it('should throw error on invalid email format', async () => { const invalidData = { id: 1, name: 'John Doe', email: 'invalid-email', // 잘못된 이메일 형식 }; mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => invalidData, }); const promise = callApi('https://api.example.com/user', { schema: UserSchema, retries: 0, }); const expectPromise = expect(promise).rejects.toThrow(); await vi.runAllTimersAsync(); await expectPromise; }); }); describe('타임아웃', () => { it('should abort request after timeout (default 30s)', async () => { mockFetch.mockImplementation(async (_url, options) => { // 타임아웃 이후까지 대기 await new Promise(resolve => setTimeout(resolve, 35000)); if (options?.signal?.aborted) { throw new DOMException('The operation was aborted', 'AbortError'); } return { ok: true, status: 200, json: async () => ({}), }; }); const promise = callApi('https://api.example.com/slow', { retries: 0, }); // 타임아웃 전에는 에러 없음 await vi.advanceTimersByTimeAsync(29000); // 타임아웃 발생 await vi.advanceTimersByTimeAsync(2000); const expectPromise = expect(promise).rejects.toThrow(); await vi.runAllTimersAsync(); await expectPromise; }); it('should use custom timeout value', async () => { mockFetch.mockImplementation(async (_url, options) => { await new Promise(resolve => setTimeout(resolve, 6000)); if (options?.signal?.aborted) { throw new DOMException('The operation was aborted', 'AbortError'); } return { ok: true, status: 200, json: async () => ({}), }; }); const promise = callApi('https://api.example.com/slow', { timeout: 5000, // 5초 타임아웃 retries: 0, }); // 타임아웃 전 await vi.advanceTimersByTimeAsync(4000); // 타임아웃 발생 await vi.advanceTimersByTimeAsync(2000); const expectPromise = expect(promise).rejects.toThrow(); await vi.runAllTimersAsync(); await expectPromise; }); it('should complete before timeout', async () => { const mockData = { status: 'ok' }; mockFetch.mockImplementation(async () => { // 2초만 걸림 await new Promise(resolve => setTimeout(resolve, 2000)); return { ok: true, status: 200, json: async () => mockData, }; }); const promise = callApi('https://api.example.com/fast', { timeout: 5000, }); await vi.advanceTimersByTimeAsync(2500); await vi.runAllTimersAsync(); const result = await promise; expect(result).toEqual(mockData); }); }); describe('재시도 로직', () => { it('should retry on failure (default 3 times)', async () => { let attempts = 0; mockFetch.mockImplementation(async () => { attempts++; if (attempts < 3) { return { ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}), }; } return { ok: true, status: 200, json: async () => ({ success: true }), }; }); const promise = callApi('https://api.example.com/unstable'); await vi.runAllTimersAsync(); const result = await promise; expect(result).toEqual({ success: true }); expect(attempts).toBe(3); }); it('should use custom retry count', async () => { let attempts = 0; mockFetch.mockImplementation(async () => { attempts++; if (attempts < 5) { return { ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}), }; } return { ok: true, status: 200, json: async () => ({ success: true }), }; }); const promise = callApi('https://api.example.com/very-unstable', { retries: 5, }); await vi.runAllTimersAsync(); const result = await promise; expect(result).toEqual({ success: true }); expect(attempts).toBe(5); }); it('should throw after exhausting retries', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', json: async () => ({}), }); const promise = callApi('https://api.example.com/always-fails', { retries: 2, }); const expectPromise = expect(promise).rejects.toThrow(); await vi.runAllTimersAsync(); await expectPromise; expect(mockFetch).toHaveBeenCalledTimes(3); // 초기 + 2회 재시도 }); it('should not retry on 4xx errors (client errors)', async () => { mockFetch.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found', json: async () => ({}), }); const promise = callApi('https://api.example.com/missing', { retries: 3, }); const expectPromise = expect(promise).rejects.toThrow(); await vi.runAllTimersAsync(); await expectPromise; // 재시도 로직은 4xx 에러를 재시도하지 않음 (retryWithBackoff 구현에 따라 다름) }); }); describe('커스텀 헤더', () => { it('should merge custom headers with defaults', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}), }); const promise = callApi('https://api.example.com/auth', { headers: { 'Authorization': 'Bearer token123', 'X-Custom-Header': 'value', }, }); await vi.runAllTimersAsync(); await promise; expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer token123', 'X-Custom-Header': 'value', }), }) ); }); it('should override default headers with custom ones', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}), }); const promise = callApi('https://api.example.com/custom', { headers: { 'Content-Type': 'application/xml', // Override }, }); await vi.runAllTimersAsync(); await promise; expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ 'Content-Type': 'application/xml', }), }) ); }); }); describe('HTTP 메서드', () => { it('should support PUT method', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ updated: true }), }); const promise = callApi('https://api.example.com/item/123', { method: 'PUT', body: { name: 'Updated' }, }); await vi.runAllTimersAsync(); await promise; expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'PUT', }) ); }); it('should support DELETE method', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}), }); const promise = callApi('https://api.example.com/item/123', { method: 'DELETE', }); await vi.runAllTimersAsync(); await promise; expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'DELETE', }) ); }); }); describe('Edge Cases', () => { it('should handle empty response body', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 204, json: async () => null, }); const promise = callApi('https://api.example.com/no-content'); await vi.runAllTimersAsync(); const result = await promise; expect(result).toBeNull(); }); it('should handle array response', async () => { const mockArray = [ { id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }, ]; mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockArray, }); const promise = callApi('https://api.example.com/items'); await vi.runAllTimersAsync(); const result = await promise; expect(result).toEqual(mockArray); expect(Array.isArray(result)).toBe(true); }); it('should handle nested objects', async () => { const mockData = { user: { profile: { name: 'Test', settings: { theme: 'dark', }, }, }, }; mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockData, }); const promise = callApi('https://api.example.com/user/profile'); await vi.runAllTimersAsync(); const result = await promise; expect(result).toEqual(mockData); expect(result.user.profile.settings.theme).toBe('dark'); }); }); });