Undo requests when changing map prefrences / noScreen as full group member
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-07-21 21:30:23 +03:00
parent c2d3d2e221
commit 7ab8daeebd
6 changed files with 187 additions and 20 deletions

View File

@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { db } from "~/db/sql";
import type { UserMapModePreferences } from "~/db/tables";
import { dbInsertUsers, dbReset, withUserId } from "~/utils/Test";
import * as MatchProfileRepository from "./MatchProfileRepository.server";
const USER_ID = 1;
const PREFERENCES: UserMapModePreferences = {
modes: [{ mode: "SZ", preference: "PREFER" }],
pool: [{ mode: "SZ", stages: [1, 2, 3, 4] }],
};
const OTHER_PREFERENCES: UserMapModePreferences = {
modes: [{ mode: "SZ", preference: "PREFER" }],
pool: [{ mode: "SZ", stages: [5, 6, 7, 8] }],
};
const updateProfile = (
args: Partial<
Parameters<typeof MatchProfileRepository.updateOwnMatchProfile>[0]
> = {},
) =>
withUserId(USER_ID, () =>
MatchProfileRepository.updateOwnMatchProfile({
mapModePreferences: PREFERENCES,
vc: "NO",
languages: [],
weaponPool: [],
noScreen: 0,
...args,
}),
);
describe("updateOwnMatchProfile", () => {
beforeEach(async () => {
await dbInsertUsers(1);
await db
.updateTable("User")
.set({ mapModePreferences: JSON.stringify(PREFERENCES), noScreen: 0 })
.where("id", "=", USER_ID)
.execute();
});
afterEach(() => {
dbReset();
});
test("reports no change when nothing matchmaking-relevant changed", async () => {
const result = await updateProfile({ vc: "YES", languages: ["en"] });
expect(result.mapModePreferencesChanged).toBe(false);
expect(result.noScreenChanged).toBe(false);
});
test("detects a noScreen change", async () => {
const result = await updateProfile({ noScreen: 1 });
expect(result.noScreenChanged).toBe(true);
expect(result.mapModePreferencesChanged).toBe(false);
});
test("detects a map/mode preferences change", async () => {
const result = await updateProfile({
mapModePreferences: OTHER_PREFERENCES,
});
expect(result.mapModePreferencesChanged).toBe(true);
expect(result.noScreenChanged).toBe(false);
});
});

View File

@@ -1,3 +1,4 @@
import * as R from "remeda";
import { db } from "~/db/sql";
import type { Tables, UserMapModePreferences } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
@@ -56,20 +57,29 @@ export async function updateOwnMatchProfile({
noScreen: number;
}) {
const userId = actorId();
const currentPreferences = (
await db
.selectFrom("User")
.select("mapModePreferences")
.where("id", "=", userId)
.executeTakeFirstOrThrow()
).mapModePreferences;
const current = await db
.selectFrom("User")
.select(["mapModePreferences", "noScreen"])
.where("id", "=", userId)
.executeTakeFirstOrThrow();
const mergedPool = mergeExcludedModePreferences(
mapModePreferences.pool,
currentPreferences?.pool,
current.mapModePreferences?.pool,
);
return db.transaction().execute(async (trx) => {
const newMapModePreferences: UserMapModePreferences = {
...mapModePreferences,
pool: mergedPool,
};
const mapModePreferencesChanged = !R.isDeepEqual(
newMapModePreferences,
current.mapModePreferences,
);
const noScreenChanged = current.noScreen !== noScreen;
await db.transaction().execute(async (trx) => {
await trx
.deleteFrom("UserWeaponPool")
.where("userId", "=", userId)
@@ -92,10 +102,7 @@ export async function updateOwnMatchProfile({
await trx
.updateTable("User")
.set({
mapModePreferences: JSON.stringify({
...mapModePreferences,
pool: mergedPool,
}),
mapModePreferences: JSON.stringify(newMapModePreferences),
vc,
languages: languages.length > 0 ? languages.join(",") : null,
noScreen,
@@ -103,6 +110,8 @@ export async function updateOwnMatchProfile({
.where("id", "=", userId)
.execute();
});
return { mapModePreferencesChanged, noScreenChanged };
}
/**

View File

@@ -675,6 +675,11 @@ export function deleteLike({
});
}
/** Deletes every like where the given group is the liker or the target. */
export function deleteAllLikesByGroupId(groupId: number) {
return db.transaction().execute((trx) => deleteLikesByGroupId(groupId, trx));
}
export function leaveGroup(userId: number) {
return db.transaction().execute(async (trx) => {
const userGroup = await trx

View File

@@ -0,0 +1,44 @@
import * as R from "remeda";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import {
FULL_GROUP_SIZE,
SENDOUQ_LOOKING_ROOM,
sqGroupWebsocketRoom,
} from "../q-constants";
import { refreshSendouQInstance, SendouQ } from "./SendouQ.server";
/**
* Cancels every pending challenge (both given and received) involving the user's
* active and full SendouQ group. Only full groups are affected: partial groups
* merge (rather than start a match) when a request is accepted, so their members'
* preferences are not yet locked in.
*/
export async function cancelActiveGroupLikes(userId: number) {
const ownGroup = SendouQ.findOwnGroup(userId);
if (!ownGroup) return;
if (ownGroup.status !== "ACTIVE" || ownGroup.matchId) return;
if (ownGroup.members.length !== FULL_GROUP_SIZE) return;
const likes = await SQGroupRepository.allLikesByGroupId(ownGroup.id);
const affectedGroupIds = R.unique([
...likes.given.map((like) => like.groupId),
...likes.received.map((like) => like.groupId),
]);
if (affectedGroupIds.length === 0) return;
await SQGroupRepository.deleteAllLikesByGroupId(ownGroup.id);
await refreshSendouQInstance();
ChatSystemMessage.send([
...[...affectedGroupIds, ownGroup.id].map((groupId) => ({
room: sqGroupWebsocketRoom(groupId),
revalidateOnly: true,
})),
{
room: SENDOUQ_LOOKING_ROOM,
revalidateOnly: true,
},
]);
}

View File

@@ -1,6 +1,7 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import { cancelActiveGroupLikes } from "~/features/sendouq/core/likes.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { isSupporter } from "~/modules/permissions/utils";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
@@ -59,13 +60,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
break;
}
case "UPDATE_MATCH_PROFILE": {
await MatchProfileRepository.updateOwnMatchProfile({
mapModePreferences: data.mapModePreferences,
vc: data.vc,
languages: data.languages,
weaponPool: data.weaponPool,
noScreen: Number(data.noScreen),
});
const { mapModePreferencesChanged, noScreenChanged } =
await MatchProfileRepository.updateOwnMatchProfile({
mapModePreferences: data.mapModePreferences,
vc: data.vc,
languages: data.languages,
weaponPool: data.weaponPool,
noScreen: Number(data.noScreen),
});
// Challenges are made based on the modes/preferences shown at that
// moment, so changing them must undo pending requests to/from the group.
if (mapModePreferencesChanged || noScreenChanged) {
await cancelActiveGroupLikes(user.id);
}
break;
}
default: {

View File

@@ -4,6 +4,7 @@ import {
SENDOUQ_LOOKING_PAGE,
SENDOUQ_PAGE,
SENDOUQ_PREPARING_PAGE,
SETTINGS_PAGE,
sendouQInviteLink,
} from "~/utils/urls";
import {
@@ -13,6 +14,7 @@ import {
seed,
submit,
test,
waitForPOSTResponse,
} from "./helpers/playwright";
test.describe("SendouQ", () => {
@@ -140,4 +142,32 @@ test.describe("SendouQ", () => {
combinedGroup.getByTestId("sendouq-group-card-member"),
).toHaveCount(2);
});
test("Changing match preferences cancels pending requests", async ({
page,
}) => {
await seed(page);
// Sendou (ADMIN) is in a full group. Challenge another full group.
await impersonate(page, ADMIN_ID);
await navigate({ page, url: SENDOUQ_LOOKING_PAGE });
await waitForPOSTResponse(page, () =>
page.getByRole("button", { name: "Challenge" }).first().click(),
);
// The challenge is now pending and can be undone
await expect(page.getByRole("button", { name: "Undo" })).toHaveCount(1);
// Changing a matchmaking preference (noScreen) last second must undo the
// pending request so it can't be matched on terms the challenger never saw
await navigate({ page, url: `${SETTINGS_PAGE}?tab=match-profile` });
await page
.getByRole("switch", { name: /Avoid Splattercolor Screen/i })
.click({ force: true });
await submit(page);
// The pending challenge has been undone
await navigate({ page, url: SENDOUQ_LOOKING_PAGE });
await expect(page.getByRole("button", { name: "Undo" })).toHaveCount(0);
});
});