Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1031 lines
34 KiB
TypeScript
1031 lines
34 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 { CustomMinefields } from "@/collections/minigames/CustomMinefields";
|
||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
||
import {
|
||
applyCreatorBank,
|
||
creatorBankIncrement,
|
||
customPlayerXp,
|
||
customXpBracket,
|
||
edgeMineIndices,
|
||
isEodUnlocked,
|
||
minefieldFlagsError,
|
||
validateGridSize,
|
||
validateMinefieldName,
|
||
validateMinefieldScore,
|
||
validateMinePositions,
|
||
} from "@/lib/custom-minigames/eod";
|
||
import { creditEodXp } from "@/lib/custom-minigames/payouts";
|
||
|
||
let payload: Payload;
|
||
const RUN = `cmf-${Date.now().toString(36)}`;
|
||
const TIMEOUT = 30_000;
|
||
|
||
/** Minimal valid mine positions for a 9x9 grid. */
|
||
const validMines = [
|
||
{ row: 0, col: 0 },
|
||
{ row: 2, col: 2 },
|
||
{ row: 8, col: 8 },
|
||
];
|
||
|
||
describe("Custom minefields", () => {
|
||
let author: User;
|
||
let authorId: number;
|
||
const minefieldIds: 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: "MINEFIELD 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 → minefield)
|
||
for (const id of scoreIds) {
|
||
await payload
|
||
.delete({ collection: "custom-minefield-scores", id, overrideAccess: true })
|
||
.catch(() => {});
|
||
}
|
||
// Delete minefields
|
||
for (const id of minefieldIds) {
|
||
await payload
|
||
.delete({ collection: "custom-minefields", id, overrideAccess: true })
|
||
.catch(() => {});
|
||
}
|
||
// Delete user (and rely on afterChange hook cleanup for profile/bank)
|
||
await payload
|
||
.delete({ collection: "users", id: authorId, overrideAccess: true })
|
||
.catch(() => {});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Collection-level field constraints
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Collection field validation", () => {
|
||
it("creates a valid minefield with all required fields", async () => {
|
||
const doc = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Test Minefield",
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
published: true,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
minefieldIds.push(doc.id);
|
||
expect(doc.name).toBe("Test Minefield");
|
||
expect(doc.rows).toBe(9);
|
||
expect(doc.cols).toBe(9);
|
||
expect(doc.mineCount).toBe(3);
|
||
expect(doc.published).toBe(true);
|
||
expect(doc.playCount).toBe(0);
|
||
});
|
||
|
||
it("rejects rows below minimum (8 < 9)", async () => {
|
||
await expect(
|
||
payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Tiny",
|
||
author: authorId,
|
||
rows: 8,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("rejects cols above maximum (501 > 500)", async () => {
|
||
await expect(
|
||
payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Wide",
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 501,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("rejects mineCount of 0", async () => {
|
||
await expect(
|
||
payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "No Mines",
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: [],
|
||
mineCount: 0,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("defaults published to true and playCount to 0", async () => {
|
||
const doc = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Defaults Test",
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
minefieldIds.push(doc.id);
|
||
expect(doc.published).toBe(true);
|
||
expect(doc.playCount).toBe(0);
|
||
});
|
||
|
||
it("rejects a minefield without a name", async () => {
|
||
await expect(
|
||
payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
} as any,
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("rejects a minefield without an author", async () => {
|
||
await expect(
|
||
payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Orphan",
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
} as any,
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Score collection
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Custom minefield scores", () => {
|
||
let minefieldId: number;
|
||
|
||
beforeAll(async () => {
|
||
const doc = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Score Test Field",
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
minefieldId = doc.id;
|
||
minefieldIds.push(minefieldId);
|
||
});
|
||
|
||
it("creates a score record with valid fields", async () => {
|
||
const score = await payload.create({
|
||
collection: "custom-minefield-scores",
|
||
data: {
|
||
minefield: minefieldId,
|
||
user: authorId,
|
||
seconds: 45,
|
||
flags: 3,
|
||
won: true,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
scoreIds.push(score.id);
|
||
expect(score.minefield).toBe(minefieldId);
|
||
expect(score.user).toBe(authorId);
|
||
expect(score.seconds).toBe(45);
|
||
expect(score.flags).toBe(3);
|
||
expect(score.won).toBe(true);
|
||
});
|
||
|
||
it("rejects a score without a minefield reference", async () => {
|
||
await expect(
|
||
payload.create({
|
||
collection: "custom-minefield-scores",
|
||
data: {
|
||
user: authorId,
|
||
seconds: 10,
|
||
flags: 0,
|
||
won: false,
|
||
} as any,
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("rejects a score without a user reference", async () => {
|
||
await expect(
|
||
payload.create({
|
||
collection: "custom-minefield-scores",
|
||
data: {
|
||
minefield: minefieldId,
|
||
seconds: 10,
|
||
flags: 0,
|
||
won: false,
|
||
} as any,
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it("creates a losing score with flags=0", async () => {
|
||
const score = await payload.create({
|
||
collection: "custom-minefield-scores",
|
||
data: {
|
||
minefield: minefieldId,
|
||
user: authorId,
|
||
seconds: 120,
|
||
flags: 0,
|
||
won: false,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
scoreIds.push(score.id);
|
||
expect(score.won).toBe(false);
|
||
expect(score.flags).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Access control
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Access control", () => {
|
||
let viewer: User;
|
||
let viewerId: number;
|
||
let viewerMinefieldId: 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;
|
||
|
||
const doc = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Viewer Minefield",
|
||
author: viewerId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
viewerMinefieldId = doc.id;
|
||
minefieldIds.push(viewerMinefieldId);
|
||
});
|
||
|
||
it("logged-in user can read published minefields", async () => {
|
||
const result = await payload.find({
|
||
collection: "custom-minefields",
|
||
where: { published: { equals: true } },
|
||
limit: 1,
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
expect(result.docs.length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it("logged-in user can create a minefield", async () => {
|
||
const doc = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Can Create",
|
||
author: viewerId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
minefieldIds.push(doc.id);
|
||
expect(doc.name).toBe("Can Create");
|
||
});
|
||
|
||
it("author can update their own minefield name via overrideAccess", async () => {
|
||
const updated = await payload.update({
|
||
collection: "custom-minefields",
|
||
id: viewerMinefieldId,
|
||
data: { name: "Updated Name" },
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
expect(updated.name).toBe("Updated Name");
|
||
});
|
||
|
||
it("published minefields are visible to all logged-in users", async () => {
|
||
const result = await payload.find({
|
||
collection: "custom-minefields",
|
||
where: { published: { equals: true } },
|
||
limit: 20,
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
expect(result.docs.some((doc) => doc.name === "Updated Name")).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Delete flow
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Delete flow", () => {
|
||
it("deleting a minefield's scores then the minefield cleans up cleanly", async () => {
|
||
const mf = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Delete Test",
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
|
||
const score = await payload.create({
|
||
collection: "custom-minefield-scores",
|
||
data: {
|
||
minefield: mf.id,
|
||
user: authorId,
|
||
seconds: 25,
|
||
flags: 1,
|
||
won: true,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
scoreIds.push(score.id);
|
||
|
||
await payload.delete({
|
||
collection: "custom-minefield-scores",
|
||
id: score.id,
|
||
overrideAccess: true,
|
||
});
|
||
|
||
await payload.delete({
|
||
collection: "custom-minefields",
|
||
id: mf.id,
|
||
overrideAccess: true,
|
||
});
|
||
|
||
const remaining = await payload.findByID({
|
||
collection: "custom-minefield-scores",
|
||
id: score.id,
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
}).catch(() => null);
|
||
expect(remaining).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Unlock gating (pure lib: mirrors checkEodUnlock)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Unlock gating", () => {
|
||
it("locks custom minefields until a win is recorded on all three difficulties", () => {
|
||
expect(
|
||
isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
|
||
).toBe(false);
|
||
expect(
|
||
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
|
||
).toBe(false);
|
||
expect(
|
||
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("unlocks once every difficulty has a best time above zero", () => {
|
||
expect(
|
||
isEodUnlocked({ bestTrainingTime: 1, bestPerimeterTime: 1, bestSupplyRoadTime: 1 }),
|
||
).toBe(true);
|
||
expect(
|
||
isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 120 }),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Action-level validation (pure lib: mirrors createMinefield / updateMinefield
|
||
// / submitCustomMinefieldScore)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Action-level validation", () => {
|
||
it("rejects empty, oversized, and non-string names; trims valid ones", () => {
|
||
expect(validateMinefieldName("").error).toBe("Name must be 1-100 characters.");
|
||
expect(validateMinefieldName(" ").error).toBe("Name must be 1-100 characters.");
|
||
expect(validateMinefieldName("x".repeat(101)).error).toBe("Name must be 1-100 characters.");
|
||
expect(validateMinefieldName(123).error).toBe("Name must be 1-100 characters.");
|
||
expect(validateMinefieldName(" Clear Path ")).toEqual({ name: "Clear Path" });
|
||
});
|
||
|
||
it("rejects grid sizes outside 9-500 on either axis and non-integers", () => {
|
||
expect(validateGridSize(8, 9).error).toBe("Grid must be 9×9 to 500×500.");
|
||
expect(validateGridSize(9, 501).error).toBe("Grid must be 9×9 to 500×500.");
|
||
expect(validateGridSize(9.5, 9).error).toBe("Grid must be 9×9 to 500×500.");
|
||
expect(validateGridSize("9", 9).error).toBe("Grid must be 9×9 to 500×500.");
|
||
expect(validateGridSize(9, 9)).toEqual({ rows: 9, cols: 9 });
|
||
expect(validateGridSize(500, 500)).toEqual({ rows: 500, cols: 500 });
|
||
});
|
||
|
||
it("rejects non-array, empty, oversized, out-of-range, and duplicate mine lists", () => {
|
||
expect(validateMinePositions("boom", 9, 9).error).toBe("Mines must be an array of positions.");
|
||
expect(validateMinePositions([], 9, 9).error).toBe("Mine count must be between 1 and 10000.");
|
||
const tooMany = Array.from({ length: 10001 }, (_, i) => ({ row: 0, col: i % 9 }));
|
||
expect(validateMinePositions(tooMany, 9, 9).error).toBe(
|
||
"Mine count must be between 1 and 10000.",
|
||
);
|
||
const overflow = Array.from({ length: 82 }, (_, i) => ({
|
||
row: Math.floor(i / 9),
|
||
col: i % 9,
|
||
}));
|
||
expect(validateMinePositions(overflow, 9, 9).error).toBe("Mine count exceeds the 9×9 grid.");
|
||
expect(validateMinePositions([{ row: 9, col: 0 }], 9, 9).error).toBe(
|
||
"Every mine needs a row (0-8) and column (0-8).",
|
||
);
|
||
expect(validateMinePositions([{ row: 0, col: -1 }], 9, 9).error).toBe(
|
||
"Every mine needs a row (0-8) and column (0-8).",
|
||
);
|
||
expect(validateMinePositions([{ row: 0.5, col: 0 }], 9, 9).error).toBe(
|
||
"Every mine needs a row (0-8) and column (0-8).",
|
||
);
|
||
expect(
|
||
validateMinePositions(
|
||
[
|
||
{ row: 0, col: 0 },
|
||
{ row: 0, col: 0 },
|
||
],
|
||
9,
|
||
9,
|
||
).error,
|
||
).toBe("Each cell can only hold one mine.");
|
||
});
|
||
|
||
it("normalizes valid mine positions", () => {
|
||
const result = validateMinePositions(validMines, 9, 9);
|
||
expect(result.error).toBeUndefined();
|
||
expect(result.positions).toEqual(validMines);
|
||
});
|
||
|
||
it("rejects impossible score results", () => {
|
||
expect(validateMinefieldScore({ seconds: 0, flags: 0 }).error).toBe("Invalid result.");
|
||
expect(validateMinefieldScore({ seconds: -5, flags: 0 }).error).toBe("Invalid result.");
|
||
expect(validateMinefieldScore({ seconds: 2.5, flags: 0 }).error).toBe("Invalid result.");
|
||
expect(validateMinefieldScore({ seconds: 3601, flags: 0 }).error).toBe("Invalid result.");
|
||
expect(validateMinefieldScore({ seconds: 30, flags: -1 }).error).toBe("Invalid result.");
|
||
expect(validateMinefieldScore({ seconds: Number.NaN, flags: 0 }).error).toBe(
|
||
"Invalid result.",
|
||
);
|
||
expect(validateMinefieldScore({ seconds: 30, flags: 2 })).toEqual({ seconds: 30, flags: 2 });
|
||
expect(validateMinefieldScore({ seconds: 3600, flags: 0 })).toEqual({
|
||
seconds: 3600,
|
||
flags: 0,
|
||
});
|
||
});
|
||
|
||
it("rejects flag counts above the minefield's declared mine count", () => {
|
||
expect(minefieldFlagsError(4, 3)).toBe("Invalid result.");
|
||
expect(minefieldFlagsError(3, 3)).toBeUndefined();
|
||
expect(minefieldFlagsError(0, 3)).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Leaderboard ordering (DB: replicates CustomMinefieldLeaderboard's query:
|
||
// where minefield + won=true, sort seconds,flags, best won time per player)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Leaderboard ordering", () => {
|
||
let fieldA: number;
|
||
let fieldB: 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 (
|
||
minefieldId: number,
|
||
userId: number,
|
||
seconds: number,
|
||
flags: number,
|
||
won: boolean,
|
||
) => {
|
||
const score = await payload.create({
|
||
collection: "custom-minefield-scores",
|
||
data: { minefield: minefieldId, user: userId, seconds, flags, won },
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
scoreIds.push(score.id);
|
||
return score.id;
|
||
};
|
||
|
||
beforeAll(async () => {
|
||
const a = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "LBoard Alpha",
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
const b = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "LBoard Bravo",
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
fieldA = a.id;
|
||
fieldB = b.id;
|
||
minefieldIds.push(fieldA, fieldB);
|
||
}, 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-minefield-scores",
|
||
where: { user: { equals: uid } },
|
||
overrideAccess: true,
|
||
})
|
||
.catch(() => {});
|
||
await payload
|
||
.delete({ collection: "users", id: uid, overrideAccess: true })
|
||
.catch(() => {});
|
||
}
|
||
});
|
||
|
||
it("keeps each player's best won time and isolates leaderboards per minefield", async () => {
|
||
const u1 = await makePlayer("one");
|
||
const u2 = await makePlayer("two");
|
||
|
||
// Field A: u1 40s then improved 35s, u2 30s, plus a faster-but-lost run.
|
||
await record(fieldA, u1, 40, 2, true);
|
||
await record(fieldA, u1, 35, 2, true);
|
||
await record(fieldA, u2, 30, 1, true);
|
||
await record(fieldA, u1, 5, 0, false);
|
||
// Field B: only u1 plays (50s).
|
||
await record(fieldB, u1, 50, 1, true);
|
||
|
||
const leaderboardFor = async (minefieldId: number) => {
|
||
const res = await payload.find({
|
||
collection: "custom-minefield-scores",
|
||
where: { minefield: { equals: minefieldId }, won: { equals: true } },
|
||
sort: "seconds,flags",
|
||
limit: 100,
|
||
depth: 1,
|
||
overrideAccess: true,
|
||
});
|
||
const best = new Map<number, { seconds: number; flags: 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 || doc.seconds < prev.seconds) {
|
||
best.set(uid, { seconds: doc.seconds, flags: doc.flags ?? 0 });
|
||
}
|
||
}
|
||
return Array.from(best.entries())
|
||
.map(([uid, v]) => ({ uid, ...v }))
|
||
.sort((x, y) => x.seconds - y.seconds || x.flags - y.flags);
|
||
};
|
||
|
||
const ordered = await leaderboardFor(fieldA);
|
||
expect(ordered).toHaveLength(2);
|
||
// u2's 30s beats u1's best won 35s; the 5s loss never counts.
|
||
expect(ordered[0].uid).toBe(u2);
|
||
expect(ordered[0].seconds).toBe(30);
|
||
expect(ordered[1].uid).toBe(u1);
|
||
expect(ordered[1].seconds).toBe(35);
|
||
expect(ordered[1].flags).toBe(2);
|
||
|
||
const orderedB = await leaderboardFor(fieldB);
|
||
expect(orderedB).toHaveLength(1);
|
||
expect(orderedB[0].uid).toBe(u1);
|
||
expect(orderedB[0].seconds).toBe(50);
|
||
}, 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 gateFieldId: 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}-mf-manager`,
|
||
slug: `${RUN}-mf-manager`,
|
||
permissions: ["custom-minefields:update"],
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
})) as unknown as Role;
|
||
const devRole = (await payload.create({
|
||
collection: "roles",
|
||
data: { name: `${RUN}-mf-dev`, slug: `${RUN}-mf-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 gateField = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Gate Field",
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
gateFieldId = gateField.id;
|
||
minefieldIds.push(gateFieldId);
|
||
}, 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 updateMinefield: isManager || isAuthor, evaluated against the
|
||
// gate field authored by `author`.
|
||
const gate = async (user: User) => {
|
||
const isManager = await hasPermission(payload, user, "custom-minefields: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 = CustomMinefields.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-minefields:delete", async () => {
|
||
const fn = CustomMinefields.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 = CustomMinefields.access?.create;
|
||
const readFn = CustomMinefields.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);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Custom XP payouts, loss reveal rules, and exposure toggle
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("Custom XP payouts and loss reveal rules", () => {
|
||
describe("payout math", () => {
|
||
it("maps mine counts to standard difficulty brackets", () => {
|
||
expect(customXpBracket(10)).toEqual({ baseScore: 100, parSeconds: 60 });
|
||
expect(customXpBracket(15)).toEqual({ baseScore: 100, parSeconds: 60 });
|
||
expect(customXpBracket(16)).toEqual({ baseScore: 300, parSeconds: 180 });
|
||
expect(customXpBracket(50)).toEqual({ baseScore: 300, parSeconds: 180 });
|
||
expect(customXpBracket(51)).toEqual({ baseScore: 600, parSeconds: 420 });
|
||
});
|
||
|
||
it("pays nothing on losses", () => {
|
||
expect(customPlayerXp({ mineCount: 10, seconds: 30, won: false })).toBe(0);
|
||
});
|
||
|
||
it("pays 1 XP on training-bracket wins regardless of pace", () => {
|
||
expect(customPlayerXp({ mineCount: 10, seconds: 60, won: true })).toBe(1);
|
||
expect(customPlayerXp({ mineCount: 10, seconds: 1, won: true })).toBe(1);
|
||
});
|
||
|
||
it("pays 2 XP only on fast supply-road-bracket clears", () => {
|
||
expect(customPlayerXp({ mineCount: 100, seconds: 1, won: true })).toBe(2);
|
||
expect(customPlayerXp({ mineCount: 100, seconds: 420, won: true })).toBe(1);
|
||
expect(customPlayerXp({ mineCount: 60, seconds: 120, won: true })).toBe(1);
|
||
});
|
||
|
||
it("banks the creator share fractionally and credits whole points at 1", () => {
|
||
const first = applyCreatorBank(0, 1);
|
||
expect(first.bank).toBeCloseTo(0.05);
|
||
expect(first.wholeXpToCredit).toBe(0);
|
||
|
||
const second = applyCreatorBank(0.96, 1);
|
||
expect(second.bank).toBeCloseTo(0.01);
|
||
expect(second.wholeXpToCredit).toBe(1);
|
||
|
||
const third = applyCreatorBank(1.98, 2);
|
||
expect(third.bank).toBeCloseTo(0.08);
|
||
expect(third.wholeXpToCredit).toBe(2);
|
||
|
||
expect(creatorBankIncrement(2)).toBeCloseTo(0.1);
|
||
});
|
||
});
|
||
|
||
describe("edge-mine loss reveal", () => {
|
||
it("returns nothing when nothing is exposed", () => {
|
||
expect(
|
||
edgeMineIndices({ rows: 9, cols: 9, mines: [0, 40, 80], revealed: [] }),
|
||
).toEqual([]);
|
||
});
|
||
|
||
it("picks mines adjacent to the exposed area, diagonals included", () => {
|
||
// 9x9: revealed cell index 1 (row 0 col 1); mine at 0 (row 0 col 0) touches it.
|
||
expect(edgeMineIndices({ rows: 9, cols: 9, mines: [0], revealed: [1] })).toEqual([0]);
|
||
// Mine at 40 (row 4 col 4) touches revealed 48 (row 5 col 3) diagonally.
|
||
expect(edgeMineIndices({ rows: 9, cols: 9, mines: [40], revealed: [48] })).toEqual([40]);
|
||
// Mine at 80 (row 8 col 8) is far from the exposed area and stays hidden.
|
||
expect(edgeMineIndices({ rows: 9, cols: 9, mines: [40, 80], revealed: [48] })).toEqual([
|
||
40,
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("exposure rule", () => {
|
||
it("defaults exposeEdgeMines to true on new minefields", async () => {
|
||
const field = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: { name: "Exposure Default", author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3 },
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
minefieldIds.push(field.id);
|
||
expect(field.exposeEdgeMines).toBe(true);
|
||
}, TIMEOUT);
|
||
|
||
it("persists an author-chosen exposure rule of false", async () => {
|
||
const field = await payload.create({
|
||
collection: "custom-minefields",
|
||
data: {
|
||
name: "Exposure Off",
|
||
author: authorId,
|
||
rows: 9,
|
||
cols: 9,
|
||
mines: validMines,
|
||
mineCount: 3,
|
||
exposeEdgeMines: false,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
minefieldIds.push(field.id);
|
||
expect(field.exposeEdgeMines).toBe(false);
|
||
}, TIMEOUT);
|
||
});
|
||
|
||
describe("profile credit", () => {
|
||
let xpPlayer: User;
|
||
|
||
beforeAll(async () => {
|
||
xpPlayer = (await payload.create({
|
||
collection: "users",
|
||
data: {
|
||
username: `${RUN}-xp-player`,
|
||
discordUsername: `${RUN}-xp-player`,
|
||
displayName: "XP PLAYER",
|
||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||
password: "Test123",
|
||
roles: ["user"],
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
})) as unknown as User;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await payload
|
||
.delete({ collection: "profiles", where: { user: { equals: xpPlayer.id } }, overrideAccess: true })
|
||
.catch(() => {});
|
||
await payload.delete({ collection: "users", id: xpPlayer.id, overrideAccess: true }).catch(() => {});
|
||
});
|
||
|
||
it("credits EOD minigame XP cumulatively on the profile", async () => {
|
||
const first = await creditEodXp(payload, xpPlayer.id, 1);
|
||
expect(first).not.toBeNull();
|
||
expect(first?.xpEarned).toBe(1);
|
||
|
||
const second = await creditEodXp(payload, xpPlayer.id, 1);
|
||
expect(second?.xpEarned).toBe(2);
|
||
expect(second?.profileId).toBe(first?.profileId);
|
||
|
||
const profileRes = await payload.find({
|
||
collection: "profiles",
|
||
where: { user: { equals: xpPlayer.id } },
|
||
limit: 1,
|
||
depth: 0,
|
||
overrideAccess: true,
|
||
});
|
||
const profile = profileRes.docs[0];
|
||
expect(profile).toBeDefined();
|
||
expect(profile?.progression?.minigames?.eodMinesweeper?.xpEarned).toBe(2);
|
||
}, TIMEOUT);
|
||
|
||
it("returns null for users without a profile", async () => {
|
||
// User id 999999999 has no profile doc in this database.
|
||
expect(await creditEodXp(payload, 999999999, 1)).toBeNull();
|
||
}, TIMEOUT);
|
||
});
|
||
});
|
||
});
|