This commit is contained in:
Kalle
2026-07-28 18:23:44 +03:00
parent fd67d2ed54
commit a6ddd75772
45 changed files with 905 additions and 784 deletions

View File

@@ -27,6 +27,9 @@ export async function backdate<T extends BackdatableTable>(
const assignments: RawBuilder<unknown>[] = [];
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)}`,
);

View File

@@ -37,10 +37,6 @@ export type Factory<Args, Row, Defaults, Options> = {
createMany: (
...args: CreateManyParams<Args, Defaults, Options>
) => Promise<Row[]>;
/** A preset: the same factory with an extra layer of defaults. */
variant: <VariantDefaults extends Partial<Args>>(
defaults: VariantDefaults,
) => Factory<Args, Row, Defaults & VariantDefaults, Options>;
};
const sequenceResets = new Set<() => void>();
@@ -63,14 +59,15 @@ const sequenceResets = new Set<() => void>();
export function defineFactory<
Args,
Row,
Defaults extends Partial<Args>,
Defaults extends Partial<Args> = Record<never, never>,
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<Row>;
applyOptions?: (row: Row, options: Options) => Promise<void>;
}): Factory<Args, Row, Defaults, Options> {
@@ -89,38 +86,29 @@ export function defineFactory<
return row;
};
const factoryWith = <PresetDefaults extends Partial<Args>>(
presetDefaults: PresetDefaults,
): Factory<Args, Row, Defaults & PresetDefaults, Options> => {
// `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<Args>) =>
({
...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<Args>) =>
({
...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({});
}
/**

View File

@@ -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,

View File

@@ -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)),
});

View File

@@ -1,6 +1,8 @@
import * as ImageRepository from "~/features/img-upload/ImageRepository.server";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Parameters<typeof ImageRepository.insert>[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<InsertArgs, "validatedAt">) =>
ImageRepository.insert({ ...args, validatedAt: null }),
applyOptions: async (image, { isValidated }: Options) => {
if (!isValidated) return;

View File

@@ -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),
});

View File

@@ -36,5 +36,7 @@ export const { create, createMany } = defineFactory({
.execute();
await PlusVotingRepository.upsertMany([...alreadyCast, vote]);
return vote;
},
});

View File

@@ -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<Insert>[0] & {
additionalMemberUserIds: number[];
type InsertArgs = Omit<
Parameters<typeof SQGroupRepository.insert>[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();
}
},
});

View File

@@ -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<typeof SQMatchRepository.insert>[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 });
},
});

View File

@@ -7,6 +7,13 @@ type InsertArgs = Parameters<typeof ReportedWeaponRepository.upsertOwn>[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(),

View File

@@ -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<typeof ScrimPostRepository.insert>[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,

View File

@@ -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<typeof TeamRepository.insert>[0] & {
additionalMemberUserIds: number[];
type InsertArgs = Omit<
Parameters<typeof TeamRepository.insert>[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,
});
},
});

View File

@@ -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<typeof create>[0],
{ teamRosters, ...options }: Options & { teamRosters: number[][] },
) {
const tournament = await create(overrides, options);
const teams: Awaited<ReturnType<typeof TournamentTeamFactory.create>>[] = [];
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 {

View File

@@ -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,
});

View File

@@ -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<Tables["TournamentStreamer"], "id">;
type InsertArgs = Parameters<
typeof LiveStreamRepository.insertTournamentStreamers
>[0][number];
export const { create } = defineFactory({
defaults: ({ seq }) => ({

View File

@@ -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<typeof TournamentTeamRepository.insert>[0];
type InsertArgs = Omit<
Parameters<typeof TournamentTeamRepository.insert>[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);
}
},
});

View File

@@ -28,6 +28,12 @@ type Options = {
roles?: Array<Role>;
/** SendouQ match profile, submitted as the user themselves. */
matchProfile?: Partial<MatchProfileArgs>;
/** Ban as an admin lays one down: `1` for good, or the date it lifts on. */
ban?: Omit<Parameters<typeof AdminRepository.banUser>[0], "userId">;
/** Division the user played their last season in. */
div?: NonNullable<Tables["User"]["div"]>;
/** Weapon pool, submitted as the user themselves. */
weapons?: Parameters<typeof UserRepository.updateOwnProfile>[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<UpsertArgs> | 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<UpsertArgs> | null,
options?: Options,
) => createPinned(REGULAR_USER_TEST_ID, overrides, options);
async function createPinned(
pinnedId: number,
overrides?: Partial<UpsertArgs> | 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) {

View File

@@ -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();
},
});

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();

View File

@@ -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

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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")

View File

@@ -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(),
);

View File

@@ -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(

View File

@@ -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 },
);
}

View File

@@ -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 = (

View File

@@ -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<typeof TeamFactory.create>[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);
});
});
});

View File

@@ -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<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
import type { editTeamFormSchema } from "../team-schemas";
const editTeamAction = wrappedAction<typeof editTeamFormSchema>({
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");

View File

@@ -34,11 +34,17 @@ const createTeamWithRegularMember = (
overrides: Partial<Parameters<typeof TeamFactory.create>[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 },
},
);

View File

@@ -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,

View File

@@ -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);

View File

@@ -36,15 +36,28 @@ const tournamentMatchLoader = wrappedLoader<SerializeFrom<typeof loader>>({
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();

View File

@@ -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 = <T>(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 };
}

View File

@@ -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 },
});

View File

@@ -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({

View File

@@ -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(() =>

View File

@@ -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

View File

@@ -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),

View File

@@ -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<T>(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<T>(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<T>(id: number, fn: () => T): T {
return actAs(id, fn);