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 => { 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 => { 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[]; }, ): Promise => { const providedRoles = options.roles as NonNullable["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 => { 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 }]); }); });