Eliminates the setInterval(1s) + per-second DB write by storing picksExpiresAt in draft_timers and using a targeted setTimeout per pick. Clients count down locally from the expiresAt timestamp, removing server-pushed timer-update events. Key changes: - database/schema.ts: add picksExpiresAt and picksStartedAt to draft_timers - server/timer.ts: full rewrite — schedulePickForSeason, rescheduleTimer, 30s recovery interval instead of 1s tick, overnight-pause resume scheduling - server/socket.ts: new timer-pick-started / timer-overnight-paused events, updated draft-state-sync to include expiresAt for reconnect recovery - draft.make-pick / draft.force-manual-pick: compute actual remaining from picksExpiresAt at pick time; call rescheduleTimer after the autodraft chain - useDraftSocketEvents: handle new timer events, restore countdown on reconnect - $leagueId.draft.$seasonId: client-side countdown useEffect from expiresAt - plans/zero-downtime-scaling.md: full 4-phase scaling plan for future reference Resolves 2351 unit tests (all passing). https://claude.ai/code/session_019k5J6Ty7uP5HxSx6CsbiBK
424 lines
15 KiB
TypeScript
424 lines
15 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(),
|
||
scheduleDraftRoomClosure: vi.fn(),
|
||
}));
|
||
vi.mock("~/lib/auth.server", () => ({
|
||
auth: { api: { getSession: vi.fn() } },
|
||
}));
|
||
vi.mock("~/models/draft-pick", () => ({
|
||
getDraftPicksWithSports: vi.fn(),
|
||
getTeamDraftPicksWithSports: vi.fn(),
|
||
}));
|
||
vi.mock("~/models/season-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", () => ({
|
||
isUserAdmin: vi.fn(),
|
||
}));
|
||
vi.mock("~/models/audit-log", () => ({
|
||
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
vi.mock("~/server/timer", () => ({
|
||
rescheduleTimer: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
// ── 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 { auth } = await import("~/lib/auth.server");
|
||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
|
||
|
||
// User model: default to non-admin
|
||
const { isUserAdmin } = await import("~/models/user");
|
||
vi.mocked(isUserAdmin).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/season-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() },
|
||
seasonParticipants: { 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.seasonParticipants.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 { auth } = await import("~/lib/auth.server");
|
||
vi.mocked(auth.api.getSession).mockResolvedValue(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.seasonParticipants.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]: {
|
||
code: "no_extra_participants",
|
||
message: "Not enough NFL participants for remaining teams",
|
||
},
|
||
},
|
||
} 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(/not enough nfl participants/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 earn the increment the same as any other pick.
|
||
//
|
||
// chess_clock: bank += draftIncrementTime (atomic add).
|
||
// standard: bank resets to draftIncrementTime for the next turn.
|
||
// Next team's bank is always left completely untouched.
|
||
|
||
describe("timer behavior", () => {
|
||
describe("chess_clock mode", () => {
|
||
it("adds the increment to the picking team's bank", async () => {
|
||
// Timer has 75s (from beforeEach mock); 75 + 30 = 105s after increment
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockDb.set).toHaveBeenCalledWith(
|
||
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
|
||
);
|
||
});
|
||
|
||
it("writes the incremented timer to the DB", async () => {
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockDb.set).toHaveBeenCalledWith(
|
||
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("standard mode", () => {
|
||
beforeEach(() => {
|
||
mockDb.query.seasons.findFirst.mockResolvedValue({
|
||
...mockSeason,
|
||
draftTimerMode: "standard",
|
||
});
|
||
});
|
||
|
||
it("resets the picking team's timer to draftIncrementTime", async () => {
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockDb.set).toHaveBeenCalledWith(
|
||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||
);
|
||
});
|
||
|
||
it("writes the timer reset to the DB", async () => {
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockDb.set).toHaveBeenCalledWith(
|
||
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
|
||
);
|
||
});
|
||
});
|
||
|
||
// ── REGRESSION: rescheduleTimer should be called exactly once per pick ──────
|
||
|
||
it("REGRESSION: calls rescheduleTimer exactly once after a pick", async () => {
|
||
const { rescheduleTimer } = await import("~/server/timer");
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledTimes(1);
|
||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||
});
|
||
});
|
||
});
|