Files
sendou.ink/app/features/tournament/TournamentAuditLogRepository.server.test.ts
2026-08-13 19:05:11 +03:00

276 lines
8.0 KiB
TypeScript

import { beforeEach, describe, expect, test } from "vitest";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type { TournamentAuditLogMetadata } from "~/db/tables-json";
import { withUserId } from "~/utils/Test";
import * as TournamentAuditLogRepository from "./TournamentAuditLogRepository.server";
import * as TournamentTeamRepository from "./TournamentTeamRepository.server";
const users = UserFactory.pool();
/** Whoever performs the audited action, and whoever it is performed on. */
const actorId = () => users.id(1);
const subjectId = () => users.id(2);
const createTournament = () =>
TournamentFactory.create({ authorId: actorId() });
/** Registering a team is itself audited, so the team comes with a `TEAM_REGISTERED` event. */
const createTournamentTeam = (
tournamentId: number,
name: string,
options?: { isCheckedIn: boolean },
) =>
TournamentTeamFactory.create(
{
tournamentId,
memberUserIds: [actorId()],
team: { name, prefersNotToHost: 0, teamId: null },
},
options,
);
const deleteTeam = (tournamentTeamId: number) =>
withUserId(actorId(), () =>
TournamentTeamRepository.deleteById(tournamentTeamId),
);
const insertEvent = ({
actorUserId,
...args
}: {
type: Tables["TournamentAuditLog"]["type"];
actorUserId: number;
tournamentTeamId: number;
subjectUserId?: number;
metadata?: TournamentAuditLogMetadata;
}) =>
withUserId(actorUserId, () =>
db
.transaction()
.execute((trx) => TournamentAuditLogRepository.insert(args, trx)),
);
describe("TournamentAuditLogRepository", () => {
beforeEach(async () => {
await users.create(3);
});
test("insert creates a stable history row from the live team", async () => {
const tournament = await createTournament();
const team = await createTournamentTeam(tournament.id, "Team Olive");
const teams = await TournamentAuditLogRepository.findTeamsByTournamentId(
tournament.id,
);
expect(teams).toHaveLength(1);
expect(teams[0].tournamentTeamId).toBe(team.id);
expect(teams[0].name).toBe("Team Olive");
});
test("findByTournamentId returns events newest first with resolved relations", async () => {
const tournament = await createTournament();
await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [actorId(), subjectId()],
team: { name: "Team Olive", prefersNotToHost: 0, teamId: null },
});
const events = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
limit: 30,
offset: 0,
});
expect(events).toHaveLength(2);
// newest first
expect(events[0].type).toBe("MEMBER_ADDED");
expect(events[0].actor?.id).toBe(actorId());
expect(events[0].subject?.id).toBe(subjectId());
expect(events[0].team?.name).toBe("Team Olive");
expect(events[1].type).toBe("TEAM_REGISTERED");
});
test("team name survives the team being deleted", async () => {
const tournament = await createTournament();
const team = await createTournamentTeam(tournament.id, "Team Olive");
await deleteTeam(team.id);
const events = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
limit: 30,
offset: 0,
});
expect(events[0].type).toBe("TEAM_UNREGISTERED");
expect(events[0].team?.name).toBe("Team Olive");
});
test("a reused team id does not collapse two teams into one history", async () => {
const tournament = await createTournament();
const teamA = await createTournamentTeam(tournament.id, "Team A");
await deleteTeam(teamA.id);
const teamB = await createTournamentTeam(tournament.id, "Team B");
// SQLite reuses the highest deleted rowid for the next insert
expect(teamB.id).toBe(teamA.id);
const teams = await TournamentAuditLogRepository.findTeamsByTournamentId(
tournament.id,
);
expect(teams).toHaveLength(2);
expect(teams.map((team) => team.name).sort()).toEqual(["Team A", "Team B"]);
const events = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
limit: 30,
offset: 0,
});
const unregistered = events.find(
(event) => event.type === "TEAM_UNREGISTERED",
);
const registered = events.filter(
(event) => event.type === "TEAM_REGISTERED",
);
expect(unregistered?.team?.name).toBe("Team A");
// newest first
expect(registered.map((event) => event.team?.name)).toEqual([
"Team B",
"Team A",
]);
});
test("filters by event type and by team", async () => {
const tournament = await createTournament();
const teamA = await createTournamentTeam(tournament.id, "Team A", {
isCheckedIn: true,
});
await createTournamentTeam(tournament.id, "Team B");
const byType = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
type: "TEAM_REGISTERED",
limit: 30,
offset: 0,
});
expect(byType).toHaveLength(2);
const teams = await TournamentAuditLogRepository.findTeamsByTournamentId(
tournament.id,
);
const teamAHistoryId = teams.find(
(team) => team.tournamentTeamId === teamA.id,
)?.id;
const byTeam = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
tournamentTeamHistoryId: teamAHistoryId,
limit: 30,
offset: 0,
});
expect(byTeam).toHaveLength(2);
const count = await TournamentAuditLogRepository.countByTournamentId({
tournamentId: tournament.id,
type: "TEAM_REGISTERED",
});
expect(count).toBe(2);
});
test("paginates via limit and offset", async () => {
const tournament = await createTournament();
const team = await createTournamentTeam(tournament.id, "Team Olive");
// two more on top of the team's own TEAM_REGISTERED
for (let i = 0; i < 2; i++) {
await insertEvent({
type: "TEAM_CHECKED_IN",
actorUserId: actorId(),
tournamentTeamId: team.id,
});
}
const firstPage = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
limit: 2,
offset: 0,
});
const secondPage = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
limit: 2,
offset: 2,
});
expect(firstPage).toHaveLength(2);
expect(secondPage).toHaveLength(1);
});
test("stores and reads back metadata", async () => {
const tournament = await createTournament();
const team = await createTournamentTeam(tournament.id, "Team Olive");
await insertEvent({
type: "TEAM_CHECKED_IN",
actorUserId: actorId(),
tournamentTeamId: team.id,
metadata: { bracketIdx: 2 },
});
const events = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
limit: 30,
offset: 0,
});
expect(events[0].metadata?.bracketIdx).toBe(2);
});
test("stores and reads back the in-game name for name change events", async () => {
const tournament = await createTournament();
const team = await createTournamentTeam(tournament.id, "Team Olive");
await insertEvent({
type: "UPDATE_IN_GAME_NAME",
actorUserId: actorId(),
tournamentTeamId: team.id,
subjectUserId: subjectId(),
metadata: { inGameName: "New IGN#1234" },
});
const events = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
limit: 30,
offset: 0,
});
expect(events[0].type).toBe("UPDATE_IN_GAME_NAME");
expect(events[0].subject?.id).toBe(subjectId());
expect(events[0].metadata?.inGameName).toBe("New IGN#1234");
});
test("updateTeamHistoryName keeps the preserved name current", async () => {
const tournament = await createTournament();
const team = await createTournamentTeam(tournament.id, "Old Name");
await db.transaction().execute((trx) =>
TournamentAuditLogRepository.updateTeamHistoryName(trx, {
tournamentTeamId: team.id,
name: "New Name",
}),
);
const events = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
limit: 30,
offset: 0,
});
expect(events[0].team?.name).toBe("New Name");
});
});