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 }), })); // ── 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, 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 { getAuth } = await import("@clerk/react-router/server"); vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any); // 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 { getAuth } = await import("@clerk/react-router/server"); vi.mocked(getAuth).mockResolvedValue({ userId: 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 ───────────────────────────────────────────────────────── // // All three pick paths (user pick, force auto, force manual) must treat timers // identically: // 1. The team that was picked FOR gets +draftIncrementTime added to their bank. // 2. The next team's bank is left completely untouched. describe("timer behavior", () => { it("adds draftIncrementTime to the picking team's time bank", async () => { // Timer has 75s; increment is 30s → DB atomically returns new balance: 105s 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("emits timer-update for the picking team with their incremented balance", 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(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", { seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105, currentPickNumber: 2, }); }); it("creates a new timer for the picking team if they have no existing timer", async () => { // update() returns [] when no timer row exists → triggers the insert fallback mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([]); await action({ request: defaultPickRequest(), params: {}, context: ctx }); // insert should be called for: (1) draft pick, (2) new timer (increment only: 30s) expect(mockDb.insert).toHaveBeenCalledTimes(2); }); it("increment is additive, not a flat reset — fast pickers accumulate time", async () => { // Team had 100s remaining; increment is 30s → DB atomically returns 130s mockDb.returning .mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 130 }]); await action({ request: defaultPickRequest(), params: {}, context: ctx }); // Verify 130s (100 + 30), not 120s (which would be a flat reset to initialTime) expect(mockSocketIO.emit).toHaveBeenCalledWith( "timer-update", expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 }) ); }); // ── REGRESSION TESTS ──────────────────────────────────────────────────── // // Bug: force-manual-pick was resetting the next team's timer to // draftInitialTime (120s) instead of carrying their bank forward. // The correct behaviour (matching make-pick and force-autopick) is to // leave the next team's timer completely untouched. it("REGRESSION: does not look up the next team's timer", async () => { // The atomic SQL increment (timeRemaining + N) requires no prior read — // draftTimers.findFirst is never called. await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(0); }); it("REGRESSION: does not emit a timer-update for the next team", async () => { // Before the fix, a timer-update was emitted for the next team with // timeRemaining: draftInitialTime (120s), overwriting their actual bank. 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); }); it("REGRESSION: does not overwrite the next team's existing time bank", async () => { // Verify that db.update is called exactly twice (timer increment + season // pick number), not three times (which would include resetting the next // team's timer). await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(mockDb.update).toHaveBeenCalledTimes(2); }); }); });