Players meeting a role's minimum rank and MOS can queue for mission roles before op time. Slot holders are recomputed statelessly from signup order plus current RSVP (first N committed signups hold the role); attendance writes diff holders around the write so RSVP changes auto-promote, with notifications and events. Adds mission-role-signups collection, roleSignups/missionTime libs, sign-up panel, role slots + rich-text descriptions (with RoleDescription renderer), orphan-pruning hook, migrations, tests, and AGENTS.md docs.
581 lines
20 KiB
TypeScript
581 lines
20 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type {
|
|
Map as MissionMap,
|
|
Mission,
|
|
MissionRoleSignup,
|
|
Qualification,
|
|
Rank,
|
|
User,
|
|
} from "@/payload-types";
|
|
import {
|
|
diffPromotedUsers,
|
|
getMissionRoleSignupState,
|
|
getSlotHolders,
|
|
removeRoleSignup,
|
|
roleSignupGate,
|
|
roleSignupsClosedMessage,
|
|
signupForRole,
|
|
withdrawRoleSignup,
|
|
} from "@/lib/intelligence/roleSignups";
|
|
import { setMissionAttendance } from "@/lib/attendance";
|
|
|
|
let payload: Payload;
|
|
|
|
const RUN = `mrs-${Date.now().toString(36)}`;
|
|
|
|
/**
|
|
* 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(() => {});
|
|
};
|
|
|
|
const deleteMissionWithRelations = async (pg: Payload, missionId: number): Promise<void> => {
|
|
for (const collection of ["mission-role-signups", "mission-attendances"] as const) {
|
|
const docs = await pg
|
|
.find({
|
|
collection,
|
|
where: { mission: { equals: missionId } },
|
|
limit: 500,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null);
|
|
for (const doc of docs?.docs ?? []) {
|
|
await pg.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
}
|
|
await pg.delete({ collection: "missions", id: missionId, overrideAccess: true }).catch(() => {});
|
|
};
|
|
|
|
describe("Mission role sign-ups", () => {
|
|
let mapId: number;
|
|
let missionId: number;
|
|
let queueMissionId: number;
|
|
let switchMissionId: number;
|
|
let bypassMissionId: number;
|
|
let pastMissionId: number;
|
|
let cancelledMissionId: number;
|
|
const missionIds: number[] = [];
|
|
const rankIds: number[] = [];
|
|
const qualIds: number[] = [];
|
|
const userIds: number[] = [];
|
|
|
|
let pvt: Rank;
|
|
let cpl: Rank;
|
|
let sgt: Rank;
|
|
let medic: Qualification;
|
|
|
|
let memberA: User; // Pvt with the Medic MOS
|
|
let memberB: User; // Pvt with no qualifications
|
|
let memberC: User; // Cpl, no qualifications
|
|
let memberD: User; // Sgt with the Medic MOS
|
|
let adminUser: User;
|
|
|
|
const makeMission = async (
|
|
label: string,
|
|
options: {
|
|
status?: Mission["ownershipAndStatus"]["status"];
|
|
start?: Date;
|
|
roles: Record<string, unknown>[];
|
|
},
|
|
): Promise<Mission> => {
|
|
const providedRoles = options.roles as NonNullable<Mission["missionRoles"]>["providedRoles"];
|
|
const mission = (await payload.create({
|
|
collection: "missions",
|
|
data: {
|
|
name: `${RUN} ${label}`,
|
|
codeName: `${RUN}-${label}`,
|
|
summary: "Role sign-up test mission",
|
|
operationType: "main",
|
|
classification: {
|
|
map: mapId,
|
|
missionType: "PvE",
|
|
startDateTime: (
|
|
options.start ?? new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)
|
|
).toISOString(),
|
|
estimatedDuration: 60,
|
|
},
|
|
ownershipAndStatus: {
|
|
authors: [adminUser.id],
|
|
status: options.status ?? "Scheduled",
|
|
visibility: "unit",
|
|
},
|
|
briefing: [],
|
|
gameDetails: {
|
|
serverDetails: {
|
|
serverIp: "127.0.0.1",
|
|
serverPort: 2302,
|
|
},
|
|
},
|
|
missionRoles: {
|
|
maxPlayers: 32,
|
|
providedRoles,
|
|
},
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Mission;
|
|
missionIds.push(mission.id);
|
|
return mission;
|
|
};
|
|
|
|
const makeUser = async (username: string, roles: User["roles"] = ["user"]): Promise<User> => {
|
|
const user = (await payload.create({
|
|
collection: "users",
|
|
data: {
|
|
username,
|
|
discordUsername: username,
|
|
displayName: "ROLE SIGNUP 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 setProfile = async (userId: number, rankId: number, qualificationIds: number[]) => {
|
|
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, progression: { qualifications: qualificationIds } },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
const payloadConfig = await config;
|
|
payload = await getPayload({ config: payloadConfig });
|
|
|
|
const map = (await payload.create({
|
|
collection: "maps",
|
|
data: {
|
|
name: `${RUN} Map`,
|
|
worldSizeWidth: 8192,
|
|
worldSizeHeight: 8192,
|
|
basemapMode: "image",
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as MissionMap;
|
|
mapId = map.id;
|
|
|
|
// Self-contained 3-rank ladder: Pvt < Cpl < Sgt.
|
|
for (const [name, abbreviation] of [
|
|
["Pvt", "pvt"],
|
|
["Cpl", "cpl"],
|
|
["Sgt", "sgt"],
|
|
] 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;
|
|
}
|
|
|
|
medic = (await payload.create({
|
|
collection: "qualifications",
|
|
data: { name: `${RUN} Advanced Medic` },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Qualification;
|
|
qualIds.push(medic.id);
|
|
|
|
adminUser = await makeUser(`${RUN}-admin`, ["admin"]);
|
|
memberA = await makeUser(`${RUN}-a`);
|
|
memberB = await makeUser(`${RUN}-b`);
|
|
memberC = await makeUser(`${RUN}-c`);
|
|
memberD = await makeUser(`${RUN}-d`);
|
|
|
|
await setProfile(memberA.id, pvt.id, [medic.id]);
|
|
await setProfile(memberB.id, pvt.id, []);
|
|
await setProfile(memberC.id, cpl.id, []);
|
|
await setProfile(memberD.id, sgt.id, [medic.id]);
|
|
|
|
const main = await makeMission("Main", {
|
|
roles: [
|
|
{ roleName: "Squad Leader", slots: 1, minimumRank: cpl.id },
|
|
{ roleName: "Medic", slots: 1, requiredMos: [medic.id] },
|
|
{ roleName: "Rifleman", slots: 2 },
|
|
],
|
|
});
|
|
missionId = main.id;
|
|
|
|
const queue = await makeMission("Queue", {
|
|
roles: [{ roleName: "Rifleman", slots: 2 }],
|
|
});
|
|
queueMissionId = queue.id;
|
|
|
|
const switchMission = await makeMission("Switch", {
|
|
roles: [
|
|
{ roleName: "Medic", slots: 1, requiredMos: [medic.id] },
|
|
{ roleName: "Rifleman", slots: 2 },
|
|
],
|
|
});
|
|
switchMissionId = switchMission.id;
|
|
|
|
const bypass = await makeMission("Bypass", {
|
|
roles: [{ roleName: "Squad Leader", slots: 1, minimumRank: sgt.id }],
|
|
});
|
|
bypassMissionId = bypass.id;
|
|
|
|
const past = await makeMission("Past", {
|
|
start: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
|
roles: [{ roleName: "Rifleman", slots: 2 }],
|
|
});
|
|
pastMissionId = past.id;
|
|
|
|
const cancelled = await makeMission("Cancelled", {
|
|
status: "Cancelled",
|
|
roles: [{ roleName: "Rifleman", slots: 2 }],
|
|
});
|
|
cancelledMissionId = cancelled.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const id of missionIds) {
|
|
await deleteMissionWithRelations(payload, id);
|
|
}
|
|
// 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 userIds) {
|
|
await deleteUserWithRelations(payload, id);
|
|
}
|
|
for (const id of qualIds) {
|
|
await payload
|
|
.delete({ collection: "qualifications", id, overrideAccess: true })
|
|
.catch(() => {});
|
|
}
|
|
for (const id of rankIds) {
|
|
await payload.delete({ collection: "ranks", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
|
|
});
|
|
|
|
it("gates sign-ups by minimum rank and required MOS", async () => {
|
|
// B is a Pvt: the Squad Leader role requires Cpl or higher.
|
|
await expect(
|
|
signupForRole(payload, { missionId, userId: memberB.id, roleIndex: 0 }),
|
|
).rejects.toThrow(/Requires the rank of/);
|
|
|
|
// C is a Cpl: passes the rank gate and immediately holds the slot once RSVPed.
|
|
await setMissionAttendance(payload, { missionId, userId: memberC.id, response: "yes" });
|
|
await signupForRole(payload, { missionId, userId: memberC.id, roleIndex: 0 });
|
|
const afterC = await getMissionRoleSignupState(payload, missionId);
|
|
expect(afterC.roles[0].filled).toBe(1);
|
|
expect(afterC.roles[0].entries[0].holding).toBe(true);
|
|
|
|
// B lacks the Medic MOS.
|
|
await expect(
|
|
signupForRole(payload, { missionId, userId: memberB.id, roleIndex: 1 }),
|
|
).rejects.toThrow(/Requires qualification:/);
|
|
|
|
// A is a Pvt with the Medic MOS: the Medic role has no rank floor.
|
|
await signupForRole(payload, { missionId, userId: memberA.id, roleIndex: 1 });
|
|
const afterA = await getMissionRoleSignupState(payload, missionId);
|
|
expect(afterA.roles[1].filled).toBe(0); // A has not RSVPed yet, so no slot held
|
|
expect(afterA.roles[1].entries[0].holding).toBe(false);
|
|
|
|
// Re-signing for the same role is a no-op (one signup per user per mission).
|
|
await signupForRole(payload, { missionId, userId: memberA.id, roleIndex: 1 });
|
|
const signups = await payload.find({
|
|
collection: "mission-role-signups",
|
|
where: { mission: { equals: missionId } },
|
|
limit: 100,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
expect(signups.docs).toHaveLength(2);
|
|
|
|
// A role index past the end of the mission's role list is refused.
|
|
await expect(
|
|
signupForRole(payload, { missionId, userId: memberA.id, roleIndex: 99 }),
|
|
).rejects.toThrow(/no longer exists/);
|
|
});
|
|
|
|
it("queues by first-come-first-served and auto-promotes on RSVP drop", async () => {
|
|
await setMissionAttendance(payload, {
|
|
missionId: queueMissionId,
|
|
userId: memberA.id,
|
|
response: "yes",
|
|
});
|
|
await setMissionAttendance(payload, {
|
|
missionId: queueMissionId,
|
|
userId: memberB.id,
|
|
response: "yes",
|
|
});
|
|
await setMissionAttendance(payload, {
|
|
missionId: queueMissionId,
|
|
userId: memberC.id,
|
|
response: "yes",
|
|
});
|
|
|
|
await signupForRole(payload, { missionId: queueMissionId, userId: memberA.id, roleIndex: 0 });
|
|
await signupForRole(payload, { missionId: queueMissionId, userId: memberB.id, roleIndex: 0 });
|
|
await signupForRole(payload, { missionId: queueMissionId, userId: memberC.id, roleIndex: 0 });
|
|
|
|
let state = await getMissionRoleSignupState(payload, queueMissionId);
|
|
expect(state.roles[0].filled).toBe(2);
|
|
expect(state.roles[0].entries.map((e) => [e.position, e.holding])).toEqual([
|
|
[1, true],
|
|
[2, true],
|
|
[3, false],
|
|
]);
|
|
expect(state.roles[0].slots).toBe(2);
|
|
|
|
// A (slot holder) RSVPs No: the first waitlisted member takes over.
|
|
await setMissionAttendance(payload, {
|
|
missionId: queueMissionId,
|
|
userId: memberA.id,
|
|
response: "no",
|
|
});
|
|
|
|
state = await getMissionRoleSignupState(payload, queueMissionId);
|
|
expect(state.roles[0].filled).toBe(2);
|
|
expect(state.roles[0].entries.map((e) => [e.holding])).toEqual([[false], [true], [true]]);
|
|
|
|
// The promoted member is notified and the promotion is logged.
|
|
const notifications = await payload.find({
|
|
collection: "user-notifications",
|
|
where: { user: { equals: memberC.id } },
|
|
limit: 100,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
const promotedNotes = notifications.docs.filter(
|
|
(n) => (n as unknown as { type: string }).type === "mission:role-promoted",
|
|
);
|
|
expect(promotedNotes).toHaveLength(1);
|
|
expect(promotedNotes[0]).toMatchObject({
|
|
title: "Slot granted: Rifleman",
|
|
link: `/intelligence/missions/${queueMissionId}`,
|
|
});
|
|
|
|
const events = await payload.find({
|
|
collection: "game-event-logs",
|
|
where: { type: { equals: "mission:role-promoted" } },
|
|
limit: 100,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
const promotionEvent = events.docs.find(
|
|
(e) =>
|
|
(e as unknown as { targetId: number }).targetId === queueMissionId &&
|
|
JSON.stringify((e as unknown as { data?: { userId?: number } }).data ?? {}).includes(
|
|
`"userId":${memberC.id}`,
|
|
),
|
|
);
|
|
expect(promotionEvent).toBeDefined();
|
|
});
|
|
|
|
it("promotes on withdrawal and refuses a second withdrawal", async () => {
|
|
// D joins the (full) Rifleman queue: two holders, two waitlisted.
|
|
await setMissionAttendance(payload, {
|
|
missionId: queueMissionId,
|
|
userId: memberD.id,
|
|
response: "yes",
|
|
});
|
|
await signupForRole(payload, { missionId: queueMissionId, userId: memberD.id, roleIndex: 0 });
|
|
|
|
let state = await getMissionRoleSignupState(payload, queueMissionId);
|
|
expect(state.roles[0].entries).toHaveLength(4);
|
|
|
|
// B withdraws: C keeps a slot and D (next committed member) takes B's.
|
|
await withdrawRoleSignup(payload, { missionId: queueMissionId, userId: memberB.id });
|
|
|
|
state = await getMissionRoleSignupState(payload, queueMissionId);
|
|
expect(state.roles[0].filled).toBe(2);
|
|
expect(state.roles[0].entries.find((e) => e.userId === memberD.id)?.holding).toBe(true);
|
|
expect(state.roles[0].entries.find((e) => e.userId === memberB.id)).toBeUndefined();
|
|
|
|
await expect(
|
|
withdrawRoleSignup(payload, { missionId: queueMissionId, userId: memberB.id }),
|
|
).rejects.toThrow(/not signed up/);
|
|
});
|
|
|
|
it("moves the signup when switching roles and enforces requirements on the target", async () => {
|
|
// A holds a Rifleman slot on the switch mission, then moves to Medic.
|
|
await setMissionAttendance(payload, {
|
|
missionId: switchMissionId,
|
|
userId: memberA.id,
|
|
response: "yes",
|
|
});
|
|
await signupForRole(payload, { missionId: switchMissionId, userId: memberA.id, roleIndex: 1 });
|
|
let state = await getMissionRoleSignupState(payload, switchMissionId, memberA.id);
|
|
expect(state.roles[1].filled).toBe(1);
|
|
expect(state.mySignup).toMatchObject({ roleIndex: 1, holding: true });
|
|
|
|
// Switching to Medic moves the one live signup (no second row).
|
|
await signupForRole(payload, { missionId: switchMissionId, userId: memberA.id, roleIndex: 0 });
|
|
state = await getMissionRoleSignupState(payload, switchMissionId, memberA.id);
|
|
expect(state.roles[1].entries).toHaveLength(0);
|
|
expect(state.roles[0].filled).toBe(1);
|
|
expect(state.mySignup).toMatchObject({ roleIndex: 0, holding: true });
|
|
|
|
// B (Pvt, no MOS) and C (Cpl, no MOS) both fail the Medic MOS gate.
|
|
await expect(
|
|
signupForRole(payload, { missionId: switchMissionId, userId: memberB.id, roleIndex: 0 }),
|
|
).rejects.toThrow(/Requires qualification:/);
|
|
await expect(
|
|
signupForRole(payload, { missionId: switchMissionId, userId: memberC.id, roleIndex: 0 }),
|
|
).rejects.toThrow(/Requires qualification:/);
|
|
});
|
|
|
|
it("lets staff bypass eligibility and remove sign-ups", async () => {
|
|
// Admin signs up for the Sgt-floored role despite holding no test rank.
|
|
await setMissionAttendance(payload, {
|
|
missionId: bypassMissionId,
|
|
userId: adminUser.id,
|
|
response: "yes",
|
|
});
|
|
await signupForRole(payload, {
|
|
missionId: bypassMissionId,
|
|
userId: adminUser.id,
|
|
roleIndex: 0,
|
|
bypassEligibility: true,
|
|
});
|
|
const state = await getMissionRoleSignupState(payload, bypassMissionId, adminUser.id);
|
|
expect(state.roles[0].filled).toBe(1);
|
|
expect(state.mySignup).toMatchObject({ roleIndex: 0, holding: true });
|
|
|
|
// Staff removal reconciles slots and is marked in the event log.
|
|
const signupId = state.roles[0].entries[0].signupId;
|
|
await removeRoleSignup(payload, {
|
|
missionId: bypassMissionId,
|
|
signupId,
|
|
actorId: adminUser.id,
|
|
});
|
|
const after = await getMissionRoleSignupState(payload, bypassMissionId, adminUser.id);
|
|
expect(after.roles[0].entries).toHaveLength(0);
|
|
expect(after.mySignup).toBeNull();
|
|
|
|
const withdrawEvents = await payload.find({
|
|
collection: "game-event-logs",
|
|
where: { type: { equals: "mission:role-withdraw" } },
|
|
limit: 100,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
const removal = withdrawEvents.docs.find(
|
|
(e) =>
|
|
(e as unknown as { targetId: number }).targetId === bypassMissionId &&
|
|
(e as unknown as { data?: { removedByStaff?: boolean } }).data?.removedByStaff === true,
|
|
);
|
|
expect(removal).toBeDefined();
|
|
});
|
|
|
|
it("closes sign-ups for past and cancelled missions", async () => {
|
|
const pastGate = roleSignupGate({
|
|
ownershipAndStatus: { status: "Scheduled" },
|
|
classification: { startDateTime: new Date(Date.now() - 60_000).toISOString() },
|
|
});
|
|
expect(pastGate.open).toBe(false);
|
|
expect(pastGate.reason).toBe(roleSignupsClosedMessage);
|
|
|
|
await expect(
|
|
signupForRole(payload, { missionId: pastMissionId, userId: memberA.id, roleIndex: 0 }),
|
|
).rejects.toThrow(roleSignupsClosedMessage);
|
|
|
|
const cancelledGate = roleSignupGate({
|
|
ownershipAndStatus: { status: "Cancelled" },
|
|
classification: { startDateTime: new Date(Date.now() + 60_000).toISOString() },
|
|
});
|
|
expect(cancelledGate.open).toBe(false);
|
|
expect(cancelledGate.reason).toBe("This operation was cancelled.");
|
|
|
|
await expect(
|
|
signupForRole(payload, { missionId: cancelledMissionId, userId: memberA.id, roleIndex: 0 }),
|
|
).rejects.toThrow("This operation was cancelled.");
|
|
});
|
|
|
|
it("derives slot holders as a userId -> roleIndex map and diffs promotions", async () => {
|
|
const holders = await getSlotHolders(payload, missionId);
|
|
// C holds Squad Leader (RSVPed yes). A RSVPed no on the main mission, so no
|
|
// Medic slot is held there.
|
|
expect(holders.get(memberC.id)).toBe(0);
|
|
expect(holders.has(memberA.id)).toBe(false);
|
|
|
|
// A RSVPs yes: they now hold the Medic slot.
|
|
await setMissionAttendance(payload, { missionId, userId: memberA.id, response: "yes" });
|
|
const holdersAfter = await getSlotHolders(payload, missionId);
|
|
expect(holdersAfter.get(memberA.id)).toBe(1);
|
|
|
|
const promoted = diffPromotedUsers(holders, holdersAfter);
|
|
expect(promoted).toEqual([{ userId: memberA.id, roleIndex: 1 }]);
|
|
});
|
|
});
|