mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-10 05:06:15 -05:00
Refactor
This commit is contained in:
@@ -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)}`,
|
||||
);
|
||||
|
||||
@@ -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({});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
22
app/db/seed/factories/GroupMatchContinueVoteFactory.ts
Normal file
22
app/db/seed/factories/GroupMatchContinueVoteFactory.ts
Normal 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)),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
11
app/db/seed/factories/LogInLinkFactory.ts
Normal file
11
app/db/seed/factories/LogInLinkFactory.ts
Normal 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),
|
||||
});
|
||||
@@ -36,5 +36,7 @@ export const { create, createMany } = defineFactory({
|
||||
.execute();
|
||||
|
||||
await PlusVotingRepository.upsertMany([...alreadyCast, vote]);
|
||||
|
||||
return vote;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
6
app/db/seed/factories/TournamentLFGTeamFactory.ts
Normal file
6
app/db/seed/factories/TournamentLFGTeamFactory.ts
Normal 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,
|
||||
});
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user