brackt/app/routes/api/__tests__/draft.force-manual-pick.test.ts
Claude 3da27af036
Add regression tests for draft.force-manual-pick timer behavior
18 tests across 5 describe blocks covering:
- Authorization (401/403)
- Input validation (missing fields, bad participant, ineligible sport)
- Successful pick (response shape, draft-complete detection, socket events)
- Timer behavior (increment added to picking team, new timer creation, additive not reset)
- Two regression tests confirming the next team's timer is never touched:
  draftTimers.findFirst called exactly once, no timer-update emitted for
  next team, db.update called exactly twice (not three times)

https://claude.ai/code/session_01X7gwWmafUSEvVHcV7Raz5p
2026-02-23 02:10:52 +00:00

416 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string>) {
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);
});
});
});