chore(testing): Phase 9 — bun:test suite + CI gating with no-DB fakes
CI / typecheck + build (turbo) (push) Canceled after 0s
CI / typecheck + build (turbo) (push) Canceled after 0s
Added a real test suite (32 tests, 0 external services) using bun:test with in-process module mocking for @mcpedia/db, @mcpedia/queue, @mcpedia/core. Enablers: - apps/api: extracted createApp(deps?) factory + dashboard.ts module from index.ts so the HTTP surface is unit-testable (real queue is lazy-imported). - packages/core: exported shouldCreateRevision pure predicate; restoreRevision gained an opts.reindex seam for the chunk-rebuild contract. - apps/mcp: renamed smoke.test.ts -> smoke.ts (bun test now owns .test.ts), updated stale assertions (10 tools, 4 docs in docs section). - infra: turbo test task (cache:false), test scripts across packages, @types/bun + tsconfig base types, CI 'Test' step after Build. Packages with tests: embeddings(5), parser(5), search(8), core(4), mcp(6 auth-gates), api(8 contracts). All green: typecheck(4/4), test(6/6 pkgs), build(web). Live API verified /health, /metrics, /dashboard, /hooks/* auth gate on temp port.
This commit is contained in:
1 parent
0cdf261d40
commit
76f778c10d
25 files changed
+1055
-193
No files matched your search
@@ -0,0 +1,171 @@
|
||||
import { test, expect, mock } from "bun:test";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mock the heavy dependencies so the MCP server is fully testable without
|
||||
// Postgres / Redis / embeddings. We record calls to the mutating functions
|
||||
// so we can assert the auth gate is (or isn't) the reason a tool errors.
|
||||
// -----------------------------------------------------------------------
|
||||
const calls = {
|
||||
enqueueIndexDoc: [] as Array<[string, string]>,
|
||||
enqueueFullIndex: [] as Array<[string]>,
|
||||
restoreRevision: [] as Array<[string]>,
|
||||
};
|
||||
|
||||
mock.module("@mcpedia/queue", () => ({
|
||||
enqueueIndexDoc: (relPath: string, reason: string) => {
|
||||
calls.enqueueIndexDoc.push([relPath, reason]);
|
||||
return Promise.resolve({ id: `doc__${relPath}` });
|
||||
},
|
||||
enqueueFullIndex: (reason: string) => {
|
||||
calls.enqueueFullIndex.push([reason]);
|
||||
return Promise.resolve({ id: `full__${Date.now()}` });
|
||||
},
|
||||
getQueue: () => ({
|
||||
getWaitingCount: () => Promise.resolve(0),
|
||||
getActiveCount: () => Promise.resolve(0),
|
||||
getCompletedCount: () => Promise.resolve(0),
|
||||
getFailedCount: () => Promise.resolve(0),
|
||||
getDelayedCount: () => Promise.resolve(0),
|
||||
}),
|
||||
INDEX_QUEUE: "mcpedia-index",
|
||||
}));
|
||||
|
||||
mock.module("@mcpedia/core", () => ({
|
||||
keywordSearch: () => Promise.resolve([]),
|
||||
getDocument: () => Promise.resolve(null),
|
||||
listDocuments: () => Promise.resolve([]),
|
||||
getRelated: () => Promise.resolve([]),
|
||||
semanticSearch: () => Promise.resolve([]),
|
||||
hybridSearch: () => Promise.resolve([]),
|
||||
listRevisions: () => Promise.resolve([]),
|
||||
getRevision: () => Promise.resolve(null),
|
||||
restoreRevision: (id: string) => {
|
||||
calls.restoreRevision.push([id]);
|
||||
return Promise.resolve({ slug: "docs/test", documentId: "d1" });
|
||||
},
|
||||
readContentFile: () => "",
|
||||
}));
|
||||
|
||||
// Import AFTER mocking so the modules resolve to our fakes.
|
||||
const { createMcpServer } = await import("../src/index");
|
||||
|
||||
async function connect(secret?: string) {
|
||||
const server = createMcpServer(secret);
|
||||
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverT);
|
||||
const client = new Client({ name: "test", version: "0.0.1" });
|
||||
await client.connect(clientT);
|
||||
return { server, client };
|
||||
}
|
||||
|
||||
function reset() {
|
||||
calls.enqueueIndexDoc.length = 0;
|
||||
calls.enqueueFullIndex.length = 0;
|
||||
calls.restoreRevision.length = 0;
|
||||
}
|
||||
|
||||
// --- Auth gates on write tools ---
|
||||
|
||||
test("index_document without auth secret -> tool returns isError", async () => {
|
||||
reset();
|
||||
const { client, server } = await connect(); // no secret
|
||||
const res = await client.callTool({
|
||||
name: "index_document",
|
||||
arguments: { slug: "docs/foo" },
|
||||
});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(calls.enqueueIndexDoc).toHaveLength(0);
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("index_document WITH auth secret -> enqueues job", async () => {
|
||||
reset();
|
||||
const { client, server } = await connect("real-secret");
|
||||
const res = await client.callTool({
|
||||
name: "index_document",
|
||||
arguments: { slug: "docs/foo" },
|
||||
});
|
||||
expect(res.isError).toBeFalsy();
|
||||
const text = (res.content as any[])[0].text;
|
||||
const parsed = JSON.parse(text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.jobId).toBeTruthy();
|
||||
expect(calls.enqueueIndexDoc).toHaveLength(1);
|
||||
expect(calls.enqueueIndexDoc[0][0]).toBe("docs/foo.md");
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("reindex_all without auth -> isError; with auth -> enqueues", async () => {
|
||||
reset();
|
||||
const { client, server } = await connect();
|
||||
let res = await client.callTool({ name: "reindex_all", arguments: {} });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(calls.enqueueFullIndex).toHaveLength(0);
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
const { client: c2, server: s2 } = await connect("real-secret");
|
||||
res = await c2.callTool({ name: "reindex_all", arguments: {} });
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect(calls.enqueueFullIndex).toHaveLength(1);
|
||||
await c2.close();
|
||||
await s2.close();
|
||||
});
|
||||
|
||||
test("restore_revision without auth -> isError; with auth -> calls core.restoreRevision", async () => {
|
||||
reset();
|
||||
const { client, server } = await connect();
|
||||
let res = await client.callTool({
|
||||
name: "restore_revision",
|
||||
arguments: { id: "rev-123" },
|
||||
});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(calls.restoreRevision).toHaveLength(0);
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
const { client: c2, server: s2 } = await connect("real-secret");
|
||||
res = await c2.callTool({
|
||||
name: "restore_revision",
|
||||
arguments: { id: "rev-123" },
|
||||
});
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect(calls.restoreRevision).toHaveLength(1);
|
||||
expect(calls.restoreRevision[0][0]).toBe("rev-123");
|
||||
await c2.close();
|
||||
await s2.close();
|
||||
});
|
||||
|
||||
test("queue_status is public (no auth needed)", async () => {
|
||||
const { client, server } = await connect(); // no secret
|
||||
const res = await client.callTool({ name: "queue_status", arguments: {} });
|
||||
expect(res.isError).toBeFalsy();
|
||||
const text = (res.content as any[])[0].text;
|
||||
expect(JSON.parse(text).queue).toBe("mcpedia-index");
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("tool discovery works without auth (read tools present)", async () => {
|
||||
const { client, server } = await connect();
|
||||
const tools = await client.listTools();
|
||||
const names = tools.tools.map((t) => t.name).sort();
|
||||
expect(names).toContain("search_documents");
|
||||
expect(names).toContain("get_document");
|
||||
expect(names).toContain("list_documents");
|
||||
expect(names).toContain("semantic_search");
|
||||
expect(names).toContain("hybrid_search");
|
||||
expect(names).toContain("get_related_documents");
|
||||
// Write tools are registered regardless of secret — the gate is in the
|
||||
// handler, not registration.
|
||||
expect(names).toContain("index_document");
|
||||
expect(names).toContain("reindex_all");
|
||||
expect(names).toContain("restore_revision");
|
||||
expect(names).toContain("queue_status");
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
Reference in new issue
Block a user