Files
sendou.ink/app/features/tournament/TournamentTeamRepository.server.test.ts
2026-09-09 21:37:22 +03:00

496 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, test } from "vitest";
import * as ImageFactory from "~/db/seed/factories/ImageFactory";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import type { TournamentSettings } from "~/db/tables-json";
import { invariant } from "~/utils/invariant";
import { withUserId } from "~/utils/Test";
import * as TournamentTeamRepository from "./TournamentTeamRepository.server";
const TEAM_COUNT = 4;
/** Pools of two teams each, followed by a final between the two pool winners. */
const POOLS_TO_FINAL: TournamentSettings["bracketProgression"] = [
{
name: "Pools",
type: "round_robin",
requiresCheckIn: false,
settings: { teamsPerGroup: 2 },
},
{
name: "Final",
type: "single_elimination",
requiresCheckIn: false,
settings: { thirdPlaceMatch: false },
sources: [{ bracketIdx: 0, placements: [1] }],
},
];
const POOL_MAPS: TournamentFactory.RoundMaps = {
count: 1,
type: "BEST_OF",
list: [{ mode: "SZ", stageId: 1 }],
};
const FINAL_MAPS: TournamentFactory.RoundMaps = {
count: 1,
type: "BEST_OF",
list: [{ mode: "TC", stageId: 2 }],
};
const users = UserFactory.pool();
const organizerId = () => users.id(1);
const ownerId = () => users.id(2);
const memberId = () => users.id(3);
const anotherMemberId = () => users.id(4);
const membersByTeamId = (tournamentTeamId: number) =>
db
.selectFrom("TournamentTeamMember")
.select([
"TournamentTeamMember.userId",
"TournamentTeamMember.role",
"TournamentTeamMember.isOrganizerAdded",
])
.where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId)
.execute();
const tournamentNameOf = async (userId: number) =>
(
await db
.selectFrom("User")
.select("User.tournamentName")
.where("User.id", "=", userId)
.executeTakeFirstOrThrow()
).tournamentName;
const roleOf = (
members: Array<{ userId: number; role: string }>,
userId: number,
) => members.find((teamMember) => teamMember.userId === userId)?.role;
describe("TournamentTeamRepository", () => {
beforeEach(async () => {
await users.create(4);
});
describe("upsertRegistration", () => {
test("gives a new team's members their roles", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
});
await withUserId(organizerId(), () =>
TournamentTeamRepository.upsertRegistration({
tournamentId: tournament.id,
name: "Team Olive",
teamId: null,
avatarImgId: null,
ownerUserId: ownerId(),
ownerChange: null,
membersToAdd: [ownerId(), memberId(), anotherMemberId()],
membersToRemove: [],
inGameNameUpdates: [],
tournamentNameUpdates: [],
}),
);
const team = await db
.selectFrom("TournamentTeam")
.select("TournamentTeam.id")
.where("TournamentTeam.tournamentId", "=", tournament.id)
.executeTakeFirstOrThrow();
const members = await membersByTeamId(team.id);
expect(members).toHaveLength(3);
expect(roleOf(members, ownerId())).toBe("OWNER");
expect(roleOf(members, memberId())).toBe("REGULAR");
expect(roleOf(members, anotherMemberId())).toBe("REGULAR");
});
test("added members of an existing team don't take the owner role", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
});
const team = await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [ownerId()],
team: { name: "Team Olive", prefersNotToHost: 0, teamId: null },
});
await withUserId(organizerId(), () =>
TournamentTeamRepository.upsertRegistration({
tournamentTeamId: team.id,
tournamentId: tournament.id,
name: "Team Olive",
teamId: null,
avatarImgId: null,
ownerUserId: ownerId(),
ownerChange: null,
membersToAdd: [memberId(), anotherMemberId()],
membersToRemove: [],
inGameNameUpdates: [],
tournamentNameUpdates: [],
}),
);
const members = await membersByTeamId(team.id);
expect(members).toHaveLength(3);
expect(roleOf(members, ownerId())).toBe("OWNER");
expect(roleOf(members, memberId())).toBe("REGULAR");
expect(roleOf(members, anotherMemberId())).toBe("REGULAR");
});
test("marks added members as organizer added", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
});
await withUserId(organizerId(), () =>
TournamentTeamRepository.upsertRegistration({
tournamentId: tournament.id,
name: "Team Olive",
teamId: null,
avatarImgId: null,
ownerUserId: ownerId(),
ownerChange: null,
membersToAdd: [ownerId(), memberId()],
membersToRemove: [],
inGameNameUpdates: [],
tournamentNameUpdates: [],
}),
);
const team = await db
.selectFrom("TournamentTeam")
.select("TournamentTeam.id")
.where("TournamentTeam.tournamentId", "=", tournament.id)
.executeTakeFirstOrThrow();
const members = await membersByTeamId(team.id);
expect(members.every((teamMember) => teamMember.isOrganizerAdded)).toBe(
true,
);
});
test("updates tournament names of members", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
});
const { appliedTournamentNameChanges } = await withUserId(
organizerId(),
() =>
TournamentTeamRepository.upsertRegistration({
tournamentId: tournament.id,
name: "Team Olive",
teamId: null,
avatarImgId: null,
ownerUserId: ownerId(),
ownerChange: null,
membersToAdd: [ownerId(), memberId()],
membersToRemove: [],
inGameNameUpdates: [],
tournamentNameUpdates: [
{ userId: ownerId(), tournamentName: "Sendou" },
{ userId: memberId(), tournamentName: null },
],
}),
);
expect(appliedTournamentNameChanges).toEqual([
{
userId: ownerId(),
previousTournamentName: null,
tournamentName: "Sendou",
},
]);
expect(await tournamentNameOf(ownerId())).toBe("Sendou");
expect(await tournamentNameOf(memberId())).toBeNull();
});
test("logs a tournament name change in the audit log", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
});
await withUserId(organizerId(), () =>
TournamentTeamRepository.upsertRegistration({
tournamentId: tournament.id,
name: "Team Olive",
teamId: null,
avatarImgId: null,
ownerUserId: ownerId(),
ownerChange: null,
membersToAdd: [ownerId()],
membersToRemove: [],
inGameNameUpdates: [],
tournamentNameUpdates: [
{ userId: ownerId(), tournamentName: "Sendou" },
],
}),
);
const events = await db
.selectFrom("TournamentAuditLog")
.select([
"TournamentAuditLog.actorUserId",
"TournamentAuditLog.subjectUserId",
"TournamentAuditLog.metadata",
])
.where("TournamentAuditLog.type", "=", "UPDATE_TOURNAMENT_NAME")
.execute();
expect(events).toHaveLength(1);
expect(events[0].actorUserId).toBe(organizerId());
expect(events[0].subjectUserId).toBe(ownerId());
expect(events[0].metadata?.tournamentName).toBe("Sendou");
});
test("does not touch a tournament name that did not change", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
});
const team = await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [ownerId()],
team: { name: "Team Olive", prefersNotToHost: 0, teamId: null },
});
const upsert = (tournamentName: string) =>
withUserId(organizerId(), () =>
TournamentTeamRepository.upsertRegistration({
tournamentTeamId: team.id,
tournamentId: tournament.id,
name: "Team Olive",
teamId: null,
avatarImgId: null,
ownerUserId: ownerId(),
ownerChange: null,
membersToAdd: [],
membersToRemove: [],
inGameNameUpdates: [],
tournamentNameUpdates: [{ userId: ownerId(), tournamentName }],
}),
);
await upsert("Sendou");
const { appliedTournamentNameChanges } = await upsert("Sendou");
expect(appliedTournamentNameChanges).toEqual([]);
const events = await db
.selectFrom("TournamentAuditLog")
.select("TournamentAuditLog.id")
.where("TournamentAuditLog.type", "=", "UPDATE_TOURNAMENT_NAME")
.execute();
expect(events).toHaveLength(1);
});
});
describe("join", () => {
test("joining on your own is not marked as organizer added", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
});
const team = await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [ownerId()],
team: { name: "Team Olive", prefersNotToHost: 0, teamId: null },
});
await withUserId(memberId(), () =>
TournamentTeamRepository.join({
newTeamId: team.id,
userId: memberId(),
}),
);
const members = await membersByTeamId(team.id);
expect(
members.find((teamMember) => teamMember.userId === memberId())
?.isOrganizerAdded,
).toBe(0);
});
});
describe("deleteById", () => {
test("returns the members who lost the team's chat room", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
});
const team = await TournamentTeamFactory.create(
{
tournamentId: tournament.id,
memberUserIds: [ownerId(), memberId()],
team: { name: "Team Olive", prefersNotToHost: 0, teamId: null },
},
{ isLooking: true },
);
const roomsChangedUserIds = await withUserId(organizerId(), () =>
TournamentTeamRepository.deleteById(team.id),
);
expect(roomsChangedUserIds.sort(byId)).toEqual(
[ownerId(), memberId()].sort(byId),
);
});
test("returns nobody when the team had no chat room", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
});
const team = await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [ownerId()],
team: { name: "Team Olive", prefersNotToHost: 0, teamId: null },
});
expect(
await withUserId(organizerId(), () =>
TournamentTeamRepository.deleteById(team.id),
),
).toEqual([]);
});
});
describe("findAllRegistrationsByUserIds", () => {
const WINDOW_STARTS_AT = 1_700_000_000;
const DAY_IN_SECONDS = 60 * 60 * 24;
const WINDOW_ENDS_AT = WINDOW_STARTS_AT + 7 * DAY_IN_SECONDS;
const registerAt = async (startTime: number) => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
startTimes: [startTime],
});
await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [ownerId(), memberId()],
});
return tournament.id;
};
const registrationsInWindow = (excludeTournamentId?: number) =>
TournamentTeamRepository.findAllRegistrationsByUserIds({
userIds: [memberId()],
startsAt: WINDOW_STARTS_AT,
endsAt: WINDOW_ENDS_AT,
excludeTournamentId,
});
test("leaves out the registrations starting outside the window", async () => {
await registerAt(WINDOW_STARTS_AT + DAY_IN_SECONDS);
await registerAt(WINDOW_STARTS_AT - DAY_IN_SECONDS);
await registerAt(WINDOW_ENDS_AT + DAY_IN_SECONDS);
const registrations = await registrationsInWindow();
expect(registrations).toHaveLength(1);
expect(registrations[0].userId).toBe(memberId());
expect(registrations[0].startsAt).toBe(WINDOW_STARTS_AT + DAY_IN_SECONDS);
});
test("leaves out the excluded tournament", async () => {
const excludedTournamentId = await registerAt(
WINDOW_STARTS_AT + DAY_IN_SECONDS,
);
await registerAt(WINDOW_STARTS_AT + 2 * DAY_IN_SECONDS);
const registrations = await registrationsInWindow(excludedTournamentId);
expect(registrations).toHaveLength(1);
expect(registrations[0].startsAt).toBe(
WINDOW_STARTS_AT + 2 * DAY_IN_SECONDS,
);
});
test("returns nothing when no user ids are given", async () => {
await registerAt(WINDOW_STARTS_AT + DAY_IN_SECONDS);
expect(
await TournamentTeamRepository.findAllRegistrationsByUserIds({
userIds: [],
startsAt: WINDOW_STARTS_AT,
endsAt: WINDOW_ENDS_AT,
}),
).toEqual([]);
});
});
describe("findRecentlyPlayedMapsByIds", () => {
test("leaves out the games of the match the maps are resolved for", async () => {
// an in-progress set's map list is regenerated when its cache entry is lost, so counting its
// own games as recently played would change the maps left to play under the teams
const players = await UserFactory.createMany(TEAM_COUNT);
const tournament = await TournamentFactory.createPlayed(
{
authorId: organizerId(),
bracketProgression: POOLS_TO_FINAL,
minMembersPerTeam: 1,
},
{
teamRosters: players.map((player) => [player.id]),
playedOut: 0,
maps: POOL_MAPS,
},
);
const [final] = await TournamentFactory.playOut(tournament.id, 1, {
maps: FINAL_MAPS,
});
const recentMaps =
await TournamentTeamRepository.findRecentlyPlayedMapsByIds({
teamIds: [final.winnerTeamId, final.loserTeamId],
excludeMatchId: final.id,
});
expect(recentMaps).toEqual([
{ mode: "SZ", stageId: 1 },
{ mode: "SZ", stageId: 1 },
]);
});
});
describe("isPickupAvatarImgId", () => {
test("tells a team's pickup logo apart from an image no team uses", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
});
await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [ownerId()],
hasAvatar: true,
});
const unusedImage = await ImageFactory.create({
submitterUserId: ownerId(),
});
const pickupAvatarImgId = (
await db
.selectFrom("TournamentTeam")
.select("TournamentTeam.avatarImgId")
.where("TournamentTeam.tournamentId", "=", tournament.id)
.executeTakeFirstOrThrow()
).avatarImgId;
invariant(pickupAvatarImgId, "Expected the team to have a logo");
expect(
await TournamentTeamRepository.isPickupAvatarImgId(pickupAvatarImgId),
).toBe(true);
expect(
await TournamentTeamRepository.isPickupAvatarImgId(unusedImage.id),
).toBe(false);
});
});
});
const byId = (a: number, b: number) => a - b;