diff --git a/app/routes/api/__tests__/draft.force-manual-pick.test.ts b/app/routes/api/__tests__/draft.force-manual-pick.test.ts new file mode 100644 index 0000000..1c30d93 --- /dev/null +++ b/app/routes/api/__tests__/draft.force-manual-pick.test.ts @@ -0,0 +1,416 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { action } from "~/routes/api/draft.force-manual-pick"; + +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(), +})); + +// ── 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: {} }); + + 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: {} }); + + 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: {}, + }); + + 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: {} }); + + expect(response.status).toBe(404); + }); + + it("returns 400 when the participant is already drafted", async () => { + mockDb.query.draftPicks.findFirst.mockResolvedValue({ + id: "existing-pick", + participantId: PARTICIPANT_ID, + }); + + const response = await action({ request: defaultPickRequest(), params: {}, context: {} }); + + 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: {} }); + + 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: {} }); + + 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: {} }); + + 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: {}, + }); + + 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: {} }); + + 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: {} }); + + 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 () => { + // Team has 75s; increment is 30s → expected new balance: 105s + mockDb.query.draftTimers.findFirst.mockResolvedValue({ + id: "timer-1", + seasonId: SEASON_ID, + teamId: TEAM_ID, + timeRemaining: 75, + }); + + await action({ request: defaultPickRequest(), params: {}, context: {} }); + + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ timeRemaining: 105 }) + ); + }); + + it("emits timer-update for the picking team with their incremented balance", async () => { + mockDb.query.draftTimers.findFirst.mockResolvedValue({ + id: "timer-1", + seasonId: SEASON_ID, + teamId: TEAM_ID, + timeRemaining: 75, + }); + + await action({ request: defaultPickRequest(), params: {}, context: {} }); + + expect(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", { + seasonId: SEASON_ID, + teamId: TEAM_ID, + timeRemaining: 105, + currentPickNumber: 1, + }); + }); + + it("creates a new timer for the picking team if they have no existing timer", async () => { + mockDb.query.draftTimers.findFirst.mockResolvedValue(null); + + await action({ request: defaultPickRequest(), params: {}, context: {} }); + + // insert should be called for: (1) draft pick, (2) new timer (0 + 30 = 30s) + expect(mockDb.insert).toHaveBeenCalledTimes(2); + }); + + it("increment is additive, not a flat reset — fast pickers accumulate time", async () => { + // Team used 20s of their 120s initial bank before picking (100s remaining) + mockDb.query.draftTimers.findFirst.mockResolvedValue({ + id: "timer-1", + seasonId: SEASON_ID, + teamId: TEAM_ID, + timeRemaining: 100, + }); + + await action({ request: defaultPickRequest(), params: {}, context: {} }); + + // 100 + 30 = 130, not 120 (which would be a reset to initialTime) + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ 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 () => { + // Before the fix, draftTimers.findFirst was called twice: + // once to get the picking team's current balance, and + // once to find (and then reset) the next team's timer. + // After the fix it should be called exactly once. + await action({ request: defaultPickRequest(), params: {}, context: {} }); + + expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(1); + }); + + 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: {} }); + + 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: {} }); + + expect(mockDb.update).toHaveBeenCalledTimes(2); + }); + }); +});