yap-blog/tests/integration/import-export.test.ts
matt 3175bd9172 Add JSON import/export backup on the settings page
Export downloads a versioned JSON snapshot of all posts, pages, tags,
navigation, and settings from GET /api/admin/export. Cross-references
are keyed by slug rather than database id, so a backup restores cleanly
into any database. Import (a new settings-page section) validates the
file and atomically replaces all content in one transaction, sanitizing
bodies at the trust boundary; users, sessions, and uploaded files are
untouched. Server-action body limit raised so backups fit in the
import upload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:53:38 -04:00

174 lines
5.8 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { db } from "@/db";
import { navItems, posts, tags } from "@/db/schema";
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("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 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");
});
});