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