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
This commit is contained in:
712
packages/core/tests/integration/mcp/schema.test.ts
Normal file
712
packages/core/tests/integration/mcp/schema.test.ts
Normal file
@@ -0,0 +1,712 @@
|
||||
/**
|
||||
* MCP schema tools — comprehensive integration tests.
|
||||
*
|
||||
* Covers every branch of:
|
||||
* - schema_list_collections
|
||||
* - schema_get_collection
|
||||
* - schema_create_collection (also bug #11 — supports default)
|
||||
* - schema_delete_collection
|
||||
* - schema_create_field
|
||||
* - schema_delete_field
|
||||
*
|
||||
* For each tool: happy path, edge cases (empty, missing, duplicate,
|
||||
* reserved names), permission gates, error envelope (bug #3 — currently
|
||||
* generic). Where the omnibus fix is expected to introduce structured
|
||||
* errors, the assertions name the specific failure mode so they fail
|
||||
* usefully today.
|
||||
*/
|
||||
|
||||
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";
|
||||
const EDITOR_ID = "user_editor";
|
||||
const AUTHOR_ID = "user_author";
|
||||
|
||||
const VALIDATION_ERROR = /validation|invalid|reserved|pattern|format|required/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schema_list_collections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("schema_list_collections", () => {
|
||||
let db: Kysely<Database>;
|
||||
let harness: McpHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.cleanup();
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("returns empty list when no collections exist", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_list_collections",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
const { items } = extractJson<{ items: unknown[] }>(result);
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists multiple collections in stable order", async () => {
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({ slug: "post", label: "Posts" });
|
||||
await registry.createCollection({ slug: "page", label: "Pages" });
|
||||
await registry.createCollection({ slug: "product", label: "Products" });
|
||||
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_list_collections",
|
||||
arguments: {},
|
||||
});
|
||||
const { items } = extractJson<{ items: Array<{ slug: string }> }>(result);
|
||||
const slugs = items.map((c) => c.slug).toSorted();
|
||||
expect(slugs).toEqual(["page", "post", "product"]);
|
||||
});
|
||||
|
||||
it("requires EDITOR or higher", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: AUTHOR_ID, userRole: Role.AUTHOR });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_list_collections",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("EDITOR can list collections", async () => {
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({ slug: "post", label: "Posts" });
|
||||
|
||||
harness = await connectMcpHarness({ db, userId: EDITOR_ID, userRole: Role.EDITOR });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_list_collections",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schema_get_collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("schema_get_collection", () => {
|
||||
let db: Kysely<Database>;
|
||||
let harness: McpHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
const registry = new SchemaRegistry(db);
|
||||
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" });
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.cleanup();
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("returns collection with its fields", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_get_collection",
|
||||
arguments: { slug: "post" },
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
const collection = extractJson<{
|
||||
slug: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
supports: string[];
|
||||
fields: Array<{ slug: string; type: string }>;
|
||||
}>(result);
|
||||
expect(collection.slug).toBe("post");
|
||||
expect(collection.label).toBe("Posts");
|
||||
expect(collection.labelSingular).toBe("Post");
|
||||
expect(collection.supports).toEqual(expect.arrayContaining(["drafts", "revisions"]));
|
||||
const fieldSlugs = collection.fields.map((f) => f.slug).toSorted();
|
||||
expect(fieldSlugs).toEqual(["body", "title"]);
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND-style error for missing collection", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_get_collection",
|
||||
arguments: { slug: "nonexistent" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(extractText(result)).toMatch(/COLLECTION_NOT_FOUND|\bnot found\b/i);
|
||||
expect(extractText(result)).toContain("nonexistent");
|
||||
});
|
||||
|
||||
it("requires EDITOR or higher", async () => {
|
||||
await harness.cleanup();
|
||||
harness = await connectMcpHarness({ db, userId: AUTHOR_ID, userRole: Role.AUTHOR });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_get_collection",
|
||||
arguments: { slug: "post" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schema_create_collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("schema_create_collection", () => {
|
||||
let db: Kysely<Database>;
|
||||
let harness: McpHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.cleanup();
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("creates a collection with minimal arguments", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "article", label: "Articles" },
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
const created = extractJson<{ slug: string; label: string }>(result);
|
||||
expect(created.slug).toBe("article");
|
||||
expect(created.label).toBe("Articles");
|
||||
});
|
||||
|
||||
it("creates with all optional fields", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: {
|
||||
slug: "story",
|
||||
label: "Stories",
|
||||
labelSingular: "Story",
|
||||
description: "A story collection",
|
||||
icon: "book",
|
||||
supports: ["drafts", "revisions", "scheduling"],
|
||||
},
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
const created = extractJson<{
|
||||
slug: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports: string[];
|
||||
}>(result);
|
||||
expect(created.labelSingular).toBe("Story");
|
||||
expect(created.description).toBe("A story collection");
|
||||
expect(created.icon).toBe("book");
|
||||
expect(created.supports.toSorted()).toEqual(["drafts", "revisions", "scheduling"].toSorted());
|
||||
});
|
||||
|
||||
it("rejects slug that doesn't match the collection slug pattern", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "Has-Caps", label: "Bad" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects slug starting with a number", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "1posts", label: "Posts" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty slug", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "", label: "Empty" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate slug", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "post", label: "Posts" },
|
||||
});
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "post", label: "Posts Two" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(extractText(result)).toMatch(/exist|duplicate|conflict|already/i);
|
||||
});
|
||||
|
||||
it("rejects reserved slug like 'media' or 'options'", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
// `options` is a reserved table name
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "options", label: "Options" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("requires ADMIN role (EDITOR is blocked)", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: EDITOR_ID, userRole: Role.EDITOR });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "blocked", label: "Blocked" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(extractText(result)).toMatch(/permission|insufficient/i);
|
||||
});
|
||||
|
||||
it("accepts SQL-injection attempt as a normal slug rejection (regression)", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "drop_tables); --", label: "x" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schema_delete_collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("schema_delete_collection", () => {
|
||||
let db: Kysely<Database>;
|
||||
let harness: McpHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({ slug: "post", label: "Posts" });
|
||||
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.cleanup();
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("deletes an empty collection", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_delete_collection",
|
||||
arguments: { slug: "post" },
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
|
||||
// Verify it's gone
|
||||
const list = await harness.client.callTool({
|
||||
name: "schema_list_collections",
|
||||
arguments: {},
|
||||
});
|
||||
const { items } = extractJson<{ items: Array<{ slug: string }> }>(list);
|
||||
expect(items.find((c) => c.slug === "post")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects deleting a collection with content unless force is true", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
await harness.client.callTool({
|
||||
name: "content_create",
|
||||
arguments: { collection: "post", data: { title: "A" } },
|
||||
});
|
||||
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_delete_collection",
|
||||
arguments: { slug: "post" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
// Tight: the error must say "has content" and tell the caller how
|
||||
// to override (force: true). Loose word matches like /empty|content/
|
||||
// passed against unrelated 500s, hiding regressions.
|
||||
const text = extractText(result);
|
||||
expect(text).toMatch(/has content/i);
|
||||
expect(text).toContain("force: true");
|
||||
});
|
||||
|
||||
it("force deletes a collection with content", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
await harness.client.callTool({
|
||||
name: "content_create",
|
||||
arguments: { collection: "post", data: { title: "A" } },
|
||||
});
|
||||
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_delete_collection",
|
||||
arguments: { slug: "post", force: true },
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
});
|
||||
|
||||
it("returns clear NOT_FOUND error for missing collection", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_delete_collection",
|
||||
arguments: { slug: "nonexistent" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(extractText(result)).toMatch(/COLLECTION_NOT_FOUND|\bnot found\b/i);
|
||||
expect(extractText(result)).toContain("nonexistent");
|
||||
});
|
||||
|
||||
it("requires ADMIN role", async () => {
|
||||
harness = await connectMcpHarness({ db, userId: EDITOR_ID, userRole: Role.EDITOR });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_delete_collection",
|
||||
arguments: { slug: "post" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schema_create_field
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("schema_create_field", () => {
|
||||
let db: Kysely<Database>;
|
||||
let harness: McpHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({ slug: "post", label: "Posts" });
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.cleanup();
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("creates a string field with minimal args", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: { collection: "post", slug: "title", label: "Title", type: "string" },
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
const field = extractJson<{ slug: string; type: string; required?: boolean }>(result);
|
||||
expect(field.slug).toBe("title");
|
||||
expect(field.type).toBe("string");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["text", "f_text"],
|
||||
["number", "f_number"],
|
||||
["integer", "f_integer"],
|
||||
["boolean", "f_bool"],
|
||||
["datetime", "f_dt"],
|
||||
["portableText", "f_portable_text"],
|
||||
["json", "f_json"],
|
||||
["slug", "f_slug"],
|
||||
])("creates a %s field", async (fieldType, slug) => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: { collection: "post", slug, label: fieldType, type: fieldType },
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
});
|
||||
|
||||
it("creates a select field with options", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
slug: "priority",
|
||||
label: "Priority",
|
||||
type: "select",
|
||||
validation: { options: ["low", "high"] },
|
||||
},
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
});
|
||||
|
||||
it("creates a reference field with target collection", async () => {
|
||||
await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "page", label: "Pages" },
|
||||
});
|
||||
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
slug: "parent",
|
||||
label: "Parent",
|
||||
type: "reference",
|
||||
options: { collection: "page" },
|
||||
},
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
});
|
||||
|
||||
it("rejects field slug not matching the slug pattern", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
slug: "Has-Caps",
|
||||
label: "Bad",
|
||||
type: "string",
|
||||
},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate field slug on the same collection", async () => {
|
||||
await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: { collection: "post", slug: "title", label: "Title", type: "string" },
|
||||
});
|
||||
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: { collection: "post", slug: "title", label: "Title v2", type: "string" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(extractText(result)).toMatch(/exist|duplicate|already/i);
|
||||
});
|
||||
|
||||
it("rejects field on non-existent collection", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: {
|
||||
collection: "ghost",
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(extractText(result)).toMatch(/COLLECTION_NOT_FOUND|\bnot found\b/i);
|
||||
expect(extractText(result)).toContain("ghost");
|
||||
});
|
||||
|
||||
it("rejects field type not in the enum", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
slug: "weird",
|
||||
label: "Weird",
|
||||
type: "not_a_real_type",
|
||||
},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects reserved field slug like 'id' or 'created_at'", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
slug: "id",
|
||||
label: "ID",
|
||||
type: "string",
|
||||
},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("requires ADMIN role", async () => {
|
||||
await harness.cleanup();
|
||||
harness = await connectMcpHarness({ db, userId: EDITOR_ID, userRole: Role.EDITOR });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("required field is reflected in the response", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_field",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
const field = extractJson<{ required?: boolean }>(result);
|
||||
expect(field.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schema_delete_field
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("schema_delete_field", () => {
|
||||
let db: Kysely<Database>;
|
||||
let harness: McpHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({ slug: "post", label: "Posts" });
|
||||
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
|
||||
await registry.createField("post", { slug: "body", label: "Body", type: "text" });
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.cleanup();
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("deletes an unused field", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_delete_field",
|
||||
arguments: { collection: "post", fieldSlug: "body" },
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
|
||||
// Verify it's gone
|
||||
const get = await harness.client.callTool({
|
||||
name: "schema_get_collection",
|
||||
arguments: { slug: "post" },
|
||||
});
|
||||
const collection = extractJson<{ fields: Array<{ slug: string }> }>(get);
|
||||
expect(collection.fields.find((f) => f.slug === "body")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns clear error for missing field slug", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_delete_field",
|
||||
arguments: { collection: "post", fieldSlug: "ghost" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(extractText(result)).toMatch(/FIELD_NOT_FOUND|\bnot found\b/i);
|
||||
expect(extractText(result)).toContain("ghost");
|
||||
});
|
||||
|
||||
it("returns clear error for missing collection", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_delete_field",
|
||||
arguments: { collection: "noplace", fieldSlug: "title" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(extractText(result)).toMatch(/COLLECTION_NOT_FOUND|\bnot found\b/i);
|
||||
expect(extractText(result)).toContain("noplace");
|
||||
});
|
||||
|
||||
it("requires ADMIN role", async () => {
|
||||
await harness.cleanup();
|
||||
harness = await connectMcpHarness({ db, userId: EDITOR_ID, userRole: Role.EDITOR });
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_delete_field",
|
||||
arguments: { collection: "post", fieldSlug: "body" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("deleting a field with existing content also drops the data (no orphan)", async () => {
|
||||
// Create content using the field
|
||||
await harness.client.callTool({
|
||||
name: "content_create",
|
||||
arguments: { collection: "post", data: { title: "T", body: "Body content" } },
|
||||
});
|
||||
|
||||
// Delete the field
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_delete_field",
|
||||
arguments: { collection: "post", fieldSlug: "body" },
|
||||
});
|
||||
expect(result.isError, extractText(result)).toBeFalsy();
|
||||
|
||||
// content_get should return data without the body field
|
||||
const list = await harness.client.callTool({
|
||||
name: "content_list",
|
||||
arguments: { collection: "post" },
|
||||
});
|
||||
const items = extractJson<{ items: Array<Record<string, unknown>> }>(list).items;
|
||||
// At minimum, the API shouldn't crash. The field should not appear,
|
||||
// and the data fetch should still succeed.
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-cutting: error envelope quality (bug #3 lens)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("schema tools — error envelope quality (bug #3 lens)", () => {
|
||||
let db: Kysely<Database>;
|
||||
let harness: McpHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.cleanup();
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("schema_create_collection on duplicate names a discriminated CONFLICT-like error", async () => {
|
||||
await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "post", label: "Posts" },
|
||||
});
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "post", label: "Posts" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = extractText(result);
|
||||
// Today: probably leaks raw SQLite UNIQUE error or generic. After fix:
|
||||
// a stable signal like "already exists" / CONFLICT.
|
||||
expect(text).toMatch(/exist|conflict|duplicate|unique|already/i);
|
||||
expect(text).not.toMatch(/^Failed to /);
|
||||
});
|
||||
|
||||
it("validation error names the offending field/value in the message", async () => {
|
||||
const result = await harness.client.callTool({
|
||||
name: "schema_create_collection",
|
||||
arguments: { slug: "Bad-Slug", label: "Bad" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(extractText(result)).toMatch(VALIDATION_ERROR);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user