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

736 lines
24 KiB
TypeScript

/**
* 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<Database>;
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<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
async function seedTrashedItem(): Promise<string> {
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<Database>;
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<Database>;
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<Database>;
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<Database>;
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<Database>;
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<Database>;
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");
});
});