import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Role, User } from "@/payload-types"; import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests"; import { hasPermission } from "@/utils/access-control/hasPermission"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; import { isRadioUnlocked, isValidRadioOrder, validateMessages, validateRadioScore, validateTestName, } from "@/lib/minigames/custom/radio"; let payload: Payload; const RUN = `crt-${Date.now().toString(36)}`; const TIMEOUT = 30_000; const sampleMessages = [ "VIKTOR ONE, EN ROUTE TO GRID 341-512, STATUS GREEN, OVER", "ECHO TWO, CONTACT AT CHECKPOINT ALPHA, WEAPONS FREE, OUT", "FOXTROT THREE, REQUESTING FIRE SUPPORT ON BEARING 270, OVER", ]; describe("Custom radio tests", () => { let author: User; let authorId: number; const testIds: number[] = []; const scoreIds: number[] = []; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); author = (await payload.create({ collection: "users", data: { username: `${RUN}-author`, discordUsername: `${RUN}-author`, displayName: "RADIO AUTHOR", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; authorId = author.id; }); afterAll(async () => { // Delete scores first (FK → test) for (const id of scoreIds) { await payload .delete({ collection: "custom-radio-test-scores", id, overrideAccess: true }) .catch(() => {}); } // Delete tests for (const id of testIds) { await payload .delete({ collection: "custom-radio-tests", id, overrideAccess: true }) .catch(() => {}); } await payload .delete({ collection: "users", id: authorId, overrideAccess: true }) .catch(() => {}); }); // --------------------------------------------------------------------------- // Collection-level field constraints // --------------------------------------------------------------------------- describe("Collection field validation", () => { it("creates a valid test with all required fields", async () => { const doc = await payload.create({ collection: "custom-radio-tests", data: { name: "Test Radio Drill", author: authorId, messages: sampleMessages, messageCount: 3, order: "fixed", published: true, }, overrideAccess: true, depth: 0, }); testIds.push(doc.id); expect(doc.name).toBe("Test Radio Drill"); expect(doc.messageCount).toBe(3); expect(doc.order).toBe("fixed"); expect(doc.published).toBe(true); expect(doc.playCount).toBe(0); }); it("defaults order to fixed and published to true", async () => { const doc = await payload.create({ collection: "custom-radio-tests", data: { name: "Defaults Test", author: authorId, messages: sampleMessages, messageCount: 3, }, overrideAccess: true, depth: 0, }); testIds.push(doc.id); expect(doc.order).toBe("fixed"); expect(doc.published).toBe(true); expect(doc.playCount).toBe(0); }); it("accepts shuffled order", async () => { const doc = await payload.create({ collection: "custom-radio-tests", data: { name: "Shuffled Test", author: authorId, messages: sampleMessages, messageCount: 3, order: "shuffled", }, overrideAccess: true, depth: 0, }); testIds.push(doc.id); expect(doc.order).toBe("shuffled"); }); it("rejects a test without a name", async () => { await expect( payload.create({ collection: "custom-radio-tests", data: { author: authorId, messages: sampleMessages, messageCount: 3, } as any, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("rejects a test without messages", async () => { await expect( payload.create({ collection: "custom-radio-tests", data: { name: "Empty", author: authorId, messageCount: 0, } as any, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("rejects a test without an author", async () => { await expect( payload.create({ collection: "custom-radio-tests", data: { name: "Orphan", messages: sampleMessages, messageCount: 3, } as any, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("rejects messageCount below minimum (0 < 1)", async () => { await expect( payload.create({ collection: "custom-radio-tests", data: { name: "Zero Messages", author: authorId, messages: [], messageCount: 0, }, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); }); // --------------------------------------------------------------------------- // Score collection // --------------------------------------------------------------------------- describe("Custom radio test scores", () => { let testId: number; beforeAll(async () => { const doc = await payload.create({ collection: "custom-radio-tests", data: { name: "Score Test Radio", author: authorId, messages: sampleMessages, messageCount: 3, }, overrideAccess: true, depth: 0, }); testId = doc.id; testIds.push(testId); }); it("creates a sprint score with valid fields", async () => { const score = await payload.create({ collection: "custom-radio-test-scores", data: { test: testId, user: authorId, score: 850, accuracy: 0.95, correctMessages: 9, incorrectMessages: 1, mode: "sprint", timeMs: 45000, }, overrideAccess: true, depth: 0, }); scoreIds.push(score.id); expect(score.test).toBe(testId); expect(score.user).toBe(authorId); expect(score.score).toBe(850); expect(score.accuracy).toBe(0.95); expect(score.mode).toBe("sprint"); expect(score.timeMs).toBe(45000); }); it("creates a watch score without timeMs", async () => { const score = await payload.create({ collection: "custom-radio-test-scores", data: { test: testId, user: authorId, score: 320, accuracy: 0.88, correctMessages: 8, incorrectMessages: 2, mode: "watch", }, overrideAccess: true, depth: 0, }); scoreIds.push(score.id); expect(score.mode).toBe("watch"); expect(score.timeMs === null || score.timeMs === undefined).toBe(true); }); it("rejects a score without a test reference", async () => { await expect( payload.create({ collection: "custom-radio-test-scores", data: { user: authorId, score: 100, accuracy: 0.5, correctMessages: 5, incorrectMessages: 5, mode: "sprint", timeMs: 30000, } as any, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("rejects a score without a user reference", async () => { await expect( payload.create({ collection: "custom-radio-test-scores", data: { test: testId, score: 100, accuracy: 0.5, correctMessages: 5, incorrectMessages: 5, mode: "sprint", timeMs: 30000, } as any, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("rejects a score without a mode", async () => { await expect( payload.create({ collection: "custom-radio-test-scores", data: { test: testId, user: authorId, score: 100, accuracy: 0.5, correctMessages: 5, incorrectMessages: 5, } as any, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("rejects score updates (update: () => false)", async () => { const score = await payload.create({ collection: "custom-radio-test-scores", data: { test: testId, user: authorId, score: 100, accuracy: 0.5, correctMessages: 5, incorrectMessages: 5, mode: "watch", }, overrideAccess: true, depth: 0, }); scoreIds.push(score.id); await expect( payload.update({ collection: "custom-radio-test-scores", id: score.id, data: { score: 999 }, overrideAccess: false, depth: 0, }), ).rejects.toThrow(); }); }); // --------------------------------------------------------------------------- // Access control // --------------------------------------------------------------------------- describe("Access control", () => { let viewer: User; let viewerId: number; beforeAll(async () => { viewer = (await payload.create({ collection: "users", data: { username: `${RUN}-viewer`, discordUsername: `${RUN}-viewer`, displayName: "VIEWER", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; viewerId = viewer.id; }); it("logged-in user can read published tests", async () => { const result = await payload.find({ collection: "custom-radio-tests", where: { published: { equals: true } }, limit: 1, overrideAccess: true, depth: 0, }); expect(result.docs.length).toBeGreaterThanOrEqual(1); }); it("logged-in user can create a test", async () => { const doc = await payload.create({ collection: "custom-radio-tests", data: { name: "Can Create", author: viewerId, messages: sampleMessages, messageCount: 3, }, overrideAccess: true, depth: 0, }); testIds.push(doc.id); expect(doc.name).toBe("Can Create"); }); it("published tests are visible to all logged-in users", async () => { const result = await payload.find({ collection: "custom-radio-tests", where: { published: { equals: true } }, limit: 20, overrideAccess: true, depth: 0, }); expect(result.docs.some((doc) => doc.name === "Can Create")).toBe(true); }); }); // --------------------------------------------------------------------------- // Cascade delete // --------------------------------------------------------------------------- describe("Cascade delete", () => { it("deleting a test removes associated scores", async () => { const test = await payload.create({ collection: "custom-radio-tests", data: { name: "Cascade Test Radio", author: authorId, messages: sampleMessages, messageCount: 3, }, overrideAccess: true, depth: 0, }); const score = await payload.create({ collection: "custom-radio-test-scores", data: { test: test.id, user: authorId, score: 200, accuracy: 0.7, correctMessages: 4, incorrectMessages: 6, mode: "sprint", timeMs: 60000, }, overrideAccess: true, depth: 0, }); scoreIds.push(score.id); await payload.delete({ collection: "custom-radio-test-scores", id: score.id, overrideAccess: true, }); await payload.delete({ collection: "custom-radio-tests", id: test.id, overrideAccess: true, }); const remaining = await payload.findByID({ collection: "custom-radio-test-scores", id: score.id, overrideAccess: true, depth: 0, }).catch(() => null); expect(remaining).toBeNull(); }); }); // --------------------------------------------------------------------------- // Unlock gating (pure lib: mirrors checkRadioUnlock) // --------------------------------------------------------------------------- describe("Unlock gating", () => { it("locks custom radio tests until both accuracy requirements are met", () => { expect(isRadioUnlocked({ sprintBestAccuracy: 0, watchBestAccuracy: 0 })).toBe(false); expect(isRadioUnlocked({ sprintBestAccuracy: 0.9, watchBestAccuracy: 0.5 })).toBe(false); expect(isRadioUnlocked({ sprintBestAccuracy: 0.5, watchBestAccuracy: 0.85 })).toBe(false); }); it("unlocks at exactly the sprint (0.9) and watch (0.85) thresholds", () => { expect(isRadioUnlocked({ sprintBestAccuracy: 0.9, watchBestAccuracy: 0.85 })).toBe(true); expect(isRadioUnlocked({ sprintBestAccuracy: 1, watchBestAccuracy: 1 })).toBe(true); }); }); // --------------------------------------------------------------------------- // Action-level validation (pure lib: mirrors createTest / updateTest / // submitCustomTestScore) // --------------------------------------------------------------------------- describe("Action-level validation", () => { it("rejects empty, oversized, and non-string names; trims valid ones", () => { expect(validateTestName("").error).toBe("Name must be 1-100 characters."); expect(validateTestName(" ").error).toBe("Name must be 1-100 characters."); expect(validateTestName("x".repeat(101)).error).toBe("Name must be 1-100 characters."); expect(validateTestName(123).error).toBe("Name must be 1-100 characters."); expect(validateTestName(" Sweep Drill ")).toEqual({ name: "Sweep Drill" }); }); it("rejects non-array, wrong-size, non-string, and oversized message pools; trims valid ones", () => { expect(validateMessages("boom").error).toBe("Messages must be a list of strings."); expect(validateMessages([]).error).toBe("Message pool must contain 1-100 messages."); expect( validateMessages(Array.from({ length: 101 }, (_, i) => `m${i}`)).error, ).toBe("Message pool must contain 1-100 messages."); expect(validateMessages([42]).error).toBe("Every message must be a string."); expect(validateMessages([" "]).error).toBe("Messages must be 1-500 characters."); expect(validateMessages(["x".repeat(501)]).error).toBe("Messages must be 1-500 characters."); expect(validateMessages([" Alpha ", "Bravo"])).toEqual({ messages: ["Alpha", "Bravo"], }); }); it("accepts only fixed and shuffled orders", () => { expect(isValidRadioOrder("fixed")).toBe(true); expect(isValidRadioOrder("shuffled")).toBe(true); expect(isValidRadioOrder("random")).toBe(false); expect(isValidRadioOrder("")).toBe(false); }); it("validates score submissions per mode", () => { const sprintResult = { score: 120, correctMessages: 4, incorrectMessages: 1, accuracy: 0.8, mode: "sprint", timeMs: 45_000, }; const watchResult = { score: 80, correctMessages: 8, incorrectMessages: 2, accuracy: 0.8, mode: "watch", }; expect(validateRadioScore(sprintResult)).toEqual({ score: 120, mode: "sprint", timeMs: 45_000, }); expect(validateRadioScore(watchResult)).toEqual({ score: 80, mode: "watch", timeMs: null }); expect(validateRadioScore({ ...watchResult, timeMs: 5_000 })).toEqual({ score: 80, mode: "watch", timeMs: null, }); expect(validateRadioScore({ ...sprintResult, score: 100.9 })).toEqual({ score: 100, mode: "sprint", timeMs: 45_000, }); expect(validateRadioScore({ ...sprintResult, mode: "nope" }).error).toBe("Invalid result."); expect(validateRadioScore({ ...sprintResult, score: -1 }).error).toBe("Invalid score."); expect(validateRadioScore({ ...sprintResult, score: Number.NaN }).error).toBe( "Invalid score.", ); expect(validateRadioScore({ ...sprintResult, score: 5001 }).error).toBe("Invalid score."); expect(validateRadioScore({ ...sprintResult, correctMessages: -1 }).error).toBe( "Invalid result.", ); expect(validateRadioScore({ ...sprintResult, correctMessages: 101 }).error).toBe( "Invalid result.", ); expect(validateRadioScore({ ...sprintResult, incorrectMessages: -1 }).error).toBe( "Invalid result.", ); expect(validateRadioScore({ ...sprintResult, accuracy: 1.5 }).error).toBe("Invalid result."); expect(validateRadioScore({ ...sprintResult, accuracy: -0.1 }).error).toBe( "Invalid result.", ); expect(validateRadioScore({ ...sprintResult, timeMs: undefined }).error).toBe( "Invalid result.", ); expect(validateRadioScore({ ...sprintResult, timeMs: 0 }).error).toBe("Invalid result."); expect(validateRadioScore({ ...sprintResult, timeMs: 600_001 }).error).toBe( "Invalid result.", ); }); }); // --------------------------------------------------------------------------- // Leaderboard ordering (DB: replicates CustomTestLeaderboard's query: where // test + mode, sort -score, best score per player) // --------------------------------------------------------------------------- describe("Leaderboard ordering", () => { let testA: number; let testB: number; const tempUserIds: number[] = []; const makePlayer = async (label: string): Promise => { const user = (await payload.create({ collection: "users", data: { username: `${RUN}-lb-${label}`, discordUsername: `${RUN}-lb-${label}`, displayName: `LBOARD ${label.toUpperCase()}`, steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; tempUserIds.push(user.id); return user.id; }; const record = async ( testId: number, userId: number, scoreData: { score: number; accuracy: number; correctMessages: number; incorrectMessages: number; mode: "sprint" | "watch"; timeMs?: number; }, ) => { const score = await payload.create({ collection: "custom-radio-test-scores", data: { test: testId, user: userId, ...scoreData }, overrideAccess: true, depth: 0, }); scoreIds.push(score.id); return score.id; }; beforeAll(async () => { const a = await payload.create({ collection: "custom-radio-tests", data: { name: "LBoard Alpha", author: authorId, messages: sampleMessages, messageCount: 3, }, overrideAccess: true, depth: 0, }); const b = await payload.create({ collection: "custom-radio-tests", data: { name: "LBoard Bravo", author: authorId, messages: sampleMessages, messageCount: 3, }, overrideAccess: true, depth: 0, }); testA = a.id; testB = b.id; testIds.push(testA, testB); }, TIMEOUT); afterAll(async () => { if (!payload) return; // Runs before the parent afterAll: remove each temp user's scores first // (FK), then the users. Leftover ids in scoreIds are cleaned up (and // tolerated) by the parent afterAll. for (const uid of tempUserIds) { await payload .delete({ collection: "custom-radio-test-scores", where: { user: { equals: uid } }, overrideAccess: true, }) .catch(() => {}); await payload .delete({ collection: "users", id: uid, overrideAccess: true }) .catch(() => {}); } }); it("keeps each player's best score and isolates leaderboards per test and mode", async () => { const u1 = await makePlayer("one"); const u2 = await makePlayer("two"); await record(testA, u1, { score: 300, accuracy: 0.8, correctMessages: 4, incorrectMessages: 1, mode: "sprint", timeMs: 45_000, }); await record(testA, u1, { score: 450, accuracy: 0.9, correctMessages: 5, incorrectMessages: 0, mode: "sprint", timeMs: 40_000, }); await record(testA, u2, { score: 500, accuracy: 0.95, correctMessages: 6, incorrectMessages: 0, mode: "sprint", timeMs: 38_000, }); await record(testA, u1, { score: 200, accuracy: 0.7, correctMessages: 7, incorrectMessages: 3, mode: "watch", }); await record(testB, u1, { score: 150, accuracy: 0.6, correctMessages: 5, incorrectMessages: 3, mode: "watch", }); const leaderboardFor = async (testId: number, mode: "sprint" | "watch") => { const res = await payload.find({ collection: "custom-radio-test-scores", where: { test: { equals: testId }, mode: { equals: mode } }, sort: "-score", limit: 100, depth: 1, overrideAccess: true, }); const best = new Map(); for (const doc of res.docs) { const ref = doc.user as unknown as { id: number } | number | null; if (ref === null) continue; const uid = typeof ref === "object" ? ref.id : ref; if (typeof uid !== "number") continue; const prev = best.get(uid); if (prev === undefined || doc.score > prev) best.set(uid, doc.score); } return Array.from(best.entries()) .map(([uid, score]) => ({ uid, score })) .sort((x, y) => y.score - x.score); }; const sprintOrder = await leaderboardFor(testA, "sprint"); expect(sprintOrder).toHaveLength(2); expect(sprintOrder[0]).toEqual({ uid: u2, score: 500 }); expect(sprintOrder[1]).toEqual({ uid: u1, score: 450 }); const watchOrder = await leaderboardFor(testA, "watch"); expect(watchOrder).toHaveLength(1); expect(watchOrder[0]).toEqual({ uid: u1, score: 200 }); const testBOrder = await leaderboardFor(testB, "watch"); expect(testBOrder).toHaveLength(1); expect(testBOrder[0]).toEqual({ uid: u1, score: 150 }); }, TIMEOUT); }); // --------------------------------------------------------------------------- // Edit and delete permissions (action gate + collection access) // --------------------------------------------------------------------------- describe("Edit and delete permissions", () => { const roleIds: number[] = []; const permUserIds: number[] = []; let otherPlayer: User; let manager: User; let devUser: User; let gateTestId: number; const makeUserWithRole = async (label: string, roleId: number): Promise => { const user = (await payload.create({ collection: "users", data: { username: `${RUN}-perm-${label}`, discordUsername: `${RUN}-perm-${label}`, displayName: `PERM ${label.toUpperCase()}`, steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", // Permission resolution reads roleDocs (dynamic RBAC), not the legacy // `roles` enum: assign a real role doc to grant permissions. roleDocs: [roleId], }, overrideAccess: true, depth: 0, })) as unknown as User; permUserIds.push(user.id); return user; }; const invokeAccess = (fn: unknown, user: User | null): Promise => { return (fn as (args: { req: unknown }) => Promise)({ req: { user, payload }, }); }; beforeAll(async () => { const plainRole = (await payload.create({ collection: "roles", data: { name: `${RUN}-plain`, slug: `${RUN}-plain`, permissions: [] }, overrideAccess: true, depth: 0, })) as unknown as Role; const managerRole = (await payload.create({ collection: "roles", data: { name: `${RUN}-rt-manager`, slug: `${RUN}-rt-manager`, permissions: ["custom-radio-tests:update"], }, overrideAccess: true, depth: 0, })) as unknown as Role; const devRole = (await payload.create({ collection: "roles", data: { name: `${RUN}-rt-dev`, slug: `${RUN}-rt-dev`, isSuperuser: true }, overrideAccess: true, depth: 0, })) as unknown as Role; roleIds.push(plainRole.id, managerRole.id, devRole.id); invalidatePermissionCache(); otherPlayer = await makeUserWithRole("other", plainRole.id); manager = await makeUserWithRole("manager", managerRole.id); devUser = await makeUserWithRole("dev", devRole.id); const gateTest = await payload.create({ collection: "custom-radio-tests", data: { name: "Gate Test Radio", author: authorId, messages: sampleMessages, messageCount: 3, }, overrideAccess: true, depth: 0, }); gateTestId = gateTest.id; testIds.push(gateTestId); }, TIMEOUT); afterAll(async () => { if (!payload) return; // Runs before the parent afterAll: profiles first (FK), then users, then roles. for (const uid of permUserIds) { const profiles = await payload .find({ collection: "profiles", where: { user: { equals: uid } }, limit: 5, depth: 0, overrideAccess: true, }) .catch(() => null); for (const p of profiles?.docs ?? []) { await payload .delete({ collection: "profiles", id: p.id, overrideAccess: true }) .catch(() => {}); } await payload .delete({ collection: "users", id: uid, overrideAccess: true }) .catch(() => {}); } for (const id of roleIds) { await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); } }); it("lets only the author (or a permissioned manager) pass the action's edit gate", async () => { // Mirrors updateTest: isManager || isAuthor, evaluated against the gate // test authored by `author`. const gate = async (user: User) => { const isManager = await hasPermission(payload, user, "custom-radio-tests:update"); return isManager || user.id === authorId; }; expect(await gate(author)).toBe(true); expect(await gate(otherPlayer)).toBe(false); expect(await gate(manager)).toBe(true); expect(await gate(devUser)).toBe(true); }, TIMEOUT); it("scopes collection update access to the author for non-managers", async () => { const fn = CustomRadioTests.access?.update; expect(typeof fn).toBe("function"); expect(await invokeAccess(fn, author)).toMatchObject({ author: { equals: authorId } }); expect(await invokeAccess(fn, otherPlayer)).toMatchObject({ author: { equals: otherPlayer.id }, }); expect(await invokeAccess(fn, manager)).toBe(true); expect(await invokeAccess(fn, null)).toBe(false); }, TIMEOUT); it("gates deletes behind custom-radio-tests:delete", async () => { const fn = CustomRadioTests.access?.delete; expect(typeof fn).toBe("function"); expect(await invokeAccess(fn, null)).toBe(false); expect(await invokeAccess(fn, author)).toBe(false); expect(await invokeAccess(fn, otherPlayer)).toBe(false); expect(await invokeAccess(fn, devUser)).toBe(true); }, TIMEOUT); it("gates publish (create) and read behind login", async () => { const createFn = CustomRadioTests.access?.create; const readFn = CustomRadioTests.access?.read; expect(typeof createFn).toBe("function"); expect(typeof readFn).toBe("function"); expect(await invokeAccess(createFn, null)).toBe(false); expect(await invokeAccess(createFn, otherPlayer)).toBe(true); expect(await invokeAccess(readFn, null)).toBe(false); expect(await invokeAccess(readFn, otherPlayer)).toBe(true); }, TIMEOUT); }); });