1
0
Fork 0

test(wiki): add wiki integration tests

Integration coverage for the wiki service layer: page create/update with
slug collision handling, revision restore, lockdown enforcement, template
expansion, and wikilink preprocessing.
This commit is contained in:
Jason Fraley 2026-09-05 00:02:25 -04:00
parent 696bd9b392
commit d8f7c06ca7

363
tests/int/wiki.int.spec.ts Normal file
View file

@ -0,0 +1,363 @@
import { getPayload, Payload } 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 {
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)}`;
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(() => {});
}
// User deletion trips FK constraints unless the hook-provisioned
// personal bank account and profile are removed first.
const accounts = await payload
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: userId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await payload
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
.catch(() => {});
}
const profiles = await payload
.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await payload
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await payload
.delete({ collection: "users", id: userId, overrideAccess: true })
.catch(() => {});
});
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");
});
});
});