import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { CircuitBreaker } from "../circuit-breaker.js"; import { createMockLogger } from "./mock-logger.js"; describe("CircuitBreaker", () => { let onBreak: ReturnType; let circuitBreaker: CircuitBreaker; let mockLogger: ReturnType; beforeEach(() => { onBreak = vi.fn().mockResolvedValue(undefined); mockLogger = createMockLogger(); }); afterEach(() => { vi.clearAllMocks(); }); describe("Construction", () => { it("should initialize with maxFailures and onBreak callback", () => { expect(() => { circuitBreaker = new CircuitBreaker(5, onBreak, mockLogger); }).not.toThrow(); expect(circuitBreaker.getFailureCount()).toBe(0); }); }); describe("execute", () => { beforeEach(() => { circuitBreaker = new CircuitBreaker(3, onBreak, mockLogger); }); it("should execute operation successfully and reset counter", async () => { const operation = vi.fn().mockResolvedValue("success"); await circuitBreaker.execute(operation, "test-operation"); expect(operation).toHaveBeenCalledOnce(); expect(circuitBreaker.getFailureCount()).toBe(0); expect(onBreak).not.toHaveBeenCalled(); }); it("should track consecutive failures", async () => { const operation = vi.fn().mockRejectedValue(new Error("Operation failed")); await circuitBreaker.execute(operation, "test-operation"); expect(circuitBreaker.getFailureCount()).toBe(1); await circuitBreaker.execute(operation, "test-operation"); expect(circuitBreaker.getFailureCount()).toBe(2); expect(onBreak).not.toHaveBeenCalled(); }); it("should trigger onBreak when max failures reached", async () => { const operation = vi.fn().mockRejectedValue(new Error("Operation failed")); // Fail 3 times (maxFailures = 3) await circuitBreaker.execute(operation, "test-operation"); await circuitBreaker.execute(operation, "test-operation"); await circuitBreaker.execute(operation, "test-operation"); expect(circuitBreaker.getFailureCount()).toBe(3); expect(onBreak).toHaveBeenCalledOnce(); }); it("should log failures with operation name", async () => { const operation = vi.fn().mockRejectedValue(new Error("Operation failed")); await circuitBreaker.execute(operation, "custom-operation"); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining("Circuit breaker"), expect.objectContaining({ operationName: "custom-operation" }) ); }); it("should log when circuit breaks", async () => { const operation = vi.fn().mockRejectedValue(new Error("Operation failed")); // Trigger circuit breaker await circuitBreaker.execute(operation, "test-operation"); await circuitBreaker.execute(operation, "test-operation"); await circuitBreaker.execute(operation, "test-operation"); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining("max consecutive failures"), expect.objectContaining({ maxFailures: 3 }) ); }); it("should reset counter after successful operation", async () => { const failingOp = vi.fn().mockRejectedValue(new Error("Failed")); const successOp = vi.fn().mockResolvedValue("success"); // Fail twice await circuitBreaker.execute(failingOp, "failing-op"); await circuitBreaker.execute(failingOp, "failing-op"); expect(circuitBreaker.getFailureCount()).toBe(2); // Succeed once - should reset counter await circuitBreaker.execute(successOp, "success-op"); expect(circuitBreaker.getFailureCount()).toBe(0); // Verify onBreak was never called expect(onBreak).not.toHaveBeenCalled(); }); }); describe("reset", () => { beforeEach(() => { circuitBreaker = new CircuitBreaker(3, onBreak, mockLogger); }); it("should reset failure counter", async () => { const operation = vi.fn().mockRejectedValue(new Error("Failed")); await circuitBreaker.execute(operation, "test-operation"); await circuitBreaker.execute(operation, "test-operation"); expect(circuitBreaker.getFailureCount()).toBe(2); circuitBreaker.reset(); expect(circuitBreaker.getFailureCount()).toBe(0); }); }); describe("getFailureCount", () => { beforeEach(() => { circuitBreaker = new CircuitBreaker(3, onBreak, mockLogger); }); it("should return current failure count", async () => { const operation = vi.fn().mockRejectedValue(new Error("Failed")); expect(circuitBreaker.getFailureCount()).toBe(0); await circuitBreaker.execute(operation, "test-operation"); expect(circuitBreaker.getFailureCount()).toBe(1); await circuitBreaker.execute(operation, "test-operation"); expect(circuitBreaker.getFailureCount()).toBe(2); }); }); describe("Edge Cases", () => { it("should handle onBreak callback errors", async () => { const failingOnBreak = vi.fn().mockRejectedValue(new Error("onBreak failed")); circuitBreaker = new CircuitBreaker(2, failingOnBreak, mockLogger); const operation = vi.fn().mockRejectedValue(new Error("Operation failed")); // This should not throw even if onBreak fails await circuitBreaker.execute(operation, "test-operation"); // Second call triggers the circuit breaker, which calls the failing onBreak // We need to catch the unhandled promise rejection from onBreak try { await circuitBreaker.execute(operation, "test-operation"); // Wait a bit for the onBreak promise to be handled await new Promise(resolve => setTimeout(resolve, 10)); } catch { // Ignore error from onBreak } expect(failingOnBreak).toHaveBeenCalled(); }); it("should handle maxFailures of 1", async () => { circuitBreaker = new CircuitBreaker(1, onBreak, mockLogger); const operation = vi.fn().mockRejectedValue(new Error("Failed")); await circuitBreaker.execute(operation, "test-operation"); expect(circuitBreaker.getFailureCount()).toBe(1); expect(onBreak).toHaveBeenCalledOnce(); }); }); });