Files
emdash-patch-imageupload/packages/core/tests/integration/mcp/drafts.test.ts
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

646 lines
21 KiB
TypeScript

/**
* MCP draft / revision data round-trip tests.
*
* For collections that support revisions, `content_update` writes the
* new data into a draft revision rather than the content table columns
* (the columns hold the live/published values). `content_get` and
* `content_update` hydrate the response item with the draft revision's
* data when one exists, exposing the previously-published values as
* `liveData` alongside.
*
* The user-visible contract: "if I update X to Y, then read back, I see Y"
* — even for revision-supporting collections.
*
* Slug updates and `revision_restore` round-trips share the same response
* shape, so they're tested here too.
*/
import { Role } from "@emdash-cms/auth";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Database } from "../../../src/database/types.js";
import { SchemaRegistry } from "../../../src/schema/registry.js";
import {
connectMcpHarness,
extractJson,
extractText,
type McpHarness,
} from "../../utils/mcp-runtime.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
const ADMIN_ID = "user_admin";
interface ItemEnvelope {
item: {
id: string;
slug: string | null;
status: string;
liveRevisionId: string | null;
draftRevisionId: string | null;
version: number;
publishedAt: string | null;
updatedAt: string;
// Field columns flattened onto item — title is what we care about
title?: unknown;
// Some response variants nest the typed values under `data`
data?: { title?: unknown };
};
_rev?: string;
}
/** Read whatever the response thinks the current title is, regardless of shape. */
function readTitle(item: ItemEnvelope["item"]): unknown {
if (item.data && typeof item.data === "object" && "title" in item.data) {
return item.data.title;
}
return item.title;
}
describe("MCP drafts — content_get and content_update round-trip (bug #2)", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabase();
const registry = new SchemaRegistry(db);
// Collection that supports revisions — this is the surface area
// where the bug surfaces. Without "revisions" in supports, updates
// write directly to content columns and the round-trip is trivially
// correct.
await registry.createCollection({
slug: "post",
label: "Posts",
labelSingular: "Post",
supports: ["drafts", "revisions"],
});
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
await registry.createField("post", { slug: "body", label: "Body", type: "text" });
// Collection without revision support — for contrast/regression
await registry.createCollection({
slug: "page",
label: "Pages",
labelSingular: "Page",
supports: [],
});
await registry.createField("page", { slug: "title", label: "Title", type: "string" });
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
// ----- Core round-trip: update should be visible on get -----
describe("revision-supporting collection", () => {
it("content_update response data reflects the new title", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "Original" } },
});
const createdItem = extractJson<ItemEnvelope>(created);
const updated = await harness.client.callTool({
name: "content_update",
arguments: {
collection: "post",
id: createdItem.item.id,
data: { title: "Updated" },
},
});
expect(updated.isError, extractText(updated)).toBeFalsy();
const updatedItem = extractJson<ItemEnvelope>(updated);
// Bug #2: today this returns "Original". After fix: "Updated".
expect(readTitle(updatedItem.item)).toBe("Updated");
});
it("content_get returns the latest draft data after update", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "Original" } },
});
const createdItem = extractJson<ItemEnvelope>(created);
await harness.client.callTool({
name: "content_update",
arguments: {
collection: "post",
id: createdItem.item.id,
data: { title: "Updated via draft" },
},
});
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id: createdItem.item.id },
});
const gotItem = extractJson<ItemEnvelope>(got);
expect(readTitle(gotItem.item)).toBe("Updated via draft");
});
it("multiple sequential updates all reflect on read", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "v1" } },
});
const id = extractJson<ItemEnvelope>(created).item.id;
for (const title of ["v2", "v3", "v4"]) {
await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title } },
});
}
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
});
expect(readTitle(extractJson<ItemEnvelope>(got).item)).toBe("v4");
});
it("publishing a draft makes its data the new live data on read", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "Original" } },
});
const id = extractJson<ItemEnvelope>(created).item.id;
// Publish initial as live
await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id },
});
// Update creates a draft revision
await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Draft change" } },
});
// Publish promotes draft to live
await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id },
});
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
});
expect(readTitle(extractJson<ItemEnvelope>(got).item)).toBe("Draft change");
});
it("partial updates merge with current draft (only title changes, body preserved)", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "T1", body: "B1" } },
});
const id = extractJson<ItemEnvelope>(created).item.id;
await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "T2" } },
});
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
});
const item = extractJson<ItemEnvelope>(got).item;
expect(readTitle(item)).toBe("T2");
// Read body the same way
const body =
item.data && typeof item.data === "object" && "body" in item.data
? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- shape narrowed by 'in' check
(item.data as { body?: unknown }).body
: (item as Record<string, unknown>).body;
expect(body).toBe("B1");
});
});
// ----- content_compare must still expose both sides -----
describe("content_compare", () => {
it("returns both live and draft data when a draft exists", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "Original" } },
});
const id = extractJson<ItemEnvelope>(created).item.id;
// Publish, then update to create a draft on top of live
await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id },
});
await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Drafted" } },
});
const compare = await harness.client.callTool({
name: "content_compare",
arguments: { collection: "post", id },
});
expect(compare.isError, extractText(compare)).toBeFalsy();
const result = extractJson<{
live: { title?: unknown; data?: { title?: unknown } } | null;
draft: { title?: unknown; data?: { title?: unknown } } | null;
hasChanges?: boolean;
}>(compare);
expect(result.live).not.toBeNull();
expect(result.draft).not.toBeNull();
const liveTitle = result.live?.data?.title ?? result.live?.title;
const draftTitle = result.draft?.data?.title ?? result.draft?.title;
expect(liveTitle).toBe("Original");
expect(draftTitle).toBe("Drafted");
});
});
// ----- content_discard_draft -----
describe("content_discard_draft", () => {
it("after discard, content_get returns published live data", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "Live title" } },
});
const id = extractJson<ItemEnvelope>(created).item.id;
await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id },
});
await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "Draft title" } },
});
await harness.client.callTool({
name: "content_discard_draft",
arguments: { collection: "post", id },
});
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
});
expect(readTitle(extractJson<ItemEnvelope>(got).item)).toBe("Live title");
});
});
// ----- regression guard: non-revision collection still works -----
describe("non-revision-supporting collection (regression guard)", () => {
it("content_update on collection without revisions support reflects on read", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "page", data: { title: "Page A" } },
});
const id = extractJson<ItemEnvelope>(created).item.id;
await harness.client.callTool({
name: "content_update",
arguments: { collection: "page", id, data: { title: "Page A Updated" } },
});
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "page", id },
});
expect(readTitle(extractJson<ItemEnvelope>(got).item)).toBe("Page A Updated");
});
});
});
describe("MCP drafts — slug updates (bug #9)", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabase();
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "post",
label: "Posts",
supports: ["drafts", "revisions"],
});
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
it("content_update with a new slug actually changes the slug visible on read", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "Original" }, slug: "original-slug" },
});
const id = extractJson<ItemEnvelope>(created).item.id;
await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, slug: "new-slug" },
});
// After publish, slug change should be visible.
await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id },
});
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
});
expect(extractJson<ItemEnvelope>(got).item.slug).toBe("new-slug");
});
it("content_get by new slug works after slug update + publish", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "T" }, slug: "old" },
});
const id = extractJson<ItemEnvelope>(created).item.id;
await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, slug: "new" },
});
await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id },
});
const gotByNew = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id: "new" },
});
expect(gotByNew.isError, extractText(gotByNew)).toBeFalsy();
expect(extractJson<ItemEnvelope>(gotByNew).item.id).toBe(id);
});
});
describe("MCP drafts — revision_restore semantics (bug #17)", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabase();
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "post",
label: "Posts",
supports: ["drafts", "revisions"],
});
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
/**
* Bug #17 repro from MCP_BUGS.md: live=v1, draft=v2 (unpublished),
* restore v1. Per the tool contract ("Replaces the current draft..."),
* the live row must remain v1 and the draft must become v1. The
* pre-fix behavior wrote v1 onto the live row and left the draft
* pointing at v2.
*/
it("restore replaces the current draft and leaves the live row alone", async () => {
// Create v1, publish so live = v1.
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "v1" } },
});
const id = extractJson<ItemEnvelope>(created).item.id;
await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id },
});
// Find the v1 revision id BEFORE updating to v2 — once we update
// without publishing, v1 is still in revision history.
const revsBeforeUpdate = await harness.client.callTool({
name: "revision_list",
arguments: { collection: "post", id },
});
const v1Rev = extractJson<{
items: Array<{ id: string; data?: { title?: unknown } }>;
}>(revsBeforeUpdate).items.find((r) => r.data?.title === "v1");
expect(v1Rev, "v1 revision must exist after publish").toBeTruthy();
// Update to v2 (creates a draft revision; live remains v1).
await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "v2" } },
});
// Sanity: before restore, get returns v2 (the draft) and liveData=v1.
const preRestore = extractJson<ItemEnvelope>(
await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
}),
).item as ItemEnvelope["item"] & { liveData?: { title?: unknown } };
expect(readTitle(preRestore)).toBe("v2");
expect(preRestore.liveData?.title).toBe("v1");
const v2DraftId = preRestore.draftRevisionId;
expect(v2DraftId, "v2 draft revision id must be set").toBeTruthy();
// Restore v1.
const restored = await harness.client.callTool({
name: "revision_restore",
arguments: { collection: "post", id, revisionId: v1Rev!.id },
});
expect(restored.isError, extractText(restored)).toBeFalsy();
// The restore response itself must show the new draft state (v1),
// not stale data. Same shape as the bug-#2 fix for content_update.
const restoredItem = extractJson<ItemEnvelope>(restored).item;
expect(readTitle(restoredItem)).toBe("v1");
// And a follow-up content_get must agree.
const postRestore = extractJson<ItemEnvelope>(
await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
}),
).item;
expect(readTitle(postRestore)).toBe("v1");
// The live row must still hold v1 (unchanged from the original
// publish — restore must NOT overwrite live).
const dbRow = (await db
.selectFrom("ec_post" as never)
.select(["title", "live_revision_id", "draft_revision_id"] as never)
.where("id" as never, "=", id)
.executeTakeFirst()) as
| {
title: unknown;
live_revision_id: string | null;
draft_revision_id: string | null;
}
| undefined;
expect(dbRow?.title).toBe("v1");
// A new draft revision was created. It is distinct from BOTH the
// original v1 revision id (we created a new revision row carrying
// v1's data — we don't reuse history rows) AND the v2 draft id
// (the v2 draft was abandoned). This is the strongest differentia
// from the pre-fix behavior, which left v2's draft pointer
// in place.
expect(dbRow?.draft_revision_id).toBeTruthy();
expect(dbRow?.draft_revision_id).not.toBe(v1Rev!.id);
expect(dbRow?.draft_revision_id).not.toBe(v2DraftId);
});
/**
* Companion case: restoring while no draft exists should still create
* a new draft (rather than no-op or overwrite live).
*/
it("restore creates a new draft when none exists", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "v1" } },
});
const id = extractJson<ItemEnvelope>(created).item.id;
await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id },
});
// Update + publish v2 so there's no live draft.
await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "v2" } },
});
await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id },
});
const revs = extractJson<{
items: Array<{ id: string; data?: { title?: unknown } }>;
}>(
await harness.client.callTool({
name: "revision_list",
arguments: { collection: "post", id },
}),
);
const v1Rev = revs.items.find((r) => r.data?.title === "v1");
expect(v1Rev).toBeTruthy();
// Now live = v2, no draft. Restore v1.
const restored = await harness.client.callTool({
name: "revision_restore",
arguments: { collection: "post", id, revisionId: v1Rev!.id },
});
expect(restored.isError, extractText(restored)).toBeFalsy();
expect(readTitle(extractJson<ItemEnvelope>(restored).item)).toBe("v1");
// Live row should still hold v2; a new draft now exists pointing
// at v1.
const dbRow = (await db
.selectFrom("ec_post" as never)
.select(["title", "draft_revision_id"] as never)
.where("id" as never, "=", id)
.executeTakeFirst()) as
| {
title: unknown;
draft_revision_id: string | null;
}
| undefined;
expect(dbRow?.title).toBe("v2");
expect(dbRow?.draft_revision_id).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// F13: liveData carries the published values when a draft revision exists.
// When no draft exists, liveData is undefined.
// ---------------------------------------------------------------------------
describe("MCP drafts — liveData hydration (F13)", () => {
let db: Kysely<Database>;
let harness: McpHarness;
beforeEach(async () => {
db = await setupTestDatabase();
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "post",
label: "Posts",
supports: ["drafts", "revisions"],
});
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
});
afterEach(async () => {
if (harness) await harness.cleanup();
await teardownTestDatabase(db);
});
it("liveData is undefined when there is no draft revision", async () => {
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "First" } },
});
const id = extractJson<{ item: { id: string } }>(created).item.id;
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
});
const item = extractJson<{
item: { data: { title: string }; liveData?: { title?: string } };
}>(got).item;
expect(item.data.title).toBe("First");
expect(item.liveData).toBeUndefined();
});
it("liveData carries the published values when a draft revision exists", async () => {
// Create + publish, so the live value is "published title".
const created = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "published title" } },
});
const id = extractJson<{ item: { id: string } }>(created).item.id;
await harness.client.callTool({
name: "content_publish",
arguments: { collection: "post", id },
});
// Update writes a draft revision (data column stays at "published title").
await harness.client.callTool({
name: "content_update",
arguments: { collection: "post", id, data: { title: "draft title" } },
});
// Read back: data reflects the draft, liveData carries the published value.
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
});
const item = extractJson<{
item: { data: { title: string }; liveData?: { title?: string } };
}>(got).item;
expect(item.data.title).toBe("draft title");
expect(item.liveData?.title).toBe("published title");
});
});