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>
385 lines
No EOL
13 KiB
TypeScript
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.",
|
|
);
|
|
});
|
|
});
|
|
}); |