- 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.
471 lines
17 KiB
TypeScript
471 lines
17 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import type { LeadershipTransfer, Role, User } from "@/payload-types";
|
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
|
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
|
import {
|
|
cancelLeadershipTransfer,
|
|
requestLeadershipTransfer,
|
|
resolveLeadershipTransfer,
|
|
} from "@/lib/transfers/leadership";
|
|
|
|
let payload: Payload;
|
|
|
|
const RUN = `leadxfer-${Date.now().toString(36)}`;
|
|
const TIMEOUT = 30_000;
|
|
|
|
describe("Leadership-initiated removals (leadership-transfers)", () => {
|
|
const userIds: number[] = [];
|
|
const roleIds: number[] = [];
|
|
const assignmentIds: number[] = [];
|
|
const transferIds: number[] = [];
|
|
|
|
const users: Record<string, User> = {};
|
|
const assignmentIdsByName: Record<string, number> = {};
|
|
|
|
let pendingDoc: LeadershipTransfer | null = null;
|
|
|
|
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
|
|
const role = (await payload.create({
|
|
collection: "roles",
|
|
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Role;
|
|
roleIds.push(role.id);
|
|
return role;
|
|
};
|
|
|
|
const makeUser = async (label: string, extra: Partial<User> = {}): Promise<User> => {
|
|
const user = (await payload.create({
|
|
collection: "users",
|
|
data: {
|
|
username: `${RUN}-${label}`,
|
|
discordUsername: `${RUN}-${label}`,
|
|
displayName: label.toUpperCase(),
|
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
|
password: "Test123",
|
|
roles: ["user"],
|
|
...extra,
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as User;
|
|
userIds.push(user.id);
|
|
return user;
|
|
};
|
|
|
|
const makeAssignment = async (
|
|
label: string,
|
|
type: "division" | "squad",
|
|
leader: number | null,
|
|
members: number[],
|
|
): Promise<number> => {
|
|
const doc = await payload.create({
|
|
collection: "assignments",
|
|
data: { name: `${RUN} ${label}`, type, leader, members },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
assignmentIds.push(doc.id);
|
|
assignmentIdsByName[label] = doc.id;
|
|
return doc.id;
|
|
};
|
|
|
|
const getAssignment = async (id: number): Promise<{ leader: number | null; members: number[] }> => {
|
|
const doc = await payload.findByID({ collection: "assignments", id, depth: 0 });
|
|
return {
|
|
leader: (doc.leader as number | null) ?? null,
|
|
members: (doc.members as number[]) ?? [],
|
|
};
|
|
};
|
|
|
|
const notificationsFor = async (userId: number, type?: string) => {
|
|
const res = await payload.find({
|
|
collection: "user-notifications",
|
|
where: type
|
|
? { and: [{ user: { equals: userId } }, { type: { equals: type } }] }
|
|
: { user: { equals: userId } },
|
|
limit: 100,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
return res.docs;
|
|
};
|
|
|
|
const leadershipEventsFor = async (transferId: number) => {
|
|
const res = await payload.find({
|
|
collection: "game-event-logs",
|
|
where: {
|
|
and: [
|
|
{ targetCollection: { equals: "leadership-transfers" } },
|
|
{ targetId: { equals: transferId } },
|
|
],
|
|
},
|
|
limit: 100,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
return res.docs;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
const payloadConfig = await config;
|
|
payload = await getPayload({ config: payloadConfig });
|
|
// The per-user permission cache is keyed by numeric id, which can collide
|
|
// with users from previously run spec files in the same serialized run.
|
|
invalidatePermissionCache();
|
|
|
|
// The superuser needs BOTH sides of the RBAC split: the dynamic
|
|
// isSuperuser role (roleDocs) for resolveLeadershipTransfer's guard and the
|
|
// legacy admin role for the transfers lib's hasRoles-based notifySuperusers.
|
|
const superRole = await makeRole("super", { isSuperuser: true });
|
|
users.boss = await makeUser("boss-su", { roles: ["admin"], roleDocs: [superRole.id] });
|
|
|
|
// Make sure the destination starts unconfigured for the unset-error test,
|
|
// regardless of what earlier files in the same serialized run left behind.
|
|
await payload.updateGlobal({
|
|
slug: "game-rules",
|
|
data: { infantryAssignment: null },
|
|
overrideAccess: true,
|
|
});
|
|
|
|
users.leaderX = await makeUser("lead-x");
|
|
users.leaderY = await makeUser("lead-y");
|
|
users.member1 = await makeUser("member-1");
|
|
users.member2 = await makeUser("member-2");
|
|
users.member3 = await makeUser("member-3");
|
|
users.memberInf = await makeUser("member-inf");
|
|
users.infantryLead = await makeUser("inf-lead");
|
|
users.outsider = await makeUser("outsider");
|
|
|
|
await makeAssignment("Division X", "division", users.leaderX.id, [
|
|
users.member1.id,
|
|
users.member2.id,
|
|
users.member3.id,
|
|
]);
|
|
await makeAssignment("Division Y", "division", users.leaderY.id, []);
|
|
await makeAssignment("Squad S", "squad", users.leaderX.id, [users.member1.id]);
|
|
await makeAssignment("Infantry", "division", users.infantryLead.id, [users.memberInf.id]);
|
|
}, TIMEOUT);
|
|
|
|
afterAll(async () => {
|
|
if (!payload) return;
|
|
|
|
// Restore the global so other spec files in the same run are unaffected.
|
|
await payload.updateGlobal({
|
|
slug: "game-rules",
|
|
data: { infantryAssignment: null },
|
|
overrideAccess: true,
|
|
});
|
|
|
|
const notifs = await payload
|
|
.find({
|
|
collection: "user-notifications",
|
|
where: { user: { in: userIds } },
|
|
limit: 500,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null);
|
|
for (const n of notifs?.docs ?? []) {
|
|
await payload.delete({ collection: "user-notifications", id: n.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
|
|
const events = await payload
|
|
.find({
|
|
collection: "game-event-logs",
|
|
where: {
|
|
or: [
|
|
{ actor: { in: userIds } },
|
|
{ targetCollection: { equals: "leadership-transfers" } },
|
|
],
|
|
},
|
|
limit: 500,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null);
|
|
for (const e of events?.docs ?? []) {
|
|
await payload.delete({ collection: "game-event-logs", id: e.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
|
|
for (const id of transferIds) {
|
|
await payload.delete({ collection: "leadership-transfers", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of assignmentIds) {
|
|
await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of roleIds) {
|
|
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
for (const id of userIds) {
|
|
const profiles = await payload
|
|
.find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true })
|
|
.catch(() => null);
|
|
for (const p of profiles?.docs ?? []) {
|
|
await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
const accounts = await payload
|
|
.find({
|
|
collection: "bank-accounts",
|
|
where: { ownerUser: { equals: id } },
|
|
limit: 5,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null);
|
|
for (const a of accounts?.docs ?? []) {
|
|
await payload.delete({ collection: "bank-accounts", id: a.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
});
|
|
|
|
it("enforces creation guards", async () => {
|
|
const divisionX = assignmentIdsByName["Division X"];
|
|
|
|
// Non-leader initiator.
|
|
await expect(
|
|
requestLeadershipTransfer(payload, users.outsider, {
|
|
subjectId: users.member1.id,
|
|
fromAssignmentId: divisionX,
|
|
}),
|
|
).rejects.toThrow("Only the current leader of");
|
|
|
|
// Leader of a different assignment.
|
|
await expect(
|
|
requestLeadershipTransfer(payload, users.leaderY, {
|
|
subjectId: users.member1.id,
|
|
fromAssignmentId: divisionX,
|
|
}),
|
|
).rejects.toThrow("Only the current leader of");
|
|
|
|
// Squad-type source (leader is right, assignment type is wrong).
|
|
await expect(
|
|
requestLeadershipTransfer(payload, users.leaderX, {
|
|
subjectId: users.member1.id,
|
|
fromAssignmentId: assignmentIdsByName["Squad S"],
|
|
}),
|
|
).rejects.toThrow("Only divisions can remove members this way.");
|
|
|
|
// Self-removal.
|
|
await expect(
|
|
requestLeadershipTransfer(payload, users.leaderX, {
|
|
subjectId: users.leaderX.id,
|
|
fromAssignmentId: divisionX,
|
|
}),
|
|
).rejects.toThrow("You cannot remove yourself.");
|
|
|
|
// Subject is not a member of the division.
|
|
await expect(
|
|
requestLeadershipTransfer(payload, users.leaderX, {
|
|
subjectId: users.outsider.id,
|
|
fromAssignmentId: divisionX,
|
|
}),
|
|
).rejects.toThrow("That member is not a member of your division.");
|
|
}, TIMEOUT);
|
|
|
|
it("rejects requests while the Infantry destination is not configured", async () => {
|
|
await expect(
|
|
requestLeadershipTransfer(payload, users.leaderX, {
|
|
subjectId: users.member1.id,
|
|
fromAssignmentId: assignmentIdsByName["Division X"],
|
|
reason: "presence",
|
|
}),
|
|
).rejects.toThrow("Command must configure the default transfer destination first (Game Rules).");
|
|
}, TIMEOUT);
|
|
|
|
it("rejects infantry-origin removals", async () => {
|
|
const infantry = assignmentIdsByName["Infantry"];
|
|
await payload.updateGlobal({
|
|
slug: "game-rules",
|
|
data: { infantryAssignment: infantry },
|
|
overrideAccess: true,
|
|
});
|
|
|
|
await expect(
|
|
requestLeadershipTransfer(payload, users.infantryLead, {
|
|
subjectId: users.memberInf.id,
|
|
fromAssignmentId: infantry,
|
|
}),
|
|
).rejects.toThrow("Infantry-origin removals are not supported yet.");
|
|
}, TIMEOUT);
|
|
|
|
it("creates a pending request, notifies only superusers, and emits no event", async () => {
|
|
const doc = await requestLeadershipTransfer(payload, users.leaderX, {
|
|
subjectId: users.member1.id,
|
|
fromAssignmentId: assignmentIdsByName["Division X"],
|
|
reason: "presence",
|
|
});
|
|
transferIds.push(doc.id);
|
|
|
|
expect(doc.status).toBe("pending");
|
|
expect(doc.toAssignment).toBe(assignmentIdsByName["Infantry"]);
|
|
expect(doc.reason).toBe("presence");
|
|
|
|
const bossNotified = await notificationsFor(users.boss.id, "assignment:leadership-transfer-requested");
|
|
expect(bossNotified.length).toBeGreaterThan(0);
|
|
expect(bossNotified[0]?.title).toBe("Removal request");
|
|
|
|
// The subject learns nothing while the request is pending.
|
|
const subjectNotified = await notificationsFor(users.member1.id);
|
|
expect(subjectNotified.length).toBe(0);
|
|
|
|
// No public event-log entry while pending.
|
|
expect((await leadershipEventsFor(doc.id)).length).toBe(0);
|
|
|
|
// A second open request for the same member + division is rejected.
|
|
await expect(
|
|
requestLeadershipTransfer(payload, users.leaderX, {
|
|
subjectId: users.member1.id,
|
|
fromAssignmentId: assignmentIdsByName["Division X"],
|
|
}),
|
|
).rejects.toThrow("A removal request for this member is already pending.");
|
|
|
|
// Stash for the approval test.
|
|
pendingDoc = doc;
|
|
}, TIMEOUT);
|
|
|
|
it("superuser approval moves the member and attributes everything to the leader", async () => {
|
|
const doc = pendingDoc!;
|
|
const divisionX = assignmentIdsByName["Division X"];
|
|
const infantry = assignmentIdsByName["Infantry"];
|
|
|
|
// Non-superusers cannot resolve.
|
|
await expect(
|
|
resolveLeadershipTransfer(payload, users.leaderY, doc.id, { approve: true }),
|
|
).rejects.toThrow("Only a superuser can resolve removal requests.");
|
|
|
|
const approved = await resolveLeadershipTransfer(payload, users.boss, doc.id, {
|
|
approve: true,
|
|
note: "agreed",
|
|
});
|
|
expect(approved.status).toBe("approved");
|
|
expect(approved.reviewedBy).toBe(users.boss.id);
|
|
expect(approved.resolvedAt).toBeTruthy();
|
|
expect(approved.reviewNote).toBe("agreed");
|
|
|
|
const from = await getAssignment(divisionX);
|
|
const to = await getAssignment(infantry);
|
|
expect(from.members).not.toContain(users.member1.id);
|
|
expect(to.members).toContain(users.member1.id);
|
|
|
|
// The subject's notification names the LEADER, never the approving superuser.
|
|
const subjectNotified = await notificationsFor(users.member1.id, "assignment:leadership-transfer");
|
|
expect(subjectNotified.length).toBeGreaterThan(0);
|
|
const subjectMessage = subjectNotified[0]?.message ?? "";
|
|
expect(subjectMessage).toContain(users.leaderX.displayName);
|
|
expect(subjectMessage).not.toContain(users.boss.displayName);
|
|
|
|
const initiatorNotified = await notificationsFor(
|
|
users.leaderX.id,
|
|
"assignment:leadership-transfer-approved",
|
|
);
|
|
expect(initiatorNotified.length).toBeGreaterThan(0);
|
|
|
|
// Exactly one public event, visibly authored by the leader; the resolver's
|
|
// id lives in data for audit only.
|
|
const events = await leadershipEventsFor(doc.id);
|
|
expect(events.length).toBe(1);
|
|
const event = events[0]!;
|
|
expect(event.type).toBe(EventTypes.PersonnelTransferred);
|
|
expect(event.actor).toBe(users.leaderX.id);
|
|
const data = event.data as Record<string, unknown>;
|
|
expect(data.initiatorId).toBe(users.leaderX.id);
|
|
expect(data.approvedById).toBe(users.boss.id);
|
|
expect(data.subjectId).toBe(users.member1.id);
|
|
expect(data.fromAssignmentId).toBe(divisionX);
|
|
expect(data.toAssignmentId).toBe(infantry);
|
|
expect(data.reason).toBe("presence");
|
|
|
|
// A second resolution attempt is rejected (no longer pending).
|
|
await expect(
|
|
resolveLeadershipTransfer(payload, users.boss, doc.id, { approve: true }),
|
|
).rejects.toThrow("Only pending removal requests can be resolved");
|
|
|
|
// The collection hook rejects illegal transitions out of the terminal state.
|
|
await expect(
|
|
payload.update({
|
|
collection: "leadership-transfers",
|
|
id: doc.id,
|
|
data: { status: "pending" },
|
|
overrideAccess: true,
|
|
}),
|
|
).rejects.toThrow("Illegal leadership transfer status transition");
|
|
}, TIMEOUT);
|
|
|
|
it("denial moves nothing, notifies only the initiator, and emits no event", async () => {
|
|
const divisionX = assignmentIdsByName["Division X"];
|
|
|
|
const doc = await requestLeadershipTransfer(payload, users.leaderX, {
|
|
subjectId: users.member2.id,
|
|
fromAssignmentId: divisionX,
|
|
});
|
|
transferIds.push(doc.id);
|
|
|
|
const denied = await resolveLeadershipTransfer(payload, users.boss, doc.id, {
|
|
approve: false,
|
|
note: "keep them",
|
|
});
|
|
expect(denied.status).toBe("denied");
|
|
expect(denied.reviewedBy).toBe(users.boss.id);
|
|
expect(denied.resolvedAt).toBeTruthy();
|
|
expect(denied.reviewNote).toBe("keep them");
|
|
|
|
const from = await getAssignment(divisionX);
|
|
const to = await getAssignment(assignmentIdsByName["Infantry"]);
|
|
expect(from.members).toContain(users.member2.id);
|
|
expect(to.members).not.toContain(users.member2.id);
|
|
|
|
const initiatorNotified = await notificationsFor(
|
|
users.leaderX.id,
|
|
"assignment:leadership-transfer-denied",
|
|
);
|
|
expect(initiatorNotified.length).toBeGreaterThan(0);
|
|
expect(initiatorNotified[0]?.message).toContain("keep them");
|
|
|
|
// The subject is not told about a denial, and nothing goes public.
|
|
expect((await notificationsFor(users.member2.id, "assignment:leadership-transfer")).length).toBe(0);
|
|
expect((await leadershipEventsFor(doc.id)).length).toBe(0);
|
|
}, TIMEOUT);
|
|
|
|
it("cancels own pending requests only", async () => {
|
|
const divisionX = assignmentIdsByName["Division X"];
|
|
|
|
const doc = await requestLeadershipTransfer(payload, users.leaderX, {
|
|
subjectId: users.member3.id,
|
|
fromAssignmentId: divisionX,
|
|
});
|
|
transferIds.push(doc.id);
|
|
|
|
// Another leader cannot cancel someone else's request.
|
|
await expect(
|
|
cancelLeadershipTransfer(payload, users.leaderY, doc.id),
|
|
).rejects.toThrow("Only your own pending requests can be cancelled.");
|
|
|
|
const cancelled = await cancelLeadershipTransfer(payload, users.leaderX, doc.id);
|
|
expect(cancelled.status).toBe("cancelled");
|
|
|
|
// Cancelled is terminal: no second cancellation, and no membership moved.
|
|
await expect(cancelLeadershipTransfer(payload, users.leaderX, doc.id)).rejects.toThrow(
|
|
"Only your own pending requests can be cancelled.",
|
|
);
|
|
const from = await getAssignment(divisionX);
|
|
const to = await getAssignment(assignmentIdsByName["Infantry"]);
|
|
expect(from.members).toContain(users.member3.id);
|
|
expect(to.members).not.toContain(users.member3.id);
|
|
|
|
// A cancelled request is not "open": a fresh one for the same member is allowed.
|
|
const second = await requestLeadershipTransfer(payload, users.leaderX, {
|
|
subjectId: users.member3.id,
|
|
fromAssignmentId: divisionX,
|
|
});
|
|
transferIds.push(second.id);
|
|
expect(second.status).toBe("pending");
|
|
await cancelLeadershipTransfer(payload, users.leaderX, second.id);
|
|
}, TIMEOUT);
|
|
});
|