From a6ddd75772764d593c7c51ba055ca44adb4ff95f Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:23:44 +0300 Subject: [PATCH] Refactor --- app/db/seed/core/backdate.ts | 3 + app/db/seed/core/defineFactory.ts | 56 ++-- app/db/seed/factories/CalendarEventFactory.ts | 28 +- .../GroupMatchContinueVoteFactory.ts | 22 ++ app/db/seed/factories/ImageFactory.ts | 6 +- app/db/seed/factories/LogInLinkFactory.ts | 11 + app/db/seed/factories/PlusVoteFactory.ts | 2 + app/db/seed/factories/SQGroupFactory.ts | 59 ++-- app/db/seed/factories/SQMatchFactory.ts | 62 ++++- .../seed/factories/SQReportedWeaponFactory.ts | 7 + app/db/seed/factories/ScrimPostFactory.ts | 10 +- app/db/seed/factories/TeamFactory.ts | 54 +++- app/db/seed/factories/TournamentFactory.ts | 54 +++- .../factories/TournamentLFGTeamFactory.ts | 6 + .../factories/TournamentStreamerFactory.ts | 5 +- .../seed/factories/TournamentTeamFactory.ts | 55 ++-- app/db/seed/factories/UserFactory.ts | 57 ++-- .../seed/factories/XRankPlacementFactory.ts | 35 ++- .../admin/AdminRepository.server.test.ts | 134 ++++------ app/features/admin/routes/admin.test.ts | 25 +- app/features/api/ApiRepository.server.test.ts | 19 +- .../auth/LogInLinkRepository.server.test.ts | 5 +- .../badges/BadgeRepository.server.test.ts | 15 +- .../builds/BuildRepository.server.test.ts | 7 +- .../LeaderboardRepository.server.test.ts | 50 +--- .../scrims/ScrimPostRepository.server.test.ts | 18 +- ...MatchContinueVoteRepository.server.test.ts | 25 +- .../SQMatchRepository.server.test.ts | 18 +- .../PrivateUserNoteRepository.server.test.ts | 34 +-- .../sendouq/SQGroupRepository.server.test.ts | 33 +-- .../sendouq/core/SendouQ.server.test.ts | 53 ++-- app/features/sendouq/routes/q.looking.test.ts | 59 ++-- .../actions/t.$customUrl.edit.server.test.ts | 253 +++++++++--------- .../team/routes/t.$customUrl.edit.test.ts | 29 +- app/features/team/routes/t.$customUrl.test.ts | 33 ++- .../TournamentLFGRepository.server.test.ts | 65 ++--- .../TournamentMatchRepository.server.test.ts | 21 +- .../routes/to.$id.matches.$mid.test.ts | 119 ++++---- .../tournament-organization/test-utils.ts | 48 +--- ...ournamentAuditLogRepository.server.test.ts | 5 +- .../TournamentRepository.finalize.test.ts | 2 +- .../UserCardRepository.server.test.ts | 15 +- .../closeExpiredContinueVotes.test.ts | 31 +-- app/routines/syncTournamentVods.test.ts | 25 +- app/utils/Test.ts | 16 +- 45 files changed, 905 insertions(+), 784 deletions(-) create mode 100644 app/db/seed/factories/GroupMatchContinueVoteFactory.ts create mode 100644 app/db/seed/factories/LogInLinkFactory.ts create mode 100644 app/db/seed/factories/TournamentLFGTeamFactory.ts diff --git a/app/db/seed/core/backdate.ts b/app/db/seed/core/backdate.ts index c65d11328..d955559df 100644 --- a/app/db/seed/core/backdate.ts +++ b/app/db/seed/core/backdate.ts @@ -27,6 +27,9 @@ export async function backdate( const assignments: RawBuilder[] = []; for (const [column, date] of Object.entries(timestamps)) { + // so that a caller passing its own optional dates through needs no filtering + if (!date) continue; + assignments.push( sql`${sql.ref(column)} = ${dateToDatabaseTimestamp(date as Date)}`, ); diff --git a/app/db/seed/core/defineFactory.ts b/app/db/seed/core/defineFactory.ts index 59540bc89..1d0edc532 100644 --- a/app/db/seed/core/defineFactory.ts +++ b/app/db/seed/core/defineFactory.ts @@ -37,10 +37,6 @@ export type Factory = { createMany: ( ...args: CreateManyParams ) => Promise; - /** A preset: the same factory with an extra layer of defaults. */ - variant: >( - defaults: VariantDefaults, - ) => Factory; }; const sequenceResets = new Set<() => void>(); @@ -63,14 +59,15 @@ const sequenceResets = new Set<() => void>(); export function defineFactory< Args, Row, - Defaults extends Partial, + Defaults extends Partial = Record, Options = never, >({ defaults, insert, applyOptions, }: { - defaults: (ctx: { seq: number }) => Defaults; + /** Omitted by a factory whose every argument is a foreign key it must not invent. */ + defaults?: (ctx: { seq: number }) => Defaults; insert: (args: Args) => Promise; applyOptions?: (row: Row, options: Options) => Promise; }): Factory { @@ -89,38 +86,29 @@ export function defineFactory< return row; }; - const factoryWith = >( - presetDefaults: PresetDefaults, - ): Factory => { - // `create` requires everything the two default layers don't supply, so the merge - // is a complete `Args` — something the compiler can't work out from the spread - const build = (overrides: Partial) => - ({ - ...defaults({ seq: ++seq }), - ...presetDefaults, - ...overrides, - }) as Args; + // `create` requires everything `defaults` doesn't supply, so the merge is a + // complete `Args` — something the compiler can't work out from the spread + const build = (overrides: Partial) => + ({ + ...defaults?.({ seq: ++seq }), + ...overrides, + }) as Args; - return { - create: (...args) => insertOne(build(args[0] ?? {}), args[1]), - createMany: async (...args) => { - const [count, overrides, options] = args; - const rows: Row[] = []; + return { + create: (...args) => insertOne(build(args[0] ?? {}), args[1]), + createMany: async (...args) => { + const [count, overrides, options] = args; + const rows: Row[] = []; - for (let index = 0; index < count; index++) { - rows.push( - await insertOne(build(overridesAt(overrides, index)), options), - ); - } + for (let index = 0; index < count; index++) { + rows.push( + await insertOne(build(overridesAt(overrides, index)), options), + ); + } - return rows; - }, - variant: (variantDefaults) => - factoryWith({ ...presetDefaults, ...variantDefaults }), - }; + return rows; + }, }; - - return factoryWith({}); } /** diff --git a/app/db/seed/factories/CalendarEventFactory.ts b/app/db/seed/factories/CalendarEventFactory.ts index 62e49a1a5..7e68c4c58 100644 --- a/app/db/seed/factories/CalendarEventFactory.ts +++ b/app/db/seed/factories/CalendarEventFactory.ts @@ -8,18 +8,24 @@ type InsertArgs = Omit< "isFullTournament" | "bracketProgression" | "mapPickingStyle" >; +/** + * What every calendar event is defaulted to, tournaments included — a tournament is + * a calendar event with one attached, see `TournamentFactory`. + */ +export const eventDefaults = () => ({ + name: faker.company.name(), + description: null, + discordInviteCode: null, + bracketUrl: faker.internet.url(), + organizationId: null, + tags: null, + badges: [], + rules: null, + startTimes: [databaseTimestampNow()], +}); + 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()], - }), + defaults: eventDefaults, insert: async (args: InsertArgs) => { const { eventId } = await CalendarRepository.insert({ ...args, diff --git a/app/db/seed/factories/GroupMatchContinueVoteFactory.ts b/app/db/seed/factories/GroupMatchContinueVoteFactory.ts new file mode 100644 index 000000000..371292c7d --- /dev/null +++ b/app/db/seed/factories/GroupMatchContinueVoteFactory.ts @@ -0,0 +1,22 @@ +import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server"; +import { actAs } from "../core/actAs"; +import { defineFactory } from "../core/defineFactory"; + +type InsertArgs = Parameters< + typeof GroupMatchContinueVoteRepository.castOwnVote +>[0] & { + /** The group member whose vote it is, on whose behalf it is cast. */ + userId: number; +}; + +/** + * Creates the votes a SendouQ group casts on carrying on with the same teammates + * after a match. A vote against clears the group's votes in favour, since those + * were for carrying on at a size the group no longer has — the repository's own + * doing, which is why the votes go through it in the order they were cast. + */ +export const { create } = defineFactory({ + defaults: () => ({ isContinuing: true }), + insert: ({ userId, ...args }: InsertArgs) => + actAs(userId, () => GroupMatchContinueVoteRepository.castOwnVote(args)), +}); diff --git a/app/db/seed/factories/ImageFactory.ts b/app/db/seed/factories/ImageFactory.ts index 1179a4c2d..4c952a8e0 100644 --- a/app/db/seed/factories/ImageFactory.ts +++ b/app/db/seed/factories/ImageFactory.ts @@ -1,6 +1,8 @@ import * as ImageRepository from "~/features/img-upload/ImageRepository.server"; import { defineFactory } from "../core/defineFactory"; +type InsertArgs = Parameters[0]; + type Options = { /** Has an admin approved the image, making it show wherever it is used? */ isValidated: boolean; @@ -16,9 +18,9 @@ type Options = { export const { create } = defineFactory({ defaults: ({ seq }) => ({ url: `image-${seq}.png`, - validatedAt: null, }), - insert: ImageRepository.insert, + insert: (args: Omit) => + ImageRepository.insert({ ...args, validatedAt: null }), applyOptions: async (image, { isValidated }: Options) => { if (!isValidated) return; diff --git a/app/db/seed/factories/LogInLinkFactory.ts b/app/db/seed/factories/LogInLinkFactory.ts new file mode 100644 index 000000000..fc1bd22af --- /dev/null +++ b/app/db/seed/factories/LogInLinkFactory.ts @@ -0,0 +1,11 @@ +import * as LogInLinkRepository from "~/features/auth/LogInLinkRepository.server"; +import { defineFactory } from "../core/defineFactory"; + +/** + * Creates the single use links the log in with a code flow hands out. `userId` is + * who the link logs in; its code and expiry are the repository's own. + */ +export const { create } = defineFactory({ + insert: ({ userId }: { userId: number }) => + LogInLinkRepository.insert(userId), +}); diff --git a/app/db/seed/factories/PlusVoteFactory.ts b/app/db/seed/factories/PlusVoteFactory.ts index 3080edd06..2040081bb 100644 --- a/app/db/seed/factories/PlusVoteFactory.ts +++ b/app/db/seed/factories/PlusVoteFactory.ts @@ -36,5 +36,7 @@ export const { create, createMany } = defineFactory({ .execute(); await PlusVotingRepository.upsertMany([...alreadyCast, vote]); + + return vote; }, }); diff --git a/app/db/seed/factories/SQGroupFactory.ts b/app/db/seed/factories/SQGroupFactory.ts index a4cdb5767..7915dc8a2 100644 --- a/app/db/seed/factories/SQGroupFactory.ts +++ b/app/db/seed/factories/SQGroupFactory.ts @@ -1,44 +1,63 @@ import { db } from "~/db/sql"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import invariant from "~/utils/invariant"; import { defineFactory } from "../core/defineFactory"; -type Insert = typeof SQGroupRepository.insert; - -type InsertArgs = Parameters[0] & { - additionalMemberUserIds: number[]; +type InsertArgs = Omit< + Parameters[0], + "userId" +> & { + /** The group's members, the first of them its owner. */ + memberUserIds: number[]; }; type Options = { /** Was the group made in the matchmaking UI? */ - isMatchmade: boolean; + isMatchmade?: boolean; + /** Groups that have liked this one, each of them as its own owner. */ + likedByGroupIds?: number[]; }; /** - * Creates SendouQ groups. `userId` is the owner, whose membership the repository - * 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. + * Creates SendouQ groups. The first of `memberUserIds` is the owner, whose + * membership the repository creates with the group; the rest join it the way they do + * in production. Invite and chat codes are the repository's own. */ export const { create } = defineFactory({ defaults: () => ({ status: "ACTIVE" as const, - additionalMemberUserIds: [], }), - insert: async ({ additionalMemberUserIds, ...args }: InsertArgs) => { - const group = await SQGroupRepository.insert(args); + insert: async ({ memberUserIds, ...args }: InsertArgs) => { + const [ownerUserId, ...otherMemberUserIds] = memberUserIds; + invariant(ownerUserId, "A group needs at least an owner"); - for (const userId of additionalMemberUserIds) { + const group = await SQGroupRepository.insert({ + ...args, + userId: ownerUserId, + }); + + for (const userId of otherMemberUserIds) { await SQGroupRepository.insertMember(group.id, { userId }); } - return group; + return { id: group.id, memberUserIds, ownerUserId }; }, - applyOptions: async (group, { isMatchmade }: Options) => { - if (!isMatchmade) return; + applyOptions: async (group, { isMatchmade, likedByGroupIds }: Options) => { + for (const likerGroupId of likedByGroupIds ?? []) { + await SQGroupRepository.insertLike({ + likerGroupId, + targetGroupId: group.id, + }); + } - await db - .updateTable("Group") - .set({ matchmade: 1 }) - .where("id", "=", group.id) - .execute(); + if (isMatchmade) { + // written directly because the only production write of the column is + // `morphGroups`, which needs two separate groups to merge into one + await db + .updateTable("Group") + .set({ matchmade: 1 }) + .where("id", "=", group.id) + .execute(); + } }, }); diff --git a/app/db/seed/factories/SQMatchFactory.ts b/app/db/seed/factories/SQMatchFactory.ts index cf53897c8..5ffeb87bf 100644 --- a/app/db/seed/factories/SQMatchFactory.ts +++ b/app/db/seed/factories/SQMatchFactory.ts @@ -1,17 +1,38 @@ import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import invariant from "~/utils/invariant"; +import { backdate } from "../core/backdate"; import { defineFactory } from "../core/defineFactory"; import * as SplatoonFaker from "../core/SplatoonFaker"; +import * as SQGroupFactory from "./SQGroupFactory"; + +type InsertArgs = Omit< + Parameters[0], + "alphaGroupId" | "bravoGroupId" +> & { + /** Members of the alpha group, the first of them its owner. */ + alphaUserIds: number[]; + /** Members of the bravo group, the first of them its owner. */ + bravoUserIds: number[]; + /** Were the two groups made in the matchmaking UI, rather than by inviting? */ + isMatchmade?: boolean; +}; type Options = { /** Play the match out, alpha winning every map, up to both teams having agreed * on the score. Leaves both groups inactive, as a real concluded match does. */ - isConcluded: boolean; + isConcluded?: boolean; + /** When the match was made, for one that should look older than now. */ + createdAt?: Date; + /** When the two groups had agreed on the score. Needs `isConcluded`. */ + confirmedAt?: Date; }; -/** Creates SendouQ matches. Both groups have to be full, as they are when the - * matchmaking UI creates a match. */ +/** + * Creates SendouQ matches together with the two groups playing them: a match is + * only ever made out of two full groups, the way the matchmaking UI makes one, so + * the groups are not the caller's to bring. Both are returned with the match. + */ export const { create } = defineFactory({ defaults: () => ({ mapList: SplatoonFaker.mapList(SENDOUQ_BEST_OF).map((map) => ({ @@ -20,11 +41,38 @@ export const { create } = defineFactory({ })), memento: { users: {}, groups: {}, pools: [] }, }), - insert: SQMatchRepository.insert, - applyOptions: async (match, { isConcluded }: Options) => { - if (!isConcluded) return; + insert: async ({ + alphaUserIds, + bravoUserIds, + isMatchmade, + ...args + }: InsertArgs) => { + const alphaGroup = await SQGroupFactory.create( + { memberUserIds: alphaUserIds }, + { isMatchmade }, + ); + const bravoGroup = await SQGroupFactory.create( + { memberUserIds: bravoUserIds }, + { isMatchmade }, + ); - await playOutMatch(match.id); + const match = await SQMatchRepository.insert({ + ...args, + alphaGroupId: alphaGroup.id, + bravoGroupId: bravoGroup.id, + }); + + return { ...match, alphaGroup, bravoGroup }; + }, + applyOptions: async ( + match, + { isConcluded, createdAt, confirmedAt }: Options, + ) => { + if (isConcluded) { + await playOutMatch(match.id); + } + + await backdate("GroupMatch", match.id, { createdAt, confirmedAt }); }, }); diff --git a/app/db/seed/factories/SQReportedWeaponFactory.ts b/app/db/seed/factories/SQReportedWeaponFactory.ts index 003839646..d830c5afe 100644 --- a/app/db/seed/factories/SQReportedWeaponFactory.ts +++ b/app/db/seed/factories/SQReportedWeaponFactory.ts @@ -7,6 +7,13 @@ type InsertArgs = Parameters[0] & { userId: number; }; +/** + * Creates the weapons a SendouQ match's players report having used. `userId` is the + * player whose weapon it was, on whose behalf it is reported. + * + * `mapIndex` is not defaulted: it is what identifies the row, so a second weapon + * without one would replace the first rather than add to it. + */ export const { createMany } = defineFactory({ defaults: () => ({ weaponSplId: SplatoonFaker.mainWeapon(), diff --git a/app/db/seed/factories/ScrimPostFactory.ts b/app/db/seed/factories/ScrimPostFactory.ts index 74f273473..ed99662c4 100644 --- a/app/db/seed/factories/ScrimPostFactory.ts +++ b/app/db/seed/factories/ScrimPostFactory.ts @@ -21,8 +21,6 @@ type Options = { /** * Creates scrim posts. `users` is the side offering the scrim, one of them its owner. * The requests made to the post, and which of them booked it, are `options`. - * - * @returns id of the new post */ export const { create } = defineFactory({ defaults: () => ({ @@ -38,11 +36,13 @@ export const { create } = defineFactory({ managedByAnyone: false, isScheduledForFuture: false, }), - insert: ScrimPostRepository.insert, - applyOptions: async (scrimPostId, { requests }: Options) => { + insert: async (args: Parameters[0]) => ({ + id: await ScrimPostRepository.insert(args), + }), + applyOptions: async (post, { requests }: Options) => { for (const request of requests ?? []) { const requestId = await ScrimPostRepository.insertRequest({ - scrimPostId, + scrimPostId: post.id, teamId: null, message: null, startsAt: request.startsAt ?? null, diff --git a/app/db/seed/factories/TeamFactory.ts b/app/db/seed/factories/TeamFactory.ts index 102d94599..9613c894d 100644 --- a/app/db/seed/factories/TeamFactory.ts +++ b/app/db/seed/factories/TeamFactory.ts @@ -1,28 +1,41 @@ import * as TeamRepository from "~/features/team/TeamRepository.server"; import { TEAM } from "~/features/team/team-constants"; +import invariant from "~/utils/invariant"; import { actAs } from "../core/actAs"; import { defineFactory } from "../core/defineFactory"; +import * as ImageFactory from "./ImageFactory"; -type InsertArgs = Parameters[0] & { - additionalMemberUserIds: number[]; +type InsertArgs = Omit< + Parameters[0], + "ownerUserId" +> & { + /** The team's members, the first of them its owner. */ + memberUserIds: number[]; +}; + +type Options = { + /** Gives the team a logo, submitted by its owner the way one is in production. */ + hasAvatar?: boolean; }; /** - * Creates teams. `ownerUserId` is the owner, whose membership the repository creates - * with the team; the members named by `additionalMemberUserIds` join it the way they - * do in production, within the team count a non-patron is allowed. Custom url and - * invite code are the repository's own, the custom url following from the name. + * Creates teams. The first of `memberUserIds` is the owner, whose membership the + * repository creates with the team; the rest join it the way they do in production, + * within the team count a non-patron is allowed. Custom url and invite code are the + * repository's own, the custom url following from the name. */ export const { create } = defineFactory({ defaults: ({ seq }) => ({ name: `Team ${seq}`, isMainTeam: true, - additionalMemberUserIds: [], }), - insert: async ({ additionalMemberUserIds, ...args }: InsertArgs) => { - const team = await TeamRepository.insert(args); + insert: async ({ memberUserIds, ...args }: InsertArgs) => { + const [ownerUserId, ...otherMemberUserIds] = memberUserIds; + invariant(ownerUserId, "A team needs at least an owner"); - for (const userId of additionalMemberUserIds) { + const team = await TeamRepository.insert({ ...args, ownerUserId }); + + for (const userId of otherMemberUserIds) { await actAs(userId, () => TeamRepository.insertOwnMembership({ teamId: team.id, @@ -31,6 +44,25 @@ export const { create } = defineFactory({ ); } - return team; + return { ...team, name: args.name, ownerUserId, memberUserIds }; + }, + applyOptions: async (team, { hasAvatar }: Options) => { + if (!hasAvatar) return; + + const image = await ImageFactory.create({ + submitterUserId: team.ownerUserId, + }); + + // the team edit page saves the whole profile at once; everything besides the + // name is still empty on a team the repository has only just inserted + await TeamRepository.update({ + id: team.id, + name: team.name, + bio: null, + bsky: null, + tag: null, + avatarImgId: image.id, + bannerImgId: null, + }); }, }); diff --git a/app/db/seed/factories/TournamentFactory.ts b/app/db/seed/factories/TournamentFactory.ts index 60b0fbc56..a12734960 100644 --- a/app/db/seed/factories/TournamentFactory.ts +++ b/app/db/seed/factories/TournamentFactory.ts @@ -12,10 +12,10 @@ import { import { resolveMatchMapList } from "~/features/tournament-match/core/mapList.server"; import { reportScore } from "~/features/tournament-match/core/reportScore.server"; import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; -import { databaseTimestampNow } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { defineFactory } from "../core/defineFactory"; -import { faker } from "../core/faker"; +import { eventDefaults } from "./CalendarEventFactory"; +import * as TournamentTeamFactory from "./TournamentTeamFactory"; const SINGLE_ELIMINATION: TournamentSettings["bracketProgression"] = [ { @@ -49,7 +49,7 @@ 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; + isFinalized?: boolean; }; /** @@ -61,15 +61,7 @@ type Options = { */ 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()], + ...eventDefaults(), mapPickingStyle: "TO" as const, bracketProgression: SINGLE_ELIMINATION, }), @@ -90,12 +82,44 @@ export const { create } = defineFactory({ }, }); +/** + * Creates a tournament that has been played. Every entry of `teamRosters` registers + * as a team owned by the first of its users and checks in, the first bracket is + * started off that seeding, and every match of it is played out. + * + * Returns the teams and the matches played alongside the tournament, so that a + * progression can carry on: start its next bracket and play that too. + */ +export async function createPlayed( + overrides: Parameters[0], + { teamRosters, ...options }: Options & { teamRosters: number[][] }, +) { + const tournament = await create(overrides, options); + + const teams: Awaited>[] = []; + for (const memberUserIds of teamRosters) { + teams.push( + await TournamentTeamFactory.create( + { tournamentId: tournament.id, memberUserIds }, + { isCheckedIn: true }, + ), + ); + } + + await startBracket(tournament.id); + const matches = await playMatches(tournament.id); + + return { ...tournament, teams, matches }; +} + /** * Starts one of the tournament's brackets, seeded by the teams that are in it — * the same teams the organizer would see offered on the bracket page. * * Later brackets of a progression are started by calling this again once the * matches they source their teams from have been played. + * + * Returns the matches the bracket was created with, in the generator's own order. */ export async function startBracket( tournamentId: number, @@ -125,6 +149,12 @@ export async function startBracket( }); clearTournamentDataCache(tournamentId); + + const started = await tournamentFromDB({ tournamentId, user: undefined }); + const startedBracket = started.bracketByIdx(bracketIdx); + invariant(startedBracket, `Bracket at index ${bracketIdx} was not created`); + + return startedBracket.data.match.map((match) => ({ id: match.id })); } interface PlayedMatch { diff --git a/app/db/seed/factories/TournamentLFGTeamFactory.ts b/app/db/seed/factories/TournamentLFGTeamFactory.ts new file mode 100644 index 000000000..e0b11a2b5 --- /dev/null +++ b/app/db/seed/factories/TournamentLFGTeamFactory.ts @@ -0,0 +1,6 @@ +import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; +import { defineFactory } from "../core/defineFactory"; + +export const { create } = defineFactory({ + insert: TournamentLFGRepository.insertPlaceholderTeam, +}); diff --git a/app/db/seed/factories/TournamentStreamerFactory.ts b/app/db/seed/factories/TournamentStreamerFactory.ts index 46edca17b..c77dff9da 100644 --- a/app/db/seed/factories/TournamentStreamerFactory.ts +++ b/app/db/seed/factories/TournamentStreamerFactory.ts @@ -1,9 +1,10 @@ -import type { Tables } from "~/db/tables"; import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server"; import invariant from "~/utils/invariant"; import { defineFactory } from "../core/defineFactory"; -type InsertArgs = Omit; +type InsertArgs = Parameters< + typeof LiveStreamRepository.insertTournamentStreamers +>[0][number]; export const { create } = defineFactory({ defaults: ({ seq }) => ({ diff --git a/app/db/seed/factories/TournamentTeamFactory.ts b/app/db/seed/factories/TournamentTeamFactory.ts index 0d5984c3b..cb2eb3dc4 100644 --- a/app/db/seed/factories/TournamentTeamFactory.ts +++ b/app/db/seed/factories/TournamentTeamFactory.ts @@ -1,42 +1,65 @@ import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; +import invariant from "~/utils/invariant"; import { actAs } from "../core/actAs"; import { defineFactory } from "../core/defineFactory"; import { faker } from "../core/faker"; -type InsertArgs = Parameters[0]; +type InsertArgs = Omit< + Parameters[0], + "userId" | "additionalMemberUserIds" +> & { + /** The team's members, the first of them its owner. */ + memberUserIds: number[]; +}; type Options = { /** Has the team checked in to the tournament? */ - isCheckedIn: boolean; + isCheckedIn?: boolean; + /** Is the team looking for more players on the tournament's LFG page? */ + isLooking?: 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. + * Creates tournament teams. The first of `memberUserIds` is the owner, on whose + * behalf the team is registered; the rest are added to it the way they are in + * production. Invite code and in-game names are the repository's own. + * + * A player looking for a team without one to register is a placeholder team + * instead, see `TournamentLFGTeamFactory`. */ -export const { create, createMany } = defineFactory({ +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 }), + insert: async ({ memberUserIds, ...args }: InsertArgs) => { + const [ownerUserId, ...additionalMemberUserIds] = memberUserIds; + invariant(ownerUserId, "A team needs at least an owner"); + + const team = await actAs(ownerUserId, () => + TournamentTeamRepository.insert({ + ...args, + userId: ownerUserId, + additionalMemberUserIds, + }), ); - return { id: team.id, ownerUserId: userId }; + return { id: team.id, ownerUserId, memberUserIds }; }, - applyOptions: async (team, { isCheckedIn }: Options) => { - if (!isCheckedIn) return; + applyOptions: async (team, { isCheckedIn, isLooking }: Options) => { + if (isCheckedIn) { + await actAs(team.ownerUserId, () => + TournamentTeamRepository.checkIn(team.id), + ); + } - await actAs(team.ownerUserId, () => - TournamentTeamRepository.checkIn(team.id), - ); + if (isLooking) { + await TournamentLFGRepository.startLooking(team.id); + } }, }); diff --git a/app/db/seed/factories/UserFactory.ts b/app/db/seed/factories/UserFactory.ts index a42a451b3..458683d6d 100644 --- a/app/db/seed/factories/UserFactory.ts +++ b/app/db/seed/factories/UserFactory.ts @@ -28,6 +28,12 @@ type Options = { roles?: Array; /** SendouQ match profile, submitted as the user themselves. */ matchProfile?: Partial; + /** Ban as an admin lays one down: `1` for good, or the date it lifts on. */ + ban?: Omit[0], "userId">; + /** Division the user played their last season in. */ + div?: NonNullable; + /** Weapon pool, submitted as the user themselves. */ + weapons?: Parameters[0]["weapons"]; }; const PATRONAGE_LENGTH = { years: 1 }; @@ -60,7 +66,7 @@ export const { create, createMany } = defineFactory({ bsky: null, }), insert: UserRepository.upsert, - applyOptions: (user, options: Options) => applyUserOptions(user.id, options), + applyOptions: (user, options: Options) => grant(user.id, options), }); /** @@ -69,20 +75,10 @@ export const { create, createMany } = defineFactory({ * * Has to be created before any other user, see {@link pinUserId}. */ -export async function createAdmin( +export const createAdmin = ( overrides?: Partial | null, options?: Options, -) { - const user = await create(overrides); - const id = await pinUserId(user.id, ADMIN_ID); - - // after pinning, so that rows keyed by the user id don't point at the old one - if (options) { - await applyUserOptions(id, options); - } - - return { id }; -} +) => createPinned(ADMIN_ID, overrides, options); /** * Creates the user that `wrappedAction({ user: "regular" })` submits as. Has no @@ -91,16 +87,22 @@ export async function createAdmin( * * Has to be created before any user without a pinned id, see {@link pinUserId}. */ -export async function createRegular( +export const createRegular = ( + overrides?: Partial | null, + options?: Options, +) => createPinned(REGULAR_USER_TEST_ID, overrides, options); + +async function createPinned( + pinnedId: number, overrides?: Partial | null, options?: Options, ) { const user = await create(overrides); - const id = await pinUserId(user.id, REGULAR_USER_TEST_ID); + const id = await pinUserId(user.id, pinnedId); // after pinning, so that rows keyed by the user id don't point at the old one if (options) { - await applyUserOptions(id, options); + await grant(id, options); } return { id }; @@ -147,9 +149,14 @@ export function pool() { }; } -async function applyUserOptions( +/** + * What `create`'s second argument does, applied to a user that already exists — for + * the tests whose users come from shared setup, or that want two of them given + * different things. + */ +export async function grant( userId: number, - { plusTier, patronTier, roles, matchProfile }: Options, + { plusTier, patronTier, roles, matchProfile, ban, div, weapons }: Options, ) { if (typeof plusTier === "number") { await setPlusTier(userId, plusTier); @@ -176,6 +183,20 @@ async function applyUserOptions( }), ); } + + if (ban) { + await AdminRepository.banUser({ userId, ...ban }); + } + + if (div) { + await UserRepository.updateManyDivs([{ userId, div }]); + } + + if (weapons) { + // the profile page saves every field at once; everything besides the weapons + // is still empty on a user the repository has only just upserted + await actAs(userId, () => UserRepository.updateOwnProfile({ weapons })); + } } async function setPlusTier(userId: number, plusTier: number) { diff --git a/app/db/seed/factories/XRankPlacementFactory.ts b/app/db/seed/factories/XRankPlacementFactory.ts index df2eedc2e..4c2d2ca43 100644 --- a/app/db/seed/factories/XRankPlacementFactory.ts +++ b/app/db/seed/factories/XRankPlacementFactory.ts @@ -1,15 +1,31 @@ import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server"; +import invariant from "~/utils/invariant"; import { defineFactory } from "../core/defineFactory"; import { faker } from "../core/faker"; import * as SplatoonFaker from "../core/SplatoonFaker"; const PLACED_ON = { month: 1, year: 2024 }; +type InsertArgs = Omit< + XRankPlacementRepository.XRankPlacementInsertArgs, + "playerSplId" +> & { + /** Defaults to one derived from `playerUserId`, for a player who only ever + * appears as that user. */ + playerSplId?: string; +}; + +type Options = { + /** Derive `SplatoonPlayer.peakXp` from the placements, as the import does once + * it has added a month's worth of them. */ + refreshPeakXp?: boolean; +}; + /** * Creates X Rank placements. `playerSplId` is the in-game id of the player who * placed — the repository creates their `SplatoonPlayer` row if the id is new, so * repeating an id places the same player again. `playerUserId` is the site user - * whose results they are. + * whose results they are, and stands in for the in-game id when none is given. * * Rank counts up, since a month's leaderboard has no two players at the same rank. */ @@ -28,9 +44,22 @@ export const { create } = defineFactory({ title: faker.lorem.words(2), weaponSplId: SplatoonFaker.mainWeapon(), }), - insert: async (args: XRankPlacementRepository.XRankPlacementInsertArgs) => { - const [id] = await XRankPlacementRepository.insertMany([args]); + insert: async ({ playerSplId, ...args }: InsertArgs) => { + const splId = playerSplId ?? `player-${args.playerUserId}`; + invariant( + playerSplId || args.playerUserId, + "A placement needs either an in-game id or a user to derive one from", + ); + + const [id] = await XRankPlacementRepository.insertMany([ + { ...args, playerSplId: splId }, + ]); return { id }; }, + applyOptions: async (_placement, { refreshPeakXp }: Options) => { + if (!refreshPeakXp) return; + + await XRankPlacementRepository.refreshAllPeakXp(); + }, }); diff --git a/app/features/admin/AdminRepository.server.test.ts b/app/features/admin/AdminRepository.server.test.ts index 1f37cce19..01393554d 100644 --- a/app/features/admin/AdminRepository.server.test.ts +++ b/app/features/admin/AdminRepository.server.test.ts @@ -11,91 +11,71 @@ const createUsers = (count: number) => users.create(count, (index) => ({ discordId: String(index) })); describe("findAllBannedUsers", () => { + let admin: { id: number }; + + const createBannedUser = (bannedReason: string, banned: 1 | Date = 1) => + UserFactory.create(null, { + ban: { banned, bannedReason, bannedByUserId: admin.id }, + }); + beforeEach(async () => { - await createUsers(5); + admin = await UserFactory.create(); }); test("returns empty Map when no users are banned", async () => { + await UserFactory.create(); + const result = await AdminRepository.findAllBannedUsers(); expect(result.size).toBe(0); }); test("returns Map with single banned user", async () => { - await AdminRepository.banUser({ - userId: users.id(1), - banned: 1, - bannedReason: "Test ban", - bannedByUserId: users.id(2), - }); + const banned = await createBannedUser("Test ban"); const result = await AdminRepository.findAllBannedUsers(); expect(result.size).toBe(1); - expect(result.get(users.id(1))).toBeDefined(); - expect(result.get(users.id(1))?.userId).toBe(users.id(1)); - expect(result.get(users.id(1))?.banned).toBe(1); - expect(result.get(users.id(1))?.bannedReason).toBe("Test ban"); + expect(result.get(banned.id)).toBeDefined(); + expect(result.get(banned.id)?.userId).toBe(banned.id); + expect(result.get(banned.id)?.banned).toBe(1); + expect(result.get(banned.id)?.bannedReason).toBe("Test ban"); }); test("returns Map with multiple banned users", async () => { - await AdminRepository.banUser({ - userId: users.id(1), - banned: 1, - bannedReason: "Reason 1", - bannedByUserId: users.id(3), - }); - await AdminRepository.banUser({ - userId: users.id(2), - banned: 1, - bannedReason: "Reason 2", - bannedByUserId: users.id(3), - }); + const first = await createBannedUser("Reason 1"); + const second = await createBannedUser("Reason 2"); const result = await AdminRepository.findAllBannedUsers(); expect(result.size).toBe(2); - expect(result.get(users.id(1))?.userId).toBe(users.id(1)); - expect(result.get(users.id(2))?.userId).toBe(users.id(2)); + expect(result.get(first.id)?.userId).toBe(first.id); + expect(result.get(second.id)?.userId).toBe(second.id); }); test("excludes non-banned users from results", async () => { - await AdminRepository.banUser({ - userId: users.id(1), - banned: 1, - bannedReason: "Test ban", - bannedByUserId: users.id(2), - }); + const banned = await createBannedUser("Test ban"); + const [other, another] = await UserFactory.createMany(2); const result = await AdminRepository.findAllBannedUsers(); expect(result.size).toBe(1); - expect(result.get(users.id(1))).toBeDefined(); - expect(result.get(users.id(2))).toBeUndefined(); - expect(result.get(users.id(3))).toBeUndefined(); + expect(result.get(banned.id)).toBeDefined(); + expect(result.get(other.id)).toBeUndefined(); + expect(result.get(another.id)).toBeUndefined(); }); test("includes both permanently and temporarily banned users", async () => { const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); - await AdminRepository.banUser({ - userId: users.id(1), - banned: 1, - bannedReason: "Permanent ban", - bannedByUserId: users.id(3), - }); - await AdminRepository.banUser({ - userId: users.id(2), - banned: futureDate, - bannedReason: "Temporary ban", - bannedByUserId: users.id(3), - }); + const permanent = await createBannedUser("Permanent ban"); + const temporary = await createBannedUser("Temporary ban", futureDate); const result = await AdminRepository.findAllBannedUsers(); expect(result.size).toBe(2); - expect(result.get(users.id(1))?.banned).toBe(1); - expect(result.get(users.id(2))?.banned).toBeGreaterThan(1); + expect(result.get(permanent.id)?.banned).toBe(1); + expect(result.get(temporary.id)?.banned).toBeGreaterThan(1); }); }); @@ -211,24 +191,29 @@ describe("banUser", () => { }); describe("unbanUser", () => { + let banner: { id: number }; + let unbanner: { id: number }; + + const createBannedUser = (bannedReason: string, banned: 1 | Date = 1) => + UserFactory.create(null, { + ban: { banned, bannedReason, bannedByUserId: banner.id }, + }); + beforeEach(async () => { - await createUsers(3); + // the unban log records who unbanned by their Discord id + banner = await UserFactory.create({ discordId: "1" }); + unbanner = await UserFactory.create({ discordId: "2" }); }); test("unbans a previously banned user", async () => { - await AdminRepository.banUser({ - userId: users.id(1), - banned: 1, - bannedReason: "Test ban", - bannedByUserId: users.id(2), - }); + const banned = await createBannedUser("Test ban"); let result = await AdminRepository.findAllBannedUsers(); expect(result.size).toBe(1); await AdminRepository.unbanUser({ - userId: users.id(1), - unbannedByUserId: users.id(2), + userId: banned.id, + unbannedByUserId: unbanner.id, }); result = await AdminRepository.findAllBannedUsers(); @@ -236,19 +221,14 @@ describe("unbanUser", () => { }); test("creates BanLog entry with correct unbannedByUserId", async () => { - await AdminRepository.banUser({ - userId: users.id(1), - banned: 1, - bannedReason: "Test ban", - bannedByUserId: users.id(2), - }); + const banned = await createBannedUser("Test ban"); await AdminRepository.unbanUser({ - userId: users.id(1), - unbannedByUserId: users.id(3), + userId: banned.id, + unbannedByUserId: unbanner.id, }); - const modInfo = await UserRepository.findModInfoById(users.id(1)); + const modInfo = await UserRepository.findModInfoById(banned.id); expect(modInfo?.banLogs).toHaveLength(2); @@ -259,16 +239,11 @@ describe("unbanUser", () => { }); test("can unban permanently banned user", async () => { - await AdminRepository.banUser({ - userId: users.id(1), - banned: 1, - bannedReason: "Permanent ban", - bannedByUserId: users.id(2), - }); + const banned = await createBannedUser("Permanent ban"); await AdminRepository.unbanUser({ - userId: users.id(1), - unbannedByUserId: users.id(2), + userId: banned.id, + unbannedByUserId: unbanner.id, }); const result = await AdminRepository.findAllBannedUsers(); @@ -279,16 +254,11 @@ describe("unbanUser", () => { test("can unban temporarily banned user", async () => { const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); - await AdminRepository.banUser({ - userId: users.id(1), - banned: futureDate, - bannedReason: "Temporary ban", - bannedByUserId: users.id(2), - }); + const banned = await createBannedUser("Temporary ban", futureDate); await AdminRepository.unbanUser({ - userId: users.id(1), - unbannedByUserId: users.id(2), + userId: banned.id, + unbannedByUserId: unbanner.id, }); const result = await AdminRepository.findAllBannedUsers(); diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts index fc76c655d..dbbf0e16d 100644 --- a/app/features/admin/routes/admin.test.ts +++ b/app/features/admin/routes/admin.test.ts @@ -9,7 +9,7 @@ import * as BuildRepository from "~/features/builds/BuildRepository.server"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; import * as TeamRepository from "~/features/team/TeamRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { assertResponseErrored, withUserId, wrappedAction } from "~/utils/Test"; +import { assertResponseErrored, wrappedAction } from "~/utils/Test"; import type { adminActionSchema } from "../actions/admin.server"; import { action } from "./admin"; @@ -285,8 +285,8 @@ describe("Account migration", () => { }); it("two accounts with teams results in an error", async () => { - await TeamFactory.create({ ownerUserId: users.id(1) }); - await TeamFactory.create({ ownerUserId: users.id(2) }); + await TeamFactory.create({ memberUserIds: [users.id(1)] }); + await TeamFactory.create({ memberUserIds: [users.id(2)] }); const response = await migrateUserAction(); @@ -301,7 +301,7 @@ describe("Account migration", () => { .executeTakeFirst(); it("deletes past team membership status of the new user", async () => { - const team = await TeamFactory.create({ ownerUserId: users.id(2) }); + const team = await TeamFactory.create({ memberUserIds: [users.id(2)] }); await TeamRepository.deleteById(team.id); const membershipBeforeMigration = await membershipOf(users.id(2)); @@ -316,8 +316,7 @@ describe("Account migration", () => { it("handles old user member of the same team as new user (old user has left the team, new user current)", async () => { const team = await TeamFactory.create({ - ownerUserId: users.id(2), - additionalMemberUserIds: [users.id(1)], + memberUserIds: [users.id(2), users.id(1)], }); await TeamRepository.handleMemberLeaving({ teamId: team.id, @@ -339,16 +338,10 @@ describe("Account migration", () => { }); it("deletes weapon pool from the new user when migrating (takes weapon pool from the old user)", async () => { - await withUserId(users.id(1), () => - UserRepository.updateOwnProfile({ - weapons: [{ weaponSplId: 1, isFavorite: 1 }], - }), - ); - await withUserId(users.id(2), () => - UserRepository.updateOwnProfile({ - weapons: [{ weaponSplId: 10 }], - }), - ); + await UserFactory.grant(users.id(1), { + weapons: [{ weaponSplId: 1, isFavorite: 1 }], + }); + await UserFactory.grant(users.id(2), { weapons: [{ weaponSplId: 10 }] }); await migrateUserAction(); diff --git a/app/features/api/ApiRepository.server.test.ts b/app/features/api/ApiRepository.server.test.ts index d3e499f6e..de5da2568 100644 --- a/app/features/api/ApiRepository.server.test.ts +++ b/app/features/api/ApiRepository.server.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from "vitest"; +import * as ApiTokenFactory from "~/db/seed/factories/ApiTokenFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as ApiRepository from "./ApiRepository.server"; @@ -16,7 +17,7 @@ describe("findTokenByUserId", () => { }); test("finds existing token for user", async () => { - await ApiRepository.generateToken(users.id(1), "read"); + await ApiTokenFactory.create({ userId: users.id(1), type: "read" }); const result = await ApiRepository.findTokenByUserId(users.id(1), "read"); @@ -26,8 +27,14 @@ describe("findTokenByUserId", () => { }); test("returns correct token for specific user", async () => { - const token1 = await ApiRepository.generateToken(users.id(1), "read"); - const token2 = await ApiRepository.generateToken(users.id(2), "read"); + const token1 = await ApiTokenFactory.create({ + userId: users.id(1), + type: "read", + }); + const token2 = await ApiTokenFactory.create({ + userId: users.id(2), + type: "read", + }); const result1 = await ApiRepository.findTokenByUserId(users.id(1), "read"); const result2 = await ApiRepository.findTokenByUserId(users.id(2), "read"); @@ -38,8 +45,8 @@ describe("findTokenByUserId", () => { }); test("finds correct token by type", async () => { - await ApiRepository.generateToken(users.id(1), "read"); - await ApiRepository.generateToken(users.id(1), "write"); + await ApiTokenFactory.create({ userId: users.id(1), type: "read" }); + await ApiTokenFactory.create({ userId: users.id(1), type: "write" }); const readResult = await ApiRepository.findTokenByUserId( users.id(1), @@ -143,7 +150,7 @@ describe("findAllApiTokens", () => { }); test("returns array of token objects with type", async () => { - await ApiRepository.generateToken(users.id(1), "read"); + await ApiTokenFactory.create({ userId: users.id(1), type: "read" }); const result = await ApiRepository.findAllApiTokens(); diff --git a/app/features/auth/LogInLinkRepository.server.test.ts b/app/features/auth/LogInLinkRepository.server.test.ts index 2e2e4d7fd..3969564b4 100644 --- a/app/features/auth/LogInLinkRepository.server.test.ts +++ b/app/features/auth/LogInLinkRepository.server.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from "vitest"; +import * as LogInLinkFactory from "~/db/seed/factories/LogInLinkFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as LogInLinkRepository from "./LogInLinkRepository.server"; @@ -31,7 +32,7 @@ describe("del", () => { }); test("deletes a login link by code", async () => { - const link = await LogInLinkRepository.insert(userId); + const link = await LogInLinkFactory.create({ userId }); await LogInLinkRepository.deleteByCode(link.code); @@ -48,7 +49,7 @@ describe("findValidByCode", () => { }); test("returns userId for valid code", async () => { - const link = await LogInLinkRepository.insert(userId); + const link = await LogInLinkFactory.create({ userId }); const result = await LogInLinkRepository.findValidByCode(link.code); diff --git a/app/features/badges/BadgeRepository.server.test.ts b/app/features/badges/BadgeRepository.server.test.ts index 5f9e2ae37..591597641 100644 --- a/app/features/badges/BadgeRepository.server.test.ts +++ b/app/features/badges/BadgeRepository.server.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, test } from "vitest"; import * as BadgeFactory from "~/db/seed/factories/BadgeFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory"; -import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server"; import * as BadgeRepository from "./BadgeRepository.server"; import { SPLATOON_3_XP_BADGE_VALUES } from "./badges-constants"; @@ -50,15 +49,11 @@ describe("syncXPBadges", () => { }); /** Gives the user a linked X Rank player whose one placement is worth `power`. */ -async function givePeakXp(userId: number, power: number) { - await XRankPlacementFactory.create({ - playerSplId: `player-${userId}`, - playerUserId: userId, - power, - }); - - await XRankPlacementRepository.refreshAllPeakXp(); -} +const givePeakXp = (userId: number, power: number) => + XRankPlacementFactory.create( + { playerUserId: userId, power }, + { refreshPeakXp: true }, + ); async function findBadgeByCode(code: string) { const badges = await BadgeRepository.findAll(); diff --git a/app/features/builds/BuildRepository.server.test.ts b/app/features/builds/BuildRepository.server.test.ts index 319d57dc2..d9636f472 100644 --- a/app/features/builds/BuildRepository.server.test.ts +++ b/app/features/builds/BuildRepository.server.test.ts @@ -59,12 +59,7 @@ const createBuild = ( /** Puts the user in the top 500 with the given weapon, which builds sort by. */ const makeTop500 = (userId: number, weaponSplId: MainWeaponId) => - XRankPlacementFactory.create({ - playerSplId: `player-${userId}`, - playerUserId: userId, - weaponSplId, - rank: 1, - }); + XRankPlacementFactory.create({ playerUserId: userId, weaponSplId, rank: 1 }); const buildById = (id: number) => db diff --git a/app/features/leaderboards/LeaderboardRepository.server.test.ts b/app/features/leaderboards/LeaderboardRepository.server.test.ts index c45a863dd..3daa4703c 100644 --- a/app/features/leaderboards/LeaderboardRepository.server.test.ts +++ b/app/features/leaderboards/LeaderboardRepository.server.test.ts @@ -6,13 +6,10 @@ vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ setMetadata: vi.fn(), })); -import { backdate } from "~/db/seed/core/backdate"; -import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as SQReportedWeaponFactory from "~/db/seed/factories/SQReportedWeaponFactory"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentReportedWeaponFactory from "~/db/seed/factories/TournamentReportedWeaponFactory"; -import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as Seasons from "~/features/mmr/core/Seasons"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; @@ -32,29 +29,17 @@ let otherPlayer: { id: number }; /** The other players of the SendouQ groups the two report their weapons in. */ let groupFillers: Array<{ id: number }>; -const createSendouqMatch = async (createdAt: Date) => { - const alpha = await createGroup([ - player, - otherPlayer, - ...groupFillers.slice(0, 2), - ]); - const bravo = await createGroup(groupFillers.slice(2)); - +const createSendouqMatch = (createdAt: Date) => // played out so that the groups go inactive and the same users can queue again - const match = await SQMatchFactory.create( - { alphaGroupId: alpha.id, bravoGroupId: bravo.id }, - { isConcluded: true }, + SQMatchFactory.create( + { + alphaUserIds: [player, otherPlayer, ...groupFillers.slice(0, 2)].map( + (user) => user.id, + ), + bravoUserIds: groupFillers.slice(2).map((user) => user.id), + }, + { isConcluded: true, createdAt }, ); - await backdate("GroupMatch", match.id, { createdAt }); - - return match; -}; - -const createGroup = ([owner, ...members]: Array<{ id: number }>) => - SQGroupFactory.create({ - userId: owner.id, - additionalMemberUserIds: members.map((member) => member.id), - }); /** A played tournament match, its two teams being the reporter and somebody else. */ const createTournamentMatch = async ({ @@ -64,23 +49,12 @@ const createTournamentMatch = async ({ authorId: number; isFinalized: boolean; }) => { - const tournament = await TournamentFactory.create( + const { matches } = await TournamentFactory.createPlayed( { authorId, minMembersPerTeam: 1 }, - { isFinalized }, + { isFinalized, teamRosters: [[authorId], [groupFillers[1].id]] }, ); - await TournamentTeamFactory.createMany( - 2, - (index) => ({ - tournamentId: tournament.id, - userId: index === 0 ? authorId : groupFillers[index].id, - }), - { isCheckedIn: true }, - ); - await TournamentFactory.startBracket(tournament.id); - const [match] = await TournamentFactory.playMatches(tournament.id); - - return match; + return matches[0]; }; const reportSendouqWeapons = async (args: { diff --git a/app/features/scrims/ScrimPostRepository.server.test.ts b/app/features/scrims/ScrimPostRepository.server.test.ts index 126a3d84e..bea52b510 100644 --- a/app/features/scrims/ScrimPostRepository.server.test.ts +++ b/app/features/scrims/ScrimPostRepository.server.test.ts @@ -24,7 +24,7 @@ describe("findPendingOverlapsForUsers", () => { }); test("returns a specific-time pending post in window with its member ids", async () => { - const postId = await ScrimPostFactory.create({ + const { id: postId } = await ScrimPostFactory.create({ startsAt: dbTs(BOOKED_AT), users: [ { userId: users.id(1), isOwner: 1 }, @@ -46,7 +46,7 @@ describe("findPendingOverlapsForUsers", () => { }); test("returns a ranged post whose interval overlaps the window even if its start is outside", async () => { - const postId = await ScrimPostFactory.create({ + const { id: postId } = await ScrimPostFactory.create({ startsAt: dbTs(sub(BOOKED_AT, { hours: 2 })), rangeEndsAt: dbTs(BOOKED_AT), users: [{ userId: users.id(1), isOwner: 1 }], @@ -78,7 +78,7 @@ describe("findPendingOverlapsForUsers", () => { }); test("excludes the just-booked post even when it overlaps", async () => { - const postId = await ScrimPostFactory.create({ + const { id: postId } = await ScrimPostFactory.create({ startsAt: dbTs(BOOKED_AT), users: [{ userId: users.id(1), isOwner: 1 }], }); @@ -136,7 +136,7 @@ describe("findPendingOverlapsForUsers", () => { }); test("returns pending request ids whose effective time falls in the window", async () => { - const postId = await ScrimPostFactory.create( + const { id: postId } = await ScrimPostFactory.create( { startsAt: dbTs(add(BOOKED_AT, { hours: 3 })), users: [{ userId: users.id(3), isOwner: 1 }], @@ -274,11 +274,11 @@ describe("insertRequest", () => { }); test("throws if the team already has a request for the post", async () => { - const postId = await ScrimPostFactory.create({ + const { id: postId } = await ScrimPostFactory.create({ startsAt: dbTs(BOOKED_AT), users: [{ userId: users.id(1), isOwner: 1 }], }); - const team = await TeamFactory.create({ ownerUserId: users.id(2) }); + const team = await TeamFactory.create({ memberUserIds: [users.id(2)] }); await insertTeamRequest({ scrimPostId: postId, @@ -299,15 +299,15 @@ describe("insertRequest", () => { }); test("allows the team to request another post", async () => { - const postId = await ScrimPostFactory.create({ + const { id: postId } = await ScrimPostFactory.create({ startsAt: dbTs(BOOKED_AT), users: [{ userId: users.id(1), isOwner: 1 }], }); - const otherPostId = await ScrimPostFactory.create({ + const { id: otherPostId } = await ScrimPostFactory.create({ startsAt: dbTs(BOOKED_AT), users: [{ userId: users.id(4), isOwner: 1 }], }); - const team = await TeamFactory.create({ ownerUserId: users.id(2) }); + const team = await TeamFactory.create({ memberUserIds: [users.id(2)] }); await insertTeamRequest({ scrimPostId: postId, diff --git a/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts index 3b97bb638..c7785fdb6 100644 --- a/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts +++ b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import * as GroupMatchContinueVoteFactory from "~/db/seed/factories/GroupMatchContinueVoteFactory"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { withUserId } from "~/utils/Test"; @@ -6,7 +7,7 @@ import * as GroupMatchContinueVoteRepository from "./GroupMatchContinueVoteRepos const createGroup = async () => { const owner = await UserFactory.create(); - const group = await SQGroupFactory.create({ userId: owner.id }); + const group = await SQGroupFactory.create({ memberUserIds: [owner.id] }); return group.id; }; @@ -15,9 +16,7 @@ const fetchVotes = (groupId: number) => GroupMatchContinueVoteRepository.findAllByGroupIds([groupId]); const castVote = (userId: number, groupId: number, isContinuing: boolean) => - withUserId(userId, () => - GroupMatchContinueVoteRepository.castOwnVote({ groupId, isContinuing }), - ); + GroupMatchContinueVoteFactory.create({ userId, groupId, isContinuing }); describe("findAllByGroupIds", () => { test("returns empty array without querying when no group ids given", async () => { @@ -49,12 +48,18 @@ describe("findAllByGroupIds", () => { }); describe("cast", () => { + /** The subject of this block, so it goes through the repository directly. */ + const cast = (userId: number, groupId: number, isContinuing: boolean) => + withUserId(userId, () => + GroupMatchContinueVoteRepository.castOwnVote({ groupId, isContinuing }), + ); + test("updates existing vote on conflict instead of inserting a duplicate", async () => { const voter = await UserFactory.create(); const groupId = await createGroup(); - await castVote(voter.id, groupId, true); - await castVote(voter.id, groupId, false); + await cast(voter.id, groupId, true); + await cast(voter.id, groupId, false); const votes = await fetchVotes(groupId); expect(votes).toHaveLength(1); @@ -66,11 +71,11 @@ describe("cast", () => { const groupA = await createGroup(); const groupB = await createGroup(); - await castVote(voters[0].id, groupA, true); - await castVote(voters[1].id, groupA, true); - await castVote(voters[0].id, groupB, true); + await cast(voters[0].id, groupA, true); + await cast(voters[1].id, groupA, true); + await cast(voters[0].id, groupB, true); - await castVote(voters[2].id, groupA, false); + await cast(voters[2].id, groupA, false); const groupAVotes = await fetchVotes(groupA); expect(groupAVotes).toHaveLength(1); diff --git a/app/features/sendouq-match/SQMatchRepository.server.test.ts b/app/features/sendouq-match/SQMatchRepository.server.test.ts index 0281def91..020b05257 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.test.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, test } from "vitest"; -import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; @@ -12,29 +11,20 @@ const setupMatch = async () => { const alphaMembers = users.slice(0, FULL_GROUP_SIZE); const bravoMembers = users.slice(FULL_GROUP_SIZE); - const alphaGroup = await createGroup(alphaMembers); - const bravoGroup = await createGroup(bravoMembers); - const match = await SQMatchFactory.create({ - alphaGroupId: alphaGroup.id, - bravoGroupId: bravoGroup.id, + alphaUserIds: alphaMembers.map((member) => member.id), + bravoUserIds: bravoMembers.map((member) => member.id), }); return { match, - alphaGroupId: alphaGroup.id, - bravoGroupId: bravoGroup.id, + alphaGroupId: match.alphaGroup.id, + bravoGroupId: match.bravoGroup.id, alphaMembers, bravoMembers, }; }; -const createGroup = ([owner, ...members]: Array<{ id: number }>) => - SQGroupFactory.create({ - userId: owner.id, - additionalMemberUserIds: members.map((member) => member.id), - }); - const fetchMapResults = async (matchId: number) => { return db .selectFrom("GroupMatchMap") diff --git a/app/features/sendouq/PrivateUserNoteRepository.server.test.ts b/app/features/sendouq/PrivateUserNoteRepository.server.test.ts index 62739345c..7d3e63063 100644 --- a/app/features/sendouq/PrivateUserNoteRepository.server.test.ts +++ b/app/features/sendouq/PrivateUserNoteRepository.server.test.ts @@ -1,26 +1,20 @@ import { describe, expect, test } from "vitest"; import * as UserFactory from "~/db/seed/factories/UserFactory"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { withUser } from "~/utils/Test"; +import { withUserId } from "~/utils/Test"; import * as PrivateUserNoteRepository from "./PrivateUserNoteRepository.server"; const authorAndTarget = async () => { - const { id: authorId } = await UserFactory.create(); - const target = await UserFactory.create(); + const [author, target] = await UserFactory.createMany(2); - return { - // xxx: why needed, is create not returning it? - author: (await UserRepository.findLeanById(authorId))!, - targetId: target.id, - }; + return { authorId: author.id, targetId: target.id }; }; describe("PrivateUserNoteRepository", () => { describe("upsertOwnNote", () => { test("stamps the acting user as the author", async () => { - const { author, targetId } = await authorAndTarget(); + const { authorId, targetId } = await authorAndTarget(); - await withUser(author, () => + await withUserId(authorId, () => PrivateUserNoteRepository.upsertOwnNote({ targetId, sentiment: "POSITIVE", @@ -28,7 +22,7 @@ describe("PrivateUserNoteRepository", () => { }), ); - const notes = await withUser(author, () => + const notes = await withUserId(authorId, () => PrivateUserNoteRepository.findAllOwn(), ); @@ -41,16 +35,16 @@ describe("PrivateUserNoteRepository", () => { }); test("updates an existing note on conflict", async () => { - const { author, targetId } = await authorAndTarget(); + const { authorId, targetId } = await authorAndTarget(); - await withUser(author, () => + await withUserId(authorId, () => PrivateUserNoteRepository.upsertOwnNote({ targetId, sentiment: "POSITIVE", text: "first", }), ); - await withUser(author, () => + await withUserId(authorId, () => PrivateUserNoteRepository.upsertOwnNote({ targetId, sentiment: "NEGATIVE", @@ -58,7 +52,7 @@ describe("PrivateUserNoteRepository", () => { }), ); - const notes = await withUser(author, () => + const notes = await withUserId(authorId, () => PrivateUserNoteRepository.findAllOwn(), ); @@ -70,20 +64,20 @@ describe("PrivateUserNoteRepository", () => { describe("deleteOwnNote", () => { test("deletes the acting user's note", async () => { - const { author, targetId } = await authorAndTarget(); + const { authorId, targetId } = await authorAndTarget(); - await withUser(author, () => + await withUserId(authorId, () => PrivateUserNoteRepository.upsertOwnNote({ targetId, sentiment: "NEUTRAL", text: "note", }), ); - await withUser(author, () => + await withUserId(authorId, () => PrivateUserNoteRepository.deleteOwnNoteById(targetId), ); - const notes = await withUser(author, () => + const notes = await withUserId(authorId, () => PrivateUserNoteRepository.findAllOwn(), ); diff --git a/app/features/sendouq/SQGroupRepository.server.test.ts b/app/features/sendouq/SQGroupRepository.server.test.ts index fb437d436..bf088cdaf 100644 --- a/app/features/sendouq/SQGroupRepository.server.test.ts +++ b/app/features/sendouq/SQGroupRepository.server.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "vitest"; +import * as GroupMatchContinueVoteFactory from "~/db/seed/factories/GroupMatchContinueVoteFactory"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server"; -import { withUserId } from "~/utils/Test"; import { FULL_GROUP_SIZE } from "./q-constants"; import * as SQGroupRepository from "./SQGroupRepository.server"; @@ -11,41 +11,28 @@ const setupConcludedMatch = async () => { const users = await UserFactory.createMany(FULL_GROUP_SIZE * 2); const alphaMembers = users.slice(0, FULL_GROUP_SIZE); - const alphaGroup = await createMatchmadeGroup(alphaMembers); - const bravoGroup = await createMatchmadeGroup(users.slice(FULL_GROUP_SIZE)); - const match = await SQMatchFactory.create( - { alphaGroupId: alphaGroup.id, bravoGroupId: bravoGroup.id }, + { + alphaUserIds: alphaMembers.map((member) => member.id), + bravoUserIds: users.slice(FULL_GROUP_SIZE).map((member) => member.id), + isMatchmade: true, + }, { isConcluded: true }, ); return { - alphaGroupId: alphaGroup.id, - bravoGroupId: bravoGroup.id, + alphaGroupId: match.alphaGroup.id, + bravoGroupId: match.bravoGroup.id, matchChatCode: match.chatCode, alphaMembers, }; }; -const createMatchmadeGroup = ([owner, ...members]: Array<{ id: number }>) => - SQGroupFactory.create( - { - userId: owner.id, - additionalMemberUserIds: members.map((member) => member.id), - }, - { isMatchmade: true }, - ); - const fetchVotes = (groupId: number) => GroupMatchContinueVoteRepository.findAllByGroupIds([groupId]); const castYesVote = (userId: number, groupId: number) => - withUserId(userId, () => - GroupMatchContinueVoteRepository.castOwnVote({ - groupId, - isContinuing: true, - }), - ); + GroupMatchContinueVoteFactory.create({ userId, groupId }); describe("insert", () => { test("records implicit no-vote on previous matchmade group when user creates a new group", async () => { @@ -126,7 +113,7 @@ describe("insertMember", () => { const newGroup = await SQGroupFactory.create({ status: "PREPARING", - userId: newOwner.id, + memberUserIds: [newOwner.id], }); const { chatCodeToRevalidate } = await SQGroupRepository.insertMember( diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts index e070c9eb4..6486d5199 100644 --- a/app/features/sendouq/core/SendouQ.server.test.ts +++ b/app/features/sendouq/core/SendouQ.server.test.ts @@ -2,6 +2,7 @@ import { subSeconds } from "date-fns"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { backdate } from "~/db/seed/core/backdate"; import * as SkillFactory from "~/db/seed/factories/SkillFactory"; +import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; @@ -24,28 +25,21 @@ vi.mock("~/features/mmr/core/Seasons", () => ({ /** Users are interchangeable here, so tests name them by 1-based position. */ const users = UserFactory.pool(); +const userIds = (positions: number[]) => + positions.map((position) => users.id(position)); + const createGroup = async ( memberPositions: number[], options: { status?: "PREPARING" | "ACTIVE"; } = {}, ) => { - const { status = "ACTIVE" } = options; - const [ownerPosition, ...restPositions] = memberPositions; - - const groupResult = await SQGroupRepository.insert({ - status, - userId: users.id(ownerPosition), + const group = await SQGroupFactory.create({ + status: options.status ?? "ACTIVE", + memberUserIds: userIds(memberPositions), }); - for (const position of restPositions) { - await SQGroupRepository.insertMember(groupResult.id, { - userId: users.id(position), - role: "REGULAR", - }); - } - - return groupResult.id; + return group.id; }; /** @@ -95,12 +89,9 @@ describe("SendouQ", () => { }); test("returns 'match' when user in ACTIVE group with matchId", async () => { - const groupId1 = await createGroup([1, 2, 3, 4]); - const groupId2 = await createGroup([5, 6, 7, 8]); - await SQMatchFactory.create({ - alphaGroupId: groupId1, - bravoGroupId: groupId2, + alphaUserIds: userIds([1, 2, 3, 4]), + bravoUserIds: userIds([5, 6, 7, 8]), }); await refreshSendouQInstance(); @@ -415,14 +406,11 @@ describe("SendouQ", () => { test("only returns groups without matchId", async () => { await createGroup([1, 2, 3, 4]); - const matchedGroup1 = await createGroup([5, 6, 7, 8]); - const matchedGroup2 = await createGroup([9, 10, 11, 12]); - const lookingGroup = await createGroup([13, 14, 15, 16]); - await SQMatchFactory.create({ - alphaGroupId: matchedGroup1, - bravoGroupId: matchedGroup2, + alphaUserIds: userIds([5, 6, 7, 8]), + bravoUserIds: userIds([9, 10, 11, 12]), }); + const lookingGroup = await createGroup([13, 14, 15, 16]); await refreshSendouQInstance(); @@ -695,15 +683,14 @@ describe("SendouQ", () => { }); /** Leaves both groups inactive with a freshly concluded match between them. */ -async function playOutMatchBetween( +const playOutMatchBetween = ( alphaPositions: number[], bravoPositions: number[], -) { - const alphaGroupId = await createGroup(alphaPositions); - const bravoGroupId = await createGroup(bravoPositions); - - await SQMatchFactory.create( - { alphaGroupId, bravoGroupId }, +) => + SQMatchFactory.create( + { + alphaUserIds: userIds(alphaPositions), + bravoUserIds: userIds(bravoPositions), + }, { isConcluded: true }, ); -} diff --git a/app/features/sendouq/routes/q.looking.test.ts b/app/features/sendouq/routes/q.looking.test.ts index ff2f07ab9..4456a5019 100644 --- a/app/features/sendouq/routes/q.looking.test.ts +++ b/app/features/sendouq/routes/q.looking.test.ts @@ -18,7 +18,6 @@ import { withUserId, wrappedAction } from "~/utils/Test"; import { refreshSendouQInstance } from "../core/SendouQ.server"; import { FULL_GROUP_SIZE } from "../q-constants"; import type { lookingSchema } from "../q-schemas.server"; -import * as SQGroupRepository from "../SQGroupRepository.server"; import { action as rawLookingAction } from "./q.looking"; const SZ_ONLY_PREFERENCE: UserMapModePreferences["modes"] = [ @@ -29,40 +28,40 @@ const SZ_ONLY_PREFERENCE: UserMapModePreferences["modes"] = [ ]; const prepareGroups = async () => { - const owner = await UserFactory.createAdmin(); - const others = await UserFactory.createMany(FULL_GROUP_SIZE * 2 - 1); - - const ownGroup = await SQGroupFactory.create({ - userId: owner.id, - additionalMemberUserIds: others - .slice(0, FULL_GROUP_SIZE - 1) - .map((user) => user.id), + const owner = await UserFactory.createAdmin(null, { + matchProfile: { + mapModePreferences: { + modes: SZ_ONLY_PREFERENCE, + pool: [{ mode: "SZ", stages: [...stageIds].slice(0, 7) }], + }, + }, }); + const ownMembers = await UserFactory.createMany(FULL_GROUP_SIZE - 1); + + const theirOwner = await UserFactory.create(null, { + matchProfile: { + mapModePreferences: { + modes: SZ_ONLY_PREFERENCE, + pool: [ + { + mode: "SZ", + stages: [...stageIds].slice(0, 20).reverse().slice(0, 7), + }, + ], + }, + }, + }); + const theirMembers = await UserFactory.createMany(FULL_GROUP_SIZE - 1); - const [theirOwner, ...theirMembers] = others.slice(FULL_GROUP_SIZE - 1); const theirGroup = await SQGroupFactory.create({ - userId: theirOwner.id, - additionalMemberUserIds: theirMembers.map((user) => user.id), + memberUserIds: [theirOwner.id, ...theirMembers.map((user) => user.id)], }); + const ownGroup = await SQGroupFactory.create( + { memberUserIds: [owner.id, ...ownMembers.map((user) => user.id)] }, + { likedByGroupIds: [theirGroup.id] }, + ); - await SQGroupRepository.insertLike({ - likerGroupId: theirGroup.id, - targetGroupId: ownGroup.id, - }); - - await setMapModePreferences(owner.id, { - modes: SZ_ONLY_PREFERENCE, - pool: [{ mode: "SZ", stages: [...stageIds].slice(0, 7) }], - }); - - await setMapModePreferences(theirOwner.id, { - modes: SZ_ONLY_PREFERENCE, - pool: [ - { mode: "SZ", stages: [...stageIds].slice(0, 20).reverse().slice(0, 7) }, - ], - }); - - return { owner, ownGroup, theirGroup, teammate: others[0] }; + return { owner, ownGroup, theirGroup, teammate: ownMembers[0] }; }; const setMapModePreferences = ( diff --git a/app/features/team/actions/t.$customUrl.edit.server.test.ts b/app/features/team/actions/t.$customUrl.edit.server.test.ts index 76c918abc..68236a1cd 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.test.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; import { REGULAR_USER_TEST_ID } from "~/db/seed/constants"; -import * as ImageFactory from "~/db/seed/factories/ImageFactory"; import * as TeamFactory from "~/db/seed/factories/TeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as ImageRepository from "~/features/img-upload/ImageRepository.server"; @@ -45,143 +44,143 @@ const expectedStoredTheme = () => JSON.parse(JSON.stringify(clampThemeToGamut(VALID_CUSTOM_THEME))); describe("team page editing", () => { + let customUrl: string; + + const createTeam = async ( + options?: Parameters[1], + ) => { + const team = await TeamFactory.create( + { name: "Team 1", memberUserIds: [REGULAR_USER_TEST_ID] }, + options, + ); + customUrl = team.customUrl; + }; + + const teamRow = async () => { + const team = await TeamRepository.findByCustomUrl(customUrl); + invariant(team, `No team with the custom url ${customUrl}`); + + return team; + }; + beforeEach(async () => { // a patron because setting a custom theme is a patron only feature await UserFactory.createRegular(null, { patronTier: 2 }); - await TeamFactory.create({ - name: "Team 1", - ownerUserId: REGULAR_USER_TEST_ID, - }); }); - it("sets a custom theme via UPDATE_CUSTOM_THEME", async () => { - const response = await editTeamProfileAction( - { - _action: "UPDATE_CUSTOM_THEME", - newValue: VALID_CUSTOM_THEME, - }, - { user: "regular", params: { customUrl: "team-1" } }, - ); + describe("custom theme", () => { + beforeEach(() => createTeam()); - expect(response).toEqual({ ok: true }); - - const team = await TeamRepository.findByCustomUrl("team-1"); - expect(team?.customTheme).toEqual(expectedStoredTheme()); - }); - - it("clears a custom theme via UPDATE_CUSTOM_THEME with null", async () => { - await editTeamProfileAction( - { - _action: "UPDATE_CUSTOM_THEME", - newValue: VALID_CUSTOM_THEME, - }, - { user: "regular", params: { customUrl: "team-1" } }, - ); - - const response = await editTeamProfileAction( - { - _action: "UPDATE_CUSTOM_THEME", - newValue: null, - }, - { user: "regular", params: { customUrl: "team-1" } }, - ); - - expect(response).toEqual({ ok: true }); - - const team = await TeamRepository.findByCustomUrl("team-1"); - expect(team?.customTheme).toBeNull(); - }); - - it("prevents setting an invalid custom theme", async () => { - const response = await editTeamProfileAction( - { - _action: "UPDATE_CUSTOM_THEME", - newValue: { - ...VALID_CUSTOM_THEME, - baseHue: 500, // Invalid: max is 360 + it("sets a custom theme via UPDATE_CUSTOM_THEME", async () => { + const response = await editTeamProfileAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: VALID_CUSTOM_THEME, }, - }, - { user: "regular", params: { customUrl: "team-1" } }, - ); + { user: "regular", params: { customUrl } }, + ); - expect(response.fieldErrors["newValue.baseHue"]).toBeTruthy(); - }); - - it("preserves an existing custom theme when editing the team profile", async () => { - await editTeamProfileAction( - { - _action: "UPDATE_CUSTOM_THEME", - newValue: VALID_CUSTOM_THEME, - }, - { user: "regular", params: { customUrl: "team-1" } }, - ); - - const response = await editTeamProfileAction( - { ...DEFAULT_EDIT_FIELDS, bio: "Updated bio" }, - { user: "regular", params: { customUrl: "team-1" } }, - ); - - expect(response.status).toBe(302); - - const team = await TeamRepository.findByCustomUrl("team-1"); - expect(team?.customTheme).toEqual(expectedStoredTheme()); - expect(team?.bio).toBe("Updated bio"); - }); - - const addTeamAvatar = async () => { - const team = await TeamRepository.findByCustomUrl("team-1"); - invariant(team, "No team with the custom url team-1"); - - const image = await ImageFactory.create({ - submitterUserId: REGULAR_USER_TEST_ID, + expect(response).toEqual({ ok: true }); + expect((await teamRow()).customTheme).toEqual(expectedStoredTheme()); }); - await TeamRepository.update({ - id: team.id, - name: team.name, - bio: team.bio, - bsky: team.bsky, - tag: team.tag, - avatarImgId: image.id, - bannerImgId: team.bannerImgId, - }); - - return image.id; - }; - - const imageExists = async (id: number) => - Boolean(await ImageRepository.findById(id)); - - it("deletes the submitted image row when an image is removed while editing", async () => { - const imageId = await addTeamAvatar(); - - await editTeamProfileAction( - { ...DEFAULT_EDIT_FIELDS }, - { user: "regular", params: { customUrl: "team-1" } }, - ); - - const team = await TeamRepository.findByCustomUrl("team-1"); - expect(team?.avatarImgId).toBeNull(); - expect(await imageExists(imageId)).toBe(false); - }); - - it("keeps the submitted image row when an existing image is unchanged", async () => { - const imageId = await addTeamAvatar(); - - await editTeamProfileAction( - { - ...DEFAULT_EDIT_FIELDS, - logo: { - type: "EXISTING", - imgId: imageId, - url: "https://example.com/test-avatar.jpg", + it("clears a custom theme via UPDATE_CUSTOM_THEME with null", async () => { + await editTeamProfileAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: VALID_CUSTOM_THEME, }, - }, - { user: "regular", params: { customUrl: "team-1" } }, - ); + { user: "regular", params: { customUrl } }, + ); - const team = await TeamRepository.findByCustomUrl("team-1"); - expect(team?.avatarImgId).toBe(imageId); - expect(await imageExists(imageId)).toBe(true); + const response = await editTeamProfileAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: null, + }, + { user: "regular", params: { customUrl } }, + ); + + expect(response).toEqual({ ok: true }); + expect((await teamRow()).customTheme).toBeNull(); + }); + + it("prevents setting an invalid custom theme", async () => { + const response = await editTeamProfileAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: { + ...VALID_CUSTOM_THEME, + baseHue: 500, // Invalid: max is 360 + }, + }, + { user: "regular", params: { customUrl } }, + ); + + expect(response.fieldErrors["newValue.baseHue"]).toBeTruthy(); + }); + + it("preserves an existing custom theme when editing the team profile", async () => { + await editTeamProfileAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: VALID_CUSTOM_THEME, + }, + { user: "regular", params: { customUrl } }, + ); + + const response = await editTeamProfileAction( + { ...DEFAULT_EDIT_FIELDS, bio: "Updated bio" }, + { user: "regular", params: { customUrl } }, + ); + + expect(response.status).toBe(302); + + const team = await teamRow(); + expect(team.customTheme).toEqual(expectedStoredTheme()); + expect(team.bio).toBe("Updated bio"); + }); + }); + + describe("logo", () => { + let imageId: number; + + const imageExists = async (id: number) => + Boolean(await ImageRepository.findById(id)); + + beforeEach(async () => { + await createTeam({ hasAvatar: true }); + + const avatarImgId = (await teamRow()).avatarImgId; + invariant(avatarImgId, "The team was created without a logo"); + imageId = avatarImgId; + }); + + it("deletes the submitted image row when an image is removed while editing", async () => { + await editTeamProfileAction( + { ...DEFAULT_EDIT_FIELDS }, + { user: "regular", params: { customUrl } }, + ); + + expect((await teamRow()).avatarImgId).toBeNull(); + expect(await imageExists(imageId)).toBe(false); + }); + + it("keeps the submitted image row when an existing image is unchanged", async () => { + await editTeamProfileAction( + { + ...DEFAULT_EDIT_FIELDS, + logo: { + type: "EXISTING", + imgId: imageId, + url: "https://example.com/test-avatar.jpg", + }, + }, + { user: "regular", params: { customUrl } }, + ); + + expect((await teamRow()).avatarImgId).toBe(imageId); + expect(await imageExists(imageId)).toBe(true); + }); }); }); diff --git a/app/features/team/routes/t.$customUrl.edit.test.ts b/app/features/team/routes/t.$customUrl.edit.test.ts index e5f73a755..9ec2374bd 100644 --- a/app/features/team/routes/t.$customUrl.edit.test.ts +++ b/app/features/team/routes/t.$customUrl.edit.test.ts @@ -1,20 +1,23 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { REGULAR_USER_TEST_ID } from "~/db/seed/constants"; +import * as TeamFactory from "~/db/seed/factories/TeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { wrappedAction } from "~/utils/Test"; -import { action as teamIndexPageAction } from "../actions/t.new.server"; import { action as _editTeamAction } from "../routes/t.$customUrl.edit"; -import type { createTeamSchema, editTeamFormSchema } from "../team-schemas"; - -const createTeamAction = wrappedAction({ - action: teamIndexPageAction, - isJsonSubmission: true, -}); +import type { editTeamFormSchema } from "../team-schemas"; const editTeamAction = wrappedAction({ action: _editTeamAction, isJsonSubmission: true, }); +const createTeam = (name: string, isMainTeam = true) => + TeamFactory.create({ + name, + isMainTeam, + memberUserIds: [REGULAR_USER_TEST_ID], + }); + const DEFAULT_FIELDS = { tag: null, bsky: null, @@ -23,14 +26,14 @@ const DEFAULT_FIELDS = { banner: null, } as any; -describe("team creation", () => { +describe("team name editing", () => { beforeEach(async () => { await UserFactory.createRegular(); }); it("can't take another team's name via editing", async () => { - await createTeamAction({ name: "Team 1" }, { user: "regular" }); - await createTeamAction({ name: "Team 2" }, { user: "regular" }); + const team = await createTeam("Team 1"); + await createTeam("Team 2", false); const res = await editTeamAction( { @@ -38,14 +41,14 @@ describe("team creation", () => { name: "Team 2", ...DEFAULT_FIELDS, }, - { user: "regular", params: { customUrl: "team-1" } }, + { user: "regular", params: { customUrl: team.customUrl } }, ); expect(res.fieldErrors.name).toBe("forms:errors.duplicateName"); }); it("prevents editing team name to only special characters", async () => { - await createTeamAction({ name: "Team 1" }, { user: "regular" }); + const team = await createTeam("Team 1"); const res = await editTeamAction( { @@ -53,7 +56,7 @@ describe("team creation", () => { name: "𝓢𝓲𝓵", ...DEFAULT_FIELDS, }, - { user: "regular", params: { customUrl: "team-1" } }, + { user: "regular", params: { customUrl: team.customUrl } }, ); expect(res.fieldErrors.name).toBe("forms:errors.noOnlySpecialCharacters"); diff --git a/app/features/team/routes/t.$customUrl.test.ts b/app/features/team/routes/t.$customUrl.test.ts index 5eb673afd..e8fb233cf 100644 --- a/app/features/team/routes/t.$customUrl.test.ts +++ b/app/features/team/routes/t.$customUrl.test.ts @@ -34,11 +34,17 @@ const createTeamWithRegularMember = ( overrides: Partial[0]> = {}, ) => TeamFactory.create({ - ownerUserId: ADMIN_ID, - additionalMemberUserIds: [REGULAR_USER_TEST_ID], + memberUserIds: [ADMIN_ID, REGULAR_USER_TEST_ID], ...overrides, }); +const createTeamOwnedByRegular = (name: string, isMainTeam = true) => + TeamFactory.create({ + name, + isMainTeam, + memberUserIds: [REGULAR_USER_TEST_ID], + }); + describe("Secondary teams", () => { beforeEach(async () => { await UserFactory.createAdmin(); @@ -75,12 +81,12 @@ describe("Secondary teams", () => { }); it("sets main team (2 team)", async () => { - await createTeamAction({ name: "Team 1" }, { user: "regular" }); - await createTeamAction({ name: "Team 2" }, { user: "regular" }); + await createTeamOwnedByRegular("Team 1"); + const secondary = await createTeamOwnedByRegular("Team 2", false); await teamPageAction( { _action: "MAKE_MAIN_TEAM" }, - { user: "regular", params: { customUrl: "team-2" } }, + { user: "regular", params: { customUrl: secondary.customUrl } }, ); const { team } = await loadTeams(); @@ -89,8 +95,8 @@ describe("Secondary teams", () => { }); it("when deleting the main team, the secondary team becomes main", async () => { - await createTeamAction({ name: "Team 1" }, { user: "regular" }); - await createTeamAction({ name: "Team 2" }, { user: "regular" }); + const main = await createTeamOwnedByRegular("Team 1"); + await createTeamOwnedByRegular("Team 2", false); await teamPageAction( { @@ -98,7 +104,7 @@ describe("Secondary teams", () => { }, { user: "regular", - params: { customUrl: "team-1" }, + params: { customUrl: main.customUrl }, }, ); @@ -109,22 +115,21 @@ describe("Secondary teams", () => { }); it("only the team owner (or admin) can delete a team", async () => { - await createTeamWithRegularMember({ name: "Team 1" }); + const { customUrl } = await createTeamWithRegularMember({ name: "Team 1" }); const response = await teamPageAction( { _action: "DELETE_TEAM" }, - { user: "regular", params: { customUrl: "team-1" } }, + { user: "regular", params: { customUrl } }, ); assertResponseErrored(response); - const team = await TeamRepository.findByCustomUrl("team-1"); - expect(team).toBeTruthy(); + expect(await TeamRepository.findByCustomUrl(customUrl)).toBeTruthy(); }); it("when leaving the main team, the secondary team becomes main", async () => { // owned by the admin because you can't leave a team you own - await createTeamWithRegularMember({ name: "Team 1" }); + const main = await createTeamWithRegularMember({ name: "Team 1" }); await createTeamWithRegularMember({ name: "Team 2", isMainTeam: false }); const { team, secondaryTeams } = await loadTeams(); @@ -138,7 +143,7 @@ describe("Secondary teams", () => { }, { user: "regular", - params: { customUrl: "team-1" }, + params: { customUrl: main.customUrl }, }, ); diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts index 19234b6ed..3bdf325cf 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test } from "vitest"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentLFGTeamFactory from "~/db/seed/factories/TournamentLFGTeamFactory"; import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; @@ -12,17 +13,7 @@ const createTournament = () => 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, - }); + TournamentLFGTeamFactory.create({ tournamentId, userId }); describe("insertPlaceholderTeam", () => { beforeEach(async () => { @@ -31,7 +22,10 @@ describe("insertPlaceholderTeam", () => { test("creates a placeholder team with owner member", async () => { const tournament = await createTournament(); - const team = await createPlaceholder(tournament.id, users.id(1)); + const team = await TournamentLFGRepository.insertPlaceholderTeam({ + tournamentId: tournament.id, + userId: users.id(1), + }); const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( tournament.id, @@ -43,7 +37,10 @@ describe("insertPlaceholderTeam", () => { test("owner has OWNER role", async () => { const tournament = await createTournament(); - await createPlaceholder(tournament.id, users.id(1)); + await TournamentLFGRepository.insertPlaceholderTeam({ + tournamentId: tournament.id, + userId: users.id(1), + }); const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( tournament.id, @@ -215,10 +212,10 @@ describe("startLooking", () => { test("generates chatCode for a 2+ member team", async () => { const tournament = await createTournament(); - const team = await createRegisteredTeam(tournament.id, [ - users.id(1), - users.id(2), - ]); + const team = await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [users.id(1), users.id(2)], + }); const pickup = await TournamentLFGRepository.startLooking(team.id); @@ -238,7 +235,10 @@ describe("startLooking", () => { test("returns null when team has only 1 member", async () => { const tournament = await createTournament(); - const team = await createRegisteredTeam(tournament.id, [users.id(1)]); + const team = await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [users.id(1)], + }); const pickup = await TournamentLFGRepository.startLooking(team.id); @@ -254,10 +254,10 @@ describe("startLooking", () => { test("reuses existing chatCode if already set", async () => { const tournament = await createTournament(); - const team = await createRegisteredTeam(tournament.id, [ - users.id(1), - users.id(2), - ]); + const team = await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [users.id(1), users.id(2)], + }); // the only production write of the column is `startLooking` itself, which // invents a random code // biome-ignore lint/plugin: no production write sets a known chatCode @@ -495,12 +495,13 @@ describe("updateMemberRole", () => { test("changes role from REGULAR to MANAGER", async () => { const tournament = await createTournament(); - const team = await TournamentTeamFactory.create({ - tournamentId: tournament.id, - userId: users.id(1), - additionalMemberUserIds: [users.id(2)], - }); - await TournamentLFGRepository.startLooking(team.id); + const team = await TournamentTeamFactory.create( + { + tournamentId: tournament.id, + memberUserIds: [users.id(1), users.id(2)], + }, + { isLooking: true }, + ); await TournamentLFGRepository.updateMemberRole({ userId: users.id(2), @@ -587,8 +588,10 @@ describe("leaveLfg", () => { test("sets isLooking=0 for non-placeholder team", async () => { const tournament = await createTournament(); - const team = await createRegisteredTeam(tournament.id, [users.id(1)]); - await TournamentLFGRepository.startLooking(team.id); + const team = await TournamentTeamFactory.create( + { tournamentId: tournament.id, memberUserIds: [users.id(1)] }, + { isLooking: true }, + ); await TournamentLFGRepository.leaveLfg({ userId: users.id(1), @@ -618,7 +621,7 @@ describe("findAllSubsByTournamentId", () => { test("returns userIds with isStayAsSub", async () => { const tournament = await createTournament(); - await TournamentLFGRepository.insertPlaceholderTeam({ + await TournamentLFGTeamFactory.create({ tournamentId: tournament.id, userId: users.id(1), isStayAsSub: true, diff --git a/app/features/tournament-match/TournamentMatchRepository.server.test.ts b/app/features/tournament-match/TournamentMatchRepository.server.test.ts index 022c04b08..d29b9cfcc 100644 --- a/app/features/tournament-match/TournamentMatchRepository.server.test.ts +++ b/app/features/tournament-match/TournamentMatchRepository.server.test.ts @@ -1,6 +1,5 @@ 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 type { TournamentSettings } from "~/db/tables-json"; import * as TournamentMatchRepository from "./TournamentMatchRepository.server"; @@ -36,19 +35,15 @@ describe("findByTournamentTeamId", () => { test("preserves stage order: matches from an earlier stage come first even when later stage has lower group numbers", async () => { // the pools stage numbers its groups 1..2 while the final is group 1 of its // own stage, so the team page has to order by stage before group - const tournament = await TournamentFactory.create({ - authorId: users.id(1), - bracketProgression: POOLS_TO_FINAL, - minMembersPerTeam: 1, - }); - await TournamentTeamFactory.createMany( - TEAM_COUNT, - (index) => ({ tournamentId: tournament.id, userId: users.id(index + 1) }), - { isCheckedIn: true }, + const tournament = await TournamentFactory.createPlayed( + { + authorId: users.id(1), + bracketProgression: POOLS_TO_FINAL, + minMembersPerTeam: 1, + }, + { teamRosters: users.ids(TEAM_COUNT).map((userId) => [userId]) }, ); - - await TournamentFactory.startBracket(tournament.id); - const poolMatches = await TournamentFactory.playMatches(tournament.id); + const poolMatches = tournament.matches; await TournamentFactory.startBracket(tournament.id, { bracketIdx: 1 }); const [finalMatch] = await TournamentFactory.playMatches(tournament.id); 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 75abe72e1..1292fa057 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 @@ -36,15 +36,28 @@ const tournamentMatchLoader = wrappedLoader>({ loader, }); -const loadMatchData = () => - tournamentMatchLoader({ - params: { id: "1", mid: "1" }, - }); +const ROSTER_SIZE = 4; + +/** Everybody but the organizer, who is created apart from them for their pinned id. */ +const users = UserFactory.pool(); + +let tournamentId: number; +let matchId: number; +let teamOne: { id: number }; +/** Organizes the tournament and plays on team one. Who the actions submit as. */ +let organizerId: number; + +const matchParams = () => ({ id: String(tournamentId), mid: String(matchId) }); + +/** Team one's first four members, the ones it fields when it has to pick. */ +const activeRoster = () => [organizerId, ...users.ids(ROSTER_SIZE - 1)]; + +const loadMatchData = () => tournamentMatchLoader({ params: matchParams() }); const reportScoreAction = ({ position, - params = { id: "1", mid: "1" }, - winnerTeamId = 1, + params = matchParams(), + winnerTeamId = teamOne.id, }: { position: number; params?: { id: string; mid: string }; @@ -59,14 +72,14 @@ const reportScoreAction = ({ { user: "admin", params }, ); -const setActiveRosterAction = (teamId = 1, roster = [1, 2, 3, 4]) => +const setActiveRosterAction = (teamId = teamOne.id, roster = activeRoster()) => tournamentMatchAction( { _action: "SET_ACTIVE_ROSTER", - roster: roster, + roster, teamId, }, - { user: "admin", params: { id: "1", mid: "1" } }, + { user: "admin", params: matchParams() }, ); const removeMemberAction = ({ @@ -78,29 +91,33 @@ const removeMemberAction = ({ }) => removeMemberApiActionWrapped( { userId }, - { user: "admin", params: { id: "1", teamId: String(teamId) } }, + { + user: "admin", + params: { id: String(tournamentId), teamId: String(teamId) }, + }, ); -const createTeam = ( - tournamentId: number, - [owner, ...members]: number[], -): Promise<{ id: number }> => +const createTeam = (tournamentId: number, memberUserIds: number[]) => TournamentTeamFactory.create( - { - tournamentId, - userId: owner, - additionalMemberUserIds: members, - }, + { tournamentId, memberUserIds }, { isCheckedIn: true }, ); describe("Tournament match page", () => { beforeEach(async () => { - await UserFactory.createMany(10); - const tournament = await TournamentFactory.create({ authorId: 1 }); - await createTeam(tournament.id, [1, 2, 3, 4, 5, 6]); - await createTeam(tournament.id, [7, 8, 9, 10]); - await TournamentFactory.startBracket(tournament.id); + organizerId = (await UserFactory.createAdmin()).id; + await users.create(9); + + const tournament = await TournamentFactory.create({ + authorId: organizerId, + }); + tournamentId = tournament.id; + + // six members, so that team one has subs and a roster to pick from them + teamOne = await createTeam(tournamentId, [organizerId, ...users.ids(5)]); + await createTeam(tournamentId, users.ids(9).slice(5)); + + [{ id: matchId }] = await TournamentFactory.startBracket(tournamentId); }); describe("results", () => { @@ -120,17 +137,18 @@ describe("Tournament match page", () => { expect(data.results.length).toBe(1); const result = data.results[0]; + const playing = [...activeRoster(), ...users.ids(9).slice(5)]; expect(result.stageId).toBe(1); expect(result.mode).toBe("SZ"); expect( result.participants.every((participant) => - [1, 2, 3, 4, 7, 8, 9, 10].includes(participant.userId), + playing.includes(participant.userId), ), "Result participants should only include active roster user ids", ).toBeTruthy(); expect(result.ko).toBe(null); - expect(result.winnerTeamId).toBe(1); + expect(result.winnerTeamId).toBe(teamOne.id); }); it("returns results for a completed match", async () => { @@ -176,13 +194,19 @@ describe("Tournament match page", () => { describe("active roster", () => { it("should return error if submitted active roster contains user id not in the team", async () => { - const res = await setActiveRosterAction(1, [1, 2, 3, 7]); + const res = await setActiveRosterAction(teamOne.id, [ + ...activeRoster().slice(0, ROSTER_SIZE - 1), + users.id(6), + ]); assertResponseErrored(res, "Invalid roster"); }); it("should return error if submitted active roster is not of correct length", async () => { - const res = await setActiveRosterAction(1, [1, 2, 3]); + const res = await setActiveRosterAction( + teamOne.id, + activeRoster().slice(0, ROSTER_SIZE - 1), + ); assertResponseErrored(res, "Invalid roster length"); }); @@ -196,10 +220,7 @@ describe("Tournament match page", () => { it("should wipe active roster if member in it removed by tournament admin", async () => { await setActiveRosterAction(); - await removeMemberAction({ - teamId: 1, - userId: 2, - }); + await removeMemberAction({ teamId: teamOne.id, userId: users.id(1) }); const res = await reportScoreAction({ position: 0 }); assertResponseErrored(res, "Team one has no active roster"); @@ -207,10 +228,9 @@ describe("Tournament match page", () => { it("should retain active roster if member removed by tournament admin was not in it", async () => { await setActiveRosterAction(); - await removeMemberAction({ - teamId: 1, - userId: 5, - }); + + // team one's sixth member, so not one of the four it fields + await removeMemberAction({ teamId: teamOne.id, userId: users.id(5) }); const res = await reportScoreAction({ position: 0 }); @@ -218,18 +238,21 @@ describe("Tournament match page", () => { }); it("should not require setting active roster if both teams have no subs", async () => { - const tournament = await TournamentFactory.create({ authorId: 1 }); - const teamOne = await createTeam(tournament.id, [1, 2, 3, 4]); - await createTeam(tournament.id, [5, 6, 7, 8]); - await TournamentFactory.startBracket(tournament.id); + const tournament = await TournamentFactory.create({ + authorId: organizerId, + }); + const subLessTeam = await createTeam(tournament.id, [ + organizerId, + ...users.ids(ROSTER_SIZE - 1), + ]); + await createTeam(tournament.id, users.ids(ROSTER_SIZE * 2 - 1).slice(3)); + + const [match] = await TournamentFactory.startBracket(tournament.id); const res = await reportScoreAction({ position: 0, - params: { - id: String(tournament.id), - mid: "2", - }, - winnerTeamId: teamOne.id, + params: { id: String(tournament.id), mid: String(match.id) }, + winnerTeamId: subLessTeam.id, }); expect(res).toBe(null); @@ -245,7 +268,7 @@ describe("Tournament match page", () => { await db .updateTable("TournamentMatch") .set({ opponentOne: JSON.stringify({ id: null }) }) - .where("id", "=", 1) + .where("id", "=", matchId) .execute(); const res = await reportScoreAction({ position: 0 }); @@ -262,7 +285,7 @@ describe("Tournament match page", () => { await db .updateTable("TournamentMatch") .set({ opponentTwo: null }) - .where("id", "=", 1) + .where("id", "=", matchId) .execute(); await expect(loadMatchData()).rejects.toThrow("404"); @@ -273,7 +296,7 @@ describe("Tournament match page", () => { await db .updateTable("TournamentMatch") .set({ opponentTwo: JSON.stringify({ id: null }) }) - .where("id", "=", 1) + .where("id", "=", matchId) .execute(); await expect(loadMatchData()).resolves.toBeDefined(); diff --git a/app/features/tournament-organization/test-utils.ts b/app/features/tournament-organization/test-utils.ts index 4a46fd117..730d4e62f 100644 --- a/app/features/tournament-organization/test-utils.ts +++ b/app/features/tournament-organization/test-utils.ts @@ -1,5 +1,4 @@ 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 { withUserId } from "~/utils/Test"; import * as TournamentTeamRepository from "../tournament/TournamentTeamRepository.server"; @@ -25,31 +24,25 @@ export async function seedOrgEventWithParticipants({ participantUserIds: number[]; checkIn?: "in" | "out" | "none"; }) { - const [ownerUserId, ...memberUserIds] = participantUserIds; + const [ownerUserId] = participantUserIds; const asOwner = (fn: () => T) => withUserId(ownerUserId, fn); - const tournament = await TournamentFactory.create({ - authorId: ownerUserId, - organizationId, - startTimes: [startTime], - minMembersPerTeam: participantUserIds.length, - }); + const opponentUserIds = ( + await UserFactory.createMany(participantUserIds.length) + ).map((user) => user.id); - const team = await TournamentTeamFactory.create( + const { + id: tournamentId, + teams: [team, opponent], + } = await TournamentFactory.createPlayed( { - tournamentId: tournament.id, - userId: ownerUserId, - additionalMemberUserIds: memberUserIds, + authorId: ownerUserId, + organizationId, + startTimes: [startTime], + minMembersPerTeam: participantUserIds.length, }, - { isCheckedIn: true }, + { teamRosters: [participantUserIds, opponentUserIds] }, ); - const opponent = await createOpponent( - tournament.id, - participantUserIds.length, - ); - - await TournamentFactory.startBracket(tournament.id); - await TournamentFactory.playMatches(tournament.id); // the opponent exists only to give the participants somebody to play, so it // leaves no check in behind to be counted as one of the event's own teams @@ -85,18 +78,5 @@ export async function seedOrgEventWithParticipants({ }); } - return { tournamentId: tournament.id, teamId: team.id }; -} - -async function createOpponent(tournamentId: number, memberCount: number) { - const [owner, ...members] = await UserFactory.createMany(memberCount); - - return TournamentTeamFactory.create( - { - tournamentId, - userId: owner.id, - additionalMemberUserIds: members.map((member) => member.id), - }, - { isCheckedIn: true }, - ); + return { tournamentId, teamId: team.id }; } diff --git a/app/features/tournament/TournamentAuditLogRepository.server.test.ts b/app/features/tournament/TournamentAuditLogRepository.server.test.ts index 755e1b4c1..24f6df5aa 100644 --- a/app/features/tournament/TournamentAuditLogRepository.server.test.ts +++ b/app/features/tournament/TournamentAuditLogRepository.server.test.ts @@ -23,7 +23,7 @@ const createTeam = ( TournamentTeamFactory.create( { tournamentId, - userId: actor.id, + memberUserIds: [actor.id], team: { name, prefersNotToHost: 0, teamId: null }, }, options, @@ -72,8 +72,7 @@ describe("TournamentAuditLogRepository", () => { const tournament = await createTournament(); await TournamentTeamFactory.create({ tournamentId: tournament.id, - userId: actor.id, - additionalMemberUserIds: [subject.id], + memberUserIds: [actor.id, subject.id], team: { name: "Team Olive", prefersNotToHost: 0, teamId: null }, }); diff --git a/app/features/tournament/TournamentRepository.finalize.test.ts b/app/features/tournament/TournamentRepository.finalize.test.ts index 5e5450eae..b62b4b223 100644 --- a/app/features/tournament/TournamentRepository.finalize.test.ts +++ b/app/features/tournament/TournamentRepository.finalize.test.ts @@ -124,7 +124,7 @@ describe("TournamentRepository.finalize", () => { const { id: tournamentId } = await createTournament(); const { id: tournamentTeamId } = await TournamentTeamFactory.create({ tournamentId, - userId: player.id, + memberUserIds: [player.id], }); await TournamentRepository.finalize({ diff --git a/app/features/user-card/UserCardRepository.server.test.ts b/app/features/user-card/UserCardRepository.server.test.ts index 4bc874f5a..38dd7c85f 100644 --- a/app/features/user-card/UserCardRepository.server.test.ts +++ b/app/features/user-card/UserCardRepository.server.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import * as ImageFactory from "~/db/seed/factories/ImageFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; import { withNoUser, withUserId } from "~/utils/Test"; import * as UserCardRepository from "./UserCardRepository.server"; import type { UserCardData } from "./user-card-types"; @@ -14,13 +13,7 @@ const insertVerifiedXp = ( userId: number, power: number, region: "WEST" | "JPN" = "WEST", -) => - XRankPlacementFactory.create({ - playerSplId: `player-${userId}`, - playerUserId: userId, - power, - region, - }); +) => XRankPlacementFactory.create({ playerUserId: userId, power, region }); const findXpStat = (card: UserCardData | undefined) => card?.stats.find((stat) => stat.type === "XP"); @@ -41,8 +34,10 @@ describe("UserCardRepository.findAllByUserIds", () => { }); it("keys cards by user id and builds the stats array from db fields", async () => { - const plusMember = await UserFactory.create(null, { plusTier: 2 }); - await UserRepository.updateManyDivs([{ userId: plusMember.id, div: "1" }]); + const plusMember = await UserFactory.create(null, { + plusTier: 2, + div: "1", + }); await insertVerifiedXp(plusMember.id, 2500); const { userCards } = await withNoUser(() => diff --git a/app/routines/closeExpiredContinueVotes.test.ts b/app/routines/closeExpiredContinueVotes.test.ts index 4f8d7484d..f9a6c940d 100644 --- a/app/routines/closeExpiredContinueVotes.test.ts +++ b/app/routines/closeExpiredContinueVotes.test.ts @@ -7,14 +7,11 @@ vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ setMetadata: vi.fn(), })); -import { backdate } from "~/db/seed/core/backdate"; -import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; +import * as GroupMatchContinueVoteFactory from "~/db/seed/factories/GroupMatchContinueVoteFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; -import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server"; -import { withUserId } from "~/utils/Test"; import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes"; let alphaUserIds: number[]; @@ -27,31 +24,19 @@ const setupMatch = async ({ isMatchmade: boolean; confirmedAt: Date; }) => { - const alphaGroup = await createGroup(alphaUserIds, isMatchmade); - const bravoGroup = await createGroup(bravoUserIds, isMatchmade); - const match = await SQMatchFactory.create( - { alphaGroupId: alphaGroup.id, bravoGroupId: bravoGroup.id }, - { isConcluded: true }, + { alphaUserIds, bravoUserIds, isMatchmade }, + { isConcluded: true, confirmedAt }, ); - await backdate("GroupMatch", match.id, { confirmedAt }); - return { alphaGroupId: alphaGroup.id, bravoGroupId: bravoGroup.id }; + return { + alphaGroupId: match.alphaGroup.id, + bravoGroupId: match.bravoGroup.id, + }; }; -const createGroup = ([owner, ...members]: number[], isMatchmade: boolean) => - SQGroupFactory.create( - { userId: owner, additionalMemberUserIds: members }, - { isMatchmade }, - ); - const castContinueVote = (groupId: number, userId: number) => - withUserId(userId, () => - GroupMatchContinueVoteRepository.castOwnVote({ - groupId, - isContinuing: true, - }), - ); + GroupMatchContinueVoteFactory.create({ userId, groupId }); const fetchVotes = (groupId: number) => db diff --git a/app/routines/syncTournamentVods.test.ts b/app/routines/syncTournamentVods.test.ts index 845945c88..348268aeb 100644 --- a/app/routines/syncTournamentVods.test.ts +++ b/app/routines/syncTournamentVods.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { backdate } from "~/db/seed/core/backdate"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentStreamerFactory from "~/db/seed/factories/TournamentStreamerFactory"; -import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; import type { TournamentSettings } from "~/db/tables-json"; @@ -254,21 +253,17 @@ async function seedTournamentWithMatches({ }: { castedOn?: { match: "first" | "second"; twitchAccount: string }; } = {}) { - const tournament = await TournamentFactory.create({ - authorId: users.id(1), - bracketProgression: DOUBLE_ELIMINATION, - minMembersPerTeam: 1, - }); - tournamentId = tournament.id; - - teams = await TournamentTeamFactory.createMany( - TEAM_COUNT, - (index) => ({ tournamentId, userId: users.id(index + 1) }), - { isCheckedIn: true }, + const tournament = await TournamentFactory.createPlayed( + { + authorId: users.id(1), + bracketProgression: DOUBLE_ELIMINATION, + minMembersPerTeam: 1, + }, + { teamRosters: users.ids(TEAM_COUNT).map((userId) => [userId]) }, ); - - await TournamentFactory.startBracket(tournamentId); - [firstMatch, secondMatch] = await TournamentFactory.playMatches(tournamentId); + tournamentId = tournament.id; + teams = tournament.teams; + [firstMatch, secondMatch] = tournament.matches; await backdate("TournamentMatch", firstMatch.id, { startedAt: new Date(MATCH_START_SECONDS * 1000), diff --git a/app/utils/Test.ts b/app/utils/Test.ts index a15a37ba3..2c693edb9 100644 --- a/app/utils/Test.ts +++ b/app/utils/Test.ts @@ -15,7 +15,6 @@ import { ADMIN_ID } from "~/features/admin/admin-constants"; import { SESSION_KEY } from "~/features/auth/core/authenticator.server"; import { authSessionStorage } from "~/features/auth/core/session.server"; import { - type AuthenticatedUser, getUserFromRequest, userAsyncLocalStorage, } from "~/features/auth/core/user-context.server"; @@ -29,17 +28,10 @@ export function arrayContainsSameItems(arr1: T[], arr2: T[]) { /** * Runs `fn` inside the user AsyncLocalStorage store so that repository functions - * resolving the actor via `actorId()` / `actorIdOrNull()` see `user` as the acting - * user. Use in direct repository unit tests, which run outside a request. - */ -export function withUser(user: AuthenticatedUser, fn: () => T): T { - return userAsyncLocalStorage.run({ user }, fn); -} - -/** - * Like {@link withUser} but takes only a user id, building a minimal acting-user - * context. Convenient for repository data-setup in tests where only the actor's id - * matters (repositories read the actor solely via `actorId()` / `actorIdOrNull()`). + * resolving the actor via `actorId()` / `actorIdOrNull()` see the user as the acting + * one. Use in direct repository unit tests, which run outside a request. + * + * An id is all it takes: repositories read the actor solely through `actorId()`. */ export function withUserId(id: number, fn: () => T): T { return actAs(id, fn);