Files
gridpilot.gg/apps/website/lib/gateways/api/base/RetryHandler.test.ts
2026-01-24 12:44:57 +01:00

127 lines
3.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RetryHandler, CircuitBreaker, CircuitBreakerRegistry, DEFAULT_RETRY_CONFIG } from './RetryHandler';
describe('RetryHandler', () => {
let handler: RetryHandler;
const fastConfig = {
...DEFAULT_RETRY_CONFIG,
baseDelay: 1,
maxDelay: 1,
backoffMultiplier: 1,
};
beforeEach(() => {
handler = new RetryHandler(fastConfig);
vi.spyOn(Math, 'random').mockReturnValue(0);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('execute', () => {
it('should execute function successfully without retry', async () => {
const fn = vi.fn().mockResolvedValue('success');
const result = await handler.execute(fn);
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(1);
});
it('should retry on failure and eventually succeed', async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new Error('First attempt'))
.mockResolvedValueOnce('success');
const result = await handler.execute(fn);
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(2);
});
it('should exhaust retries and throw final error', async () => {
const fn = vi.fn().mockRejectedValue(new Error('Always fails'));
await expect(handler.execute(fn)).rejects.toThrow('Always fails');
expect(fn).toHaveBeenCalledTimes(fastConfig.maxRetries + 1);
});
it('should respect custom retry config', async () => {
const customConfig = { ...fastConfig, maxRetries: 2 };
const customHandler = new RetryHandler(customConfig);
const fn = vi.fn().mockRejectedValue(new Error('Fail'));
await expect(customHandler.execute(fn)).rejects.toThrow('Fail');
expect(fn).toHaveBeenCalledTimes(3); // 2 retries + 1 initial
});
});
});
describe('CircuitBreaker', () => {
let breaker: CircuitBreaker;
beforeEach(() => {
breaker = new CircuitBreaker({ failureThreshold: 3, successThreshold: 1, timeout: 1000 });
});
describe('canExecute', () => {
it('should allow execution when closed', () => {
expect(breaker.canExecute()).toBe(true);
});
it('should prevent execution when open', () => {
breaker.recordFailure();
breaker.recordFailure();
breaker.recordFailure();
expect(breaker.canExecute()).toBe(false);
});
});
describe('recordSuccess', () => {
it('should reset failure count on success', () => {
breaker.recordFailure();
breaker.recordFailure();
breaker.recordSuccess();
// Should be closed again
expect(breaker.canExecute()).toBe(true);
});
});
describe('recordFailure', () => {
it('should increment failure count', () => {
breaker.recordFailure();
expect(breaker.canExecute()).toBe(true);
breaker.recordFailure();
expect(breaker.canExecute()).toBe(true);
breaker.recordFailure();
expect(breaker.canExecute()).toBe(false);
});
});
});
describe('CircuitBreakerRegistry', () => {
it('should return singleton instance', () => {
const registry1 = CircuitBreakerRegistry.getInstance();
const registry2 = CircuitBreakerRegistry.getInstance();
expect(registry1).toBe(registry2);
});
it('should return same breaker for same path', () => {
const registry = CircuitBreakerRegistry.getInstance();
const breaker1 = registry.getBreaker('/api/test');
const breaker2 = registry.getBreaker('/api/test');
expect(breaker1).toBe(breaker2);
});
it('should return different breakers for different paths', () => {
const registry = CircuitBreakerRegistry.getInstance();
const breaker1 = registry.getBreaker('/api/test1');
const breaker2 = registry.getBreaker('/api/test2');
expect(breaker1).not.toBe(breaker2);
});
});