/** * Tests for useDraftSocket reconnection logic. * * Covers the Disconnect → State-Change-in-DB → Reconnect flow and ensures * reconnectCount (which the draft route uses to trigger revalidate()) behaves * correctly across reconnects, visibility changes, and network flapping. * * We mock socket.io-client so the tests run in jsdom with no real network. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; // --------------------------------------------------------------------------- // socket.io-client mock // --------------------------------------------------------------------------- type EventHandler = (...args: any[]) => void; /** * Minimal, controllable EventEmitter that mimics the socket.io-client Socket * and its Manager (socket.io) interface. */ function makeSocketMock() { const socketHandlers: Record = {}; const managerHandlers: Record = {}; const socketMock = { connected: false, id: "socket-test-id", on: vi.fn((event: string, handler: EventHandler) => { socketHandlers[event] ??= []; socketHandlers[event].push(handler); }), off: vi.fn((event: string, handler?: EventHandler) => { if (!handler) { socketHandlers[event] = []; } else { socketHandlers[event] = (socketHandlers[event] ?? []).filter( (h) => h !== handler ); } }), emit: vi.fn(), connect: vi.fn(() => { socketMock.connected = true; // Simulate the "connect" event firing on the next tick setTimeout(() => { socketMock._trigger("connect"); }, 0); }), disconnect: vi.fn(() => { socketMock.connected = false; }), // Manager-level events (socket.io.on / socket.io.off) io: { on: vi.fn((event: string, handler: EventHandler) => { managerHandlers[event] ??= []; managerHandlers[event].push(handler); }), off: vi.fn((event: string, handler?: EventHandler) => { if (!handler) { managerHandlers[event] = []; } else { managerHandlers[event] = (managerHandlers[event] ?? []).filter( (h) => h !== handler ); } }), }, // Test helpers — call these to simulate server-side events _trigger(event: string, ...args: any[]) { (socketHandlers[event] ?? []).forEach((h) => h(...args)); }, _triggerManager(event: string, ...args: any[]) { (managerHandlers[event] ?? []).forEach((h) => h(...args)); }, }; return socketMock; } type SocketMock = ReturnType; let socketMock: SocketMock; vi.mock("socket.io-client", () => ({ io: vi.fn(() => socketMock), })); // --------------------------------------------------------------------------- // Import the hook under test (after mocking) // --------------------------------------------------------------------------- import { useDraftSocket } from "../useDraftSocket"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function renderDraftSocket(seasonId = "season-1", teamId?: string) { return renderHook(() => useDraftSocket(seasonId, teamId)); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("useDraftSocket – reconnection", () => { beforeEach(() => { socketMock = makeSocketMock(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); // ------------------------------------------------------------------------- // Basic connection // ------------------------------------------------------------------------- it("starts disconnected and connects on mount", async () => { const { result } = renderDraftSocket(); expect(result.current.isConnected).toBe(false); // Simulate the socket emitting "connect" act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); expect(result.current.isConnected).toBe(true); expect(result.current.reconnectCount).toBe(0); expect(result.current.isReconnecting).toBe(false); }); // ------------------------------------------------------------------------- // reconnectCount increments on true socket reconnect // ------------------------------------------------------------------------- it("increments reconnectCount on socket reconnect (not initial connect)", async () => { const { result } = renderDraftSocket(); // First connection — reconnectCount must stay 0 act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); expect(result.current.reconnectCount).toBe(0); // Server-initiated disconnect act(() => { socketMock.connected = false; socketMock._trigger("disconnect", "transport error"); }); expect(result.current.isConnected).toBe(false); expect(result.current.isReconnecting).toBe(true); // Reconnect (second call to "connect" on the same socket instance) act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); expect(result.current.isConnected).toBe(true); expect(result.current.reconnectCount).toBe(1); }); // ------------------------------------------------------------------------- // Visibility-change triggers reconnect counter when socket is alive // ------------------------------------------------------------------------- it("increments reconnectCount on visibilitychange when socket is already connected", () => { const { result } = renderDraftSocket("season-1", "team-1"); // Initial connect act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); expect(result.current.reconnectCount).toBe(0); // App comes back to foreground while socket is still alive act(() => { Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true, }); document.dispatchEvent(new Event("visibilitychange")); }); expect(result.current.reconnectCount).toBe(1); // join-draft re-emitted so the server knows we're back expect(socketMock.emit).toHaveBeenCalledWith("join-draft", "season-1", "team-1"); }); it("triggers socket.connect() on visibilitychange when socket is disconnected", () => { const { result } = renderDraftSocket(); // Connect then lose the connection act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); act(() => { socketMock.connected = false; socketMock._trigger("disconnect", "transport close"); }); expect(result.current.isReconnecting).toBe(true); // Tab becomes visible — hook should call socket.connect() act(() => { Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true, }); document.dispatchEvent(new Event("visibilitychange")); }); expect(socketMock.connect).toHaveBeenCalled(); }); // ------------------------------------------------------------------------- // Network flapping — multiple rapid reconnects in succession // ------------------------------------------------------------------------- it("correctly counts reconnects during network flapping (5 rapid cycles)", () => { const { result } = renderDraftSocket(); // Initial connect act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); expect(result.current.reconnectCount).toBe(0); const FLAP_CYCLES = 5; for (let i = 0; i < FLAP_CYCLES; i++) { act(() => { socketMock.connected = false; socketMock._trigger("disconnect", "transport error"); }); act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); } // Each reconnect (not the initial connect) increments the counter expect(result.current.reconnectCount).toBe(FLAP_CYCLES); expect(result.current.isConnected).toBe(true); expect(result.current.isReconnecting).toBe(false); }); it("does not set a permanent error state during transient network flapping", () => { const { result } = renderDraftSocket(); act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); // Flap 3 times for (let i = 0; i < 3; i++) { act(() => { socketMock.connected = false; socketMock._trigger("disconnect", "transport error"); }); act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); } expect(result.current.connectionError).toBeNull(); expect(result.current.isReconnecting).toBe(false); }); // ------------------------------------------------------------------------- // Disconnect → DB state change → Reconnect simulation // // This is the primary regression test for the stale-state bug. // The hook's responsibility is to increment reconnectCount so that the // draft route's useEffect calls revalidate(). The actual state merge // (picks + currentPick sync) lives in the route component and is tested // via the sync effect in the route; here we verify the contract the hook // fulfils. // ------------------------------------------------------------------------- it("emits join-draft on every reconnect so the server re-sends presence data", () => { const { result } = renderDraftSocket("season-42", "team-7"); // Initial connect act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); const joinCallsBefore = socketMock.emit.mock.calls.filter( ([ev]) => ev === "join-draft" ).length; // Simulate DB picks being added while client is disconnected act(() => { socketMock.connected = false; socketMock._trigger("disconnect", "ping timeout"); }); // Client reconnects — the hook must re-emit join-draft act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); const joinCallsAfter = socketMock.emit.mock.calls.filter( ([ev]) => ev === "join-draft" ).length; expect(joinCallsAfter).toBe(joinCallsBefore + 1); expect(result.current.reconnectCount).toBe(1); }); // ------------------------------------------------------------------------- // Error handling — exhausted reconnect attempts // ------------------------------------------------------------------------- it("sets connectionError and clears isReconnecting on reconnect_failed", () => { const { result } = renderDraftSocket(); act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); act(() => { socketMock.connected = false; socketMock._trigger("disconnect", "transport error"); }); // All reconnect attempts exhausted act(() => { socketMock._triggerManager("reconnect_failed"); }); expect(result.current.connectionError).toBeTruthy(); expect(result.current.isReconnecting).toBe(false); }); // ------------------------------------------------------------------------- // Cleanup — no listeners leak after unmount // ------------------------------------------------------------------------- it("emits leave-draft and disconnects on unmount", () => { const { unmount } = renderDraftSocket("season-1", "team-1"); act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); unmount(); expect(socketMock.emit).toHaveBeenCalledWith("leave-draft", "season-1"); expect(socketMock.disconnect).toHaveBeenCalled(); }); });