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