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

618 lines
19 KiB
TypeScript

/**
* Comprehensive Unit Tests for retry.ts
*
* 테스트 범위:
* 1. 즉시 성공 (재시도 없음)
* 2. 재시도 후 성공 (N번 실패 후)
* 3. 모든 재시도 실패 (RetryError)
* 4. 지수 백오프 검증 (delay 증가)
* 5. maxDelay 상한선 확인
* 6. Jitter 적용 확인
* 7. 커스텀 옵션 테스트
* 8. RetryError 속성 검증
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { retryWithBackoff, RetryError, RetryOptions } from '../src/utils/retry';
import { metrics } from '../src/utils/metrics';
// Mock dependencies
vi.mock('../src/utils/metrics', () => ({
metrics: {
increment: vi.fn(),
gauge: vi.fn(),
timing: vi.fn(),
},
}));
vi.mock('../src/services/notification', () => ({
notifyAdmin: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../src/utils/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
startTimer: vi.fn(() => vi.fn()),
}),
}));
describe('retryWithBackoff', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('즉시 성공', () => {
it('should return result on first attempt', async () => {
const mockFn = vi.fn().mockResolvedValue('success');
const promise = retryWithBackoff(mockFn);
await vi.runAllTimersAsync();
const result = await promise;
expect(result).toBe('success');
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should not add any delays on immediate success', async () => {
const mockFn = vi.fn().mockResolvedValue(42);
const promise = retryWithBackoff(mockFn);
await vi.runAllTimersAsync();
const result = await promise;
expect(result).toBe(42);
expect(vi.getTimerCount()).toBe(0); // No pending timers
});
it('should handle complex return types', async () => {
const mockData = { id: 1, name: 'test', values: [1, 2, 3] };
const mockFn = vi.fn().mockResolvedValue(mockData);
const promise = retryWithBackoff(mockFn);
await vi.runAllTimersAsync();
const result = await promise;
expect(result).toEqual(mockData);
});
});
describe('재시도 후 성공', () => {
it('should succeed after 1 failure', async () => {
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('First failure'))
.mockResolvedValueOnce('success');
const promise = retryWithBackoff(mockFn, { maxRetries: 3 });
// Fast-forward through delays
await vi.runAllTimersAsync();
const result = await promise;
expect(result).toBe('success');
expect(mockFn).toHaveBeenCalledTimes(2); // Initial + 1 retry
});
it('should succeed after 2 failures', async () => {
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('First failure'))
.mockRejectedValueOnce(new Error('Second failure'))
.mockResolvedValueOnce('success');
const promise = retryWithBackoff(mockFn, { maxRetries: 3 });
await vi.runAllTimersAsync();
const result = await promise;
expect(result).toBe('success');
expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
});
it('should succeed on last attempt', async () => {
const maxRetries = 3;
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('Fail 1'))
.mockRejectedValueOnce(new Error('Fail 2'))
.mockRejectedValueOnce(new Error('Fail 3'))
.mockResolvedValueOnce('success on last');
const promise = retryWithBackoff(mockFn, { maxRetries });
await vi.runAllTimersAsync();
const result = await promise;
expect(result).toBe('success on last');
expect(mockFn).toHaveBeenCalledTimes(4); // Initial + 3 retries
});
});
describe('모든 재시도 실패', () => {
it('should throw RetryError after exhausting all retries', async () => {
const originalError = new Error('Persistent failure');
const mockFn = vi.fn().mockRejectedValue(originalError);
const promise = retryWithBackoff(mockFn, { maxRetries: 2 });
// Attach error handler before advancing timers
const expectPromise = expect(promise).rejects.toThrow(RetryError);
await vi.runAllTimersAsync();
await expectPromise;
expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
});
it('should preserve original error in RetryError', async () => {
const originalError = new Error('Original failure');
originalError.stack = 'Original stack trace';
const mockFn = vi.fn().mockRejectedValue(originalError);
const promise = retryWithBackoff(mockFn, { maxRetries: 1 });
// Attach catch handler before advancing timers
const errorPromise = promise.catch(error => error);
await vi.runAllTimersAsync();
const error = await errorPromise;
expect(error).toBeInstanceOf(RetryError);
const retryError = error as RetryError;
expect(retryError.name).toBe('RetryError');
expect(retryError.attempts).toBe(2); // Initial + 1 retry
expect(retryError.lastError).toBe(originalError);
expect(retryError.lastError.message).toBe('Original failure');
expect(retryError.lastError.stack).toBe('Original stack trace');
});
it('should include attempts count in RetryError message', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('API error'));
const promise = retryWithBackoff(mockFn, { maxRetries: 2 });
// Attach catch handler before advancing timers
const errorPromise = promise.catch(error => error);
await vi.runAllTimersAsync();
const error = await errorPromise;
const retryError = error as RetryError;
expect(retryError.message).toContain('3 attempts'); // Initial + 2 retries
expect(retryError.message).toContain('API error');
});
});
describe('지수 백오프 검증', () => {
it('should apply exponential backoff with default multiplier (2x)', async () => {
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('Fail 1'))
.mockRejectedValueOnce(new Error('Fail 2'))
.mockResolvedValueOnce('success');
const initialDelay = 100;
const promise = retryWithBackoff(mockFn, {
maxRetries: 3,
initialDelayMs: initialDelay,
jitter: false, // Disable jitter for predictable testing
});
// First failure -> delay ~100ms (2^0 * 100)
await vi.advanceTimersByTimeAsync(100);
expect(mockFn).toHaveBeenCalledTimes(2); // Initial + 1st retry
// Second failure -> delay ~200ms (2^1 * 100)
await vi.advanceTimersByTimeAsync(200);
expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2nd retry
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
});
it('should respect custom backoff multiplier', async () => {
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('Fail 1'))
.mockRejectedValueOnce(new Error('Fail 2'))
.mockResolvedValueOnce('success');
const initialDelay = 100;
const promise = retryWithBackoff(mockFn, {
maxRetries: 3,
initialDelayMs: initialDelay,
backoffMultiplier: 3, // 3x instead of 2x
jitter: false,
});
// First failure -> delay ~100ms (3^0 * 100)
await vi.advanceTimersByTimeAsync(100);
expect(mockFn).toHaveBeenCalledTimes(2);
// Second failure -> delay ~300ms (3^1 * 100)
await vi.advanceTimersByTimeAsync(300);
expect(mockFn).toHaveBeenCalledTimes(3);
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
});
});
describe('maxDelay 상한선 확인', () => {
it('should cap delay at maxDelayMs', async () => {
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('Fail 1'))
.mockRejectedValueOnce(new Error('Fail 2'))
.mockRejectedValueOnce(new Error('Fail 3'))
.mockResolvedValueOnce('success');
const maxDelay = 500;
const promise = retryWithBackoff(mockFn, {
maxRetries: 5,
initialDelayMs: 100,
maxDelayMs: maxDelay,
jitter: false,
});
// First retry: 100ms (2^0 * 100)
await vi.advanceTimersByTimeAsync(100);
expect(mockFn).toHaveBeenCalledTimes(2);
// Second retry: 200ms (2^1 * 100)
await vi.advanceTimersByTimeAsync(200);
expect(mockFn).toHaveBeenCalledTimes(3);
// Third retry: Should be 400ms (2^2 * 100), still under 500ms cap
await vi.advanceTimersByTimeAsync(400);
expect(mockFn).toHaveBeenCalledTimes(4);
// Fourth retry: Would be 800ms (2^3 * 100), but capped at 500ms
await vi.advanceTimersByTimeAsync(500);
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
});
it('should use default maxDelay (10000ms)', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Always fail'));
const promise = retryWithBackoff(mockFn, {
maxRetries: 20, // Many retries to test cap
initialDelayMs: 1000,
jitter: false,
});
// Attach error handler before advancing timers
const expectPromise = expect(promise).rejects.toThrow(RetryError);
// After several retries, delay should cap at 10000ms
// Let's verify it doesn't exceed 10000ms by checking timer behavior
await vi.runAllTimersAsync();
await expectPromise;
expect(mockFn).toHaveBeenCalledTimes(21); // Initial + 20 retries
});
});
describe('Jitter 적용 확인', () => {
it('should apply jitter by default (±20%)', async () => {
const delays: number[] = [];
let callCount = 0;
const mockFn = vi.fn().mockImplementation(async () => {
callCount++;
if (callCount < 4) {
// Record the delay before this call (if not first)
throw new Error(`Fail ${callCount}`);
}
return 'success';
});
// Spy on setTimeout to capture actual delays
const originalSetTimeout = global.setTimeout;
const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => {
if (typeof ms === 'number' && ms > 0) {
delays.push(ms);
}
return originalSetTimeout(callback as () => void, ms);
});
const promise = retryWithBackoff(mockFn, {
maxRetries: 5,
initialDelayMs: 1000,
jitter: true, // Enable jitter (default)
});
await vi.runAllTimersAsync();
await promise;
setTimeoutSpy.mockRestore();
// With jitter, delays should vary within ±20% of expected values
// Expected base delays (without jitter): [1000, 2000, 4000]
expect(delays.length).toBeGreaterThan(0);
// First delay should be ~1000ms ±20% (800-1200ms)
if (delays[0]) {
expect(delays[0]).toBeGreaterThanOrEqual(800);
expect(delays[0]).toBeLessThanOrEqual(1200);
}
});
it('should not apply jitter when disabled', async () => {
const delays: number[] = [];
let callCount = 0;
const mockFn = vi.fn().mockImplementation(async () => {
callCount++;
if (callCount < 3) {
throw new Error(`Fail ${callCount}`);
}
return 'success';
});
const originalSetTimeout = global.setTimeout;
const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => {
if (typeof ms === 'number' && ms > 0) {
delays.push(ms);
}
return originalSetTimeout(callback as () => void, ms);
});
const promise = retryWithBackoff(mockFn, {
maxRetries: 3,
initialDelayMs: 1000,
jitter: false,
});
await vi.runAllTimersAsync();
await promise;
setTimeoutSpy.mockRestore();
// Without jitter, delays should be exact powers of 2
// Expected: [1000, 2000]
expect(delays[0]).toBe(1000); // 2^0 * 1000
expect(delays[1]).toBe(2000); // 2^1 * 1000
});
});
describe('커스텀 옵션 테스트', () => {
it('should respect custom maxRetries', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Always fail'));
const customMaxRetries = 5;
const promise = retryWithBackoff(mockFn, {
maxRetries: customMaxRetries,
});
// Attach error handler before advancing timers
const expectPromise = expect(promise).rejects.toThrow(RetryError);
await vi.runAllTimersAsync();
await expectPromise;
expect(mockFn).toHaveBeenCalledTimes(customMaxRetries + 1); // Initial + retries
});
it('should respect custom initialDelayMs', async () => {
const delays: number[] = [];
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('Fail 1'))
.mockResolvedValueOnce('success');
const originalSetTimeout = global.setTimeout;
const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => {
if (typeof ms === 'number' && ms > 0) {
delays.push(ms);
}
return originalSetTimeout(callback as () => void, ms);
});
const customInitialDelay = 500;
const promise = retryWithBackoff(mockFn, {
initialDelayMs: customInitialDelay,
jitter: false,
});
await vi.runAllTimersAsync();
await promise;
setTimeoutSpy.mockRestore();
expect(delays[0]).toBe(customInitialDelay);
});
it('should respect custom maxDelayMs', async () => {
const delays: number[] = [];
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('Fail 1'))
.mockRejectedValueOnce(new Error('Fail 2'))
.mockRejectedValueOnce(new Error('Fail 3'))
.mockResolvedValueOnce('success');
const originalSetTimeout = global.setTimeout;
const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => {
if (typeof ms === 'number' && ms > 0) {
delays.push(ms);
}
return originalSetTimeout(callback as () => void, ms);
});
const customMaxDelay = 300;
const promise = retryWithBackoff(mockFn, {
initialDelayMs: 100,
maxDelayMs: customMaxDelay,
jitter: false,
});
await vi.runAllTimersAsync();
await promise;
setTimeoutSpy.mockRestore();
// Delays: [100, 200, 300] - third should be capped at 300 (not 400)
expect(delays[0]).toBe(100);
expect(delays[1]).toBe(200);
expect(delays[2]).toBe(300); // Capped
});
it('should work with maxRetries = 0 (no retries)', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Immediate fail'));
const promise = retryWithBackoff(mockFn, { maxRetries: 0 });
// Attach error handler before advancing timers
const expectPromise = expect(promise).rejects.toThrow(RetryError);
await vi.runAllTimersAsync();
await expectPromise;
expect(mockFn).toHaveBeenCalledTimes(1); // Only initial attempt
});
});
describe('Edge Cases', () => {
it('should handle non-Error rejections', async () => {
const mockFn = vi.fn().mockRejectedValue('String error');
const promise = retryWithBackoff(mockFn, { maxRetries: 1 });
// Attach catch handler before advancing timers
const errorPromise = promise.catch(error => error);
await vi.runAllTimersAsync();
const error = await errorPromise;
expect(error).toBeInstanceOf(RetryError);
const retryError = error as RetryError;
expect(retryError.lastError).toBeInstanceOf(Error);
expect(retryError.lastError.message).toBe('String error');
});
it('should handle undefined rejections', async () => {
const mockFn = vi.fn().mockRejectedValue(undefined);
const promise = retryWithBackoff(mockFn, { maxRetries: 1 });
// Attach catch handler before advancing timers
const errorPromise = promise.catch(error => error);
await vi.runAllTimersAsync();
const error = await errorPromise;
expect(error).toBeInstanceOf(RetryError);
const retryError = error as RetryError;
expect(retryError.lastError).toBeInstanceOf(Error);
expect(retryError.lastError.message).toBe('undefined');
});
it('should work with serviceName for metrics tracking', async () => {
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('Fail'))
.mockResolvedValueOnce('success');
const promise = retryWithBackoff(mockFn, {
maxRetries: 2,
serviceName: 'test-service',
});
await vi.runAllTimersAsync();
await promise;
// Note: metrics.increment is NOT called because we succeed on the retry
// The metric is only tracked AFTER a retry fails (attempt > 0 && still failing)
// In this case: attempt 0 fails -> no metric, attempt 1 succeeds -> no metric
// Let's test a case where metrics ARE tracked
});
it('should track metrics for failed retries', async () => {
const mockFn = vi
.fn()
.mockRejectedValueOnce(new Error('Fail 1'))
.mockRejectedValueOnce(new Error('Fail 2'))
.mockResolvedValueOnce('success');
const promise = retryWithBackoff(mockFn, {
maxRetries: 3,
serviceName: 'test-service',
});
await vi.runAllTimersAsync();
await promise;
// Metrics should be tracked for attempt 1 (which failed)
expect(metrics.increment).toHaveBeenCalledWith(
'retry_count',
expect.objectContaining({
service: 'test-service',
attempt: '1',
})
);
});
});
describe('RetryError 속성 검증', () => {
it('should have correct RetryError properties', async () => {
const originalError = new Error('Test error');
const mockFn = vi.fn().mockRejectedValue(originalError);
const promise = retryWithBackoff(mockFn, { maxRetries: 2 });
// Attach catch handler before advancing timers
const errorPromise = promise.catch(error => error);
await vi.runAllTimersAsync();
const error = await errorPromise;
expect(error).toBeInstanceOf(RetryError);
expect(error).toBeInstanceOf(Error);
const retryError = error as RetryError;
expect(retryError.name).toBe('RetryError');
expect(retryError.attempts).toBe(3); // Initial + 2 retries
expect(retryError.lastError).toBe(originalError);
expect(retryError.message).toContain('3 attempts');
expect(retryError.message).toContain('Test error');
});
it('should be catchable as Error', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Fail'));
const promise = retryWithBackoff(mockFn, { maxRetries: 0 });
// Attach catch handler before advancing timers
let caughtError: Error | null = null;
const errorPromise = promise.catch(error => {
if (error instanceof Error) {
caughtError = error;
}
return error;
});
await vi.runAllTimersAsync();
await errorPromise;
expect(caughtError).not.toBeNull();
expect(caughtError).toBeInstanceOf(Error);
expect(caughtError).toBeInstanceOf(RetryError);
});
});
});