mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-27 13:06:13 -05:00
Block tournament LFG roster changes after registration closes
This commit is contained in:
@@ -28,6 +28,8 @@ type Options = {
|
||||
isCheckedIn?: boolean;
|
||||
/** Is the team looking for more players on the tournament's LFG page? */
|
||||
isLooking?: boolean;
|
||||
/** Teams this team has liked in the LFG matchmaking view. */
|
||||
likedTeamIds?: number[];
|
||||
/** The division (starting bracket) the organizer placed the team in, as the seeds page does. */
|
||||
startingBracketIdx?: number;
|
||||
};
|
||||
@@ -88,7 +90,7 @@ export const { create } = defineFactory({
|
||||
},
|
||||
applyOptions: async (
|
||||
team,
|
||||
{ isCheckedIn, isLooking, startingBracketIdx }: Options,
|
||||
{ isCheckedIn, isLooking, likedTeamIds, startingBracketIdx }: Options,
|
||||
) => {
|
||||
if (typeof startingBracketIdx === "number") {
|
||||
await TournamentTeamRepository.updateStartingBrackets([
|
||||
@@ -108,5 +110,12 @@ export const { create } = defineFactory({
|
||||
chatRoomExpiresAt: addDays(new Date(), 7),
|
||||
});
|
||||
}
|
||||
|
||||
for (const targetTeamId of likedTeamIds ?? []) {
|
||||
await TournamentLFGRepository.insertLike({
|
||||
likerTeamId: team.id,
|
||||
targetTeamId,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,8 +36,10 @@ const mergeTeams = (args: {
|
||||
survivingTeamId: number;
|
||||
otherTeamId: number;
|
||||
maxGroupSize: number;
|
||||
requireInGameNames?: boolean;
|
||||
}) =>
|
||||
TournamentLFGRepository.mergeTeams({
|
||||
requireInGameNames: false,
|
||||
...args,
|
||||
chatRoomExpiresAt: addDays(new Date(), 7),
|
||||
});
|
||||
@@ -395,6 +397,34 @@ describe("mergeTeams", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("fills in the in-game names of merged members when required", async () => {
|
||||
const tournament = await createTournament();
|
||||
const playerOne = await UserFactory.create({
|
||||
profile: { inGameName: "Player#1111" },
|
||||
});
|
||||
const playerTwo = await UserFactory.create({
|
||||
profile: { inGameName: "Player#2222" },
|
||||
});
|
||||
const team1 = await createPlaceholder(tournament.id, playerOne.id);
|
||||
const team2 = await createPlaceholder(tournament.id, playerTwo.id);
|
||||
|
||||
await mergeTeams({
|
||||
survivingTeamId: team1.id,
|
||||
otherTeamId: team2.id,
|
||||
maxGroupSize: 4,
|
||||
requireInGameNames: true,
|
||||
});
|
||||
|
||||
const members = await db
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.select("inGameName")
|
||||
.where("tournamentTeamId", "=", team1.id)
|
||||
.execute();
|
||||
expect(new Set(members.map((member) => member.inGameName))).toEqual(
|
||||
new Set(["Player#1111", "Player#2222"]),
|
||||
);
|
||||
});
|
||||
|
||||
test("deletes the other team's chat room", async () => {
|
||||
const tournament = await createTournament();
|
||||
const team1 = await createPlaceholder(tournament.id, users.id(1));
|
||||
|
||||
@@ -128,11 +128,14 @@ export function mergeTeams({
|
||||
survivingTeamId,
|
||||
otherTeamId,
|
||||
maxGroupSize,
|
||||
requireInGameNames,
|
||||
chatRoomExpiresAt,
|
||||
}: {
|
||||
survivingTeamId: number;
|
||||
otherTeamId: number;
|
||||
maxGroupSize: number;
|
||||
/** Fills the in-game name of members who don't have one yet, as registering does. */
|
||||
requireInGameNames: boolean;
|
||||
chatRoomExpiresAt: Date;
|
||||
}): Promise<number[]> {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
@@ -156,6 +159,20 @@ export function mergeTeams({
|
||||
.execute();
|
||||
}
|
||||
|
||||
if (requireInGameNames) {
|
||||
await trx
|
||||
.updateTable("TournamentTeamMember")
|
||||
.set((eb) => ({
|
||||
inGameName: eb
|
||||
.selectFrom("User")
|
||||
.select("User.inGameName")
|
||||
.whereRef("User.id", "=", "TournamentTeamMember.userId"),
|
||||
}))
|
||||
.where("TournamentTeamMember.tournamentTeamId", "=", survivingTeamId)
|
||||
.where("TournamentTeamMember.inGameName", "is", null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await deleteLikesByTeamId(survivingTeamId, trx);
|
||||
|
||||
await trx
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { addDays, subDays } from "date-fns";
|
||||
import { beforeEach, describe, expect, test, vi } 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 { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
|
||||
import * as TournamentLFGRepository from "../TournamentLFGRepository.server";
|
||||
import type { lookingSchema } from "../tournament-lfg-schemas";
|
||||
import { action as lookingAction } from "./to.$id.looking.server";
|
||||
|
||||
// likes and accepts notify the other group; not under test here
|
||||
vi.mock("~/features/notifications/core/notify.server", () => ({
|
||||
notify: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
const looking = wrappedAction<typeof lookingSchema>({ action: lookingAction });
|
||||
|
||||
const users = UserFactory.pool();
|
||||
|
||||
const inAWeek = () => dateToDatabaseTimestamp(addDays(new Date(), 7));
|
||||
|
||||
const createOpenTournament = (
|
||||
args: Partial<Parameters<typeof TournamentFactory.create>[0]> = {},
|
||||
) =>
|
||||
TournamentFactory.create({
|
||||
authorId: users.id(1),
|
||||
startTimes: [inAWeek()],
|
||||
...args,
|
||||
});
|
||||
|
||||
/** Two registered teams (users 1-2 and 3-4) and a started bracket, registration closed as the start time has passed. */
|
||||
const createStartedTournament = async () => {
|
||||
const tournament = await TournamentFactory.create({ authorId: users.id(1) });
|
||||
await TournamentTeamFactory.create(
|
||||
{ tournamentId: tournament.id, memberUserIds: [users.id(1), users.id(2)] },
|
||||
{ isCheckedIn: true },
|
||||
);
|
||||
await TournamentTeamFactory.create(
|
||||
{ tournamentId: tournament.id, memberUserIds: [users.id(3), users.id(4)] },
|
||||
{ isCheckedIn: true },
|
||||
);
|
||||
await TournamentFactory.startBracket(tournament.id);
|
||||
|
||||
return tournament;
|
||||
};
|
||||
|
||||
/** A placeholder of `likerUserId` that has liked the placeholder of `likedUserId`. */
|
||||
const createLikedPair = async ({
|
||||
tournamentId,
|
||||
likedUserId,
|
||||
likerUserId,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
likedUserId: number;
|
||||
likerUserId: number;
|
||||
}) => {
|
||||
const liked = await TournamentLFGTeamFactory.create({
|
||||
tournamentId,
|
||||
userId: likedUserId,
|
||||
});
|
||||
const liker = await TournamentLFGTeamFactory.create(
|
||||
{ tournamentId, userId: likerUserId },
|
||||
{ likedTeamIds: [liked.id] },
|
||||
);
|
||||
|
||||
return { liked, liker };
|
||||
};
|
||||
|
||||
const memberCountsOfGroups = async (tournamentId: number) =>
|
||||
(await TournamentLFGRepository.findLookingTeamsByTournamentId(tournamentId))
|
||||
.map((group) => group.members.length)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const accept = ({
|
||||
tournamentId,
|
||||
userId,
|
||||
targetTeamId,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
userId: number;
|
||||
targetTeamId: number;
|
||||
}) =>
|
||||
looking(
|
||||
{ _action: "ACCEPT", targetTeamId },
|
||||
{ user: userId, params: { id: String(tournamentId) } },
|
||||
);
|
||||
|
||||
describe("Tournament LFG LIKE", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(6);
|
||||
});
|
||||
|
||||
test("errors after the tournament has started", async () => {
|
||||
const tournament = await createStartedTournament();
|
||||
const own = await TournamentLFGTeamFactory.create({
|
||||
tournamentId: tournament.id,
|
||||
userId: users.id(5),
|
||||
});
|
||||
const target = await TournamentLFGTeamFactory.create({
|
||||
tournamentId: tournament.id,
|
||||
userId: users.id(6),
|
||||
});
|
||||
|
||||
assertResponseErrored(
|
||||
await looking(
|
||||
{ _action: "LIKE", targetTeamId: target.id },
|
||||
{ user: users.id(5), params: { id: String(tournament.id) } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await TournamentLFGRepository.findAllLikesByTeamId(own.id)).given,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tournament LFG ACCEPT", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(6);
|
||||
});
|
||||
|
||||
test("merges two groups while registration is open", async () => {
|
||||
const tournament = await createOpenTournament();
|
||||
const { liker } = await createLikedPair({
|
||||
tournamentId: tournament.id,
|
||||
likedUserId: users.id(1),
|
||||
likerUserId: users.id(2),
|
||||
});
|
||||
|
||||
await accept({
|
||||
tournamentId: tournament.id,
|
||||
userId: users.id(1),
|
||||
targetTeamId: liker.id,
|
||||
});
|
||||
|
||||
expect(await memberCountsOfGroups(tournament.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
test("errors after the tournament has started and leaves the groups as they were", async () => {
|
||||
const tournament = await createStartedTournament();
|
||||
const { liker } = await createLikedPair({
|
||||
tournamentId: tournament.id,
|
||||
likedUserId: users.id(5),
|
||||
likerUserId: users.id(6),
|
||||
});
|
||||
|
||||
assertResponseErrored(
|
||||
await accept({
|
||||
tournamentId: tournament.id,
|
||||
userId: users.id(5),
|
||||
targetTeamId: liker.id,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await memberCountsOfGroups(tournament.id)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
test("errors after registration has closed", async () => {
|
||||
const tournament = await createOpenTournament({
|
||||
regClosesAt: dateToDatabaseTimestamp(subDays(new Date(), 1)),
|
||||
});
|
||||
const { liker } = await createLikedPair({
|
||||
tournamentId: tournament.id,
|
||||
likedUserId: users.id(1),
|
||||
likerUserId: users.id(2),
|
||||
});
|
||||
|
||||
assertResponseErrored(
|
||||
await accept({
|
||||
tournamentId: tournament.id,
|
||||
userId: users.id(1),
|
||||
targetTeamId: liker.id,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await memberCountsOfGroups(tournament.id)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
test("does not merge away a team that has checked in", async () => {
|
||||
const tournament = await createOpenTournament();
|
||||
const ownTeam = await TournamentTeamFactory.create(
|
||||
{
|
||||
tournamentId: tournament.id,
|
||||
memberUserIds: [users.id(1), users.id(2)],
|
||||
},
|
||||
{ isLooking: true },
|
||||
);
|
||||
const checkedInTeam = await TournamentTeamFactory.create(
|
||||
{
|
||||
tournamentId: tournament.id,
|
||||
memberUserIds: [users.id(3), users.id(4)],
|
||||
},
|
||||
{ isLooking: true, isCheckedIn: true, likedTeamIds: [ownTeam.id] },
|
||||
);
|
||||
|
||||
assertResponseErrored(
|
||||
await accept({
|
||||
tournamentId: tournament.id,
|
||||
userId: users.id(1),
|
||||
targetTeamId: checkedInTeam.id,
|
||||
}),
|
||||
"checked in",
|
||||
);
|
||||
|
||||
expect(await memberCountsOfGroups(tournament.id)).toEqual([2, 2]);
|
||||
});
|
||||
|
||||
test("errors when a member has no friend code", async () => {
|
||||
const tournament = await createOpenTournament();
|
||||
const withoutFriendCode = await UserFactory.create({ friendCode: null });
|
||||
const { liker } = await createLikedPair({
|
||||
tournamentId: tournament.id,
|
||||
likedUserId: users.id(1),
|
||||
likerUserId: withoutFriendCode.id,
|
||||
});
|
||||
|
||||
assertResponseErrored(
|
||||
await accept({
|
||||
tournamentId: tournament.id,
|
||||
userId: users.id(1),
|
||||
targetTeamId: liker.id,
|
||||
}),
|
||||
"no friend code",
|
||||
);
|
||||
|
||||
expect(await memberCountsOfGroups(tournament.id)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
test("errors when a member has no in-game name and the tournament requires one", async () => {
|
||||
const tournament = await createOpenTournament({ requireInGameNames: true });
|
||||
const withInGameName = await UserFactory.create({
|
||||
profile: { inGameName: "Player#1234" },
|
||||
});
|
||||
const withoutInGameName = await UserFactory.create({ profile: null });
|
||||
const { liker } = await createLikedPair({
|
||||
tournamentId: tournament.id,
|
||||
likedUserId: withInGameName.id,
|
||||
likerUserId: withoutInGameName.id,
|
||||
});
|
||||
|
||||
assertResponseErrored(
|
||||
await accept({
|
||||
tournamentId: tournament.id,
|
||||
userId: withInGameName.id,
|
||||
targetTeamId: liker.id,
|
||||
}),
|
||||
"no in-game name",
|
||||
);
|
||||
|
||||
expect(await memberCountsOfGroups(tournament.id)).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tournament LFG JOIN_QUEUE", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(4);
|
||||
});
|
||||
|
||||
test("errors for a registered team after the tournament has started", async () => {
|
||||
const tournament = await createStartedTournament();
|
||||
|
||||
assertResponseErrored(
|
||||
await looking(
|
||||
{ _action: "JOIN_QUEUE" },
|
||||
{ user: users.id(1), params: { id: String(tournament.id) } },
|
||||
),
|
||||
);
|
||||
|
||||
expect(await memberCountsOfGroups(tournament.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,14 @@ import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournam
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
|
||||
import { requireNotBannedByOrganization } from "~/features/tournament/tournament-utils.server";
|
||||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
import {
|
||||
clearTournamentDataCache,
|
||||
requireTournamentOrganizer,
|
||||
tournamentFromParams,
|
||||
tournamentTeamsFullCached,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { defineAction } from "~/form/define-action.server";
|
||||
import { errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
@@ -50,6 +52,10 @@ export const action = defineAction(
|
||||
const existingGroup = await findOwnGroup();
|
||||
if (existingGroup) return null;
|
||||
|
||||
errorToastIfFalsy(
|
||||
tournament.registrationOpen,
|
||||
"Registration is closed",
|
||||
);
|
||||
await requireNotBannedByOrganization({ tournament, user });
|
||||
errorToastIfFalsy(
|
||||
tournament.canAddNewSubPost,
|
||||
@@ -93,6 +99,11 @@ export const action = defineAction(
|
||||
break;
|
||||
}
|
||||
case "LIKE": {
|
||||
errorToastIfFalsy(
|
||||
tournament.registrationOpen,
|
||||
"Registration is closed",
|
||||
);
|
||||
|
||||
const groups =
|
||||
await TournamentLFGRepository.findLookingTeamsByTournamentId(
|
||||
tournamentId,
|
||||
@@ -137,6 +148,11 @@ export const action = defineAction(
|
||||
break;
|
||||
}
|
||||
case "ACCEPT": {
|
||||
errorToastIfFalsy(
|
||||
tournament.registrationOpen,
|
||||
"Registration is closed",
|
||||
);
|
||||
|
||||
const groups =
|
||||
await TournamentLFGRepository.findLookingTeamsByTournamentId(
|
||||
tournamentId,
|
||||
@@ -179,10 +195,22 @@ export const action = defineAction(
|
||||
|
||||
const otherGroup = surviving === ownGroup.id ? theirGroup : ownGroup;
|
||||
|
||||
errorToastIfFalsy(
|
||||
!tournament.teamById(otherGroup.id)?.checkIns.length,
|
||||
"A team that has checked in can't be merged into another team",
|
||||
);
|
||||
await requireMembersCanJoinTeam({
|
||||
tournament,
|
||||
members: [...ownGroup.members, ...theirGroup.members],
|
||||
});
|
||||
|
||||
const roomsChangedUserIds = await TournamentLFGRepository.mergeTeams({
|
||||
survivingTeamId: surviving,
|
||||
otherTeamId: otherGroup.id,
|
||||
maxGroupSize: tournament.maxMembersPerTeam,
|
||||
requireInGameNames: Boolean(
|
||||
tournament.ctx.settings.requireInGameNames,
|
||||
),
|
||||
chatRoomExpiresAt: pickupChatRoomExpiresAt(tournament.ctx.startsAt),
|
||||
});
|
||||
ChatSystemMessage.notifyRoomsChanged(roomsChangedUserIds);
|
||||
@@ -374,3 +402,23 @@ export const action = defineAction(
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
async function requireMembersCanJoinTeam({
|
||||
tournament,
|
||||
members,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
members: Array<{ id: number; username: string }>;
|
||||
}) {
|
||||
for (const member of members) {
|
||||
const memberUser = await UserRepository.findLeanById(member.id);
|
||||
errorToastIfFalsy(
|
||||
memberUser?.friendCode,
|
||||
`${member.username} has no friend code set`,
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
!tournament.ctx.settings.requireInGameNames || memberUser.inGameName,
|
||||
`${member.username} has no in-game name set`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user