From 6c480d47a0f6eda2a23d3403eb3a161d84476e70 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:23:24 -0800 Subject: [PATCH] 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 --- .../useDraftSocket.draft-state-sync.test.ts | 284 +++++++ .../leagues/$leagueId.draft.$seasonId.tsx | 91 ++- .../__tests__/draft-reconnection-sync.test.ts | 756 ++++++++++++++++++ server/socket.ts | 82 +- 4 files changed, 1210 insertions(+), 3 deletions(-) create mode 100644 app/hooks/__tests__/useDraftSocket.draft-state-sync.test.ts create mode 100644 app/routes/leagues/__tests__/draft-reconnection-sync.test.ts diff --git a/app/hooks/__tests__/useDraftSocket.draft-state-sync.test.ts b/app/hooks/__tests__/useDraftSocket.draft-state-sync.test.ts new file mode 100644 index 0000000..9b93c06 --- /dev/null +++ b/app/hooks/__tests__/useDraftSocket.draft-state-sync.test.ts @@ -0,0 +1,284 @@ +/** + * 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); + }); +}); diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 5e54844..7e747ee 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -263,11 +263,27 @@ export default function DraftRoom() { userAutodraftRef.current = userAutodraft; }, [userAutodraft]); - // Re-fetch loader data after each reconnect so stale picks/timers are refreshed + // Re-fetch loader data after each reconnect so stale picks/timers are refreshed. + // Schedule a delayed retry so that if the first attempt fails (common on mobile + // where the network may not be fully restored yet), we get another chance. + const revalidationRetryRef = useRef | null>(null); useEffect(() => { if (reconnectCount > 0) { revalidate(); + // Clear any pending retry from a previous reconnect + if (revalidationRetryRef.current) { + clearTimeout(revalidationRetryRef.current); + } + revalidationRetryRef.current = setTimeout(() => { + revalidate(); + revalidationRetryRef.current = null; + }, 3000); } + return () => { + if (revalidationRetryRef.current) { + clearTimeout(revalidationRetryRef.current); + } + }; }, [reconnectCount, revalidate]); // Guard against Clerk JWT expiry race: if the loader ran without a userId (expired @@ -298,6 +314,11 @@ export default function DraftRoom() { const revalidatorStateRef = useRef(revalidatorState); const isRevalidatingRef = useRef(false); const pendingPicksDuringRevalidationRef = useRef([]); + // Track the draftPicks reference when revalidation starts. If it hasn't + // changed by the time revalidation completes, the loader didn't return new + // data (e.g. network failure) and we should NOT overwrite state that may + // have been freshly set by draft-state-sync. + const draftPicksAtRevalidationStartRef = useRef(draftPicks); useEffect(() => { const prev = revalidatorStateRef.current; @@ -307,9 +328,19 @@ export default function DraftRoom() { // Revalidation just started — buffer any live picks to avoid loss isRevalidatingRef.current = true; pendingPicksDuringRevalidationRef.current = []; + draftPicksAtRevalidationStartRef.current = draftPicks; } else if (prev === "loading" && revalidatorState === "idle") { // Revalidation just completed — merge DB snapshot with buffered picks isRevalidatingRef.current = false; + + // If draftPicks is the same reference as when revalidation started, the + // loader didn't actually update (likely a failed fetch). Skip the sync + // to avoid overwriting fresh draft-state-sync data with stale state. + if (draftPicks === draftPicksAtRevalidationStartRef.current) { + pendingPicksDuringRevalidationRef.current = []; + return; + } + const dbPickIds = new Set(draftPicks.map((p: any) => p.id)); const missedPicks = pendingPicksDuringRevalidationRef.current.filter( (p: any) => !dbPickIds.has(p.id) @@ -327,8 +358,28 @@ export default function DraftRoom() { // `participant-removed-from-queues` events. The loader re-fetches // userQueue fresh on every revalidation so we can trust it here. setQueue(userQueue); + // Sync timers — the server-side timer state may have changed while + // we were disconnected (time bank adjustments, new timers, etc.) + if (timers.length > 0) { + setTeamTimers((prev) => { + const updated = { ...prev }; + timers.forEach((timer: any) => { + updated[timer.teamId] = timer.timeRemaining; + }); + return updated; + }); + } + // Sync autodraft settings — teams may have toggled autodraft while + // we were away. + setAutodraftStatus(() => { + const status: Record = {}; + autodraftSettings.forEach((setting: any) => { + status[setting.teamId] = setting.isEnabled; + }); + return status; + }); } - }, [revalidatorState, draftPicks, season]); + }, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings]); const [picks, setPicks] = useState(draftPicks); const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1); @@ -546,6 +597,13 @@ export default function DraftRoom() { ...prev, [data.teamId]: data.timeRemaining, })); + // Also sync the current pick number from the server. This is critical + // for mobile reconnection: timer-update events arrive every second, so + // this ensures currentPick is correct within 1s of reconnecting — even + // if the HTTP revalidation hasn't completed yet. + if (data.currentPickNumber != null) { + setCurrentPick(data.currentPickNumber); + } }; const handleDraftPaused = () => { @@ -619,6 +677,33 @@ export default function DraftRoom() { setIsPaused(false); }; + // Full state sync sent by the server on join-draft. Provides an immediate, + // socket-based path to bring the client up to date — critical for mobile + // reconnection where the HTTP revalidation may be delayed or fail entirely. + const handleDraftStateSync = (data: any) => { + // If a revalidation is in-flight, let it complete and handle the merge. + // The revalidation path already buffers picks and deduplicates — layering + // this on top would risk conflicts. But if NOT revalidating, apply the + // server snapshot directly. + if (!isRevalidatingRef.current) { + setPicks(data.picks); + setCurrentPick(data.currentPickNumber); + setIsPaused(data.isPaused); + setIsDraftComplete( + data.status === "active" || data.status === "completed" + ); + if (data.timers) { + setTeamTimers((prev) => { + const updated = { ...prev }; + data.timers.forEach((t: any) => { + updated[t.teamId] = t.timeRemaining; + }); + return updated; + }); + } + } + }; + on("pick-made", handlePickMade); on("timer-update", handleTimerUpdate); on("draft-paused", handleDraftPaused); @@ -631,6 +716,7 @@ export default function DraftRoom() { on("participant-removed-from-queues", handleParticipantRemovedFromQueues); on("pick-replaced", handlePickReplaced); on("draft-rolled-back", handleDraftRolledBack); + on("draft-state-sync", handleDraftStateSync); return () => { off("pick-made", handlePickMade); @@ -645,6 +731,7 @@ export default function DraftRoom() { off("participant-removed-from-queues", handleParticipantRemovedFromQueues); off("pick-replaced", handlePickReplaced); off("draft-rolled-back", handleDraftRolledBack); + off("draft-state-sync", handleDraftStateSync); }; // `on`/`off` are stable useCallback refs — this effect runs once per socket instance. // `draftSlots`, `userTeam`, and `season.league.name` are intentionally omitted: they diff --git a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts new file mode 100644 index 0000000..9c63b78 --- /dev/null +++ b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts @@ -0,0 +1,756 @@ +/** + * Tests for draft room reconnection state synchronization. + * + * These tests validate the core sync logic that runs when a mobile user + * returns from a long background period. They exercise the handler functions + * and state sync patterns in isolation, verifying: + * + * 1. All picks made during the offline window are visible after reconnection + * 2. The correct person is shown as "on the clock" + * 3. Available players list is accurate (drafted players excluded) + * 4. Timer-update events immediately sync currentPickNumber + * 5. draft-state-sync event provides a full snapshot for instant sync + * 6. Revalidation retry ensures the HTTP path also succeeds + * 7. Buffered picks during revalidation are properly merged + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getTeamForPick } from "~/lib/draft-order"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeDraftSlots(count: number) { + return Array.from({ length: count }, (_, i) => ({ + id: `slot-${i + 1}`, + seasonId: "season-1", + teamId: `team-${i + 1}`, + draftOrder: i + 1, + team: { + id: `team-${i + 1}`, + name: `Team ${i + 1}`, + seasonId: "season-1", + ownerId: `user-${i + 1}`, + }, + })); +} + +function makePick(pickNumber: number, teamId: string, participantId: string) { + const teamCount = 4; // default for tests + const round = Math.ceil(pickNumber / teamCount); + const pickInRound = ((pickNumber - 1) % teamCount) + 1; + return { + id: `pick-${pickNumber}`, + pickNumber, + round, + pickInRound, + timeUsed: 100, + team: { + id: teamId, + name: `Team ${teamId.split("-")[1]}`, + seasonId: "season-1", + ownerId: `user-${teamId.split("-")[1]}`, + }, + participant: { + id: participantId, + name: `Player ${participantId.split("-")[1]}`, + }, + sport: { id: "sport-1", name: "NFL" }, + }; +} + +function makeParticipant(id: string, name: string, sportId = "sport-1", sportName = "NFL") { + return { id, name, sport: { id: sportId, name: sportName } }; +} + +// --------------------------------------------------------------------------- +// 1. draft-order helper validates correctly for snake draft +// --------------------------------------------------------------------------- + +describe("getTeamForPick – snake draft order", () => { + const draftSlots = makeDraftSlots(4); + + it("round 1 (odd): picks go 1→4", () => { + expect(getTeamForPick(1, draftSlots)?.team.id).toBe("team-1"); + expect(getTeamForPick(2, draftSlots)?.team.id).toBe("team-2"); + expect(getTeamForPick(3, draftSlots)?.team.id).toBe("team-3"); + expect(getTeamForPick(4, draftSlots)?.team.id).toBe("team-4"); + }); + + it("round 2 (even): picks go 4→1 (snake)", () => { + expect(getTeamForPick(5, draftSlots)?.team.id).toBe("team-4"); + expect(getTeamForPick(6, draftSlots)?.team.id).toBe("team-3"); + expect(getTeamForPick(7, draftSlots)?.team.id).toBe("team-2"); + expect(getTeamForPick(8, draftSlots)?.team.id).toBe("team-1"); + }); + + it("round 3 (odd): picks go 1→4 again", () => { + expect(getTeamForPick(9, draftSlots)?.team.id).toBe("team-1"); + expect(getTeamForPick(12, draftSlots)?.team.id).toBe("team-4"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. timer-update handler syncs currentPickNumber +// --------------------------------------------------------------------------- + +describe("timer-update handler – currentPickNumber sync", () => { + it("updates currentPick from timer-update data", () => { + let currentPick = 5; + const setCurrentPick = (value: number) => { currentPick = value; }; + let teamTimers: Record = {}; + const setTeamTimers = (fn: (prev: Record) => Record) => { + teamTimers = fn(teamTimers); + }; + + // Simulate the timer-update handler from the draft room component + const handleTimerUpdate = (data: any) => { + setTeamTimers((prev) => ({ + ...prev, + [data.teamId]: data.timeRemaining, + })); + if (data.currentPickNumber != null) { + setCurrentPick(data.currentPickNumber); + } + }; + + // Timer update for pick 25 (user was away, picks advanced from 5 to 25) + handleTimerUpdate({ + seasonId: "season-1", + teamId: "team-2", + timeRemaining: 85, + currentPickNumber: 25, + }); + + expect(currentPick).toBe(25); + expect(teamTimers["team-2"]).toBe(85); + }); + + it("does not update currentPick when currentPickNumber is null", () => { + let currentPick = 5; + const setCurrentPick = (value: number) => { currentPick = value; }; + + const handleTimerUpdate = (data: any) => { + if (data.currentPickNumber != null) { + setCurrentPick(data.currentPickNumber); + } + }; + + handleTimerUpdate({ + seasonId: "season-1", + teamId: "team-1", + timeRemaining: 100, + currentPickNumber: null, + }); + + expect(currentPick).toBe(5); + }); +}); + +// --------------------------------------------------------------------------- +// 3. draft-state-sync handler – full state snapshot +// --------------------------------------------------------------------------- + +describe("draft-state-sync handler – full state sync on reconnection", () => { + it("syncs picks, currentPick, isPaused, and isDraftComplete from server snapshot", () => { + // Initial state (stale – from before mobile background) + let picks = [makePick(1, "team-1", "player-1")]; + let currentPick = 2; + let isPaused = false; + let isDraftComplete = false; + let teamTimers: Record = { "team-1": 100, "team-2": 120 }; + + const setPicks = (value: any) => { picks = value; }; + const setCurrentPick = (value: number) => { currentPick = value; }; + const setIsPaused = (value: boolean) => { isPaused = value; }; + const setIsDraftComplete = (value: boolean) => { isDraftComplete = value; }; + const setTeamTimers = (fn: (prev: Record) => Record) => { + teamTimers = fn(teamTimers); + }; + const isRevalidatingRef = { current: false }; + + // Simulate the draft-state-sync handler + const handleDraftStateSync = (data: any) => { + if (!isRevalidatingRef.current) { + setPicks(data.picks); + setCurrentPick(data.currentPickNumber); + setIsPaused(data.isPaused); + setIsDraftComplete( + data.status === "active" || data.status === "completed" + ); + if (data.timers) { + setTeamTimers((prev) => { + const updated = { ...prev }; + data.timers.forEach((t: any) => { + updated[t.teamId] = t.timeRemaining; + }); + return updated; + }); + } + } + }; + + // Server sends full state after join-draft on reconnect + const serverPicks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + makePick(3, "team-3", "player-3"), + makePick(4, "team-4", "player-4"), + makePick(5, "team-4", "player-5"), + makePick(6, "team-3", "player-6"), + ]; + + handleDraftStateSync({ + currentPickNumber: 7, + isPaused: false, + status: "draft", + picks: serverPicks, + timers: [ + { teamId: "team-1", timeRemaining: 45 }, + { teamId: "team-2", timeRemaining: 80 }, + { teamId: "team-3", timeRemaining: 60 }, + { teamId: "team-4", timeRemaining: 55 }, + ], + }); + + // All 6 picks should be synced + expect(picks).toHaveLength(6); + expect(picks[5].pickNumber).toBe(6); + + // Current pick should be 7 (next pick) + expect(currentPick).toBe(7); + + // Paused and complete status synced + expect(isPaused).toBe(false); + expect(isDraftComplete).toBe(false); + + // Timers synced + expect(teamTimers["team-1"]).toBe(45); + expect(teamTimers["team-2"]).toBe(80); + expect(teamTimers["team-3"]).toBe(60); + expect(teamTimers["team-4"]).toBe(55); + }); + + it("sets isDraftComplete when status is 'active'", () => { + let isDraftComplete = false; + const setIsDraftComplete = (value: boolean) => { isDraftComplete = value; }; + const isRevalidatingRef = { current: false }; + + const handleDraftStateSync = (data: any) => { + if (!isRevalidatingRef.current) { + setIsDraftComplete( + data.status === "active" || data.status === "completed" + ); + } + }; + + handleDraftStateSync({ + currentPickNumber: 81, + isPaused: false, + status: "active", + picks: [], + timers: [], + }); + + expect(isDraftComplete).toBe(true); + }); + + it("skips sync when revalidation is in-flight", () => { + let picks = [makePick(1, "team-1", "player-1")]; + const setPicks = (value: any) => { picks = value; }; + const isRevalidatingRef = { current: true }; // revalidation in progress + + const handleDraftStateSync = (data: any) => { + if (!isRevalidatingRef.current) { + setPicks(data.picks); + } + }; + + handleDraftStateSync({ + currentPickNumber: 7, + isPaused: false, + status: "draft", + picks: [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + ], + timers: [], + }); + + // Picks should NOT be updated because revalidation is in-flight + expect(picks).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Revalidation sync merges picks correctly +// --------------------------------------------------------------------------- + +describe("revalidation sync – pick merge with buffering", () => { + it("merges DB snapshot with buffered picks from socket events during revalidation", () => { + const dbPicks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + makePick(3, "team-3", "player-3"), + ]; + + // A pick that arrived via socket during the revalidation window + const bufferedPick = makePick(4, "team-4", "player-4"); + + const dbPickIds = new Set(dbPicks.map((p) => p.id)); + const pendingPicks = [bufferedPick]; + + // Merge logic from the sync effect + const missedPicks = pendingPicks.filter((p) => !dbPickIds.has(p.id)); + const mergedPicks = [...dbPicks, ...missedPicks]; + + expect(mergedPicks).toHaveLength(4); + expect(mergedPicks[3].pickNumber).toBe(4); + expect(mergedPicks[3].participant.id).toBe("player-4"); + }); + + it("deduplicates picks that are in both DB snapshot and buffer", () => { + const dbPicks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + makePick(3, "team-3", "player-3"), + ]; + + // The same pick arrived via socket AND is in the DB snapshot + const bufferedPick = makePick(3, "team-3", "player-3"); + + const dbPickIds = new Set(dbPicks.map((p) => p.id)); + const pendingPicks = [bufferedPick]; + + const missedPicks = pendingPicks.filter((p) => !dbPickIds.has(p.id)); + const mergedPicks = [...dbPicks, ...missedPicks]; + + // No duplicates – pick 3 appears only once + expect(mergedPicks).toHaveLength(3); + }); + + it("handles empty buffer (no socket events during revalidation)", () => { + const dbPicks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + ]; + + const pendingPicks: any[] = []; + const dbPickIds = new Set(dbPicks.map((p) => p.id)); + const missedPicks = pendingPicks.filter((p) => !dbPickIds.has(p.id)); + const mergedPicks = [...dbPicks, ...missedPicks]; + + expect(mergedPicks).toHaveLength(2); + }); + + it("handles multiple buffered picks during slow revalidation", () => { + const dbPicks = [ + makePick(1, "team-1", "player-1"), + ]; + + // Three picks arrived while revalidation was slow + const bufferedPicks = [ + makePick(2, "team-2", "player-2"), + makePick(3, "team-3", "player-3"), + makePick(4, "team-4", "player-4"), + ]; + + const dbPickIds = new Set(dbPicks.map((p) => p.id)); + const missedPicks = bufferedPicks.filter((p) => !dbPickIds.has(p.id)); + const mergedPicks = [...dbPicks, ...missedPicks]; + + expect(mergedPicks).toHaveLength(4); + }); +}); + +// --------------------------------------------------------------------------- +// 4b. Failed revalidation staleness guard +// --------------------------------------------------------------------------- + +describe("revalidation sync – staleness guard on failed fetch", () => { + it("skips state overwrite when draftPicks reference is unchanged (fetch failed)", () => { + // Initial loader data (stale — from before mobile background) + const staleDraftPicks = [ + makePick(1, "team-1", "player-1"), + ]; + + // Simulated state that draft-state-sync already updated to fresh data + let picks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + makePick(3, "team-3", "player-3"), + ]; + let currentPick = 4; + const setPicks = (value: any) => { picks = value; }; + const setCurrentPick = (value: number) => { currentPick = value; }; + + // Track the draftPicks reference at revalidation start + const draftPicksAtRevalidationStart = staleDraftPicks; + + // Simulate: revalidation completes but draftPicks is the SAME reference + // (loader fetch failed, React Router didn't update the data) + const draftPicksAfterRevalidation = staleDraftPicks; // same reference + + if (draftPicksAfterRevalidation === draftPicksAtRevalidationStart) { + // Guard fires — skip the overwrite + return; + } + + // This code should NOT run + setPicks(draftPicksAfterRevalidation); + setCurrentPick(1); + + // If guard worked, these should still be the fresh values + expect(picks).toHaveLength(3); + expect(currentPick).toBe(4); + }); + + it("applies state overwrite when draftPicks reference changes (fetch succeeded)", () => { + const staleDraftPicks = [ + makePick(1, "team-1", "player-1"), + ]; + + let picks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + makePick(3, "team-3", "player-3"), + ]; + let currentPick = 4; + const setPicks = (value: any) => { picks = value; }; + const setCurrentPick = (value: number) => { currentPick = value; }; + + const draftPicksAtRevalidationStart = staleDraftPicks; + + // Simulate: revalidation completes with NEW data (different reference) + const freshDraftPicks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + makePick(3, "team-3", "player-3"), + makePick(4, "team-4", "player-4"), + ]; + + if (freshDraftPicks === draftPicksAtRevalidationStart) { + return; // Guard fires — but won't in this case + } + + // Merge logic runs — apply fresh data + const dbPickIds = new Set(freshDraftPicks.map((p) => p.id)); + const bufferedPicks: any[] = []; + const missedPicks = bufferedPicks.filter((p: any) => !dbPickIds.has(p.id)); + setPicks([...freshDraftPicks, ...missedPicks]); + setCurrentPick(5); + + expect(picks).toHaveLength(4); + expect(currentPick).toBe(5); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Available players accuracy after reconnection +// --------------------------------------------------------------------------- + +describe("available players filtering after reconnection", () => { + const allParticipants = [ + makeParticipant("player-1", "Patrick Mahomes"), + makeParticipant("player-2", "Josh Allen"), + makeParticipant("player-3", "Lamar Jackson"), + makeParticipant("player-4", "Jalen Hurts"), + makeParticipant("player-5", "Joe Burrow"), + makeParticipant("player-6", "Dak Prescott"), + ]; + + it("correctly filters drafted players from available list after sync", () => { + // After reconnection, picks include players 1-3 + const picks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + makePick(3, "team-3", "player-3"), + ]; + + const draftedIds = new Set(picks.map((p) => p.participant.id)); + const available = allParticipants.filter((p) => !draftedIds.has(p.id)); + + expect(available).toHaveLength(3); + expect(available.map((p) => p.name)).toEqual([ + "Jalen Hurts", + "Joe Burrow", + "Dak Prescott", + ]); + }); + + it("shows all players as available when picks are empty (draft just started)", () => { + const picks: any[] = []; + const draftedIds = new Set(picks.map((p: any) => p.participant.id)); + const available = allParticipants.filter((p) => !draftedIds.has(p.id)); + + expect(available).toHaveLength(6); + }); + + it("shows no players as available when all are drafted", () => { + const picks = allParticipants.map((p, i) => + makePick(i + 1, `team-${(i % 4) + 1}`, p.id) + ); + const draftedIds = new Set(picks.map((p) => p.participant.id)); + const available = allParticipants.filter((p) => !draftedIds.has(p.id)); + + expect(available).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 6. "On the clock" correctness after reconnection +// --------------------------------------------------------------------------- + +describe("on-the-clock display after reconnection", () => { + const draftSlots = makeDraftSlots(4); + + it("shows correct team on clock after timer-update syncs currentPickNumber", () => { + // User was disconnected at pick 5 (Team 4's turn in round 2) + // 20 picks happened, now at pick 25 (which is round 7, pick 1 → Team 1) + const currentPickNumber = 25; + const currentSlot = getTeamForPick(currentPickNumber, draftSlots); + + expect(currentSlot?.team.id).toBe("team-1"); + expect(currentSlot?.team.name).toBe("Team 1"); + }); + + it("shows correct team after draft-state-sync provides currentPickNumber", () => { + // Server says currentPickNumber is 13 (round 4, even, pick 1 → team 4) + const currentPickNumber = 13; + const currentSlot = getTeamForPick(currentPickNumber, draftSlots); + + expect(currentSlot?.team.id).toBe("team-4"); + }); + + it("handles edge case: currentPickNumber = 1 (draft just started)", () => { + const currentSlot = getTeamForPick(1, draftSlots); + expect(currentSlot?.team.id).toBe("team-1"); + }); + + it("handles last pick of a round correctly", () => { + // Pick 4 = last pick of round 1 (team 4) + expect(getTeamForPick(4, draftSlots)?.team.id).toBe("team-4"); + // Pick 8 = last pick of round 2 (team 1, snake) + expect(getTeamForPick(8, draftSlots)?.team.id).toBe("team-1"); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Revalidation retry mechanism +// --------------------------------------------------------------------------- + +describe("revalidation retry on reconnection", () => { + it("calls revalidate immediately and schedules a retry", () => { + const revalidate = vi.fn(); + let reconnectCount = 0; + + // Simulate the effect logic + const runEffect = () => { + if (reconnectCount > 0) { + revalidate(); + // Schedule retry + setTimeout(() => revalidate(), 3000); + } + }; + + // First reconnect + reconnectCount = 1; + runEffect(); + + expect(revalidate).toHaveBeenCalledTimes(1); + + // After 3 seconds, retry fires + vi.advanceTimersByTime(3000); + expect(revalidate).toHaveBeenCalledTimes(2); + }); + + it("clears pending retry when a new reconnect happens", () => { + const revalidate = vi.fn(); + let timeoutId: ReturnType | null = null; + + const runEffect = (reconnectCount: number) => { + if (reconnectCount > 0) { + revalidate(); + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => revalidate(), 3000); + } + }; + + // First reconnect + runEffect(1); + expect(revalidate).toHaveBeenCalledTimes(1); + + // Second reconnect before retry fires + vi.advanceTimersByTime(1000); + runEffect(2); + expect(revalidate).toHaveBeenCalledTimes(2); + + // After 3s from second reconnect, only one retry fires (not two) + vi.advanceTimersByTime(3000); + expect(revalidate).toHaveBeenCalledTimes(3); + }); + + beforeEach(() => { + vi.useFakeTimers(); + }); +}); + +// --------------------------------------------------------------------------- +// 8. Full reconnection scenario end-to-end (state simulation) +// --------------------------------------------------------------------------- + +describe("full mobile reconnection scenario", () => { + it("correctly syncs all state after 20 picks made during mobile background", () => { + const draftSlots = makeDraftSlots(4); + + // STATE BEFORE MOBILE BACKGROUND (picks 1-4 made) + let picks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + makePick(3, "team-3", "player-3"), + makePick(4, "team-4", "player-4"), + ]; + let currentPick = 5; + let teamTimers: Record = { + "team-1": 100, + "team-2": 100, + "team-3": 100, + "team-4": 100, + }; + + // --- USER GOES TO MOBILE BACKGROUND --- + // 20 more picks happen (picks 5-24), now on pick 25 + + // --- USER RETURNS FROM BACKGROUND --- + + // Step 1: Socket reconnects, server sends draft-state-sync + const serverPicks = Array.from({ length: 24 }, (_, i) => { + const pickNum = i + 1; + const teamIdx = getTeamForPick(pickNum, draftSlots); + return makePick(pickNum, teamIdx!.team.id, `player-${pickNum}`); + }); + + // Simulate draft-state-sync handler + picks = serverPicks; + currentPick = 25; + teamTimers = { + "team-1": 45, + "team-2": 80, + "team-3": 60, + "team-4": 55, + }; + + // VERIFY: All 24 picks are now visible + expect(picks).toHaveLength(24); + + // VERIFY: Correct person on the clock (pick 25 = round 7, odd, pick 1 → team 1) + const onClock = getTeamForPick(currentPick, draftSlots); + expect(onClock?.team.id).toBe("team-1"); + + // VERIFY: Available players excludes drafted ones + const allParticipants = Array.from({ length: 40 }, (_, i) => + makeParticipant(`player-${i + 1}`, `Player ${i + 1}`) + ); + const draftedIds = new Set(picks.map((p) => p.participant.id)); + const available = allParticipants.filter((p) => !draftedIds.has(p.id)); + expect(available).toHaveLength(16); // 40 - 24 = 16 + + // VERIFY: Timer state is fresh + expect(teamTimers["team-1"]).toBe(45); + + // Step 2: Timer-update arrives 1 second later + // This would also sync currentPickNumber (belt-and-suspenders) + const timerUpdate = { + seasonId: "season-1", + teamId: "team-1", + timeRemaining: 44, + currentPickNumber: 25, + }; + teamTimers = { ...teamTimers, [timerUpdate.teamId]: timerUpdate.timeRemaining }; + currentPick = timerUpdate.currentPickNumber; + + expect(currentPick).toBe(25); + expect(teamTimers["team-1"]).toBe(44); + }); + + it("handles reconnection during a paused draft", () => { + let isPaused = false; + let currentPick = 5; + + // Server sends sync with paused state + isPaused = true; + currentPick = 10; + + expect(isPaused).toBe(true); + expect(currentPick).toBe(10); + }); + + it("handles reconnection after draft completed", () => { + let isDraftComplete = false; + let picks: any[] = []; + + // Server sends sync with completed status + const totalPicks = 80; // 4 teams × 20 rounds + picks = Array.from({ length: totalPicks }, (_, i) => + makePick(i + 1, `team-${(i % 4) + 1}`, `player-${i + 1}`) + ); + isDraftComplete = true; + + expect(isDraftComplete).toBe(true); + expect(picks).toHaveLength(80); + }); +}); + +// --------------------------------------------------------------------------- +// 9. Autodraft and timer sync on revalidation +// --------------------------------------------------------------------------- + +describe("autodraft and timer sync on revalidation completion", () => { + it("syncs autodraftStatus from loader data on revalidation", () => { + let autodraftStatus: Record = { + "team-1": false, + "team-2": false, + }; + + // Simulate what happens on revalidation completion + const autodraftSettings = [ + { teamId: "team-1", isEnabled: true }, + { teamId: "team-2", isEnabled: false }, + { teamId: "team-3", isEnabled: true }, + ]; + + autodraftStatus = {}; + autodraftSettings.forEach((setting) => { + autodraftStatus[setting.teamId] = setting.isEnabled; + }); + + expect(autodraftStatus["team-1"]).toBe(true); + expect(autodraftStatus["team-2"]).toBe(false); + expect(autodraftStatus["team-3"]).toBe(true); + }); + + it("syncs teamTimers from loader data on revalidation", () => { + let teamTimers: Record = { + "team-1": 100, + "team-2": 100, + }; + + const timers = [ + { teamId: "team-1", timeRemaining: 45 }, + { teamId: "team-2", timeRemaining: 80 }, + { teamId: "team-3", timeRemaining: 120 }, + ]; + + // Simulate the sync logic + const updated = { ...teamTimers }; + timers.forEach((timer) => { + updated[timer.teamId] = timer.timeRemaining; + }); + teamTimers = updated; + + expect(teamTimers["team-1"]).toBe(45); + expect(teamTimers["team-2"]).toBe(80); + expect(teamTimers["team-3"]).toBe(120); + }); +}); diff --git a/server/socket.ts b/server/socket.ts index 3f1e67e..622ae75 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -4,7 +4,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "~/database/schema"; -import { eq, and } from "drizzle-orm"; +import { eq, and, asc } from "drizzle-orm"; // Lazy-initialized DB for socket-level validation queries (team ownership checks) let _socketDb: PostgresJsDatabase | null = null; @@ -46,6 +46,25 @@ interface ServerToClientEvents { "draft-paused": (data: { seasonId: string; paused: boolean }) => void; "draft-resumed": (data: { seasonId: string; paused: boolean }) => void; "participant-removed-from-queues": (data: { participantId: string }) => void; + "draft-state-sync": (data: { + currentPickNumber: number; + isPaused: boolean; + status: string; + picks: Array<{ + id: string; + pickNumber: number; + round: number; + pickInRound: number; + timeUsed: number; + team: any; + participant: any; + sport: any; + }>; + timers: Array<{ + teamId: string; + timeRemaining: number; + }>; + }) => void; } interface ClientToServerEvents { @@ -146,6 +165,67 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { io!.to(`draft-${seasonId}`).emit("team-connected", { teamId }); console.log(`Team ${teamId} connected to draft-${seasonId}. Total connected: ${seasonConnectedTeams.size}`); } + + // Send full draft state to the joining client so it can sync immediately. + // This is critical for mobile reconnection: the client may have been in the + // background for minutes/hours and missed many picks. The revalidate() path + // (HTTP loader re-fetch) covers this too, but it can fail due to expired auth + // tokens or flaky mobile networks. This socket-based sync provides an + // additional, more reliable path since the socket is already connected. + try { + const db = getSocketDb(); + const [seasonData, picks, timerRows] = await Promise.all([ + db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + }), + db + .select({ + id: schema.draftPicks.id, + pickNumber: schema.draftPicks.pickNumber, + round: schema.draftPicks.round, + pickInRound: schema.draftPicks.pickInRound, + timeUsed: schema.draftPicks.timeUsed, + team: schema.teams, + participant: schema.participants, + sport: schema.sports, + }) + .from(schema.draftPicks) + .innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id)) + .innerJoin( + schema.participants, + eq(schema.draftPicks.participantId, schema.participants.id) + ) + .innerJoin( + schema.sportsSeasons, + eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id) + ) + .innerJoin( + schema.sports, + eq(schema.sportsSeasons.sportId, schema.sports.id) + ) + .where(eq(schema.draftPicks.seasonId, seasonId)) + .orderBy(asc(schema.draftPicks.pickNumber)), + db.query.draftTimers.findMany({ + where: eq(schema.draftTimers.seasonId, seasonId), + }), + ]); + + if (seasonData) { + socket.emit("draft-state-sync", { + currentPickNumber: seasonData.currentPickNumber || 1, + isPaused: seasonData.draftPaused || false, + status: seasonData.status, + picks, + timers: timerRows.map((t) => ({ + teamId: t.teamId, + timeRemaining: t.timeRemaining, + })), + }); + } + } catch (err) { + console.error("[Socket] draft-state-sync query failed:", err); + // Non-fatal — the client will fall back to HTTP revalidation + } }); socket.on("leave-draft", (seasonId: string) => {