- Attach rejects handler before advancing timers (vitest 2.x strict mode) - Fix FK constraint cleanup order in test setup - Fix 7-char prefix matching test data - Add INSERT OR IGNORE for deposit concurrency safety - Add secondary ORDER BY for deterministic transaction ordering - Update summary-service test assertions to match current prompt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
618 lines
16 KiB
TypeScript
618 lines
16 KiB
TypeScript
/**
|
|
* 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<typeof mockData>('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');
|
|
});
|
|
});
|
|
});
|