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:
parent
696bd9b392
commit
d8f7c06ca7
1 changed files with 363 additions and 0 deletions
363
tests/int/wiki.int.spec.ts
Normal file
363
tests/int/wiki.int.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue