test: add comprehensive unit tests for utils and security

- Add security.test.ts: 36 tests for webhook validation, rate limiting
- Add circuit-breaker.test.ts: 31 tests for state transitions
- Add retry.test.ts: 25 tests for exponential backoff
- Add api-helper.test.ts: 25 tests for API abstraction
- Add optimistic-lock.test.ts: 11 tests for concurrency control
- Add summary-service.test.ts: 29 tests for profile system

Total: 157 new test cases (222 passing overall)

- Fix setup.ts D1 schema initialization for Miniflare
- Update vitest.config.ts to exclude demo files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-29 11:38:49 +09:00
parent fbe696b88c
commit 18e7d3ca6e
8 changed files with 3432 additions and 22 deletions

View File

@@ -0,0 +1,753 @@
/**
* Comprehensive Unit Tests for circuit-breaker.ts
*
* 테스트 범위:
* 1. 초기 상태 검증
* 2. CLOSED → OPEN 전환 (failureThreshold)
* 3. OPEN → HALF_OPEN 전환 (resetTimeout)
* 4. HALF_OPEN → CLOSED 전환 (성공 시)
* 5. HALF_OPEN → OPEN 전환 (실패 시)
* 6. 메서드 동작 검증
* 7. Monitoring window 기반 failure cleanup
* 8. 통계 및 메트릭 검증
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
CircuitBreaker,
CircuitState,
CircuitBreakerError,
CircuitBreakerOptions,
} from '../src/utils/circuit-breaker';
// Mock dependencies
vi.mock('../src/utils/metrics', () => ({
metrics: {
record: vi.fn(),
increment: vi.fn(),
},
}));
vi.mock('../src/services/notification', () => ({
notifyAdmin: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../src/utils/logger', () => ({
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
})),
}));
describe('CircuitBreaker', () => {
let breaker: CircuitBreaker;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
describe('Initial State', () => {
it('should start in CLOSED state', () => {
breaker = new CircuitBreaker();
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
it('should allow execution in CLOSED state', async () => {
breaker = new CircuitBreaker();
const mockFn = vi.fn().mockResolvedValue('success');
const result = await breaker.execute(mockFn);
expect(result).toBe('success');
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should initialize with default options', () => {
breaker = new CircuitBreaker();
const stats = breaker.getStats();
expect(stats.config).toEqual({
failureThreshold: 5,
resetTimeoutMs: 60000,
monitoringWindowMs: 120000,
});
});
it('should initialize with custom options', () => {
breaker = new CircuitBreaker({
failureThreshold: 3,
resetTimeoutMs: 30000,
monitoringWindowMs: 60000,
serviceName: 'test-service',
});
const stats = breaker.getStats();
expect(stats.config).toEqual({
failureThreshold: 3,
resetTimeoutMs: 30000,
monitoringWindowMs: 60000,
});
});
});
describe('CLOSED → OPEN Transition', () => {
beforeEach(() => {
breaker = new CircuitBreaker({
failureThreshold: 3,
serviceName: 'test-service',
});
});
it('should open circuit after threshold failures', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// 3번 실패
for (let i = 0; i < 3; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected failure
}
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('should block requests when circuit is OPEN', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Trigger opening
for (let i = 0; i < 3; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
// Next request should be blocked
await expect(
breaker.execute(mockFn)
).rejects.toThrow(CircuitBreakerError);
// mockFn should not be called (circuit is open)
expect(mockFn).toHaveBeenCalledTimes(3); // Only from previous calls
});
it('should throw CircuitBreakerError with correct state', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Open the circuit
for (let i = 0; i < 3; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
try {
await breaker.execute(mockFn);
expect.fail('Should have thrown CircuitBreakerError');
} catch (error) {
expect(error).toBeInstanceOf(CircuitBreakerError);
expect((error as CircuitBreakerError).state).toBe(CircuitState.OPEN);
expect((error as CircuitBreakerError).message).toContain('Circuit breaker is open');
}
});
it('should record failures within threshold without opening', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// 2번 실패 (threshold는 3)
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
expect(breaker.getState()).toBe(CircuitState.CLOSED);
const stats = breaker.getStats();
expect(stats.failures).toBe(2);
});
});
describe('OPEN → HALF_OPEN Transition', () => {
beforeEach(() => {
breaker = new CircuitBreaker({
failureThreshold: 2,
resetTimeoutMs: 10000, // 10초
serviceName: 'test-service',
});
});
it('should transition to HALF_OPEN after resetTimeout', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Open circuit
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
// Advance time by resetTimeout
vi.advanceTimersByTime(10000);
// Next execute() should check reset timeout
mockFn.mockResolvedValueOnce('success');
await breaker.execute(mockFn);
// Should have transitioned to HALF_OPEN, then CLOSED after success
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
it('should allow one test request in HALF_OPEN state', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Open circuit
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
// Wait for reset timeout
vi.advanceTimersByTime(10000);
// Test request should succeed
mockFn.mockResolvedValueOnce('success');
const result = await breaker.execute(mockFn);
expect(result).toBe('success');
});
it('should not transition before resetTimeout elapsed', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Open circuit
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
// Advance time but not enough
vi.advanceTimersByTime(5000); // Only 5 seconds
// Should still be OPEN
await expect(
breaker.execute(mockFn)
).rejects.toThrow(CircuitBreakerError);
expect(breaker.getState()).toBe(CircuitState.OPEN);
});
});
describe('HALF_OPEN → CLOSED Transition', () => {
beforeEach(() => {
breaker = new CircuitBreaker({
failureThreshold: 2,
resetTimeoutMs: 10000,
serviceName: 'test-service',
});
});
it('should close circuit on successful test request', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Open circuit
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
// Wait and succeed
vi.advanceTimersByTime(10000);
mockFn.mockResolvedValueOnce('success');
await breaker.execute(mockFn);
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
it('should clear failures after closing', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Open circuit
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
expect(breaker.getStats().failures).toBe(2);
// Wait and succeed
vi.advanceTimersByTime(10000);
mockFn.mockResolvedValueOnce('success');
await breaker.execute(mockFn);
// Failures should be cleared
expect(breaker.getStats().failures).toBe(0);
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
});
describe('HALF_OPEN → OPEN Transition', () => {
beforeEach(() => {
breaker = new CircuitBreaker({
failureThreshold: 2,
resetTimeoutMs: 10000,
serviceName: 'test-service',
});
});
it('should reopen circuit on failed test request', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Open circuit
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
// Wait for reset timeout
vi.advanceTimersByTime(10000);
// Test request fails
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
// Should be back to OPEN
expect(breaker.getState()).toBe(CircuitState.OPEN);
});
it('should block subsequent requests after reopening', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Open circuit
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
// Wait and fail test
vi.advanceTimersByTime(10000);
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
// Next request should be blocked
await expect(
breaker.execute(mockFn)
).rejects.toThrow(CircuitBreakerError);
});
});
describe('reset() Method', () => {
it('should manually reset circuit to CLOSED', async () => {
breaker = new CircuitBreaker({ failureThreshold: 2 });
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Open circuit
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
// Manual reset
breaker.reset();
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
it('should clear all failures and stats on reset', async () => {
breaker = new CircuitBreaker({ failureThreshold: 2 });
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Record some failures
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
const statsBefore = breaker.getStats();
expect(statsBefore.failures).toBe(2);
expect(statsBefore.stats.totalFailures).toBe(2);
breaker.reset();
const statsAfter = breaker.getStats();
expect(statsAfter.failures).toBe(0);
expect(statsAfter.stats.totalFailures).toBe(0);
expect(statsAfter.stats.totalSuccesses).toBe(0);
});
it('should allow execution after reset', async () => {
breaker = new CircuitBreaker({ failureThreshold: 2 });
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Open circuit
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
breaker.reset();
// Should allow execution
mockFn.mockResolvedValueOnce('success');
const result = await breaker.execute(mockFn);
expect(result).toBe('success');
});
});
describe('Monitoring Window Cleanup', () => {
it('should clean up old failures outside monitoring window', async () => {
breaker = new CircuitBreaker({
failureThreshold: 5,
monitoringWindowMs: 10000, // 10초
});
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Record 2 failures
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
expect(breaker.getStats().failures).toBe(2);
// Advance time beyond monitoring window
vi.advanceTimersByTime(11000);
// Record another failure (triggers cleanup)
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
// Old failures should be cleaned up
const stats = breaker.getStats();
expect(stats.failures).toBe(1); // Only the latest failure
});
it('should not open circuit if old failures are cleaned up', async () => {
breaker = new CircuitBreaker({
failureThreshold: 3,
monitoringWindowMs: 10000,
});
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
// Record 2 failures
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
// Advance time to clean up failures
vi.advanceTimersByTime(11000);
// Record one more failure (total recent: 1)
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
// Should still be CLOSED (only 1 recent failure)
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
});
describe('Statistics and Metrics', () => {
beforeEach(() => {
breaker = new CircuitBreaker({
failureThreshold: 3,
serviceName: 'test-service',
});
});
it('should track success count', async () => {
const mockFn = vi.fn().mockResolvedValue('success');
await breaker.execute(mockFn);
await breaker.execute(mockFn);
const stats = breaker.getStats();
expect(stats.stats.totalSuccesses).toBe(2);
expect(stats.stats.totalRequests).toBe(2);
});
it('should track failure count', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
const stats = breaker.getStats();
expect(stats.stats.totalFailures).toBe(2);
expect(stats.failures).toBe(2);
});
it('should track last failure time', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
const beforeTime = Date.now();
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
const afterTime = Date.now();
const stats = breaker.getStats();
expect(stats.lastFailureTime).toBeDefined();
const failureTime = stats.lastFailureTime!.getTime();
expect(failureTime).toBeGreaterThanOrEqual(beforeTime);
expect(failureTime).toBeLessThanOrEqual(afterTime);
});
it('should return correct state in stats', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Service error'));
const initialStats = breaker.getStats();
expect(initialStats.state).toBe(CircuitState.CLOSED);
// Open circuit
for (let i = 0; i < 3; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
const openStats = breaker.getStats();
expect(openStats.state).toBe(CircuitState.OPEN);
});
});
describe('Edge Cases', () => {
it('should handle synchronous errors', async () => {
breaker = new CircuitBreaker({ failureThreshold: 2 });
const mockFn = vi.fn().mockImplementation(() => {
throw new Error('Sync error');
});
try {
await breaker.execute(mockFn);
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
expect(breaker.getStats().failures).toBe(1);
});
it('should handle non-Error throws', async () => {
breaker = new CircuitBreaker({ failureThreshold: 2 });
const mockFn = vi.fn().mockRejectedValue('string error');
try {
await breaker.execute(mockFn);
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
expect(breaker.getStats().failures).toBe(1);
});
it('should handle multiple state transitions', async () => {
breaker = new CircuitBreaker({
failureThreshold: 2,
resetTimeoutMs: 5000,
});
const mockFn = vi.fn();
// CLOSED → OPEN
mockFn.mockRejectedValue(new Error('Error'));
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
// OPEN → HALF_OPEN → CLOSED
vi.advanceTimersByTime(5000);
mockFn.mockResolvedValueOnce('success');
await breaker.execute(mockFn);
expect(breaker.getState()).toBe(CircuitState.CLOSED);
// CLOSED → OPEN again
mockFn.mockRejectedValue(new Error('Error'));
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
});
it('should handle rapid successive calls', async () => {
breaker = new CircuitBreaker({ failureThreshold: 5 });
const mockFn = vi.fn().mockResolvedValue('success');
// Execute 10 times rapidly
const promises = Array.from({ length: 10 }, () =>
breaker.execute(mockFn)
);
await Promise.all(promises);
expect(mockFn).toHaveBeenCalledTimes(10);
expect(breaker.getState()).toBe(CircuitState.CLOSED);
const stats = breaker.getStats();
expect(stats.stats.totalSuccesses).toBe(10);
});
});
describe('Configuration Options', () => {
it('should respect custom failureThreshold', async () => {
breaker = new CircuitBreaker({ failureThreshold: 10 });
const mockFn = vi.fn().mockRejectedValue(new Error('Error'));
// 9 failures should not open circuit
for (let i = 0; i < 9; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
expect(breaker.getState()).toBe(CircuitState.CLOSED);
// 10th failure should open it
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
expect(breaker.getState()).toBe(CircuitState.OPEN);
});
it('should respect custom resetTimeoutMs', async () => {
breaker = new CircuitBreaker({
failureThreshold: 2,
resetTimeoutMs: 20000, // 20초
});
const mockFn = vi.fn().mockRejectedValue(new Error('Error'));
// Open circuit
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(mockFn);
} catch (error) {
// Expected
}
}
// 10초는 부족
vi.advanceTimersByTime(10000);
await expect(breaker.execute(mockFn)).rejects.toThrow(CircuitBreakerError);
// 20초면 충분
vi.advanceTimersByTime(10000);
mockFn.mockResolvedValueOnce('success');
await breaker.execute(mockFn);
expect(breaker.getState()).toBe(CircuitState.CLOSED);
});
it('should use custom serviceName in stats', () => {
breaker = new CircuitBreaker({ serviceName: 'custom-service' });
// serviceName은 내부적으로만 사용되므로 logger mock으로 확인
// 실제로는 getStats()에 serviceName이 포함되지 않지만,
// 초기화 시 logger.info에 전달되었는지 확인 가능
expect(breaker).toBeDefined();
});
});
});