Files
telegram-bot-workers/tests/optimistic-lock.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

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