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("~/lib/auth.server", () => ({ auth: { api: { getSession: 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", () => ({ isUserAdmin: 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 SPORT_ID = "sport-nfl"; const mockSeason = { id: SEASON_ID, leagueId: "league-1", status: "draft", draftRounds: 3, draftInitialTime: 120, draftIncrementTime: 30, draftTimerMode: "chess_clock", currentPickNumber: 1, draftPaused: false, }; const mockTeams = [ { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: "owner-1" }, { id: NEXT_TEAM_ID, name: "Team 2", seasonId: SEASON_ID, ownerId: "owner-2" }, ]; const mockDraftSlots = [ { id: "slot-1", seasonId: SEASON_ID, teamId: TEAM_ID, draftOrder: 1, team: mockTeams[0] }, { id: "slot-2", seasonId: SEASON_ID, teamId: NEXT_TEAM_ID, draftOrder: 2, team: mockTeams[1] }, ]; 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", }; // ── Helpers ─────────────────────────────────────────────────────────────────── function makeRequest(data: Record) { const formData = new FormData(); for (const [key, value] of Object.entries(data)) { formData.append(key, value); } return new Request("http://localhost/api/draft/force-manual-pick", { method: "POST", body: formData, }); } function defaultPickRequest() { return makeRequest({ seasonId: SEASON_ID, teamId: TEAM_ID, participantId: PARTICIPANT_ID, pickNumber: "1", }); } // ── Tests ───────────────────────────────────────────────────────────────────── describe("draft.force-manual-pick action", () => { let mockDb: any; let mockSocketIO: any; beforeEach(async () => { vi.clearAllMocks(); // Auth: default to authenticated commissioner const { auth } = await import("~/lib/auth.server"); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any); // User model: default to non-admin const { isUserAdmin } = await import("~/models/user"); vi.mocked(isUserAdmin).mockResolvedValue(false); // Socket mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() }; const socketModule = await import("~/server/socket"); vi.mocked(socketModule.getSocketIO).mockReturnValue(mockSocketIO); // Eligibility model functions 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); // Database — happy-path defaults mockDb = { query: { seasons: { findFirst: vi.fn() }, commissioners: { findFirst: vi.fn() }, draftPicks: { findFirst: vi.fn() }, participants: { findFirst: vi.fn() }, draftSlots: { findMany: vi.fn() }, draftTimers: { findFirst: vi.fn() }, }, insert: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis(), returning: vi.fn(), update: vi.fn().mockReturnThis(), set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), delete: vi.fn().mockReturnThis(), }; mockDb.query.seasons.findFirst.mockResolvedValue(mockSeason); mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID, }); mockDb.query.draftPicks.findFirst.mockResolvedValue(null); mockDb.query.participants.findFirst.mockResolvedValue(mockParticipant); mockDb.query.draftSlots.findMany.mockResolvedValue(mockDraftSlots); mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 75, }); mockDb.returning.mockResolvedValue([mockDraftPick]); const { database } = await import("~/database/context"); vi.mocked(database).mockReturnValue(mockDb); }); // ── Authorization ────────────────────────────────────────────────────────── describe("authorization", () => { it("returns 401 when user is not authenticated", async () => { const { auth } = await import("~/lib/auth.server"); vi.mocked(auth.api.getSession).mockResolvedValue(null as any); const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(response.status).toBe(401); }); it("returns 403 when authenticated user is not a commissioner", async () => { mockDb.query.commissioners.findFirst.mockResolvedValue(null); const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(response.status).toBe(403); const data = await response.json(); expect(data.error).toMatch(/commissioner/i); }); }); // ── Input validation ─────────────────────────────────────────────────────── describe("input validation", () => { it("returns 400 when required fields are missing", async () => { const response = await action({ request: makeRequest({ seasonId: SEASON_ID }), // missing teamId, participantId, pickNumber params: {}, context: ctx, }); expect(response.status).toBe(400); }); it("returns 404 when the season does not exist", async () => { mockDb.query.seasons.findFirst.mockResolvedValue(null); const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(response.status).toBe(404); }); it("returns 400 when the draft is not active", async () => { mockDb.query.seasons.findFirst.mockResolvedValue({ ...mockSeason, status: "pre_draft" }); const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(response.status).toBe(400); const data = await response.json(); expect(data.error).toMatch(/not currently active/i); }); it("returns 400 when a pick already exists at the requested slot", async () => { mockDb.query.draftPicks.findFirst.mockResolvedValueOnce({ id: "existing-pick", participantId: "other-participant", }); const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(response.status).toBe(400); const data = await response.json(); expect(data.error).toMatch(/already exists at this slot/i); }); it("returns 400 when the participant is already drafted", async () => { // First call: slot check (no pick at this slot number) // Second call: participant check (participant already drafted elsewhere) mockDb.query.draftPicks.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: "existing-pick", participantId: PARTICIPANT_ID, }); const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(response.status).toBe(400); const data = await response.json(); expect(data.error).toMatch(/already drafted/i); }); it("returns 404 when the participant does not exist", async () => { mockDb.query.participants.findFirst.mockResolvedValue(null); const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(response.status).toBe(404); }); it("returns 400 when the participant's sport is ineligible for the team", async () => { const { calculateDraftEligibility } = await import("~/lib/draft-eligibility"); vi.mocked(calculateDraftEligibility).mockReturnValue({ eligibleSportIds: new Set(), ineligibleReasons: { [SPORT_ID]: "Sport limit reached" }, } as any); const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(response.status).toBe(400); const data = await response.json(); expect(data.error).toMatch(/sport limit reached/i); }); }); // ── Successful pick ──────────────────────────────────────────────────────── describe("successful pick", () => { it("returns 200 with pick data and next pick number", async () => { const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(response.status).toBe(200); const data = await response.json(); expect(data.success).toBe(true); expect(data.pick).toBeDefined(); expect(data.nextPickNumber).toBe(2); expect(data.isDraftComplete).toBe(false); }); it("marks the draft as complete when the final pick is made", async () => { // 2 teams × 3 rounds = 6 total picks; pick 6 is the last const response = await action({ request: makeRequest({ seasonId: SEASON_ID, teamId: TEAM_ID, participantId: PARTICIPANT_ID, pickNumber: "6", }), params: {}, context: ctx, }); const data = await response.json(); expect(data.isDraftComplete).toBe(true); }); it("emits pick-made to the correct draft room", async () => { await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${SEASON_ID}`); expect(mockSocketIO.emit).toHaveBeenCalledWith( "pick-made", expect.objectContaining({ nextPickNumber: 2, isDraftComplete: false }) ); }); it("removes the participant from all team queues in the season", async () => { await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(mockDb.delete).toHaveBeenCalled(); expect(mockSocketIO.emit).toHaveBeenCalledWith("participant-removed-from-queues", { participantId: PARTICIPANT_ID, }); }); }); // ── Timer behavior ───────────────────────────────────────────────────────── // // Force manual picks earn the increment the same as any other pick. // // chess_clock: bank += draftIncrementTime (atomic add). // standard: bank resets to draftIncrementTime for the next turn. // Next team's bank is always left completely untouched. describe("timer behavior", () => { describe("chess_clock mode", () => { it("adds the increment to the picking team's bank", async () => { // Timer has 75s (from beforeEach mock); 75 + 30 = 105s after increment mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]); await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 }) ); }); it("writes the incremented timer to the DB", async () => { mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]); await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(mockDb.set).toHaveBeenCalledWith( expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) }) ); }); }); describe("standard mode", () => { beforeEach(() => { mockDb.query.seasons.findFirst.mockResolvedValue({ ...mockSeason, draftTimerMode: "standard", }); }); it("resets the picking team's timer to draftIncrementTime", async () => { mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]); await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 }) ); }); it("writes the timer reset to the DB", async () => { mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]); await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(mockDb.set).toHaveBeenCalledWith( expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) }) ); }); }); // ── REGRESSION: Next team's timer must never be touched ────────────────── it("REGRESSION: does not emit a timer-update for the next team", async () => { await action({ request: defaultPickRequest(), params: {}, context: ctx }); const nextTeamTimerEmits = mockSocketIO.emit.mock.calls.filter( ([event, payload]: [string, any]) => event === "timer-update" && payload?.teamId === NEXT_TEAM_ID ); expect(nextTeamTimerEmits).toHaveLength(0); }); }); });