1
0
Fork 0
polaris-task-force/tests/int/wiki.int.spec.ts
2026-09-11 03:25:59 -04:00

509 lines
No EOL
18 KiB
TypeScript

import { getPayload, Payload } from "payload";
import type { AccessArgs } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { User, WikiPage, WikiRevision, WikiTemplate } from "@/payload-types";
import { WikiPages } from "@/collections/wiki/WikiPages";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import {
createPage,
deletePage,
listTemplates,
restoreRevision,
setPageLockdown,
templateMap,
updatePage,
} from "@/lib/wiki/service";
import { slugify } from "@/lib/wiki/slugify";
import { expandTemplates } from "@/lib/wiki/templates";
import { preprocessWikilinks } from "@/lib/wiki/wikilinks";
import { prepareMarkdown } from "@/lib/wiki/prepare";
let payload: Payload;
const RUN = `wiki-${Date.now().toString(36)}`;
/**
* User deletion trips FK constraints unless the hook-provisioned personal bank
* account and profile are removed first.
*/
const deleteUserWithRelations = async (pg: Payload, id: number): Promise<void> => {
const accounts = await pg
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await pg
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
.catch(() => {});
}
const profiles = await pg
.find({
collection: "profiles",
where: { user: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await pg
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await pg.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
};
describe("Wiki", () => {
let user: User;
let userId: number;
const pageIds: number[] = [];
const templateIds: number[] = [];
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-user`,
discordUsername: `${RUN}-user`,
displayName: "WIKI TEST",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userId = user.id;
});
afterAll(async () => {
// Revisions reference the page, so delete them before the page itself.
for (const id of pageIds) {
const revisions = await payload
.find({
collection: "wiki-revisions",
where: { page: { equals: id } },
limit: 500,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const revision of revisions?.docs ?? []) {
await payload
.delete({ collection: "wiki-revisions", id: revision.id, overrideAccess: true })
.catch(() => {});
}
await payload
.delete({ collection: "wiki-pages", id, overrideAccess: true })
.catch(() => {});
}
for (const id of templateIds) {
await payload
.delete({ collection: "wiki-templates", id, overrideAccess: true })
.catch(() => {});
}
await deleteUserWithRelations(payload, userId);
});
const fetchRevisions = async (pageId: number): Promise<WikiRevision[]> => {
const result = (await payload.find({
collection: "wiki-revisions",
where: { page: { equals: pageId } },
sort: "revisionNumber",
limit: 500,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: WikiRevision[] };
return result.docs;
};
const makePage = async (
title: string,
body = "Body",
category: WikiPage["category"] = "Lore",
tags?: string[],
): Promise<WikiPage> => {
const page = await createPage(payload, user, { title, category, body, tags });
pageIds.push(page.id);
return page;
};
describe("slugify", () => {
it("lowercases, trims, and collapses non-alphanumerics into single hyphens", () => {
expect(slugify("Hello World!")).toBe("hello-world");
expect(slugify(" A--B ")).toBe("a-b");
expect(slugify("Café")).toBe("caf");
expect(slugify("123")).toBe("123");
expect(slugify("")).toBe("page");
expect(slugify("!!!")).toBe("page");
});
});
describe("expandTemplates", () => {
it("replaces a basic template placeholder", () => {
expect(expandTemplates("Hello {{T}}!", { T: "world" })).toBe("Hello world!");
});
it("leaves unknown templates literal", () => {
expect(expandTemplates("{{Nope}}", {})).toBe("{{Nope}}");
});
it("expands two templates on one line", () => {
expect(expandTemplates("{{A}} and {{B}}", { A: "x", B: "y" })).toBe("x and y");
});
it("trims whitespace inside placeholders before matching", () => {
expect(expandTemplates("{{ A }}", { A: "x" })).toBe("x");
});
it("expands 2 levels of nesting and leaves the 3rd level literal", () => {
expect(expandTemplates("{{A}}", { A: "{{B}}", B: "hello" })).toBe("hello");
expect(expandTemplates("{{A}}", { A: "{{B}}", B: "{{C}}", C: "deep" })).toBe("{{C}}");
});
it("terminates cycles leaving the cycle literal", () => {
expect(expandTemplates("{{A}}", { A: "{{A}}" })).toBe("{{A}}");
expect(expandTemplates("{{A}}", { A: "{{B}}", B: "{{A}}" })).toBe("{{A}}");
});
});
describe("preprocessWikilinks", () => {
it("converts a plain wikilink to a markdown link with the slugified target", () => {
expect(preprocessWikilinks("See [[Page Title]]")).toBe(
"See [Page Title](/wiki/page-title)",
);
});
it("uses the alias text as the link label", () => {
expect(preprocessWikilinks("[[Page Title|Custom Text]]")).toBe(
"[Custom Text](/wiki/page-title)",
);
});
it("trims whitespace around the target and alias", () => {
expect(preprocessWikilinks("[[ Page Title | Custom ]]")).toBe(
"[Custom](/wiki/page-title)",
);
});
it("handles multiple links on one line", () => {
expect(preprocessWikilinks("[[Alpha]] and [[Beta|B]]")).toBe(
"[Alpha](/wiki/alpha) and [B](/wiki/beta)",
);
});
});
describe("prepareMarkdown", () => {
it("expands templates first, then wikilinks", () => {
const templates = { T: "See [[Page Title]]" };
expect(prepareMarkdown("{{T}}", templates)).toBe("See [Page Title](/wiki/page-title)");
});
});
describe("service", () => {
it("createPage creates the page and exactly one create revision", async () => {
const title = `${RUN} Create`;
const page = await makePage(title, "Initial body", "World");
expect(page.slug).toBe(slugify(title));
expect(page.category).toBe("World");
expect(page.tags ?? []).toEqual([]);
const revisions = await fetchRevisions(page.id);
expect(revisions).toHaveLength(1);
expect(revisions[0].revisionNumber).toBe(1);
expect(revisions[0].type).toBe("create");
expect(revisions[0].titleSnapshot).toBe(title);
expect(revisions[0].contentSnapshot).toBe("Initial body");
});
it("createPage stores tags", async () => {
const page = await makePage(`${RUN} Tags`, "Body", "Guides", ["alpha", "beta"]);
expect(page.tags).toEqual(["alpha", "beta"]);
});
it("updatePage snapshots the old content as the next revision and updates the page", async () => {
const page = await makePage(`${RUN} Update`, "v1 body", "Lore");
const updated = await updatePage(payload, user, page, {
body: "v2 body",
category: "Guides",
tags: ["x", "y"],
summary: "edit",
});
expect(updated.body).toBe("v2 body");
expect(updated.category).toBe("Guides");
expect(updated.tags).toEqual(["x", "y"]);
expect(updated.lastEditor).toBe(userId);
const revisions = await fetchRevisions(page.id);
expect(revisions).toHaveLength(2);
expect(revisions[1].revisionNumber).toBe(2);
expect(revisions[1].type).toBe("edit");
expect(revisions[1].titleSnapshot).toBe(page.title);
expect(revisions[1].contentSnapshot).toBe("v1 body");
});
it("updatePage rename produces a new unique slug", async () => {
const page = await makePage(`${RUN} Rename`, "body");
const renamed = await updatePage(payload, user, page, { title: `${RUN} Renamed` });
expect(renamed.title).toBe(`${RUN} Renamed`);
expect(renamed.slug).toBe(slugify(`${RUN} Renamed`));
});
it("restoreRevision restores content from the target revision and records a restore revision", async () => {
const page = await makePage(`${RUN} Restore`, "original body");
const edited = await updatePage(payload, user, page, { body: "edited body" });
const restored = await restoreRevision(payload, user, edited, 1);
expect(restored.title).toBe(page.title);
expect(restored.body).toBe("original body");
const revisions = await fetchRevisions(page.id);
expect(revisions).toHaveLength(3);
expect(revisions[2].revisionNumber).toBe(3);
expect(revisions[2].type).toBe("restore");
expect(revisions[2].restoredFromRevision).toBe(1);
expect(revisions[2].titleSnapshot).toBe(page.title);
expect(revisions[2].contentSnapshot).toBe("original body");
});
it("restoreRevision restores the title and re-slugs when the title changed", async () => {
const page = await makePage(`${RUN} RestoreTitle`, "body");
const renamed = await updatePage(payload, user, page, {
title: `${RUN} RestoreTitle Renamed`,
});
expect(renamed.slug).toBe(slugify(`${RUN} RestoreTitle Renamed`));
const restored = await restoreRevision(payload, user, renamed, 1);
expect(restored.title).toBe(`${RUN} RestoreTitle`);
expect(restored.slug).toBe(slugify(`${RUN} RestoreTitle`));
});
it("restoreRevision throws when the revision does not exist", async () => {
const page = await makePage(`${RUN} MissingRev`, "body");
await expect(restoreRevision(payload, user, page, 99)).rejects.toThrow(
"Revision 99 not found for this page.",
);
});
it("creates a unique slug with a -2 suffix for a duplicate title", async () => {
const title = `${RUN} Duplicate`;
const first = await makePage(title, "a");
const second = await makePage(title, "b");
expect(first.slug).toBe(slugify(title));
expect(second.slug).toBe(`${slugify(title)}-2`);
});
it("setPageLockdown blocks edits and unlock allows them again", async () => {
const page = await makePage(`${RUN} Lock`, "body");
const locked = await setPageLockdown(payload, user, page, true);
expect(locked.lockdown).toBe(true);
expect(locked.lockedBy).toBe(userId);
expect(locked.lockedAt).toBeTruthy();
await expect(updatePage(payload, user, locked, { body: "nope" })).rejects.toThrow(
"This page is locked and cannot be edited.",
);
await expect(restoreRevision(payload, user, locked, 1)).rejects.toThrow(
"This page is locked and cannot be edited.",
);
const unlocked = await setPageLockdown(payload, user, locked, false);
expect(unlocked.lockdown).toBe(false);
expect(unlocked.lockedBy).toBeNull();
expect(unlocked.lockedAt).toBeNull();
const updated = await updatePage(payload, user, unlocked, { body: "yes" });
expect(updated.body).toBe("yes");
});
it("deletePage removes the page and all of its revisions", async () => {
const page = await makePage(`${RUN} Delete`, "body");
await updatePage(payload, user, page, { body: "v2" });
await deletePage(payload, user, page);
const pages = (await payload.find({
collection: "wiki-pages",
where: { id: { equals: page.id } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: WikiPage[] };
expect(pages.docs).toHaveLength(0);
expect(await fetchRevisions(page.id)).toHaveLength(0);
});
it("listTemplates returns created templates and templateMap maps name to body", async () => {
const t1 = (await payload.create({
collection: "wiki-templates",
data: { name: `${RUN}-tpl-a`, body: "Template A body" },
overrideAccess: true,
depth: 0,
})) as unknown as WikiTemplate;
templateIds.push(t1.id);
const t2 = (await payload.create({
collection: "wiki-templates",
data: { name: `${RUN}-tpl-b`, body: "Template B body" },
overrideAccess: true,
depth: 0,
})) as unknown as WikiTemplate;
templateIds.push(t2.id);
const templates = await listTemplates(payload);
const map = templateMap(templates);
expect(map[`${RUN}-tpl-a`]).toBe("Template A body");
expect(map[`${RUN}-tpl-b`]).toBe("Template B body");
});
});
describe("intelligence qualification gating", () => {
let readonlyUser: User;
let qualifiedUser: User;
let superuserUser: User;
const gatingRoleIds: number[] = [];
const gatingUserIds: number[] = [];
let createdQualificationId: number | null = null;
const wikiDeleteAccess = async (user: User | null) => {
const args = { req: { payload, user } } as unknown as AccessArgs;
return (await WikiPages.access?.delete?.(args)) ?? false;
};
const createGatingUser = async (
username: string,
roleDocIds?: number[],
): Promise<User> => {
const u = (await payload.create({
collection: "users",
data: {
username,
discordUsername: username,
displayName: "WIKI GATING TEST",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}),
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
gatingUserIds.push(u.id);
return u;
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
// Regression fixture: a role granting ONLY the four broad intelligence
// read permissions that the standard "user" role also grants. Before the
// hasIntelligenceQualification fix, holding these alone made every
// player a wiki moderator.
const readOnlyRole = await payload.create({
collection: "roles",
data: {
name: `${RUN} Intel Read Only`,
slug: `${RUN}-intel-read-only`,
permissions: [
"missions:read",
"campaigns:read",
"factions:read",
"technologies:read",
],
},
overrideAccess: true,
depth: 0,
});
gatingRoleIds.push(readOnlyRole.id);
const superuserRole = await payload.create({
collection: "roles",
data: { name: `${RUN} Superuser`, slug: `${RUN}-superuser`, isSuperuser: true },
overrideAccess: true,
depth: 0,
});
gatingRoleIds.push(superuserRole.id);
readonlyUser = await createGatingUser(`${RUN}-intel-readonly`, [readOnlyRole.id]);
qualifiedUser = await createGatingUser(`${RUN}-intel-qualified`);
superuserUser = await createGatingUser(`${RUN}-intel-super`, [superuserRole.id]);
// Qualification docs have unique names; reuse a shared "Intelligence"
// qualification if one already exists, otherwise create (and clean up) our own.
let qualification = (await payload.find({
collection: "qualifications",
where: { name: { equals: "Intelligence" } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Array<{ id: number }> };
if (qualification.docs.length === 0) {
const created = await payload.create({
collection: "qualifications",
data: { name: "Intelligence" },
overrideAccess: true,
depth: 0,
});
createdQualificationId = created.id;
qualification = { docs: [created] };
}
const profile = (await payload.find({
collection: "profiles",
where: { user: { equals: qualifiedUser.id } },
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Array<{ id: number }> };
await payload.update({
collection: "profiles",
id: profile.docs[0].id,
data: { progression: { qualifications: [qualification.docs[0].id] } },
overrideAccess: true,
depth: 0,
});
});
afterAll(async () => {
for (const id of gatingUserIds) {
await deleteUserWithRelations(payload, id);
}
for (const id of gatingRoleIds) {
await payload
.delete({ collection: "roles", id, overrideAccess: true })
.catch(() => {});
}
if (createdQualificationId !== null) {
await payload
.delete({ collection: "qualifications", id: createdQualificationId, overrideAccess: true })
.catch(() => {});
}
});
it("a user holding only intelligence read permissions does not qualify as moderator", async () => {
expect(await hasIntelligenceQualification(payload, readonlyUser)).toBe(false);
expect(await wikiDeleteAccess(readonlyUser)).toBe(false);
});
it("a user with the intelligence profile qualification may moderate", async () => {
expect(await hasIntelligenceQualification(payload, qualifiedUser)).toBe(true);
expect(await wikiDeleteAccess(qualifiedUser)).toBe(true);
});
it("a superuser role qualifies regardless of granted permissions", async () => {
expect(await hasIntelligenceQualification(payload, superuserUser)).toBe(true);
expect(await wikiDeleteAccess(superuserUser)).toBe(true);
});
});
});