Files
telegram-bot-workers/tests/api-helper.test.ts
kappa bd25316fd3 fix: resolve all test failures after vitest 2.x upgrade
- 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>
2026-02-07 19:41:11 +09:00

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');
});
});
});