On reconnect, revalidate() re-fetches fresh loader data but the local useState copies (picks, currentPick, isPaused, isDraftComplete, queue) never synced with the new values — leaving the UI stale until a manual refresh. - Watch revalidatorState (idle→loading→idle) to detect when a revalidation completes and apply the fresh loader snapshot to all affected local state - Buffer pick-made socket events received during the revalidation window (instead of discarding or double-applying them); merge into the DB snapshot on completion, deduplicated by pick ID - Keep setCurrentPick paired with setPicks in handlePickMade so the "on the clock" indicator and the picks list never desync - Sync queue state from fresh userQueue after revalidation so participants drafted while the user was away disappear from their queue even if the participant-removed-from-queues events were missed Adds useDraftSocket reconnect test suite (9 tests) covering initial connect, socket reconnect, visibility-change triggering, network flapping, error exhaustion, and cleanup. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
/**
|
||
* 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<string, EventHandler[]> = {};
|
||
const managerHandlers: Record<string, EventHandler[]> = {};
|
||
|
||
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<typeof makeSocketMock>;
|
||
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();
|
||
});
|
||
});
|