Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
646 lines
21 KiB
TypeScript
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");
|
|
});
|
|
});
|