import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Assignment, Profile, Rank, Role, User } from "@/payload-types"; import { cancelNomination, recordLeaderDecision, resolveNomination, submitNomination, } from "@/lib/promotions/nominations"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; let payload: Payload; const RUN = `promnom-${Date.now().toString(36)}`; const TIMEOUT = 30_000; const ACCOLADES = "Consistently leads the squad under fire and never misses a rally point."; /** * 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(() => {}); }; describe("Promotion nominations", () => { const rankIds: number[] = []; const roleIds: number[] = []; const userIds: number[] = []; const assignmentIds: number[] = []; const nominationIds: number[] = []; let pvt: Rank; let cpl: Rank; let sgt: Rank; let colonel: Rank; let superuser: User; let leader: User; let outsider: User; let selfNominee: User; let divisionlessNominee: User; let topRankUser: User; let leaderNominee: User; let leaderRejectNominee: User; let cancelNominee: User; let hookNominee: User; let restNominee: User; const makeUser = async (username: string, roles: User["roles"] = ["user"]): Promise => { const user = (await payload.create({ collection: "users", data: { username, discordUsername: username, displayName: username, 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 setProfileRank = async (userId: number, rankId: number): Promise => { const profiles = (await payload.find({ collection: "profiles", where: { user: { equals: userId } }, limit: 1, depth: 0, overrideAccess: true, })) as unknown as { docs: Array<{ id: number }> }; expect(profiles.docs.length).toBeGreaterThan(0); await payload.update({ collection: "profiles", id: profiles.docs[0].id, data: { rank: rankId }, overrideAccess: true, depth: 0, }); }; const getProfile = async (userId: number): Promise => { const profiles = (await payload.find({ collection: "profiles", where: { user: { equals: userId } }, limit: 1, depth: 0, overrideAccess: true, })) as unknown as { docs: Profile[] }; return profiles.docs[0]; }; const notificationsFor = async (userId: number, type: string) => { const res = await payload.find({ collection: "user-notifications", where: { and: [{ user: { equals: userId } }, { type: { equals: type } }], }, limit: 100, depth: 0, overrideAccess: true, }); return res.docs; }; const eventsOfType = async (type: string) => { const res = await payload.find({ collection: "game-event-logs", where: { type: { equals: type } }, limit: 100, depth: 0, overrideAccess: true, }); return res.docs; }; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); invalidatePermissionCache(); // Self-contained 4-rank ladder: Pvt < Cpl < Sgt < Col (orderable _order). for (const [name, abbreviation] of [ ["Pvt", "pvt"], ["Cpl", "cpl"], ["Sgt", "sgt"], ["Col", "col"], ] 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; if (name === "Col") colonel = rank; } const superRole = (await payload.create({ collection: "roles", data: { name: `${RUN}-super`, slug: `${RUN}-super`, isSuperuser: true }, overrideAccess: true, depth: 0, })) as unknown as Role; roleIds.push(superRole.id); superuser = await makeUser(`${RUN}-superuser`, ["admin"]); await payload.update({ collection: "users", id: superuser.id, data: { roleDocs: [superRole.id] }, overrideAccess: true, depth: 0, }); // promoteMember resolves the actor's authority from their profile rank; // a legacy admin gets unbounded authority but still needs a rank above the // granted rank. await setProfileRank(superuser.id, colonel.id); leader = await makeUser(`${RUN}-leader`); outsider = await makeUser(`${RUN}-outsider`); selfNominee = await makeUser(`${RUN}-self`); divisionlessNominee = await makeUser(`${RUN}-divless`); topRankUser = await makeUser(`${RUN}-top`); leaderNominee = await makeUser(`${RUN}-leadnom`); leaderRejectNominee = await makeUser(`${RUN}-lerej`); cancelNominee = await makeUser(`${RUN}-cancel`); hookNominee = await makeUser(`${RUN}-hook`); restNominee = await makeUser(`${RUN}-rest`); await setProfileRank(selfNominee.id, pvt.id); await setProfileRank(divisionlessNominee.id, pvt.id); await setProfileRank(topRankUser.id, colonel.id); await setProfileRank(leaderNominee.id, pvt.id); await setProfileRank(leaderRejectNominee.id, pvt.id); await setProfileRank(cancelNominee.id, pvt.id); await setProfileRank(hookNominee.id, pvt.id); await setProfileRank(restNominee.id, pvt.id); const division = (await payload.create({ collection: "assignments", data: { name: `${RUN} Division`, type: "division", leader: leader.id, members: [ selfNominee.id, leaderNominee.id, leaderRejectNominee.id, cancelNominee.id, hookNominee.id, ], }, overrideAccess: true, depth: 0, })) as unknown as Assignment; assignmentIds.push(division.id); }, TIMEOUT); afterAll(async () => { if (!payload) return; for (const id of nominationIds) { await payload .delete({ collection: "promotion-nominations", id, overrideAccess: true }) .catch(() => {}); } // 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 assignmentIds) { await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {}); } for (const id of userIds) { await deleteUserWithRelations(payload, id); } for (const id of roleIds) { await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); } for (const id of rankIds) { await payload.delete({ collection: "ranks", id, overrideAccess: true }).catch(() => {}); } }, TIMEOUT); it("guards submissions: short accolades, open duplicate, and top of the ladder", async () => { // Short accolades are refused with the plain-language message. await expect( submitNomination(payload, outsider, { nomineeId: divisionlessNominee.id, accolades: "too short", }), ).rejects.toThrow(/Provide at least a sentence/); // A valid submission for a leaderless member skips straight to the superuser. const submitted = await submitNomination(payload, outsider, { nomineeId: divisionlessNominee.id, accolades: ACCOLADES, }); nominationIds.push(submitted.id); expect(submitted.status).toBe("awaiting_superuser"); expect(submitted.leaderDecision).toBe("not_required"); // Superusers are notified for the leaderless routing. const superuserNotified = (await notificationsFor(superuser.id, "promotion:nomination")).filter( (n) => n.message?.includes(divisionlessNominee.displayName), ); expect(superuserNotified).toHaveLength(1); expect(superuserNotified[0].message).toContain(outsider.displayName); // A second open nomination for the same nominee is refused. await expect( submitNomination(payload, leader, { nomineeId: divisionlessNominee.id, accolades: ACCOLADES, }), ).rejects.toThrow(/already has an open promotion nomination/); // A member at the top of the ladder cannot be nominated. await expect( submitNomination(payload, outsider, { nomineeId: topRankUser.id, accolades: ACCOLADES, }), ).rejects.toThrow(/already holds the highest rank/); }); it("routes the leader stage: division leader notified and pending, self-request allowed", async () => { // Self-request by a member of a led division: pending with the leader. const selfRequest = await submitNomination(payload, selfNominee, { nomineeId: selfNominee.id, accolades: ACCOLADES, }); nominationIds.push(selfRequest.id); expect(selfRequest.status).toBe("pending"); expect(selfRequest.leaderDecision).toBe("pending"); expect(selfRequest.nominator).toBe(selfRequest.nominee); const leaderNotified = await notificationsFor(leader.id, "promotion:nomination"); expect(leaderNotified).toHaveLength(1); expect(leaderNotified[0]).toMatchObject({ title: "Promotion nomination", link: "/personnel/promotions", }); // The division leader nominating their own member needs no leader stage. const leaderNominated = await submitNomination(payload, leader, { nomineeId: leaderNominee.id, accolades: ACCOLADES, }); nominationIds.push(leaderNominated.id); expect(leaderNominated.status).toBe("awaiting_superuser"); expect(leaderNominated.leaderDecision).toBe("not_required"); }); it("authorizes leader decisions and endorses through to the superuser stage", async () => { const selfRequest = (await payload.find({ collection: "promotion-nominations", where: { nominee: { equals: selfNominee.id } }, limit: 1, depth: 0, overrideAccess: true, })) as unknown as { docs: Array<{ id: number }> }; const nominationId = selfRequest.docs[0].id; // A member who is not the division leader cannot decide. await expect( recordLeaderDecision(payload, outsider, nominationId, { endorse: true }), ).rejects.toThrow(/Only the nominee's division leader/); // The current leader endorses: awaiting_superuser + stamped decision. const endorsed = await recordLeaderDecision(payload, leader, nominationId, { endorse: true, note: "Well earned.", }); expect(endorsed.status).toBe("awaiting_superuser"); expect(endorsed.leaderDecision).toBe("endorsed"); expect(endorsed.leaderDecisionBy).toBe(leader.id); expect(endorsed.leaderNote).toBe("Well earned."); // The nominator is notified of the endorsement. const endorsedNotes = await notificationsFor(selfNominee.id, "promotion:endorsed"); expect(endorsedNotes).toHaveLength(1); expect(endorsedNotes[0].message).toContain(leader.displayName); // The decision is one-shot: a second endorsement is refused. await expect( recordLeaderDecision(payload, leader, nominationId, { endorse: true }), ).rejects.toThrow(/not awaiting endorsement/); }); it("rejects a non-superuser final resolution", async () => { const awaiting = (await payload.find({ collection: "promotion-nominations", where: { and: [ { nominee: { equals: selfNominee.id } }, { status: { equals: "awaiting_superuser" } }, ], }, limit: 1, depth: 0, overrideAccess: true, })) as unknown as { docs: Array<{ id: number }> }; expect(awaiting.docs).toHaveLength(1); await expect( resolveNomination(payload, leader, awaiting.docs[0].id, { approve: true }), ).rejects.toThrow(/Only a superuser/); }); it("approves through promoteMember: exactly one rung, event, and nominator notification", async () => { const awaiting = (await payload.find({ collection: "promotion-nominations", where: { and: [ { nominee: { equals: selfNominee.id } }, { status: { equals: "awaiting_superuser" } }, ], }, limit: 1, depth: 0, overrideAccess: true, })) as unknown as { docs: Array<{ id: number }> }; expect(awaiting.docs).toHaveLength(1); const nominationId = awaiting.docs[0].id; const approved = await resolveNomination(payload, superuser, nominationId, { approve: true, note: "Approved by command.", }); expect(approved.status).toBe("approved"); expect(approved.reviewedBy).toBe(superuser.id); expect(approved.reviewNote).toBe("Approved by command."); // The nominee moved EXACTLY one rung: Pvt -> Cpl. const profile = await getProfile(selfNominee.id); expect(profile.rank).toBe(cpl.id); // The public event carries the promotion trace. const events = await eventsOfType("promotion:approved"); const nominationEvent = events.find( (e) => (e as unknown as { targetId: number }).targetId === nominationId, ); expect(nominationEvent).toBeDefined(); const eventData = (nominationEvent as unknown as { data: Record }).data; expect(eventData).toMatchObject({ nominationId, nomineeId: selfNominee.id, nominatorId: selfNominee.id, fromRankId: pvt.id, toRankId: cpl.id, }); // The nominator is notified with the new rank. const approvedNotes = await notificationsFor(selfNominee.id, "promotion:approved"); expect(approvedNotes).toHaveLength(1); expect(approvedNotes[0].message).toContain(cpl.name); }); it("rejects at final review without touching the nominee's rank", async () => { // The divisionless member's nomination is still awaiting final approval. const awaiting = (await payload.find({ collection: "promotion-nominations", where: { and: [ { nominee: { equals: divisionlessNominee.id } }, { status: { equals: "awaiting_superuser" } }, ], }, limit: 1, depth: 0, overrideAccess: true, })) as unknown as { docs: Array<{ id: number }> }; expect(awaiting.docs).toHaveLength(1); const rejected = await resolveNomination(payload, superuser, awaiting.docs[0].id, { approve: false, note: "Not this cycle.", }); expect(rejected.status).toBe("rejected"); expect(rejected.reviewedBy).toBe(superuser.id); expect(rejected.reviewNote).toBe("Not this cycle."); const profile = await getProfile(divisionlessNominee.id); expect(profile.rank).toBe(pvt.id); const rejectedNotes = await notificationsFor(outsider.id, "promotion:rejected"); expect(rejectedNotes).toHaveLength(1); expect(rejectedNotes[0].message).toContain("Not this cycle."); }); it("records the leader's rejection as terminal and notifies the nominator", async () => { const submitted = await submitNomination(payload, outsider, { nomineeId: leaderRejectNominee.id, accolades: ACCOLADES, }); nominationIds.push(submitted.id); expect(submitted.status).toBe("pending"); const rejected = await recordLeaderDecision(payload, leader, submitted.id, { endorse: false, note: "Needs more time in grade.", }); expect(rejected.status).toBe("rejected"); expect(rejected.leaderDecision).toBe("rejected"); expect(rejected.leaderDecisionBy).toBe(leader.id); const rejectedNotes = (await notificationsFor(outsider.id, "promotion:rejected")).filter( (n) => n.message?.includes("Needs more time in grade."), ); expect(rejectedNotes).toHaveLength(1); expect(rejectedNotes[0].message).toContain(leader.displayName); const profile = await getProfile(leaderRejectNominee.id); expect(profile.rank).toBe(pvt.id); }); it("lets only the nominator cancel their own pending nomination", async () => { const submitted = await submitNomination(payload, outsider, { nomineeId: cancelNominee.id, accolades: ACCOLADES, }); nominationIds.push(submitted.id); await expect(cancelNomination(payload, leader, submitted.id)).rejects.toThrow( /Only your own pending nominations can be withdrawn/, ); const cancelled = await cancelNomination(payload, outsider, submitted.id); expect(cancelled.status).toBe("cancelled"); await expect(cancelNomination(payload, outsider, submitted.id)).rejects.toThrow( /Only pending nominations can be withdrawn/, ); }); it("rejects illegal status transitions at the collection hook", async () => { const submitted = await submitNomination(payload, outsider, { nomineeId: hookNominee.id, accolades: ACCOLADES, }); nominationIds.push(submitted.id); // pending -> approved skips the endorsement and final stages: illegal. await expect( payload.update({ collection: "promotion-nominations", id: submitted.id, data: { status: "approved" }, overrideAccess: true, depth: 0, }), ).rejects.toThrow(/Illegal promotion nomination status transition/); const reread = await payload.findByID({ collection: "promotion-nominations", id: submitted.id, depth: 0, overrideAccess: true, }); expect(reread.status).toBe("pending"); }); it("forces the nominator to the authenticated caller on a REST-style create", async () => { // A REST create claiming another nominator is refused by the hook. await expect( payload.create({ collection: "promotion-nominations", data: { nominee: restNominee.id, nominator: leader.id, accolades: ACCOLADES, status: "pending", }, user: outsider, overrideAccess: false, depth: 0, }), ).rejects.toThrow(/only submit a promotion nomination as yourself/); // The caller is accepted as the nominator. const created = await payload.create({ collection: "promotion-nominations", data: { nominee: restNominee.id, nominator: outsider.id, accolades: ACCOLADES, status: "pending", }, user: outsider, overrideAccess: false, depth: 0, }); nominationIds.push(created.id); expect(created.nominator).toBe(outsider.id); expect(created.status).toBe("pending"); }); });