From 893db167db5c279e364254315f88a605f840e8b5 Mon Sep 17 00:00:00 2001 From: zho Date: Thu, 14 May 2026 04:47:02 +0800 Subject: [PATCH 1/4] added tests --- src/server/tools/guided-query-tool.test.ts | 126 ++++++++++++ src/server/tools/list-namespaces-tool.test.ts | 72 +++++++ src/server/tools/query-documents-tool.test.ts | 140 ++++++++++++++ src/server/tools/query-tool.test.ts | 179 ++++++++++++++++++ .../tools/suggest-query-params-tool.test.ts | 106 +++++++++++ src/server/tools/test-helpers.ts | 60 ++++++ vitest.config.ts | 2 +- 7 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 src/server/tools/guided-query-tool.test.ts create mode 100644 src/server/tools/list-namespaces-tool.test.ts create mode 100644 src/server/tools/query-documents-tool.test.ts create mode 100644 src/server/tools/query-tool.test.ts create mode 100644 src/server/tools/suggest-query-params-tool.test.ts create mode 100644 src/server/tools/test-helpers.ts diff --git a/src/server/tools/guided-query-tool.test.ts b/src/server/tools/guided-query-tool.test.ts new file mode 100644 index 0000000..056fbac --- /dev/null +++ b/src/server/tools/guided-query-tool.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getPineconeClient } from '../client-context.js'; +import { getNamespacesWithCache } from '../namespaces-cache.js'; +import { registerGuidedQueryTool } from './guided-query-tool.js'; +import { createMockServer, makeNamespaceCacheEntry, makeSearchResult, parseToolJson } from './test-helpers.js'; + +vi.mock('../client-context.js', () => ({ + getPineconeClient: vi.fn(), +})); + +vi.mock('../namespaces-cache.js', () => ({ + getNamespacesWithCache: vi.fn(), +})); + +const mockedGetNamespaces = vi.mocked(getNamespacesWithCache); +const mockedGetClient = vi.mocked(getPineconeClient); + +describe('guided_query tool handler', () => { + const nsEntry = makeNamespaceCacheEntry('papers', { + document_number: 'string', + title: 'string', + url: 'string', + author: 'string', + chunk_text: 'string', + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockedGetNamespaces.mockResolvedValue({ + data: [nsEntry], + cache_hit: false, + expires_at: Date.now() + 1_800_000, + }); + mockedGetClient.mockReturnValue({ + query: vi.fn().mockResolvedValue([makeSearchResult()]), + count: vi.fn().mockResolvedValue({ count: 7, truncated: false }), + } as never); + }); + + it('runs query_detailed path on auto when user asks for content', async () => { + const server = createMockServer(); + registerGuidedQueryTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const body = parseToolJson( + await server.getHandler('guided_query')!({ + user_query: 'What does the paper say about contracts?', + namespace: 'papers', + top_k: 8, + preferred_tool: 'auto', + enrich_urls: false, + }) + ); + + expect(body.status).toBe('success'); + const trace = body.decision_trace as Record; + expect(trace.selected_namespace).toBe('papers'); + expect(trace.selected_tool).toBe('query_detailed'); + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'papers', + topK: 8, + useReranking: true, + }) + ); + const result = body.result as Record; + expect(result.mode).toBe('query_detailed'); + }); + + it('runs count when preferred_tool is count', async () => { + const server = createMockServer(); + registerGuidedQueryTool(server as never); + const count = mockedGetClient().count as ReturnType; + + const body = parseToolJson( + await server.getHandler('guided_query')!({ + user_query: 'browse', + namespace: 'papers', + preferred_tool: 'count', + }) + ); + + expect(count).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'browse', + namespace: 'papers', + }) + ); + const result = body.result as Record; + expect(result.tool).toBe('count'); + expect(result.count).toBe(7); + }); + + it('returns error when user_query is empty', async () => { + const server = createMockServer(); + registerGuidedQueryTool(server as never); + + const raw = await server.getHandler('guided_query')!({ + user_query: ' ', + namespace: 'papers', + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + expect(parseToolJson(raw).message).toBe('user_query cannot be empty'); + }); + + it('returns error when no namespace can be resolved', async () => { + mockedGetNamespaces.mockResolvedValue({ + data: [], + cache_hit: false, + expires_at: Date.now() + 1_800_000, + }); + + const server = createMockServer(); + registerGuidedQueryTool(server as never); + + const body = parseToolJson( + await server.getHandler('guided_query')!({ + user_query: 'hello world', + }) + ); + + expect(body.status).toBe('error'); + expect(String(body.message)).toContain('No namespace available'); + }); +}); diff --git a/src/server/tools/list-namespaces-tool.test.ts b/src/server/tools/list-namespaces-tool.test.ts new file mode 100644 index 0000000..639f8eb --- /dev/null +++ b/src/server/tools/list-namespaces-tool.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getNamespacesWithCache } from '../namespaces-cache.js'; +import { registerListNamespacesTool } from './list-namespaces-tool.js'; +import { createMockServer, parseToolJson } from './test-helpers.js'; + +vi.mock('../namespaces-cache.js', () => ({ + getNamespacesWithCache: vi.fn(), +})); + +const mockedGetNamespaces = vi.mocked(getNamespacesWithCache); + +describe('list_namespaces tool handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns success with namespaces on happy path', async () => { + const expiresAt = Date.now() + 1_800_000; + mockedGetNamespaces.mockResolvedValue({ + data: [ + { namespace: 'a', recordCount: 1, metadata: { title: 'string' } }, + { namespace: 'b', recordCount: 2, metadata: { url: 'string' } }, + ], + cache_hit: false, + expires_at: expiresAt, + }); + + const server = createMockServer(); + registerListNamespacesTool(server as never); + const handler = server.getHandler('list_namespaces')!; + const raw = await handler({}); + + const body = parseToolJson(raw); + expect(body.status).toBe('success'); + expect(body.cache_hit).toBe(false); + expect(body.count).toBe(2); + expect(body.namespaces).toEqual([ + { name: 'a', record_count: 1, metadata_fields: { title: 'string' } }, + { name: 'b', record_count: 2, metadata_fields: { url: 'string' } }, + ]); + expect(typeof body.cache_ttl_seconds).toBe('number'); + }); + + it('propagates cache_hit when namespaces cache is warm', async () => { + mockedGetNamespaces.mockResolvedValue({ + data: [{ namespace: 'x', recordCount: 0, metadata: {} }], + cache_hit: true, + expires_at: Date.now() + 60_000, + }); + + const server = createMockServer(); + registerListNamespacesTool(server as never); + const body = parseToolJson(await server.getHandler('list_namespaces')!({})); + + expect(body.cache_hit).toBe(true); + expect(body.count).toBe(1); + }); + + it('returns error payload when getNamespacesWithCache throws', async () => { + mockedGetNamespaces.mockRejectedValue(new Error('network down')); + + const server = createMockServer(); + registerListNamespacesTool(server as never); + const raw = await server.getHandler('list_namespaces')!({}); + const payload = raw as { isError?: boolean }; + + expect(payload.isError).toBe(true); + const body = parseToolJson(raw); + expect(body.status).toBe('error'); + expect(String(body.message)).toBe('Failed to list namespaces'); + }); +}); diff --git a/src/server/tools/query-documents-tool.test.ts b/src/server/tools/query-documents-tool.test.ts new file mode 100644 index 0000000..10dab60 --- /dev/null +++ b/src/server/tools/query-documents-tool.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_QUERY_DOCUMENTS_TOP_K, QUERY_DOCUMENTS_MAX_CHUNKS } from '../../constants.js'; +import { getPineconeClient } from '../client-context.js'; +import { reassembleByDocument } from '../reassemble-documents.js'; +import * as suggestionFlow from '../suggestion-flow.js'; +import { registerQueryDocumentsTool } from './query-documents-tool.js'; +import { createMockServer, makeSearchResult, parseToolJson } from './test-helpers.js'; + +vi.mock('../client-context.js', () => ({ + getPineconeClient: vi.fn(), +})); + +vi.mock('../reassemble-documents.js', () => ({ + reassembleByDocument: vi.fn(), +})); + +const mockedGetClient = vi.mocked(getPineconeClient); +const mockedReassemble = vi.mocked(reassembleByDocument); + +describe('query_documents tool handler', () => { + const flowOk = { + ok: true as const, + flow: { + updatedAt: Date.now(), + recommended_tool: 'query_detailed' as const, + suggested_fields: [], + user_query: 'q', + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue(flowOk); + mockedReassemble.mockReturnValue([ + { + document_id: 'D1', + merged_content: 'full doc text', + metadata: { document_number: 'D1' }, + chunk_count: 3, + best_score: 0.99, + }, + ]); + mockedGetClient.mockReturnValue({ + query: vi.fn().mockResolvedValue([makeSearchResult()]), + } as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('happy path: queries chunks, reassembles, and returns documents', async () => { + const server = createMockServer(); + registerQueryDocumentsTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const body = parseToolJson( + await server.getHandler('query_documents')!({ + query_text: 'semantic question', + namespace: 'wg21', + top_k: DEFAULT_QUERY_DOCUMENTS_TOP_K, + }) + ); + + const expectedTopK = Math.min( + QUERY_DOCUMENTS_MAX_CHUNKS, + DEFAULT_QUERY_DOCUMENTS_TOP_K * 50 + ); + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'semantic question', + namespace: 'wg21', + topK: expectedTopK, + useReranking: true, + fields: undefined, + }) + ); + expect(mockedReassemble).toHaveBeenCalled(); + expect(body.status).toBe('success'); + const docs = body.documents as Array<{ merged_content: string }>; + expect(docs[0].merged_content).toBe('full doc text'); + }); + + it('returns error when query_text is empty', async () => { + const server = createMockServer(); + registerQueryDocumentsTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const raw = await server.getHandler('query_documents')!({ + query_text: '', + namespace: 'wg21', + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + expect(query).not.toHaveBeenCalled(); + expect(parseToolJson(raw).message).toBe('query_text cannot be empty'); + }); + + it('returns flow error when suggest_query_params gate fails', async () => { + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({ + ok: false, + message: + 'Flow requires suggest_query_params first. Call suggest_query_params with namespace and user_query before query/count tools.', + }); + + const server = createMockServer(); + registerQueryDocumentsTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const body = parseToolJson( + await server.getHandler('query_documents')!({ + query_text: 'ok', + namespace: 'wg21', + }) + ); + + expect(body.status).toBe('error'); + expect(query).not.toHaveBeenCalled(); + }); + + it('returns TTL expiry error from requireSuggested', async () => { + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({ + ok: false, + message: + 'Previous suggest_query_params context expired (30 minutes). Call suggest_query_params again before query/count tools.', + }); + + const server = createMockServer(); + registerQueryDocumentsTool(server as never); + + const body = parseToolJson( + await server.getHandler('query_documents')!({ + query_text: 'ok', + namespace: 'wg21', + }) + ); + + expect(body.message).toContain('expired'); + }); +}); diff --git a/src/server/tools/query-tool.test.ts b/src/server/tools/query-tool.test.ts new file mode 100644 index 0000000..ad971b1 --- /dev/null +++ b/src/server/tools/query-tool.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FAST_QUERY_FIELDS } from '../../constants.js'; +import { getPineconeClient } from '../client-context.js'; +import * as suggestionFlow from '../suggestion-flow.js'; +import { registerQueryTool } from './query-tool.js'; +import { createMockServer, makeSearchResult, parseToolJson } from './test-helpers.js'; + +vi.mock('../client-context.js', () => ({ + getPineconeClient: vi.fn(), +})); + +const mockedGetClient = vi.mocked(getPineconeClient); + +describe('query / query_fast / query_detailed tool handlers', () => { + const flowOk = { + ok: true as const, + flow: { + updatedAt: Date.now(), + recommended_tool: 'query_detailed' as const, + suggested_fields: ['chunk_text'], + user_query: 'q', + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue(flowOk); + mockedGetClient.mockReturnValue({ + query: vi.fn().mockResolvedValue([makeSearchResult()]), + count: vi.fn(), + } as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('query: happy path calls client.query and returns formatted rows', async () => { + const server = createMockServer(); + registerQueryTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const body = parseToolJson( + await server.getHandler('query')!({ + query_text: 'contracts', + namespace: 'wg21', + top_k: 5, + use_reranking: true, + }) + ); + + expect(body.status).toBe('success'); + expect(body.mode).toBe('query'); + expect(body.result_count).toBe(1); + expect(Array.isArray(body.results)).toBe(true); + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'contracts', + namespace: 'wg21', + topK: 5, + useReranking: true, + }) + ); + }); + + it('query_fast: uses no reranking and default lightweight fields', async () => { + const server = createMockServer(); + registerQueryTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const body = parseToolJson( + await server.getHandler('query_fast')!({ + query_text: 'list', + namespace: 'wg21', + top_k: 10, + }) + ); + + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + useReranking: false, + fields: [...FAST_QUERY_FIELDS], + }) + ); + expect(body.mode).toBe('query_fast'); + expect(body.status).toBe('success'); + }); + + it('query: rejects empty query_text before calling Pinecone', async () => { + const server = createMockServer(); + registerQueryTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const raw = await server.getHandler('query')!({ + query_text: ' ', + namespace: 'wg21', + top_k: 10, + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + expect(query).not.toHaveBeenCalled(); + const body = parseToolJson(raw); + expect(body.message).toBe('Query text cannot be empty'); + }); + + it('query: returns flow error when suggest_query_params was not called first', async () => { + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({ + ok: false, + message: + 'Flow requires suggest_query_params first. Call suggest_query_params with namespace and user_query before query/count tools.', + }); + + const server = createMockServer(); + registerQueryTool(server as never); + const query = mockedGetClient().query as ReturnType; + + const raw = await server.getHandler('query')!({ + query_text: 'hello', + namespace: 'wg21', + top_k: 10, + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + expect(query).not.toHaveBeenCalled(); + const body = parseToolJson(raw); + expect(body.message).toBe( + 'Flow requires suggest_query_params first. Call suggest_query_params with namespace and user_query before query/count tools.' + ); + }); + + it('query: returns TTL expiry message when suggestion context expired', async () => { + vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({ + ok: false, + message: + 'Previous suggest_query_params context expired (30 minutes). Call suggest_query_params again before query/count tools.', + }); + + const server = createMockServer(); + registerQueryTool(server as never); + + const body = parseToolJson( + await server.getHandler('query')!({ + query_text: 'hello', + namespace: 'wg21', + top_k: 10, + }) + ); + + expect(body.status).toBe('error'); + expect(body.message).toBe( + 'Previous suggest_query_params context expired (30 minutes). Call suggest_query_params again before query/count tools.' + ); + }); + + it('query: surfaces unreranked hits when client returns reranked:false (rerank fallback shape)', async () => { + mockedGetClient.mockReturnValue({ + query: vi + .fn() + .mockResolvedValue([makeSearchResult({ reranked: false, score: 0.5, content: 'x' })]), + count: vi.fn(), + } as never); + + const server = createMockServer(); + registerQueryTool(server as never); + + const body = parseToolJson( + await server.getHandler('query')!({ + query_text: 'hello', + namespace: 'wg21', + top_k: 3, + }) + ); + + expect(body.status).toBe('success'); + const rows = body.results as Array<{ reranked: boolean }>; + expect(rows).toHaveLength(1); + expect(rows[0].reranked).toBe(false); + }); +}); diff --git a/src/server/tools/suggest-query-params-tool.test.ts b/src/server/tools/suggest-query-params-tool.test.ts new file mode 100644 index 0000000..3c6e317 --- /dev/null +++ b/src/server/tools/suggest-query-params-tool.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getNamespacesWithCache } from '../namespaces-cache.js'; +import { markSuggested } from '../suggestion-flow.js'; +import { registerSuggestQueryParamsTool } from './suggest-query-params-tool.js'; +import { createMockServer, makeNamespaceCacheEntry, parseToolJson } from './test-helpers.js'; + +vi.mock('../namespaces-cache.js', () => ({ + getNamespacesWithCache: vi.fn(), +})); + +vi.mock('../suggestion-flow.js', () => ({ + markSuggested: vi.fn(), +})); + +const mockedGetNamespaces = vi.mocked(getNamespacesWithCache); +const mockedMarkSuggested = vi.mocked(markSuggested); + +describe('suggest_query_params tool handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('marks suggestion flow and returns success when namespace exists in cache', async () => { + mockedGetNamespaces.mockResolvedValue({ + data: [makeNamespaceCacheEntry('wg21')], + cache_hit: false, + expires_at: Date.now() + 1_800_000, + }); + + const server = createMockServer(); + registerSuggestQueryParamsTool(server as never); + const body = parseToolJson( + await server.getHandler('suggest_query_params')!({ + namespace: 'wg21', + user_query: 'List papers with titles', + }) + ); + + expect(body.status).toBe('success'); + expect(body.namespace_found).toBe(true); + expect(mockedMarkSuggested).toHaveBeenCalledTimes(1); + expect(mockedMarkSuggested).toHaveBeenCalledWith( + 'wg21', + expect.objectContaining({ + user_query: 'List papers with titles', + suggested_fields: expect.any(Array), + }) + ); + }); + + it('does not mark suggested when namespace is absent from cache', async () => { + mockedGetNamespaces.mockResolvedValue({ + data: [makeNamespaceCacheEntry('other')], + cache_hit: true, + expires_at: Date.now() + 1_800_000, + }); + + const server = createMockServer(); + registerSuggestQueryParamsTool(server as never); + const body = parseToolJson( + await server.getHandler('suggest_query_params')!({ + namespace: 'missing-ns', + user_query: 'anything', + }) + ); + + expect(body.namespace_found).toBe(false); + expect(mockedMarkSuggested).not.toHaveBeenCalled(); + }); + + it('returns error when user_query is empty', async () => { + mockedGetNamespaces.mockResolvedValue({ + data: [], + cache_hit: false, + expires_at: Date.now() + 1_800_000, + }); + + const server = createMockServer(); + registerSuggestQueryParamsTool(server as never); + const raw = await server.getHandler('suggest_query_params')!({ + namespace: 'wg21', + user_query: ' ', + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + const body = parseToolJson(raw); + expect(body.status).toBe('error'); + expect(body.message).toBe('user_query cannot be empty'); + }); + + it('returns error when namespace cache fails', async () => { + mockedGetNamespaces.mockRejectedValue(new Error('cache boom')); + + const server = createMockServer(); + registerSuggestQueryParamsTool(server as never); + const raw = await server.getHandler('suggest_query_params')!({ + namespace: 'wg21', + user_query: 'hello', + }); + + expect((raw as { isError?: boolean }).isError).toBe(true); + const body = parseToolJson(raw); + expect(body.status).toBe('error'); + expect(String(body.message)).toBe('Failed to suggest query params'); + }); +}); diff --git a/src/server/tools/test-helpers.ts b/src/server/tools/test-helpers.ts new file mode 100644 index 0000000..3ed1642 --- /dev/null +++ b/src/server/tools/test-helpers.ts @@ -0,0 +1,60 @@ +import type { SearchResult } from '../../types.js'; + +/** Handler invoked by MCP tool registration (params shape varies by tool). */ +export type ToolHandler = (params: Record) => Promise; + +/** + * Minimal stand-in for {@link McpServer} that records `registerTool` handlers by name. + */ +export function createMockServer(): { + registerTool: (name: string, _schema: unknown, handler: ToolHandler) => void; + getHandler: (name: string) => ToolHandler | undefined; + handlers: Map; +} { + const handlers = new Map(); + return { + registerTool(name, _schema, handler) { + handlers.set(name, handler); + }, + getHandler(name) { + return handlers.get(name); + }, + handlers, + }; +} + +/** Parse JSON body from {@link jsonResponse} / {@link jsonErrorResponse} payload. */ +export function parseToolJson(payload: unknown): Record { + const p = payload as { content: Array<{ type: string; text: string }> }; + const text = p.content[0]?.text; + if (typeof text !== 'string') { + throw new Error('Expected text content in tool response'); + } + return JSON.parse(text) as Record; +} + +export function makeSearchResult(overrides?: Partial): SearchResult { + return { + id: 'hit-1', + content: 'chunk body', + score: 0.95, + metadata: { document_number: 'WG21-P1234', title: 'T', author: 'A', url: 'https://x' }, + reranked: true, + ...overrides, + }; +} + +/** Shape returned by {@link getNamespacesWithCache} `data` entries. */ +export function makeNamespaceCacheEntry( + namespace: string, + metadata: Record = { + document_number: 'string', + title: 'string', + url: 'string', + author: 'string', + chunk_text: 'string', + }, + recordCount = 42 +): { namespace: string; recordCount: number; metadata: Record } { + return { namespace, recordCount, metadata }; +} diff --git a/vitest.config.ts b/vitest.config.ts index eacb728..fbae058 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ environment: 'node', coverage: { provider: 'v8', - reporter: ['text', 'json', 'html'], + reporter: ['text', 'json', 'html', 'lcov'], exclude: ['node_modules/', 'dist/', '**/*.test.ts'], }, }, From 52d21f4b300dae4181add192f9bcc065b4349298 Mon Sep 17 00:00:00 2001 From: zho Date: Thu, 14 May 2026 05:02:30 +0800 Subject: [PATCH 2/4] fixed line error --- src/server/tools/guided-query-tool.test.ts | 7 ++++++- src/server/tools/query-documents-tool.test.ts | 5 +---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/server/tools/guided-query-tool.test.ts b/src/server/tools/guided-query-tool.test.ts index 056fbac..6699b98 100644 --- a/src/server/tools/guided-query-tool.test.ts +++ b/src/server/tools/guided-query-tool.test.ts @@ -2,7 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getPineconeClient } from '../client-context.js'; import { getNamespacesWithCache } from '../namespaces-cache.js'; import { registerGuidedQueryTool } from './guided-query-tool.js'; -import { createMockServer, makeNamespaceCacheEntry, makeSearchResult, parseToolJson } from './test-helpers.js'; +import { + createMockServer, + makeNamespaceCacheEntry, + makeSearchResult, + parseToolJson, +} from './test-helpers.js'; vi.mock('../client-context.js', () => ({ getPineconeClient: vi.fn(), diff --git a/src/server/tools/query-documents-tool.test.ts b/src/server/tools/query-documents-tool.test.ts index 10dab60..0e0932a 100644 --- a/src/server/tools/query-documents-tool.test.ts +++ b/src/server/tools/query-documents-tool.test.ts @@ -62,10 +62,7 @@ describe('query_documents tool handler', () => { }) ); - const expectedTopK = Math.min( - QUERY_DOCUMENTS_MAX_CHUNKS, - DEFAULT_QUERY_DOCUMENTS_TOP_K * 50 - ); + const expectedTopK = Math.min(QUERY_DOCUMENTS_MAX_CHUNKS, DEFAULT_QUERY_DOCUMENTS_TOP_K * 50); expect(query).toHaveBeenCalledWith( expect.objectContaining({ query: 'semantic question', From 39873a96f239928910da1e2ccfd46bd1799c27b6 Mon Sep 17 00:00:00 2001 From: zho Date: Thu, 14 May 2026 08:26:45 +0800 Subject: [PATCH 3/4] fixed test errors --- package.json | 1 + src/server/tools/guided-query-tool.test.ts | 5 +++++ src/server/tools/test-helpers.ts | 11 ++++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 52c8e5a..5400369 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "start": "node dist/index.js", "smoke": "npm run build && node dist/index.js --help", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest", "test:search": "tsx scripts/test-search.ts", "test:mcp": "node test-mcp-server.js", diff --git a/src/server/tools/guided-query-tool.test.ts b/src/server/tools/guided-query-tool.test.ts index 6699b98..9cd38db 100644 --- a/src/server/tools/guided-query-tool.test.ts +++ b/src/server/tools/guided-query-tool.test.ts @@ -17,6 +17,11 @@ vi.mock('../namespaces-cache.js', () => ({ getNamespacesWithCache: vi.fn(), })); +/** Real `markSuggested` may call `getServerConfig()` during sweep (CI has no API key); isolate the handler. */ +vi.mock('../suggestion-flow.js', () => ({ + markSuggested: vi.fn(), +})); + const mockedGetNamespaces = vi.mocked(getNamespacesWithCache); const mockedGetClient = vi.mocked(getPineconeClient); diff --git a/src/server/tools/test-helpers.ts b/src/server/tools/test-helpers.ts index 3ed1642..f0db291 100644 --- a/src/server/tools/test-helpers.ts +++ b/src/server/tools/test-helpers.ts @@ -7,14 +7,19 @@ export type ToolHandler = (params: Record) => Promise; * Minimal stand-in for {@link McpServer} that records `registerTool` handlers by name. */ export function createMockServer(): { - registerTool: (name: string, _schema: unknown, handler: ToolHandler) => void; + /** Matches {@link McpServer.registerTool}: `(name, config, callback)`; callback is always the last argument. */ + registerTool: (...args: unknown[]) => void; getHandler: (name: string) => ToolHandler | undefined; handlers: Map; } { const handlers = new Map(); return { - registerTool(name, _schema, handler) { - handlers.set(name, handler); + registerTool(...args: unknown[]) { + if (args.length < 2) return; + const name = args[0]; + const handler = args[args.length - 1]; + if (typeof name !== 'string' || typeof handler !== 'function') return; + handlers.set(name, handler as ToolHandler); }, getHandler(name) { return handlers.get(name); From 742f5e015434ba0c7ed290690e7a8438936a32c4 Mon Sep 17 00:00:00 2001 From: zho Date: Thu, 14 May 2026 08:32:21 +0800 Subject: [PATCH 4/4] fixed test errors --- src/server/tools/guided-query-tool.test.ts | 2 +- src/server/tools/query-documents-tool.test.ts | 2 +- src/server/tools/query-tool.test.ts | 25 +++++++++++-------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/server/tools/guided-query-tool.test.ts b/src/server/tools/guided-query-tool.test.ts index 9cd38db..05dcd08 100644 --- a/src/server/tools/guided-query-tool.test.ts +++ b/src/server/tools/guided-query-tool.test.ts @@ -65,7 +65,7 @@ describe('guided_query tool handler', () => { expect(body.status).toBe('success'); const trace = body.decision_trace as Record; expect(trace.selected_namespace).toBe('papers'); - expect(trace.selected_tool).toBe('query_detailed'); + expect(trace.selected_tool).toBe('detailed'); expect(query).toHaveBeenCalledWith( expect.objectContaining({ namespace: 'papers', diff --git a/src/server/tools/query-documents-tool.test.ts b/src/server/tools/query-documents-tool.test.ts index 0e0932a..c68b452 100644 --- a/src/server/tools/query-documents-tool.test.ts +++ b/src/server/tools/query-documents-tool.test.ts @@ -22,7 +22,7 @@ describe('query_documents tool handler', () => { ok: true as const, flow: { updatedAt: Date.now(), - recommended_tool: 'query_detailed' as const, + recommended_tool: 'detailed' as const, suggested_fields: [], user_query: 'q', }, diff --git a/src/server/tools/query-tool.test.ts b/src/server/tools/query-tool.test.ts index ad971b1..fe40079 100644 --- a/src/server/tools/query-tool.test.ts +++ b/src/server/tools/query-tool.test.ts @@ -11,12 +11,12 @@ vi.mock('../client-context.js', () => ({ const mockedGetClient = vi.mocked(getPineconeClient); -describe('query / query_fast / query_detailed tool handlers', () => { +describe('query tool handler (preset-driven)', () => { const flowOk = { ok: true as const, flow: { updatedAt: Date.now(), - recommended_tool: 'query_detailed' as const, + recommended_tool: 'detailed' as const, suggested_fields: ['chunk_text'], user_query: 'q', }, @@ -35,7 +35,7 @@ describe('query / query_fast / query_detailed tool handlers', () => { vi.restoreAllMocks(); }); - it('query: happy path calls client.query and returns formatted rows', async () => { + it('query (preset=full): happy path calls client.query and returns formatted rows', async () => { const server = createMockServer(); registerQueryTool(server as never); const query = mockedGetClient().query as ReturnType; @@ -45,6 +45,7 @@ describe('query / query_fast / query_detailed tool handlers', () => { query_text: 'contracts', namespace: 'wg21', top_k: 5, + preset: 'full', use_reranking: true, }) ); @@ -63,16 +64,17 @@ describe('query / query_fast / query_detailed tool handlers', () => { ); }); - it('query_fast: uses no reranking and default lightweight fields', async () => { + it('query (preset=fast): uses no reranking and default lightweight fields', async () => { const server = createMockServer(); registerQueryTool(server as never); const query = mockedGetClient().query as ReturnType; const body = parseToolJson( - await server.getHandler('query_fast')!({ + await server.getHandler('query')!({ query_text: 'list', namespace: 'wg21', top_k: 10, + preset: 'fast', }) ); @@ -95,6 +97,7 @@ describe('query / query_fast / query_detailed tool handlers', () => { query_text: ' ', namespace: 'wg21', top_k: 10, + preset: 'full', }); expect((raw as { isError?: boolean }).isError).toBe(true); @@ -118,6 +121,7 @@ describe('query / query_fast / query_detailed tool handlers', () => { query_text: 'hello', namespace: 'wg21', top_k: 10, + preset: 'full', }); expect((raw as { isError?: boolean }).isError).toBe(true); @@ -129,10 +133,11 @@ describe('query / query_fast / query_detailed tool handlers', () => { }); it('query: returns TTL expiry message when suggestion context expired', async () => { + const expiredMsg = + 'Previous suggest_query_params context expired. Call suggest_query_params again before query/count tools.'; vi.spyOn(suggestionFlow, 'requireSuggested').mockReturnValue({ ok: false, - message: - 'Previous suggest_query_params context expired (30 minutes). Call suggest_query_params again before query/count tools.', + message: expiredMsg, }); const server = createMockServer(); @@ -143,13 +148,12 @@ describe('query / query_fast / query_detailed tool handlers', () => { query_text: 'hello', namespace: 'wg21', top_k: 10, + preset: 'full', }) ); expect(body.status).toBe('error'); - expect(body.message).toBe( - 'Previous suggest_query_params context expired (30 minutes). Call suggest_query_params again before query/count tools.' - ); + expect(body.message).toBe(expiredMsg); }); it('query: surfaces unreranked hits when client returns reranked:false (rerank fallback shape)', async () => { @@ -168,6 +172,7 @@ describe('query / query_fast / query_detailed tool handlers', () => { query_text: 'hello', namespace: 'wg21', top_k: 3, + preset: 'full', }) );