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) => {