From d8f7c06ca76852b6fe93b8588288654e52829507 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sat, 5 Sep 2026 00:02:25 -0400 Subject: [PATCH] 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. --- tests/int/wiki.int.spec.ts | 363 +++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 tests/int/wiki.int.spec.ts diff --git a/tests/int/wiki.int.spec.ts b/tests/int/wiki.int.spec.ts new file mode 100644 index 0000000..451443a --- /dev/null +++ b/tests/int/wiki.int.spec.ts @@ -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 => { + 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 => { + 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"); + }); + }); +}); \ No newline at end of file