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
This commit is contained in:
parent
853cd3756b
commit
3da27af036
1 changed files with 416 additions and 0 deletions
416
app/routes/api/__tests__/draft.force-manual-pick.test.ts
Normal file
416
app/routes/api/__tests__/draft.force-manual-pick.test.ts
Normal file
|
|
@ -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<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue