329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
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 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<number> {
|
|
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<number> {
|
|
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 makeEvaluation(missionId: number, ratee: number): Promise<number> {
|
|
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<void> {
|
|
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 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 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("evaluation");
|
|
expect(categories).toContain("objectives");
|
|
expect(result.calculationVersion).toBe(READINESS_CALCULATION_VERSION);
|
|
expect(result.nature).toBe("advisory");
|
|
|
|
const evaluation = result.contributingCategories.find((c) => c.category === "evaluation");
|
|
expect(evaluation?.score).toBe(70);
|
|
|
|
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 };
|
|
|
|
await makeObjectiveEvent("obj-2", subjectId);
|
|
|
|
const after = (await computeReadiness(payload, scope)) as {
|
|
id: number;
|
|
readinessScore: number;
|
|
contributingCategories: { category: string; inputCount: number }[];
|
|
};
|
|
expect(after.id).toBe(before.id);
|
|
const objectives = after.contributingCategories.find((c) => c.category === "objectives");
|
|
expect(objectives?.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`;
|
|
}
|