GitNexus/gitnexus/test/integration/lbug-pool.test.ts
Candido Sales Gomes 5a5850832c
refactor: migrate from KuzuDB to LadybugDB v0.15 (#275)
* refactor: migrate from KuzuDB to LadybugDB v0.15

KuzuDB was archived (Apple acquisition, Oct 2025). LadybugDB is the
community fork with full API compatibility.

- Package swap: kuzu → @ladybugdb/core, kuzu-wasm → @ladybugdb/wasm-core
- Rename all internal paths: kuzu → lbug (adapters, schema, storage)
- Storage path: .gitnexus/kuzu → .gitnexus/lbug (with auto-cleanup)
- Add explicit VECTOR extension loading (required in v0.15)
- Update CI workflow, documentation, and all tests
- 1151 unit + 27 integration tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings (P1-P3)

P1: Fix WASM adapter to use getAll() API, wire cleanupOldKuzuFiles
into analyze command, add symlink path traversal protection.
P2: Cache VECTOR extension load state, batch augmentation engine
queries (20→4), fix web getCopyQuery for multi-language tables,
fix stale KuzuDB references, correct brainstorm package names.
P3: Complete lbug-wasm.d.ts type declarations, batch semantic
search per-label, update stale BM25 comment.

* chore: remove outdated KuzuDB migration brainstorming document

* fix: load FTS extension in MCP pool adapter on init

The read-only pool adapter never loaded the FTS extension, so all
QUERY_FTS_INDEX calls failed silently. This broke search-pool and
augmentation integration tests, and caused empty results in the
web UI server mode.

* feat: implement shared Database caching and connection reference counting

* feat: enhance KuzuDB migration handling and status reporting

* fix: mock cleanupOldKuzuFiles in local backend callTool tests

* fix: update mock for cleanupOldKuzuFiles and adjust imports in callTool tests

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 15:53:01 +00:00

187 lines
7.5 KiB
TypeScript

/**
* P0 Integration Tests: LadybugDB Connection Pool
*
* Tests: initLbug, executeQuery, executeParameterized, closeLbug lifecycle
* Covers hardening fixes: parameterized queries, query timeout,
* waiter queue timeout, idle eviction guards, stdout silencing race
*/
import { describe, it, expect, afterEach } from 'vitest';
import {
initLbug,
executeQuery,
executeParameterized,
closeLbug,
isLbugReady,
} from '../../src/mcp/core/lbug-adapter.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
const POOL_SEED_DATA = [
`CREATE (f:File {id: 'file:index.ts', name: 'index.ts', filePath: 'src/index.ts', content: ''})`,
`CREATE (fn:Function {id: 'func:main', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`,
`CREATE (fn2:Function {id: 'func:helper', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`,
`MATCH (a:Function), (b:Function)
WHERE a.id = 'func:main' AND b.id = 'func:helper'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
];
// ─── Pool lifecycle tests — test the pool adapter API directly ───────
withTestLbugDB('lbug-pool', (handle) => {
afterEach(async () => {
try { await closeLbug('test-repo'); } catch { /* best-effort */ }
try { await closeLbug('repo1'); } catch { /* best-effort */ }
try { await closeLbug('repo2'); } catch { /* best-effort */ }
try { await closeLbug(''); } catch { /* best-effort */ }
});
// ─── Lifecycle: init → query → close ─────────────────────────────────
describe('pool lifecycle', () => {
it('initLbug + executeQuery + closeLbug', async () => {
await initLbug('test-repo', handle.dbPath);
expect(isLbugReady('test-repo')).toBe(true);
const rows = await executeQuery('test-repo', 'MATCH (n:Function) RETURN n.name AS name');
expect(rows.length).toBeGreaterThanOrEqual(2);
const names = rows.map((r: any) => r.name);
expect(names).toContain('main');
expect(names).toContain('helper');
await closeLbug('test-repo');
expect(isLbugReady('test-repo')).toBe(false);
});
it('initLbug reuses existing pool entry', async () => {
await initLbug('test-repo', handle.dbPath);
await initLbug('test-repo', handle.dbPath); // second call should be no-op
expect(isLbugReady('test-repo')).toBe(true);
});
it('closeLbug is idempotent', async () => {
await initLbug('test-repo', handle.dbPath);
await closeLbug('test-repo');
await closeLbug('test-repo'); // second close should not throw
expect(isLbugReady('test-repo')).toBe(false);
});
it('closeLbug with no args closes all repos', async () => {
await initLbug('repo1', handle.dbPath);
await initLbug('repo2', handle.dbPath);
expect(isLbugReady('repo1')).toBe(true);
expect(isLbugReady('repo2')).toBe(true);
await closeLbug();
expect(isLbugReady('repo1')).toBe(false);
expect(isLbugReady('repo2')).toBe(false);
});
});
// ─── Parameterized queries ───────────────────────────────────────────
describe('executeParameterized', () => {
it('works with parameterized query', async () => {
await initLbug('test-repo', handle.dbPath);
const rows = await executeParameterized(
'test-repo',
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
{ name: 'main' },
);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('main');
});
it('injection attempt is harmless with parameterized query', async () => {
await initLbug('test-repo', handle.dbPath);
const rows = await executeParameterized(
'test-repo',
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
{ name: "' OR 1=1 --" }, // SQL/Cypher injection attempt
);
// Should return 0 rows, not all rows
expect(rows).toHaveLength(0);
});
});
// ─── Error handling ──────────────────────────────────────────────────
describe('error handling', () => {
it('throws when querying uninitialized repo', async () => {
await expect(executeQuery('nonexistent-repo', 'MATCH (n) RETURN n'))
.rejects.toThrow(/not initialized/);
});
it('throws when db path does not exist', async () => {
await expect(initLbug('bad-repo', '/nonexistent/path/lbug'))
.rejects.toThrow();
});
it('read-only mode: write query throws', async () => {
await initLbug('test-repo', handle.dbPath);
await expect(executeQuery('test-repo', "CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})"))
.rejects.toThrow();
});
});
// ─── Relationship queries ────────────────────────────────────────────
describe('relationship queries', () => {
it('can query relationships', async () => {
await initLbug('test-repo', handle.dbPath);
const rows = await executeQuery(
'test-repo',
`MATCH (a:Function)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name AS caller, b.name AS callee`,
);
expect(rows.length).toBeGreaterThanOrEqual(1);
const row = rows.find((r: any) => r.caller === 'main');
expect(row).toBeDefined();
expect(row.callee).toBe('helper');
});
});
// ─── Unhappy paths ──────────────────────────────────────────────────
describe('unhappy paths', () => {
it('executeParameterized throws when repo is not initialized', async () => {
await expect(executeParameterized('ghost-repo', 'MATCH (n) RETURN n', {}))
.rejects.toThrow(/not initialized/);
});
it('executeQuery rejects invalid Cypher syntax', async () => {
await initLbug('test-repo', handle.dbPath);
await expect(executeQuery('test-repo', 'THIS IS NOT CYPHER'))
.rejects.toThrow();
});
it('executeParameterized rejects when referenced parameter is missing', async () => {
await initLbug('test-repo', handle.dbPath);
await expect(executeParameterized(
'test-repo',
'MATCH (n:Function) WHERE n.name = $name RETURN n',
{ wrong_param: 'main' },
)).rejects.toThrow();
});
it('closeLbug with unknown repoId does not throw', async () => {
await expect(closeLbug('never-existed-repo')).resolves.toBeUndefined();
});
it('isLbugReady returns false for unknown repoId', () => {
expect(isLbugReady('never-existed-repo')).toBe(false);
});
it('initLbug with empty string repoId stores entry under empty key', async () => {
await initLbug('', handle.dbPath);
expect(isLbugReady('')).toBe(true);
await closeLbug('');
expect(isLbugReady('')).toBe(false);
});
it('executeQuery with empty query string rejects', async () => {
await initLbug('test-repo', handle.dbPath);
await expect(executeQuery('test-repo', '')).rejects.toThrow();
});
});
}, {
seed: POOL_SEED_DATA,
poolAdapter: true,
});