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>
284 lines
8.2 KiB
TypeScript
284 lines
8.2 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|