/** * 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); if (!teamIdx) throw new Error(`No team for pick ${pickNum}`); 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", () => { type AutodraftStatusEntry = { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }; let autodraftStatus: Record = { "team-1": { isEnabled: false, mode: "next_pick", queueOnly: false }, "team-2": { isEnabled: false, mode: "next_pick", queueOnly: false }, }; // Simulate what happens on revalidation completion const autodraftSettings = [ { teamId: "team-1", isEnabled: true, mode: "while_on" as const, queueOnly: false }, { teamId: "team-2", isEnabled: false, mode: "next_pick" as const, queueOnly: false }, { teamId: "team-3", isEnabled: true, mode: "next_pick" as const, queueOnly: true }, ]; autodraftStatus = {}; autodraftSettings.forEach((setting) => { autodraftStatus[setting.teamId] = { isEnabled: setting.isEnabled, mode: setting.mode, queueOnly: setting.queueOnly, }; }); expect(autodraftStatus["team-1"].isEnabled).toBe(true); expect(autodraftStatus["team-2"].isEnabled).toBe(false); expect(autodraftStatus["team-3"].isEnabled).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); }); });