import type { Kysely } from "kysely"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { handleContentCreate, handleContentGet, handleContentList, handleContentUpdate, handleContentDuplicate, handleContentPermanentDelete, } from "../../../src/api/handlers/content.js"; import { handleSitemapData } from "../../../src/api/handlers/seo.js"; import { createDatabase } from "../../../src/database/connection.js"; import { runMigrations } from "../../../src/database/migrations/runner.js"; import { ContentRepository } from "../../../src/database/repositories/content.js"; import { RevisionRepository } from "../../../src/database/repositories/revision.js"; import { SeoRepository } from "../../../src/database/repositories/seo.js"; import type { ContentItem } from "../../../src/database/repositories/types.js"; import type { Database } from "../../../src/database/types.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { getSeoMeta, getContentSeo } from "../../../src/seo/index.js"; describe("SEO", () => { let db: Kysely; let repo: ContentRepository; let seoRepo: SeoRepository; let registry: SchemaRegistry; beforeEach(async () => { db = createDatabase({ url: ":memory:" }); await runMigrations(db); repo = new ContentRepository(db); seoRepo = new SeoRepository(db); registry = new SchemaRegistry(db); // Create post collection with title field and SEO enabled await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post", }); await registry.createField("post", { slug: "title", label: "Title", type: "string", }); // Enable SEO on posts await db .updateTable("_emdash_collections") .set({ has_seo: 1 }) .where("slug", "=", "post") .execute(); // Create page collection with SEO enabled await registry.createCollection({ slug: "page", label: "Pages", labelSingular: "Page", }); await registry.createField("page", { slug: "title", label: "Title", type: "string", }); await db .updateTable("_emdash_collections") .set({ has_seo: 1 }) .where("slug", "=", "page") .execute(); }); afterEach(async () => { await db.destroy(); }); describe("SeoRepository", () => { it("should return default SEO when no row exists", async () => { const content = await repo.create({ type: "post", data: { title: "Test" }, }); const seo = await seoRepo.get("post", content.id); expect(seo).toEqual({ title: null, description: null, image: null, canonical: null, noIndex: false, }); }); it("should upsert and retrieve SEO data", async () => { const content = await repo.create({ type: "post", data: { title: "Test" }, }); await seoRepo.upsert("post", content.id, { title: "Custom SEO Title", description: "A meta description", image: "media-123", canonical: "https://example.com/original", noIndex: true, }); const seo = await seoRepo.get("post", content.id); expect(seo.title).toBe("Custom SEO Title"); expect(seo.description).toBe("A meta description"); expect(seo.image).toBe("media-123"); expect(seo.canonical).toBe("https://example.com/original"); expect(seo.noIndex).toBe(true); }); it("should upsert with partial fields", async () => { const content = await repo.create({ type: "post", data: { title: "Test" }, }); await seoRepo.upsert("post", content.id, { title: "Just a title" }); const seo = await seoRepo.get("post", content.id); expect(seo.title).toBe("Just a title"); expect(seo.description).toBeNull(); expect(seo.image).toBeNull(); expect(seo.canonical).toBeNull(); expect(seo.noIndex).toBe(false); }); it("should update existing SEO data", async () => { const content = await repo.create({ type: "post", data: { title: "Test" }, }); await seoRepo.upsert("post", content.id, { title: "Original" }); await seoRepo.upsert("post", content.id, { title: "Updated", description: "New desc", }); const seo = await seoRepo.get("post", content.id); expect(seo.title).toBe("Updated"); expect(seo.description).toBe("New desc"); }); it("should clear fields with null values", async () => { const content = await repo.create({ type: "post", data: { title: "Test" }, }); await seoRepo.upsert("post", content.id, { title: "Title", description: "Desc", }); await seoRepo.upsert("post", content.id, { title: null, description: null, }); const seo = await seoRepo.get("post", content.id); expect(seo.title).toBeNull(); expect(seo.description).toBeNull(); }); it("should delete SEO data", async () => { const content = await repo.create({ type: "post", data: { title: "Test" }, }); await seoRepo.upsert("post", content.id, { title: "Title" }); await seoRepo.delete("post", content.id); const seo = await seoRepo.get("post", content.id); expect(seo.title).toBeNull(); }); it("should copy SEO for duplicate without canonical", async () => { const original = await repo.create({ type: "post", data: { title: "Original" }, }); await seoRepo.upsert("post", original.id, { title: "SEO Title", description: "SEO Desc", canonical: "https://example.com/original", noIndex: true, }); const duplicate = await repo.create({ type: "post", data: { title: "Copy" }, }); await seoRepo.copyForDuplicate("post", original.id, duplicate.id); const seo = await seoRepo.get("post", duplicate.id); expect(seo.title).toBe("SEO Title"); expect(seo.description).toBe("SEO Desc"); expect(seo.canonical).toBeNull(); // Canonical should not be copied expect(seo.noIndex).toBe(true); }); it("should batch-get SEO for multiple content items", async () => { const c1 = await repo.create({ type: "post", data: { title: "Post 1" } }); const c2 = await repo.create({ type: "post", data: { title: "Post 2" } }); const c3 = await repo.create({ type: "post", data: { title: "Post 3" } }); await seoRepo.upsert("post", c1.id, { title: "SEO 1" }); await seoRepo.upsert("post", c3.id, { title: "SEO 3", noIndex: true }); // c2 has no SEO row const seoMap = await seoRepo.getMany("post", [c1.id, c2.id, c3.id]); expect(seoMap.size).toBe(3); expect(seoMap.get(c1.id)!.title).toBe("SEO 1"); expect(seoMap.get(c2.id)!.title).toBeNull(); // defaults expect(seoMap.get(c3.id)!.title).toBe("SEO 3"); expect(seoMap.get(c3.id)!.noIndex).toBe(true); }); it("should return empty map for getMany with no IDs", async () => { const seoMap = await seoRepo.getMany("post", []); expect(seoMap.size).toBe(0); }); it("should skip upsert when input has no fields set", async () => { const content = await repo.create({ type: "post", data: { title: "Test" } }); // Empty seo input should be a no-op const seo = await seoRepo.upsert("post", content.id, {}); // Should return defaults without creating a row expect(seo.title).toBeNull(); expect(seo.noIndex).toBe(false); // Verify no row was actually written const directCheck = await seoRepo.get("post", content.id); expect(directCheck.title).toBeNull(); }); it("should not copy SEO when source has no data", async () => { const original = await repo.create({ type: "post", data: { title: "Original" }, }); const duplicate = await repo.create({ type: "post", data: { title: "Copy" }, }); await seoRepo.copyForDuplicate("post", original.id, duplicate.id); // Should still return defaults (no row was created) const seo = await seoRepo.get("post", duplicate.id); expect(seo.title).toBeNull(); }); }); describe("Content handlers with SEO", () => { it("should create content with SEO via handler", async () => { const result = await handleContentCreate(db, "post", { data: { title: "Test Post" }, seo: { title: "Custom SEO Title", description: "A meta description", }, }); expect(result.success).toBe(true); const item = result.data!.item; expect(item.seo).toBeDefined(); expect(item.seo!.title).toBe("Custom SEO Title"); expect(item.seo!.description).toBe("A meta description"); }); it("should return default SEO for SEO-enabled collection with no SEO input", async () => { const result = await handleContentCreate(db, "post", { data: { title: "Test Post" }, }); expect(result.success).toBe(true); const item = result.data!.item; expect(item.seo).toBeDefined(); expect(item.seo!.title).toBeNull(); expect(item.seo!.noIndex).toBe(false); }); it("should update SEO via content handler", async () => { const createResult = await handleContentCreate(db, "post", { data: { title: "Test Post" }, }); const id = createResult.data!.item.id; const updateResult = await handleContentUpdate(db, "post", id, { seo: { title: "Updated SEO Title", description: "Updated description", noIndex: true, }, }); expect(updateResult.success).toBe(true); const item = updateResult.data!.item; expect(item.seo!.title).toBe("Updated SEO Title"); expect(item.seo!.description).toBe("Updated description"); expect(item.seo!.noIndex).toBe(true); }); it("should preserve SEO when updating only content data", async () => { const createResult = await handleContentCreate(db, "post", { data: { title: "Test Post" }, seo: { title: "SEO Title", description: "SEO Description" }, }); const id = createResult.data!.item.id; // Update only content data, not SEO const updateResult = await handleContentUpdate(db, "post", id, { data: { title: "Updated Title" }, }); expect(updateResult.success).toBe(true); expect(updateResult.data!.item.seo!.title).toBe("SEO Title"); expect(updateResult.data!.item.seo!.description).toBe("SEO Description"); }); it("should hydrate SEO in handleContentGet", async () => { const createResult = await handleContentCreate(db, "post", { data: { title: "Test Post" }, seo: { title: "SEO Title" }, }); const id = createResult.data!.item.id; const getResult = await handleContentGet(db, "post", id); expect(getResult.success).toBe(true); expect(getResult.data!.item.seo!.title).toBe("SEO Title"); }); it("should hydrate SEO in handleContentList", async () => { await handleContentCreate(db, "post", { data: { title: "Post 1" }, seo: { title: "SEO 1" }, }); await handleContentCreate(db, "post", { data: { title: "Post 2" }, seo: { title: "SEO 2", noIndex: true }, }); const listResult = await handleContentList(db, "post", {}); expect(listResult.success).toBe(true); expect(listResult.data!.items).toHaveLength(2); const seoTitles = listResult.data!.items.map((item) => item.seo?.title); expect(seoTitles).toContain("SEO 1"); expect(seoTitles).toContain("SEO 2"); const noIndexItem = listResult.data!.items.find((item) => item.seo?.noIndex); expect(noIndexItem).toBeDefined(); expect(noIndexItem!.seo!.title).toBe("SEO 2"); }); it("should copy SEO when duplicating content", async () => { const createResult = await handleContentCreate(db, "post", { data: { title: "Original Post" }, seo: { title: "SEO Title", description: "SEO Desc", canonical: "https://example.com/original", }, }); const id = createResult.data!.item.id; const dupResult = await handleContentDuplicate(db, "post", id); expect(dupResult.success).toBe(true); const dupItem = dupResult.data!.item; expect(dupItem.seo).toBeDefined(); expect(dupItem.seo!.title).toBe("SEO Title"); expect(dupItem.seo!.description).toBe("SEO Desc"); expect(dupItem.seo!.canonical).toBeNull(); // Canonical should not be copied }); it("should return default SEO on duplicate when original has no SEO customizations", async () => { // Create with no explicit SEO const createResult = await handleContentCreate(db, "post", { data: { title: "Plain Post" }, }); const id = createResult.data!.item.id; const dupResult = await handleContentDuplicate(db, "post", id); expect(dupResult.success).toBe(true); // Duplicate of an SEO-enabled collection should always have seo field const dupItem = dupResult.data!.item; expect(dupItem.seo).toBeDefined(); expect(dupItem.seo!.title).toBeNull(); expect(dupItem.seo!.noIndex).toBe(false); }); it("should not include seo on duplicate of non-SEO collection", async () => { await registry.createCollection({ slug: "tags", label: "Tags", labelSingular: "Tag", }); await registry.createField("tags", { slug: "name", label: "Name", type: "string" }); const createResult = await handleContentCreate(db, "tags", { data: { name: "TypeScript" }, }); expect(createResult.success).toBe(true); const dupResult = await handleContentDuplicate(db, "tags", createResult.data!.item.id); expect(dupResult.success).toBe(true); expect(dupResult.data!.item.seo).toBeUndefined(); }); it("should clean up SEO on permanent delete", async () => { const createResult = await handleContentCreate(db, "post", { data: { title: "Test Post" }, seo: { title: "SEO Title" }, }); const id = createResult.data!.item.id; // Soft delete first, then permanent delete await repo.delete("post", id); await handleContentPermanentDelete(db, "post", id); // SEO row should be gone const seo = await seoRepo.get("post", id); expect(seo.title).toBeNull(); }); it("should clean up revisions on permanent delete", async () => { const createResult = await handleContentCreate(db, "post", { data: { title: "Test Post" }, }); const id = createResult.data!.item.id; // Create some revisions const revisionRepo = new RevisionRepository(db); await revisionRepo.create({ collection: "post", entryId: id, data: { title: "v1" } }); await revisionRepo.create({ collection: "post", entryId: id, data: { title: "v2" } }); const before = await revisionRepo.findByEntry("post", id); expect(before).toHaveLength(2); // Soft delete first, then permanent delete await repo.delete("post", id); await handleContentPermanentDelete(db, "post", id); // Revisions should be gone const after = await revisionRepo.findByEntry("post", id); expect(after).toHaveLength(0); }); it("should not hydrate SEO for collections without has_seo", async () => { // Create a collection without SEO await registry.createCollection({ slug: "snippet", label: "Snippets", labelSingular: "Snippet", }); await registry.createField("snippet", { slug: "code", label: "Code", type: "string", }); const createResult = await handleContentCreate(db, "snippet", { data: { code: "console.log('hi')" }, }); expect(createResult.success).toBe(true); expect(createResult.data!.item.seo).toBeUndefined(); const getResult = await handleContentGet(db, "snippet", createResult.data!.item.id); expect(getResult.success).toBe(true); expect(getResult.data!.item.seo).toBeUndefined(); }); it("should return validation error for SEO input on non-SEO collections", async () => { await registry.createCollection({ slug: "nav", label: "Nav Items", labelSingular: "Nav Item", }); await registry.createField("nav", { slug: "label", label: "Label", type: "string", }); // Providing seo input for a non-SEO collection should return a validation error const result = await handleContentCreate(db, "nav", { data: { label: "Home" }, seo: { title: "Should be rejected" }, }); expect(result.success).toBe(false); expect(result.error!.code).toBe("VALIDATION_ERROR"); expect(result.error!.message).toContain("does not have SEO enabled"); }); it("should return validation error for SEO input on update for non-SEO collections", async () => { await registry.createCollection({ slug: "nav", label: "Nav Items", labelSingular: "Nav Item", }); await registry.createField("nav", { slug: "label", label: "Label", type: "string", }); // Create without SEO (should succeed) const created = await handleContentCreate(db, "nav", { data: { label: "Home" }, }); expect(created.success).toBe(true); // Try to update with SEO (should fail) const updated = await handleContentUpdate(db, "nav", created.data!.item.id, { data: { label: "Updated" }, seo: { title: "Should be rejected" }, }); expect(updated.success).toBe(false); expect(updated.error!.code).toBe("VALIDATION_ERROR"); }); }); describe("getSeoMeta helper", () => { it("should generate meta from content SEO fields", () => { const content = createMockContent({ seo: { title: "SEO Title", description: "Meta desc", image: null, canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content, { siteTitle: "My Blog" }); expect(meta.title).toBe("SEO Title | My Blog"); expect(meta.description).toBe("Meta desc"); expect(meta.ogTitle).toBe("SEO Title"); }); it("should fall back to content data.title", () => { const content = createMockContent({ data: { title: "Content Title" }, seo: { title: null, description: null, image: null, canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content, { siteTitle: "My Blog" }); expect(meta.title).toBe("Content Title | My Blog"); expect(meta.ogTitle).toBe("Content Title"); }); it("should use custom title separator", () => { const content = createMockContent({ data: { title: "Page" }, seo: { title: null, description: null, image: null, canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content, { siteTitle: "Site", titleSeparator: " - ", }); expect(meta.title).toBe("Page - Site"); }); it("should build canonical from path when no explicit canonical", () => { const content = createMockContent({ seo: { title: null, description: null, image: null, canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content, { siteUrl: "https://example.com", path: "/posts/my-post", }); expect(meta.canonical).toBe("https://example.com/posts/my-post"); }); it("should strip trailing slash from siteUrl in canonical", () => { const content = createMockContent({ seo: { title: null, description: null, image: null, canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content, { siteUrl: "https://example.com/", path: "/posts/my-post", }); expect(meta.canonical).toBe("https://example.com/posts/my-post"); }); it("should prefix relative canonical paths with siteUrl", () => { const content = createMockContent({ seo: { title: null, description: null, image: null, canonical: "posts/my-post", noIndex: false, }, }); const meta = getSeoMeta(content, { siteUrl: "https://example.com", }); expect(meta.canonical).toBe("https://example.com/posts/my-post"); }); it("should handle path without leading slash in path-based canonical", () => { const content = createMockContent({ seo: { title: null, description: null, image: null, canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content, { siteUrl: "https://example.com", path: "posts/my-post", }); expect(meta.canonical).toBe("https://example.com/posts/my-post"); }); it("should prefer explicit canonical over path-based", () => { const content = createMockContent({ seo: { title: null, description: null, image: null, canonical: "https://other.com/original", noIndex: false, }, }); const meta = getSeoMeta(content, { siteUrl: "https://example.com", path: "/posts/my-post", }); expect(meta.canonical).toBe("https://other.com/original"); }); it("should set noindex robots when noIndex is true", () => { const content = createMockContent({ seo: { title: null, description: null, image: null, canonical: null, noIndex: true, }, }); const meta = getSeoMeta(content); expect(meta.robots).toBe("noindex, nofollow"); }); it("should return null robots when noIndex is false", () => { const content = createMockContent({ seo: { title: null, description: null, image: null, canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content); expect(meta.robots).toBeNull(); }); it("should build OG image URL from media reference", () => { const content = createMockContent({ seo: { title: null, description: null, image: "media-123", canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content, { siteUrl: "https://example.com", }); expect(meta.ogImage).toBe("https://example.com/_emdash/api/media/file/media-123"); }); it("should pass through absolute image URLs", () => { const content = createMockContent({ seo: { title: null, description: null, image: "https://cdn.example.com/image.jpg", canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content); expect(meta.ogImage).toBe("https://cdn.example.com/image.jpg"); }); it("should use defaultOgImage when content has no image", () => { const content = createMockContent({ seo: { title: null, description: null, image: null, canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content, { defaultOgImage: "https://example.com/default-og.jpg", }); expect(meta.ogImage).toBe("https://example.com/default-og.jpg"); }); it("should fall back to data.excerpt for description", () => { const content = createMockContent({ data: { title: "Post", excerpt: "A brief excerpt" }, seo: { title: null, description: null, image: null, canonical: null, noIndex: false, }, }); const meta = getSeoMeta(content); expect(meta.description).toBe("A brief excerpt"); expect(meta.ogDescription).toBe("A brief excerpt"); }); it("should handle content with no SEO field (non-SEO collection)", () => { const content = createMockContent({}); // Remove seo to simulate a non-SEO collection delete (content as Partial).seo; const meta = getSeoMeta(content, { siteTitle: "My Blog" }); // Should use fallback defaults expect(meta.title).toBe("Default Title | My Blog"); expect(meta.description).toBeNull(); expect(meta.robots).toBeNull(); }); }); describe("getContentSeo", () => { it("should return the content SEO object", () => { const seo = { title: "Title", description: "Desc", image: null, canonical: null, noIndex: false, }; const content = createMockContent({ seo }); expect(getContentSeo(content)).toEqual(seo); }); it("should return undefined for content without SEO", () => { const content = createMockContent({}); delete (content as Partial).seo; expect(getContentSeo(content)).toBeUndefined(); }); }); describe("handleSitemapData", () => { /** Flatten the per-collection response into a flat list with collection tag. */ function flatEntries(data: { collections: Array<{ collection: string; entries: Array<{ id: string; slug: string | null; updatedAt: string }>; }>; }) { return data.collections.flatMap((c) => c.entries.map((e) => ({ collection: c.collection, ...e })), ); } it("should return published content from SEO-enabled collections", async () => { await repo.create({ type: "post", slug: "published-post", data: { title: "Published" }, status: "published", }); await repo.create({ type: "post", slug: "draft-post", data: { title: "Draft" }, status: "draft", }); const result = await handleSitemapData(db); expect(result.success).toBe(true); const entries = flatEntries(result.data!); expect(entries).toHaveLength(1); expect(entries[0]!.collection).toBe("post"); expect(entries[0]!.slug).toBe("published-post"); }); it("should exclude noindex content from sitemap", async () => { await repo.create({ type: "post", slug: "visible-post", data: { title: "Visible" }, status: "published", }); const hidden = await repo.create({ type: "post", slug: "hidden-post", data: { title: "Hidden" }, status: "published", }); // Mark hidden post as noindex via SeoRepository await seoRepo.upsert("post", hidden.id, { noIndex: true }); const result = await handleSitemapData(db); expect(result.success).toBe(true); const entries = flatEntries(result.data!); expect(entries).toHaveLength(1); expect(entries[0]!.slug).toBe("visible-post"); }); it("should exclude deleted content from sitemap", async () => { const created = await repo.create({ type: "post", slug: "deleted-post", data: { title: "Deleted" }, status: "published", }); await repo.delete("post", created.id); const result = await handleSitemapData(db); expect(result.success).toBe(true); const entries = flatEntries(result.data!); expect(entries).toHaveLength(0); }); it("should include content from multiple SEO-enabled collections", async () => { await repo.create({ type: "post", slug: "my-post", data: { title: "A Post" }, status: "published", }); await repo.create({ type: "page", slug: "about", data: { title: "About Us" }, status: "published", }); const result = await handleSitemapData(db); expect(result.success).toBe(true); expect(result.data!.collections).toHaveLength(2); const entries = flatEntries(result.data!); const slugs = entries.map((e) => `${e.collection}/${e.slug}`); expect(slugs).toContain("post/my-post"); expect(slugs).toContain("page/about"); }); it("should exclude content from non-SEO collections", async () => { // Create a collection WITHOUT has_seo await registry.createCollection({ slug: "snippet", label: "Snippets", labelSingular: "Snippet", }); await registry.createField("snippet", { slug: "code", label: "Code", type: "string", }); await repo.create({ type: "post", slug: "my-post", data: { title: "A Post" }, status: "published", }); await repo.create({ type: "snippet", slug: "my-snippet", data: { code: "hello" }, status: "published", }); const result = await handleSitemapData(db); expect(result.success).toBe(true); expect(result.data!.collections).toHaveLength(1); expect(result.data!.collections[0]!.collection).toBe("post"); }); it("should return null slug and valid id when slug is null", async () => { const created = await repo.create({ type: "post", data: { title: "No Slug Post" }, status: "published", }); const result = await handleSitemapData(db); expect(result.success).toBe(true); const entries = flatEntries(result.data!); expect(entries[0]!.collection).toBe("post"); expect(entries[0]!.slug).toBeNull(); expect(entries[0]!.id).toBe(created.id); }); it("should include updatedAt and lastmod", async () => { await repo.create({ type: "post", slug: "test", data: { title: "Test" }, status: "published", }); const result = await handleSitemapData(db); const col = result.data!.collections[0]!; expect(col.lastmod).toBeDefined(); expect(new Date(col.lastmod).getTime()).not.toBeNaN(); expect(col.entries[0]!.updatedAt).toBeDefined(); expect(new Date(col.entries[0]!.updatedAt).getTime()).not.toBeNaN(); }); it("should include urlPattern from collection", async () => { await db .updateTable("_emdash_collections") .set({ url_pattern: "/blog/{slug}" }) .where("slug", "=", "post") .execute(); await repo.create({ type: "post", slug: "test", data: { title: "Test" }, status: "published", }); const result = await handleSitemapData(db); expect(result.data!.collections[0]!.urlPattern).toBe("/blog/{slug}"); }); it("should filter by collection when collectionSlug is provided", async () => { await repo.create({ type: "post", slug: "my-post", data: { title: "A Post" }, status: "published", }); await repo.create({ type: "page", slug: "about", data: { title: "About Us" }, status: "published", }); const result = await handleSitemapData(db, "post"); expect(result.success).toBe(true); expect(result.data!.collections).toHaveLength(1); expect(result.data!.collections[0]!.collection).toBe("post"); }); it("should return empty collections when no SEO-enabled collections exist", async () => { // Disable SEO on all collections await db.updateTable("_emdash_collections").set({ has_seo: 0 }).execute(); await repo.create({ type: "post", slug: "test", data: { title: "Test" }, status: "published", }); const result = await handleSitemapData(db); expect(result.success).toBe(true); expect(result.data!.collections).toEqual([]); }); it("should return empty collections for empty database", async () => { const result = await handleSitemapData(db); expect(result.success).toBe(true); expect(result.data!.collections).toEqual([]); }); }); describe("has_seo opt-in per collection", () => { it("should default has_seo to 0 for new collections", async () => { await registry.createCollection({ slug: "article", label: "Articles", labelSingular: "Article", }); const row = await db .selectFrom("_emdash_collections") .select("has_seo") .where("slug", "=", "article") .executeTakeFirst(); expect(row!.has_seo).toBe(0); }); it("should allow enabling has_seo on existing collections", async () => { await registry.createCollection({ slug: "article", label: "Articles", labelSingular: "Article", }); await db .updateTable("_emdash_collections") .set({ has_seo: 1 }) .where("slug", "=", "article") .execute(); const row = await db .selectFrom("_emdash_collections") .select("has_seo") .where("slug", "=", "article") .executeTakeFirst(); expect(row!.has_seo).toBe(1); }); }); }); /** * Helper to create a mock ContentItem for unit-level getSeoMeta tests. */ function createMockContent( overrides: Partial<{ data: Record; seo: { title: string | null; description: string | null; image: string | null; canonical: string | null; noIndex: boolean; }; }> = {}, ): ContentItem { return { id: "test-id", type: "post", slug: "test-post", status: "published", data: overrides.data ?? { title: "Default Title" }, authorId: null, createdAt: "2025-01-01T00:00:00Z", updatedAt: "2025-01-01T00:00:00Z", publishedAt: "2025-01-01T00:00:00Z", scheduledAt: null, liveRevisionId: null, draftRevisionId: null, version: 1, seo: overrides.seo ?? { title: null, description: null, image: null, canonical: null, noIndex: false, }, }; }