@dpesch/mantisbt-mcp-server 1.10.3 → 1.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
- package/.gitea/workflows/ci.yml +0 -95
- package/.github/workflows/ci.yml +0 -32
- package/scripts/hooks/pre-push.mjs +0 -220
- package/scripts/init.mjs +0 -95
- package/scripts/record-fixtures.ts +0 -160
- package/tests/cache.test.ts +0 -265
- package/tests/client.test.ts +0 -372
- package/tests/config.test.ts +0 -263
- package/tests/fixtures/get_current_user.json +0 -40
- package/tests/fixtures/get_issue.json +0 -133
- package/tests/fixtures/get_issue_enums.json +0 -67
- package/tests/fixtures/get_issue_fields_sample.json +0 -76
- package/tests/fixtures/get_project_categories.json +0 -157
- package/tests/fixtures/get_project_versions.json +0 -3
- package/tests/fixtures/get_project_versions_with_data.json +0 -52
- package/tests/fixtures/list_issues.json +0 -95
- package/tests/fixtures/list_projects.json +0 -65
- package/tests/helpers/mock-server.ts +0 -166
- package/tests/helpers/search-mocks.ts +0 -84
- package/tests/prompts/prompts.test.ts +0 -242
- package/tests/resources/resources.test.ts +0 -309
- package/tests/search/embedder.test.ts +0 -81
- package/tests/search/highlight.test.ts +0 -129
- package/tests/search/store.test.ts +0 -193
- package/tests/search/sync.test.ts +0 -249
- package/tests/search/tools.test.ts +0 -661
- package/tests/tools/config.test.ts +0 -212
- package/tests/tools/files.test.ts +0 -343
- package/tests/tools/issues.test.ts +0 -1180
- package/tests/tools/metadata.test.ts +0 -509
- package/tests/tools/monitors.test.ts +0 -101
- package/tests/tools/projects.test.ts +0 -338
- package/tests/tools/relationships.test.ts +0 -177
- package/tests/tools/string-coercion.test.ts +0 -317
- package/tests/tools/users.test.ts +0 -62
- package/tests/utils/date-filter.test.ts +0 -169
- package/tests/version-hint.test.ts +0 -230
- package/tsconfig.build.json +0 -8
- package/vitest.config.ts +0 -8
|
@@ -1,661 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { MantisClient } from '../../src/client.js';
|
|
3
|
-
import { registerSearchTools } from '../../src/search/tools.js';
|
|
4
|
-
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
5
|
-
import { makeMockStore, makeMockEmbedder } from '../helpers/search-mocks.js';
|
|
6
|
-
import type { Embedder } from '../../src/search/embedder.js';
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Setup
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
|
|
12
|
-
let mockServer: MockMcpServer;
|
|
13
|
-
let client: MantisClient;
|
|
14
|
-
let embedder: Embedder;
|
|
15
|
-
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
mockServer = new MockMcpServer();
|
|
18
|
-
client = new MantisClient('https://mantis.example.com', 'test-token');
|
|
19
|
-
embedder = makeMockEmbedder();
|
|
20
|
-
vi.stubGlobal('fetch', vi.fn());
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
afterEach(() => {
|
|
24
|
-
vi.unstubAllGlobals();
|
|
25
|
-
vi.clearAllMocks();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// Tool registration
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
|
-
describe('registerSearchTools – registration', () => {
|
|
33
|
-
it('registers search_issues', () => {
|
|
34
|
-
const store = makeMockStore({ itemCount: 0 });
|
|
35
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
36
|
-
expect(mockServer.hasToolRegistered('search_issues')).toBe(true);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('registers rebuild_search_index', () => {
|
|
40
|
-
const store = makeMockStore({ itemCount: 0 });
|
|
41
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
42
|
-
expect(mockServer.hasToolRegistered('rebuild_search_index')).toBe(true);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// search_issues – empty store
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
|
|
50
|
-
describe('search_issues – empty store', () => {
|
|
51
|
-
it('returns error message when store is empty', async () => {
|
|
52
|
-
const store = makeMockStore({ itemCount: 0 });
|
|
53
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
54
|
-
|
|
55
|
-
const result = await mockServer.callTool('search_issues', { query: 'login error', top_n: 5 });
|
|
56
|
-
|
|
57
|
-
expect(result.isError).toBe(true);
|
|
58
|
-
expect(result.content[0]!.text).toContain('Search index is empty');
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
// search_issues – with results
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
describe('search_issues – with results', () => {
|
|
67
|
-
it('returns a JSON array with id and score', async () => {
|
|
68
|
-
const store = makeMockStore({ itemCount: 3 });
|
|
69
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
70
|
-
|
|
71
|
-
const result = await mockServer.callTool('search_issues', { query: 'login error', top_n: 3 });
|
|
72
|
-
|
|
73
|
-
expect(result.isError).toBeUndefined();
|
|
74
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; score: number }>;
|
|
75
|
-
expect(Array.isArray(parsed)).toBe(true);
|
|
76
|
-
expect(parsed.length).toBeGreaterThan(0);
|
|
77
|
-
expect(typeof parsed[0]!.id).toBe('number');
|
|
78
|
-
expect(typeof parsed[0]!.score).toBe('number');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('respects the top_n parameter', async () => {
|
|
82
|
-
const store = makeMockStore({ itemCount: 10 });
|
|
83
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
84
|
-
|
|
85
|
-
const result = await mockServer.callTool('search_issues', { query: 'bug', top_n: 2 });
|
|
86
|
-
|
|
87
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; score: number }>;
|
|
88
|
-
expect(parsed.length).toBeLessThanOrEqual(2);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('calls embedder.embed with the query', async () => {
|
|
92
|
-
const store = makeMockStore({ itemCount: 1 });
|
|
93
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
94
|
-
|
|
95
|
-
await mockServer.callTool('search_issues', { query: 'performance issue', top_n: 5 });
|
|
96
|
-
|
|
97
|
-
expect(embedder.embed).toHaveBeenCalledWith('performance issue');
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
// rebuild_search_index – full rebuild
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
|
|
105
|
-
describe('rebuild_search_index – full: true', () => {
|
|
106
|
-
it('clears the store and resets lastSyncedAt before syncing', async () => {
|
|
107
|
-
const store = makeMockStore({ itemCount: 5 });
|
|
108
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
109
|
-
|
|
110
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
111
|
-
makeResponse(200, JSON.stringify({ issues: [], total_count: 0 }))
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
await mockServer.callTool('rebuild_search_index', { full: true });
|
|
115
|
-
|
|
116
|
-
expect(store.clear).toHaveBeenCalled();
|
|
117
|
-
expect(store.resetLastSyncedAt).toHaveBeenCalled();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('returns indexed, skipped, duration_ms in the response', async () => {
|
|
121
|
-
const store = makeMockStore({ itemCount: 0 });
|
|
122
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
123
|
-
|
|
124
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
125
|
-
makeResponse(200, JSON.stringify({ issues: [], total_count: 0 }))
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
const result = await mockServer.callTool('rebuild_search_index', { full: false });
|
|
129
|
-
|
|
130
|
-
expect(result.isError).toBeUndefined();
|
|
131
|
-
const parsed = JSON.parse(result.content[0]!.text) as {
|
|
132
|
-
indexed: number;
|
|
133
|
-
skipped: number;
|
|
134
|
-
duration_ms: number;
|
|
135
|
-
};
|
|
136
|
-
expect(typeof parsed.indexed).toBe('number');
|
|
137
|
-
expect(typeof parsed.skipped).toBe('number');
|
|
138
|
-
expect(typeof parsed.duration_ms).toBe('number');
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
// rebuild_search_index – incremental (full: false)
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
describe('rebuild_search_index – full: false', () => {
|
|
147
|
-
it('does NOT clear the store', async () => {
|
|
148
|
-
const store = makeMockStore({ itemCount: 0 });
|
|
149
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
150
|
-
|
|
151
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
152
|
-
makeResponse(200, JSON.stringify({ issues: [], total_count: 0 }))
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
await mockServer.callTool('rebuild_search_index', { full: false });
|
|
156
|
-
|
|
157
|
-
expect(store.clear).not.toHaveBeenCalled();
|
|
158
|
-
expect(store.resetLastSyncedAt).not.toHaveBeenCalled();
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
// ---------------------------------------------------------------------------
|
|
163
|
-
// search_issues – select parameter
|
|
164
|
-
// ---------------------------------------------------------------------------
|
|
165
|
-
|
|
166
|
-
describe('search_issues – select parameter', () => {
|
|
167
|
-
it('returns plain {id, score, view_url} array when select is not provided', async () => {
|
|
168
|
-
const store = makeMockStore({ itemCount: 2 });
|
|
169
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
170
|
-
|
|
171
|
-
const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 2 });
|
|
172
|
-
|
|
173
|
-
expect(result.isError).toBeUndefined();
|
|
174
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; score: number; view_url: string }>;
|
|
175
|
-
expect(parsed[0]).toEqual(expect.objectContaining({ id: expect.any(Number), score: expect.any(Number) }));
|
|
176
|
-
expect(Object.keys(parsed[0]!)).toEqual(['id', 'score', 'view_url']);
|
|
177
|
-
expect(parsed[0]!.view_url).toBe(`https://mantis.example.com/view.php?id=${parsed[0]!.id}`);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('fetches issues and projects requested fields when select is provided', async () => {
|
|
181
|
-
const store = makeMockStore({ itemCount: 2 });
|
|
182
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
183
|
-
|
|
184
|
-
vi.mocked(fetch)
|
|
185
|
-
.mockResolvedValueOnce(
|
|
186
|
-
makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Login bug', status: { id: 10, name: 'new' }, priority: { id: 30, name: 'normal' } }] }))
|
|
187
|
-
)
|
|
188
|
-
.mockResolvedValueOnce(
|
|
189
|
-
makeResponse(200, JSON.stringify({ issues: [{ id: 2, summary: 'Crash on save', status: { id: 50, name: 'assigned' }, priority: { id: 40, name: 'high' } }] }))
|
|
190
|
-
);
|
|
191
|
-
|
|
192
|
-
const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 2, select: 'summary,status' });
|
|
193
|
-
|
|
194
|
-
expect(result.isError).toBeUndefined();
|
|
195
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
196
|
-
expect(parsed).toHaveLength(2);
|
|
197
|
-
// id and score always present
|
|
198
|
-
expect(parsed[0]).toHaveProperty('id');
|
|
199
|
-
expect(parsed[0]).toHaveProperty('score');
|
|
200
|
-
// requested fields present
|
|
201
|
-
expect(parsed[0]).toHaveProperty('summary', 'Login bug');
|
|
202
|
-
expect(parsed[0]).toHaveProperty('status');
|
|
203
|
-
// non-requested field absent
|
|
204
|
-
expect(parsed[0]).not.toHaveProperty('priority');
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('id and score are always included even when not listed in select', async () => {
|
|
208
|
-
const store = makeMockStore({ itemCount: 1 });
|
|
209
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
210
|
-
|
|
211
|
-
vi.mocked(fetch).mockResolvedValueOnce(
|
|
212
|
-
makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Test issue' }] }))
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 1, select: 'summary' });
|
|
216
|
-
|
|
217
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
218
|
-
expect(parsed[0]).toHaveProperty('id', 1);
|
|
219
|
-
expect(parsed[0]).toHaveProperty('score');
|
|
220
|
-
expect(parsed[0]).toHaveProperty('summary', 'Test issue');
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('falls back to {id, score} when issue fetch fails', async () => {
|
|
224
|
-
const store = makeMockStore({ itemCount: 2 });
|
|
225
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
226
|
-
|
|
227
|
-
vi.mocked(fetch)
|
|
228
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'OK issue' }] })))
|
|
229
|
-
.mockResolvedValueOnce(makeResponse(500, 'Internal Server Error'));
|
|
230
|
-
|
|
231
|
-
const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 2, select: 'summary' });
|
|
232
|
-
|
|
233
|
-
expect(result.isError).toBeUndefined();
|
|
234
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
235
|
-
expect(parsed).toHaveLength(2);
|
|
236
|
-
// First item enriched
|
|
237
|
-
expect(parsed[0]).toHaveProperty('summary');
|
|
238
|
-
// Second item fallback — id, score, and view_url
|
|
239
|
-
expect(Object.keys(parsed[1]!).sort()).toEqual(['id', 'score', 'view_url']);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it('omits non-existent fields silently', async () => {
|
|
243
|
-
const store = makeMockStore({ itemCount: 1 });
|
|
244
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
245
|
-
|
|
246
|
-
vi.mocked(fetch).mockResolvedValueOnce(
|
|
247
|
-
makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Test' }] }))
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 1, select: 'summary,nonexistent_field' });
|
|
251
|
-
|
|
252
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
253
|
-
expect(parsed[0]).toHaveProperty('summary', 'Test');
|
|
254
|
-
expect(parsed[0]).not.toHaveProperty('nonexistent_field');
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it('makes one API call per result when select is provided', async () => {
|
|
258
|
-
const store = makeMockStore({ itemCount: 3 });
|
|
259
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
260
|
-
|
|
261
|
-
vi.mocked(fetch).mockResolvedValue(
|
|
262
|
-
makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Issue' }] }))
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
await mockServer.callTool('search_issues', { query: 'test', top_n: 3, select: 'summary' });
|
|
266
|
-
|
|
267
|
-
expect(fetch).toHaveBeenCalledTimes(3);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('makes no API calls when select is not provided', async () => {
|
|
271
|
-
const store = makeMockStore({ itemCount: 3 });
|
|
272
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
273
|
-
|
|
274
|
-
await mockServer.callTool('search_issues', { query: 'test', top_n: 3 });
|
|
275
|
-
|
|
276
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
// ---------------------------------------------------------------------------
|
|
281
|
-
// get_search_index_status – registration
|
|
282
|
-
// ---------------------------------------------------------------------------
|
|
283
|
-
|
|
284
|
-
describe('get_search_index_status – registration', () => {
|
|
285
|
-
it('is registered', () => {
|
|
286
|
-
const store = makeMockStore({ itemCount: 0 });
|
|
287
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
288
|
-
expect(mockServer.hasToolRegistered('get_search_index_status')).toBe(true);
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
// ---------------------------------------------------------------------------
|
|
293
|
-
// get_search_index_status – reads from store, no API call
|
|
294
|
-
// ---------------------------------------------------------------------------
|
|
295
|
-
|
|
296
|
-
describe('get_search_index_status – with stored total', () => {
|
|
297
|
-
it('returns summary, indexed, total, percent and last_synced_at from store', async () => {
|
|
298
|
-
const store = makeMockStore({
|
|
299
|
-
itemCount: 42,
|
|
300
|
-
lastSyncedAt: '2026-03-16T10:00:00.000Z',
|
|
301
|
-
lastKnownTotal: 100,
|
|
302
|
-
});
|
|
303
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
304
|
-
|
|
305
|
-
const result = await mockServer.callTool('get_search_index_status', {});
|
|
306
|
-
|
|
307
|
-
expect(result.isError).toBeUndefined();
|
|
308
|
-
const parsed = JSON.parse(result.content[0]!.text) as {
|
|
309
|
-
summary: string;
|
|
310
|
-
indexed: number;
|
|
311
|
-
total: number;
|
|
312
|
-
percent: number;
|
|
313
|
-
last_synced_at: string;
|
|
314
|
-
};
|
|
315
|
-
expect(parsed.indexed).toBe(42);
|
|
316
|
-
expect(parsed.total).toBe(100);
|
|
317
|
-
expect(parsed.percent).toBe(42);
|
|
318
|
-
expect(parsed.summary).toBe('42/100 (42 %)');
|
|
319
|
-
expect(parsed.last_synced_at).toBe('2026-03-16T10:00:00.000Z');
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it('makes no API call', async () => {
|
|
323
|
-
const store = makeMockStore({ itemCount: 0, lastKnownTotal: 50 });
|
|
324
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
325
|
-
|
|
326
|
-
await mockServer.callTool('get_search_index_status', {});
|
|
327
|
-
|
|
328
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
// ---------------------------------------------------------------------------
|
|
333
|
-
// get_search_index_status – edge cases
|
|
334
|
-
// ---------------------------------------------------------------------------
|
|
335
|
-
|
|
336
|
-
// ---------------------------------------------------------------------------
|
|
337
|
-
// search_issues – date filters
|
|
338
|
-
// ---------------------------------------------------------------------------
|
|
339
|
-
|
|
340
|
-
describe('search_issues – updated_after filter (no select, uses store metadata)', () => {
|
|
341
|
-
it('returns only results whose store metadata updated_at is after the threshold', async () => {
|
|
342
|
-
const store = makeMockStore({
|
|
343
|
-
items: [
|
|
344
|
-
{ id: 1, score: 0.9, updated_at: '2026-03-26T00:00:00Z' }, // pass
|
|
345
|
-
{ id: 2, score: 0.8, updated_at: '2026-03-23T00:00:00Z' }, // fail
|
|
346
|
-
{ id: 3, score: 0.7, updated_at: '2026-03-25T12:00:00Z' }, // pass
|
|
347
|
-
],
|
|
348
|
-
});
|
|
349
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
350
|
-
|
|
351
|
-
const result = await mockServer.callTool('search_issues', {
|
|
352
|
-
query: 'test',
|
|
353
|
-
top_n: 10,
|
|
354
|
-
updated_after: '2026-03-25T00:00:00Z',
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
expect(result.isError).toBeUndefined();
|
|
358
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number }>;
|
|
359
|
-
expect(parsed.map(r => r.id)).toEqual([1, 3]);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it('makes no API calls when filtering via store metadata (no select)', async () => {
|
|
363
|
-
const store = makeMockStore({
|
|
364
|
-
items: [{ id: 1, updated_at: '2026-03-26T00:00:00Z' }],
|
|
365
|
-
});
|
|
366
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
367
|
-
|
|
368
|
-
await mockServer.callTool('search_issues', {
|
|
369
|
-
query: 'test',
|
|
370
|
-
top_n: 5,
|
|
371
|
-
updated_after: '2026-03-25T00:00:00Z',
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
it('excludes results with no updated_at in store metadata', async () => {
|
|
378
|
-
const store = makeMockStore({
|
|
379
|
-
items: [
|
|
380
|
-
{ id: 1, score: 0.9 }, // no updated_at → excluded
|
|
381
|
-
{ id: 2, score: 0.8, updated_at: '2026-03-26T00:00:00Z' }, // pass
|
|
382
|
-
],
|
|
383
|
-
});
|
|
384
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
385
|
-
|
|
386
|
-
const result = await mockServer.callTool('search_issues', {
|
|
387
|
-
query: 'test',
|
|
388
|
-
top_n: 10,
|
|
389
|
-
updated_after: '2026-03-25T00:00:00Z',
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number }>;
|
|
393
|
-
expect(parsed.map(r => r.id)).toEqual([2]);
|
|
394
|
-
});
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
describe('search_issues – updated_after filter (with select, uses fetched issue data)', () => {
|
|
398
|
-
it('returns only results whose fetched updated_at is after the threshold', async () => {
|
|
399
|
-
const store = makeMockStore({
|
|
400
|
-
items: [
|
|
401
|
-
{ id: 1, score: 0.9 },
|
|
402
|
-
{ id: 2, score: 0.8 },
|
|
403
|
-
],
|
|
404
|
-
});
|
|
405
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
406
|
-
|
|
407
|
-
vi.mocked(fetch)
|
|
408
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({
|
|
409
|
-
issues: [{ id: 1, summary: 'Recent bug', updated_at: '2026-03-26T00:00:00Z' }],
|
|
410
|
-
})))
|
|
411
|
-
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({
|
|
412
|
-
issues: [{ id: 2, summary: 'Old bug', updated_at: '2026-03-20T00:00:00Z' }],
|
|
413
|
-
})));
|
|
414
|
-
|
|
415
|
-
const result = await mockServer.callTool('search_issues', {
|
|
416
|
-
query: 'test',
|
|
417
|
-
top_n: 10,
|
|
418
|
-
select: 'summary',
|
|
419
|
-
updated_after: '2026-03-25T00:00:00Z',
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
expect(result.isError).toBeUndefined();
|
|
423
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; summary: string }>;
|
|
424
|
-
expect(parsed).toHaveLength(1);
|
|
425
|
-
expect(parsed[0]!.id).toBe(1);
|
|
426
|
-
expect(parsed[0]!.summary).toBe('Recent bug');
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
describe('get_search_index_status – edge cases', () => {
|
|
431
|
-
it('returns 0 % when stored total is 0', async () => {
|
|
432
|
-
const store = makeMockStore({ itemCount: 0, lastKnownTotal: 0 });
|
|
433
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
434
|
-
|
|
435
|
-
const result = await mockServer.callTool('get_search_index_status', {});
|
|
436
|
-
|
|
437
|
-
const parsed = JSON.parse(result.content[0]!.text) as { percent: number; summary: string };
|
|
438
|
-
expect(parsed.percent).toBe(0);
|
|
439
|
-
expect(parsed.summary).toBe('0/0 (0 %)');
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
it('returns 100 % when all issues are indexed', async () => {
|
|
443
|
-
const store = makeMockStore({ itemCount: 7842, lastKnownTotal: 7842 });
|
|
444
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
445
|
-
|
|
446
|
-
const result = await mockServer.callTool('get_search_index_status', {});
|
|
447
|
-
|
|
448
|
-
const parsed = JSON.parse(result.content[0]!.text) as { percent: number; summary: string };
|
|
449
|
-
expect(parsed.percent).toBe(100);
|
|
450
|
-
expect(parsed.summary).toBe('7842/7842 (100 %)');
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
it('returns total unknown when no sync has run yet', async () => {
|
|
454
|
-
const store = makeMockStore({ itemCount: 5, lastKnownTotal: null });
|
|
455
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
456
|
-
|
|
457
|
-
const result = await mockServer.callTool('get_search_index_status', {});
|
|
458
|
-
|
|
459
|
-
expect(result.isError).toBeUndefined();
|
|
460
|
-
const parsed = JSON.parse(result.content[0]!.text) as {
|
|
461
|
-
total: null;
|
|
462
|
-
percent: null;
|
|
463
|
-
summary: string;
|
|
464
|
-
};
|
|
465
|
-
expect(parsed.total).toBeNull();
|
|
466
|
-
expect(parsed.percent).toBeNull();
|
|
467
|
-
expect(parsed.summary).toContain('total unknown');
|
|
468
|
-
});
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
// ---------------------------------------------------------------------------
|
|
472
|
-
// search_issues – highlight parameter
|
|
473
|
-
// ---------------------------------------------------------------------------
|
|
474
|
-
|
|
475
|
-
describe('search_issues – highlight: true (no select, uses store metadata)', () => {
|
|
476
|
-
it('adds highlights field with bolded terms from store metadata summary', async () => {
|
|
477
|
-
const store = makeMockStore({
|
|
478
|
-
items: [{ id: 1, score: 0.9 }],
|
|
479
|
-
});
|
|
480
|
-
vi.mocked(store.getItem).mockResolvedValue({
|
|
481
|
-
id: 1,
|
|
482
|
-
vector: [],
|
|
483
|
-
metadata: { summary: 'Login error occurred', description: 'The login fails with error code 500.' },
|
|
484
|
-
});
|
|
485
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
486
|
-
|
|
487
|
-
const result = await mockServer.callTool('search_issues', {
|
|
488
|
-
query: 'login error',
|
|
489
|
-
top_n: 1,
|
|
490
|
-
highlight: true,
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
expect(result.isError).toBeUndefined();
|
|
494
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
495
|
-
expect(parsed[0]).toHaveProperty('highlights');
|
|
496
|
-
const highlights = parsed[0]!['highlights'] as Record<string, string>;
|
|
497
|
-
expect(highlights['summary']).toContain('**');
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
it('omits highlights field when no query terms match', async () => {
|
|
501
|
-
const store = makeMockStore({
|
|
502
|
-
items: [{ id: 1, score: 0.9 }],
|
|
503
|
-
});
|
|
504
|
-
vi.mocked(store.getItem).mockResolvedValue({
|
|
505
|
-
id: 1,
|
|
506
|
-
vector: [],
|
|
507
|
-
metadata: { summary: 'Unrelated issue', description: undefined },
|
|
508
|
-
});
|
|
509
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
510
|
-
|
|
511
|
-
const result = await mockServer.callTool('search_issues', {
|
|
512
|
-
query: 'xyzzy',
|
|
513
|
-
top_n: 1,
|
|
514
|
-
highlight: true,
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
518
|
-
expect(parsed[0]).not.toHaveProperty('highlights');
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
it('does not call store.getItem for highlighting when highlight is false', async () => {
|
|
522
|
-
const store = makeMockStore({ itemCount: 2 });
|
|
523
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
524
|
-
|
|
525
|
-
await mockServer.callTool('search_issues', { query: 'login', top_n: 2 });
|
|
526
|
-
|
|
527
|
-
// getItem should NOT have been called (no date filter, no highlight)
|
|
528
|
-
expect(store.getItem).not.toHaveBeenCalled();
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
it('still returns id and score alongside highlights', async () => {
|
|
532
|
-
const store = makeMockStore({
|
|
533
|
-
items: [{ id: 42, score: 0.85 }],
|
|
534
|
-
});
|
|
535
|
-
vi.mocked(store.getItem).mockResolvedValue({
|
|
536
|
-
id: 42,
|
|
537
|
-
vector: [],
|
|
538
|
-
metadata: { summary: 'Login timeout issue' },
|
|
539
|
-
});
|
|
540
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
541
|
-
|
|
542
|
-
const result = await mockServer.callTool('search_issues', {
|
|
543
|
-
query: 'login',
|
|
544
|
-
top_n: 1,
|
|
545
|
-
highlight: true,
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
549
|
-
expect(parsed[0]).toHaveProperty('id', 42);
|
|
550
|
-
expect(parsed[0]).toHaveProperty('score');
|
|
551
|
-
expect(parsed[0]).toHaveProperty('highlights');
|
|
552
|
-
});
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
describe('search_issues – highlight: true (with select)', () => {
|
|
556
|
-
it('highlights summary from fetched issue when summary is in select', async () => {
|
|
557
|
-
const store = makeMockStore({ itemCount: 1 });
|
|
558
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
559
|
-
|
|
560
|
-
vi.mocked(fetch).mockResolvedValueOnce(
|
|
561
|
-
makeResponse(200, JSON.stringify({
|
|
562
|
-
issues: [{ id: 1, summary: 'Login error in dashboard', status: { id: 10, name: 'new' } }],
|
|
563
|
-
}))
|
|
564
|
-
);
|
|
565
|
-
|
|
566
|
-
const result = await mockServer.callTool('search_issues', {
|
|
567
|
-
query: 'login error',
|
|
568
|
-
top_n: 1,
|
|
569
|
-
select: 'summary,status',
|
|
570
|
-
highlight: true,
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
expect(result.isError).toBeUndefined();
|
|
574
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
575
|
-
expect(parsed[0]).toHaveProperty('highlights');
|
|
576
|
-
const highlights = parsed[0]!['highlights'] as Record<string, string>;
|
|
577
|
-
expect(highlights['summary']).toContain('**Login**');
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it('falls back to store metadata for highlighting when API fetch fails', async () => {
|
|
581
|
-
const store = makeMockStore({ items: [{ id: 1, score: 0.9 }] });
|
|
582
|
-
vi.mocked(store.getItem).mockResolvedValue({
|
|
583
|
-
id: 1,
|
|
584
|
-
vector: [],
|
|
585
|
-
metadata: { summary: 'Login crash issue' },
|
|
586
|
-
});
|
|
587
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
588
|
-
|
|
589
|
-
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(500, 'Server Error'));
|
|
590
|
-
|
|
591
|
-
const result = await mockServer.callTool('search_issues', {
|
|
592
|
-
query: 'login',
|
|
593
|
-
top_n: 1,
|
|
594
|
-
select: 'summary',
|
|
595
|
-
highlight: true,
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
// Falls back to {id, score} but we still try to add highlights from store metadata
|
|
599
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
600
|
-
expect(parsed[0]).toHaveProperty('id', 1);
|
|
601
|
-
});
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
describe('search_issues – highlight: true combined with date filter', () => {
|
|
605
|
-
it('returns highlights only for date-filtered results (no select)', async () => {
|
|
606
|
-
const store = makeMockStore({
|
|
607
|
-
items: [
|
|
608
|
-
{ id: 1, score: 0.9, updated_at: '2026-03-26T00:00:00Z' }, // passes filter
|
|
609
|
-
{ id: 2, score: 0.8, updated_at: '2026-03-20T00:00:00Z' }, // filtered out
|
|
610
|
-
],
|
|
611
|
-
});
|
|
612
|
-
vi.mocked(store.getItem).mockImplementation(async (id: number) => ({
|
|
613
|
-
id,
|
|
614
|
-
vector: [],
|
|
615
|
-
metadata: {
|
|
616
|
-
summary: id === 1 ? 'Login crash issue' : 'Old login bug',
|
|
617
|
-
updated_at: id === 1 ? '2026-03-26T00:00:00Z' : '2026-03-20T00:00:00Z',
|
|
618
|
-
},
|
|
619
|
-
}));
|
|
620
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
621
|
-
|
|
622
|
-
const result = await mockServer.callTool('search_issues', {
|
|
623
|
-
query: 'login',
|
|
624
|
-
top_n: 10,
|
|
625
|
-
highlight: true,
|
|
626
|
-
updated_after: '2026-03-25T00:00:00Z',
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number }>;
|
|
630
|
-
expect(parsed).toHaveLength(1);
|
|
631
|
-
expect(parsed[0]!.id).toBe(1);
|
|
632
|
-
});
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
// ---------------------------------------------------------------------------
|
|
636
|
-
// string-coercion – highlight as string
|
|
637
|
-
// ---------------------------------------------------------------------------
|
|
638
|
-
|
|
639
|
-
describe('string-coercion – search_issues highlight as string', () => {
|
|
640
|
-
it('accepts highlight "true" as boolean true', async () => {
|
|
641
|
-
const store = makeMockStore({
|
|
642
|
-
items: [{ id: 1, score: 0.9 }],
|
|
643
|
-
});
|
|
644
|
-
vi.mocked(store.getItem).mockResolvedValue({
|
|
645
|
-
id: 1,
|
|
646
|
-
vector: [],
|
|
647
|
-
metadata: { summary: 'Login error occurred', description: 'The login fails.' },
|
|
648
|
-
});
|
|
649
|
-
registerSearchTools(mockServer as never, client, store, embedder);
|
|
650
|
-
|
|
651
|
-
const result = await mockServer.callTool(
|
|
652
|
-
'search_issues',
|
|
653
|
-
{ query: 'login error', top_n: 1, highlight: 'true' },
|
|
654
|
-
{ validate: true },
|
|
655
|
-
);
|
|
656
|
-
|
|
657
|
-
expect(result.isError).toBeUndefined();
|
|
658
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
659
|
-
expect(parsed[0]).toHaveProperty('highlights');
|
|
660
|
-
});
|
|
661
|
-
});
|