/** * MCP content tools — coverage for the remaining tools and edges. * * Covers: * - content_duplicate * - content_permanent_delete * - content_translations + locale handling on create/get * - _rev optimistic concurrency (happy + race) * - Soft-delete visibility (content_get / content_list filtering) * - Edit-while-trashed * - Idempotency (publish twice, unpublish-on-draft, schedule + publish) */ import { Role } from "@emdash-cms/auth"; import { sql, type Kysely } from "kysely"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ContentRepository } from "../../../src/database/repositories/content.js"; import type { Database } from "../../../src/database/types.js"; import { connectMcpHarness, extractJson, extractText, type McpHarness, } from "../../utils/mcp-runtime.js"; import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; const ADMIN_ID = "user_admin"; // --------------------------------------------------------------------------- // content_duplicate // --------------------------------------------------------------------------- describe("content_duplicate", () => { let db: Kysely; let harness: McpHarness; beforeEach(async () => { db = await setupTestDatabaseWithCollections(); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); }); afterEach(async () => { if (harness) await harness.cleanup(); await teardownTestDatabase(db); }); it("creates a copy with new id and slug", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Original" }, slug: "original" }, }); const original = extractJson<{ item: { id: string; slug: string } }>(created).item; const dup = await harness.client.callTool({ name: "content_duplicate", arguments: { collection: "post", id: original.id }, }); expect(dup.isError, extractText(dup)).toBeFalsy(); const copy = extractJson<{ item: { id: string; slug: string; status: string } }>(dup).item; expect(copy.id).not.toBe(original.id); expect(copy.slug).not.toBe(original.slug); // Created as draft per tool description expect(copy.status).toBe("draft"); }); it("rejects duplicating a missing item", async () => { const result = await harness.client.callTool({ name: "content_duplicate", arguments: { collection: "post", id: "01NEVER" }, }); expect(result.isError).toBe(true); expect(extractText(result)).toMatch(/\bNOT_FOUND\b|\bnot found\b/i); }); it("rejects duplicating in non-existent collection", async () => { const result = await harness.client.callTool({ name: "content_duplicate", arguments: { collection: "ghost", id: "01NEVER" }, }); expect(result.isError).toBe(true); }); it("requires CONTRIBUTOR or higher", async () => { await harness.cleanup(); harness = await connectMcpHarness({ db, userId: "user_subscriber", userRole: Role.SUBSCRIBER, }); const result = await harness.client.callTool({ name: "content_duplicate", arguments: { collection: "post", id: "01ANY" }, }); expect(result.isError).toBe(true); }); }); // --------------------------------------------------------------------------- // content_permanent_delete // --------------------------------------------------------------------------- describe("content_permanent_delete", () => { let db: Kysely; let harness: McpHarness; beforeEach(async () => { db = await setupTestDatabaseWithCollections(); }); afterEach(async () => { if (harness) await harness.cleanup(); await teardownTestDatabase(db); }); async function seedTrashedItem(): Promise { const repo = new ContentRepository(db); const item = await repo.create({ type: "post", data: { title: "T" }, slug: `t-${Math.random().toString(36).slice(2, 6)}`, status: "draft", authorId: ADMIN_ID, }); await repo.delete("post", item.id); return item.id; } it("permanently deletes a trashed item (ADMIN)", async () => { const id = await seedTrashedItem(); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); const result = await harness.client.callTool({ name: "content_permanent_delete", arguments: { collection: "post", id }, }); expect(result.isError, extractText(result)).toBeFalsy(); // Verify it's gone — not even in trash const got = await harness.client.callTool({ name: "content_get", arguments: { collection: "post", id }, }); expect(got.isError).toBe(true); }); it("EDITOR cannot permanent-delete (ADMIN-only)", async () => { const id = await seedTrashedItem(); harness = await connectMcpHarness({ db, userId: "user_editor", userRole: Role.EDITOR }); const result = await harness.client.callTool({ name: "content_permanent_delete", arguments: { collection: "post", id }, }); expect(result.isError).toBe(true); }); it("returns NOT_FOUND for missing id", async () => { harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); const result = await harness.client.callTool({ name: "content_permanent_delete", arguments: { collection: "post", id: "01NEVEREXISTED" }, }); expect(result.isError).toBe(true); expect(extractText(result)).toMatch(/\bNOT_FOUND\b|\bnot found\b/i); expect(extractText(result)).toContain("01NEVEREXISTED"); }); }); // --------------------------------------------------------------------------- // content_translations + locale handling // --------------------------------------------------------------------------- describe("content_translations + locale", () => { let db: Kysely; let harness: McpHarness; beforeEach(async () => { db = await setupTestDatabaseWithCollections(); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); }); afterEach(async () => { if (harness) await harness.cleanup(); await teardownTestDatabase(db); }); it("creates a translation linked via translationOf", async () => { const en = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Hello" }, locale: "en" }, }); const enId = extractJson<{ item: { id: string } }>(en).item.id; const fr = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Bonjour" }, locale: "fr", translationOf: enId, }, }); expect(fr.isError, extractText(fr)).toBeFalsy(); const trans = await harness.client.callTool({ name: "content_translations", arguments: { collection: "post", id: enId }, }); expect(trans.isError, extractText(trans)).toBeFalsy(); const data = extractJson<{ translations: Array<{ id: string; locale: string }>; }>(trans); const locales = data.translations.map((t) => t.locale).toSorted(); expect(locales).toEqual(["en", "fr"]); }); it("returns single-locale translations array for content with no other translations", async () => { const en = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Standalone" }, locale: "en" }, }); const id = extractJson<{ item: { id: string } }>(en).item.id; const result = await harness.client.callTool({ name: "content_translations", arguments: { collection: "post", id }, }); expect(result.isError, extractText(result)).toBeFalsy(); const data = extractJson<{ translations: unknown[] }>(result); expect(data.translations.length).toBeGreaterThanOrEqual(1); }); it("content_get with locale param resolves slug per-locale", async () => { await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "EN" }, slug: "shared", locale: "en" }, }); await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "FR" }, slug: "shared", locale: "fr" }, }); const en = await harness.client.callTool({ name: "content_get", arguments: { collection: "post", id: "shared", locale: "en" }, }); expect(en.isError, extractText(en)).toBeFalsy(); const fr = await harness.client.callTool({ name: "content_get", arguments: { collection: "post", id: "shared", locale: "fr" }, }); expect(fr.isError, extractText(fr)).toBeFalsy(); const enItem = extractJson<{ item: { locale: string; data?: { title?: unknown }; title?: unknown }; }>(en).item; const frItem = extractJson<{ item: { locale: string; data?: { title?: unknown }; title?: unknown }; }>(fr).item; const enTitle = enItem.data?.title ?? enItem.title; const frTitle = frItem.data?.title ?? frItem.title; expect(enTitle).toBe("EN"); expect(frTitle).toBe("FR"); }); it("rejects translationOf pointing to a non-existent item", async () => { const result = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Orphan" }, locale: "fr", translationOf: "01NEVEREXISTED", }, }); expect(result.isError).toBe(true); }); }); // --------------------------------------------------------------------------- // _rev optimistic concurrency // --------------------------------------------------------------------------- describe("_rev optimistic concurrency", () => { let db: Kysely; let harness: McpHarness; beforeEach(async () => { db = await setupTestDatabaseWithCollections(); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); }); afterEach(async () => { if (harness) await harness.cleanup(); await teardownTestDatabase(db); }); it("content_get returns a _rev token", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "T" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; const got = await harness.client.callTool({ name: "content_get", arguments: { collection: "post", id }, }); const data = extractJson<{ item: { id: string }; _rev?: string }>(got); expect(data._rev).toBeTruthy(); }); it("content_update with current _rev succeeds", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Original" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; const got = await harness.client.callTool({ name: "content_get", arguments: { collection: "post", id }, }); const rev = extractJson<{ _rev: string }>(got)._rev; const updated = await harness.client.callTool({ name: "content_update", arguments: { collection: "post", id, data: { title: "Updated" }, _rev: rev }, }); expect(updated.isError, extractText(updated)).toBeFalsy(); }); it("content_update with stale _rev returns CONFLICT-style error", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Original" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; const got = await harness.client.callTool({ name: "content_get", arguments: { collection: "post", id }, }); const oldRev = extractJson<{ _rev: string }>(got)._rev; // First update: succeeds and bumps the rev await harness.client.callTool({ name: "content_update", arguments: { collection: "post", id, data: { title: "Update 1" }, _rev: oldRev }, }); // Second update with stale rev: should conflict const result = await harness.client.callTool({ name: "content_update", arguments: { collection: "post", id, data: { title: "Update 2" }, _rev: oldRev }, }); expect(result.isError).toBe(true); expect(extractText(result)).toMatch(/conflict|stale|outdated|modified|rev/i); }); it("content_update without _rev still succeeds (opt-in concurrency)", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "T" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; const result = await harness.client.callTool({ name: "content_update", arguments: { collection: "post", id, data: { title: "U" } }, }); expect(result.isError, extractText(result)).toBeFalsy(); }); }); // --------------------------------------------------------------------------- // Soft-delete visibility // --------------------------------------------------------------------------- describe("soft-delete visibility", () => { let db: Kysely; let harness: McpHarness; beforeEach(async () => { db = await setupTestDatabaseWithCollections(); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); }); afterEach(async () => { if (harness) await harness.cleanup(); await teardownTestDatabase(db); }); it("content_get on a trashed item returns NOT_FOUND (not the item)", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "T" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; await harness.client.callTool({ name: "content_delete", arguments: { collection: "post", id }, }); const got = await harness.client.callTool({ name: "content_get", arguments: { collection: "post", id }, }); expect(got.isError).toBe(true); expect(extractText(got)).toMatch(/\bNOT_FOUND\b|\bnot found\b/i); }); it("content_list does NOT include trashed items by default", async () => { const a = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Live" } }, }); const b = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Trashed" } }, }); const trashedId = extractJson<{ item: { id: string } }>(b).item.id; await harness.client.callTool({ name: "content_delete", arguments: { collection: "post", id: trashedId }, }); const list = await harness.client.callTool({ name: "content_list", arguments: { collection: "post" }, }); const ids = extractJson<{ items: Array<{ id: string }> }>(list).items.map((i) => i.id); expect(ids).not.toContain(trashedId); expect(ids).toContain(extractJson<{ item: { id: string } }>(a).item.id); }); it("content_list_trashed returns only trashed items", async () => { await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Live" } }, }); const b = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Trashed" } }, }); await harness.client.callTool({ name: "content_delete", arguments: { collection: "post", id: extractJson<{ item: { id: string } }>(b).item.id, }, }); const trashed = await harness.client.callTool({ name: "content_list_trashed", arguments: { collection: "post" }, }); const items = extractJson<{ items: Array<{ id: string }> }>(trashed).items; expect(items).toHaveLength(1); expect(items[0]?.id).toBe(extractJson<{ item: { id: string } }>(b).item.id); }); }); describe("edit-while-trashed", () => { let db: Kysely; let harness: McpHarness; beforeEach(async () => { db = await setupTestDatabaseWithCollections(); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); }); afterEach(async () => { if (harness) await harness.cleanup(); await teardownTestDatabase(db); }); it("content_update on a trashed item is rejected (item not visible)", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "T" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; await harness.client.callTool({ name: "content_delete", arguments: { collection: "post", id }, }); const updated = await harness.client.callTool({ name: "content_update", arguments: { collection: "post", id, data: { title: "Edit while dead" } }, }); expect(updated.isError).toBe(true); expect(extractText(updated)).toMatch(/\bNOT_FOUND\b|\bnot found\b|trash/i); }); it("content_publish on a trashed item is rejected", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "T" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; await harness.client.callTool({ name: "content_delete", arguments: { collection: "post", id }, }); const result = await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id }, }); expect(result.isError).toBe(true); }); }); // --------------------------------------------------------------------------- // Idempotency // --------------------------------------------------------------------------- describe("idempotency", () => { let db: Kysely; let harness: McpHarness; beforeEach(async () => { db = await setupTestDatabaseWithCollections(); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); }); afterEach(async () => { if (harness) await harness.cleanup(); await teardownTestDatabase(db); }); it("publish twice is idempotent: second call succeeds, status stays published, publishedAt is preserved", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "T" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; const first = await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id }, }); expect(first.isError, extractText(first)).toBeFalsy(); const firstItem = extractJson<{ item: { status: string; publishedAt: string | null }; }>(first).item; expect(firstItem.status).toBe("published"); expect(firstItem.publishedAt).toBeTruthy(); // Pin publishedAt to a known fixed value so the comparison can't be // satisfied by coincidence (two publishes within the same ms would // produce identical ISO strings even on a regression that drops the // COALESCE preservation). const KNOWN = "2020-01-01T00:00:00.000Z"; await sql`UPDATE ec_post SET published_at = ${KNOWN} WHERE id = ${id}`.execute(db); const second = await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id }, }); // Contract: publish is idempotent. Second call succeeds, status // remains published, and publishedAt is preserved (the repository // uses COALESCE so the existing timestamp survives a re-publish). expect(second.isError, extractText(second)).toBeFalsy(); const secondItem = extractJson<{ item: { status: string; publishedAt: string | null }; }>(second).item; expect(secondItem.status).toBe("published"); expect(secondItem.publishedAt).toBe(KNOWN); }); it("unpublish on a draft (already unpublished) is idempotent: status stays draft", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "T" } }, }); const createdItem = extractJson<{ item: { id: string; version: number }; }>(created).item; const id = createdItem.id; const versionBefore = createdItem.version; // Item is born as draft. Contract: unpublish is idempotent — succeeds // and the item stays draft. const result = await harness.client.callTool({ name: "content_unpublish", arguments: { collection: "post", id }, }); expect(result.isError, extractText(result)).toBeFalsy(); const item = extractJson<{ item: { status: string; publishedAt: string | null; version: number }; }>(result).item; expect(item.status).toBe("draft"); expect(item.publishedAt).toBeNull(); // Idempotent: nothing meaningful changed. A regression that always // bumps the version or creates a phantom revision would surface here. // (updated_at can tick because the UPDATE re-runs; version is the // stricter invariant.) expect(item.version).toBe(versionBefore); }); it("schedule then publish: schedule is preserved or cleared cleanly", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "T" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; const future = new Date(Date.now() + 3600_000).toISOString(); await harness.client.callTool({ name: "content_schedule", arguments: { collection: "post", id, scheduledAt: future }, }); const publish = await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id }, }); expect(publish.isError, extractText(publish)).toBeFalsy(); const got = await harness.client.callTool({ name: "content_get", arguments: { collection: "post", id }, }); const item = extractJson<{ item: { status: string; scheduledAt: string | null }; }>(got).item; expect(item.status).toBe("published"); // Once published, the future schedule is moot — should be cleared. expect(item.scheduledAt).toBeNull(); }); it("delete twice is safe — second call returns NOT_FOUND, not a crash", async () => { const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "T" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; await harness.client.callTool({ name: "content_delete", arguments: { collection: "post", id }, }); const second = await harness.client.callTool({ name: "content_delete", arguments: { collection: "post", id }, }); expect(second.isError).toBe(true); expect(extractText(second)).toMatch(/\bNOT_FOUND\b|\bnot found\b/i); }); }); // --------------------------------------------------------------------------- // content_unschedule gap (no MCP tool for this, only on runtime) // --------------------------------------------------------------------------- describe("content_unschedule gap", () => { let db: Kysely; let harness: McpHarness; beforeEach(async () => { db = await setupTestDatabaseWithCollections(); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); }); afterEach(async () => { if (harness) await harness.cleanup(); await teardownTestDatabase(db); }); it("MCP exposes content_unschedule", async () => { const tools = await harness.client.listTools(); const names = tools.tools.map((t) => t.name); expect(names).toContain("content_unschedule"); }); it("schedule + unschedule clears scheduledAt and re-publish still works (F12)", async () => { // Create a draft item. const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Scheduled post" } }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; // Schedule for the near future. const future = new Date(Date.now() + 60_000).toISOString(); const schedule = await harness.client.callTool({ name: "content_schedule", arguments: { collection: "post", id, scheduledAt: future }, }); expect(schedule.isError, extractText(schedule)).toBeFalsy(); // Sanity: scheduledAt is set. const afterSchedule = await harness.client.callTool({ name: "content_get", arguments: { collection: "post", id }, }); const scheduled = extractJson<{ item: { scheduledAt: string | null; status: string } }>( afterSchedule, ).item; expect(scheduled.scheduledAt).toBeTruthy(); // Unschedule. const unschedule = await harness.client.callTool({ name: "content_unschedule", arguments: { collection: "post", id }, }); expect(unschedule.isError, extractText(unschedule)).toBeFalsy(); // scheduledAt is now null. const afterUnschedule = await harness.client.callTool({ name: "content_get", arguments: { collection: "post", id }, }); const cleared = extractJson<{ item: { scheduledAt: string | null } }>(afterUnschedule).item; expect(cleared.scheduledAt).toBeNull(); // Re-publish still works after unschedule. const republish = await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id }, }); expect(republish.isError, extractText(republish)).toBeFalsy(); const final = extractJson<{ item: { status: string } }>(republish).item; expect(final.status).toBe("published"); }); });