- 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>
227 lines
7.7 KiB
TypeScript
227 lines
7.7 KiB
TypeScript
/**
|
|
* 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
|
|
});
|
|
});
|
|
});
|