mirror of
https://github.com/tobi/qmd.git
synced 2026-09-14 20:27:06 +08:00
260 lines
9.6 KiB
TypeScript
260 lines
9.6 KiB
TypeScript
/**
|
|
* metadata-surfaces.test.ts - Metadata filter support across the public
|
|
* surfaces: SDK, MCP tool, and HTTP REST endpoints. Uses lex-only searches
|
|
* with reranking disabled so no models are needed.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
|
|
import { unlinkSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import YAML from "yaml";
|
|
import { createStore, type QMDStore } from "../src/index.js";
|
|
import {
|
|
createStore as createInternalStore,
|
|
insertContent,
|
|
insertDocument,
|
|
hashContent,
|
|
syncConfigToDb,
|
|
_resetProductionModeForTesting,
|
|
} from "../src/store.js";
|
|
import { replaceDocumentMetadata } from "../src/metadata-store.js";
|
|
import { METADATA_EXTRACTION_VERSION, type DocumentMetadata } from "../src/metadata.js";
|
|
import { startMcpHttpServer, type HttpServerHandle } from "../src/mcp/server.js";
|
|
import type { CollectionConfig } from "../src/collections.js";
|
|
|
|
let testDir: string;
|
|
|
|
beforeAll(async () => {
|
|
testDir = await mkdtemp(join(tmpdir(), "qmd-metadata-surfaces-"));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function buildDoc(status: string, body: string): string {
|
|
return `---\nqmd:\n metadata:\n status: ${status}\n topics: [typescript]\n---\n\n${body}`;
|
|
}
|
|
|
|
// =============================================================================
|
|
// SDK
|
|
// =============================================================================
|
|
|
|
describe("SDK metadata filter", () => {
|
|
let store: QMDStore;
|
|
let collectionDir: string;
|
|
|
|
beforeAll(async () => {
|
|
collectionDir = join(testDir, "sdk-collection");
|
|
await mkdir(collectionDir, { recursive: true });
|
|
await writeFile(join(collectionDir, "published.md"), buildDoc("published", "# Pub\n\nsdk keyword body"));
|
|
await writeFile(join(collectionDir, "draft.md"), buildDoc("draft", "# Draft\n\nsdk keyword body"));
|
|
|
|
store = await createStore({
|
|
dbPath: join(testDir, "sdk.sqlite"),
|
|
config: { collections: { docs: { path: collectionDir, pattern: "**/*.md" } } },
|
|
});
|
|
await store.update();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await store.close();
|
|
});
|
|
|
|
test("searchLex applies the filter and returns metadata", async () => {
|
|
const unfiltered = await store.searchLex("sdk keyword");
|
|
expect(unfiltered.length).toBe(2);
|
|
|
|
const filtered = await store.searchLex("sdk keyword", {
|
|
filter: { key: "status", operator: "eq", value: "published" },
|
|
});
|
|
expect(filtered.map(r => r.displayPath)).toEqual(["docs/published.md"]);
|
|
expect(filtered[0]!.metadata).toEqual({ status: "published", topics: ["typescript"] });
|
|
});
|
|
|
|
test("search with pre-expanded queries applies the filter", async () => {
|
|
const results = await store.search({
|
|
queries: [{ type: "lex", query: "sdk keyword" }],
|
|
filter: { key: "status", operator: "ne", value: "draft" },
|
|
rerank: false,
|
|
});
|
|
expect(results.map(r => r.displayPath)).toEqual(["docs/published.md"]);
|
|
expect(results[0]!.metadata).toEqual({ status: "published", topics: ["typescript"] });
|
|
});
|
|
|
|
test("getStatus exposes the pending metadata count", async () => {
|
|
const status = await store.getStatus();
|
|
expect(status.pendingMetadata).toBe(0);
|
|
});
|
|
|
|
test("validates filters at the SDK runtime boundary", async () => {
|
|
await expect(store.searchLex("sdk keyword", {
|
|
filter: { operator: "and", operands: [] },
|
|
})).rejects.toThrow(/non-empty 'operands'/);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// MCP tool + HTTP REST
|
|
// =============================================================================
|
|
|
|
describe("MCP and HTTP metadata filter", () => {
|
|
let handle: HttpServerHandle;
|
|
let baseUrl: string;
|
|
let dbPath: string;
|
|
let configDir: string;
|
|
const origIndexPath = process.env.INDEX_PATH;
|
|
const origConfigDir = process.env.QMD_CONFIG_DIR;
|
|
|
|
async function seedDoc(db: import("../src/db.js").Database, path: string, body: string, metadata: DocumentMetadata): Promise<void> {
|
|
const now = new Date().toISOString();
|
|
const hash = await hashContent(body);
|
|
insertContent(db, hash, body, now);
|
|
const documentId = insertDocument(db, "docs", path, path, hash, now, now);
|
|
replaceDocumentMetadata(db, documentId, { metadata, extractionVersion: METADATA_EXTRACTION_VERSION });
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
dbPath = join(testDir, `mcp-${Date.now()}.sqlite`);
|
|
const internal = createInternalStore(dbPath);
|
|
await seedDoc(internal.db, "published.md", "# Pub\n\nhttp keyword body", { status: "published" });
|
|
await seedDoc(internal.db, "draft.md", "# Draft\n\nhttp keyword body", { status: "draft" });
|
|
|
|
const testConfig: CollectionConfig = {
|
|
collections: { docs: { path: "/test/docs", pattern: "**/*.md" } },
|
|
};
|
|
syncConfigToDb(internal.db, testConfig);
|
|
internal.close();
|
|
|
|
configDir = await mkdtemp(join(tmpdir(), "qmd-metadata-surfaces-config-"));
|
|
await writeFile(join(configDir, "index.yml"), YAML.stringify(testConfig));
|
|
|
|
process.env.INDEX_PATH = dbPath;
|
|
process.env.QMD_CONFIG_DIR = configDir;
|
|
handle = await startMcpHttpServer(0, { quiet: true, dbPath });
|
|
baseUrl = `http://localhost:${handle.port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (handle) await handle.stop();
|
|
_resetProductionModeForTesting();
|
|
if (origIndexPath !== undefined) process.env.INDEX_PATH = origIndexPath;
|
|
else delete process.env.INDEX_PATH;
|
|
if (origConfigDir !== undefined) process.env.QMD_CONFIG_DIR = origConfigDir;
|
|
else delete process.env.QMD_CONFIG_DIR;
|
|
try { unlinkSync(dbPath); } catch {}
|
|
await rm(configDir, { recursive: true, force: true });
|
|
});
|
|
|
|
type HttpRequestBody = Record<string, unknown>;
|
|
|
|
async function postJson(path: string, body: HttpRequestBody): Promise<{ status: number; json: any }> {
|
|
const res = await fetch(`${baseUrl}${path}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return { status: res.status, json: await res.json() };
|
|
}
|
|
|
|
async function callQueryTool(args: Record<string, unknown>): Promise<{ status: number; json: any }> {
|
|
const res = await fetch(`${baseUrl}/mcp`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
"MCP-Protocol-Version": "2026-07-28",
|
|
"Mcp-Method": "tools/call",
|
|
"Mcp-Name": "query",
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "query",
|
|
arguments: args,
|
|
_meta: {
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
"io.modelcontextprotocol/clientInfo": { name: "metadata-test", version: "1.0.0" },
|
|
"io.modelcontextprotocol/clientCapabilities": {},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
return { status: res.status, json: await res.json() };
|
|
}
|
|
|
|
test("POST /query applies the filter and includes metadata", async () => {
|
|
const { status, json } = await postJson("/query", {
|
|
searches: [{ type: "lex", query: "http keyword" }],
|
|
filter: { key: "status", operator: "eq", value: "published" },
|
|
rerank: false,
|
|
});
|
|
expect(status).toBe(200);
|
|
expect(json.results.length).toBe(1);
|
|
expect(json.results[0].file).toBe("qmd://docs/published.md");
|
|
expect(json.results[0].metadata).toEqual({ status: "published" });
|
|
});
|
|
|
|
test("POST /search alias accepts the same filter", async () => {
|
|
const { status, json } = await postJson("/search", {
|
|
searches: [{ type: "lex", query: "http keyword" }],
|
|
filter: { key: "status", operator: "eq", value: "draft" },
|
|
rerank: false,
|
|
});
|
|
expect(status).toBe(200);
|
|
expect(json.results.length).toBe(1);
|
|
expect(json.results[0].file).toBe("qmd://docs/draft.md");
|
|
});
|
|
|
|
test("POST /query rejects non-object and invalid filters with 400", async () => {
|
|
const stringFilter = await postJson("/query", {
|
|
searches: [{ type: "lex", query: "http keyword" }],
|
|
filter: "status = published",
|
|
});
|
|
expect(stringFilter.status).toBe(400);
|
|
expect(stringFilter.json.error).toMatch(/must be an object/);
|
|
|
|
const invalidAst = await postJson("/query", {
|
|
searches: [{ type: "lex", query: "http keyword" }],
|
|
filter: { key: "status", operator: "equal", value: "published" },
|
|
});
|
|
expect(invalidAst.status).toBe(400);
|
|
expect(invalidAst.json.error).toMatch(/unknown operator 'equal'/);
|
|
});
|
|
|
|
test("MCP query tool applies a nested filter and includes metadata", async () => {
|
|
const { status, json } = await callQueryTool({
|
|
searches: [{ type: "lex", query: "http keyword" }],
|
|
filter: {
|
|
operator: "and",
|
|
operands: [
|
|
{ key: "status", operator: "eq", value: "published" },
|
|
{ operator: "not", operand: { key: "status", operator: "eq", value: "draft" } },
|
|
],
|
|
},
|
|
rerank: false,
|
|
});
|
|
expect(status).toBe(200);
|
|
expect(json.result.isError).toBeFalsy();
|
|
const items = json.result.structuredContent.results;
|
|
expect(items.length).toBe(1);
|
|
expect(items[0].file).toBe("docs/published.md");
|
|
expect(items[0].metadata).toEqual({ status: "published" });
|
|
});
|
|
|
|
test("MCP query tool rejects invalid filters", async () => {
|
|
const { status, json } = await callQueryTool({
|
|
searches: [{ type: "lex", query: "http keyword" }],
|
|
filter: { operator: "and", operands: [] },
|
|
rerank: false,
|
|
});
|
|
expect(status).toBe(200);
|
|
expect(json.result.isError).toBe(true);
|
|
expect(json.result.content[0].text).toMatch(/non-empty 'operands'/);
|
|
});
|
|
});
|