Add maps basemap config (world size, image/tiles mode, zoom defaults, isActive), map-roads, map-zones, and resource-nodes collections with maps:* permissions. Track game-structures map placement, meter coordinates, and label visibility/priority. Bump proximityThreshold to meters and adapt side-op map creation plus test fixtures to the new required fields. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type { Campaign, Map as MissionMap, Mission, Role, User } from "@/payload-types";
|
|
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
|
import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels";
|
|
import {
|
|
ANONYMITY_THRESHOLD,
|
|
evaluateEligibility,
|
|
getLatestSubordinateLevel,
|
|
getLeaderAggregate,
|
|
isMissionEvaluable,
|
|
upsertEvaluation,
|
|
} from "@/lib/evaluations";
|
|
|
|
let payload: Payload;
|
|
|
|
const RUN = `evl-${Date.now().toString(36)}`;
|
|
|
|
describe("Leadership evaluations", () => {
|
|
let mapId: number;
|
|
let campaignId: number;
|
|
let leaderA: User;
|
|
let subordinateB: User;
|
|
let participantC: User;
|
|
let participantE: User;
|
|
let noRsvpF: User;
|
|
let outsiderD: User;
|
|
let assignmentId: number;
|
|
let missionCompletedId: number;
|
|
let missionFutureId: number;
|
|
|
|
const userIds: number[] = [];
|
|
const attendanceIds: number[] = [];
|
|
const evaluationIds: number[] = [];
|
|
|
|
const makeUser = async (label: string): 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"],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as User;
|
|
userIds.push(user.id);
|
|
return user;
|
|
};
|
|
|
|
const makeMission = async (
|
|
label: string,
|
|
status: Mission["ownershipAndStatus"]["status"],
|
|
start: Date,
|
|
): Promise<Mission> => {
|
|
const mission = (await payload.create({
|
|
collection: "missions",
|
|
data: {
|
|
name: `${RUN} ${label}`,
|
|
codeName: `${RUN}-${label}`,
|
|
summary: "Leadership evaluation test mission",
|
|
operationType: "main",
|
|
classification: {
|
|
map: mapId,
|
|
missionType: "PvE",
|
|
campaign: campaignId,
|
|
startDateTime: start.toISOString(),
|
|
estimatedDuration: 60,
|
|
},
|
|
ownershipAndStatus: {
|
|
authors: [leaderA.id],
|
|
status,
|
|
visibility: "unit",
|
|
},
|
|
missionRoles: {
|
|
maxPlayers: 16,
|
|
},
|
|
gameDetails: {
|
|
serverDetails: {
|
|
serverIp: "127.0.0.1",
|
|
serverPort: 2302,
|
|
},
|
|
},
|
|
briefing: [],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Mission;
|
|
return mission;
|
|
};
|
|
|
|
const rsvp = async (user: User, missionId: number): Promise<void> => {
|
|
const attendance = await payload.create({
|
|
collection: "mission-attendances",
|
|
data: { mission: missionId, user: user.id, response: "yes" },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
attendanceIds.push(attendance.id);
|
|
};
|
|
|
|
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;
|
|
|
|
const campaign = (await payload.create({
|
|
collection: "campaigns",
|
|
data: { name: `${RUN} Campaign`, summary: "Evaluation test campaign", status: "concept", campaignMode: "custom" },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Campaign;
|
|
campaignId = campaign.id;
|
|
|
|
leaderA = await makeUser("leader-a");
|
|
subordinateB = await makeUser("subordinate-b");
|
|
participantC = await makeUser("participant-c");
|
|
participantE = await makeUser("participant-e");
|
|
noRsvpF = await makeUser("no-rsvp-f");
|
|
outsiderD = await makeUser("outsider-d");
|
|
|
|
const assignment = await payload.create({
|
|
collection: "assignments",
|
|
data: {
|
|
name: `${RUN} Squad`,
|
|
type: "squad",
|
|
leader: leaderA.id,
|
|
members: [subordinateB.id, noRsvpF.id],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
assignmentId = assignment.id;
|
|
|
|
const past = new Date();
|
|
past.setDate(past.getDate() - 7);
|
|
const future = new Date();
|
|
future.setDate(future.getDate() + 14);
|
|
|
|
const missionCompleted = await makeMission("completed", "Completed", past);
|
|
missionCompletedId = missionCompleted.id;
|
|
const missionFuture = await makeMission("future-scheduled", "Scheduled", future);
|
|
missionFutureId = missionFuture.id;
|
|
|
|
// Participation proxy: yes-RSVPs for B, C, E. F and D deliberately have none.
|
|
await rsvp(subordinateB, missionCompletedId);
|
|
await rsvp(participantC, missionCompletedId);
|
|
await rsvp(participantE, missionCompletedId);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
const evaluations = await payload
|
|
.find({
|
|
collection: "evaluations",
|
|
where: { mission: { in: [missionCompletedId, missionFutureId] } },
|
|
limit: 100,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null);
|
|
for (const doc of evaluations?.docs ?? []) {
|
|
await payload.delete({ collection: "evaluations", id: doc.id, overrideAccess: true }).catch(
|
|
() => {},
|
|
);
|
|
}
|
|
evaluationIds.length = 0;
|
|
|
|
for (const id of attendanceIds) {
|
|
await payload
|
|
.delete({ collection: "mission-attendances", id, overrideAccess: true })
|
|
.catch(() => {});
|
|
}
|
|
await payload.delete({ collection: "assignments", id: assignmentId, overrideAccess: true }).catch(
|
|
() => {},
|
|
);
|
|
for (const id of [missionCompletedId, missionFutureId]) {
|
|
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
await payload
|
|
.delete({ collection: "campaigns", id: campaignId, overrideAccess: true })
|
|
.catch(() => {});
|
|
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
|
|
|
|
for (const userId of userIds) {
|
|
const accounts = await payload
|
|
.find({
|
|
collection: "bank-accounts",
|
|
where: { ownerUser: { equals: userId } },
|
|
limit: 5,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null);
|
|
for (const account of accounts?.docs ?? []) {
|
|
await payload
|
|
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
|
.catch(() => {});
|
|
}
|
|
const profiles = await payload
|
|
.find({
|
|
collection: "profiles",
|
|
where: { user: { equals: userId } },
|
|
limit: 5,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null);
|
|
for (const profile of profiles?.docs ?? []) {
|
|
await payload
|
|
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
|
.catch(() => {});
|
|
}
|
|
await payload.delete({ collection: "users", id: userId, overrideAccess: true }).catch(() => {});
|
|
}
|
|
});
|
|
|
|
it("classifies mission statuses for evaluability (pure)", () => {
|
|
const past = new Date(Date.now() - 86_400_000).toISOString();
|
|
const future = new Date(Date.now() + 86_400_000).toISOString();
|
|
|
|
expect(isMissionEvaluable("Completed")).toBe(true);
|
|
expect(isMissionEvaluable("Scheduled", past)).toBe(true);
|
|
expect(isMissionEvaluable("Active", past)).toBe(true);
|
|
expect(isMissionEvaluable("Scheduled", future)).toBe(false);
|
|
expect(isMissionEvaluable("Ready", past)).toBe(false);
|
|
expect(isMissionEvaluable("Cancelled", past)).toBe(false);
|
|
expect(isMissionEvaluable("Scheduled")).toBe(false);
|
|
expect(isMissionEvaluable("Scheduled", "not-a-date")).toBe(false);
|
|
});
|
|
|
|
it("derives the evaluation kind from the rater/ratee/mission relationship", async () => {
|
|
const cases = [
|
|
{ input: { raterId: subordinateB.id, rateeId: leaderA.id }, expectKind: "leader-direct" },
|
|
{ input: { raterId: participantC.id, rateeId: leaderA.id }, expectKind: "leader-indirect" },
|
|
{ input: { raterId: leaderA.id, rateeId: subordinateB.id }, expectKind: "subordinate" },
|
|
];
|
|
for (const { input, expectKind } of cases) {
|
|
const result = await evaluateEligibility(payload, { ...input, missionId: missionCompletedId });
|
|
expect(result.eligible).toBe(true);
|
|
expect(result.kind).toBe(expectKind);
|
|
}
|
|
|
|
// In A's assignment but never RSVPed yes — the participation proxy fails.
|
|
const noRsvp = await evaluateEligibility(payload, {
|
|
raterId: noRsvpF.id,
|
|
rateeId: leaderA.id,
|
|
missionId: missionCompletedId,
|
|
});
|
|
expect(noRsvp.eligible).toBe(false);
|
|
expect(noRsvp.reason).toBe("No attendance recorded for this mission.");
|
|
|
|
// No leadership relationship at all.
|
|
const outsider = await evaluateEligibility(payload, {
|
|
raterId: outsiderD.id,
|
|
rateeId: leaderA.id,
|
|
missionId: missionCompletedId,
|
|
});
|
|
expect(outsider.eligible).toBe(false);
|
|
|
|
// Self-rating is always rejected.
|
|
const selfRating = await evaluateEligibility(payload, {
|
|
raterId: leaderA.id,
|
|
rateeId: leaderA.id,
|
|
missionId: missionCompletedId,
|
|
});
|
|
expect(selfRating.eligible).toBe(false);
|
|
|
|
// A not-yet-past mission is not evaluable.
|
|
const tooEarly = await evaluateEligibility(payload, {
|
|
raterId: subordinateB.id,
|
|
rateeId: leaderA.id,
|
|
missionId: missionFutureId,
|
|
});
|
|
expect(tooEarly.eligible).toBe(false);
|
|
});
|
|
|
|
it("keeps one row per rater/ratee/mission/kind", async () => {
|
|
const first = await upsertEvaluation(payload, {
|
|
raterId: subordinateB.id,
|
|
rateeId: leaderA.id,
|
|
missionId: missionCompletedId,
|
|
kind: "leader-direct",
|
|
level: "excellent",
|
|
comment: "First pass",
|
|
});
|
|
evaluationIds.push(first.evaluation.id);
|
|
expect(first.created).toBe(true);
|
|
|
|
const second = await upsertEvaluation(payload, {
|
|
raterId: subordinateB.id,
|
|
rateeId: leaderA.id,
|
|
missionId: missionCompletedId,
|
|
kind: "leader-direct",
|
|
level: "proficient",
|
|
comment: "Revised",
|
|
});
|
|
expect(second.created).toBe(false);
|
|
expect(second.evaluation.id).toBe(first.evaluation.id);
|
|
expect(second.evaluation.level).toBe("proficient");
|
|
|
|
const rows = await payload.find({
|
|
collection: "evaluations",
|
|
where: {
|
|
and: [
|
|
{ rater: { equals: subordinateB.id } },
|
|
{ ratee: { equals: leaderA.id } },
|
|
{ mission: { equals: missionCompletedId } },
|
|
{ kind: { equals: "leader-direct" } },
|
|
],
|
|
},
|
|
limit: 10,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
expect(rows.docs).toHaveLength(1);
|
|
});
|
|
|
|
it("withholds the leader aggregate below the anonymity threshold", async () => {
|
|
const c = await upsertEvaluation(payload, {
|
|
raterId: participantC.id,
|
|
rateeId: leaderA.id,
|
|
missionId: missionCompletedId,
|
|
kind: "leader-indirect",
|
|
level: "excellent",
|
|
});
|
|
evaluationIds.push(c.evaluation.id);
|
|
|
|
const aggregate = await getLeaderAggregate(payload, leaderA.id);
|
|
expect(aggregate.totalRaters).toBe(2);
|
|
expect(ANONYMITY_THRESHOLD).toBe(3);
|
|
expect(aggregate.visible).toBe(false);
|
|
expect(aggregate.levels).toBeNull();
|
|
expect(aggregate.averageScore).toBeNull();
|
|
});
|
|
|
|
it("exposes the leader aggregate once the anonymity threshold is met", async () => {
|
|
const e = await upsertEvaluation(payload, {
|
|
raterId: participantE.id,
|
|
rateeId: leaderA.id,
|
|
missionId: missionCompletedId,
|
|
kind: "leader-indirect",
|
|
level: "exceptional",
|
|
});
|
|
evaluationIds.push(e.evaluation.id);
|
|
|
|
const aggregate = await getLeaderAggregate(payload, leaderA.id);
|
|
expect(aggregate.totalRaters).toBe(3);
|
|
expect(aggregate.visible).toBe(true);
|
|
expect(aggregate.levels).not.toBeNull();
|
|
expect(aggregate.averageScore).toBe(85); // (70 + 85 + 100) / 3
|
|
|
|
const byId = new Map((aggregate.levels ?? []).map((l) => [l.id, l]));
|
|
expect(byId.get("exceptional")?.count).toBe(1);
|
|
expect(byId.get("excellent")?.count).toBe(1);
|
|
expect(byId.get("proficient")?.count).toBe(1);
|
|
expect(byId.get("adequate")).toBeUndefined();
|
|
for (const level of aggregate.levels ?? []) {
|
|
expect(level.percentage).toBeCloseTo(33.33, 1);
|
|
expect(PERFORMANCE_LEVELS.some((p) => p.id === level.id)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("returns only the latest subordinate level, without rater data", async () => {
|
|
await upsertEvaluation(payload, {
|
|
raterId: leaderA.id,
|
|
rateeId: subordinateB.id,
|
|
missionId: missionCompletedId,
|
|
kind: "subordinate",
|
|
level: "adequate",
|
|
comment: "Manager note — must never leak",
|
|
});
|
|
|
|
const revised = await upsertEvaluation(payload, {
|
|
raterId: leaderA.id,
|
|
rateeId: subordinateB.id,
|
|
missionId: missionCompletedId,
|
|
kind: "subordinate",
|
|
level: "excellent",
|
|
});
|
|
evaluationIds.push(revised.evaluation.id);
|
|
|
|
const latest = await getLatestSubordinateLevel(payload, subordinateB.id);
|
|
expect(latest).not.toBeNull();
|
|
expect(latest?.level.id).toBe("excellent");
|
|
expect(latest?.missionId).toBe(missionCompletedId);
|
|
expect(typeof latest?.at).toBe("string");
|
|
expect(latest).not.toHaveProperty("rater");
|
|
expect(latest).not.toHaveProperty("comment");
|
|
|
|
const none = await getLatestSubordinateLevel(payload, participantC.id);
|
|
expect(none).toBeNull();
|
|
});
|
|
|
|
it("hides raw evaluation documents from users without the permission", async () => {
|
|
// Local ops bypass access control unless overrideAccess: false — this flag
|
|
// makes findOperation run the same requirePermission("evaluations:read")
|
|
// check that the REST endpoint runs. A user with no access gets a Forbidden
|
|
// error rather than an empty list.
|
|
await expect(
|
|
payload.find({
|
|
collection: "evaluations",
|
|
limit: 50,
|
|
depth: 0,
|
|
user: outsiderD,
|
|
overrideAccess: false,
|
|
}),
|
|
).rejects.toThrow(/not allowed/);
|
|
});
|
|
|
|
it("exposes raw evaluation documents to users holding evaluations:read", async () => {
|
|
const role = (await payload.create({
|
|
collection: "roles",
|
|
data: {
|
|
name: `${RUN} Evaluation Reader`,
|
|
slug: `${RUN}-evaluation-reader`,
|
|
permissions: ["evaluations:read"],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as Role;
|
|
|
|
const reader = (await payload.create({
|
|
collection: "users",
|
|
data: {
|
|
username: `${RUN}-reader`,
|
|
discordUsername: `${RUN}-reader`,
|
|
displayName: "READER",
|
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
|
password: "Test123",
|
|
roles: ["user"],
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
})) as unknown as User;
|
|
userIds.push(reader.id);
|
|
|
|
await payload.update({
|
|
collection: "users",
|
|
id: reader.id,
|
|
data: { roleDocs: [role.id] },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
invalidatePermissionCache();
|
|
|
|
try {
|
|
const result = await payload.find({
|
|
collection: "evaluations",
|
|
limit: 50,
|
|
depth: 0,
|
|
user: reader,
|
|
overrideAccess: false,
|
|
});
|
|
expect(result.docs.length).toBeGreaterThan(0);
|
|
const doc = result.docs[0] as unknown as Record<string, unknown>;
|
|
expect(typeof doc.rater).toBe("number");
|
|
expect(typeof doc.ratee).toBe("number");
|
|
expect(typeof doc.mission).toBe("number");
|
|
expect(["leader-direct", "leader-indirect", "subordinate"]).toContain(doc.kind);
|
|
} finally {
|
|
await payload.delete({ collection: "roles", id: role.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
});
|
|
});
|