- 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.
599 lines
20 KiB
TypeScript
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");
|
|
});
|
|
});
|