brackt/app/routes/api/__tests__/draft.force-manual-pick.test.ts
chrisp f96c8f5244
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m40s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m26s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Fix draft timer: broadcasts, increments, reconnect sync, overnight pause (#72)
## Summary

- **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started`
- **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display
- **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick
- **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire
- **adjust-time-bank fix**: for on-clock teams, shift `picksExpiresAt` by the adjustment and reschedule so the client countdown updates; block adjustments that would reduce the bank to zero
- **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event
- **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload
- **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt`
- **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure
- **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early
- **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction
- **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join

## Test plan

- [ ] Manual pick: all clients see bank increment immediately after pick
- [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s
- [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log
- [ ] Force-manual-pick: all clients see bank increment
- [ ] Pause while clock running: countdown freezes on all clients
- [ ] Resume: clock continues from frozen value
- [ ] adjust-time-bank on on-clock team: countdown shifts immediately
- [ ] adjust-time-bank to zero: returns 400 error
- [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team
- [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately
- [ ] Draft complete: "Room closes in X" counts down
- [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen
- [ ] `npm run test:run` — all 158 files / 2351 tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #72
2026-06-06 05:57:46 +00:00

424 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 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);
});
});
});