* Grant sitewide admins commissioner-level access in leagues - `isCommissioner()` now returns true for site admins, covering all commissioner-gated loaders (league home, settings, sport season detail) and draft API routes (start, pause, resume, rollback, replace-pick, force-autopick, force-manual-pick, adjust-time-bank, make-pick) - Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the "already a commissioner" duplicate-entry check in the settings action, preventing a false positive when adding a site admin as commissioner - `isCommissioner()` now runs the admin check and DB query in parallel via Promise.all to avoid a serial roundtrip on every check - Added "admin" to the `picked_by_type` enum (migration 0049) so picks forced by a site admin are recorded accurately in the audit log rather than as "commissioner" - 8 unit tests covering both isCommissioner and hasCommissionerRecord Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix draft.force-manual-pick tests broken by isUserAdminByClerkId The route now calls isUserAdminByClerkId which hits database().query.users, but the test's mock DB had no query.users entry. Add a vi.mock for ~/models/user and default isUserAdminByClerkId to false in beforeEach. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
448 lines
17 KiB
TypeScript
448 lines
17 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,
|
||
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 ─────────────────────────────────────────────────────────
|
||
//
|
||
// All three pick paths (user pick, force auto, force manual) must treat timers
|
||
// identically:
|
||
// 1. The team that was picked FOR gets +draftIncrementTime added to their bank.
|
||
// 2. The next team's bank is left completely untouched.
|
||
|
||
describe("timer behavior", () => {
|
||
it("adds draftIncrementTime to the picking team's time bank", async () => {
|
||
// Timer has 75s; increment is 30s → DB atomically returns new balance: 105s
|
||
mockDb.returning
|
||
.mockResolvedValueOnce([mockDraftPick])
|
||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
|
||
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||
"timer-update",
|
||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
||
);
|
||
});
|
||
|
||
it("emits timer-update for the picking team with their incremented balance", async () => {
|
||
mockDb.returning
|
||
.mockResolvedValueOnce([mockDraftPick])
|
||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
|
||
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", {
|
||
seasonId: SEASON_ID,
|
||
teamId: TEAM_ID,
|
||
timeRemaining: 105,
|
||
currentPickNumber: 2,
|
||
});
|
||
});
|
||
|
||
it("creates a new timer for the picking team if they have no existing timer", async () => {
|
||
// update() returns [] when no timer row exists → triggers the insert fallback
|
||
mockDb.returning
|
||
.mockResolvedValueOnce([mockDraftPick])
|
||
.mockResolvedValueOnce([]);
|
||
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
// insert should be called for: (1) draft pick, (2) new timer (increment only: 30s)
|
||
expect(mockDb.insert).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("increment is additive, not a flat reset — fast pickers accumulate time", async () => {
|
||
// Team had 100s remaining; increment is 30s → DB atomically returns 130s
|
||
mockDb.returning
|
||
.mockResolvedValueOnce([mockDraftPick])
|
||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 130 }]);
|
||
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
// Verify 130s (100 + 30), not 120s (which would be a flat reset to initialTime)
|
||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||
"timer-update",
|
||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
|
||
);
|
||
});
|
||
|
||
// ── REGRESSION TESTS ────────────────────────────────────────────────────
|
||
//
|
||
// Bug: force-manual-pick was resetting the next team's timer to
|
||
// draftInitialTime (120s) instead of carrying their bank forward.
|
||
// The correct behaviour (matching make-pick and force-autopick) is to
|
||
// leave the next team's timer completely untouched.
|
||
|
||
it("REGRESSION: does not look up the next team's timer", async () => {
|
||
// The atomic SQL increment (timeRemaining + N) requires no prior read —
|
||
// draftTimers.findFirst is never called.
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(0);
|
||
});
|
||
|
||
it("REGRESSION: does not emit a timer-update for the next team", async () => {
|
||
// Before the fix, a timer-update was emitted for the next team with
|
||
// timeRemaining: draftInitialTime (120s), overwriting their actual bank.
|
||
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);
|
||
});
|
||
|
||
it("REGRESSION: does not overwrite the next team's existing time bank", async () => {
|
||
// Verify that db.update is called exactly twice (timer increment + season
|
||
// pick number), not three times (which would include resetting the next
|
||
// team's timer).
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockDb.update).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
});
|