Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
915 lines
29 KiB
TypeScript
915 lines
29 KiB
TypeScript
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/custom-minigames/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<number> => {
|
|
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<number, number>();
|
|
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<User> => {
|
|
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<unknown> => {
|
|
return (fn as (args: { req: unknown }) => Promise<unknown>)({
|
|
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);
|
|
});
|
|
});
|