import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { ReconnectionManager } from "../reconnection-manager.js"; import { createMockLogger } from "./mock-logger.js"; describe("ReconnectionManager", () => { let reconnectionManager: ReconnectionManager; let reconnectFn: ReturnType; let mockLogger: ReturnType; beforeEach(() => { vi.useFakeTimers(); reconnectFn = vi.fn().mockResolvedValue(undefined); mockLogger = createMockLogger(); }); afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); }); describe("Construction", () => { it("should initialize with maxAttempts and baseDelayMs", () => { expect(() => { reconnectionManager = new ReconnectionManager(5, 1000, mockLogger); }).not.toThrow(); expect(reconnectionManager.getAttemptCount()).toBe(0); }); }); describe("attemptReconnect", () => { beforeEach(() => { reconnectionManager = new ReconnectionManager(3, 1000, mockLogger); }); it("should attempt reconnection with exponential backoff", async () => { // First attempt: 1000ms delay (1000 * 2^0) const promise1 = reconnectionManager.attemptReconnect(reconnectFn); expect(mockLogger.info).toHaveBeenCalledWith( "Attempting to reconnect", expect.objectContaining({ attempt: 1, maxAttempts: 3, delayMs: 1000 }) ); await vi.advanceTimersByTimeAsync(1000); await promise1; expect(reconnectFn).toHaveBeenCalledTimes(1); // Second attempt: 2000ms delay (1000 * 2^1) const promise2 = reconnectionManager.attemptReconnect(reconnectFn); expect(mockLogger.info).toHaveBeenCalledWith( "Attempting to reconnect", expect.objectContaining({ attempt: 2, maxAttempts: 3, delayMs: 2000 }) ); await vi.advanceTimersByTimeAsync(2000); await promise2; expect(reconnectFn).toHaveBeenCalledTimes(2); // Third attempt: 4000ms delay (1000 * 2^2) const promise3 = reconnectionManager.attemptReconnect(reconnectFn); expect(mockLogger.info).toHaveBeenCalledWith( "Attempting to reconnect", expect.objectContaining({ attempt: 3, maxAttempts: 3, delayMs: 4000 }) ); await vi.advanceTimersByTimeAsync(4000); await promise3; expect(reconnectFn).toHaveBeenCalledTimes(3); }); it("should throw error when max attempts exceeded", async () => { // Exhaust all attempts const promise1 = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise1; const promise2 = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise2; const promise3 = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise3; // Fourth attempt should throw await expect(reconnectionManager.attemptReconnect(reconnectFn)).rejects.toThrow( "Max reconnection attempts exceeded" ); expect(mockLogger.fatal).toHaveBeenCalledWith( expect.stringContaining("Max reconnect attempts reached"), expect.objectContaining({ maxAttempts: 3 }) ); }); it("should log reconnection attempts", async () => { const promise = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise; expect(mockLogger.info).toHaveBeenCalledWith( "Attempting to reconnect", expect.any(Object) ); }); it("should propagate errors from reconnectFn", async () => { const error = new Error("Reconnection failed"); reconnectFn.mockRejectedValueOnce(error); // Attach rejection handler BEFORE advancing timers const promise = reconnectionManager.attemptReconnect(reconnectFn); const assertion = expect(promise).rejects.toThrow("Reconnection failed"); await vi.runAllTimersAsync(); await assertion; }); }); describe("reset", () => { beforeEach(() => { reconnectionManager = new ReconnectionManager(3, 1000, mockLogger); }); it("should reset attempt counter", async () => { // Make one attempt const promise = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise; expect(reconnectionManager.getAttemptCount()).toBe(1); // Reset reconnectionManager.reset(); expect(reconnectionManager.getAttemptCount()).toBe(0); }); it("should allow reconnection after reset", async () => { // Exhaust all attempts const promise1 = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise1; const promise2 = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise2; const promise3 = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise3; expect(reconnectionManager.getAttemptCount()).toBe(3); // Reset should allow new attempts reconnectionManager.reset(); const promise4 = reconnectionManager.attemptReconnect(reconnectFn); expect(mockLogger.info).toHaveBeenCalledWith( "Attempting to reconnect", expect.objectContaining({ attempt: 1, maxAttempts: 3, delayMs: 1000 }) ); await vi.runAllTimersAsync(); await promise4; }); }); describe("getAttemptCount", () => { beforeEach(() => { reconnectionManager = new ReconnectionManager(3, 1000, mockLogger); }); it("should return current attempt count", async () => { expect(reconnectionManager.getAttemptCount()).toBe(0); const promise1 = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise1; expect(reconnectionManager.getAttemptCount()).toBe(1); const promise2 = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise2; expect(reconnectionManager.getAttemptCount()).toBe(2); }); }); describe("Edge Cases", () => { it("should handle maxAttempts of 1", async () => { reconnectionManager = new ReconnectionManager(1, 1000, mockLogger); const promise = reconnectionManager.attemptReconnect(reconnectFn); await vi.runAllTimersAsync(); await promise; expect(reconnectionManager.getAttemptCount()).toBe(1); // Second attempt should fail await expect(reconnectionManager.attemptReconnect(reconnectFn)).rejects.toThrow( "Max reconnection attempts exceeded" ); }); it("should handle very small base delays", async () => { reconnectionManager = new ReconnectionManager(2, 10, mockLogger); const promise = reconnectionManager.attemptReconnect(reconnectFn); await vi.advanceTimersByTimeAsync(10); await promise; expect(reconnectFn).toHaveBeenCalled(); }); it("should calculate exponential backoff correctly for many attempts", async () => { reconnectionManager = new ReconnectionManager(5, 100, mockLogger); // Attempt 1: 100ms (100 * 2^0) let promise = reconnectionManager.attemptReconnect(reconnectFn); expect(mockLogger.info).toHaveBeenCalledWith( "Attempting to reconnect", expect.objectContaining({ delayMs: 100 }) ); await vi.runAllTimersAsync(); await promise; // Attempt 2: 200ms (100 * 2^1) promise = reconnectionManager.attemptReconnect(reconnectFn); expect(mockLogger.info).toHaveBeenCalledWith( "Attempting to reconnect", expect.objectContaining({ delayMs: 200 }) ); await vi.runAllTimersAsync(); await promise; // Attempt 3: 400ms (100 * 2^2) promise = reconnectionManager.attemptReconnect(reconnectFn); expect(mockLogger.info).toHaveBeenCalledWith( "Attempting to reconnect", expect.objectContaining({ delayMs: 400 }) ); await vi.runAllTimersAsync(); await promise; // Attempt 4: 800ms (100 * 2^3) promise = reconnectionManager.attemptReconnect(reconnectFn); expect(mockLogger.info).toHaveBeenCalledWith( "Attempting to reconnect", expect.objectContaining({ delayMs: 800 }) ); await vi.runAllTimersAsync(); await promise; // Attempt 5: 1600ms (100 * 2^4) promise = reconnectionManager.attemptReconnect(reconnectFn); expect(mockLogger.info).toHaveBeenCalledWith( "Attempting to reconnect", expect.objectContaining({ delayMs: 1600 }) ); await vi.runAllTimersAsync(); await promise; }); }); });