Implements a new "standard" timer mode alongside the existing chess clock mode. In standard mode the per-pick timer resets to a fixed value after every pick (no carry-over), and the speed selector shows plain time values instead of named chess-clock presets. Key changes: - Add `draft_timer_mode` enum column to `seasons` table (migration 0053) - `draft.start`: standard mode seeds timers at `draftIncrementTime` (the per-pick value) rather than `draftInitialTime` - `draft.make-pick`: three-way branch — standard resets, chess clock owner earns increment, commissioner/admin pick leaves bank frozen - `draft.force-manual-pick`: commissioner picks never earn bank time; chess clock path uses a pre-pick snapshot to avoid a race window with the 1-second timer loop - `executeAutoPick` in draft-utils: auto picks never earn bank time; chess clock path skips the DB update (timer already at 0) - League creation and settings pages: mode-aware speed selector (raw seconds for standard, named presets for chess clock); shared `parseDraftSpeed` utility extracted to `app/lib/draft-timer.ts` - Tests added for draft.start timer init and make-pick timer mode behavior; force-manual-pick tests updated for new timer semantics Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
436 lines
16 KiB
TypeScript
436 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(),
|
||
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
|
||
}));
|
||
vi.mock("~/models/user", () => ({
|
||
isUserAdminByClerkId: 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,
|
||
draftTimerMode: "chess_clock",
|
||
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);
|
||
|
||
// User model: default to non-admin
|
||
const { isUserAdminByClerkId } = await import("~/models/user");
|
||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
||
|
||
// 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 ─────────────────────────────────────────────────────────
|
||
//
|
||
// Force manual picks are commissioner actions — they do NOT earn the team
|
||
// any extra time bank, regardless of timer mode.
|
||
//
|
||
// chess_clock: bank is frozen (no DB update); current value read for socket emit.
|
||
// standard: bank resets to draftIncrementTime (the per-pick time) for the next turn.
|
||
// Next team's bank is always left completely untouched.
|
||
|
||
describe("timer behavior", () => {
|
||
describe("chess_clock mode", () => {
|
||
it("does NOT add increment to the picking team's bank", async () => {
|
||
// Timer has 75s (from beforeEach mock); forced pick → bank stays at 75s
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||
"timer-update",
|
||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 75 })
|
||
);
|
||
});
|
||
|
||
it("emits timer-update with the frozen (unchanged) bank value", async () => {
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", {
|
||
seasonId: SEASON_ID,
|
||
teamId: TEAM_ID,
|
||
timeRemaining: 75,
|
||
currentPickNumber: 2,
|
||
});
|
||
});
|
||
|
||
it("reads the picking team's timer but does not write it", async () => {
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
// findFirst called once (picking team's current value for the emit)
|
||
expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(1);
|
||
// update called once only for season.currentPickNumber — NOT for the timer
|
||
expect(mockDb.update).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe("standard mode", () => {
|
||
beforeEach(() => {
|
||
mockDb.query.seasons.findFirst.mockResolvedValue({
|
||
...mockSeason,
|
||
draftTimerMode: "standard",
|
||
});
|
||
});
|
||
|
||
it("resets the picking team's timer to draftIncrementTime", async () => {
|
||
mockDb.returning
|
||
.mockResolvedValueOnce([mockDraftPick])
|
||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
||
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||
"timer-update",
|
||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||
);
|
||
});
|
||
|
||
it("writes the DB timer (reset) and updates the season pick number", async () => {
|
||
mockDb.returning
|
||
.mockResolvedValueOnce([mockDraftPick])
|
||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
||
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
// Two updates: timer reset + season.currentPickNumber
|
||
expect(mockDb.update).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
|
||
// ── REGRESSION: Next team's timer must never be touched ──────────────────
|
||
|
||
it("REGRESSION: does not emit a timer-update for the next team", async () => {
|
||
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);
|
||
});
|
||
});
|
||
});
|