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>
358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
/**
|
|
* Tests for draft.force-manual-pick timer behavior across chess_clock and standard modes.
|
|
*
|
|
* chess_clock: force picks earn the increment just like any other pick (bank += increment)
|
|
* standard: force picks always reset the bank to exactly draftIncrementTime
|
|
*/
|
|
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 ADMIN_ID = "admin-user-1";
|
|
const SPORT_ID = "sport-nfl";
|
|
|
|
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",
|
|
};
|
|
|
|
const mockDraftSlots = [
|
|
{
|
|
id: "slot-1",
|
|
seasonId: SEASON_ID,
|
|
teamId: TEAM_ID,
|
|
draftOrder: 1,
|
|
team: { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: "owner-1" },
|
|
},
|
|
{
|
|
id: "slot-2",
|
|
seasonId: SEASON_ID,
|
|
teamId: NEXT_TEAM_ID,
|
|
draftOrder: 2,
|
|
team: { id: NEXT_TEAM_ID, name: "Team 2", seasonId: SEASON_ID, ownerId: "owner-2" },
|
|
},
|
|
];
|
|
|
|
function makeSeason(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: SEASON_ID,
|
|
leagueId: "league-1",
|
|
status: "draft",
|
|
draftRounds: 3,
|
|
draftInitialTime: 120,
|
|
draftIncrementTime: 30,
|
|
draftTimerMode: "chess_clock",
|
|
currentPickNumber: 1,
|
|
draftPaused: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRequest() {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", SEASON_ID);
|
|
formData.append("teamId", TEAM_ID);
|
|
formData.append("participantId", PARTICIPANT_ID);
|
|
formData.append("pickNumber", "1");
|
|
return new Request("http://localhost/api/draft/force-manual-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────────────
|
|
|
|
describe("draft.force-manual-pick action — timer mode behavior", () => {
|
|
let mockDb: any;
|
|
let mockSocketIO: any;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
|
|
// Default: authenticated as commissioner
|
|
const { getAuth } = await import("@clerk/react-router/server");
|
|
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
|
|
|
const { isUserAdminByClerkId } = await import("~/models/user");
|
|
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
|
|
|
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
|
const socketModule = await import("~/server/socket");
|
|
vi.mocked(socketModule.getSocketIO).mockReturnValue(mockSocketIO);
|
|
|
|
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);
|
|
|
|
mockDb = {
|
|
query: {
|
|
seasons: { findFirst: vi.fn() },
|
|
commissioners: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID }),
|
|
},
|
|
draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
participants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) },
|
|
draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
|
|
draftTimers: {
|
|
findFirst: vi.fn().mockResolvedValue({ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 75 }),
|
|
},
|
|
},
|
|
insert: vi.fn().mockReturnThis(),
|
|
values: vi.fn().mockReturnThis(),
|
|
returning: vi.fn().mockResolvedValue([mockDraftPick]),
|
|
update: vi.fn().mockReturnThis(),
|
|
set: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
delete: vi.fn().mockReturnThis(),
|
|
};
|
|
|
|
const { database } = await import("~/database/context");
|
|
vi.mocked(database).mockReturnValue(mockDb);
|
|
});
|
|
|
|
// ── chess_clock mode ────────────────────────────────────────────────────────
|
|
|
|
describe("chess_clock mode", () => {
|
|
beforeEach(() => {
|
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
|
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
|
|
);
|
|
});
|
|
|
|
describe("commissioner force pick", () => {
|
|
it("emits timer-update with bank + increment", async () => {
|
|
// Pre-pick bank: 75s. Expected after increment: 75 + 30 = 105s.
|
|
mockDb.returning
|
|
.mockResolvedValueOnce([mockDraftPick])
|
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
"timer-update",
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
|
);
|
|
});
|
|
|
|
it("writes the timer update to the DB", async () => {
|
|
mockDb.returning
|
|
.mockResolvedValueOnce([mockDraftPick])
|
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
// Two DB updates: timer row + season.currentPickNumber
|
|
expect(mockDb.set).toHaveBeenCalledWith(
|
|
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
|
);
|
|
});
|
|
|
|
it("accumulates a larger bank when more time was remaining", async () => {
|
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
|
// 100 + 30 = 130
|
|
mockDb.returning
|
|
.mockResolvedValueOnce([mockDraftPick])
|
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 130 }]);
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
"timer-update",
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("admin force pick", () => {
|
|
beforeEach(async () => {
|
|
const { getAuth } = await import("@clerk/react-router/server");
|
|
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
|
|
|
const { isUserAdminByClerkId } = await import("~/models/user");
|
|
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
|
|
|
// Admin is not a commissioner
|
|
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
|
});
|
|
|
|
it("emits timer-update with bank + increment", async () => {
|
|
mockDb.returning
|
|
.mockResolvedValueOnce([mockDraftPick])
|
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
"timer-update",
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
|
);
|
|
});
|
|
|
|
it("writes the timer update to the DB", async () => {
|
|
mockDb.returning
|
|
.mockResolvedValueOnce([mockDraftPick])
|
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
expect(mockDb.set).toHaveBeenCalledWith(
|
|
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── standard mode ───────────────────────────────────────────────────────────
|
|
|
|
describe("standard mode", () => {
|
|
beforeEach(() => {
|
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
|
makeSeason({ draftTimerMode: "standard", draftInitialTime: 30, draftIncrementTime: 30 })
|
|
);
|
|
});
|
|
|
|
describe("commissioner force pick", () => {
|
|
it("resets bank to exactly draftIncrementTime", async () => {
|
|
// Pre-pick bank doesn't matter — standard always resets
|
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 5 });
|
|
mockDb.returning
|
|
.mockResolvedValueOnce([mockDraftPick])
|
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
"timer-update",
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
);
|
|
});
|
|
|
|
it("does not accumulate time even when bank was large", async () => {
|
|
// Team had 25s left; standard mode resets to increment, never adds to prior balance
|
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
|
|
mockDb.returning
|
|
.mockResolvedValueOnce([mockDraftPick])
|
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
"timer-update",
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("admin force pick", () => {
|
|
beforeEach(async () => {
|
|
const { getAuth } = await import("@clerk/react-router/server");
|
|
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
|
|
|
const { isUserAdminByClerkId } = await import("~/models/user");
|
|
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
|
|
|
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
|
});
|
|
|
|
it("resets bank to exactly draftIncrementTime", async () => {
|
|
mockDb.returning
|
|
.mockResolvedValueOnce([mockDraftPick])
|
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
"timer-update",
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
);
|
|
});
|
|
|
|
it("uses custom draftIncrementTime when configured", async () => {
|
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
|
makeSeason({ draftTimerMode: "standard", draftInitialTime: 60, draftIncrementTime: 60 })
|
|
);
|
|
mockDb.returning
|
|
.mockResolvedValueOnce([mockDraftPick])
|
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 60 }]);
|
|
|
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
|
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
"timer-update",
|
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 60 })
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|