/** * Tests for draft.force-manual-pick timer behavior across chess_clock and standard modes. * * chess_clock: force picks earn the increment just like any other pick (bank += increment) * standard: force picks always reset the bank to exactly draftIncrementTime */ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { RouterContextProvider } from "react-router"; import { action } from "~/routes/api/draft.force-manual-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 }), })); vi.mock("~/models/user", () => ({ isUserAdminByClerkId: vi.fn(), })); vi.mock("~/models/audit-log", () => ({ logCommissionerAction: vi.fn().mockResolvedValue(undefined), })); // ── Fixtures ────────────────────────────────────────────────────────────────── const SEASON_ID = "season-1"; const TEAM_ID = "team-1"; const NEXT_TEAM_ID = "team-2"; const PARTICIPANT_ID = "participant-1"; const COMMISSIONER_ID = "commissioner-user-1"; const ADMIN_ID = "admin-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: COMMISSIONER_ID, pickedByType: "commissioner", }; 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-1" }, }, { 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("teamId", TEAM_ID); formData.append("participantId", PARTICIPANT_ID); formData.append("pickNumber", "1"); return new Request("http://localhost/api/draft/force-manual-pick", { method: "POST", body: formData, }); } // ── Tests ────────────────────────────────────────────────────────────────────── describe("draft.force-manual-pick action — timer mode behavior", () => { let mockDb: any; let mockSocketIO: any; beforeEach(async () => { vi.clearAllMocks(); // Default: authenticated as commissioner const { getAuth } = await import("@clerk/react-router/server"); vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_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({ id: "c-1", userId: COMMISSIONER_ID }), }, draftPicks: { findFirst: vi.fn().mockResolvedValue(null) }, participants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) }, draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) }, draftTimers: { findFirst: vi.fn().mockResolvedValue({ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 75 }), }, }, 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); }); // ── chess_clock mode ──────────────────────────────────────────────────────── describe("chess_clock mode", () => { beforeEach(() => { mockDb.query.seasons.findFirst.mockResolvedValue( makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 }) ); }); describe("commissioner force pick", () => { it("emits timer-update with bank + increment", async () => { // Pre-pick bank: 75s. Expected after increment: 75 + 30 = 105s. mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]); await action({ request: makeRequest(), params: {}, context: ctx }); expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 }) ); }); it("writes the timer update to the DB", async () => { mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]); await action({ request: makeRequest(), params: {}, context: ctx }); // Two DB updates: timer row + season.currentPickNumber expect(mockDb.set).toHaveBeenCalledWith( expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) }) ); }); it("accumulates a larger bank when more time was remaining", async () => { mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 }); // 100 + 30 = 130 mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", 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("admin force pick", () => { beforeEach(async () => { const { getAuth } = await import("@clerk/react-router/server"); vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any); const { isUserAdminByClerkId } = await import("~/models/user"); vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); // Admin is not a commissioner mockDb.query.commissioners.findFirst.mockResolvedValue(null); }); it("emits timer-update with bank + increment", async () => { mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]); await action({ request: makeRequest(), params: {}, context: ctx }); expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 }) ); }); it("writes the timer update to the DB", async () => { mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]); await action({ request: makeRequest(), params: {}, context: ctx }); expect(mockDb.set).toHaveBeenCalledWith( expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) }) ); }); }); }); // ── standard mode ─────────────────────────────────────────────────────────── describe("standard mode", () => { beforeEach(() => { mockDb.query.seasons.findFirst.mockResolvedValue( makeSeason({ draftTimerMode: "standard", draftInitialTime: 30, draftIncrementTime: 30 }) ); }); describe("commissioner force pick", () => { it("resets bank to exactly draftIncrementTime", async () => { // Pre-pick bank doesn't matter — standard always resets mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 5 }); mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", 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("does not accumulate time even when bank was large", async () => { // Team had 25s left; standard mode resets to increment, never adds to prior balance mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 }); mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", 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 }) ); }); }); describe("admin force pick", () => { beforeEach(async () => { const { getAuth } = await import("@clerk/react-router/server"); vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any); const { isUserAdminByClerkId } = await import("~/models/user"); vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); mockDb.query.commissioners.findFirst.mockResolvedValue(null); }); it("resets bank to exactly draftIncrementTime", async () => { mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", 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("uses custom draftIncrementTime when configured", async () => { mockDb.query.seasons.findFirst.mockResolvedValue( makeSeason({ draftTimerMode: "standard", draftInitialTime: 60, draftIncrementTime: 60 }) ); mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 60 }]); await action({ request: makeRequest(), params: {}, context: ctx }); expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 60 }) ); }); }); }); });