/** * WordPress migration acceptance test (issue #1691). * * Drives the runtime WXR import pipeline — the same code path the admin * import wizard uses — end to end against a realistic committed export * fixture (`fixtures/realistic-export.xml`), then asserts on the resulting * database state: * * parse WXR → taxonomy plan → importContent → sections → media import * (network stubbed) → URL rewriting * * What this covers: posts, pages, a custom post type, drafts/pending, * preserved slugs and dates, Gutenberg → Portable Text (incl. classic * HTML), hierarchical categories, tags, a custom taxonomy, featured * images, media import with URL rewriting into content, bylines from WXR * authors, reusable blocks as sections, and idempotent re-runs. * * What this deliberately does NOT cover (tested elsewhere / other scope): * * - Custom-field and SEO *values* (ACF/Yoast meta): the WXR runtime path * surfaces them in the analyze step but does not write them; value * import is the plugin importer's job — see * `wordpress-import/plugin-execute-fields.test.ts`. The fixture still * carries the meta so the parser-visibility of it is pinned here. * - Comments and menus: plugin importer scope — see * `wordpress-import/plugin-execute-chunked.test.ts`. * - The redirects map: generated by the CLI two-phase import — see * `wordpress-import/wordpress-import.test.ts` ("creates redirects map"). * - Multilingual (WPML/Polylang) imports: see * `wordpress-import/wxr-i18n-taxonomies.test.ts`. */ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { Kysely } from "kysely"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; const { JPEG, PNG } = vi.hoisted(() => ({ // 4x4 JPEG JPEG: new Uint8Array( Buffer.from( "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q==", "base64", ), ), // 1x1 PNG — distinct bytes per attachment, or the importer's // content-hash dedupe would collapse both into one media row. PNG: new Uint8Array( Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64", ), ), })); // The media importer downloads attachments from the source site; stub the // SSRF-safe fetch so the test needs no network and stays deterministic. vi.mock("#import/ssrf.js", () => ({ validateExternalUrl: () => {}, SsrfError: class SsrfError extends Error {}, ssrfSafeFetch: async (url: string) => url.endsWith(".png") ? new Response(PNG, { status: 200, headers: { "content-type": "image/png" } }) : new Response(JPEG, { status: 200, headers: { "content-type": "image/jpeg" } }), })); import { handleTaxonomyCreate } from "../../../src/api/handlers/taxonomies.js"; import { importContent, type ImportConfig, type ImportResult, } from "../../../src/astro/routes/api/import/wordpress/execute.js"; import { importMediaWithProgress, type MediaImportResult, } from "../../../src/astro/routes/api/import/wordpress/media.js"; import { rewriteUrls, type RewriteUrlsResult, } from "../../../src/astro/routes/api/import/wordpress/rewrite-urls.js"; import type { EmDashHandlers, EmDashManifest } from "../../../src/astro/types.js"; import { parseWxrString } from "../../../src/cli/wxr/parser.js"; import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; import type { Database } from "../../../src/database/types.js"; import { importReusableBlocksAsSections } from "../../../src/import/sections.js"; import { preImportWxrTaxonomies } from "../../../src/import/wxr-taxonomies.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import type { Storage } from "../../../src/storage/types.js"; import { createTestRuntime, handlersFromRuntime } from "../../utils/mcp-runtime.js"; import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; const FIXTURE_PATH = join(import.meta.dirname, "fixtures", "realistic-export.xml"); function fakeStorage(): Storage { return { async upload(o) { const b = o.body instanceof Uint8Array ? o.body : new Uint8Array(o.body as ArrayBuffer); return { key: o.key, url: `/m/${o.key}`, size: b.byteLength }; }, } as Storage; } /** Read a column of a content row by slug, throwing when the row is absent. */ async function rowBySlug(db: Kysely, table: string, slug: string) { return ( db // eslint-disable-next-line typescript/no-unsafe-type-assertion -- dynamic ec_ table not in the static schema .selectFrom(table as keyof Database) .selectAll() // eslint-disable-next-line typescript/no-unsafe-type-assertion -- dynamic ec_ table not in the static schema .where("slug" as never, "=", slug as never) .executeTakeFirstOrThrow() as Promise> ); } describe("WordPress migration acceptance (#1691)", () => { let db: Kysely; let emdash: EmDashHandlers; let manifest: EmDashManifest; let wxr: Awaited>; let importResult: ImportResult; let mediaResult: MediaImportResult; let rewriteResult: RewriteUrlsResult; let config: ImportConfig; let attachmentMap: Map; let authorDisplayNames: Map; beforeAll(async () => { db = await setupTestDatabase(); const registry = new SchemaRegistry(db); // Schema the admin wizard would have created from the analyze step. for (const [slug, label, singular] of [ ["post", "Posts", "Post"], ["page", "Pages", "Page"], ["book", "Books", "Book"], ] as const) { await registry.createCollection({ slug, label, labelSingular: singular }); await registry.createField(slug, { slug: "title", label: "Title", type: "string" }); await registry.createField(slug, { slug: "content", label: "Content", type: "portableText", }); await registry.createField(slug, { slug: "excerpt", label: "Excerpt", type: "text" }); } await registry.createField("post", { slug: "featured_image", label: "Featured Image", type: "image", }); // The seeded category/tag defs target the conventional `posts` slug; // point them at this site's `post` collection. await db .updateTable("_emdash_taxonomy_defs") .set({ collections: JSON.stringify(["post"]) }) .where("name", "in", ["category", "tag"]) .execute(); // The custom `genre` taxonomy the user creates in the admin after the // analyze step surfaces it in `missingTaxonomies`. const genre = await handleTaxonomyCreate(db, { name: "genre", label: "Genres", labelSingular: "Genre", hierarchical: false, collections: ["book"], }); if (!genre.success) throw new Error("genre def creation failed"); const runtime = createTestRuntime(db); emdash = handlersFromRuntime(runtime); manifest = await emdash.getManifest(); // --- The pipeline, in wizard order ------------------------------- wxr = await parseWxrString(await readFile(FIXTURE_PATH, "utf-8")); attachmentMap = new Map(); for (const att of wxr.attachments) { if (att.id && att.url) attachmentMap.set(String(att.id), att.url); } authorDisplayNames = new Map(); for (const author of wxr.authors) { if (author.login) authorDisplayNames.set(author.login, author.displayName || author.login); } config = { postTypeMappings: { post: { collection: "post", enabled: true }, page: { collection: "page", enabled: true }, book: { collection: "book", enabled: true }, }, skipExisting: false, }; const plan = await preImportWxrTaxonomies( db, wxr.posts, wxr.categories, wxr.tags, wxr.terms, "en", ); importResult = await importContent( wxr.posts, config, emdash, manifest, attachmentMap, "en", authorDisplayNames, plan, ); const sectionsResult = await importReusableBlocksAsSections(wxr.posts, db); importResult.sections = { created: sectionsResult.sectionsCreated, skipped: sectionsResult.sectionsSkipped, }; mediaResult = await importMediaWithProgress( wxr.attachments.map((att) => ({ id: att.id, url: att.url, filename: att.url?.split("/").at(-1), mimeType: att.url?.endsWith(".png") ? "image/png" : "image/jpeg", })), db, fakeStorage(), () => {}, ); rewriteResult = await rewriteUrls(db, mediaResult.urlMap, () => undefined); }); afterAll(async () => { await teardownTestDatabase(db); }); it("imports every mapped item without errors", () => { expect(importResult.errors).toEqual([]); expect(importResult.imported).toBe(9); expect(importResult.byCollection).toEqual({ post: 5, page: 2, book: 2 }); // The wp_block item is unmapped in postTypeMappings (it becomes a // section instead of content). expect(importResult.skipped).toBe(1); }); it("preserves WordPress slugs so permalinks map 1:1", async () => { const slugsOf = async (table: string) => { const rows = await db // eslint-disable-next-line typescript/no-unsafe-type-assertion -- dynamic ec_ table not in the static schema .selectFrom(table as keyof Database) .select("slug" as never) .execute(); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- dynamic row shape return (rows as { slug: string }[]).map((r) => r.slug).toSorted(); }; expect(await slugsOf("ec_post")).toEqual([ "building-for-the-long-term", "classic-editor-memories", "hello-cloudflare", "pending-review", "work-in-progress", ]); expect(await slugsOf("ec_page")).toEqual(["about-us", "contact"]); expect(await slugsOf("ec_book")).toEqual(["dune", "the-essayist"]); }); it("maps statuses and preserves original publication dates", async () => { const published = await rowBySlug(db, "ec_post", "building-for-the-long-term"); expect(published.status).toBe("published"); // post_date_gmt from the fixture, not the import timestamp. expect(String(published.created_at)).toMatch(/^2026-01-15T08:30:00/); expect(String(published.published_at)).toMatch(/^2026-01-15T08:30:00/); const draft = await rowBySlug(db, "ec_post", "work-in-progress"); expect(draft.status).toBe("draft"); expect(draft.published_at).toBeNull(); // WP `pending` has no EmDash equivalent and degrades to draft. const pending = await rowBySlug(db, "ec_post", "pending-review"); expect(pending.status).toBe("draft"); }); it("converts Gutenberg blocks to Portable Text", async () => { const row = await rowBySlug(db, "ec_post", "building-for-the-long-term"); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- portableText column stores a JSON array const blocks = JSON.parse(String(row.content)) as Record[]; const styles = blocks.map((b) => b.style); expect(styles).toContain("h2"); expect(styles).toContain("blockquote"); expect(blocks.some((b) => b.listItem === "bullet")).toBe(true); expect(blocks.some((b) => b._type === "image")).toBe(true); // Inline survives as a mark. const withMarks = blocks.some( (b) => Array.isArray(b.children) && // eslint-disable-next-line typescript/no-unsafe-type-assertion -- portable text children shape (b.children as { marks?: string[] }[]).some((c) => (c.marks?.length ?? 0) > 0), ); expect(withMarks).toBe(true); // Code block in the second post. const cloudflare = await rowBySlug(db, "ec_post", "hello-cloudflare"); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- portableText column stores a JSON array const cfBlocks = JSON.parse(String(cloudflare.content)) as Record[]; expect(cfBlocks.some((b) => b._type === "code")).toBe(true); // Classic-editor HTML (no block markers) still converts. const classic = await rowBySlug(db, "ec_post", "classic-editor-memories"); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- portableText column stores a JSON array const classicBlocks = JSON.parse(String(classic.content)) as Record[]; expect(classicBlocks.length).toBeGreaterThan(0); }); it("stores the excerpt", async () => { const row = await rowBySlug(db, "ec_post", "building-for-the-long-term"); expect(row.excerpt).toBe("Why we build for decades, not quarters."); }); it("creates bylines from WXR authors and links them", async () => { const bylines = await db.selectFrom("_emdash_bylines").select(["id", "display_name"]).execute(); const names = bylines.map((b) => b.display_name).toSorted(); expect(names).toEqual(["Bob Smith", "Jane Doe"]); const post = await rowBySlug(db, "ec_post", "building-for-the-long-term"); const jane = bylines.find((b) => b.display_name === "Jane Doe"); expect(post.primary_byline_id).toBe(jane?.id); }); it("imports taxonomies: hierarchical categories, tags, and the custom taxonomy", async () => { expect(importResult.taxonomies?.missingTaxonomies).toEqual([]); const repo = new TaxonomyRepository(db); const categories = await repo.findByName("category"); expect(categories.map((c) => c.slug).toSorted()).toEqual(["guides", "howto", "news"]); const tags = await repo.findByName("tag"); expect(tags.map((t) => t.slug).toSorted()).toEqual(["cloudflare", "typescript"]); const genres = await repo.findByName("genre"); expect(genres.map((g) => g.slug).toSorted()).toEqual(["essay", "sci-fi"]); const post = await rowBySlug(db, "ec_post", "building-for-the-long-term"); const assigned = await repo.getTermsForEntry("post", String(post.id)); expect(assigned.map((t) => `${t.name}:${t.slug}`).toSorted()).toEqual([ "category:guides", "category:howto", "tag:typescript", ]); const dune = await rowBySlug(db, "ec_book", "dune"); const duneTerms = await repo.getTermsForEntry("book", String(dune.id)); expect(duneTerms.map((t) => `${t.name}:${t.slug}`)).toEqual(["genre:sci-fi"]); }); it("imports the reusable block as a section", async () => { expect(importResult.sections).toEqual({ created: 1, skipped: 0 }); const section = await db .selectFrom("_emdash_sections") .selectAll() .where("slug", "=", "newsletter-cta") .executeTakeFirstOrThrow(); expect(String(section.content)).toContain("Subscribe to the Acme newsletter"); }); it("imports media with enrichment and produces a URL map", async () => { expect(mediaResult.failed).toEqual([]); expect(mediaResult.imported).toHaveLength(2); expect(Object.keys(mediaResult.urlMap).toSorted()).toEqual([ "https://acme-publishing.example/wp-content/uploads/2026/01/hero.jpg", "https://acme-publishing.example/wp-content/uploads/2026/02/diagram.png", ]); // Enrichment ran: dimensions from the actual bytes (4x4 JPEG, 1x1 PNG). const media = await db.selectFrom("media").select(["width", "height"]).execute(); expect(media).toHaveLength(2); expect(media.map((m) => `${m.width}x${m.height}`).toSorted()).toEqual(["1x1", "4x4"]); }); it("rewrites content and featured-image URLs to the imported media (URL behavior)", async () => { expect(rewriteResult.errors).toEqual([]); expect(rewriteResult.urlsRewritten).toBeGreaterThanOrEqual(3); const heroNewUrl = mediaResult.urlMap["https://acme-publishing.example/wp-content/uploads/2026/01/hero.jpg"]; const diagramNewUrl = mediaResult.urlMap["https://acme-publishing.example/wp-content/uploads/2026/02/diagram.png"]; // The in-content image used a `-1024x683` size variant of hero.jpg; the // base-URL matcher must still map it to the imported original. const post = await rowBySlug(db, "ec_post", "building-for-the-long-term"); expect(String(post.content)).toContain(heroNewUrl); expect(String(post.content)).not.toContain("wp-content/uploads"); expect(String(post.featured_image)).toContain(heroNewUrl); expect(String(post.featured_image)).not.toContain("wp-content/uploads"); const cloudflare = await rowBySlug(db, "ec_post", "hello-cloudflare"); expect(String(cloudflare.content)).toContain(diagramNewUrl); expect(String(cloudflare.content)).not.toContain("wp-content/uploads"); }); it("keeps SEO and custom-field meta visible to the analyze step", () => { // The WXR runtime path does not import meta *values* (that's the // plugin importer's job — see the header comment). But the fixture's // Yoast/ACF meta must at least be parsed so the analyze step can // suggest fields for it; losing it in the parser would silently // degrade the wizard. const post = wxr.posts.find((p) => p.postName === "building-for-the-long-term"); expect(post?.meta.get("_yoast_wpseo_title")).toBe( "Building for the Long Term — Acme Publishing", ); expect(post?.meta.get("_yoast_wpseo_metadesc")).toBe( "Why we build software for decades, not quarters.", ); expect(post?.meta.get("reading_time")).toBe("7"); expect(post?.meta.get("subtitle")).toBe("An operating thesis"); }); it("re-running the import with skipExisting is a no-op (idempotency)", async () => { const plan = await preImportWxrTaxonomies( db, wxr.posts, wxr.categories, wxr.tags, wxr.terms, "en", ); const rerun = await importContent( wxr.posts, { ...config, skipExisting: true }, emdash, manifest, attachmentMap, "en", authorDisplayNames, plan, ); expect(rerun.errors).toEqual([]); expect(rerun.imported).toBe(0); // 9 existing items + the unmapped wp_block. expect(rerun.skipped).toBe(10); const sectionsRerun = await importReusableBlocksAsSections(wxr.posts, db); expect(sectionsRerun.sectionsCreated).toBe(0); expect(sectionsRerun.sectionsSkipped).toBe(1); // Row counts across every table the import writes to must be unchanged — // a rerun that duplicates pages, books, bylines, or sections would // otherwise slip through a posts-only guard. const expectedCounts: Record = { ec_post: 5, ec_page: 2, ec_book: 2, _emdash_bylines: 2, _emdash_sections: 1, }; for (const [table, expected] of Object.entries(expectedCounts)) { const rows = await db // eslint-disable-next-line typescript/no-unsafe-type-assertion -- dynamic ec_ table not in the static schema .selectFrom(table as keyof Database) .select((eb) => eb.fn.countAll().as("count")) .executeTakeFirstOrThrow(); expect(Number(rows.count), `row count of ${table}`).toBe(expected); } }); });