brackt/app/models/__tests__/executeAutoPick.timer.test.ts
Chris Parsons 0eb6715655
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m51s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m26s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause
- Broadcast timer-bank-updated after every pick so all connected clients
  immediately see the updated time bank (was only visible on next timer-pick-started)
- Capture pickMadeAt at route entry (before auth/DB overhead) and use Math.ceil
  so credited seconds always match the client countdown display
- Clear picksExpiresAt on every pick so _schedulePickForSeason starts fresh
- Hold schedulingInProgress lock for full timer callback to prevent the recovery
  interval from scheduling a duplicate timeout mid-pick
- Fix force-autopick route: call rescheduleTimer so the next team's clock
  starts immediately instead of waiting for the old timeout to fire naturally
- Fix draft.adjust-time-bank 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
- Add timer-pick-started / timer-overnight-paused / timer-bank-updated socket
  events with full type definitions; replace dead timer-update event
- Fix draft-state-sync to include expiresAt for the active timer and
  isOvernightPause state so reconnecting clients see accurate countdown and
  pause banner immediately
- Fix room-closure countdown: capture client-side timestamp when draft completes
  so countdown runs even before the loader revalidates with draftCompletedAt
- Run countdown interval at 500ms with Math.ceil to prevent skipped seconds
- Add draft-started socket handler to transition pre-draft UI without a refresh
- Fix overnight pause: canPick only blocks on commissioner pause, not overnight
  pause (timer freezes but player can still pick early)
- Extract checkOvernightPause to server/overnight-pause-check.ts, breaking the
  timer↔socket circular import and ensuring the timezone cache is shared and
  evicted correctly across both callers
- Fix PostgreSQL varchar=uuid type mismatch in getTeamTimezone join

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 22:51:43 -07:00

352 lines
12 KiB
TypeScript

/**
* Tests for executeAutoPick timer behavior across chess_clock and standard modes.
*
* chess_clock: auto-picks earn the increment (bank += increment) so a team that
* times out doesn't get permanently frozen at 0 on all future turns.
* standard: auto-picks reset the bank to exactly draftIncrementTime.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { executeAutoPick } from "../draft-utils";
// ── Module-level mocks ────────────────────────────────────────────────────────
vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(),
scheduleDraftRoomClosure: vi.fn(),
}));
vi.mock("~/models/draft-pick", () => ({
getDraftPicksWithSports: vi.fn(),
getTeamDraftPicksWithSports: vi.fn(),
isParticipantDrafted: vi.fn(),
}));
vi.mock("~/models/season-participant", () => ({
getParticipantsForSeasonWithSports: vi.fn(),
}));
vi.mock("~/models/season-sport", () => ({
getSeasonSportsSimple: vi.fn(),
}));
vi.mock("~/models/draft-queue", () => ({
getTeamQueue: vi.fn(),
getAllQueuesForSeason: vi.fn(),
}));
vi.mock("~/lib/draft-eligibility", () => ({
calculateDraftEligibility: vi.fn(),
}));
// database context not needed — we pass db directly to executeAutoPick
vi.mock("~/database/context");
import { getDraftPicksWithSports, getTeamDraftPicksWithSports, isParticipantDrafted } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/season-participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { getTeamQueue, getAllQueuesForSeason } from "~/models/draft-queue";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
// ── Fixtures ──────────────────────────────────────────────────────────────────
const SEASON_ID = "season-1";
const TEAM_ID = "team-1";
const NEXT_TEAM_ID = "team-2";
const PARTICIPANT_ID = "p-1";
const SPORT_ID = "sport-1";
const mockParticipantForQueue = {
id: PARTICIPANT_ID,
name: "Test Player",
sportsSeason: { sport: { id: SPORT_ID, name: "NFL" } },
};
const mockParticipantFull = {
id: PARTICIPANT_ID,
name: "Test Player",
sportsSeason: {
id: "ss-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: "",
pickedByType: "auto",
};
const mockDraftSlots = [
{
id: "slot-1",
seasonId: SEASON_ID,
teamId: TEAM_ID,
draftOrder: 1,
team: { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: "owner-1" },
},
{
id: "slot-2",
seasonId: SEASON_ID,
teamId: NEXT_TEAM_ID,
draftOrder: 2,
team: { id: NEXT_TEAM_ID, name: "Team 2", seasonId: SEASON_ID, ownerId: "owner-2" },
},
];
function makeSeason(overrides: Record<string, unknown> = {}) {
return {
id: SEASON_ID,
leagueId: "league-1",
status: "draft",
draftRounds: 3,
draftInitialTime: 120,
draftIncrementTime: 15,
draftTimerMode: "chess_clock",
currentPickNumber: 1,
draftPaused: false,
...overrides,
};
}
/**
* Build a mock DB for executeAutoPick.
*
* The `returning` mock is left unconfigured so individual tests can set up
* the sequence of values they expect (pick insert, then timer update).
*/
function makeMockDb(seasonOverrides: Record<string, unknown> = {}) {
const mockDb: any = {
query: {
// Race-condition guard — no existing pick at this slot
draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
seasons: { findFirst: vi.fn().mockResolvedValue(makeSeason(seasonOverrides)) },
draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
seasonParticipants: {
// findMany: used by autoPickForTeam queue path
findMany: vi.fn().mockResolvedValue([mockParticipantForQueue]),
// findFirst: used by executeAutoPick to fetch full participant details
findFirst: vi.fn().mockResolvedValue(mockParticipantFull),
},
draftTimers: {
// Default: timer is at 0 (expired), which is the normal auto-pick trigger state
findFirst: vi.fn().mockResolvedValue({ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 0 }),
},
// checkAndTriggerNextAutodraft reads this; returning null prevents any recursion
autodraftSettings: { findFirst: vi.fn().mockResolvedValue(null) },
},
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
onConflictDoNothing: vi.fn().mockReturnThis(),
returning: vi.fn(), // configured per-test
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
};
return mockDb;
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe("executeAutoPick — timer mode behavior", () => {
let mockSocketIO: any;
beforeEach(async () => {
vi.clearAllMocks();
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
const { getSocketIO } = await import("~/server/socket");
vi.mocked(getSocketIO).mockReturnValue(mockSocketIO);
// autoPickForTeam external dependencies — return minimal happy-path values
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
vi.mocked(calculateDraftEligibility).mockReturnValue({
eligibleSportIds: new Set([SPORT_ID]),
ineligibleReasons: {},
} as ReturnType<typeof calculateDraftEligibility>);
// Queue has one item so autoPickForTeam selects it without hitting the EV path
vi.mocked(getTeamQueue).mockResolvedValue([
{ id: "q-1", participantId: PARTICIPANT_ID, queuePosition: 1 } as any,
]);
vi.mocked(isParticipantDrafted).mockResolvedValue(false);
// pruneIneligibleQueueItems calls getAllQueuesForSeason — return empty Map to skip pruning
vi.mocked(getAllQueuesForSeason).mockResolvedValue(new Map() as any);
});
// ── chess_clock mode ────────────────────────────────────────────────────────
describe("chess_clock mode", () => {
it("writes exactly increment to DB timer when timer expired (timeRemainingAtPick = 0)", async () => {
// Default mock has timeRemaining: 0 and picksExpiresAt: null (expired timer)
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
// 0 remaining + 15 increment = 15; picksExpiresAt cleared so next turn starts fresh
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 15, picksExpiresAt: null, picksStartedAt: null })
);
});
it("writes the timer increment to the DB", async () => {
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
);
});
it("applies the configured increment, not a hard-coded default", async () => {
// Use a non-default increment of 30 to confirm the value is read from the season
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 30 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
// 0 remaining (expired) + 30 increment = 30
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
);
});
it("returns success after the pick", async () => {
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]);
const result = await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(result.success).toBe(true);
});
});
// ── standard mode ───────────────────────────────────────────────────────────
describe("standard mode", () => {
it("resets timer to exactly draftIncrementTime in DB after a timer-triggered pick", async () => {
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
);
});
it("writes the timer reset to the DB", async () => {
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(mockDb.update).toHaveBeenCalledTimes(2);
});
it("uses custom draftIncrementTime when configured", async () => {
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 90 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 90 }]);
await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 90, picksExpiresAt: null, picksStartedAt: null })
);
});
it("returns success after the pick", async () => {
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
const result = await executeAutoPick({
seasonId: SEASON_ID,
teamId: TEAM_ID,
pickNumber: 1,
triggeredBy: "timer",
autodraftSettings: null,
db: mockDb,
});
expect(result.success).toBe(true);
});
});
});