1
0
Fork 0

feat(evaluations): add evaluation service

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-08-27 18:36:04 -04:00
parent 15145f613a
commit a21770e54e
2 changed files with 872 additions and 0 deletions

View file

@ -0,0 +1,396 @@
import {
getPerformanceLevel,
isPerformanceLevelId,
PERFORMANCE_LEVELS,
type PerformanceLevel,
type PerformanceLevelId,
} from "./levels";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
/** The three evaluation kinds, per the leadership-evaluations feature. */
export type EvaluationKind = "leader-direct" | "leader-indirect" | "subordinate";
/** Shape of an evaluations doc as consumed by this service (depth 0). */
export interface EvaluationDoc {
id: number;
rater: number;
ratee: number;
mission: number;
kind: EvaluationKind;
level: PerformanceLevelId;
comment: string | null;
createdAt: string;
updatedAt: string;
}
/**
* Minimum number of distinct raters before a leader aggregate is visible to anyone.
* Below this threshold the per-level breakdown and average are withheld so a small
* group of raters cannot be identified from the displayed distribution.
*/
export const ANONYMITY_THRESHOLD = 3;
/**
* Pure eligibility check for whether a mission is "completed/past" enough to be
* evaluated. Draft statuses (Concept/Planning/Ready) and Cancelled are never
* eligible. Scheduled/Active missions become eligible once their scheduled start
* has passed.
*/
export function isMissionEvaluable(
status: string,
startDateTimeISO?: string | null,
now: Date = new Date(),
): boolean {
if (status === "Completed") return true;
if (status === "Scheduled" || status === "Active") {
if (!startDateTimeISO) return false;
const start = new Date(startDateTimeISO).getTime();
if (Number.isNaN(start)) return false;
return start < now.getTime();
}
return false;
}
/**
* Documented participation proxy: this codebase has no actual participant list or
* mission-leader field on missions. A "yes" RSVP in `mission-attendances` is the
* evidence we treat as "this user took part in the mission". Used for leader
* ratings (direct and indirect). Subordinate ratings do not require it — the
* command relationship itself is the basis.
*/
export async function hasYesAttendance(
payload: PayloadType,
userId: number,
missionId: number,
): Promise<boolean> {
const res = await payload.find({
collection: "mission-attendances",
where: {
and: [
{ user: { equals: userId } },
{ mission: { equals: missionId } },
{ response: { equals: "yes" } },
],
},
limit: 1,
depth: 0,
overrideAccess: true,
});
return res.docs.length > 0;
}
function extractIds(value: unknown): number[] {
if (!Array.isArray(value)) return [];
const ids: number[] = [];
for (const v of value) {
if (typeof v === "number") ids.push(v);
else if (v && typeof v === "object" && typeof (v as { id?: unknown }).id === "number") {
ids.push((v as { id: number }).id);
}
}
return ids;
}
/** True when `leaderId` leads an assignment whose members include `subordinateId`. */
export async function isDirectLeaderOf(
payload: PayloadType,
leaderId: number,
subordinateId: number,
): Promise<boolean> {
const assignments = await payload.find({
collection: "assignments",
where: { leader: { equals: leaderId } },
depth: 0,
overrideAccess: true,
});
return assignments.docs.some((a) => extractIds(a.members).includes(subordinateId));
}
/** True when `userId` leads at least one assignment. */
export async function isAnyLeader(payload: PayloadType, userId: number): Promise<boolean> {
const res = await payload.find({
collection: "assignments",
where: { leader: { equals: userId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
return res.docs.length > 0;
}
export interface EligibilityResult {
eligible: boolean;
kind?: EvaluationKind;
reason?: string;
}
/**
* Determines whether `raterId` may evaluate `rateeId` for `missionId`, and under
* which kind. Rules (leadership-evaluations feature):
*
* - You cannot evaluate yourself.
* - The mission must be completed/past (see {@link isMissionEvaluable}).
* - `leader-direct`: the ratee directly leads the rater's assignment AND the rater
* participated in the mission (yes-RSVP proxy).
* - `subordinate`: the rater directly leads the ratee's assignment. No RSVP
* required — the command relationship itself is the basis.
* - `leader-indirect`: the rater participated in the mission and the ratee leads at
* least one assignment, but did not directly lead the rater.
*/
export async function evaluateEligibility(
payload: PayloadType,
input: { raterId: number; rateeId: number; missionId: number },
): Promise<EligibilityResult> {
const { raterId, rateeId, missionId } = input;
if (raterId === rateeId) {
return { eligible: false, reason: "You cannot evaluate yourself." };
}
const missions = await payload.find({
collection: "missions",
where: { id: { equals: missionId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const mission = missions.docs[0] as
| {
ownershipAndStatus?: { status?: string };
classification?: { startDateTime?: string | null };
}
| undefined;
if (!mission) return { eligible: false, reason: "Mission not found." };
const status = mission.ownershipAndStatus?.status ?? "";
const startDateTime = mission.classification?.startDateTime;
if (!isMissionEvaluable(status, startDateTime)) {
return { eligible: false, reason: "This mission is not completed or past yet." };
}
const participated = await hasYesAttendance(payload, raterId, missionId);
const rateeLeadsRater = await isDirectLeaderOf(payload, rateeId, raterId);
if (rateeLeadsRater) {
if (!participated) {
return { eligible: false, reason: "No attendance recorded for this mission." };
}
return { eligible: true, kind: "leader-direct" };
}
const raterLeadsRatee = await isDirectLeaderOf(payload, raterId, rateeId);
if (raterLeadsRatee) {
return { eligible: true, kind: "subordinate" };
}
if (participated && (await isAnyLeader(payload, rateeId))) {
return { eligible: true, kind: "leader-indirect" };
}
return { eligible: false, reason: "No leadership relationship for this mission." };
}
export interface UpsertEvaluationInput {
raterId: number;
rateeId: number;
missionId: number;
kind: EvaluationKind;
level: PerformanceLevelId;
comment?: string;
}
/**
* One row per (rater, ratee, mission, kind). An existing evaluation for that tuple
* is updated in place; otherwise a new one is created. Enforced here in the service
* layer — there is deliberately no DB constraint to hand-maintain.
*/
export async function upsertEvaluation(
payload: PayloadType,
input: UpsertEvaluationInput,
): Promise<{ evaluation: EvaluationDoc; created: boolean }> {
if (!isPerformanceLevelId(input.level)) {
throw new Error(`Unknown performance level: ${String(input.level)}`);
}
const existing = await payload.find({
collection: "evaluations",
where: {
and: [
{ rater: { equals: input.raterId } },
{ ratee: { equals: input.rateeId } },
{ mission: { equals: input.missionId } },
{ kind: { equals: input.kind } },
],
},
limit: 1,
depth: 0,
overrideAccess: true,
});
if (existing.docs.length > 0) {
const updated = await payload.update({
collection: "evaluations",
id: existing.docs[0].id,
data: { level: input.level, comment: input.comment ?? null },
depth: 0,
overrideAccess: true,
});
return { evaluation: updated as unknown as EvaluationDoc, created: false };
}
const created = await payload.create({
collection: "evaluations",
data: {
rater: input.raterId,
ratee: input.rateeId,
mission: input.missionId,
kind: input.kind,
level: input.level,
comment: input.comment ?? null,
},
depth: 0,
overrideAccess: true,
});
return { evaluation: created as unknown as EvaluationDoc, created: true };
}
export interface LeaderEvaluationAggregate {
rateeId: number;
/** Distinct raters across all leader-kind evaluations. */
totalRaters: number;
/** False while raters are below ANONYMITY_THRESHOLD — breakdown withheld. */
visible: boolean;
levels: {
id: string;
label: string;
score: number;
color: string;
count: number;
percentage: number;
}[] | null;
averageScore: number | null;
missions: LeaderMissionAggregate[];
}
export interface LeaderMissionAggregate {
missionId: number;
missionName: string;
missionCodeName: string;
startDateTime: string | null;
totalRaters: number;
percentage: number | null;
}
/**
* Anonymous aggregate of leader-kind evaluations (direct + indirect) for a ratee.
* The per-level breakdown and average are only exposed once at least
* ANONYMITY_THRESHOLD distinct raters have evaluated — below that, `visible` is
* false and the numbers are null so no rater can be identified. No rater identity
* or comment ever appears in the output.
*/
export async function getLeaderAggregate(
payload: PayloadType,
rateeId: number,
): Promise<LeaderEvaluationAggregate> {
const res = await payload.find({
collection: "evaluations",
where: {
and: [{ ratee: { equals: rateeId } }, { kind: { in: ["leader-direct", "leader-indirect"] } }],
},
depth: 0,
overrideAccess: true,
});
const docs = res.docs as unknown as EvaluationDoc[];
const totalRaters = new Set(docs.map((d) => d.rater)).size;
if (totalRaters < ANONYMITY_THRESHOLD) {
return { rateeId, totalRaters, visible: false, levels: null, averageScore: null, missions: [] };
}
const counts = new Map<string, number>();
for (const d of docs) {
counts.set(d.level, (counts.get(d.level) ?? 0) + 1);
}
const totalDocs = docs.length;
const levels = PERFORMANCE_LEVELS.map((l) => {
const count = counts.get(l.id) ?? 0;
return {
id: l.id,
label: l.label,
score: l.score,
color: l.color,
count,
percentage: totalDocs ? Math.round((count / totalDocs) * 10000) / 100 : 0,
};
}).filter((l) => l.count > 0);
const averageScore = totalDocs
? Math.round(
(docs.reduce((sum, d) => sum + (getPerformanceLevel(d.level)?.score ?? 0), 0) /
totalDocs) *
100,
) / 100
: 0;
const missionIds = [...new Set(docs.map((doc) => doc.mission))];
const missionRows = await Promise.all(
missionIds.map(async (missionId) => {
const missionResult = await payload.findByID({
collection: "missions",
id: missionId,
depth: 0,
overrideAccess: true,
});
const missionDocs = docs.filter((doc) => doc.mission === missionId);
const missionRaters = new Set(missionDocs.map((doc) => doc.rater)).size;
const missionScore = missionDocs.reduce(
(sum, doc) => sum + (getPerformanceLevel(doc.level)?.score ?? 0),
0,
);
return {
missionId,
missionName: missionResult.name,
missionCodeName: missionResult.codeName,
startDateTime: missionResult.classification?.startDateTime ?? null,
totalRaters: missionRaters,
percentage:
missionRaters >= ANONYMITY_THRESHOLD && missionDocs.length > 0
? Math.round((missionScore / missionDocs.length) * 100) / 100
: null,
} satisfies LeaderMissionAggregate;
}),
);
missionRows.sort((a, b) => {
const dateA = a.startDateTime ? new Date(a.startDateTime).getTime() : 0;
const dateB = b.startDateTime ? new Date(b.startDateTime).getTime() : 0;
return dateB - dateA;
});
return { rateeId, totalRaters, visible: true, levels, averageScore, missions: missionRows.slice(0, 5) };
}
/**
* The most recent subordinate-kind evaluation for a ratee, reduced to the level
* definition plus mission and timestamp. Rater identity and comment are private
* and intentionally absent from this output.
*/
export async function getLatestSubordinateLevel(
payload: PayloadType,
rateeId: number,
): Promise<{ level: PerformanceLevel; missionId: number; at: string } | null> {
const res = await payload.find({
collection: "evaluations",
where: {
and: [{ ratee: { equals: rateeId } }, { kind: { equals: "subordinate" } }],
},
sort: "-updatedAt",
limit: 1,
depth: 0,
overrideAccess: true,
});
const doc = res.docs[0] as unknown as EvaluationDoc | undefined;
if (!doc) return null;
const level = getPerformanceLevel(doc.level);
if (!level) return null;
return { level, missionId: doc.mission, at: doc.updatedAt };
}

View file

@ -0,0 +1,476 @@
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` },
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" },
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(() => {});
}
});
});