mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 13:47:56 -05:00
497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
import { sub } from "date-fns";
|
|
import { describe, expect, test } from "vitest";
|
|
import { backdate } from "~/db/seed/core/backdate";
|
|
import * as GroupMatchContinueVoteFactory from "~/db/seed/factories/GroupMatchContinueVoteFactory";
|
|
import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
|
|
import * as SQGroupLikeFactory from "~/db/seed/factories/SQGroupLikeFactory";
|
|
import * as SQGroupSuggestionFactory from "~/db/seed/factories/SQGroupSuggestionFactory";
|
|
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
|
|
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
|
|
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
|
import { db } from "~/db/sql";
|
|
import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server";
|
|
import { invariant } from "~/utils/invariant";
|
|
import { FULL_GROUP_SIZE } from "./q-constants";
|
|
import * as SQGroupRepository from "./SQGroupRepository.server";
|
|
|
|
const setupConcludedMatch = async (
|
|
/** Give the same members another match, for a user with a match history. */
|
|
returningAlphaMembers?: Array<{ id: number }>,
|
|
) => {
|
|
const bravoMembers = await UserFactory.createMany(FULL_GROUP_SIZE);
|
|
const alphaMembers =
|
|
returningAlphaMembers ?? (await UserFactory.createMany(FULL_GROUP_SIZE));
|
|
|
|
const match = await SQMatchFactory.create(
|
|
{
|
|
alphaUserIds: alphaMembers.map((member) => member.id),
|
|
bravoUserIds: bravoMembers.map((member) => member.id),
|
|
isMatchmade: true,
|
|
},
|
|
{ isConcluded: true },
|
|
);
|
|
|
|
return {
|
|
alphaGroupId: match.alphaGroup.id,
|
|
bravoGroupId: match.bravoGroup.id,
|
|
matchChatRoomId: match.chatRoomId,
|
|
alphaMembers,
|
|
};
|
|
};
|
|
|
|
const fetchVotes = (groupId: number) =>
|
|
GroupMatchContinueVoteRepository.findAllByGroupIds([groupId]);
|
|
|
|
const castYesVote = (userId: number, groupId: number) =>
|
|
GroupMatchContinueVoteFactory.create({ userId, groupId });
|
|
|
|
/** Creates a full team's worth of users and the team they are all members of. */
|
|
const setupTeam = async () => {
|
|
const members = await UserFactory.createMany(FULL_GROUP_SIZE);
|
|
const team = await TeamFactory.create({
|
|
memberUserIds: members.map((member) => member.id),
|
|
});
|
|
|
|
return { team, members };
|
|
};
|
|
|
|
const groupChatRoomId = async (groupId: number) => {
|
|
const group = await db
|
|
.selectFrom("Group")
|
|
.select("Group.chatRoomId")
|
|
.where("Group.id", "=", groupId)
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return group.chatRoomId;
|
|
};
|
|
|
|
const allChatRooms = () => db.selectFrom("ChatRoom").selectAll().execute();
|
|
|
|
const teamIdOfGroup = async (groupId: number) => {
|
|
const group = await db
|
|
.selectFrom("Group")
|
|
.select("Group.teamId")
|
|
.where("Group.id", "=", groupId)
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return group.teamId;
|
|
};
|
|
|
|
describe("insert", () => {
|
|
test("creates an SQ_GROUP chat room owned by the group", async () => {
|
|
const user = await UserFactory.create();
|
|
|
|
const result = await SQGroupRepository.insert({
|
|
status: "PREPARING",
|
|
userId: user.id,
|
|
});
|
|
|
|
const chatRoomId = await groupChatRoomId(result.id);
|
|
expect(chatRoomId).toEqual(expect.any(Number));
|
|
|
|
const rooms = await allChatRooms();
|
|
expect(rooms).toHaveLength(1);
|
|
expect(rooms[0].type).toBe("SQ_GROUP");
|
|
});
|
|
|
|
test("records implicit no-vote on previous matchmade group when user creates a new group", async () => {
|
|
const { alphaGroupId, alphaMembers, matchChatRoomId } =
|
|
await setupConcludedMatch();
|
|
|
|
const votesBefore = await fetchVotes(alphaGroupId);
|
|
expect(votesBefore).toHaveLength(0);
|
|
|
|
const result = await SQGroupRepository.insert({
|
|
status: "ACTIVE",
|
|
userId: alphaMembers[0].id,
|
|
});
|
|
|
|
const votes = await fetchVotes(alphaGroupId);
|
|
expect(votes).toHaveLength(1);
|
|
expect(votes[0].userId).toBe(alphaMembers[0].id);
|
|
expect(votes[0].isContinuing).toBe(false);
|
|
expect(result.chatRoomIdToRevalidate).toBe(matchChatRoomId);
|
|
});
|
|
|
|
test("overrides the user's own yes vote on the previous match", async () => {
|
|
const { alphaGroupId, alphaMembers, matchChatRoomId } =
|
|
await setupConcludedMatch();
|
|
|
|
await castYesVote(alphaMembers[0].id, alphaGroupId);
|
|
|
|
const result = await SQGroupRepository.insert({
|
|
status: "ACTIVE",
|
|
userId: alphaMembers[0].id,
|
|
});
|
|
|
|
// leaving a yes vote standing would let the rest of the group reach a
|
|
// unanimous vote for a group the user is no longer available for
|
|
const votes = await fetchVotes(alphaGroupId);
|
|
expect(votes).toHaveLength(1);
|
|
expect(votes[0].isContinuing).toBe(false);
|
|
expect(result.chatRoomIdToRevalidate).toBe(matchChatRoomId);
|
|
});
|
|
|
|
test("clears other members' yes votes on the previous group when recording implicit no", async () => {
|
|
const { alphaGroupId, alphaMembers } = await setupConcludedMatch();
|
|
|
|
await castYesVote(alphaMembers[1].id, alphaGroupId);
|
|
|
|
const votesBefore = await fetchVotes(alphaGroupId);
|
|
expect(votesBefore[0].userId).toBe(alphaMembers[1].id);
|
|
|
|
await SQGroupRepository.insert({
|
|
status: "ACTIVE",
|
|
userId: alphaMembers[0].id,
|
|
});
|
|
|
|
const votes = await fetchVotes(alphaGroupId);
|
|
expect(votes).toHaveLength(1);
|
|
expect(votes[0].userId).toBe(alphaMembers[0].id);
|
|
expect(votes[0].isContinuing).toBe(false);
|
|
});
|
|
|
|
test("records the implicit no-vote on the newest matchmade group of many", async () => {
|
|
const { alphaGroupId: olderGroupId, alphaMembers } =
|
|
await setupConcludedMatch();
|
|
const { alphaGroupId: newerGroupId, matchChatRoomId } =
|
|
await setupConcludedMatch(alphaMembers);
|
|
const olderVotesBefore = await fetchVotes(olderGroupId);
|
|
|
|
const result = await SQGroupRepository.insert({
|
|
status: "ACTIVE",
|
|
userId: alphaMembers[0].id,
|
|
});
|
|
|
|
expect(await fetchVotes(olderGroupId)).toEqual(olderVotesBefore);
|
|
|
|
const votes = await fetchVotes(newerGroupId);
|
|
expect(votes).toHaveLength(1);
|
|
expect(votes[0].userId).toBe(alphaMembers[0].id);
|
|
expect(votes[0].isContinuing).toBe(false);
|
|
expect(result.chatRoomIdToRevalidate).toBe(matchChatRoomId);
|
|
});
|
|
|
|
test("leaves the previous group's votes alone on a later, unrelated queue action", async () => {
|
|
const { alphaGroupId, alphaMembers } = await setupConcludedMatch();
|
|
|
|
await SQGroupRepository.insert({
|
|
status: "ACTIVE",
|
|
userId: alphaMembers[0].id,
|
|
});
|
|
|
|
// the three who stayed settle the vote among themselves
|
|
for (const member of alphaMembers.slice(1)) {
|
|
await castYesVote(member.id, alphaGroupId);
|
|
}
|
|
|
|
// ...meanwhile the one who left gives up on queueing alone and queues again
|
|
await SQGroupRepository.leaveGroup(alphaMembers[0].id);
|
|
const result = await SQGroupRepository.insert({
|
|
status: "ACTIVE",
|
|
userId: alphaMembers[0].id,
|
|
});
|
|
|
|
const votes = await fetchVotes(alphaGroupId);
|
|
expect(votes.filter((vote) => vote.isContinuing)).toHaveLength(
|
|
FULL_GROUP_SIZE - 1,
|
|
);
|
|
expect(result.chatRoomIdToRevalidate).toBeNull();
|
|
});
|
|
|
|
test("does not record any vote when user has no previous matchmade group", async () => {
|
|
const user = await UserFactory.create();
|
|
|
|
const result = await SQGroupRepository.insert({
|
|
status: "ACTIVE",
|
|
userId: user.id,
|
|
});
|
|
|
|
const allVotes = await GroupMatchContinueVoteRepository.findAllByGroupIds([
|
|
result.id,
|
|
]);
|
|
expect(allVotes).toHaveLength(0);
|
|
expect(result.chatRoomIdToRevalidate).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("insertMember", () => {
|
|
test("records implicit no-vote on previous matchmade group when user joins another group", async () => {
|
|
const { alphaGroupId, alphaMembers, matchChatRoomId } =
|
|
await setupConcludedMatch();
|
|
const newOwner = await UserFactory.create();
|
|
|
|
const newGroup = await SQGroupFactory.create({
|
|
status: "PREPARING",
|
|
memberUserIds: [newOwner.id],
|
|
});
|
|
|
|
const { chatRoomIdToRevalidate } = await SQGroupRepository.insertMember(
|
|
newGroup.id,
|
|
{ userId: alphaMembers[0].id },
|
|
);
|
|
|
|
const votes = await fetchVotes(alphaGroupId);
|
|
expect(votes).toHaveLength(1);
|
|
expect(votes[0].userId).toBe(alphaMembers[0].id);
|
|
expect(votes[0].isContinuing).toBe(false);
|
|
expect(chatRoomIdToRevalidate).toBe(matchChatRoomId);
|
|
});
|
|
});
|
|
|
|
describe("setOldGroupsAsInactive", () => {
|
|
test("deletes the expired groups' likes and suggestions", async () => {
|
|
const [expiring, other] = await UserFactory.createMany(2);
|
|
|
|
const expiringGroup = await SQGroupFactory.create({
|
|
memberUserIds: [expiring.id],
|
|
});
|
|
const otherGroup = await SQGroupFactory.create({
|
|
memberUserIds: [other.id],
|
|
});
|
|
|
|
await SQGroupRepository.insertLike({
|
|
likerGroupId: otherGroup.id,
|
|
targetGroupId: expiringGroup.id,
|
|
createdByUserId: other.id,
|
|
});
|
|
await SQGroupRepository.insertSuggestion({
|
|
suggesterGroupId: expiringGroup.id,
|
|
targetGroupId: otherGroup.id,
|
|
createdByUserId: expiring.id,
|
|
});
|
|
|
|
await backdate("Group", expiringGroup.id, {
|
|
latestActionAt: sub(new Date(), { hours: 2 }),
|
|
});
|
|
|
|
await SQGroupRepository.setOldGroupsAsInactive();
|
|
|
|
const likes = await db.selectFrom("GroupLike").selectAll().execute();
|
|
const suggestions = await db
|
|
.selectFrom("GroupSuggestion")
|
|
.selectAll()
|
|
.execute();
|
|
|
|
expect(likes).toHaveLength(0);
|
|
expect(suggestions).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("syncTeamId", () => {
|
|
test("stamps the group as the team's once the last of its members joins", async () => {
|
|
const { team, members } = await setupTeam();
|
|
const group = await SQGroupFactory.create({
|
|
memberUserIds: members.slice(0, -1).map((member) => member.id),
|
|
});
|
|
|
|
expect(await teamIdOfGroup(group.id)).toBeNull();
|
|
|
|
await SQGroupRepository.insertMember(group.id, {
|
|
userId: members.at(-1)!.id,
|
|
});
|
|
|
|
expect(await teamIdOfGroup(group.id)).toBe(team.id);
|
|
});
|
|
|
|
test("leaves the group unstamped when its members are not all of one team", async () => {
|
|
const { members } = await setupTeam();
|
|
const outsider = await UserFactory.create();
|
|
const group = await SQGroupFactory.create({
|
|
memberUserIds: members.slice(0, -1).map((member) => member.id),
|
|
});
|
|
|
|
await SQGroupRepository.insertMember(group.id, { userId: outsider.id });
|
|
|
|
expect(await teamIdOfGroup(group.id)).toBeNull();
|
|
});
|
|
|
|
test("clears the stamp when a member leaves", async () => {
|
|
const { team, members } = await setupTeam();
|
|
const group = await SQGroupFactory.create({
|
|
memberUserIds: members.map((member) => member.id),
|
|
});
|
|
|
|
expect(await teamIdOfGroup(group.id)).toBe(team.id);
|
|
|
|
await SQGroupRepository.leaveGroup(members[0].id);
|
|
|
|
expect(await teamIdOfGroup(group.id)).toBeNull();
|
|
});
|
|
|
|
test("recomputes the stamp rather than copying it from the previous group", async () => {
|
|
const { team, members } = await setupTeam();
|
|
const previousGroup = await SQGroupFactory.create({
|
|
memberUserIds: members.map((member) => member.id),
|
|
});
|
|
|
|
expect(await teamIdOfGroup(previousGroup.id)).toBe(team.id);
|
|
|
|
await backdate("Group", previousGroup.id, {
|
|
latestActionAt: sub(new Date(), { hours: 2 }),
|
|
});
|
|
await SQGroupRepository.setOldGroupsAsInactive();
|
|
|
|
const newGroup = await SQGroupRepository.insertFromPrevious({
|
|
previousGroupId: previousGroup.id,
|
|
memberUserIds: members.slice(0, -1).map((member) => member.id),
|
|
});
|
|
|
|
expect(await teamIdOfGroup(newGroup.id)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("insertFromPrevious", () => {
|
|
test("moves the previous group's chat room to the successor", async () => {
|
|
const { alphaGroupId, alphaMembers } = await setupConcludedMatch();
|
|
const previousChatRoomId = await groupChatRoomId(alphaGroupId);
|
|
|
|
const successor = await SQGroupRepository.insertFromPrevious({
|
|
previousGroupId: alphaGroupId,
|
|
memberUserIds: alphaMembers.map((member) => member.id),
|
|
});
|
|
|
|
expect(await groupChatRoomId(successor.id)).toBe(previousChatRoomId);
|
|
expect(await groupChatRoomId(alphaGroupId)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("morphGroups", () => {
|
|
test("gives the survivor a fresh chat room and deletes both old rooms", async () => {
|
|
const [userOne, userTwo] = await UserFactory.createMany(2);
|
|
const survivingGroup = await SQGroupFactory.create({
|
|
memberUserIds: [userOne.id],
|
|
});
|
|
const otherGroup = await SQGroupFactory.create({
|
|
memberUserIds: [userTwo.id],
|
|
});
|
|
const oldChatRoomId = await groupChatRoomId(survivingGroup.id);
|
|
|
|
await SQGroupRepository.morphGroups({
|
|
survivingGroupId: survivingGroup.id,
|
|
otherGroupId: otherGroup.id,
|
|
});
|
|
|
|
const survivorChatRoomId = await groupChatRoomId(survivingGroup.id);
|
|
expect(survivorChatRoomId).not.toBe(oldChatRoomId);
|
|
|
|
const rooms = await allChatRooms();
|
|
expect(rooms.map((room) => room.id)).toEqual([survivorChatRoomId]);
|
|
});
|
|
});
|
|
|
|
describe("leaveGroup", () => {
|
|
test("deletes the group and its chat room when the last member leaves", async () => {
|
|
const user = await UserFactory.create();
|
|
const group = await SQGroupFactory.create({ memberUserIds: [user.id] });
|
|
|
|
await SQGroupRepository.leaveGroup(user.id);
|
|
|
|
const groupRow = await db
|
|
.selectFrom("Group")
|
|
.selectAll()
|
|
.where("id", "=", group.id)
|
|
.executeTakeFirst();
|
|
expect(groupRow).toBeUndefined();
|
|
expect(await allChatRooms()).toHaveLength(0);
|
|
});
|
|
|
|
test("clears the challenges the group received", async () => {
|
|
const { ownGroup, challengerGroup } = await setupGroupWithChallenges();
|
|
|
|
await SQGroupRepository.leaveGroup(ownGroup.leaverId);
|
|
|
|
const likes = await SQGroupRepository.findAllLikesByGroupId(ownGroup.id);
|
|
expect(likes.received).toHaveLength(0);
|
|
expect(
|
|
await SQGroupRepository.findAllLikesByGroupId(challengerGroup.id),
|
|
).toMatchObject({ given: [] });
|
|
});
|
|
|
|
test("clears the challenges the leaver sent but not those of the members who stay", async () => {
|
|
const { ownGroup, leaverTargetGroup, stayerTargetGroup } =
|
|
await setupGroupWithChallenges();
|
|
|
|
await SQGroupRepository.leaveGroup(ownGroup.leaverId);
|
|
|
|
const likes = await SQGroupRepository.findAllLikesByGroupId(ownGroup.id);
|
|
expect(likes.given.map((like) => like.groupId)).toEqual([
|
|
stayerTargetGroup.id,
|
|
]);
|
|
expect(
|
|
await SQGroupRepository.findAllLikesByGroupId(leaverTargetGroup.id),
|
|
).toMatchObject({ received: [] });
|
|
});
|
|
|
|
test("clears the suggestions the leaver made but not those of the members who stay", async () => {
|
|
const { ownGroup, stayerTargetGroup } = await setupGroupWithChallenges();
|
|
|
|
await SQGroupRepository.leaveGroup(ownGroup.leaverId);
|
|
|
|
const suggestions = await SQGroupRepository.findAllSuggestionsByGroupId(
|
|
ownGroup.id,
|
|
);
|
|
expect(suggestions.map((suggestion) => suggestion.groupId)).toEqual([
|
|
stayerTargetGroup.id,
|
|
]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A group of two whose first member is about to leave, with a challenge received, one sent by
|
|
* each of its members and a suggestion made by each of them.
|
|
*/
|
|
const setupGroupWithChallenges = async () => {
|
|
const [leaver, stayer] = await UserFactory.createMany(2);
|
|
invariant(leaver && stayer, "Expected two users");
|
|
|
|
const ownGroup = await SQGroupFactory.create({
|
|
memberUserIds: [leaver.id, stayer.id],
|
|
});
|
|
|
|
const [challengerGroup, leaverTargetGroup, stayerTargetGroup] =
|
|
await createSoloGroups(3);
|
|
|
|
await SQGroupLikeFactory.create({
|
|
likerGroupId: challengerGroup.id,
|
|
targetGroupId: ownGroup.id,
|
|
createdByUserId: challengerGroup.memberUserIds[0]!,
|
|
});
|
|
await SQGroupLikeFactory.create({
|
|
likerGroupId: ownGroup.id,
|
|
targetGroupId: leaverTargetGroup.id,
|
|
createdByUserId: leaver.id,
|
|
});
|
|
await SQGroupLikeFactory.create({
|
|
likerGroupId: ownGroup.id,
|
|
targetGroupId: stayerTargetGroup.id,
|
|
createdByUserId: stayer.id,
|
|
});
|
|
|
|
await SQGroupSuggestionFactory.create({
|
|
suggesterGroupId: ownGroup.id,
|
|
targetGroupId: challengerGroup.id,
|
|
createdByUserId: leaver.id,
|
|
});
|
|
await SQGroupSuggestionFactory.create({
|
|
suggesterGroupId: ownGroup.id,
|
|
targetGroupId: stayerTargetGroup.id,
|
|
createdByUserId: stayer.id,
|
|
});
|
|
|
|
return {
|
|
ownGroup: { ...ownGroup, leaverId: leaver.id },
|
|
challengerGroup,
|
|
leaverTargetGroup,
|
|
stayerTargetGroup,
|
|
};
|
|
};
|
|
|
|
const createSoloGroups = async (count: number) => {
|
|
const users = await UserFactory.createMany(count);
|
|
|
|
return Promise.all(
|
|
users.map((user) => SQGroupFactory.create({ memberUserIds: [user.id] })),
|
|
);
|
|
};
|