brackt/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts

761 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tests for draft 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<string, number> = {};
const setTeamTimers = (fn: (prev: Record<string, number>) => Record<string, number>) => {
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<string, number> = { "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<string, number>) => Record<string, number>) => {
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<typeof setTimeout> | 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<string, number> = {
"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", () => {
type AutodraftStatusEntry = { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean };
let autodraftStatus: Record<string, AutodraftStatusEntry> = {
"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<string, number> = {
"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);
});
});