yap-blog/tests/integration/import-export.test.ts
matt bb3ab4561d Add moderated threaded comments
Visitors comment with just a name and email; a checkbox controls
whether the email is shown publicly (default private — only the admin
sees it). Every comment lands as pending and is invisible until
approved on the new /admin/comments page (approve / unapprove /
delete, with a pending-count badge in the admin nav and a dashboard
stat). Replies nest under their parent; a reply is only accepted on an
approved comment of the same post, and replies stay hidden while their
parent is unapproved so threads never render out of context. A hidden
honeypot field silently drops naive bots. Comment bodies are plain
text, rendered escaped.

The backup format gains a comments section (export version 2; v1 files
still import) with parent links remapped through file-local ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:08:08 -04:00

265 lines
8.7 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { db } from "@/db";
import { navItems, posts, tags } from "@/db/schema";
import {
createComment,
listApprovedComments,
setCommentStatus,
} from "@/lib/services/comments";
import {
buildSiteExport,
importSiteExport,
parseSiteExportJson,
} from "@/lib/services/import-export";
import { createPage } from "@/lib/services/pages";
import { createPost, getPublishedPostBySlug, type PostInput } from "@/lib/services/posts";
import { getSettings, listNavItems, saveSettings } from "@/lib/services/settings";
import { resetDb } from "../helpers/db";
const input = (overrides: Partial<PostInput> = {}): PostInput => ({
title: "A post",
slug: "",
body: "<p>body</p>",
authorName: "Tester",
featuredImageUrl: null,
featuredImageAlt: null,
status: "published",
tagIds: [],
newTagNames: [],
...overrides,
});
beforeEach(resetDb);
/** Simulates writing the export to disk and reading it back. */
async function exportViaJson() {
const parsed = parseSiteExportJson(JSON.stringify(await buildSiteExport()));
if ("error" in parsed) throw new Error(parsed.error);
return parsed.data;
}
describe("export/import round trip", () => {
it("restores posts, tags, pages, nav, and settings from a JSON export", async () => {
const published = await createPost(
input({ title: "Keep me", newTagNames: ["Alpha", "Beta"] }),
);
await createPost(input({ title: "Draft one", status: "draft" }));
const about = await createPage({
title: "About",
slug: "",
body: "<p>hi</p>",
status: "published",
});
await saveSettings(
{
siteTitle: "Round Trip",
headerText: "hdr",
footerText: "ftr",
postsPerPage: 7,
excerptWords: 25,
homeMode: "page",
homeTagId: null,
homePageId: about.id,
theme: "nord",
font: "lora",
},
[
{ label: "About", url: null, pageId: about.id },
{ label: "Search", url: "https://example.com", pageId: null },
],
);
const snapshot = await exportViaJson();
// Mutate everything, then restore.
await db.delete(navItems);
await db.delete(posts);
await db.delete(tags);
await createPost(input({ title: "Impostor" }));
await importSiteExport(snapshot);
const restored = await getPublishedPostBySlug(published.slug);
expect(restored).not.toBeNull();
expect(restored?.title).toBe("Keep me");
expect(restored?.tags.map((t) => t.name).sort()).toEqual(["Alpha", "Beta"]);
// Timestamps survive (public ordering depends on publishedAt).
expect(restored?.publishedAt?.getTime()).toBe(published.publishedAt?.getTime());
expect(await getPublishedPostBySlug("impostor")).toBeNull();
const settings = await getSettings();
expect(settings.siteTitle).toBe("Round Trip");
expect(settings.postsPerPage).toBe(7);
expect(settings.homeMode).toBe("page");
expect(settings.theme).toBe("nord");
expect(settings.font).toBe("lora");
// home page and nav point at the re-created page row, not the old id.
const nav = await listNavItems();
expect(nav).toHaveLength(2);
expect(nav[0].label).toBe("About");
expect(nav[0].pageId).toBe(settings.homePageId);
expect(nav[1].url).toBe("https://example.com");
});
it("round-trips threaded comments with remapped ids and statuses", async () => {
const post = await createPost(input({ title: "Discussed" }));
const parent = await createComment({
postId: post.id,
parentId: null,
authorName: "Alice",
authorEmail: "alice@example.com",
emailPublic: true,
body: "First!",
});
if (!parent.ok) throw new Error();
await setCommentStatus(parent.comment.id, "approved");
const reply = await createComment({
postId: post.id,
parentId: parent.comment.id,
authorName: "Bob",
authorEmail: "bob@example.com",
emailPublic: false,
body: "Replying to Alice",
});
if (!reply.ok) throw new Error();
await setCommentStatus(reply.comment.id, "approved");
await createComment({
postId: post.id,
parentId: parent.comment.id,
authorName: "Spammer",
authorEmail: "spam@example.com",
emailPublic: false,
body: "pending reply",
});
const snapshot = await exportViaJson();
expect(snapshot.comments).toHaveLength(3);
await importSiteExport(snapshot);
const restored = await getPublishedPostBySlug(post.slug);
const tree = await listApprovedComments(restored!.id);
expect(tree).toHaveLength(1);
expect(tree[0].authorEmail).toBe("alice@example.com"); // opted-in email survives
expect(tree[0].replies).toHaveLength(1); // pending reply stays hidden
expect(tree[0].replies[0].body).toBe("Replying to Alice");
expect(tree[0].replies[0].authorEmail).toBeNull(); // private stays private
});
it("accepts version-1 exports (no comments field)", async () => {
const snapshot = await exportViaJson();
const v1 = { ...JSON.parse(JSON.stringify(snapshot)), version: 1 };
delete v1.comments;
const parsed = parseSiteExportJson(JSON.stringify(v1));
expect(parsed).not.toHaveProperty("error");
if ("data" in parsed) expect(parsed.data.comments).toEqual([]);
});
it("keeps draft posts and unpublished state intact", async () => {
await createPost(input({ title: "Draft", status: "draft" }));
const snapshot = await exportViaJson();
await importSiteExport(snapshot);
const [row] = await db.select().from(posts);
expect(row.status).toBe("draft");
expect(row.publishedAt).toBeNull();
});
it("sanitizes imported bodies", async () => {
const snapshot = await exportViaJson();
snapshot.posts.push({
title: "Sneaky",
slug: "sneaky",
body: '<p>ok</p><script>alert("x")</script>',
authorName: "Mallory",
featuredImageUrl: null,
featuredImageAlt: null,
status: "published",
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: new Date(),
tagSlugs: [],
});
await importSiteExport(snapshot);
const post = await getPublishedPostBySlug("sneaky");
expect(post?.body).toContain("<p>ok</p>");
expect(post?.body).not.toContain("<script>");
});
});
describe("parseSiteExportJson", () => {
it("rejects non-JSON and foreign JSON", () => {
expect(parseSiteExportJson("not json {")).toHaveProperty("error");
expect(parseSiteExportJson('{"hello":"world"}')).toHaveProperty("error");
});
it("rejects posts referencing tags missing from the file", async () => {
const snapshot = await exportViaJson();
snapshot.posts.push({
title: "Orphan",
slug: "orphan",
body: "",
authorName: "A",
featuredImageUrl: null,
featuredImageAlt: null,
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: null,
tagSlugs: ["missing-tag"],
});
const result = parseSiteExportJson(JSON.stringify(snapshot));
expect(result).toHaveProperty("error");
if ("error" in result) expect(result.error).toContain("missing-tag");
});
it("rejects duplicate slugs", async () => {
const snapshot = await exportViaJson();
snapshot.pages.push(
{ title: "P", slug: "same", body: "", status: "draft", createdAt: new Date(), updatedAt: new Date() },
{ title: "Q", slug: "same", body: "", status: "draft", createdAt: new Date(), updatedAt: new Date() },
);
const result = parseSiteExportJson(JSON.stringify(snapshot));
expect(result).toHaveProperty("error");
});
it("rejects reply cycles in a crafted file", async () => {
const snapshot = await exportViaJson();
snapshot.posts.push({
title: "P",
slug: "p",
body: "",
authorName: "A",
featuredImageUrl: null,
featuredImageAlt: null,
status: "published",
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: new Date(),
tagSlugs: [],
});
const commentBase = {
postSlug: "p",
authorName: "X",
authorEmail: "x@example.com",
emailPublic: false,
body: "hi",
status: "approved" as const,
createdAt: new Date(),
};
snapshot.comments = [
{ id: 1, parentId: 2, ...commentBase },
{ id: 2, parentId: 1, ...commentBase },
];
const result = parseSiteExportJson(JSON.stringify(snapshot));
expect(result).toHaveProperty("error");
if ("error" in result) expect(result.error).toContain("cycle");
});
it("rejects a home tag that is not part of the export", async () => {
const snapshot = await exportViaJson();
snapshot.settings.homeMode = "tag";
snapshot.settings.homeTagSlug = "ghost";
const result = parseSiteExportJson(JSON.stringify(snapshot));
expect(result).toHaveProperty("error");
});
});