import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Rank, User } from "@/payload-types"; import { allowedPromotionTargets, promoteMember, resolvePromotionAuthority, } from "@/lib/promotions"; import { loadRankLadder } from "@/lib/promotions/authority"; let payload: Payload; const RUN = `promo-${Date.now().toString(36)}`; /** * User deletion trips FK constraints unless the hook-provisioned personal bank * account and profile are removed first. */ 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("Promotions", () => { const rankIds: number[] = []; const userIds: number[] = []; let pvt: Rank; let cpl: Rank; let sgt: Rank; let ssgt: Rank; let lt: Rank; let bottomRankId: number; let originalBottomCeiling: Rank["promotionCeiling"]; let promoter: User; let admin: User; let noCeiling: User; let lowest: User; let memberA: User; let memberB: User; let memberC: User; let memberD: User; let memberE: User; let memberF: User; const makeRank = async (name: string, abbreviation: string): Promise => { 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); return rank; }; const makeUser = async (username: string, roles: User["roles"] = ["user"]): Promise => { const user = (await payload.create({ collection: "users", data: { username, discordUsername: username, displayName: "PROMO 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 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 }> }; await payload.update({ collection: "profiles", id: profiles.docs[0].id, data: { rank: rankId }, overrideAccess: true, depth: 0, }); }; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); // Self-contained 5-rank ladder: Pvt < Cpl < Sgt < SSgt < Lt. pvt = await makeRank("Pvt", "pvt"); cpl = await makeRank("Cpl", "cpl"); sgt = await makeRank("Sgt", "sgt"); ssgt = await makeRank("SSgt", "ssgt"); lt = await makeRank("Lt", "lt"); await payload.update({ collection: "ranks", id: ssgt.id, data: { promotionCeiling: cpl.id }, overrideAccess: true, depth: 0, }); // True bottom of the ladder (seed ranks sit below our test ranks) with a // self-ceiling, so the clamp must yield null authority. const ladder = await loadRankLadder(payload); const bottomRank = ladder[0]; bottomRankId = bottomRank.id; originalBottomCeiling = bottomRank.promotionCeiling; await payload.update({ collection: "ranks", id: bottomRank.id, data: { promotionCeiling: bottomRank.id }, overrideAccess: true, depth: 0, }); promoter = await makeUser(`${RUN}-promoter`); admin = await makeUser(`${RUN}-admin`, ["admin"]); noCeiling = await makeUser(`${RUN}-noceiling`); lowest = await makeUser(`${RUN}-lowest`); memberA = await makeUser(`${RUN}-member-a`); memberB = await makeUser(`${RUN}-member-b`); memberC = await makeUser(`${RUN}-member-c`); memberD = await makeUser(`${RUN}-member-d`); memberE = await makeUser(`${RUN}-member-e`); memberF = await makeUser(`${RUN}-member-f`); await setProfileRank(promoter.id, ssgt.id); await setProfileRank(admin.id, lt.id); await setProfileRank(noCeiling.id, sgt.id); await setProfileRank(lowest.id, bottomRankId); await setProfileRank(memberA.id, pvt.id); await setProfileRank(memberB.id, pvt.id); await setProfileRank(memberC.id, pvt.id); await setProfileRank(memberD.id, sgt.id); await setProfileRank(memberE.id, pvt.id); await setProfileRank(memberF.id, pvt.id); }); afterAll(async () => { // Events and notifications reference users; remove them before the users. const events = await payload .find({ collection: "game-event-logs", where: { type: { equals: "personnel:promoted" } }, limit: 500, depth: 0, overrideAccess: true, }) .catch(() => null); for (const event of events?.docs ?? []) { await payload .delete({ collection: "game-event-logs", id: event.id, overrideAccess: true }) .catch(() => {}); } const notifications = await payload .find({ collection: "user-notifications", where: { type: { equals: "personnel:promoted" } }, limit: 500, depth: 0, overrideAccess: true, }) .catch(() => null); for (const notification of notifications?.docs ?? []) { await payload .delete({ collection: "user-notifications", id: notification.id, overrideAccess: true }) .catch(() => {}); } for (const id of userIds) { await deleteUserWithRelations(payload, id); } if (bottomRankId) { await payload .update({ collection: "ranks", id: bottomRankId, data: { promotionCeiling: originalBottomCeiling ?? null }, overrideAccess: true, depth: 0, }) .catch(() => {}); } for (const id of rankIds) { await payload .delete({ collection: "ranks", id, overrideAccess: true }) .catch(() => {}); } }); describe("resolvePromotionAuthority", () => { it("resolves ceiling authority for a rank holder", async () => { const result = await resolvePromotionAuthority(payload, promoter); expect(result.authority).toEqual({ kind: "ceiling", maxRank: expect.objectContaining({ id: cpl.id }), }); }); it("resolves unbounded authority for admins", async () => { const result = await resolvePromotionAuthority(payload, admin); expect(result.authority).toEqual({ kind: "unbounded" }); }); it("returns null authority without a ceiling", async () => { const result = await resolvePromotionAuthority(payload, noCeiling); expect(result.authority).toBeNull(); }); it("returns null authority when the actor is the lowest rank", async () => { const result = await resolvePromotionAuthority(payload, lowest); expect(result.authority).toBeNull(); }); it("clamps a ceiling above the actor's own rank to the rank directly below", async () => { await payload.update({ collection: "ranks", id: ssgt.id, data: { promotionCeiling: lt.id }, overrideAccess: true, depth: 0, }); const result = await resolvePromotionAuthority(payload, promoter); expect(result.authority).toEqual({ kind: "ceiling", maxRank: expect.objectContaining({ id: sgt.id }), }); await payload.update({ collection: "ranks", id: ssgt.id, data: { promotionCeiling: cpl.id }, overrideAccess: true, depth: 0, }); }); }); describe("allowedPromotionTargets", () => { it("lists only ranks within the ceiling for the member", async () => { const result = await resolvePromotionAuthority(payload, promoter); const targets = allowedPromotionTargets(result, pvt); expect(targets.map((r) => r.id)).toEqual([cpl.id]); }); it("lists all ranks below the admin's own rank", async () => { const result = await resolvePromotionAuthority(payload, admin); const targets = allowedPromotionTargets(result, pvt); expect(targets.map((r) => r.id)).toEqual([cpl.id, sgt.id, ssgt.id]); }); }); describe("promoteMember", () => { it("promotes a member within the ceiling and records event + notification", async () => { const result = await promoteMember(payload, promoter, memberA.username, cpl.id); expect(result.toRank.id).toBe(cpl.id); const profiles = (await payload.find({ collection: "profiles", where: { user: { equals: memberA.id } }, limit: 1, depth: 0, overrideAccess: true, })) as unknown as { docs: Array<{ rank: number }> }; expect(profiles.docs[0].rank).toBe(cpl.id); const events = (await payload.find({ collection: "game-event-logs", where: { type: { equals: "personnel:promoted" }, targetId: { equals: memberA.id } }, limit: 10, depth: 0, overrideAccess: true, })) as unknown as { docs: Array<{ message: string }> }; expect(events.docs.length).toBeGreaterThan(0); expect(events.docs[0].message).toBe( `${pvt.abbreviation} ${memberA.username} promoted from ${pvt.name} to ${cpl.name} by ${promoter.username}`, ); const notifications = (await payload.find({ collection: "user-notifications", where: { user: { equals: memberA.id }, type: { equals: "personnel:promoted" } }, limit: 10, depth: 0, overrideAccess: true, })) as unknown as { docs: Array<{ link: string }> }; expect(notifications.docs.length).toBeGreaterThan(0); expect(notifications.docs[0].link).toBe(`/profile/${memberA.username}`); }); it("rejects a promotion above the ceiling", async () => { await expect(promoteMember(payload, promoter, memberB.username, sgt.id)).rejects.toThrow( `You cannot promote above ${cpl.name}.`, ); }); it("rejects promoting to the actor's own rank or above", async () => { await expect(promoteMember(payload, promoter, memberC.username, ssgt.id)).rejects.toThrow( "You cannot promote to a rank at or above your own.", ); }); it("rejects demotions and same-rank promotions", async () => { await expect(promoteMember(payload, promoter, memberD.username, cpl.id)).rejects.toThrow( "Promotions only: the new rank must be above the member's current rank.", ); await expect(promoteMember(payload, promoter, memberD.username, sgt.id)).rejects.toThrow( "Promotions only: the new rank must be above the member's current rank.", ); }); it("allows admins to promote below their own rank", async () => { const result = await promoteMember(payload, admin, memberE.username, sgt.id); expect(result.toRank.id).toBe(sgt.id); }); it("still prevents admins from promoting to their own rank", async () => { await expect(promoteMember(payload, admin, memberF.username, lt.id)).rejects.toThrow( "You cannot promote to a rank at or above your own.", ); }); it("denies users without a ceiling", async () => { await expect(promoteMember(payload, noCeiling, memberA.username, cpl.id)).rejects.toThrow( "You do not have promotion authority.", ); }); it("denies the lowest rank even with a ceiling", async () => { await expect(promoteMember(payload, lowest, memberA.username, cpl.id)).rejects.toThrow( "You do not have promotion authority.", ); }); it("rejects unknown users and ranks", async () => { await expect(promoteMember(payload, promoter, `${RUN}-nobody`, cpl.id)).rejects.toThrow( "User not found.", ); await expect(promoteMember(payload, promoter, memberA.username, 999999)).rejects.toThrow( "Unknown rank.", ); }); }); });