diff --git a/app/db/seed/core/SplatoonFaker.ts b/app/db/seed/core/SplatoonFaker.ts index 0a02ce7ca..ea3649265 100644 --- a/app/db/seed/core/SplatoonFaker.ts +++ b/app/db/seed/core/SplatoonFaker.ts @@ -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. diff --git a/app/db/seed/core/actAs.ts b/app/db/seed/core/actAs.ts new file mode 100644 index 000000000..0cc81da71 --- /dev/null +++ b/app/db/seed/core/actAs.ts @@ -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(userId: number, fn: () => T): T { + return userAsyncLocalStorage.run( + { user: { id: userId } as AuthenticatedUser }, + fn, + ); +} diff --git a/app/db/seed/factories/SQGroupFactory.ts b/app/db/seed/factories/SQGroupFactory.ts index bc7f7902b..a4cdb5767 100644 --- a/app/db/seed/factories/SQGroupFactory.ts +++ b/app/db/seed/factories/SQGroupFactory.ts @@ -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: [], diff --git a/app/db/seed/factories/SQMatchFactory.ts b/app/db/seed/factories/SQMatchFactory.ts index f6ac997f3..cf53897c8 100644 --- a/app/db/seed/factories/SQMatchFactory.ts +++ b/app/db/seed/factories/SQMatchFactory.ts @@ -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, diff --git a/app/db/seed/factories/TournamentFactory.ts b/app/db/seed/factories/TournamentFactory.ts new file mode 100644 index 000000000..090003ca3 --- /dev/null +++ b/app/db/seed/factories/TournamentFactory.ts @@ -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[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); + }, +}); diff --git a/app/db/seed/factories/TournamentTeamFactory.ts b/app/db/seed/factories/TournamentTeamFactory.ts new file mode 100644 index 000000000..feda5ec05 --- /dev/null +++ b/app/db/seed/factories/TournamentTeamFactory.ts @@ -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[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), + ); + }, +}); diff --git a/app/features/leaderboards/LeaderboardRepository.server.test.ts b/app/features/leaderboards/LeaderboardRepository.server.test.ts index 288d6ef9a..d81bf87fd 100644 --- a/app/features/leaderboards/LeaderboardRepository.server.test.ts +++ b/app/features/leaderboards/LeaderboardRepository.server.test.ts @@ -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, }); diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts index 0ac34e735..c3edcdf75 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts @@ -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), diff --git a/app/features/tournament-match/TournamentMatchRepository.server.test.ts b/app/features/tournament-match/TournamentMatchRepository.server.test.ts index 261fd2702..4495cffc3 100644 --- a/app/features/tournament-match/TournamentMatchRepository.server.test.ts +++ b/app/features/tournament-match/TournamentMatchRepository.server.test.ts @@ -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); diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts index 3d459a206..c7b4baf41 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts @@ -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); diff --git a/app/features/tournament-organization/test-utils.ts b/app/features/tournament-organization/test-utils.ts index bf14b1454..08a4401a1 100644 --- a/app/features/tournament-organization/test-utils.ts +++ b/app/features/tournament-organization/test-utils.ts @@ -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 }; } diff --git a/app/features/tournament/TournamentAuditLogRepository.server.test.ts b/app/features/tournament/TournamentAuditLogRepository.server.test.ts index 04f396e94..a82722570 100644 --- a/app/features/tournament/TournamentAuditLogRepository.server.test.ts +++ b/app/features/tournament/TournamentAuditLogRepository.server.test.ts @@ -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, diff --git a/app/features/tournament/TournamentRepository.finalize.test.ts b/app/features/tournament/TournamentRepository.finalize.test.ts index 2e8d08789..6bd7f842f 100644 --- a/app/features/tournament/TournamentRepository.finalize.test.ts +++ b/app/features/tournament/TournamentRepository.finalize.test.ts @@ -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, diff --git a/app/features/tournament/tournament-test-utils.ts b/app/features/tournament/tournament-test-utils.ts index 75788a898..653a82203 100644 --- a/app/features/tournament/tournament-test-utils.ts +++ b/app/features/tournament/tournament-test-utils.ts @@ -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. diff --git a/app/routines/notifyCheckInStart.test.ts b/app/routines/notifyCheckInStart.test.ts index 51ace0cc4..778d20a49 100644 --- a/app/routines/notifyCheckInStart.test.ts +++ b/app/routines/notifyCheckInStart.test.ts @@ -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(); diff --git a/app/utils/Test.ts b/app/utils/Test.ts index 4b99dc981..6a750c157 100644 --- a/app/utils/Test.ts +++ b/app/utils/Test.ts @@ -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(user: AuthenticatedUser, fn: () => T): T { * matters (repositories read the actor solely via `actorId()` / `actorIdOrNull()`). */ export function withUserId(id: number, fn: () => T): T { - return userAsyncLocalStorage.run( - { user: { id } as unknown as AuthenticatedUser }, - fn, - ); + return actAs(id, fn); } /**