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"); }); }); });