Tournament factories

This commit is contained in:
Kalle
2026-07-27 19:14:05 +03:00
parent e762913950
commit 86c9aeb28a
16 changed files with 299 additions and 459 deletions

View File

@@ -1,23 +1,8 @@
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeWithStage } from "~/modules/in-game-lists/types";
import { faker } from "./faker";
/** A random mode, turf war included. */
export function mode() {
return faker.helpers.arrayElement(modesShort);
}
/** A random ranked mode. */
export function rankedMode() {
return faker.helpers.arrayElement(rankedModesShort);
}
/** A random stage. */
export function stageId() {
return faker.helpers.arrayElement(stageIds);
}
/**
* A map list of `count` maps, rotating through the ranked modes and never repeating
* a stage, the way a real one looks. Callers add whatever `source` their domain uses.

16
app/db/seed/core/actAs.ts Normal file
View File

@@ -0,0 +1,16 @@
import {
type AuthenticatedUser,
userAsyncLocalStorage,
} from "~/features/auth/core/user-context.server";
/**
* Runs `fn` inside the acting-user store, so that repository functions resolving
* the actor via `actorId()` see `userId` as the acting user. Needed because seeding
* happens outside a request, where there is no acting user at all.
*/
export function actAs<T>(userId: number, fn: () => T): T {
return userAsyncLocalStorage.run(
{ user: { id: userId } as AuthenticatedUser },
fn,
);
}

View File

@@ -18,7 +18,7 @@ type Options = {
* creates with the group; the members named by `additionalMemberUserIds` join it
* the way they do in production. Invite and chat codes are the repository's own.
*/
export const { create, createMany } = defineFactory({
export const { create } = defineFactory({
defaults: () => ({
status: "ACTIVE" as const,
additionalMemberUserIds: [],

View File

@@ -12,7 +12,7 @@ type Options = {
/** Creates SendouQ matches. Both groups have to be full, as they are when the
* matchmaking UI creates a match. */
export const { create, createMany } = defineFactory({
export const { create } = defineFactory({
defaults: () => ({
mapList: SplatoonFaker.mapList(SENDOUQ_BEST_OF).map((map) => ({
...map,

View File

@@ -0,0 +1,69 @@
import type { TournamentSettings } from "~/db/tables-json";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { databaseTimestampNow } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
const SINGLE_ELIMINATION: TournamentSettings["bracketProgression"] = [
{
name: "Bracket",
type: "single_elimination",
requiresCheckIn: false,
settings: {
thirdPlaceMatch: false,
},
},
];
/** The wrapping calendar event is not the caller's to choose, so it is not an argument. */
type InsertArgs = Omit<
Parameters<typeof CalendarRepository.insert>[0],
"isFullTournament"
>;
type Options = {
/** Mark the tournament finished without recording any results. For cases that
* only need the flag; a tournament with real results is finalized by
* `TournamentRepository.finalize` with a summary. */
isFinalized: boolean;
};
/**
* Creates tournaments. Aggregate factory: the `CalendarEvent` wrapping the
* tournament and its start date are created with it, because there is no such thing
* as a tournament without one. Returns both ids.
*
* The bracket is a single elimination one unless `bracketProgression` says otherwise.
*/
export const { create } = defineFactory({
defaults: () => ({
name: faker.company.name(),
description: null,
discordInviteCode: null,
bracketUrl: faker.internet.url(),
organizationId: null,
tags: null,
badges: [],
rules: null,
startTimes: [databaseTimestampNow()],
mapPickingStyle: "TO" as const,
bracketProgression: SINGLE_ELIMINATION,
}),
insert: async (args: InsertArgs) => {
const { eventId, tournamentId } = await CalendarRepository.insert({
...args,
isFullTournament: true,
});
invariant(tournamentId, "Expected the tournament to be created");
return { id: tournamentId, eventId };
},
applyOptions: async (tournament, { isFinalized }: Options) => {
if (!isFinalized) return;
await TournamentRepository.finalizeWithoutSummary(tournament.id);
},
});

View File

@@ -0,0 +1,42 @@
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { actAs } from "../core/actAs";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
type InsertArgs = Parameters<typeof TournamentTeamRepository.insert>[0];
type Options = {
/** Has the team checked in to the tournament? */
isCheckedIn: boolean;
};
/**
* Creates tournament teams. `userId` is the owner, on whose behalf the team is
* registered; the members named by `additionalMemberUserIds` are added to it the
* way they are in production. Invite code and in-game names are the repository's own.
*/
export const { create } = defineFactory({
defaults: () => ({
team: {
name: faker.company.name(),
prefersNotToHost: 0 as const,
teamId: null,
},
additionalMemberUserIds: [],
avatarImgId: null,
}),
insert: async ({ userId, ...args }: InsertArgs) => {
const team = await actAs(userId, () =>
TournamentTeamRepository.insert({ ...args, userId }),
);
return { id: team.id, ownerUserId: userId };
},
applyOptions: async (team, { isCheckedIn }: Options) => {
if (!isCheckedIn) return;
await actAs(team.ownerUserId, () =>
TournamentTeamRepository.checkIn(team.id),
);
},
});

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import * as Seasons from "~/features/mmr/core/Seasons";
@@ -43,19 +44,16 @@ const createGroupMatch = async (createdAt: number) => {
};
const createTournamentMatch = async ({
authorId,
isFinalized,
}: {
authorId: number;
isFinalized: boolean;
}) => {
const tournament = await db
.insertInto("Tournament")
.values({
mapPickingStyle: "TO",
settings: JSON.stringify({ bracketProgression: [] }),
isFinalized: isFinalized ? 1 : 0,
})
.returning("id")
.executeTakeFirstOrThrow();
const tournament = await TournamentFactory.create(
{ authorId },
{ isFinalized },
);
const stage = await db
.insertInto("TournamentStage")
@@ -131,6 +129,7 @@ const reportTournamentWeapons = async (args: {
createdAt?: number;
}) => {
const match = await createTournamentMatch({
authorId: args.userId,
isFinalized: args.isFinalized ?? true,
});

View File

@@ -1,4 +1,6 @@
import { afterEach, 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 { dbReset, withUserId } from "~/utils/Test";
@@ -7,18 +9,21 @@ import * as TournamentLFGRepository from "./TournamentLFGRepository.server";
const users = UserFactory.pool();
const createTournament = () =>
db
.insertInto("Tournament")
.values({
mapPickingStyle: "TO",
settings: JSON.stringify({ bracketProgression: [] }),
})
.returning("id")
.executeTakeFirstOrThrow();
TournamentFactory.create({ authorId: users.id(1) });
const createPlaceholder = (tournamentId: number, userId: number) =>
TournamentLFGRepository.insertPlaceholderTeam({ tournamentId, userId });
const createRegisteredTeam = (
tournamentId: number,
[owner, ...members]: number[],
) =>
TournamentTeamFactory.create({
tournamentId,
userId: owner,
additionalMemberUserIds: members,
});
describe("insertPlaceholderTeam", () => {
beforeEach(async () => {
await users.create(2);
@@ -93,7 +98,7 @@ describe("findLookingTeamsByTournamentId", () => {
);
const members = groups[0].members;
expect(members[0].id).toBe(1);
expect(members[0].id).toBe(users.id(1));
expect(members[0].username).toBeDefined();
expect(members[0].role).toBe("OWNER");
});
@@ -228,36 +233,6 @@ describe("startLooking", () => {
await dbReset();
});
const createRegisteredTeam = async (
tournamentId: number,
memberUserIds: number[],
) => {
const team = await db
.insertInto("TournamentTeam")
.values({
tournamentId,
name: "Real Team",
inviteCode: `inv-${tournamentId}-${memberUserIds.join("-")}`,
isLooking: 0,
isPlaceholder: 0,
})
.returning("id")
.executeTakeFirstOrThrow();
for (const [idx, userId] of memberUserIds.entries()) {
await db
.insertInto("TournamentTeamMember")
.values({
tournamentTeamId: team.id,
userId,
role: idx === 0 ? "OWNER" : "REGULAR",
})
.execute();
}
return team;
};
test("generates chatCode for a 2+ member team", async () => {
const tournament = await createTournament();
const team = await createRegisteredTeam(tournament.id, [
@@ -546,16 +521,12 @@ describe("updateMemberRole", () => {
test("changes role from REGULAR to MANAGER", async () => {
const tournament = await createTournament();
const team = await createPlaceholder(tournament.id, users.id(1));
await db
.insertInto("TournamentTeamMember")
.values({
tournamentTeamId: team.id,
userId: users.id(2),
role: "REGULAR",
})
.execute();
const team = await TournamentTeamFactory.create({
tournamentId: tournament.id,
userId: users.id(1),
additionalMemberUserIds: [users.id(2)],
});
await TournamentLFGRepository.startLooking(team.id);
await TournamentLFGRepository.updateMemberRole({
userId: users.id(2),
@@ -650,26 +621,8 @@ describe("leaveLfg", () => {
test("sets isLooking=0 for non-placeholder team", async () => {
const tournament = await createTournament();
const team = await db
.insertInto("TournamentTeam")
.values({
tournamentId: tournament.id,
name: "Real Team",
inviteCode: "abc",
isLooking: 1,
isPlaceholder: 0,
})
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("TournamentTeamMember")
.values({
tournamentTeamId: team.id,
userId: users.id(1),
role: "OWNER",
})
.execute();
const team = await createRegisteredTeam(tournament.id, [users.id(1)]);
await TournamentLFGRepository.startLooking(team.id);
await TournamentLFGRepository.leaveLfg({
userId: users.id(1),

View File

@@ -1,4 +1,6 @@
import { afterEach, 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 { dbReset } from "~/utils/Test";
@@ -8,25 +10,16 @@ let teamOneMember: { id: number };
let teamTwoMember: { id: number };
const createTournament = () =>
db
.insertInto("Tournament")
.values({
mapPickingStyle: "TO",
settings: JSON.stringify({ bracketProgression: [] }),
})
.returning("id")
.executeTakeFirstOrThrow();
TournamentFactory.create({ authorId: teamOneMember.id });
/** Both users are on both teams, so that either team's results can be attributed. */
const createTeam = (tournamentId: number, name: string) =>
db
.insertInto("TournamentTeam")
.values({
tournamentId,
name,
inviteCode: `inv-${tournamentId}-${name}`,
})
.returning("id")
.executeTakeFirstOrThrow();
TournamentTeamFactory.create({
tournamentId,
userId: teamOneMember.id,
additionalMemberUserIds: [teamTwoMember.id],
team: { name, prefersNotToHost: 0, teamId: null },
});
const createStage = (tournamentId: number, name: string, number: number) =>
db
@@ -140,18 +133,6 @@ describe("findByTournamentTeamId", () => {
const teamA = await createTeam(tournament.id, "A");
const teamB = await createTeam(tournament.id, "B");
// Insert team members so we have someone to attribute results to
for (const id of [teamOneMember.id, teamTwoMember.id]) {
await db
.insertInto("TournamentTeamMember")
.values({ tournamentTeamId: teamA.id, userId: id, role: "OWNER" })
.execute();
await db
.insertInto("TournamentTeamMember")
.values({ tournamentTeamId: teamB.id, userId: id, role: "OWNER" })
.execute();
}
const stage1 = await createStage(tournament.id, "Stage 1", 1);
const stage1Group = await createGroup(stage1.id, 8);
const stage1Round = await createRound(stage1.id, stage1Group.id, 1);

View File

@@ -7,14 +7,12 @@ vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
}));
import type { z } from "zod";
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 { action as removeMemberApiAction } from "~/features/api-public/routes/tournament.$id.teams.$teamId.remove-member";
import {
dbInsertTournament,
dbInsertTournamentTeam,
dbStartTournament,
} from "~/features/tournament/tournament-test-utils";
import { dbStartTournament } from "~/features/tournament/tournament-test-utils";
import type { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import type { SerializeFrom } from "~/utils/remix";
import {
@@ -85,19 +83,26 @@ const removeMemberAction = ({
{ user: "admin", params: { id: "1", teamId: String(teamId) } },
);
const createTeam = (
tournamentId: number,
[owner, ...members]: number[],
): Promise<{ id: number }> =>
TournamentTeamFactory.create(
{
tournamentId,
userId: owner,
additionalMemberUserIds: members,
},
{ isCheckedIn: true },
);
describe("Tournament match page", () => {
beforeEach(async () => {
await UserFactory.createMany(10);
await dbInsertTournament();
await dbInsertTournamentTeam({
membersCount: 6,
ownerId: 1,
});
await dbInsertTournamentTeam({
membersCount: 4,
ownerId: 7,
});
await dbStartTournament([1, 2]);
const tournament = await TournamentFactory.create({ authorId: 1 });
const teamOne = await createTeam(tournament.id, [1, 2, 3, 4, 5, 6]);
const teamTwo = await createTeam(tournament.id, [7, 8, 9, 10]);
await dbStartTournament([teamOne.id, teamTwo.id], tournament.id);
});
afterEach(async () => {
@@ -219,26 +224,18 @@ describe("Tournament match page", () => {
});
it("should not require setting active roster if both teams have no subs", async () => {
await dbInsertTournament();
await dbInsertTournamentTeam({
membersCount: 4,
ownerId: 1,
tournamentId: 2,
});
await dbInsertTournamentTeam({
membersCount: 4,
ownerId: 5,
tournamentId: 2,
});
await dbStartTournament([3, 4], 2);
const tournament = await TournamentFactory.create({ authorId: 1 });
const teamOne = await createTeam(tournament.id, [1, 2, 3, 4]);
const teamTwo = await createTeam(tournament.id, [5, 6, 7, 8]);
await dbStartTournament([teamOne.id, teamTwo.id], tournament.id);
const res = await reportScoreAction({
position: 0,
params: {
id: "2",
id: String(tournament.id),
mid: "2",
},
winnerTeamId: 3,
winnerTeamId: teamOne.id,
});
expect(res).toBe(null);

View File

@@ -1,6 +1,8 @@
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import { db } from "~/db/sql";
import invariant from "../../utils/invariant";
import { dbInsertTournament } from "../tournament/tournament-test-utils";
import { withUserId } from "~/utils/Test";
import * as TournamentTeamRepository from "../tournament/TournamentTeamRepository.server";
/**
* Seeds a played tournament hosted by `organizationId`, starting at `startTime`
@@ -25,55 +27,39 @@ export async function seedOrgEventWithParticipants({
participantUserIds: number[];
checkIn?: "in" | "out" | "none";
}) {
const { tournamentId } = await dbInsertTournament({
const [ownerUserId, ...memberUserIds] = participantUserIds;
const tournament = await TournamentFactory.create({
authorId: ownerUserId,
organizationId,
startTime,
startTimes: [startTime],
});
invariant(tournamentId, "Expected tournamentId to be defined");
const team = await TournamentTeamFactory.create(
{
tournamentId: tournament.id,
userId: ownerUserId,
additionalMemberUserIds: memberUserIds,
},
{ isCheckedIn: checkIn === "in" },
);
const event = await db
.insertInto("CalendarEvent")
.values({
authorId: participantUserIds[0],
name: `Event ${tournamentId}`,
bracketUrl: "https://example.com/bracket",
organizationId,
tournamentId,
})
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("CalendarEventDate")
.values({ eventId: event.id, startsAt: startTime })
.execute();
const team = await db
.insertInto("TournamentTeam")
.values({
tournamentId,
name: `Team ${tournamentId}`,
inviteCode: `inv-${tournamentId}`,
})
.returning("id")
.executeTakeFirstOrThrow();
if (checkIn !== "none") {
await db
.insertInto("TournamentTeamCheckIn")
.values({
if (checkIn === "out") {
// a check out leaves a row of its own only when it concerns one bracket,
// otherwise checking out simply undoes the check in
await withUserId(ownerUserId, async () => {
await TournamentTeamRepository.checkIn(team.id, { bracketIdx: 0 });
await TournamentTeamRepository.checkOut({
tournamentTeamId: team.id,
checkedInAt: startTime,
isCheckOut: checkIn === "out" ? 1 : 0,
})
.execute();
bracketIdx: 0,
});
});
}
const stage = await db
.insertInto("TournamentStage")
.values({
tournamentId,
tournamentId: tournament.id,
name: "Stage",
number: 1,
type: "single_elimination",
@@ -118,7 +104,7 @@ export async function seedOrgEventWithParticipants({
matchId: match.id,
mode: "SZ",
number: 1,
reporterId: participantUserIds[0],
reporterId: ownerUserId,
source: "TO",
stageId: 1,
winnerTeamId: team.id,
@@ -137,5 +123,5 @@ export async function seedOrgEventWithParticipants({
)
.execute();
return { tournamentId, teamId: team.id };
return { tournamentId: tournament.id, teamId: team.id };
}

View File

@@ -1,34 +1,38 @@
import { afterEach, 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 { dbReset, withUserId } from "~/utils/Test";
import * as TournamentAuditLogRepository from "./TournamentAuditLogRepository.server";
import * as TournamentTeamRepository from "./TournamentTeamRepository.server";
let actor: { id: number };
let subject: { id: number };
const createTournament = () =>
db
.insertInto("Tournament")
.values({
mapPickingStyle: "TO",
settings: JSON.stringify({ bracketProgression: [] }),
})
.returning("id")
.executeTakeFirstOrThrow();
const createTournament = () => TournamentFactory.create({ authorId: actor.id });
const createTeam = (tournamentId: number, name: string) =>
db
.insertInto("TournamentTeam")
.values({
/** Registering a team is itself audited, so the team comes with a `TEAM_REGISTERED` event. */
const createTeam = (
tournamentId: number,
name: string,
options?: { isCheckedIn: boolean },
) =>
TournamentTeamFactory.create(
{
tournamentId,
name,
inviteCode: `inv-${tournamentId}-${name}`,
})
.returning("id")
.executeTakeFirstOrThrow();
userId: actor.id,
team: { name, prefersNotToHost: 0, teamId: null },
},
options,
);
const deleteTeam = (tournamentTeamId: number) =>
withUserId(actor.id, () =>
TournamentTeamRepository.deleteById(tournamentTeamId),
);
const insertEvent = ({
actorUserId,
@@ -59,12 +63,6 @@ describe("TournamentAuditLogRepository", () => {
const tournament = await createTournament();
const team = await createTeam(tournament.id, "Team Olive");
await insertEvent({
type: "TEAM_REGISTERED",
actorUserId: actor.id,
tournamentTeamId: team.id,
});
const teams = await TournamentAuditLogRepository.findTeamsByTournamentId(
tournament.id,
);
@@ -76,19 +74,11 @@ describe("TournamentAuditLogRepository", () => {
test("findByTournamentId returns events newest first with resolved relations", async () => {
const tournament = await createTournament();
const team = await createTeam(tournament.id, "Team Olive");
await insertEvent({
type: "TEAM_REGISTERED",
actorUserId: actor.id,
tournamentTeamId: team.id,
subjectUserId: actor.id,
});
await insertEvent({
type: "MEMBER_ADDED",
actorUserId: actor.id,
tournamentTeamId: team.id,
subjectUserId: subject.id,
await TournamentTeamFactory.create({
tournamentId: tournament.id,
userId: actor.id,
additionalMemberUserIds: [subject.id],
team: { name: "Team Olive", prefersNotToHost: 0, teamId: null },
});
const events = await TournamentAuditLogRepository.findByTournamentId({
@@ -110,16 +100,7 @@ describe("TournamentAuditLogRepository", () => {
const tournament = await createTournament();
const team = await createTeam(tournament.id, "Team Olive");
await insertEvent({
type: "TEAM_UNREGISTERED",
actorUserId: actor.id,
tournamentTeamId: team.id,
});
await db
.deleteFrom("TournamentTeam")
.where("TournamentTeam.id", "=", team.id)
.execute();
await deleteTeam(team.id);
const events = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
@@ -127,7 +108,7 @@ describe("TournamentAuditLogRepository", () => {
offset: 0,
});
expect(events).toHaveLength(1);
expect(events[0].type).toBe("TEAM_UNREGISTERED");
expect(events[0].team?.name).toBe("Team Olive");
});
@@ -135,27 +116,12 @@ describe("TournamentAuditLogRepository", () => {
const tournament = await createTournament();
const teamA = await createTeam(tournament.id, "Team A");
await insertEvent({
type: "TEAM_UNREGISTERED",
actorUserId: actor.id,
tournamentTeamId: teamA.id,
});
await db
.deleteFrom("TournamentTeam")
.where("TournamentTeam.id", "=", teamA.id)
.execute();
await deleteTeam(teamA.id);
const teamB = await createTeam(tournament.id, "Team B");
// SQLite reuses the highest deleted rowid for the next insert
expect(teamB.id).toBe(teamA.id);
await insertEvent({
type: "TEAM_REGISTERED",
actorUserId: actor.id,
tournamentTeamId: teamB.id,
});
const teams = await TournamentAuditLogRepository.findTeamsByTournamentId(
tournament.id,
);
@@ -167,31 +133,26 @@ describe("TournamentAuditLogRepository", () => {
limit: 30,
offset: 0,
});
const eventByType = new Map(events.map((event) => [event.type, event]));
expect(eventByType.get("TEAM_UNREGISTERED")?.team?.name).toBe("Team A");
expect(eventByType.get("TEAM_REGISTERED")?.team?.name).toBe("Team B");
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 createTeam(tournament.id, "Team A");
const teamB = await createTeam(tournament.id, "Team B");
await insertEvent({
type: "TEAM_REGISTERED",
actorUserId: actor.id,
tournamentTeamId: teamA.id,
});
await insertEvent({
type: "TEAM_CHECKED_IN",
actorUserId: actor.id,
tournamentTeamId: teamA.id,
});
await insertEvent({
type: "TEAM_REGISTERED",
actorUserId: actor.id,
tournamentTeamId: teamB.id,
const teamA = await createTeam(tournament.id, "Team A", {
isCheckedIn: true,
});
await createTeam(tournament.id, "Team B");
const byType = await TournamentAuditLogRepository.findByTournamentId({
tournamentId: tournament.id,
@@ -227,7 +188,8 @@ describe("TournamentAuditLogRepository", () => {
const tournament = await createTournament();
const team = await createTeam(tournament.id, "Team Olive");
for (let i = 0; i < 3; i++) {
// 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: actor.id,
@@ -297,12 +259,6 @@ describe("TournamentAuditLogRepository", () => {
const tournament = await createTournament();
const team = await createTeam(tournament.id, "Old Name");
await insertEvent({
type: "TEAM_REGISTERED",
actorUserId: actor.id,
tournamentTeamId: team.id,
});
await db.transaction().execute((trx) =>
TournamentAuditLogRepository.updateTeamHistoryName(trx, {
tournamentTeamId: team.id,

View File

@@ -1,4 +1,6 @@
import { afterEach, 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 { SkillTeamIdentifier } from "~/features/mmr/mmr-utils";
@@ -9,25 +11,7 @@ import * as TournamentRepository from "./TournamentRepository.server";
let player: { id: number };
const createTournament = () =>
db
.insertInto("Tournament")
.values({
mapPickingStyle: "TO",
settings: JSON.stringify({ bracketProgression: [] }),
})
.returning("id")
.executeTakeFirstOrThrow();
const createTeam = (tournamentId: number) =>
db
.insertInto("TournamentTeam")
.values({
tournamentId,
name: "team",
inviteCode: `inv-${tournamentId}`,
})
.returning("id")
.executeTakeFirstOrThrow();
TournamentFactory.create({ authorId: player.id });
const insertPriorSkill = (args: {
userId: number;
@@ -150,7 +134,10 @@ describe("TournamentRepository.finalize", () => {
await insertPriorSkill({ userId: player.id, season: 0, matchesCount: 100 });
const { id: tournamentId } = await createTournament();
const { id: tournamentTeamId } = await createTeam(tournamentId);
const { id: tournamentTeamId } = await TournamentTeamFactory.create({
tournamentId,
userId: player.id,
});
await TournamentRepository.finalize({
tournamentId,

View File

@@ -1,98 +1,7 @@
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { databaseTimestampNow } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { withUserId } from "~/utils/Test";
import * as BracketRepository from "../tournament-bracket/BracketRepository.server";
import * as Engine from "../tournament-bracket/core/engine";
import { tournamentFromDB } from "../tournament-bracket/core/Tournament.server";
import * as TournamentTeamRepository from "./TournamentTeamRepository.server";
/**
* Creates a mock tournament with one single elimination bracket.
*
* @returns The created event and tournament ids.
*/
export async function dbInsertTournament({
organizationId = null,
startTime = null,
}: {
/** Organization hosting the tournament. Defaults to no organization. */
organizationId?: number | null;
/** Event start time as a database timestamp (seconds). Defaults to now. */
startTime?: number | null;
} = {}) {
return CalendarRepository.insert({
isFullTournament: true,
authorId: 1,
badges: [],
bracketUrl: "https://example.com/bracket",
description: null,
discordInviteCode: "test-discord",
name: "Test Tournament",
organizationId,
rules: null,
startTimes: [startTime ?? databaseTimestampNow()],
tags: null,
bracketProgression: [
{
name: "Bracket",
type: "single_elimination",
requiresCheckIn: false,
settings: {
thirdPlaceMatch: false,
},
},
],
mapPickingStyle: "TO",
mapPoolMaps: ([1, 2, 3, 4, 5] as const).map((id) => ({
mode: "SZ",
stageId: id,
})),
});
}
/**
* Inserts a tournament team into the database with the specified number of members. Also checks in the team to the tournament.
*/
export async function dbInsertTournamentTeam({
membersCount,
ownerId,
tournamentId = 1,
}: {
/** Total number of members in the team, including the owner. */
membersCount: number;
/** Id of the user who owns the team. The other members are relative to this ID so e.g. if captain has ID of 5 then other members have 6,7,8 etc. */
ownerId: number;
/** Id of the tournament to associate the team with. Defaults to 1. */
tournamentId?: number;
}) {
const tournamentTeam = await withUserId(ownerId, () =>
TournamentTeamRepository.insert({
team: {
name: `Test Team ${ownerId}`,
prefersNotToHost: 0,
teamId: null,
},
userId: ownerId,
tournamentId,
}),
);
for (let i = 1; i < membersCount; i++) {
const memberId = ownerId + i;
await withUserId(memberId, () =>
TournamentTeamRepository.join({
userId: memberId,
newTeamId: tournamentTeam.id,
}),
);
}
await withUserId(ownerId, () =>
TournamentTeamRepository.checkIn(tournamentTeam.id),
);
}
/**
* Starts a tournament with the given seeding and tournament ID.

View File

@@ -1,7 +1,7 @@
import { add } from "date-fns";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { dbReset } from "~/utils/Test";
@@ -18,47 +18,6 @@ vi.mock("~/features/notifications/core/notify.server", () => ({
notify: mockNotify,
}));
async function createTestTournament({
name,
startTime,
authorId = author.id,
discordInviteCode = "test-discord",
}: {
name: string;
startTime: Date;
authorId?: number;
discordInviteCode?: string;
}) {
return CalendarRepository.insert({
isFullTournament: true,
authorId,
badges: [],
bracketUrl: "https://example.com/bracket",
description: null,
discordInviteCode,
name,
organizationId: null,
rules: null,
startTimes: [dateToDatabaseTimestamp(startTime)],
tags: null,
bracketProgression: [
{
name: "Bracket",
type: "single_elimination",
requiresCheckIn: false,
settings: {
thirdPlaceMatch: false,
},
},
],
mapPickingStyle: "TO",
mapPoolMaps: ([1, 2, 3, 4, 5] as const).map((id) => ({
mode: "SZ",
stageId: id,
})),
});
}
describe("NotifyCheckInStartRoutine", () => {
beforeEach(async () => {
vi.useFakeTimers();
@@ -77,9 +36,10 @@ describe("NotifyCheckInStartRoutine", () => {
const now = new Date();
const oneHourFromNow = add(now, { hours: 1 });
await createTestTournament({
await TournamentFactory.create({
name: "Tournament 1 Hour Away",
startTime: oneHourFromNow,
authorId: author.id,
startTimes: [dateToDatabaseTimestamp(oneHourFromNow)],
});
await NotifyCheckInStartRoutine.run();
@@ -100,9 +60,10 @@ describe("NotifyCheckInStartRoutine", () => {
test("does NOT send notification for tournament starting exactly now", async () => {
const now = new Date();
await createTestTournament({
await TournamentFactory.create({
name: "Tournament Starting Now",
startTime: now,
authorId: author.id,
startTimes: [dateToDatabaseTimestamp(now)],
});
await NotifyCheckInStartRoutine.run();
@@ -114,9 +75,10 @@ describe("NotifyCheckInStartRoutine", () => {
const now = new Date();
const thirtyMinutesFromNow = add(now, { minutes: 30 });
await createTestTournament({
await TournamentFactory.create({
name: "Tournament 30 Minutes Away",
startTime: thirtyMinutesFromNow,
authorId: author.id,
startTimes: [dateToDatabaseTimestamp(thirtyMinutesFromNow)],
});
await NotifyCheckInStartRoutine.run();
@@ -138,9 +100,10 @@ describe("NotifyCheckInStartRoutine", () => {
const now = new Date();
const oneAndHalfHoursFromNow = add(now, { hours: 1, minutes: 30 });
await createTestTournament({
await TournamentFactory.create({
name: "Tournament 1.5 Hours Away",
startTime: oneAndHalfHoursFromNow,
authorId: author.id,
startTimes: [dateToDatabaseTimestamp(oneAndHalfHoursFromNow)],
});
await NotifyCheckInStartRoutine.run();
@@ -153,17 +116,16 @@ describe("NotifyCheckInStartRoutine", () => {
const thirtyMinutesFromNow = add(now, { minutes: 30 });
const fortyFiveMinutesFromNow = add(now, { minutes: 45 });
await createTestTournament({
await TournamentFactory.create({
name: "Tournament A",
startTime: thirtyMinutesFromNow,
discordInviteCode: "test-discord-1",
authorId: author.id,
startTimes: [dateToDatabaseTimestamp(thirtyMinutesFromNow)],
});
await createTestTournament({
await TournamentFactory.create({
name: "Tournament B",
startTime: fortyFiveMinutesFromNow,
authorId: otherAuthor.id,
discordInviteCode: "test-discord-2",
startTimes: [dateToDatabaseTimestamp(fortyFiveMinutesFromNow)],
});
await NotifyCheckInStartRoutine.run();

View File

@@ -7,6 +7,7 @@ import type {
import { expect } from "vitest";
import type { z } from "zod";
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import { actAs } from "~/db/seed/core/actAs";
import { resetFactories } from "~/db/seed/core/defineFactory";
import { db } from "~/db/sql";
import { ADMIN_ID } from "~/features/admin/admin-constants";
@@ -40,10 +41,7 @@ export function withUser<T>(user: AuthenticatedUser, fn: () => T): T {
* matters (repositories read the actor solely via `actorId()` / `actorIdOrNull()`).
*/
export function withUserId<T>(id: number, fn: () => T): T {
return userAsyncLocalStorage.run(
{ user: { id } as unknown as AuthenticatedUser },
fn,
);
return actAs(id, fn);
}
/**