1
0
Fork 0
polaris-task-force/tests/int/leadership-transfers.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

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);
});