brackt/app/routes/api/__tests__/draft.force-manual-pick.test.ts
Chris Parsons 41384f08fb
Grant sitewide admins commissioner-level access in leagues (#162)
* 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>
2026-03-18 00:41:56 -07:00

448 lines
17 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(),
}));
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);
});
});
});