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 => { 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 => { 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 => { 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; 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(() => {}); } }); });