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,222 @@
/**
* Comprehensive Unit Tests for optimistic-lock.ts
*
* 테스트 범위:
* 1. executeWithOptimisticLock() 첫 시도 성공
* 2. Version 충돌 시 자동 재시도
* 3. 최대 재시도 실패 (모든 시도 실패)
* 4. 지수 백오프 지연 검증
* 5. 비 OptimisticLockError 즉시 전파
* 6. 재시도 횟수 커스터마이징
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
executeWithOptimisticLock,
OptimisticLockError,
} from '../src/utils/optimistic-lock';
describe('OptimisticLockError', () => {
it('should be an instance of Error', () => {
const error = new OptimisticLockError('test message');
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe('OptimisticLockError');
expect(error.message).toBe('test message');
});
});
describe('executeWithOptimisticLock', () => {
// Mock D1 Database (not used by optimistic-lock logic, only passed through)
const mockDb = {} as D1Database;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('성공 케이스', () => {
it('should succeed on first attempt', async () => {
const mockOperation = vi.fn().mockResolvedValue('success');
const result = await executeWithOptimisticLock(mockDb, mockOperation);
expect(result).toBe('success');
expect(mockOperation).toHaveBeenCalledTimes(1);
expect(mockOperation).toHaveBeenCalledWith(1);
});
it('should pass attempt number to operation', async () => {
const attemptNumbers: number[] = [];
const mockOperation = vi.fn().mockImplementation(async (attempt: number) => {
attemptNumbers.push(attempt);
return `attempt ${attempt}`;
});
const result = await executeWithOptimisticLock(mockDb, mockOperation);
expect(result).toBe('attempt 1');
expect(attemptNumbers).toEqual([1]);
});
});
describe('재시도 케이스', () => {
it('should retry on OptimisticLockError and succeed on second attempt', async () => {
let callCount = 0;
const mockOperation = vi.fn().mockImplementation(async () => {
callCount++;
if (callCount === 1) {
throw new OptimisticLockError('Version mismatch');
}
return 'success on retry';
});
// Start operation
const promise = executeWithOptimisticLock(mockDb, mockOperation);
// Wait for first failure and retry delay
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
expect(result).toBe('success on retry');
expect(mockOperation).toHaveBeenCalledTimes(2);
expect(mockOperation).toHaveBeenNthCalledWith(1, 1);
expect(mockOperation).toHaveBeenNthCalledWith(2, 2);
});
it('should retry with exponential backoff', async () => {
let callCount = 0;
const mockOperation = vi.fn().mockImplementation(async () => {
callCount++;
if (callCount <= 2) {
throw new OptimisticLockError('Version mismatch');
}
return 'success';
});
// Start operation
const promise = executeWithOptimisticLock(mockDb, mockOperation);
// First failure: 100ms delay
await vi.advanceTimersByTimeAsync(100);
// Second failure: 200ms delay
await vi.advanceTimersByTimeAsync(200);
const result = await promise;
expect(result).toBe('success');
expect(mockOperation).toHaveBeenCalledTimes(3);
});
it('should respect custom maxRetries parameter', async () => {
const mockOperation = vi.fn().mockRejectedValue(new OptimisticLockError('conflict'));
// Start operation with 5 max retries
const promise = executeWithOptimisticLock(mockDb, mockOperation, 5);
// Advance through all retry delays
await vi.advanceTimersByTimeAsync(100); // 1st retry
await vi.advanceTimersByTimeAsync(200); // 2nd retry
await vi.advanceTimersByTimeAsync(400); // 3rd retry
await vi.advanceTimersByTimeAsync(800); // 4th retry
// Wait for final rejection
await expect(promise).rejects.toThrow('처리 중 동시성 충돌이 발생했습니다');
expect(mockOperation).toHaveBeenCalledTimes(5);
});
});
describe('실패 케이스', () => {
it('should throw after max retries exhausted', async () => {
const mockOperation = vi.fn().mockRejectedValue(new OptimisticLockError('conflict'));
// Start operation (default 3 retries)
const promise = executeWithOptimisticLock(mockDb, mockOperation);
// Advance through all retry delays
await vi.advanceTimersByTimeAsync(100); // 1st retry
await vi.advanceTimersByTimeAsync(200); // 2nd retry
// Wait for final rejection
await expect(promise).rejects.toThrow(
'처리 중 동시성 충돌이 발생했습니다. 다시 시도해주세요. (3회 재시도 실패)'
);
expect(mockOperation).toHaveBeenCalledTimes(3);
});
it('should propagate non-OptimisticLockError immediately', async () => {
const customError = new Error('Database connection failed');
const mockOperation = vi.fn().mockRejectedValue(customError);
const promise = executeWithOptimisticLock(mockDb, mockOperation);
// No timer advancement needed - should fail immediately
await expect(promise).rejects.toThrow('Database connection failed');
expect(mockOperation).toHaveBeenCalledTimes(1); // No retry
});
it('should not retry on TypeError', async () => {
const mockOperation = vi.fn().mockRejectedValue(new TypeError('Invalid argument'));
const promise = executeWithOptimisticLock(mockDb, mockOperation);
// No timer advancement needed - should fail immediately
await expect(promise).rejects.toThrow('Invalid argument');
expect(mockOperation).toHaveBeenCalledTimes(1);
});
});
describe('지수 백오프 검증', () => {
it('should wait correct delays: 100ms, 200ms, 400ms', async () => {
const delays: number[] = [];
let callCount = 0;
const mockOperation = vi.fn().mockImplementation(async () => {
callCount++;
if (callCount <= 3) {
throw new OptimisticLockError('conflict');
}
return 'success';
});
// Start operation with 4 max retries
const promise = executeWithOptimisticLock(mockDb, mockOperation, 4);
// Measure delays between retries
const start1 = Date.now();
await vi.advanceTimersByTimeAsync(100);
delays.push(Date.now() - start1);
const start2 = Date.now();
await vi.advanceTimersByTimeAsync(200);
delays.push(Date.now() - start2);
const start3 = Date.now();
await vi.advanceTimersByTimeAsync(400);
delays.push(Date.now() - start3);
await promise;
// Verify exponential backoff delays
expect(delays).toEqual([100, 200, 400]);
expect(mockOperation).toHaveBeenCalledTimes(4);
});
});
describe('실제 시나리오 (통합 테스트)', () => {
it.skip('should simulate concurrent balance update with version conflict', async () => {
// NOTE: This test requires Miniflare D1 Database binding
// Run with proper test environment configuration to enable
//
// Test scenario:
// 1. Create user with initial balance
// 2. Simulate concurrent update (version conflict)
// 3. Verify retry mechanism works with real DB
// 4. Confirm final balance and version incremented
});
});
});