b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
/**
|
|
* 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<Database>): Promise<void> {
|
|
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<Database>;
|
|
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("paginates results via the cursor argument", async () => {
|
|
await setupSearchablePostCollection(db);
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
const c = await harness.client.callTool({
|
|
name: "content_create",
|
|
arguments: {
|
|
collection: "post",
|
|
data: { title: `paginated item ${i}`, body: "common-cursor-text" },
|
|
},
|
|
});
|
|
await harness.client.callTool({
|
|
name: "content_publish",
|
|
arguments: {
|
|
collection: "post",
|
|
id: extractJson<{ item: { id: string } }>(c).item.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
const firstPage = await harness.client.callTool({
|
|
name: "search",
|
|
arguments: { query: "common-cursor-text", limit: 2 },
|
|
});
|
|
const firstData = extractJson<{ items: Array<{ id: string }>; nextCursor?: string }>(firstPage);
|
|
expect(firstData.items).toHaveLength(2);
|
|
expect(firstData.nextCursor).toBeTruthy();
|
|
|
|
const secondPage = await harness.client.callTool({
|
|
name: "search",
|
|
arguments: { query: "common-cursor-text", limit: 2, cursor: firstData.nextCursor },
|
|
});
|
|
const secondData = extractJson<{ items: Array<{ id: string }> }>(secondPage);
|
|
expect(secondData.items).toHaveLength(2);
|
|
const firstIds = new Set(firstData.items.map((i) => i.id));
|
|
for (const item of secondData.items) {
|
|
expect(firstIds.has(item.id)).toBe(false);
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|