Files
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

1145 lines
31 KiB
TypeScript

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<Database>;
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<ContentItem>).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<ContentItem>).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<string, unknown>;
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,
},
};
}