Closes #144 * feat: add commissioner audit log for league transparency (issue #144) Adds a complete audit log system so league members can verify that settings, draft order, picks, and time banks have not been quietly changed without their awareness. Changes: - database/schema.ts: new `audit_action` enum + `commissioner_audit_log` table (seasonId, leagueId, actorClerkId, actorDisplayName, action, affectedTeamIds[], details jsonb, createdAt) - drizzle/0075: generated migration for the new table - app/models/audit-log.ts: createAuditLogEntry, getAuditLogForSeason (paginated), logCommissionerAction (resolves display name automatically) - app/lib/audit-log-display.ts: shared formatAuditDetail() helper used by both the league home widget and the full audit log page - app/routes/leagues/$leagueId.audit-log.tsx: new read-only route at /leagues/:id/audit-log, accessible to all league members, with action-type filter and pagination - app/routes.ts: registers the new route - League home page ($leagueId.server.ts / $leagueId.tsx): "Recent Activity" summary card showing the last 5 entries with "View all" link - Settings page ($leagueId.settings.tsx): "View Full Audit Log" link card; audit log calls added for league/draft settings changes, draft order set/randomized, and draft reset - API routes: audit log calls added to draft.start, draft.pause, draft.resume, draft.rollback, draft.adjust-time-bank, draft.force-autopick, draft.force-manual-pick, draft.replace-pick - Tests: 11 new unit tests for the audit-log model; mocks added to 3 existing route test files to account for the new logCommissionerAction call https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm * fix: validate action filter URL param against known enum values The action filter on the audit log route was cast directly from the URL search param to AuditAction without validation. An invalid value would be passed into the Drizzle inArray() call, potentially throwing a PostgreSQL enum type error. Now validates against the actual enum values before using the filter. https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm * Fix lint errors: use !== instead of != and toSorted instead of sort https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm --------- Co-authored-by: Claude <noreply@anthropic.com>
435 lines
16 KiB
TypeScript
435 lines
16 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(),
|
||
}));
|
||
vi.mock("~/models/audit-log", () => ({
|
||
logCommissionerAction: 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 { 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 ─────────────────────────────────────────────────────────
|
||
//
|
||
// 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
|
||
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("writes the incremented timer to the DB", 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(mockDb.set).toHaveBeenCalledWith(
|
||
expect.objectContaining({ timeRemaining: expect.anything(), 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 () => {
|
||
mockDb.returning
|
||
.mockResolvedValueOnce([mockDraftPick])
|
||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
||
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||
"timer-update",
|
||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||
);
|
||
});
|
||
|
||
it("writes the timer reset to the DB", async () => {
|
||
mockDb.returning
|
||
.mockResolvedValueOnce([mockDraftPick])
|
||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
||
|
||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||
|
||
expect(mockDb.set).toHaveBeenCalledWith(
|
||
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
||
);
|
||
});
|
||
});
|
||
|
||
// ── REGRESSION: Next team's timer must never be touched ──────────────────
|
||
|
||
it("REGRESSION: does not emit a timer-update for the next team", async () => {
|
||
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);
|
||
});
|
||
});
|
||
});
|