import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { computeReadiness, getReadiness, type ReadinessScope } from "@/lib/operations/readiness"; import { READINESS_CALCULATION_VERSION } from "@/lib/operations/readinessTypes"; let payload: Payload; const RUN = `ready-${Date.now().toString(36)}`; // --------------------------------------------------------------------------- // Fixture tracking for cleanup // --------------------------------------------------------------------------- const syncEventIds: number[] = []; const opEventIds: number[] = []; const opEffectIds: number[] = []; const userIds: number[] = []; const roleIds: number[] = []; const missionIds: number[] = []; const attendanceIds: number[] = []; const evaluationIds: number[] = []; const campaignIds: number[] = []; const mapIds: number[] = []; const readinessIds: number[] = []; let serverId: number; let subjectId: number; let raterId: number; let adminId: number; let mapId: number; let campaignId: number; let windowStart: Date; let windowEnd: Date; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function makeUser(label: string, roleDocIds?: number[]): Promise { const user = await payload.create({ collection: "users", data: { username: `${RUN}-${label}`, discordUsername: `${RUN}-${label}-discord`, displayName: `${RUN} ${label}`, steamId: `${RUN}-steam-${label}`, password: "Test1234", ...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}), }, overrideAccess: true, depth: 0, }); userIds.push(user.id); return user.id; } async function makeMission(label: string, authorId: number): Promise { const mission = await payload.create({ collection: "missions", data: { name: `${RUN} ${label}`, codeName: `${RUN}-${label}`, summary: "Readiness test mission", operationType: "main", classification: { map: mapId, missionType: "PvE", campaign: campaignId, startDateTime: new Date(Date.now() + 86_400_000).toISOString(), estimatedDuration: 60, }, ownershipAndStatus: { authors: [authorId], status: "Scheduled", visibility: "unit", }, missionRoles: { maxPlayers: 16 }, gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } }, briefing: [], }, overrideAccess: true, depth: 0, }); missionIds.push(mission.id); return mission.id; } async function makeAttendance(missionId: number, userId: number): Promise { const doc = await payload.create({ collection: "mission-attendances", data: { mission: missionId, user: userId, response: "yes" }, overrideAccess: true, depth: 0, }); attendanceIds.push(doc.id); return doc.id; } async function makeEvaluation(missionId: number, ratee: number): Promise { const doc = await payload.create({ collection: "evaluations", data: { rater: raterId, ratee, mission: missionId, kind: "leader-direct", level: "proficient" }, overrideAccess: true, depth: 0, }); evaluationIds.push(doc.id); return doc.id; } async function makeObjectiveEvent(label: string, actor: number): Promise { const sync = await payload.create({ collection: "arma-sync-events", data: { eventId: `${RUN}-${label}`, server: serverId, type: "operation.objective", occurredAt: new Date().toISOString(), payload: { operationId: RUN, version: 1, objective: "Cache secured", actorId: actor }, }, overrideAccess: true, depth: 0, }); syncEventIds.push(sync.id); const events = await payload.find({ collection: "operation-events", where: { sourceEvent: { equals: sync.id } }, limit: 1, depth: 0, overrideAccess: true, }); opEventIds.push(events.docs[0].id); const effects = await payload.find({ collection: "operation-effects", where: { operationEvent: { equals: events.docs[0].id } }, limit: 1, depth: 0, overrideAccess: true, }); opEffectIds.push(effects.docs[0].id); } function scopeFor(windowStart: Date, windowEnd: Date): ReadinessScope { return { subjectType: "user", subjectId, windowStart, windowEnd, windowLabel: `${RUN}-window`, operationId: RUN, }; } // --------------------------------------------------------------------------- // Setup / teardown // --------------------------------------------------------------------------- beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); // One shared, minute-floored window: the aggregate identity is // `subjectType:subjectId:windowStart`, so every test that expects to find // another test's row must use the exact same windowStart instant. const flooredMinute = Math.floor(Date.now() / 60_000) * 60_000; windowStart = new Date(flooredMinute - 3_600_000); windowEnd = new Date(flooredMinute + 3_600_000); const superRole = await payload.create({ collection: "roles", data: { name: `${RUN} Readiness Admin`, slug: `${RUN}-readiness-admin`, isSuperuser: true }, overrideAccess: true, depth: 0, }); roleIds.push(superRole.id); const server = await payload.create({ collection: "game-servers", data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" }, overrideAccess: true, depth: 0, }); serverId = server.id; subjectId = await makeUser("subject"); raterId = await makeUser("rater"); adminId = await makeUser("admin", [superRole.id]); const map = await payload.create({ collection: "maps", data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, overrideAccess: true, depth: 0, }); mapId = map.id; mapIds.push(map.id); const campaign = await payload.create({ collection: "campaigns", data: { name: `${RUN} Campaign`, summary: "Readiness test campaign", status: "concept", campaignMode: "custom", }, overrideAccess: true, depth: 0, }); campaignId = campaign.id; campaignIds.push(campaign.id); }); afterAll(async () => { if (!payload) return; const readinessRows = await payload.find({ collection: "operation-readiness", where: { operationId: { equals: RUN } }, limit: 100, depth: 0, overrideAccess: true, }); for (const row of readinessRows.docs) { await payload.delete({ collection: "operation-readiness", id: row.id, overrideAccess: true }).catch(() => {}); } for (const id of opEffectIds) { await payload.delete({ collection: "operation-effects", id, overrideAccess: true }).catch(() => {}); } for (const id of opEventIds) { await payload.delete({ collection: "operation-events", id, overrideAccess: true }).catch(() => {}); } for (const id of syncEventIds) { await payload.delete({ collection: "arma-sync-events", id, overrideAccess: true }).catch(() => {}); } for (const id of evaluationIds) { await payload.delete({ collection: "evaluations", id, overrideAccess: true }).catch(() => {}); } for (const id of attendanceIds) { await payload.delete({ collection: "mission-attendances", id, overrideAccess: true }).catch(() => {}); } for (const id of missionIds) { await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {}); } for (const id of campaignIds) { await payload.delete({ collection: "campaigns", id, overrideAccess: true }).catch(() => {}); } for (const id of mapIds) { await payload.delete({ collection: "maps", id, overrideAccess: true }).catch(() => {}); } for (const id of userIds) { await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); } for (const id of roleIds) { await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); } }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("Operation readiness", () => { it("computes a deterministic aggregate from window inputs and stores it with an explanation", async () => { const mission = await makeMission("Alpha", adminId); await makeAttendance(mission, subjectId); await makeEvaluation(mission, subjectId); await makeObjectiveEvent("obj-1", subjectId); const now = new Date(); const result = (await computeReadiness(payload, scopeFor(windowStart, windowEnd))) as { id: number; readinessScore: number; planningConfidenceScore: number; contributingCategories: { category: string; weight: number; score: number; inputCount: number }[]; calculationVersion: string; nature: string; explanation: string; }; readinessIds.push(result.id); const categories = result.contributingCategories.map((c) => c.category); expect(categories).toContain("attendance"); expect(categories).toContain("evaluation"); expect(categories).toContain("objectives"); expect(result.calculationVersion).toBe(READINESS_CALCULATION_VERSION); expect(result.nature).toBe("advisory"); const attendance = result.contributingCategories.find((c) => c.category === "attendance"); expect(attendance?.inputCount).toBe(1); expect(attendance?.score).toBe(34); const evaluation = result.contributingCategories.find((c) => c.category === "evaluation"); expect(evaluation?.score).toBe(70); expect(result.explanation).toContain("Attendance"); expect(result.explanation).not.toContain(raterDisplayName()); expect(result.explanation).not.toContain("evaluator"); }); it("returns the stored row unchanged when inputs are unchanged (no drift)", async () => { const first = (await computeReadiness(payload, scopeFor(windowStart, windowEnd))) as { id: number; readinessScore: number }; const second = (await computeReadiness(payload, scopeFor(windowStart, windowEnd))) as { id: number; readinessScore: number }; expect(second.id).toBe(first.id); expect(second.readinessScore).toBe(first.readinessScore); }); it("replaces the stored aggregate atomically when inputs change", async () => { const scope = scopeFor(windowStart, windowEnd); const before = (await computeReadiness(payload, scope)) as { id: number; readinessScore: number }; const missions = missionIds; await makeAttendance(missions[0], subjectId); const after = (await computeReadiness(payload, scope)) as { id: number; readinessScore: number; contributingCategories: { category: string; inputCount: number }[]; }; expect(after.id).toBe(before.id); const attendance = after.contributingCategories.find((c) => c.category === "attendance"); expect(attendance?.inputCount).toBe(2); expect(after.readinessScore).not.toBe(before.readinessScore); }); it("excludes inputs outside the window and omits absent categories", async () => { const future = scopeFor(new Date(Date.now() + 86_400_000), new Date(Date.now() + 7 * 86_400_000)); const result = (await computeReadiness(payload, future)) as { readinessScore: number; contributingCategories: unknown[]; explanation: string; }; expect(result.contributingCategories).toHaveLength(0); expect(result.readinessScore).toBe(0); expect(result.explanation).toContain("No qualifying operation data"); }); it("gates reads behind the readiness permission without leaking evaluator content", async () => { const scope = scopeFor(windowStart, windowEnd); const forbidden = await getReadiness(payload, { subjectType: "user", subjectId, windowStart: scope.windowStart }, { id: raterId, }); expect(forbidden.status).toBe("forbidden"); const allowed = await getReadiness(payload, { subjectType: "user", subjectId, windowStart: scope.windowStart }, { id: adminId, }); expect(allowed.status).toBe("ok"); const data = allowed.data as { explanation: string; rater?: unknown; evaluator?: unknown; comment?: unknown }; expect(data.explanation).not.toContain(raterDisplayName()); expect(data.rater).toBeUndefined(); expect(data.evaluator).toBeUndefined(); expect(data.comment).toBeUndefined(); }); }); function raterDisplayName(): string { return `${RUN} rater`; }