1
0
Fork 0
polaris-task-force/tests/int/promotion-nominations.int.spec.ts
Z8MB1E 62573f74e6 feat(promotions): add promotion nominations and leadership transfers
- Promotion nominations: members can nominate a peer for promotion from their
  profile, and a new /personnel/promotions page lets staff review and action
  the nominations (with an optional note on each).
- Leadership transfers: a member's leadership can be handed over to another
  member, surfaced on the transfers page and in the roster (which also gains a
  staff remove-member dialog).
- Adds the PromotionNominations and LeadershipTransfers collections (registered
  in the Payload config), their service libs, the migration, and integration
  coverage.
2026-10-03 03:29:24 -04:00

599 lines
20 KiB
TypeScript

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<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("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<User> => {
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<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 }> };
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<Profile> => {
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<string, unknown> }).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");
});
});