/** * 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); // Attach error handler before advancing timers (vitest 2.x strict mode) const expectPromise = expect(promise).rejects.toThrow('처리 중 동시성 충돌이 발생했습니다'); // 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 expectPromise; 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); // Attach error handler before advancing timers (vitest 2.x strict mode) const expectPromise = expect(promise).rejects.toThrow( '처리 중 동시성 충돌이 발생했습니다. 다시 시도해주세요. (3회 재시도 실패)' ); // Advance through all retry delays await vi.advanceTimersByTimeAsync(100); // 1st retry await vi.advanceTimersByTimeAsync(200); // 2nd retry // Wait for final rejection await expectPromise; 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 }); }); });