1
0
Fork 0
polaris-task-force/tests/int/mission-role-signups.int.spec.ts
Z8MB1E 190ddac41f feat(missions): role sign-ups with FCFS waitlist and rich-text role descriptions
Players meeting a role's minimum rank and MOS can queue for mission roles before op time. Slot holders are recomputed statelessly from signup order plus current RSVP (first N committed signups hold the role); attendance writes diff holders around the write so RSVP changes auto-promote, with notifications and events. Adds mission-role-signups collection, roleSignups/missionTime libs, sign-up panel, role slots + rich-text descriptions (with RoleDescription renderer), orphan-pruning hook, migrations, tests, and AGENTS.md docs.
2026-09-30 14:50:26 -04:00

581 lines
20 KiB
TypeScript

import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type {
Map as MissionMap,
Mission,
MissionRoleSignup,
Qualification,
Rank,
User,
} from "@/payload-types";
import {
diffPromotedUsers,
getMissionRoleSignupState,
getSlotHolders,
removeRoleSignup,
roleSignupGate,
roleSignupsClosedMessage,
signupForRole,
withdrawRoleSignup,
} from "@/lib/intelligence/roleSignups";
import { setMissionAttendance } from "@/lib/attendance";
let payload: Payload;
const RUN = `mrs-${Date.now().toString(36)}`;
/**
* User deletion trips FK constraints unless the hook-provisioned personal bank
* account and profile are removed first, and event/notification rows must go
* before the users they reference.
*/
const deleteUserWithRelations = async (pg: Payload, id: number): Promise<void> => {
const accounts = await pg
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await pg
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
.catch(() => {});
}
const profiles = await pg
.find({
collection: "profiles",
where: { user: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await pg
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await pg.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
};
const deleteMissionWithRelations = async (pg: Payload, missionId: number): Promise<void> => {
for (const collection of ["mission-role-signups", "mission-attendances"] as const) {
const docs = await pg
.find({
collection,
where: { mission: { equals: missionId } },
limit: 500,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const doc of docs?.docs ?? []) {
await pg.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
}
}
await pg.delete({ collection: "missions", id: missionId, overrideAccess: true }).catch(() => {});
};
describe("Mission role sign-ups", () => {
let mapId: number;
let missionId: number;
let queueMissionId: number;
let switchMissionId: number;
let bypassMissionId: number;
let pastMissionId: number;
let cancelledMissionId: number;
const missionIds: number[] = [];
const rankIds: number[] = [];
const qualIds: number[] = [];
const userIds: number[] = [];
let pvt: Rank;
let cpl: Rank;
let sgt: Rank;
let medic: Qualification;
let memberA: User; // Pvt with the Medic MOS
let memberB: User; // Pvt with no qualifications
let memberC: User; // Cpl, no qualifications
let memberD: User; // Sgt with the Medic MOS
let adminUser: User;
const makeMission = async (
label: string,
options: {
status?: Mission["ownershipAndStatus"]["status"];
start?: Date;
roles: Record<string, unknown>[];
},
): Promise<Mission> => {
const providedRoles = options.roles as NonNullable<Mission["missionRoles"]>["providedRoles"];
const mission = (await payload.create({
collection: "missions",
data: {
name: `${RUN} ${label}`,
codeName: `${RUN}-${label}`,
summary: "Role sign-up test mission",
operationType: "main",
classification: {
map: mapId,
missionType: "PvE",
startDateTime: (
options.start ?? new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)
).toISOString(),
estimatedDuration: 60,
},
ownershipAndStatus: {
authors: [adminUser.id],
status: options.status ?? "Scheduled",
visibility: "unit",
},
briefing: [],
gameDetails: {
serverDetails: {
serverIp: "127.0.0.1",
serverPort: 2302,
},
},
missionRoles: {
maxPlayers: 32,
providedRoles,
},
},
overrideAccess: true,
depth: 0,
})) as unknown as Mission;
missionIds.push(mission.id);
return mission;
};
const makeUser = async (username: string, roles: User["roles"] = ["user"]): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username,
discordUsername: username,
displayName: "ROLE SIGNUP TEST",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles,
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const setProfile = async (userId: number, rankId: number, qualificationIds: number[]) => {
const profiles = (await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Array<{ id: number }> };
await payload.update({
collection: "profiles",
id: profiles.docs[0].id,
data: { rank: rankId, progression: { qualifications: qualificationIds } },
overrideAccess: true,
depth: 0,
});
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
const map = (await payload.create({
collection: "maps",
data: {
name: `${RUN} Map`,
worldSizeWidth: 8192,
worldSizeHeight: 8192,
basemapMode: "image",
},
overrideAccess: true,
depth: 0,
})) as unknown as MissionMap;
mapId = map.id;
// Self-contained 3-rank ladder: Pvt < Cpl < Sgt.
for (const [name, abbreviation] of [
["Pvt", "pvt"],
["Cpl", "cpl"],
["Sgt", "sgt"],
] as const) {
const rank = (await payload.create({
collection: "ranks",
data: {
name: `${RUN} ${name}`,
abbreviation: `${RUN}-${abbreviation}`,
description: `${RUN} test rank`,
},
overrideAccess: true,
depth: 0,
})) as unknown as Rank;
rankIds.push(rank.id);
if (name === "Pvt") pvt = rank;
if (name === "Cpl") cpl = rank;
if (name === "Sgt") sgt = rank;
}
medic = (await payload.create({
collection: "qualifications",
data: { name: `${RUN} Advanced Medic` },
overrideAccess: true,
depth: 0,
})) as unknown as Qualification;
qualIds.push(medic.id);
adminUser = await makeUser(`${RUN}-admin`, ["admin"]);
memberA = await makeUser(`${RUN}-a`);
memberB = await makeUser(`${RUN}-b`);
memberC = await makeUser(`${RUN}-c`);
memberD = await makeUser(`${RUN}-d`);
await setProfile(memberA.id, pvt.id, [medic.id]);
await setProfile(memberB.id, pvt.id, []);
await setProfile(memberC.id, cpl.id, []);
await setProfile(memberD.id, sgt.id, [medic.id]);
const main = await makeMission("Main", {
roles: [
{ roleName: "Squad Leader", slots: 1, minimumRank: cpl.id },
{ roleName: "Medic", slots: 1, requiredMos: [medic.id] },
{ roleName: "Rifleman", slots: 2 },
],
});
missionId = main.id;
const queue = await makeMission("Queue", {
roles: [{ roleName: "Rifleman", slots: 2 }],
});
queueMissionId = queue.id;
const switchMission = await makeMission("Switch", {
roles: [
{ roleName: "Medic", slots: 1, requiredMos: [medic.id] },
{ roleName: "Rifleman", slots: 2 },
],
});
switchMissionId = switchMission.id;
const bypass = await makeMission("Bypass", {
roles: [{ roleName: "Squad Leader", slots: 1, minimumRank: sgt.id }],
});
bypassMissionId = bypass.id;
const past = await makeMission("Past", {
start: new Date(Date.now() - 24 * 60 * 60 * 1000),
roles: [{ roleName: "Rifleman", slots: 2 }],
});
pastMissionId = past.id;
const cancelled = await makeMission("Cancelled", {
status: "Cancelled",
roles: [{ roleName: "Rifleman", slots: 2 }],
});
cancelledMissionId = cancelled.id;
});
afterAll(async () => {
for (const id of missionIds) {
await deleteMissionWithRelations(payload, id);
}
// Events and notifications reference users; remove them before the users.
for (const collection of ["game-event-logs", "user-notifications"] as const) {
const docs = await payload
.find({
collection,
where: { id: { exists: true } },
limit: 500,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const doc of docs?.docs ?? []) {
const event = doc as unknown as {
actor?: number | { id: number } | null;
user?: number | { id: number } | null;
};
const ref = event.actor ?? event.user;
const refId = typeof ref === "object" ? ref?.id : ref;
if (refId != null && userIds.includes(refId)) {
await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
}
}
}
for (const id of userIds) {
await deleteUserWithRelations(payload, id);
}
for (const id of qualIds) {
await payload
.delete({ collection: "qualifications", id, overrideAccess: true })
.catch(() => {});
}
for (const id of rankIds) {
await payload.delete({ collection: "ranks", id, overrideAccess: true }).catch(() => {});
}
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
});
it("gates sign-ups by minimum rank and required MOS", async () => {
// B is a Pvt: the Squad Leader role requires Cpl or higher.
await expect(
signupForRole(payload, { missionId, userId: memberB.id, roleIndex: 0 }),
).rejects.toThrow(/Requires the rank of/);
// C is a Cpl: passes the rank gate and immediately holds the slot once RSVPed.
await setMissionAttendance(payload, { missionId, userId: memberC.id, response: "yes" });
await signupForRole(payload, { missionId, userId: memberC.id, roleIndex: 0 });
const afterC = await getMissionRoleSignupState(payload, missionId);
expect(afterC.roles[0].filled).toBe(1);
expect(afterC.roles[0].entries[0].holding).toBe(true);
// B lacks the Medic MOS.
await expect(
signupForRole(payload, { missionId, userId: memberB.id, roleIndex: 1 }),
).rejects.toThrow(/Requires qualification:/);
// A is a Pvt with the Medic MOS: the Medic role has no rank floor.
await signupForRole(payload, { missionId, userId: memberA.id, roleIndex: 1 });
const afterA = await getMissionRoleSignupState(payload, missionId);
expect(afterA.roles[1].filled).toBe(0); // A has not RSVPed yet, so no slot held
expect(afterA.roles[1].entries[0].holding).toBe(false);
// Re-signing for the same role is a no-op (one signup per user per mission).
await signupForRole(payload, { missionId, userId: memberA.id, roleIndex: 1 });
const signups = await payload.find({
collection: "mission-role-signups",
where: { mission: { equals: missionId } },
limit: 100,
depth: 0,
overrideAccess: true,
});
expect(signups.docs).toHaveLength(2);
// A role index past the end of the mission's role list is refused.
await expect(
signupForRole(payload, { missionId, userId: memberA.id, roleIndex: 99 }),
).rejects.toThrow(/no longer exists/);
});
it("queues by first-come-first-served and auto-promotes on RSVP drop", async () => {
await setMissionAttendance(payload, {
missionId: queueMissionId,
userId: memberA.id,
response: "yes",
});
await setMissionAttendance(payload, {
missionId: queueMissionId,
userId: memberB.id,
response: "yes",
});
await setMissionAttendance(payload, {
missionId: queueMissionId,
userId: memberC.id,
response: "yes",
});
await signupForRole(payload, { missionId: queueMissionId, userId: memberA.id, roleIndex: 0 });
await signupForRole(payload, { missionId: queueMissionId, userId: memberB.id, roleIndex: 0 });
await signupForRole(payload, { missionId: queueMissionId, userId: memberC.id, roleIndex: 0 });
let state = await getMissionRoleSignupState(payload, queueMissionId);
expect(state.roles[0].filled).toBe(2);
expect(state.roles[0].entries.map((e) => [e.position, e.holding])).toEqual([
[1, true],
[2, true],
[3, false],
]);
expect(state.roles[0].slots).toBe(2);
// A (slot holder) RSVPs No: the first waitlisted member takes over.
await setMissionAttendance(payload, {
missionId: queueMissionId,
userId: memberA.id,
response: "no",
});
state = await getMissionRoleSignupState(payload, queueMissionId);
expect(state.roles[0].filled).toBe(2);
expect(state.roles[0].entries.map((e) => [e.holding])).toEqual([[false], [true], [true]]);
// The promoted member is notified and the promotion is logged.
const notifications = await payload.find({
collection: "user-notifications",
where: { user: { equals: memberC.id } },
limit: 100,
depth: 0,
overrideAccess: true,
});
const promotedNotes = notifications.docs.filter(
(n) => (n as unknown as { type: string }).type === "mission:role-promoted",
);
expect(promotedNotes).toHaveLength(1);
expect(promotedNotes[0]).toMatchObject({
title: "Slot granted: Rifleman",
link: `/intelligence/missions/${queueMissionId}`,
});
const events = await payload.find({
collection: "game-event-logs",
where: { type: { equals: "mission:role-promoted" } },
limit: 100,
depth: 0,
overrideAccess: true,
});
const promotionEvent = events.docs.find(
(e) =>
(e as unknown as { targetId: number }).targetId === queueMissionId &&
JSON.stringify((e as unknown as { data?: { userId?: number } }).data ?? {}).includes(
`"userId":${memberC.id}`,
),
);
expect(promotionEvent).toBeDefined();
});
it("promotes on withdrawal and refuses a second withdrawal", async () => {
// D joins the (full) Rifleman queue: two holders, two waitlisted.
await setMissionAttendance(payload, {
missionId: queueMissionId,
userId: memberD.id,
response: "yes",
});
await signupForRole(payload, { missionId: queueMissionId, userId: memberD.id, roleIndex: 0 });
let state = await getMissionRoleSignupState(payload, queueMissionId);
expect(state.roles[0].entries).toHaveLength(4);
// B withdraws: C keeps a slot and D (next committed member) takes B's.
await withdrawRoleSignup(payload, { missionId: queueMissionId, userId: memberB.id });
state = await getMissionRoleSignupState(payload, queueMissionId);
expect(state.roles[0].filled).toBe(2);
expect(state.roles[0].entries.find((e) => e.userId === memberD.id)?.holding).toBe(true);
expect(state.roles[0].entries.find((e) => e.userId === memberB.id)).toBeUndefined();
await expect(
withdrawRoleSignup(payload, { missionId: queueMissionId, userId: memberB.id }),
).rejects.toThrow(/not signed up/);
});
it("moves the signup when switching roles and enforces requirements on the target", async () => {
// A holds a Rifleman slot on the switch mission, then moves to Medic.
await setMissionAttendance(payload, {
missionId: switchMissionId,
userId: memberA.id,
response: "yes",
});
await signupForRole(payload, { missionId: switchMissionId, userId: memberA.id, roleIndex: 1 });
let state = await getMissionRoleSignupState(payload, switchMissionId, memberA.id);
expect(state.roles[1].filled).toBe(1);
expect(state.mySignup).toMatchObject({ roleIndex: 1, holding: true });
// Switching to Medic moves the one live signup (no second row).
await signupForRole(payload, { missionId: switchMissionId, userId: memberA.id, roleIndex: 0 });
state = await getMissionRoleSignupState(payload, switchMissionId, memberA.id);
expect(state.roles[1].entries).toHaveLength(0);
expect(state.roles[0].filled).toBe(1);
expect(state.mySignup).toMatchObject({ roleIndex: 0, holding: true });
// B (Pvt, no MOS) and C (Cpl, no MOS) both fail the Medic MOS gate.
await expect(
signupForRole(payload, { missionId: switchMissionId, userId: memberB.id, roleIndex: 0 }),
).rejects.toThrow(/Requires qualification:/);
await expect(
signupForRole(payload, { missionId: switchMissionId, userId: memberC.id, roleIndex: 0 }),
).rejects.toThrow(/Requires qualification:/);
});
it("lets staff bypass eligibility and remove sign-ups", async () => {
// Admin signs up for the Sgt-floored role despite holding no test rank.
await setMissionAttendance(payload, {
missionId: bypassMissionId,
userId: adminUser.id,
response: "yes",
});
await signupForRole(payload, {
missionId: bypassMissionId,
userId: adminUser.id,
roleIndex: 0,
bypassEligibility: true,
});
const state = await getMissionRoleSignupState(payload, bypassMissionId, adminUser.id);
expect(state.roles[0].filled).toBe(1);
expect(state.mySignup).toMatchObject({ roleIndex: 0, holding: true });
// Staff removal reconciles slots and is marked in the event log.
const signupId = state.roles[0].entries[0].signupId;
await removeRoleSignup(payload, {
missionId: bypassMissionId,
signupId,
actorId: adminUser.id,
});
const after = await getMissionRoleSignupState(payload, bypassMissionId, adminUser.id);
expect(after.roles[0].entries).toHaveLength(0);
expect(after.mySignup).toBeNull();
const withdrawEvents = await payload.find({
collection: "game-event-logs",
where: { type: { equals: "mission:role-withdraw" } },
limit: 100,
depth: 0,
overrideAccess: true,
});
const removal = withdrawEvents.docs.find(
(e) =>
(e as unknown as { targetId: number }).targetId === bypassMissionId &&
(e as unknown as { data?: { removedByStaff?: boolean } }).data?.removedByStaff === true,
);
expect(removal).toBeDefined();
});
it("closes sign-ups for past and cancelled missions", async () => {
const pastGate = roleSignupGate({
ownershipAndStatus: { status: "Scheduled" },
classification: { startDateTime: new Date(Date.now() - 60_000).toISOString() },
});
expect(pastGate.open).toBe(false);
expect(pastGate.reason).toBe(roleSignupsClosedMessage);
await expect(
signupForRole(payload, { missionId: pastMissionId, userId: memberA.id, roleIndex: 0 }),
).rejects.toThrow(roleSignupsClosedMessage);
const cancelledGate = roleSignupGate({
ownershipAndStatus: { status: "Cancelled" },
classification: { startDateTime: new Date(Date.now() + 60_000).toISOString() },
});
expect(cancelledGate.open).toBe(false);
expect(cancelledGate.reason).toBe("This operation was cancelled.");
await expect(
signupForRole(payload, { missionId: cancelledMissionId, userId: memberA.id, roleIndex: 0 }),
).rejects.toThrow("This operation was cancelled.");
});
it("derives slot holders as a userId -> roleIndex map and diffs promotions", async () => {
const holders = await getSlotHolders(payload, missionId);
// C holds Squad Leader (RSVPed yes). A RSVPed no on the main mission, so no
// Medic slot is held there.
expect(holders.get(memberC.id)).toBe(0);
expect(holders.has(memberA.id)).toBe(false);
// A RSVPs yes: they now hold the Medic slot.
await setMissionAttendance(payload, { missionId, userId: memberA.id, response: "yes" });
const holdersAfter = await getSlotHolders(payload, missionId);
expect(holdersAfter.get(memberA.id)).toBe(1);
const promoted = diffPromotedUsers(holders, holdersAfter);
expect(promoted).toEqual([{ userId: memberA.id, roleIndex: 1 }]);
});
});