@mhalder/qdrant-mcp-server 3.1.2 → 3.2.0

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.
@@ -0,0 +1,752 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import type { CodeSearchResult } from "../code/types.js";
3
+ import type { GitSearchResult } from "../git/types.js";
4
+ import {
5
+ buildCorrelations,
6
+ normalizeScores,
7
+ calculateRRFScore,
8
+ pathsMatch,
9
+ } from "./federated.js";
10
+
11
+ // ============================================================================
12
+ // Unit Tests for Helper Functions
13
+ // ============================================================================
14
+
15
+ describe("normalizeScores", () => {
16
+ it("should return empty array for empty input", () => {
17
+ const result = normalizeScores([]);
18
+ expect(result).toEqual([]);
19
+ });
20
+
21
+ it("should normalize single result to score of 1", () => {
22
+ const input = [{ score: 0.5, id: "a" }];
23
+ const result = normalizeScores(input);
24
+
25
+ expect(result).toHaveLength(1);
26
+ expect(result[0].score).toBe(1);
27
+ expect(result[0].id).toBe("a");
28
+ });
29
+
30
+ it("should normalize scores to [0, 1] range", () => {
31
+ const input = [
32
+ { score: 10, id: "a" },
33
+ { score: 5, id: "b" },
34
+ { score: 0, id: "c" },
35
+ ];
36
+ const result = normalizeScores(input);
37
+
38
+ expect(result).toHaveLength(3);
39
+ expect(result.find((r) => r.id === "a")?.score).toBe(1); // highest
40
+ expect(result.find((r) => r.id === "b")?.score).toBe(0.5); // middle
41
+ expect(result.find((r) => r.id === "c")?.score).toBe(0); // lowest
42
+ });
43
+
44
+ it("should handle identical scores by normalizing all to 1", () => {
45
+ const input = [
46
+ { score: 0.7, id: "a" },
47
+ { score: 0.7, id: "b" },
48
+ { score: 0.7, id: "c" },
49
+ ];
50
+ const result = normalizeScores(input);
51
+
52
+ expect(result.every((r) => r.score === 1)).toBe(true);
53
+ });
54
+
55
+ it("should handle negative scores", () => {
56
+ const input = [
57
+ { score: -5, id: "a" },
58
+ { score: 5, id: "b" },
59
+ ];
60
+ const result = normalizeScores(input);
61
+
62
+ expect(result.find((r) => r.id === "a")?.score).toBe(0);
63
+ expect(result.find((r) => r.id === "b")?.score).toBe(1);
64
+ });
65
+
66
+ it("should preserve other properties", () => {
67
+ const input = [
68
+ { score: 1, id: "a", extra: "data1" },
69
+ { score: 0, id: "b", extra: "data2" },
70
+ ];
71
+ const result = normalizeScores(input);
72
+
73
+ expect(result[0].extra).toBeDefined();
74
+ expect(result[1].extra).toBeDefined();
75
+ });
76
+ });
77
+
78
+ describe("calculateRRFScore", () => {
79
+ it("should calculate RRF for single rank", () => {
80
+ // RRF with k=60: 1/(60+1) = 0.01639...
81
+ const score = calculateRRFScore([1]);
82
+ expect(score).toBeCloseTo(1 / 61, 5);
83
+ });
84
+
85
+ it("should calculate RRF for multiple ranks", () => {
86
+ // RRF with k=60: 1/(60+1) + 1/(60+2) = 0.01639... + 0.01613...
87
+ const score = calculateRRFScore([1, 2]);
88
+ expect(score).toBeCloseTo(1 / 61 + 1 / 62, 5);
89
+ });
90
+
91
+ it("should return 0 for empty ranks", () => {
92
+ const score = calculateRRFScore([]);
93
+ expect(score).toBe(0);
94
+ });
95
+
96
+ it("should give higher score to lower ranks (better positions)", () => {
97
+ const scoreRank1 = calculateRRFScore([1]);
98
+ const scoreRank10 = calculateRRFScore([10]);
99
+
100
+ expect(scoreRank1).toBeGreaterThan(scoreRank10);
101
+ });
102
+
103
+ it("should handle high ranks without overflow", () => {
104
+ const score = calculateRRFScore([1000]);
105
+ expect(score).toBeCloseTo(1 / 1060, 5);
106
+ expect(Number.isFinite(score)).toBe(true);
107
+ });
108
+ });
109
+
110
+ describe("pathsMatch", () => {
111
+ it("should match identical paths", () => {
112
+ expect(pathsMatch("src/auth/user.ts", "src/auth/user.ts")).toBe(true);
113
+ });
114
+
115
+ it("should match when shorter path is suffix of longer path", () => {
116
+ expect(pathsMatch("app/models/user.ts", "models/user.ts")).toBe(true);
117
+ expect(pathsMatch("models/user.ts", "app/models/user.ts")).toBe(true);
118
+ });
119
+
120
+ it("should match filename only against full path", () => {
121
+ expect(pathsMatch("src/components/Button.tsx", "Button.tsx")).toBe(true);
122
+ expect(pathsMatch("Button.tsx", "src/components/Button.tsx")).toBe(true);
123
+ });
124
+
125
+ it("should NOT match paths with different parent directories", () => {
126
+ // This was the false positive case
127
+ expect(pathsMatch("app/models/user.ts", "lib/user.ts")).toBe(false);
128
+ expect(pathsMatch("src/auth/middleware.ts", "other/middleware.ts")).toBe(
129
+ false,
130
+ );
131
+ });
132
+
133
+ it("should NOT match completely different filenames", () => {
134
+ expect(pathsMatch("src/auth.ts", "src/user.ts")).toBe(false);
135
+ });
136
+
137
+ it("should handle Windows-style backslashes", () => {
138
+ expect(pathsMatch("src\\auth\\user.ts", "auth/user.ts")).toBe(true);
139
+ });
140
+
141
+ it("should be case-insensitive", () => {
142
+ expect(pathsMatch("src/Auth/User.ts", "auth/user.ts")).toBe(true);
143
+ });
144
+
145
+ it("should return false for empty paths", () => {
146
+ expect(pathsMatch("", "src/file.ts")).toBe(false);
147
+ expect(pathsMatch("src/file.ts", "")).toBe(false);
148
+ expect(pathsMatch("", "")).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe("buildCorrelations", () => {
153
+ const createCodeResult = (
154
+ overrides: Partial<CodeSearchResult> = {},
155
+ ): CodeSearchResult => ({
156
+ content: "function test() {}",
157
+ filePath: "src/utils/helper.ts",
158
+ startLine: 10,
159
+ endLine: 20,
160
+ language: "typescript",
161
+ score: 0.85,
162
+ fileExtension: ".ts",
163
+ ...overrides,
164
+ });
165
+
166
+ const createGitResult = (
167
+ overrides: Partial<GitSearchResult> = {},
168
+ ): GitSearchResult => ({
169
+ content: "Commit content",
170
+ commitHash: "abc123def456",
171
+ shortHash: "abc123d",
172
+ author: "John Doe",
173
+ date: "2024-01-15T10:30:00Z",
174
+ subject: "feat: add helper function",
175
+ commitType: "feat",
176
+ files: ["src/utils/helper.ts", "src/index.ts"],
177
+ score: 0.9,
178
+ ...overrides,
179
+ });
180
+
181
+ it("should find correlations when file paths match", () => {
182
+ const codeResults = [createCodeResult({ filePath: "src/utils/helper.ts" })];
183
+ const gitResults = [
184
+ createGitResult({ files: ["src/utils/helper.ts", "src/index.ts"] }),
185
+ ];
186
+
187
+ const correlations = buildCorrelations(codeResults, gitResults);
188
+
189
+ expect(correlations).toHaveLength(1);
190
+ expect(correlations[0].codeResult.filePath).toBe("src/utils/helper.ts");
191
+ expect(correlations[0].relatedCommits).toHaveLength(1);
192
+ expect(correlations[0].relatedCommits[0].shortHash).toBe("abc123d");
193
+ });
194
+
195
+ it("should match partial paths (relative vs absolute)", () => {
196
+ const codeResults = [
197
+ createCodeResult({ filePath: "/home/user/project/src/utils/helper.ts" }),
198
+ ];
199
+ const gitResults = [createGitResult({ files: ["src/utils/helper.ts"] })];
200
+
201
+ const correlations = buildCorrelations(codeResults, gitResults);
202
+
203
+ expect(correlations).toHaveLength(1);
204
+ expect(correlations[0].relatedCommits).toHaveLength(1);
205
+ });
206
+
207
+ it("should return empty array when no matches found", () => {
208
+ const codeResults = [createCodeResult({ filePath: "src/other/file.ts" })];
209
+ const gitResults = [createGitResult({ files: ["src/different/path.ts"] })];
210
+
211
+ const correlations = buildCorrelations(codeResults, gitResults);
212
+
213
+ expect(correlations).toHaveLength(0);
214
+ });
215
+
216
+ it("should find multiple related commits for one file", () => {
217
+ const codeResults = [createCodeResult({ filePath: "src/utils/helper.ts" })];
218
+ const gitResults = [
219
+ createGitResult({
220
+ shortHash: "abc123d",
221
+ files: ["src/utils/helper.ts"],
222
+ }),
223
+ createGitResult({
224
+ shortHash: "xyz789a",
225
+ files: ["src/utils/helper.ts", "src/other.ts"],
226
+ }),
227
+ ];
228
+
229
+ const correlations = buildCorrelations(codeResults, gitResults);
230
+
231
+ expect(correlations).toHaveLength(1);
232
+ expect(correlations[0].relatedCommits).toHaveLength(2);
233
+ expect(correlations[0].relatedCommits.map((c) => c.shortHash)).toContain(
234
+ "abc123d",
235
+ );
236
+ expect(correlations[0].relatedCommits.map((c) => c.shortHash)).toContain(
237
+ "xyz789a",
238
+ );
239
+ });
240
+
241
+ it("should handle multiple code results with different correlations", () => {
242
+ const codeResults = [
243
+ createCodeResult({ filePath: "src/utils/helper.ts" }),
244
+ createCodeResult({ filePath: "src/services/api.ts" }),
245
+ ];
246
+ const gitResults = [
247
+ createGitResult({
248
+ shortHash: "abc123d",
249
+ files: ["src/utils/helper.ts"],
250
+ }),
251
+ createGitResult({
252
+ shortHash: "xyz789a",
253
+ files: ["src/services/api.ts"],
254
+ }),
255
+ ];
256
+
257
+ const correlations = buildCorrelations(codeResults, gitResults);
258
+
259
+ expect(correlations).toHaveLength(2);
260
+ });
261
+
262
+ it("should handle empty code results", () => {
263
+ const gitResults = [createGitResult()];
264
+
265
+ const correlations = buildCorrelations([], gitResults);
266
+
267
+ expect(correlations).toHaveLength(0);
268
+ });
269
+
270
+ it("should handle empty git results", () => {
271
+ const codeResults = [createCodeResult()];
272
+
273
+ const correlations = buildCorrelations(codeResults, []);
274
+
275
+ expect(correlations).toHaveLength(0);
276
+ });
277
+
278
+ it("should handle case-insensitive path matching", () => {
279
+ const codeResults = [createCodeResult({ filePath: "SRC/Utils/Helper.ts" })];
280
+ const gitResults = [createGitResult({ files: ["src/utils/helper.ts"] })];
281
+
282
+ const correlations = buildCorrelations(codeResults, gitResults);
283
+
284
+ expect(correlations).toHaveLength(1);
285
+ });
286
+
287
+ it("should normalize Windows-style paths", () => {
288
+ const codeResults = [
289
+ createCodeResult({ filePath: "src\\utils\\helper.ts" }),
290
+ ];
291
+ const gitResults = [createGitResult({ files: ["src/utils/helper.ts"] })];
292
+
293
+ const correlations = buildCorrelations(codeResults, gitResults);
294
+
295
+ expect(correlations).toHaveLength(1);
296
+ });
297
+ });
298
+
299
+ // ============================================================================
300
+ // Integration Tests for Tool Implementations
301
+ // ============================================================================
302
+
303
+ describe("contextual_search integration", () => {
304
+ // Mock indexers
305
+ const mockCodeIndexer = {
306
+ getIndexStatus: vi.fn(),
307
+ searchCode: vi.fn(),
308
+ indexCodebase: vi.fn(),
309
+ reindexChanges: vi.fn(),
310
+ clearIndex: vi.fn(),
311
+ };
312
+
313
+ const mockGitHistoryIndexer = {
314
+ getIndexStatus: vi.fn(),
315
+ searchHistory: vi.fn(),
316
+ indexHistory: vi.fn(),
317
+ indexNewCommits: vi.fn(),
318
+ clearIndex: vi.fn(),
319
+ };
320
+
321
+ beforeEach(() => {
322
+ vi.resetAllMocks();
323
+ });
324
+
325
+ it("should execute code and git searches in parallel", async () => {
326
+ mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
327
+ mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
328
+ status: "indexed",
329
+ });
330
+ mockCodeIndexer.searchCode.mockResolvedValue([
331
+ {
332
+ content: "function test() {}",
333
+ filePath: "src/test.ts",
334
+ startLine: 1,
335
+ endLine: 5,
336
+ language: "typescript",
337
+ score: 0.9,
338
+ fileExtension: ".ts",
339
+ },
340
+ ]);
341
+ mockGitHistoryIndexer.searchHistory.mockResolvedValue([
342
+ {
343
+ content: "Commit content",
344
+ commitHash: "abc123",
345
+ shortHash: "abc123d",
346
+ author: "Test Author",
347
+ date: "2024-01-15",
348
+ subject: "feat: add test",
349
+ commitType: "feat",
350
+ files: ["src/test.ts"],
351
+ score: 0.85,
352
+ },
353
+ ]);
354
+
355
+ // Import and call performContextualSearch directly
356
+ const { registerFederatedTools } = await import("./federated.js");
357
+
358
+ // Create a mock server
359
+ const mockServer = {
360
+ registerTool: vi.fn(),
361
+ };
362
+
363
+ registerFederatedTools(mockServer as any, {
364
+ codeIndexer: mockCodeIndexer as any,
365
+ gitHistoryIndexer: mockGitHistoryIndexer as any,
366
+ });
367
+
368
+ // Get the handler for contextual_search
369
+ const contextualSearchCall = mockServer.registerTool.mock.calls.find(
370
+ (call) => call[0] === "contextual_search",
371
+ );
372
+ expect(contextualSearchCall).toBeDefined();
373
+
374
+ const handler = contextualSearchCall![2];
375
+ const result = await handler({
376
+ path: "/test/repo",
377
+ query: "test function",
378
+ codeLimit: 5,
379
+ gitLimit: 5,
380
+ correlate: true,
381
+ });
382
+
383
+ expect(mockCodeIndexer.searchCode).toHaveBeenCalledWith(
384
+ "/test/repo",
385
+ "test function",
386
+ { limit: 5 },
387
+ );
388
+ expect(mockGitHistoryIndexer.searchHistory).toHaveBeenCalledWith(
389
+ "/test/repo",
390
+ "test function",
391
+ { limit: 5 },
392
+ );
393
+ expect(result.content[0].text).toContain("Code Results");
394
+ expect(result.content[0].text).toContain("Git History Results");
395
+ expect(result.content[0].text).toContain("Correlations");
396
+ });
397
+
398
+ it("should return error when code index not found", async () => {
399
+ mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "not_indexed" });
400
+ mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
401
+ status: "indexed",
402
+ });
403
+
404
+ const { registerFederatedTools } = await import("./federated.js");
405
+ const mockServer = { registerTool: vi.fn() };
406
+
407
+ registerFederatedTools(mockServer as any, {
408
+ codeIndexer: mockCodeIndexer as any,
409
+ gitHistoryIndexer: mockGitHistoryIndexer as any,
410
+ });
411
+
412
+ const contextualSearchCall = mockServer.registerTool.mock.calls.find(
413
+ (call) => call[0] === "contextual_search",
414
+ );
415
+ const handler = contextualSearchCall![2];
416
+ const result = await handler({
417
+ path: "/test/repo",
418
+ query: "test",
419
+ });
420
+
421
+ expect(result.isError).toBe(true);
422
+ expect(result.content[0].text).toContain("Code index not found");
423
+ });
424
+
425
+ it("should return error when git index not found", async () => {
426
+ mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
427
+ mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
428
+ status: "not_indexed",
429
+ });
430
+
431
+ const { registerFederatedTools } = await import("./federated.js");
432
+ const mockServer = { registerTool: vi.fn() };
433
+
434
+ registerFederatedTools(mockServer as any, {
435
+ codeIndexer: mockCodeIndexer as any,
436
+ gitHistoryIndexer: mockGitHistoryIndexer as any,
437
+ });
438
+
439
+ const contextualSearchCall = mockServer.registerTool.mock.calls.find(
440
+ (call) => call[0] === "contextual_search",
441
+ );
442
+ const handler = contextualSearchCall![2];
443
+ const result = await handler({
444
+ path: "/test/repo",
445
+ query: "test",
446
+ });
447
+
448
+ expect(result.isError).toBe(true);
449
+ expect(result.content[0].text).toContain("Git history index not found");
450
+ });
451
+
452
+ it("should skip correlations when correlate=false", async () => {
453
+ mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
454
+ mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
455
+ status: "indexed",
456
+ });
457
+ mockCodeIndexer.searchCode.mockResolvedValue([
458
+ {
459
+ content: "code",
460
+ filePath: "src/test.ts",
461
+ startLine: 1,
462
+ endLine: 5,
463
+ language: "typescript",
464
+ score: 0.9,
465
+ fileExtension: ".ts",
466
+ },
467
+ ]);
468
+ mockGitHistoryIndexer.searchHistory.mockResolvedValue([
469
+ {
470
+ content: "commit",
471
+ commitHash: "abc123",
472
+ shortHash: "abc123d",
473
+ author: "Author",
474
+ date: "2024-01-15",
475
+ subject: "feat: test",
476
+ commitType: "feat",
477
+ files: ["src/test.ts"],
478
+ score: 0.85,
479
+ },
480
+ ]);
481
+
482
+ const { registerFederatedTools } = await import("./federated.js");
483
+ const mockServer = { registerTool: vi.fn() };
484
+
485
+ registerFederatedTools(mockServer as any, {
486
+ codeIndexer: mockCodeIndexer as any,
487
+ gitHistoryIndexer: mockGitHistoryIndexer as any,
488
+ });
489
+
490
+ const contextualSearchCall = mockServer.registerTool.mock.calls.find(
491
+ (call) => call[0] === "contextual_search",
492
+ );
493
+ const handler = contextualSearchCall![2];
494
+ const result = await handler({
495
+ path: "/test/repo",
496
+ query: "test",
497
+ correlate: false,
498
+ });
499
+
500
+ expect(result.content[0].text).not.toContain("Correlations (Code");
501
+ expect(result.content[0].text).toContain("0 correlation(s)");
502
+ });
503
+ });
504
+
505
+ describe("federated_search integration", () => {
506
+ const mockCodeIndexer = {
507
+ getIndexStatus: vi.fn(),
508
+ searchCode: vi.fn(),
509
+ indexCodebase: vi.fn(),
510
+ reindexChanges: vi.fn(),
511
+ clearIndex: vi.fn(),
512
+ };
513
+
514
+ const mockGitHistoryIndexer = {
515
+ getIndexStatus: vi.fn(),
516
+ searchHistory: vi.fn(),
517
+ indexHistory: vi.fn(),
518
+ indexNewCommits: vi.fn(),
519
+ clearIndex: vi.fn(),
520
+ };
521
+
522
+ beforeEach(() => {
523
+ vi.resetAllMocks();
524
+ });
525
+
526
+ it("should search across multiple repositories", async () => {
527
+ mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
528
+ mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
529
+ status: "indexed",
530
+ });
531
+ mockCodeIndexer.searchCode.mockResolvedValue([
532
+ {
533
+ content: "function test() {}",
534
+ filePath: "src/test.ts",
535
+ startLine: 1,
536
+ endLine: 5,
537
+ language: "typescript",
538
+ score: 0.9,
539
+ fileExtension: ".ts",
540
+ },
541
+ ]);
542
+ mockGitHistoryIndexer.searchHistory.mockResolvedValue([
543
+ {
544
+ content: "Commit",
545
+ commitHash: "abc123",
546
+ shortHash: "abc123d",
547
+ author: "Author",
548
+ date: "2024-01-15",
549
+ subject: "feat: add",
550
+ commitType: "feat",
551
+ files: ["src/test.ts"],
552
+ score: 0.85,
553
+ },
554
+ ]);
555
+
556
+ const { registerFederatedTools } = await import("./federated.js");
557
+ const mockServer = { registerTool: vi.fn() };
558
+
559
+ registerFederatedTools(mockServer as any, {
560
+ codeIndexer: mockCodeIndexer as any,
561
+ gitHistoryIndexer: mockGitHistoryIndexer as any,
562
+ });
563
+
564
+ const federatedSearchCall = mockServer.registerTool.mock.calls.find(
565
+ (call) => call[0] === "federated_search",
566
+ );
567
+ const handler = federatedSearchCall![2];
568
+ const result = await handler({
569
+ paths: ["/repo1", "/repo2"],
570
+ query: "test function",
571
+ searchType: "both",
572
+ limit: 20,
573
+ });
574
+
575
+ // Each repo gets searched for code and git
576
+ expect(mockCodeIndexer.searchCode).toHaveBeenCalledTimes(2);
577
+ expect(mockGitHistoryIndexer.searchHistory).toHaveBeenCalledTimes(2);
578
+ expect(result.content[0].text).toContain("Federated Search Results");
579
+ expect(result.content[0].text).toContain("Repositories: 2");
580
+ });
581
+
582
+ it("should fail fast when any repository is not indexed", async () => {
583
+ mockCodeIndexer.getIndexStatus
584
+ .mockResolvedValueOnce({ status: "indexed" })
585
+ .mockResolvedValueOnce({ status: "not_indexed" });
586
+ mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
587
+ status: "indexed",
588
+ });
589
+
590
+ const { registerFederatedTools } = await import("./federated.js");
591
+ const mockServer = { registerTool: vi.fn() };
592
+
593
+ registerFederatedTools(mockServer as any, {
594
+ codeIndexer: mockCodeIndexer as any,
595
+ gitHistoryIndexer: mockGitHistoryIndexer as any,
596
+ });
597
+
598
+ const federatedSearchCall = mockServer.registerTool.mock.calls.find(
599
+ (call) => call[0] === "federated_search",
600
+ );
601
+ const handler = federatedSearchCall![2];
602
+ const result = await handler({
603
+ paths: ["/repo1", "/repo2"],
604
+ query: "test",
605
+ });
606
+
607
+ expect(result.isError).toBe(true);
608
+ expect(result.content[0].text).toContain("Index validation failed");
609
+ expect(result.content[0].text).toContain("Code index not found");
610
+ // Should not perform searches when validation fails
611
+ expect(mockCodeIndexer.searchCode).not.toHaveBeenCalled();
612
+ });
613
+
614
+ it("should respect searchType=code and only search code", async () => {
615
+ mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
616
+ mockCodeIndexer.searchCode.mockResolvedValue([]);
617
+
618
+ const { registerFederatedTools } = await import("./federated.js");
619
+ const mockServer = { registerTool: vi.fn() };
620
+
621
+ registerFederatedTools(mockServer as any, {
622
+ codeIndexer: mockCodeIndexer as any,
623
+ gitHistoryIndexer: mockGitHistoryIndexer as any,
624
+ });
625
+
626
+ const federatedSearchCall = mockServer.registerTool.mock.calls.find(
627
+ (call) => call[0] === "federated_search",
628
+ );
629
+ const handler = federatedSearchCall![2];
630
+ await handler({
631
+ paths: ["/repo1"],
632
+ query: "test",
633
+ searchType: "code",
634
+ });
635
+
636
+ expect(mockCodeIndexer.searchCode).toHaveBeenCalled();
637
+ expect(mockGitHistoryIndexer.searchHistory).not.toHaveBeenCalled();
638
+ });
639
+
640
+ it("should respect searchType=git and only search git history", async () => {
641
+ mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
642
+ status: "indexed",
643
+ });
644
+ mockGitHistoryIndexer.searchHistory.mockResolvedValue([]);
645
+
646
+ const { registerFederatedTools } = await import("./federated.js");
647
+ const mockServer = { registerTool: vi.fn() };
648
+
649
+ registerFederatedTools(mockServer as any, {
650
+ codeIndexer: mockCodeIndexer as any,
651
+ gitHistoryIndexer: mockGitHistoryIndexer as any,
652
+ });
653
+
654
+ const federatedSearchCall = mockServer.registerTool.mock.calls.find(
655
+ (call) => call[0] === "federated_search",
656
+ );
657
+ const handler = federatedSearchCall![2];
658
+ await handler({
659
+ paths: ["/repo1"],
660
+ query: "test",
661
+ searchType: "git",
662
+ });
663
+
664
+ expect(mockCodeIndexer.searchCode).not.toHaveBeenCalled();
665
+ expect(mockGitHistoryIndexer.searchHistory).toHaveBeenCalled();
666
+ });
667
+
668
+ it("should apply limit to combined results", async () => {
669
+ mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
670
+ mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
671
+ status: "indexed",
672
+ });
673
+
674
+ // Return many results from each search
675
+ const codeResults = Array(10)
676
+ .fill(null)
677
+ .map((_, i) => ({
678
+ content: `code ${i}`,
679
+ filePath: `src/file${i}.ts`,
680
+ startLine: 1,
681
+ endLine: 5,
682
+ language: "typescript",
683
+ score: 0.9 - i * 0.05,
684
+ fileExtension: ".ts",
685
+ }));
686
+ const gitResults = Array(10)
687
+ .fill(null)
688
+ .map((_, i) => ({
689
+ content: `commit ${i}`,
690
+ commitHash: `hash${i}`,
691
+ shortHash: `hash${i}`,
692
+ author: "Author",
693
+ date: "2024-01-15",
694
+ subject: `feat: commit ${i}`,
695
+ commitType: "feat",
696
+ files: [`src/file${i}.ts`],
697
+ score: 0.85 - i * 0.05,
698
+ }));
699
+
700
+ mockCodeIndexer.searchCode.mockResolvedValue(codeResults);
701
+ mockGitHistoryIndexer.searchHistory.mockResolvedValue(gitResults);
702
+
703
+ const { registerFederatedTools } = await import("./federated.js");
704
+ const mockServer = { registerTool: vi.fn() };
705
+
706
+ registerFederatedTools(mockServer as any, {
707
+ codeIndexer: mockCodeIndexer as any,
708
+ gitHistoryIndexer: mockGitHistoryIndexer as any,
709
+ });
710
+
711
+ const federatedSearchCall = mockServer.registerTool.mock.calls.find(
712
+ (call) => call[0] === "federated_search",
713
+ );
714
+ const handler = federatedSearchCall![2];
715
+ const result = await handler({
716
+ paths: ["/repo1"],
717
+ query: "test",
718
+ limit: 5,
719
+ });
720
+
721
+ // Should limit total results
722
+ expect(result.content[0].text).toContain("Total: 5 result(s)");
723
+ });
724
+
725
+ it("should return message when no results found", async () => {
726
+ mockCodeIndexer.getIndexStatus.mockResolvedValue({ status: "indexed" });
727
+ mockGitHistoryIndexer.getIndexStatus.mockResolvedValue({
728
+ status: "indexed",
729
+ });
730
+ mockCodeIndexer.searchCode.mockResolvedValue([]);
731
+ mockGitHistoryIndexer.searchHistory.mockResolvedValue([]);
732
+
733
+ const { registerFederatedTools } = await import("./federated.js");
734
+ const mockServer = { registerTool: vi.fn() };
735
+
736
+ registerFederatedTools(mockServer as any, {
737
+ codeIndexer: mockCodeIndexer as any,
738
+ gitHistoryIndexer: mockGitHistoryIndexer as any,
739
+ });
740
+
741
+ const federatedSearchCall = mockServer.registerTool.mock.calls.find(
742
+ (call) => call[0] === "federated_search",
743
+ );
744
+ const handler = federatedSearchCall![2];
745
+ const result = await handler({
746
+ paths: ["/repo1"],
747
+ query: "nonexistent query",
748
+ });
749
+
750
+ expect(result.content[0].text).toContain("No results found");
751
+ });
752
+ });