brackt/app/models/__tests__/audit-log.test.ts
Chris Parsons 442b392461
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 15:45:39 -07:00

287 lines
9.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/models/user", () => ({
findUserByClerkId: vi.fn(),
getUserDisplayName: vi.fn(),
}));
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import {
createAuditLogEntry,
getAuditLogForSeason,
logCommissionerAction,
} from "../audit-log";
import { findUserByClerkId, getUserDisplayName } from "~/models/user";
import { database } from "~/database/context";
const SEASON_ID = "season-1";
const LEAGUE_ID = "league-1";
const ACTOR_CLERK_ID = "user_clerk_1";
const SAMPLE_ENTRY = {
id: "entry-1",
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
actorDisplayName: "Alice",
action: "draft_rollback" as const,
affectedTeamIds: ["team-1"],
details: { rolledBackToPickNumber: 5, previousPickNumber: 10 },
createdAt: new Date("2025-01-01T12:00:00Z"),
};
/**
* Builds a mock db for insert operations.
*/
function makeInsertDb(returnValue: object) {
return {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([returnValue]),
}),
}),
};
}
/**
* Builds a mock db for getAuditLogForSeason which fires two select queries in
* parallel: one for the data rows and one for the count. We use two
* mockResolvedValueOnce calls so the first promise.all resolves the data and
* the second resolves the count.
*
* Both calls share the same chain: select().from().where() — after that they
* diverge (data adds .orderBy().limit().offset(), count stops). We build a
* single chainable mock where the terminal call is a jest.fn that returns
* different values on each invocation.
*/
function makeSelectDb(
dataRows: object[],
countRow: { count: number }
): object {
// Count chain: select().from().where() → resolves to [countRow]
const countChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([countRow]),
};
// Data chain: select().from().where().orderBy().limit().offset() → resolves to dataRows
const dataChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockResolvedValue(dataRows),
};
let selectCallCount = 0;
return {
select: vi.fn().mockImplementation(() => {
// First call is the data query; second call is the count query
selectCallCount++;
return selectCallCount === 1 ? dataChain : countChain;
}),
};
}
beforeEach(() => {
vi.clearAllMocks();
});
// ─── createAuditLogEntry ──────────────────────────────────────────────────────
describe("createAuditLogEntry", () => {
it("inserts the entry and returns the persisted record", async () => {
vi.mocked(database).mockReturnValue(makeInsertDb(SAMPLE_ENTRY) as never);
const result = await createAuditLogEntry({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
actorDisplayName: "Alice",
action: "draft_rollback",
affectedTeamIds: ["team-1"],
details: { rolledBackToPickNumber: 5, previousPickNumber: 10 },
});
expect(result).toEqual(SAMPLE_ENTRY);
});
});
// ─── getAuditLogForSeason ─────────────────────────────────────────────────────
describe("getAuditLogForSeason", () => {
it("returns entries and computes hasMore=false when on the last page", async () => {
vi.mocked(database).mockReturnValue(
makeSelectDb([SAMPLE_ENTRY], { count: 1 }) as never
);
const result = await getAuditLogForSeason(SEASON_ID, {
limit: 50,
offset: 0,
});
expect(result.entries).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.hasMore).toBe(false);
});
it("returns hasMore=true when more entries remain", async () => {
// 51 total, fetching first 50 starting at offset 0 → 50 returned → hasMore
const manyEntries = Array.from({ length: 50 }, (_, i) => ({
...SAMPLE_ENTRY,
id: `entry-${i}`,
}));
vi.mocked(database).mockReturnValue(
makeSelectDb(manyEntries, { count: 51 }) as never
);
const result = await getAuditLogForSeason(SEASON_ID, {
limit: 50,
offset: 0,
});
expect(result.total).toBe(51);
expect(result.hasMore).toBe(true);
});
it("returns hasMore=false on the final page", async () => {
// 51 total, on page 2 (offset=50), 1 entry returned
vi.mocked(database).mockReturnValue(
makeSelectDb([SAMPLE_ENTRY], { count: 51 }) as never
);
const result = await getAuditLogForSeason(SEASON_ID, {
limit: 50,
offset: 50,
});
expect(result.hasMore).toBe(false);
});
it("uses default limit of 50 and offset of 0 when options are omitted", async () => {
const db = makeSelectDb([SAMPLE_ENTRY], { count: 1 });
vi.mocked(database).mockReturnValue(db as never);
await getAuditLogForSeason(SEASON_ID);
// Verify the limit and offset were applied via the data chain
const dataChain = (db as { select: ReturnType<typeof vi.fn> }).select.mock.results[0].value;
expect(dataChain.limit).toHaveBeenCalledWith(50);
expect(dataChain.offset).toHaveBeenCalledWith(0);
});
it("returns an empty page when no entries exist", async () => {
vi.mocked(database).mockReturnValue(
makeSelectDb([], { count: 0 }) as never
);
const result = await getAuditLogForSeason(SEASON_ID);
expect(result.entries).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.hasMore).toBe(false);
});
});
// ─── logCommissionerAction ────────────────────────────────────────────────────
describe("logCommissionerAction", () => {
it("resolves the actor display name from the users table", async () => {
const mockUser = { username: "alice", displayName: "Alice Smith" };
vi.mocked(findUserByClerkId).mockResolvedValue(mockUser as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY);
vi.mocked(database).mockReturnValue(insertDb as never);
await logCommissionerAction({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
action: "draft_rollback",
});
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.actorDisplayName).toBe("alice");
});
it("falls back to actorClerkId if the user is not found", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue(undefined);
vi.mocked(getUserDisplayName).mockReturnValue(null);
const insertDb = makeInsertDb(SAMPLE_ENTRY);
vi.mocked(database).mockReturnValue(insertDb as never);
await logCommissionerAction({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
action: "draft_rollback",
});
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.actorDisplayName).toBe(ACTOR_CLERK_ID);
});
it("defaults affectedTeamIds to [] when not provided", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY);
vi.mocked(database).mockReturnValue(insertDb as never);
await logCommissionerAction({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
action: "draft_started",
});
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.affectedTeamIds).toEqual([]);
});
it("defaults details to {} when not provided", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY);
vi.mocked(database).mockReturnValue(insertDb as never);
await logCommissionerAction({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
action: "draft_started",
});
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.details).toEqual({});
});
it("passes through provided affectedTeamIds and details", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY);
vi.mocked(database).mockReturnValue(insertDb as never);
const details = { pickNumber: 7, teamName: "Team Bravo", participantName: "Max V" };
await logCommissionerAction({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID,
action: "force_manual_pick",
affectedTeamIds: ["team-7"],
details,
});
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.affectedTeamIds).toEqual(["team-7"]);
expect(insertValues.details).toEqual(details);
});
});