/** * Tests for draft-state-sync event handling on reconnection. * * Validates that after a mobile browser goes to background and returns, * the draft room properly syncs all state: picks, current pick number, * pause status, timers, and available participants. * * These tests verify the contract between useDraftSocket and the draft * room component's event handlers. */ 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; 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; setTimeout(() => { socketMock._trigger("connect"); }, 0); }), disconnect: vi.fn(() => { socketMock.connected = false; }), 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 ); } }), }, _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 { useDraftSocket } from "../useDraftSocket"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function renderDraftSocket(seasonId = "season-1", teamId?: string) { return renderHook(() => useDraftSocket(seasonId, teamId)); } /** Simulate the full connect → disconnect → reconnect cycle */ function doReconnectCycle(mock: SocketMock) { // Initial connect act(() => { mock.connected = true; mock._trigger("connect"); }); // Disconnect (mobile background / network loss) act(() => { mock.connected = false; mock._trigger("disconnect", "transport close"); }); // Reconnect act(() => { mock.connected = true; mock._trigger("connect"); }); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("useDraftSocket – draft-state-sync integration", () => { beforeEach(() => { socketMock = makeSocketMock(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it("re-emits join-draft on reconnect, which triggers server to send draft-state-sync", () => { renderDraftSocket("season-42", "team-7"); doReconnectCycle(socketMock); // join-draft should have been emitted on both initial connect and reconnect const joinCalls = socketMock.emit.mock.calls.filter( ([ev]) => ev === "join-draft" ); expect(joinCalls.length).toBe(2); expect(joinCalls[1]).toEqual(["join-draft", "season-42", "team-7"]); }); it("increments reconnectCount on reconnect so draft room can trigger revalidation", () => { const { result } = renderDraftSocket("season-1", "team-1"); doReconnectCycle(socketMock); expect(result.current.reconnectCount).toBe(1); }); it("allows consumer to register draft-state-sync handler via on()", () => { const { result } = renderDraftSocket("season-1", "team-1"); // Initial connect act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); // Consumer registers a handler for draft-state-sync const syncHandler = vi.fn(); act(() => { result.current.on("draft-state-sync", syncHandler); }); // Server sends draft-state-sync const syncData = { currentPickNumber: 15, isPaused: false, status: "draft", picks: [ { id: "pick-1", pickNumber: 1, team: { id: "team-1" } }, { id: "pick-2", pickNumber: 2, team: { id: "team-2" } }, ], timers: [ { teamId: "team-1", timeRemaining: 90 }, { teamId: "team-2", timeRemaining: 120 }, ], }; act(() => { socketMock._trigger("draft-state-sync", syncData); }); expect(syncHandler).toHaveBeenCalledWith(syncData); }); it("allows consumer to register timer-update handler that receives currentPickNumber", () => { const { result } = renderDraftSocket("season-1"); act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); const timerHandler = vi.fn(); act(() => { result.current.on("timer-update", timerHandler); }); const timerData = { seasonId: "season-1", teamId: "team-2", timeRemaining: 85, currentPickNumber: 12, }; act(() => { socketMock._trigger("timer-update", timerData); }); expect(timerHandler).toHaveBeenCalledWith(timerData); // The handler receives currentPickNumber which the draft room uses to sync expect(timerHandler.mock.calls[0][0].currentPickNumber).toBe(12); }); it("handles visibilitychange-based reconnect when socket is alive (zombie detection)", () => { const { result } = renderDraftSocket("season-1", "team-1"); // Initial connect act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); expect(result.current.reconnectCount).toBe(0); // Simulate mobile background return with still-alive socket act(() => { Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true, }); document.dispatchEvent(new Event("visibilitychange")); }); // Should increment reconnectCount even though socket didn't disconnect // This is critical for triggering revalidation expect(result.current.reconnectCount).toBe(1); // join-draft re-emitted const joinCalls = socketMock.emit.mock.calls.filter( ([ev]) => ev === "join-draft" ); expect(joinCalls.length).toBe(2); }); it("handles long background period: disconnect → many seconds → visibilitychange → reconnect", () => { const { result } = renderDraftSocket("season-1", "team-1"); // Initial connect act(() => { socketMock.connected = true; socketMock._trigger("connect"); }); // Disconnect (OS suspends the app) act(() => { socketMock.connected = false; socketMock._trigger("disconnect", "ping timeout"); }); expect(result.current.isConnected).toBe(false); expect(result.current.isReconnecting).toBe(true); // Much later: tab becomes visible (user opens phone) act(() => { Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true, }); document.dispatchEvent(new Event("visibilitychange")); }); // Socket was dead → connect() should have been called expect(socketMock.connect).toHaveBeenCalled(); // Simulate the delayed connect event act(() => { vi.runAllTimers(); }); // After connect: reconnectCount incremented expect(result.current.reconnectCount).toBe(1); expect(result.current.isConnected).toBe(true); }); });