2026-03-20 21:36:39 -07:00
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
|
import type { RouterContextProvider } from "react-router";
|
|
|
|
|
import { action } from "~/routes/api/draft.start";
|
|
|
|
|
|
|
|
|
|
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/commissioner", () => ({
|
|
|
|
|
isCommissioner: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
vi.mock("~/models/draft-timer", () => ({
|
|
|
|
|
deleteSeasonTimers: vi.fn(),
|
|
|
|
|
initializeDraftTimers: vi.fn(),
|
|
|
|
|
}));
|
Add audit logging for commissioner actions (#293)
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>
2026-04-13 18:45:39 -04:00
|
|
|
vi.mock("~/models/audit-log", () => ({
|
|
|
|
|
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
|
|
|
|
}));
|
2026-03-20 21:36:39 -07:00
|
|
|
|
|
|
|
|
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const SEASON_ID = "season-1";
|
|
|
|
|
const COMMISSIONER_ID = "commissioner-user-1";
|
|
|
|
|
|
|
|
|
|
const mockDraftSlots = [
|
|
|
|
|
{ id: "slot-1", seasonId: SEASON_ID, teamId: "team-1", draftOrder: 1 },
|
|
|
|
|
{ id: "slot-2", seasonId: SEASON_ID, teamId: "team-2", draftOrder: 2 },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function makeSeason(overrides: Record<string, unknown> = {}) {
|
|
|
|
|
return {
|
|
|
|
|
id: SEASON_ID,
|
|
|
|
|
leagueId: "league-1",
|
|
|
|
|
status: "pre_draft",
|
|
|
|
|
draftInitialTime: 120,
|
|
|
|
|
draftIncrementTime: 30,
|
|
|
|
|
draftTimerMode: "chess_clock",
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeRequest() {
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append("seasonId", SEASON_ID);
|
|
|
|
|
return new Request("http://localhost/api/draft/start", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: formData,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("draft.start action — timer initialization", () => {
|
|
|
|
|
let mockDb: any;
|
|
|
|
|
let mockSocketIO: any;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
|
|
|
|
|
const { getAuth } = await import("@clerk/react-router/server");
|
|
|
|
|
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
|
|
|
|
|
|
|
|
|
const { isCommissioner } = await import("~/models/commissioner");
|
|
|
|
|
vi.mocked(isCommissioner).mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
|
|
|
|
const socketModule = await import("~/server/socket");
|
|
|
|
|
vi.mocked(socketModule.getSocketIO).mockReturnValue(mockSocketIO);
|
|
|
|
|
|
|
|
|
|
mockDb = {
|
|
|
|
|
query: {
|
|
|
|
|
seasons: { findFirst: vi.fn() },
|
|
|
|
|
draftSlots: { findMany: vi.fn() },
|
|
|
|
|
},
|
|
|
|
|
update: vi.fn().mockReturnThis(),
|
|
|
|
|
set: vi.fn().mockReturnThis(),
|
|
|
|
|
where: vi.fn().mockReturnThis(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockDb.query.draftSlots.findMany.mockResolvedValue(mockDraftSlots);
|
|
|
|
|
|
|
|
|
|
const { database } = await import("~/database/context");
|
|
|
|
|
vi.mocked(database).mockReturnValue(mockDb);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("chess_clock mode", () => {
|
|
|
|
|
it("initializes each team's timer to draftInitialTime", async () => {
|
|
|
|
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
|
|
|
|
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
|
|
|
|
|
|
const { initializeDraftTimers } = await import("~/models/draft-timer");
|
|
|
|
|
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
|
|
|
|
|
SEASON_ID,
|
|
|
|
|
expect.any(Array),
|
|
|
|
|
120 // draftInitialTime, not draftIncrementTime
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses the configured draftInitialTime (not the increment)", async () => {
|
|
|
|
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
|
|
|
|
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 28800, draftIncrementTime: 3600 })
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
|
|
|
|
|
|
const { initializeDraftTimers } = await import("~/models/draft-timer");
|
|
|
|
|
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
|
|
|
|
|
SEASON_ID,
|
|
|
|
|
expect.any(Array),
|
|
|
|
|
28800 // 8 hours initial bank
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("standard mode", () => {
|
|
|
|
|
it("initializes each team's timer to draftIncrementTime (the per-pick time)", async () => {
|
|
|
|
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
|
|
|
|
makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 })
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
|
|
|
|
|
|
const { initializeDraftTimers } = await import("~/models/draft-timer");
|
|
|
|
|
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
|
|
|
|
|
SEASON_ID,
|
|
|
|
|
expect.any(Array),
|
|
|
|
|
30 // draftIncrementTime, not draftInitialTime
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("ignores draftInitialTime — uses only increment for the starting clock", async () => {
|
|
|
|
|
// Even though initialTime=600, standard mode should use increment=45
|
|
|
|
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
|
|
|
|
makeSeason({ draftTimerMode: "standard", draftInitialTime: 600, draftIncrementTime: 45 })
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
|
|
|
|
|
|
const { initializeDraftTimers } = await import("~/models/draft-timer");
|
|
|
|
|
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
|
|
|
|
|
SEASON_ID,
|
|
|
|
|
expect.any(Array),
|
|
|
|
|
45 // increment only — initial time is irrelevant in standard mode
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|