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>
265 lines
8.7 KiB
TypeScript
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");
|
|
});
|
|
});
|