447 lines
16 KiB
TypeScript
447 lines
16 KiB
TypeScript
|
|
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(),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// ── 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: 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 () => {
|
|||
|
|
// 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: ctx });
|
|||
|
|
|
|||
|
|
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: ctx });
|
|||
|
|
|
|||
|
|
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: ctx });
|
|||
|
|
|
|||
|
|
// 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: ctx });
|
|||
|
|
|
|||
|
|
// 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: ctx });
|
|||
|
|
|
|||
|
|
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: 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);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|