mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-28 06:10:01 -05:00
520 lines
15 KiB
TypeScript
520 lines
15 KiB
TypeScript
import { addHours, subHours } from "date-fns";
|
|
import { beforeEach, describe, expect, test } from "vitest";
|
|
import * as ScrimPostFactory from "~/db/seed/factories/ScrimPostFactory";
|
|
import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
|
|
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
|
|
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 * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
|
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
|
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
|
import { hasPermission } from "~/modules/permissions/utils";
|
|
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
|
import { invariant } from "~/utils/invariant";
|
|
import * as ChatRepository from "./ChatRepository.server";
|
|
import * as ChatRoomResolver from "./ChatRoomResolver.server";
|
|
import { setupSqMatch } from "./tests/fixtures";
|
|
|
|
const users = UserFactory.pool();
|
|
|
|
// ADMIN_ID is 1 under NODE_ENV=test, so the first pool user is site staff
|
|
const adminId = () => users.id(1);
|
|
const outsiderId = () => users.id(11);
|
|
|
|
beforeEach(async () => {
|
|
await users.create(12);
|
|
});
|
|
|
|
const setupStartedTournamentMatch = async () => {
|
|
const authorId = users.id(2);
|
|
const teamAlphaUserIds = [users.id(3), users.id(4), users.id(5), users.id(6)];
|
|
const teamBravoUserIds = [
|
|
users.id(7),
|
|
users.id(8),
|
|
users.id(9),
|
|
users.id(10),
|
|
];
|
|
|
|
const tournament = await TournamentFactory.create({ authorId });
|
|
for (const memberUserIds of [teamAlphaUserIds, teamBravoUserIds]) {
|
|
await TournamentTeamFactory.create(
|
|
{ tournamentId: tournament.id, memberUserIds },
|
|
{ isCheckedIn: true },
|
|
);
|
|
}
|
|
await TournamentFactory.startBracket(tournament.id);
|
|
|
|
const match = await db
|
|
.selectFrom("TournamentMatch")
|
|
.innerJoin(
|
|
"TournamentStage",
|
|
"TournamentStage.id",
|
|
"TournamentMatch.stageId",
|
|
)
|
|
.select(["TournamentMatch.id", "TournamentMatch.chatRoomId"])
|
|
.where("TournamentStage.tournamentId", "=", tournament.id)
|
|
.where("TournamentMatch.chatRoomId", "is not", null)
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return {
|
|
tournament,
|
|
authorId,
|
|
matchId: match.id,
|
|
chatRoomId: match.chatRoomId!,
|
|
teamAlphaUserIds,
|
|
teamBravoUserIds,
|
|
};
|
|
};
|
|
|
|
const setupAcceptedScrim = async () => {
|
|
const postUserIds = [users.id(2), users.id(3)];
|
|
const requestUserIds = [users.id(4), users.id(5)];
|
|
const startsAt = addHours(new Date(), 10);
|
|
|
|
const { id: postId } = await ScrimPostFactory.create({
|
|
startsAt: dateToDatabaseTimestamp(startsAt),
|
|
users: postUserIds.map((userId, i) => ({
|
|
userId,
|
|
isOwner: i === 0 ? (1 as const) : (0 as const),
|
|
})),
|
|
});
|
|
const team = await TeamFactory.create({ memberUserIds: requestUserIds });
|
|
const requestId = await ScrimPostRepository.insertRequest({
|
|
scrimPostId: postId,
|
|
teamId: team.id,
|
|
message: null,
|
|
startsAt: null,
|
|
users: requestUserIds.map((userId, i) => ({
|
|
userId,
|
|
isOwner: i === 0 ? (1 as const) : (0 as const),
|
|
})),
|
|
});
|
|
await ScrimPostRepository.acceptRequest(requestId);
|
|
|
|
const post = await ScrimPostRepository.findById(postId);
|
|
|
|
return {
|
|
postId,
|
|
chatRoomId: post!.chatRoomId!,
|
|
postUserIds,
|
|
requestUserIds,
|
|
startsAt,
|
|
};
|
|
};
|
|
|
|
describe("ChatRoomResolver.resolve", () => {
|
|
test("resolves an SQ_GROUP room to the group's live members", async () => {
|
|
const memberUserIds = [users.id(2), users.id(3)];
|
|
const group = await SQGroupFactory.create({ memberUserIds });
|
|
|
|
const room = await resolveOrThrow(await groupChatRoomId(group.id));
|
|
|
|
expect(room.type).toBe("SQ_GROUP");
|
|
expect(room.participantUserIds.sort(byId)).toEqual(
|
|
memberUserIds.sort(byId),
|
|
);
|
|
expect(room.url).toBe("/q/looking");
|
|
// a group chat resolves no observers of its own, site staff aside
|
|
expect(hasPermission(room, "OBSERVE", { id: outsiderId() })).toBe(false);
|
|
expect(room.inactive).toBe(false);
|
|
});
|
|
|
|
test("marks a dead group's room inactive", async () => {
|
|
const group = await SQGroupFactory.create({
|
|
memberUserIds: [users.id(2), users.id(3)],
|
|
});
|
|
await SQGroupRepository.setAsInactive(group.id);
|
|
|
|
const room = await resolveOrThrow(await groupChatRoomId(group.id));
|
|
|
|
expect(room.inactive).toBe(true);
|
|
});
|
|
|
|
test("resolves an SQ_MATCH room to both groups' members", async () => {
|
|
const { match, alphaUserIds, bravoUserIds } = await setupSqMatch(users);
|
|
|
|
const room = await resolveOrThrow(match.chatRoomId!);
|
|
|
|
expect(room.type).toBe("SQ_MATCH");
|
|
expect(room.participantUserIds.sort(byId)).toEqual(
|
|
[...alphaUserIds, ...bravoUserIds].sort(byId),
|
|
);
|
|
expect(room.titleParams).toEqual({ matchId: String(match.id) });
|
|
expect(room.url).toContain(String(match.id));
|
|
});
|
|
|
|
test("resolves a TOURNAMENT_MATCH room to both teams' members with organizer observers", async () => {
|
|
const {
|
|
chatRoomId,
|
|
matchId,
|
|
authorId,
|
|
teamAlphaUserIds,
|
|
teamBravoUserIds,
|
|
} = await setupStartedTournamentMatch();
|
|
|
|
const room = await resolveOrThrow(chatRoomId);
|
|
|
|
expect(room.type).toBe("TOURNAMENT_MATCH");
|
|
expect(room.participantUserIds.sort(byId)).toEqual(
|
|
[...teamAlphaUserIds, ...teamBravoUserIds].sort(byId),
|
|
);
|
|
expect(room.titleParams.matchId).toBe(String(matchId));
|
|
expect(room.titleParams.tournamentName).toEqual(expect.any(String));
|
|
expect(room.permissions.OBSERVE).toContain(authorId);
|
|
});
|
|
|
|
test("TOURNAMENT_MATCH observers include tournament staff organizers and streamers", async () => {
|
|
const { tournament, chatRoomId } = await setupStartedTournamentMatch();
|
|
await TournamentRepository.setStaff({
|
|
tournamentId: tournament.id,
|
|
staff: [{ userId: users.id(12), role: "STREAMER" }],
|
|
});
|
|
|
|
const room = await resolveOrThrow(chatRoomId);
|
|
|
|
expect(room.permissions.OBSERVE).toContain(users.id(12));
|
|
});
|
|
|
|
test("resolves a TOURNAMENT_TEAM room to the pickup team's members", async () => {
|
|
const authorId = users.id(2);
|
|
const memberUserIds = [users.id(3), users.id(4)];
|
|
const tournament = await TournamentFactory.create({ authorId });
|
|
const team = await TournamentTeamFactory.create(
|
|
{ tournamentId: tournament.id, memberUserIds },
|
|
{ isLooking: true },
|
|
);
|
|
|
|
const room = await resolveOrThrow(await teamChatRoomId(team.id));
|
|
|
|
expect(room.type).toBe("TOURNAMENT_TEAM");
|
|
expect(room.participantUserIds.sort(byId)).toEqual(
|
|
memberUserIds.sort(byId),
|
|
);
|
|
expect(room.titleParams.teamName).toEqual(expect.any(String));
|
|
expect(room.permissions.OBSERVE).toContain(authorId);
|
|
});
|
|
|
|
test("resolves a SCRIM room to the post's users plus the accepted request's users", async () => {
|
|
const { chatRoomId, postUserIds, requestUserIds, startsAt } =
|
|
await setupAcceptedScrim();
|
|
|
|
const room = await resolveOrThrow(chatRoomId);
|
|
|
|
expect(room.type).toBe("SCRIM");
|
|
expect(room.participantUserIds.sort(byId)).toEqual(
|
|
[...postUserIds, ...requestUserIds].sort(byId),
|
|
);
|
|
expect(room.titleParams.startsAt).toBe(
|
|
String(dateToDatabaseTimestamp(startsAt)),
|
|
);
|
|
});
|
|
|
|
test("resolves to nothing when the owner row is gone", async () => {
|
|
const { postId, chatRoomId } = await setupAcceptedScrim();
|
|
// deleting an owner in a way that skips its delete transaction would orphan
|
|
// the room; simulate by resolving an id the reaper would clean up
|
|
await ScrimPostRepository.deleteById(postId);
|
|
|
|
expect(await ChatRoomResolver.resolve(chatRoomId)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("ChatRoomResolver.findAllByUserId", () => {
|
|
test("returns the member's own group and match rooms of an SQ match", async () => {
|
|
const { match, alphaUserIds } = await setupSqMatch(users);
|
|
|
|
const rooms = await ChatRoomResolver.findAllByUserId(alphaUserIds[0]);
|
|
|
|
expect(
|
|
rooms
|
|
.map((room) => ({ roomId: room.roomId, type: room.type }))
|
|
.sort(byRoomId),
|
|
).toEqual(
|
|
[
|
|
{ roomId: match.chatRoomId!, type: "SQ_MATCH" },
|
|
{
|
|
roomId: await groupChatRoomId(match.alphaGroup.id),
|
|
type: "SQ_GROUP",
|
|
},
|
|
].sort(byRoomId),
|
|
);
|
|
});
|
|
|
|
test("leaves out a solo group's room", async () => {
|
|
await SQGroupFactory.create({ memberUserIds: [users.id(2)] });
|
|
|
|
expect(await ChatRoomResolver.findAllByUserId(users.id(2))).toEqual([]);
|
|
});
|
|
|
|
test("returns tournament match and team rooms through membership", async () => {
|
|
const { chatRoomId, teamAlphaUserIds } =
|
|
await setupStartedTournamentMatch();
|
|
|
|
const rooms = await ChatRoomResolver.findAllByUserId(teamAlphaUserIds[0]);
|
|
|
|
expect(rooms.map((room) => room.roomId)).toContain(chatRoomId);
|
|
});
|
|
|
|
test("returns scrim rooms for both sides", async () => {
|
|
const { chatRoomId, requestUserIds } = await setupAcceptedScrim();
|
|
|
|
const rooms = await ChatRoomResolver.findAllByUserId(requestUserIds[1]);
|
|
|
|
expect(rooms.map((room) => room.roomId)).toEqual([chatRoomId]);
|
|
});
|
|
|
|
test("returns nothing for a non-participant", async () => {
|
|
await setupSqMatch(users);
|
|
await setupAcceptedScrim();
|
|
|
|
expect(await ChatRoomResolver.findAllByUserId(outsiderId())).toEqual([]);
|
|
});
|
|
|
|
test("leaves out expired rooms", async () => {
|
|
const { match, alphaUserIds } = await setupSqMatch(users);
|
|
await ChatRepository.updateRoomExpiresAt({
|
|
roomId: match.chatRoomId!,
|
|
expiresAt: subHours(new Date(), 1),
|
|
});
|
|
|
|
const rooms = await ChatRoomResolver.findAllByUserId(alphaUserIds[0]);
|
|
|
|
expect(rooms.map((room) => room.roomId)).toEqual([
|
|
await groupChatRoomId(match.alphaGroup.id),
|
|
]);
|
|
});
|
|
|
|
test("leaves out closed rooms", async () => {
|
|
const { requestUserIds } = await setupAcceptedScrim();
|
|
await ChatRepository.closeExpiredRooms(addHours(new Date(), 100_000));
|
|
|
|
expect(await ChatRoomResolver.findAllByUserId(requestUserIds[0])).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ChatRoomResolver.resolve labels", () => {
|
|
test("labels the tournament's organizers TO and its streamers Stream", async () => {
|
|
const { tournament, chatRoomId, authorId } =
|
|
await setupStartedTournamentMatch();
|
|
await TournamentRepository.setStaff({
|
|
tournamentId: tournament.id,
|
|
staff: [
|
|
{ userId: users.id(11), role: "ORGANIZER" },
|
|
{ userId: users.id(12), role: "STREAMER" },
|
|
],
|
|
});
|
|
|
|
const room = await resolveOrThrow(chatRoomId);
|
|
|
|
expect(room.labelByUserId).toEqual({
|
|
[authorId]: "TO",
|
|
[users.id(11)]: "TO",
|
|
[users.id(12)]: "Stream",
|
|
});
|
|
});
|
|
|
|
test("leaves out an organizer playing in the room's own match", async () => {
|
|
const { tournament, chatRoomId, teamAlphaUserIds } =
|
|
await setupStartedTournamentMatch();
|
|
await TournamentRepository.setStaff({
|
|
tournamentId: tournament.id,
|
|
staff: [{ userId: teamAlphaUserIds[0], role: "ORGANIZER" }],
|
|
});
|
|
|
|
const room = await resolveOrThrow(chatRoomId);
|
|
|
|
expect(room.labelByUserId[teamAlphaUserIds[0]]).toBeUndefined();
|
|
});
|
|
|
|
test("labels no one in a room with no tournament behind it", async () => {
|
|
const { chatRoomId } = await setupAcceptedScrim();
|
|
|
|
const room = await resolveOrThrow(chatRoomId);
|
|
|
|
expect(room.labelByUserId).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("ChatRoomResolver.resolve permissions", () => {
|
|
test("site staff observe both group chats of an SQ match", async () => {
|
|
const { match } = await setupSqMatch(users);
|
|
|
|
const rooms = await Promise.all(
|
|
[
|
|
match.chatRoomId!,
|
|
await groupChatRoomId(match.alphaGroup.id),
|
|
await groupChatRoomId(match.bravoGroup.id),
|
|
].map(resolveOrThrow),
|
|
);
|
|
|
|
for (const room of rooms) {
|
|
expect(hasPermission(room, "OBSERVE", { id: adminId() })).toBe(true);
|
|
expect(hasPermission(room, "OBSERVE", { id: outsiderId() })).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("a participant of one group cannot view the other group's chat", async () => {
|
|
const { match, alphaUserIds } = await setupSqMatch(users);
|
|
|
|
const bravoRoom = await resolveOrThrow(
|
|
await groupChatRoomId(match.bravoGroup.id),
|
|
);
|
|
|
|
expect(hasPermission(bravoRoom, "VIEW", { id: alphaUserIds[0] })).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("tournament organizer observes and may post into the match chat", async () => {
|
|
const { chatRoomId, authorId } = await setupStartedTournamentMatch();
|
|
|
|
const room = await resolveOrThrow(chatRoomId);
|
|
|
|
expect(hasPermission(room, "OBSERVE", { id: authorId })).toBe(true);
|
|
expect(hasPermission(room, "VIEW", { id: authorId })).toBe(true);
|
|
expect(hasPermission(room, "POST", { id: authorId })).toBe(true);
|
|
});
|
|
|
|
test("participants lose view once the room closes; observers keep it", async () => {
|
|
const { chatRoomId, authorId, teamAlphaUserIds } =
|
|
await setupStartedTournamentMatch();
|
|
await ChatRepository.closeExpiredRooms(addHours(new Date(), 100_000));
|
|
|
|
const room = await resolveOrThrow(chatRoomId);
|
|
|
|
expect(room.closedAt).not.toBeNull();
|
|
expect(hasPermission(room, "VIEW", { id: teamAlphaUserIds[0] })).toBe(
|
|
false,
|
|
);
|
|
expect(hasPermission(room, "VIEW", { id: authorId })).toBe(true);
|
|
expect(hasPermission(room, "VIEW", { id: adminId() })).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("ChatRoomResolver.permissionsOf", () => {
|
|
const openRoom = {
|
|
type: "SQ_MATCH" as const,
|
|
expiresAt: dateToDatabaseTimestamp(addHours(new Date(), 1)),
|
|
closedAt: null,
|
|
};
|
|
const expiredRoom = {
|
|
...openRoom,
|
|
expiresAt: dateToDatabaseTimestamp(addHours(new Date(), -1)),
|
|
};
|
|
|
|
test.each([
|
|
{
|
|
why: "participant of an open room",
|
|
args: { room: openRoom, participantUserIds: [100], observerUserIds: [] },
|
|
userId: 100,
|
|
allowed: true,
|
|
},
|
|
{
|
|
why: "non-participant",
|
|
args: { room: openRoom, participantUserIds: [100], observerUserIds: [] },
|
|
userId: 101,
|
|
allowed: false,
|
|
},
|
|
{
|
|
why: "expired room",
|
|
args: {
|
|
room: expiredRoom,
|
|
participantUserIds: [100],
|
|
observerUserIds: [],
|
|
},
|
|
userId: 100,
|
|
allowed: false,
|
|
},
|
|
{
|
|
why: "closed room",
|
|
args: {
|
|
room: { ...openRoom, closedAt: 1 },
|
|
participantUserIds: [100],
|
|
observerUserIds: [],
|
|
},
|
|
userId: 100,
|
|
allowed: false,
|
|
},
|
|
{
|
|
why: "observer of an open match room",
|
|
args: {
|
|
room: openRoom,
|
|
participantUserIds: [100],
|
|
observerUserIds: [101],
|
|
},
|
|
userId: 101,
|
|
allowed: true,
|
|
},
|
|
{
|
|
why: "observer of an expired match room",
|
|
args: {
|
|
room: expiredRoom,
|
|
participantUserIds: [100],
|
|
observerUserIds: [101],
|
|
},
|
|
userId: 101,
|
|
allowed: false,
|
|
},
|
|
{
|
|
why: "observer of a group chat (private team space)",
|
|
args: {
|
|
room: { ...openRoom, type: "SQ_GROUP" as const },
|
|
participantUserIds: [100],
|
|
observerUserIds: [101],
|
|
},
|
|
userId: 101,
|
|
allowed: false,
|
|
},
|
|
{
|
|
why: "observer of a team pickup chat (private team space)",
|
|
args: {
|
|
room: { ...openRoom, type: "TOURNAMENT_TEAM" as const },
|
|
participantUserIds: [100],
|
|
observerUserIds: [101],
|
|
},
|
|
userId: 101,
|
|
allowed: false,
|
|
},
|
|
])("POST: $why -> $allowed", ({ args, userId, allowed }) => {
|
|
expect(ChatRoomResolver.permissionsOf(args).POST.includes(userId)).toBe(
|
|
allowed,
|
|
);
|
|
});
|
|
});
|
|
|
|
const resolveOrThrow = async (roomId: number) => {
|
|
const room = await ChatRoomResolver.resolve(roomId);
|
|
invariant(room, `room ${roomId} did not resolve`);
|
|
|
|
return room;
|
|
};
|
|
|
|
const groupChatRoomId = async (groupId: number) => {
|
|
const group = await db
|
|
.selectFrom("Group")
|
|
.select("Group.chatRoomId")
|
|
.where("Group.id", "=", groupId)
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return group.chatRoomId!;
|
|
};
|
|
|
|
const teamChatRoomId = async (teamId: number) => {
|
|
const team = await db
|
|
.selectFrom("TournamentTeam")
|
|
.select("TournamentTeam.chatRoomId")
|
|
.where("TournamentTeam.id", "=", teamId)
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return team.chatRoomId!;
|
|
};
|
|
|
|
const byId = (a: number, b: number) => a - b;
|
|
|
|
const byRoomId = (a: { roomId: number }, b: { roomId: number }) =>
|
|
a.roomId - b.roomId;
|