1
0
Fork 0
polaris-task-force/tests/int/promotions.int.spec.ts
Z8MB1E 8d92e7aaa9 feat(promotions): add rank promotion ceiling with GM promote flow
Promotion service lib, ceiling field on Ranks, permission gate, migration, and GM promote button with tests.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-14 15:37:05 -04:00

385 lines
No EOL
13 KiB
TypeScript

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<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(() => {});
};
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<Rank> => {
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<User> => {
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<void> => {
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.",
);
});
});
});