brackt/app/hooks/__tests__/useDraftSocket.draft-state-sync.test.ts
Chris Parsons 6c480d47a0
fix: guard against stale revalidation overwriting fresh socket data
Add a reference-equality check so that if HTTP revalidation fails (network
error, expired token), the sync effect does not overwrite fresh data that
draft-state-sync already applied. Also adds missing dependency array entries
(userQueue, timers, autodraftSettings) to the revalidation sync effect.

https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms (#48)

* fix: sync all draft state on mobile reconnection

After a mobile browser returns from a long background period, the draft
room had stale picks, wrong "on the clock" display, and inaccurate
available player lists. The root cause was that reconnection relied
solely on an HTTP revalidation that could fail (expired JWT, flaky
network), and the timer-update handler ignored currentPickNumber.

Changes:
- Server emits draft-state-sync on join-draft with full picks, timers,
  and season state, giving the client an immediate socket-based sync
  path that doesn't depend on HTTP revalidation
- timer-update handler now syncs currentPickNumber, fixing the "on the
  clock" display within 1 second of reconnection
- Revalidation retry with 3s delay ensures the HTTP path succeeds even
  when the network is slow to stabilize on mobile return
- Revalidation completion now also syncs teamTimers and autodraftStatus
- Added draft-state-sync client handler that applies the server snapshot
  immediately (skipped when revalidation is in-flight to avoid conflicts)

Tests: 32 new tests covering reconnection sync, pick buffering/merge,
timer-update currentPickNumber sync, draft-state-sync handling,
available player filtering, on-the-clock correctness, and revalidation
retry logic.

https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms

* fix: guard against stale revalidation overwriting fresh socket data

Add a reference-equality check so that if HTTP revalidation fails (network
error, expired token), the sync effect does not overwrite fresh data that
draft-state-sync already applied. Also adds missing dependency array entries
(userQueue, timers, autodraftSettings) to the revalidation sync effect.

https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-01 12:23:24 -08:00

284 lines
8.2 KiB
TypeScript
Raw 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 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<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;
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<typeof makeSocketMock>;
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);
});
});