/** * Tests for draft.make-pick timer behavior across chess_clock and standard modes. * * chess_clock: after a pick, the team's remaining bank += increment (carry-over) * standard: after a pick, the team's bank resets to exactly the increment */ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { RouterContextProvider } from "react-router"; import { action } from "~/routes/api/draft.make-pick"; const ctx = {} as unknown as RouterContextProvider; vi.mock("~/database/context"); vi.mock("~/server/socket", () => ({ getSocketIO: vi.fn(), })); vi.mock("@clerk/react-router/server", () => ({ getAuth: vi.fn(), })); vi.mock("~/models/draft-pick", () => ({ getDraftPicksWithSports: vi.fn(), getTeamDraftPicksWithSports: vi.fn(), })); vi.mock("~/models/participant", () => ({ getParticipantsForSeasonWithSports: vi.fn(), })); vi.mock("~/models/season-sport", () => ({ getSeasonSportsSimple: vi.fn(), })); vi.mock("~/lib/draft-eligibility", () => ({ calculateDraftEligibility: vi.fn(), })); vi.mock("~/models/draft-utils", () => ({ checkAndTriggerNextAutodraft: vi.fn(), calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }), pruneIneligibleQueueItems: vi.fn().mockResolvedValue([]), })); vi.mock("~/models/user", () => ({ isUserAdminByClerkId: vi.fn(), })); // ── Fixtures ───────────────────────────────────────────────────────────────── const SEASON_ID = "season-1"; const TEAM_ID = "team-1"; const NEXT_TEAM_ID = "team-2"; const PARTICIPANT_ID = "participant-1"; const OWNER_ID = "owner-user-1"; const SPORT_ID = "sport-nfl"; const mockParticipant = { id: PARTICIPANT_ID, name: "Patrick Mahomes", sportsSeason: { id: "sports-season-1", sport: { id: SPORT_ID, name: "NFL" }, }, }; const mockDraftPick = { id: "pick-1", seasonId: SEASON_ID, teamId: TEAM_ID, participantId: PARTICIPANT_ID, pickNumber: 1, round: 1, pickInRound: 1, pickedByUserId: OWNER_ID, pickedByType: "owner", }; const mockDraftSlots = [ { id: "slot-1", seasonId: SEASON_ID, teamId: TEAM_ID, draftOrder: 1, team: { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: OWNER_ID }, }, { id: "slot-2", seasonId: SEASON_ID, teamId: NEXT_TEAM_ID, draftOrder: 2, team: { id: NEXT_TEAM_ID, name: "Team 2", seasonId: SEASON_ID, ownerId: "owner-2" }, }, ]; function makeSeason(overrides: Record = {}) { return { id: SEASON_ID, leagueId: "league-1", status: "draft", draftRounds: 3, draftInitialTime: 120, draftIncrementTime: 30, draftTimerMode: "chess_clock", currentPickNumber: 1, draftPaused: false, ...overrides, }; } function makeRequest() { const formData = new FormData(); formData.append("seasonId", SEASON_ID); formData.append("participantId", PARTICIPANT_ID); return new Request("http://localhost/api/draft/make-pick", { method: "POST", body: formData, }); } // ── Tests ───────────────────────────────────────────────────────────────────── describe("draft.make-pick action — timer mode behavior", () => { let mockDb: any; let mockSocketIO: any; beforeEach(async () => { vi.clearAllMocks(); const { getAuth } = await import("@clerk/react-router/server"); vi.mocked(getAuth).mockResolvedValue({ userId: OWNER_ID } as any); const { isUserAdminByClerkId } = await import("~/models/user"); vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() }; const socketModule = await import("~/server/socket"); vi.mocked(socketModule.getSocketIO).mockReturnValue(mockSocketIO); const { getDraftPicksWithSports, getTeamDraftPicksWithSports } = await import("~/models/draft-pick"); vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]); const { getParticipantsForSeasonWithSports } = await import("~/models/participant"); vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]); const { getSeasonSportsSimple } = await import("~/models/season-sport"); vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); const { calculateDraftEligibility } = await import("~/lib/draft-eligibility"); vi.mocked(calculateDraftEligibility).mockReturnValue({ eligibleSportIds: new Set([SPORT_ID]), ineligibleReasons: {}, } as any); const { checkAndTriggerNextAutodraft } = await import("~/models/draft-utils"); vi.mocked(checkAndTriggerNextAutodraft).mockResolvedValue(undefined); mockDb = { query: { seasons: { findFirst: vi.fn() }, commissioners: { findFirst: vi.fn().mockResolvedValue(null) }, draftPicks: { findFirst: vi.fn().mockResolvedValue(null) }, participants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) }, draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) }, draftTimers: { findFirst: vi.fn().mockResolvedValue({ timeRemaining: 75 }) }, draftQueue: { findMany: vi.fn().mockResolvedValue([]) }, }, insert: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([mockDraftPick]), update: vi.fn().mockReturnThis(), set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), delete: vi.fn().mockReturnThis(), }; const { database } = await import("~/database/context"); vi.mocked(database).mockReturnValue(mockDb); }); describe("chess_clock mode", () => { describe("owner pick — earns the increment", () => { it("emits timer-update with the incremented balance (bank + increment)", async () => { mockDb.query.seasons.findFirst.mockResolvedValue( makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 }) ); // Simulate DB returning previous 75s + 30s increment = 105s mockDb.returning .mockResolvedValueOnce([mockDraftPick]) // insert draft pick .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]); // update timer await action({ request: makeRequest(), params: {}, context: ctx }); expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 }) ); }); it("picks with more time remaining accumulate a larger bank", async () => { mockDb.query.seasons.findFirst.mockResolvedValue( makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 }) ); // Team had 100s; 100 + 30 = 130s mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 130 }]); await action({ request: makeRequest(), params: {}, context: ctx }); expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 }) ); }); }); describe("commissioner pick — no increment earned", () => { const COMMISSIONER_ID = "commissioner-user-1"; beforeEach(async () => { // Auth as commissioner (not the team owner) const { getAuth } = await import("@clerk/react-router/server"); vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any); // Commissioner record exists mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID, }); // Team is owned by someone else mockDb.query.draftSlots.findMany.mockResolvedValue([ { id: "slot-1", seasonId: SEASON_ID, teamId: TEAM_ID, draftOrder: 1, team: { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: "different-owner" }, }, { id: "slot-2", seasonId: SEASON_ID, teamId: NEXT_TEAM_ID, draftOrder: 2, team: { id: NEXT_TEAM_ID, name: "Team 2", seasonId: SEASON_ID, ownerId: "owner-2" }, }, ]); }); it("emits timer-update with the frozen (unchanged) bank — no increment added", async () => { mockDb.query.seasons.findFirst.mockResolvedValue( makeSeason({ draftTimerMode: "chess_clock", draftIncrementTime: 30 }) ); // timerSnapshot has 75s remaining; commissioner picks → should still be 75s mockDb.query.draftTimers.findFirst.mockResolvedValue({ timeRemaining: 75 }); mockDb.returning.mockResolvedValueOnce([mockDraftPick]); await action({ request: makeRequest(), params: {}, context: ctx }); expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 75 }) ); }); it("does not write the timer to the DB (no increment update)", async () => { mockDb.query.seasons.findFirst.mockResolvedValue( makeSeason({ draftTimerMode: "chess_clock", draftIncrementTime: 30 }) ); mockDb.returning.mockResolvedValueOnce([mockDraftPick]); await action({ request: makeRequest(), params: {}, context: ctx }); // Only one update: season.currentPickNumber — the timer row is NOT written expect(mockDb.update).toHaveBeenCalledTimes(1); }); }); }); describe("standard mode", () => { it("emits timer-update with exactly draftIncrementTime regardless of remaining bank", async () => { mockDb.query.seasons.findFirst.mockResolvedValue( makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 }) ); // Simulate DB resetting to exactly 30s (the increment), ignoring prior bank mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]); await action({ request: makeRequest(), params: {}, context: ctx }); expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 }) ); }); it("fast pickers do NOT accumulate time — always resets to increment", async () => { mockDb.query.seasons.findFirst.mockResolvedValue( makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 }) ); // Team picked quickly with 90s remaining — in standard mode they still reset to 30s mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]); await action({ request: makeRequest(), params: {}, context: ctx }); // Must be 30 (increment reset), not 120 (initialTime) or 120 (90+30) expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 }) ); }); it("still returns 200 on a successful pick", async () => { mockDb.query.seasons.findFirst.mockResolvedValue( makeSeason({ draftTimerMode: "standard", draftIncrementTime: 45 }) ); mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 45 }]); const response = await action({ request: makeRequest(), params: {}, context: ctx }); expect(response.status).toBe(200); const data = await response.json(); expect(data.success).toBe(true); }); }); });