GitNexus/gitnexus/test/integration/local-backend-calltool.test.ts
Copilot 962f22482b
Some checks are pending
CI / quality (push) Waiting to run
CI / tests (push) Waiting to run
CI / e2e (push) Waiting to run
CI / scope-parity (push) Waiting to run
CI / Save PR Metadata (push) Blocked by required conditions
CI / CI Gate (push) Blocked by required conditions
Release Candidate / Check if release candidate should run (push) Waiting to run
Release Candidate / ci (push) Blocked by required conditions
Release Candidate / Publish release candidate to npm (push) Blocked by required conditions
Release Candidate / Build & Push RC Docker images (push) Blocked by required conditions
feat(cli): Fingerprint indexed repos by remote URL to detect sibling-clone graph drift (#982)
* Initial plan

* feat: detect sibling-clone graph drift via remote URL fingerprint

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e5decb67-7fec-40e7-b2a1-b5e94a0d393f

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: address review feedback — fake commit, same-commit case, regex docs

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e5decb67-7fec-40e7-b2a1-b5e94a0d393f

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(mcp): address review feedback — CI green, perf, dead branch, one-shot test

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc2259f7-94e4-4243-aaa9-e03b7c632d32

* Merge branch 'main' into copilot/fix-single-path-indexing-issue

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5840b3dd-e879-4854-a067-d1622bec2634

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* Merge branch 'main' into copilot/fix-single-path-indexing-issue

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9025262f-4dd4-4774-8f32-e14434100004

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: prettier format run-analyze.ts after merge with main

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a7be18dd-102f-4a7b-ac56-53fbd414fe3b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: realpath both sides of cwdGitRoot assertion for Windows 8.3 short-name compat

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b2a1c6a3-e454-4b87-b0e4-69d7c0d9a51b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(test): use path-agnostic assertion for cwdGitRoot on Windows (#1015)

git rev-parse --show-toplevel returns long path names on Windows
while os.tmpdir() returns 8.3 short names. fs.realpathSync does not
expand short names, so exact path comparison always fails on Windows
CI runners. Replace with behavioral assertions instead.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <copilot-swe-agent[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: evolution <wjc163@sina.cn>
2026-04-21 21:58:54 +01:00

320 lines
13 KiB
TypeScript

/**
* P0 Integration Tests: Local Backend — callTool dispatch
*
* Tests the full LocalBackend.callTool() dispatch with a real LadybugDB
* instance, verifying cypher, context, impact, and query tools work
* end-to-end against seeded graph data with FTS indexes.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import {
LOCAL_BACKEND_SEED_DATA,
LOCAL_BACKEND_FTS_INDEXES,
} from '../fixtures/local-backend-seed.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// ─── Block 2: callTool dispatch tests ────────────────────────────────
withTestLbugDB(
'local-backend-calltool',
(handle) => {
describe('callTool dispatch with real DB', () => {
let backend: LocalBackend;
beforeAll(async () => {
// backend is created in afterSetup and attached to the handle
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('cypher tool returns function names', async () => {
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
});
// cypher tool wraps results as markdown
expect(result).toHaveProperty('markdown');
expect(result).toHaveProperty('row_count');
expect(result.row_count).toBeGreaterThanOrEqual(3);
expect(result.markdown).toContain('login');
expect(result.markdown).toContain('validate');
expect(result.markdown).toContain('hash');
});
it('cypher tool blocks write queries', async () => {
const result = await backend.callTool('cypher', {
query:
"CREATE (n:Function {id: 'x', name: 'x', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})",
});
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/write operations/i);
});
it('context tool returns symbol info with callers and callees', async () => {
const result = await backend.callTool('context', { name: 'login' });
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('found');
// Should have the symbol identity
expect(result.symbol).toBeDefined();
expect(result.symbol.name).toBe('login');
expect(result.symbol.filePath).toBe('src/auth.ts');
// login calls validate and hash — should appear in outgoing.calls
expect(result.outgoing).toBeDefined();
expect(result.outgoing.calls).toBeDefined();
expect(result.outgoing.calls.length).toBeGreaterThanOrEqual(2);
const calleeNames = result.outgoing.calls.map((c: any) => c.name);
expect(calleeNames).toContain('validate');
expect(calleeNames).toContain('hash');
});
it('impact tool returns upstream dependents', async () => {
const result = await backend.callTool('impact', {
target: 'validate',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
// validate is called by login, so login should appear at depth 1
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
expect(result.byDepth).toBeDefined();
const directDeps = result.byDepth[1] || result.byDepth['1'] || [];
expect(directDeps.length).toBeGreaterThanOrEqual(1);
const depNames = directDeps.map((d: any) => d.name);
expect(depNames).toContain('login');
});
it('query tool returns results for keyword search', async () => {
const result = await backend.callTool('query', { query: 'login' });
expect(result).not.toHaveProperty('error');
// Should have some combination of processes, process_symbols, or definitions
expect(result).toHaveProperty('processes');
expect(result).toHaveProperty('definitions');
// The search should find something (FTS or graph-based)
const totalResults =
(result.processes?.length || 0) +
(result.process_symbols?.length || 0) +
(result.definitions?.length || 0);
expect(totalResults).toBeGreaterThanOrEqual(1);
// #553: query response carries per-phase timing metadata.
expect(result.timing).toBeDefined();
expect(typeof result.timing.wall).toBe('number');
expect(result.timing.wall).toBeGreaterThanOrEqual(0);
// At least one of the search phases must have fired for any
// non-error response — bm25 and/or vector always runs.
expect(result.timing.bm25 ?? result.timing.vector).toBeGreaterThanOrEqual(0);
});
it('unknown tool throws', async () => {
await expect(backend.callTool('nonexistent_tool', {})).rejects.toThrow(/unknown tool/i);
});
});
describe('impact tool relationTypes filtering', () => {
let backend: LocalBackend;
beforeAll(async () => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('filters by HAS_METHOD only', async () => {
const result = await backend.callTool('impact', {
target: 'AuthService',
direction: 'downstream',
relationTypes: ['HAS_METHOD'],
});
expect(result).not.toHaveProperty('error');
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
const names = d1.map((d: any) => d.name);
expect(names).toContain('authenticate');
// Should NOT include CALLS-reachable symbols like validate/hash
expect(names).not.toContain('validate');
expect(names).not.toContain('hash');
});
it('filters by OVERRIDES only', async () => {
// The seed has two Method nodes named 'authenticate' (AuthService's
// override and BaseService's base). Per #470, `impact` now returns
// a ranked-ambiguous response when the target name hits multiple
// symbols, so we must disambiguate with file_path to get the
// AuthService override (the one with the outgoing METHOD_OVERRIDES
// edge we want to follow downstream).
const result = await backend.callTool('impact', {
target: 'authenticate',
file_path: 'src/auth.ts',
direction: 'downstream',
relationTypes: ['METHOD_OVERRIDES'],
});
expect(result).not.toHaveProperty('error');
expect(result.status).not.toBe('ambiguous');
// AuthService.authenticate overrides BaseService.authenticate
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
const names = d1.map((d: any) => d.name);
expect(names).toContain('authenticate');
});
it('expands legacy OVERRIDES to include METHOD_OVERRIDES (dual-read)', async () => {
// Pass the LEGACY alias 'OVERRIDES' — impactByUid should flatMap-expand
// it to ['OVERRIDES', 'METHOD_OVERRIDES'] so the METHOD_OVERRIDES edge
// between BaseService.authenticate and AuthService.authenticate is found.
// file_path hint disambiguates the two 'authenticate' methods per #470.
const result = await backend.callTool('impact', {
target: 'authenticate',
file_path: 'src/auth.ts',
direction: 'downstream',
relationTypes: ['OVERRIDES'],
});
expect(result).not.toHaveProperty('error');
expect(result.status).not.toBe('ambiguous');
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
const d1 = result.byDepth[1] || result.byDepth['1'] || [];
const names = d1.map((d: any) => d.name);
expect(names).toContain('authenticate');
});
it('does not return HAS_METHOD results when filtering by CALLS only', async () => {
const result = await backend.callTool('impact', {
target: 'AuthService',
direction: 'downstream',
relationTypes: ['CALLS'],
});
expect(result).not.toHaveProperty('error');
// AuthService has no outgoing CALLS edges, only HAS_METHOD
expect(result.impactedCount).toBe(0);
});
});
describe('tool parameter edge cases', () => {
let backend: LocalBackend;
beforeAll(async () => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('context tool returns error for nonexistent symbol', async () => {
const result = await backend.callTool('context', { name: 'nonexistent_xyz_symbol_999' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/not found/i);
});
it('query tool returns error for empty query', async () => {
const result = await backend.callTool('query', { query: '' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/required/i);
});
it('query tool returns error for missing query param', async () => {
const result = await backend.callTool('query', {});
expect(result).toHaveProperty('error');
});
it('cypher tool returns error for invalid Cypher syntax', async () => {
const result = await backend.callTool('cypher', {
query: 'THIS IS NOT VALID CYPHER AT ALL',
});
expect(result).toHaveProperty('error');
});
it('context tool returns error when no name or uid provided', async () => {
const result = await backend.callTool('context', {});
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/required/i);
});
// ─── impact error handling tests (#321) ───────────────────────────
// Verify that impact() returns structured JSON instead of crashing
it('impact tool returns structured error for unknown symbol', async () => {
const result = await backend.callTool('impact', {
target: 'nonexistent_symbol_xyz_999',
direction: 'upstream',
});
// Must return structured JSON, not throw
expect(result).toBeDefined();
// Should have either an error field (not found) or impactedCount 0
// Either outcome is valid — the key is it doesn't crash
if (result.error) {
expect(typeof result.error).toBe('string');
} else {
expect(result.impactedCount).toBe(0);
}
});
it('impact error response has consistent target shape', async () => {
const result = await backend.callTool('impact', {
target: 'nonexistent_symbol_xyz_999',
direction: 'downstream',
});
// When an error is returned, target must be an object (not raw string)
// so downstream API consumers can safely access result.target.name
if (result.error && result.target !== undefined) {
expect(typeof result.target).toBe('object');
expect(result.target).not.toBeNull();
}
});
it('impact partial results: traversalComplete flag when depth fails', async () => {
// Even if traversal fails at some depth, partial results should be returned
// and partial:true should only be set when some results were collected
const result = await backend.callTool('impact', {
target: 'validate',
direction: 'upstream',
maxDepth: 10, // Large depth to trigger multi-level traversal
});
// Should succeed (validate exists in seed data)
expect(result).not.toHaveProperty('error');
if (result.partial) {
// If partial, must still have some results
expect(result.impactedCount).toBeGreaterThan(0);
}
});
});
},
{
seed: LOCAL_BACKEND_SEED_DATA,
ftsIndexes: LOCAL_BACKEND_FTS_INDEXES,
poolAdapter: true,
afterSetup: async (handle) => {
// Configure listRegisteredRepos mock with handle values
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 2, nodes: 3, communities: 1, processes: 1 },
},
]);
const backend = new LocalBackend();
await backend.init();
// Stash backend on handle so tests can access it
(handle as any)._backend = backend;
},
},
);