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 => ({ title: "A post", slug: "", body: "

body

", 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: "

hi

", 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: '

ok

', authorName: "Mallory", authorUsername: null, 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("

ok

"); expect(post?.body).not.toContain("