brackt/app/hooks/__tests__/useDraftSocket.reconnect.test.ts
Chris Parsons 6dfe56e178
fix: fully sync local draft state after socket reconnection (#41)
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>
2026-02-27 22:50:46 -08:00

369 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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();
});
});