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

713 lines
23 KiB
TypeScript

/**
* 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);
});
});