/** * MCP search tool — comprehensive integration tests. * * Covers: * - search query → matching results * - empty index / no searchable collections * - collection scoping * - locale filtering * - special characters / FTS5 syntax * - permission gating */ 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 { FTSManager } from "../../../src/search/fts-manager.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 SUBSCRIBER_ID = "user_subscriber"; async function setupSearchablePostCollection(db: Kysely): Promise { const registry = new SchemaRegistry(db); await registry.createCollection({ slug: "post", label: "Posts", supports: ["drafts", "revisions", "search"], }); await registry.createField("post", { slug: "title", label: "Title", type: "string", searchable: true, }); await registry.createField("post", { slug: "body", label: "Body", type: "text", searchable: true, }); // Activate the FTS index. Production sites do this either via the seed // pipeline or the admin "Enable search" toggle. Without it, the FTS // table and triggers don't exist and the test would silently miss real // indexing bugs. await new FTSManager(db).enableSearch("post"); } describe("search", () => { let db: Kysely; let harness: McpHarness; beforeEach(async () => { db = await setupTestDatabase(); }); afterEach(async () => { if (harness) await harness.cleanup(); await teardownTestDatabase(db); }); it("returns empty results when no collections are searchable", async () => { const registry = new SchemaRegistry(db); await registry.createCollection({ slug: "post", label: "Posts" }); // no search support harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); const result = await harness.client.callTool({ name: "search", arguments: { query: "anything" }, }); expect(result.isError, extractText(result)).toBeFalsy(); const data = extractJson<{ items: unknown[] }>(result); expect(data.items).toEqual([]); }); it("returns empty results for a query with no matches", async () => { await setupSearchablePostCollection(db); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Hello world", body: "Lorem ipsum" } }, }); await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id: "hello-world" }, }); const result = await harness.client.callTool({ name: "search", arguments: { query: "ZZZZZQuantumZebra" }, }); expect(result.isError, extractText(result)).toBeFalsy(); const data = extractJson<{ items: unknown[] }>(result); expect(data.items).toEqual([]); }); it("returns matching items for a query that hits", async () => { await setupSearchablePostCollection(db); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "Hello world", body: "Lorem ipsum about searching" }, }, }); const id = extractJson<{ item: { id: string } }>(created).item.id; await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id }, }); const result = await harness.client.callTool({ name: "search", arguments: { query: "Hello" }, }); expect(result.isError, extractText(result)).toBeFalsy(); const data = extractJson<{ items: Array<{ id: string }> }>(result); expect(data.items.length).toBeGreaterThan(0); expect(data.items.find((i) => i.id === id)).toBeTruthy(); }); it("scopes search by collections argument", async () => { const registry = new SchemaRegistry(db); await registry.createCollection({ slug: "post", label: "Posts", supports: ["search"], }); await registry.createField("post", { slug: "title", label: "Title", type: "string", searchable: true, }); await registry.createCollection({ slug: "page", label: "Pages", supports: ["search"], }); await registry.createField("page", { slug: "title", label: "Title", type: "string", searchable: true, }); const fts = new FTSManager(db); await fts.enableSearch("post"); await fts.enableSearch("page"); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); const post = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "rocket post" } }, }); const page = await harness.client.callTool({ name: "content_create", arguments: { collection: "page", data: { title: "rocket page" } }, }); await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id: extractJson<{ item: { id: string } }>(post).item.id, }, }); await harness.client.callTool({ name: "content_publish", arguments: { collection: "page", id: extractJson<{ item: { id: string } }>(page).item.id, }, }); const result = await harness.client.callTool({ name: "search", arguments: { query: "rocket", collections: ["post"] }, }); const data = extractJson<{ items: Array<{ collection?: string; type?: string }> }>(result); // We seeded one post and one page that both match "rocket". Scoping // to ["post"] must keep at least the post hit and exclude the page. expect(data.items.length).toBeGreaterThan(0); for (const item of data.items) { const c = item.collection ?? item.type; expect(c).toBe("post"); } }); it("handles empty query string gracefully", async () => { await setupSearchablePostCollection(db); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); // Seed a published item so a regression that interprets an empty // query as "match all" would produce a non-empty list and fail. const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "matchable", body: "indexed content" }, }, }); await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id: extractJson<{ item: { id: string } }>(created).item.id, }, }); const result = await harness.client.callTool({ name: "search", arguments: { query: "" }, }); // Empty queries are sanitized to a no-op and return zero matches. // They must not surface as an error AND must not match all items. expect(result.isError, extractText(result)).toBeFalsy(); const data = extractJson<{ items: unknown[] }>(result); expect(data.items).toEqual([]); }); it("handles special characters in query without leaking FTS5 syntax errors", async () => { await setupSearchablePostCollection(db); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); // Seed a published item so a regression that lets malformed input // fall through to "match all" would surface a non-empty list. const created = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "matchable", body: "indexed content" }, }, }); await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id: extractJson<{ item: { id: string } }>(created).item.id, }, }); // FTS5 has special operators: AND OR NOT NEAR " * ( ) : // `searchSingleCollection` swallows malformed-input FTS5 errors and // returns no matches; the response is a clean empty list. const result = await harness.client.callTool({ name: "search", arguments: { query: 'NOT "quotes" AND* (' }, }); expect(result.isError, extractText(result)).toBeFalsy(); const data = extractJson<{ items: unknown[] }>(result); expect(data.items).toEqual([]); }); it("respects the limit parameter", async () => { await setupSearchablePostCollection(db); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); // Create 10 items containing the same word for (let i = 0; i < 10; i++) { const c = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: `searchable item ${i}`, body: "common-text" }, }, }); await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id: extractJson<{ item: { id: string } }>(c).item.id, }, }); } const result = await harness.client.callTool({ name: "search", arguments: { query: "common-text", limit: 3 }, }); const data = extractJson<{ items: unknown[] }>(result); expect(data.items.length).toBeLessThanOrEqual(3); }); it("only returns published items (not drafts) regardless of caller role", async () => { await setupSearchablePostCollection(db); harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); // Create one draft, one published await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "draft-only-content" } }, }); const pubItem = await harness.client.callTool({ name: "content_create", arguments: { collection: "post", data: { title: "published-content" } }, }); await harness.client.callTool({ name: "content_publish", arguments: { collection: "post", id: extractJson<{ item: { id: string } }>(pubItem).item.id, }, }); const draftQuery = await harness.client.callTool({ name: "search", arguments: { query: "draft-only-content" }, }); expect(extractJson<{ items: unknown[] }>(draftQuery).items).toEqual([]); const pubQuery = await harness.client.callTool({ name: "search", arguments: { query: "published-content" }, }); expect(extractJson<{ items: unknown[] }>(pubQuery).items.length).toBeGreaterThan(0); }); it("any logged-in user (SUBSCRIBER) can search", async () => { await setupSearchablePostCollection(db); harness = await connectMcpHarness({ db, userId: SUBSCRIBER_ID, userRole: Role.SUBSCRIBER }); const result = await harness.client.callTool({ name: "search", arguments: { query: "anything" }, }); expect(result.isError, extractText(result)).toBeFalsy(); }); });