2026-03-24 17:00:32 -07:00
|
|
|
/**
|
|
|
|
|
* 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(),
|
2026-04-30 10:14:14 -07:00
|
|
|
scheduleDraftRoomClosure: vi.fn(),
|
2026-03-24 17:00:32 -07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("~/models/draft-pick", () => ({
|
|
|
|
|
getDraftPicksWithSports: vi.fn(),
|
|
|
|
|
getTeamDraftPicksWithSports: vi.fn(),
|
|
|
|
|
isParticipantDrafted: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-01 19:18:34 +00:00
|
|
|
vi.mock("~/models/season-participant", () => ({
|
2026-03-24 17:00:32 -07:00
|
|
|
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";
|
2026-05-01 06:37:53 +00:00
|
|
|
import { getParticipantsForSeasonWithSports } from "~/models/season-participant";
|
2026-03-24 17:00:32 -07:00
|
|
|
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) },
|
2026-05-01 06:37:53 +00:00
|
|
|
seasonParticipants: {
|
2026-03-24 17:00:32 -07:00
|
|
|
// 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(),
|
2026-03-24 19:09:38 -07:00
|
|
|
onConflictDoNothing: vi.fn().mockReturnThis(),
|
2026-03-24 17:00:32 -07:00
|
|
|
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("emits timer-update with bank + increment after a timer-triggered pick", async () => {
|
|
|
|
|
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
|
|
|
|
|
// Timer expired at 0; 0 + 15 = 15 after the increment
|
|
|
|
|
mockDb.returning
|
|
|
|
|
.mockResolvedValueOnce([mockDraftPick]) // insert draft pick
|
|
|
|
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]); // update timer
|
|
|
|
|
|
|
|
|
|
await executeAutoPick({
|
|
|
|
|
seasonId: SEASON_ID,
|
|
|
|
|
teamId: TEAM_ID,
|
|
|
|
|
pickNumber: 1,
|
|
|
|
|
triggeredBy: "timer",
|
|
|
|
|
autodraftSettings: null,
|
|
|
|
|
db: mockDb,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
|
|
|
"timer-update",
|
|
|
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 15 })
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
|
|
|
"timer-update",
|
|
|
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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("emits timer-update with exactly draftIncrementTime 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(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
|
|
|
"timer-update",
|
|
|
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
|
|
|
"timer-update",
|
|
|
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|