Integration coverage for the wiki service layer: page create/update with slug collision handling, revision restore, lockdown enforcement, template expansion, and wikilink preprocessing.
363 lines
No EOL
13 KiB
TypeScript
363 lines
No EOL
13 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
}); |