learning_ai_common_plat/services/extraction-service/src/lib/circuit-breaker.test.ts

222 lines
5.7 KiB
TypeScript

/**
* Tests for CircuitBreaker — state machine (CLOSED → OPEN → HALF_OPEN).
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { CircuitBreaker } from './circuit-breaker.js';
describe('CircuitBreaker', () => {
let cb: CircuitBreaker;
beforeEach(() => {
cb = new CircuitBreaker({ failureThreshold: 3, resetTimeoutMs: 1000 });
});
describe('initial state', () => {
it('starts in CLOSED state', () => {
expect(cb.currentState).toBe('CLOSED');
});
it('allows requests when CLOSED', () => {
expect(cb.allowRequest()).toBe(true);
});
it('stats reflect initial state', () => {
const stats = cb.stats;
expect(stats.state).toBe('CLOSED');
expect(stats.failureCount).toBe(0);
expect(stats.threshold).toBe(3);
expect(stats.resetMs).toBe(1000);
});
});
describe('default options', () => {
it('uses failureThreshold=5 and resetTimeoutMs=30000 by default', () => {
const defaultCb = new CircuitBreaker();
const stats = defaultCb.stats;
expect(stats.threshold).toBe(5);
expect(stats.resetMs).toBe(30_000);
});
});
describe('failure tracking', () => {
it('stays CLOSED below threshold', () => {
cb.recordFailure();
expect(cb.currentState).toBe('CLOSED');
expect(cb.stats.failureCount).toBe(1);
cb.recordFailure();
expect(cb.currentState).toBe('CLOSED');
expect(cb.stats.failureCount).toBe(2);
});
it('transitions to OPEN at threshold', () => {
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.currentState).toBe('OPEN');
expect(cb.stats.failureCount).toBe(3);
});
it('blocks requests when OPEN', () => {
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.allowRequest()).toBe(false);
});
});
describe('success resets', () => {
it('resets failure count on success', () => {
cb.recordFailure();
cb.recordFailure();
expect(cb.stats.failureCount).toBe(2);
cb.recordSuccess();
expect(cb.stats.failureCount).toBe(0);
expect(cb.currentState).toBe('CLOSED');
});
it('returns to CLOSED from any state on success', () => {
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.currentState).toBe('OPEN');
cb.recordSuccess();
expect(cb.currentState).toBe('CLOSED');
expect(cb.allowRequest()).toBe(true);
});
});
describe('HALF_OPEN transition', () => {
it('transitions to HALF_OPEN after reset timeout', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.currentState).toBe('OPEN');
expect(cb.allowRequest()).toBe(false);
// Advance past reset timeout
vi.advanceTimersByTime(1001);
// allowRequest should transition to HALF_OPEN
expect(cb.allowRequest()).toBe(true);
expect(cb.currentState).toBe('HALF_OPEN');
vi.useRealTimers();
});
it('returns to CLOSED on success during HALF_OPEN', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(1001);
cb.allowRequest(); // transitions to HALF_OPEN
cb.recordSuccess();
expect(cb.currentState).toBe('CLOSED');
expect(cb.stats.failureCount).toBe(0);
vi.useRealTimers();
});
it('returns to OPEN on failure during HALF_OPEN', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(1001);
cb.allowRequest(); // transitions to HALF_OPEN
// Failure during HALF_OPEN should re-open
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.currentState).toBe('OPEN');
vi.useRealTimers();
});
it('allows probe request in HALF_OPEN state', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(1001);
expect(cb.allowRequest()).toBe(true); // probe allowed
expect(cb.currentState).toBe('HALF_OPEN');
vi.useRealTimers();
});
});
describe('OPEN state timing', () => {
it('stays OPEN before reset timeout', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(500); // Half of reset timeout
expect(cb.allowRequest()).toBe(false);
expect(cb.currentState).toBe('OPEN');
vi.useRealTimers();
});
it('transitions exactly at reset timeout boundary', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(1000); // Exactly at boundary
expect(cb.allowRequest()).toBe(true);
expect(cb.currentState).toBe('HALF_OPEN');
vi.useRealTimers();
});
});
describe('edge cases', () => {
it('handles rapid success/failure alternation', () => {
cb.recordFailure();
cb.recordSuccess();
cb.recordFailure();
cb.recordSuccess();
cb.recordFailure();
expect(cb.currentState).toBe('CLOSED');
expect(cb.stats.failureCount).toBe(1);
});
it('threshold of 1 opens immediately on first failure', () => {
const strictCb = new CircuitBreaker({ failureThreshold: 1 });
strictCb.recordFailure();
expect(strictCb.currentState).toBe('OPEN');
});
it('multiple successes keep state CLOSED', () => {
cb.recordSuccess();
cb.recordSuccess();
cb.recordSuccess();
expect(cb.currentState).toBe('CLOSED');
expect(cb.stats.failureCount).toBe(0);
});
});
});