/** * Tests for the SqliteReader module. * * Creates a temporary SQLite database with the exact schema used by the * Python backend, inserts representative test data, and validates every * public method of SqliteReader. */ import * as assert from 'assert'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import Database from 'better-sqlite3'; import { SqliteReader, GraphNode, GraphEdge } from '../src/backend/sqlite'; // --------------------------------------------------------------------------- // Schema (mirrors the Python backend exactly) // --------------------------------------------------------------------------- const SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, name TEXT NOT NULL, qualified_name TEXT NOT NULL UNIQUE, file_path TEXT NOT NULL, line_start INTEGER, line_end INTEGER, language TEXT, parent_name TEXT, params TEXT, return_type TEXT, modifiers TEXT, is_test INTEGER DEFAULT 0, file_hash TEXT, extra TEXT DEFAULT '{}', updated_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS edges ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, source_qualified TEXT NOT NULL, target_qualified TEXT NOT NULL, file_path TEXT NOT NULL, line INTEGER DEFAULT 0, extra TEXT DEFAULT '{}', updated_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS metadata ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path); CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind); CREATE INDEX IF NOT EXISTS idx_nodes_qualified ON nodes(qualified_name); CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_qualified); CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_qualified); CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind); CREATE INDEX IF NOT EXISTS idx_edges_file ON edges(file_path); `; // --------------------------------------------------------------------------- // Test data // --------------------------------------------------------------------------- const NOW = Date.now() / 1000; interface TestNode { kind: string; name: string; qualified_name: string; file_path: string; line_start: number; line_end: number; language: string; parent_name: string | null; params: string | null; return_type: string | null; modifiers: string | null; is_test: number; file_hash: string; extra: string; updated_at: number; } interface TestEdge { kind: string; source_qualified: string; target_qualified: string; file_path: string; line: number; extra: string; updated_at: number; } const TEST_NODES: TestNode[] = [ // auth.py -- File node + 2 functions { kind: 'File', name: 'auth.py', qualified_name: 'src/auth.py', file_path: 'src/auth.py', line_start: 1, line_end: 50, language: 'python', parent_name: null, params: null, return_type: null, modifiers: null, is_test: 0, file_hash: 'aaa', extra: '{}', updated_at: NOW, }, { kind: 'Function', name: 'login', qualified_name: 'src/auth.py::login', file_path: 'src/auth.py', line_start: 5, line_end: 20, language: 'python', parent_name: null, params: '(username, password)', return_type: 'bool', modifiers: null, is_test: 0, file_hash: 'aaa', extra: '{}', updated_at: NOW, }, { kind: 'Function', name: 'logout', qualified_name: 'src/auth.py::logout', file_path: 'src/auth.py', line_start: 22, line_end: 35, language: 'python', parent_name: null, params: '(session)', return_type: 'None', modifiers: null, is_test: 0, file_hash: 'aaa', extra: '{}', updated_at: NOW, }, // routes.py -- File node + 1 function { kind: 'File', name: 'routes.py', qualified_name: 'src/routes.py', file_path: 'src/routes.py', line_start: 1, line_end: 40, language: 'python', parent_name: null, params: null, return_type: null, modifiers: null, is_test: 0, file_hash: 'bbb', extra: '{}', updated_at: NOW, }, { kind: 'Function', name: 'handle_login', qualified_name: 'src/routes.py::handle_login', file_path: 'src/routes.py', line_start: 10, line_end: 30, language: 'python', parent_name: null, params: '(request)', return_type: 'Response', modifiers: null, is_test: 0, file_hash: 'bbb', extra: '{}', updated_at: NOW, }, // test_auth.py -- File node + 1 test function { kind: 'File', name: 'test_auth.py', qualified_name: 'tests/test_auth.py', file_path: 'tests/test_auth.py', line_start: 1, line_end: 30, language: 'python', parent_name: null, params: null, return_type: null, modifiers: null, is_test: 0, file_hash: 'ccc', extra: '{}', updated_at: NOW, }, { kind: 'Test', name: 'test_login', qualified_name: 'tests/test_auth.py::test_login', file_path: 'tests/test_auth.py', line_start: 5, line_end: 25, language: 'python', parent_name: null, params: '()', return_type: 'None', modifiers: null, is_test: 1, file_hash: 'ccc', extra: '{}', updated_at: NOW, }, ]; const TEST_EDGES: TestEdge[] = [ // routes.py::handle_login CALLS auth.py::login { kind: 'CALLS', source_qualified: 'src/routes.py::handle_login', target_qualified: 'src/auth.py::login', file_path: 'src/routes.py', line: 15, extra: '{}', updated_at: NOW, }, // routes.py IMPORTS_FROM auth.py { kind: 'IMPORTS_FROM', source_qualified: 'src/routes.py', target_qualified: 'src/auth.py', file_path: 'src/routes.py', line: 1, extra: '{}', updated_at: NOW, }, // auth.py CONTAINS login { kind: 'CONTAINS', source_qualified: 'src/auth.py', target_qualified: 'src/auth.py::login', file_path: 'src/auth.py', line: 5, extra: '{}', updated_at: NOW, }, // auth.py CONTAINS logout { kind: 'CONTAINS', source_qualified: 'src/auth.py', target_qualified: 'src/auth.py::logout', file_path: 'src/auth.py', line: 22, extra: '{}', updated_at: NOW, }, // test_auth.py::test_login TESTED_BY (reverse: login is tested by test_login) { kind: 'TESTED_BY', source_qualified: 'src/auth.py::login', target_qualified: 'tests/test_auth.py::test_login', file_path: 'tests/test_auth.py', line: 5, extra: '{}', updated_at: NOW, }, ]; // --------------------------------------------------------------------------- // Helper: create a populated temp database // --------------------------------------------------------------------------- function createTestDb(): string { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'crg-test-')); const dbPath = path.join(tmpDir, 'graph.db'); const db = new Database(dbPath); db.exec(SCHEMA_SQL); const insertNode = db.prepare(` INSERT INTO nodes (kind, name, qualified_name, file_path, line_start, line_end, language, parent_name, params, return_type, modifiers, is_test, file_hash, extra, updated_at) VALUES (@kind, @name, @qualified_name, @file_path, @line_start, @line_end, @language, @parent_name, @params, @return_type, @modifiers, @is_test, @file_hash, @extra, @updated_at) `); const insertEdge = db.prepare(` INSERT INTO edges (kind, source_qualified, target_qualified, file_path, line, extra, updated_at) VALUES (@kind, @source_qualified, @target_qualified, @file_path, @line, @extra, @updated_at) `); const insertMeta = db.prepare( 'INSERT INTO metadata (key, value) VALUES (?, ?)' ); const insertMany = db.transaction(() => { for (const n of TEST_NODES) { insertNode.run(n); } for (const e of TEST_EDGES) { insertEdge.run(e); } insertMeta.run('last_updated', '2025-06-15T10:30:00Z'); }); insertMany(); db.close(); return dbPath; } function cleanup(dbPath: string): void { try { const dir = path.dirname(dbPath); fs.rmSync(dir, { recursive: true, force: true }); } catch { // best effort } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('SqliteReader', () => { let dbPath: string; let reader: SqliteReader; before(() => { dbPath = createTestDb(); reader = new SqliteReader(dbPath); }); after(() => { reader.close(); cleanup(dbPath); }); // -- isValid ------------------------------------------------------------ it('isValid() returns true for a properly initialised database', () => { assert.strictEqual(reader.isValid(), true); }); // -- getAllFiles --------------------------------------------------------- it('getAllFiles() returns file paths ordered alphabetically', () => { const files = reader.getAllFiles(); assert.deepStrictEqual(files, [ 'src/auth.py', 'src/routes.py', 'tests/test_auth.py', ]); }); // -- getNodesByFile ----------------------------------------------------- it('getNodesByFile() returns nodes ordered by line_start', () => { const nodes = reader.getNodesByFile('src/auth.py'); assert.strictEqual(nodes.length, 3); // File + login + logout // Verify ordering assert.strictEqual(nodes[0].name, 'auth.py'); assert.strictEqual(nodes[1].name, 'login'); assert.strictEqual(nodes[2].name, 'logout'); // Verify camelCase conversion assert.strictEqual(nodes[1].qualifiedName, 'src/auth.py::login'); assert.strictEqual(nodes[1].lineStart, 5); assert.strictEqual(nodes[1].lineEnd, 20); assert.strictEqual(nodes[1].returnType, 'bool'); assert.strictEqual(nodes[1].isTest, false); }); // -- getNode ------------------------------------------------------------ it('getNode() returns a single node by qualified name', () => { const node = reader.getNode('src/auth.py::login'); assert.ok(node); assert.strictEqual(node.kind, 'Function'); assert.strictEqual(node.name, 'login'); assert.strictEqual(node.params, '(username, password)'); }); it('getNode() returns undefined for non-existent qualified name', () => { const node = reader.getNode('src/auth.py::nonexistent'); assert.strictEqual(node, undefined); }); // -- getNodeAtCursor ---------------------------------------------------- it('getNodeAtCursor() returns the innermost node at the cursor', () => { // Line 10 is inside login (5-20) and inside auth.py (1-50). // login has the smaller span so it should be returned. const node = reader.getNodeAtCursor('src/auth.py', 10); assert.ok(node); assert.strictEqual(node.name, 'login'); }); it('getNodeAtCursor() returns the File node when cursor is outside functions', () => { // Line 45 is inside auth.py (1-50) but outside both functions. const node = reader.getNodeAtCursor('src/auth.py', 45); assert.ok(node); assert.strictEqual(node.kind, 'File'); assert.strictEqual(node.name, 'auth.py'); }); it('getNodeAtCursor() returns undefined when no node covers the line', () => { const node = reader.getNodeAtCursor('src/auth.py', 999); assert.strictEqual(node, undefined); }); // -- getEdgesBySource --------------------------------------------------- it('getEdgesBySource() returns outgoing edges', () => { const edges = reader.getEdgesBySource('src/routes.py::handle_login'); assert.strictEqual(edges.length, 1); assert.strictEqual(edges[0].kind, 'CALLS'); assert.strictEqual(edges[0].targetQualified, 'src/auth.py::login'); assert.strictEqual(edges[0].line, 15); }); // -- getEdgesByTarget --------------------------------------------------- it('getEdgesByTarget() returns incoming edges', () => { const edges = reader.getEdgesByTarget('src/auth.py::login'); // CALLS from handle_login + CONTAINS from auth.py assert.strictEqual(edges.length, 2); const kinds = edges.map((e) => e.kind).sort(); assert.deepStrictEqual(kinds, ['CALLS', 'CONTAINS']); }); // -- getEdgesAmong ------------------------------------------------------ it('getEdgesAmong() returns only edges within the given set', () => { const qns = new Set([ 'src/routes.py::handle_login', 'src/auth.py::login', 'src/auth.py', ]); const edges = reader.getEdgesAmong(qns); // Should include: CALLS handle_login->login, CONTAINS auth.py->login, // IMPORTS_FROM routes.py->auth.py only if routes.py is in set (it's not). assert.ok(edges.length >= 2); for (const e of edges) { assert.ok(qns.has(e.sourceQualified), `source ${e.sourceQualified} should be in set`); assert.ok(qns.has(e.targetQualified), `target ${e.targetQualified} should be in set`); } }); it('getEdgesAmong() returns empty array for empty set', () => { const edges = reader.getEdgesAmong(new Set()); assert.deepStrictEqual(edges, []); }); // -- searchNodes -------------------------------------------------------- it('searchNodes() finds nodes by name substring', () => { const results = reader.searchNodes('login'); assert.ok(results.length >= 2); // login, handle_login, test_login const names = results.map((n) => n.name); assert.ok(names.includes('login')); assert.ok(names.includes('handle_login')); }); it('searchNodes() respects limit', () => { const results = reader.searchNodes('login', 1); assert.strictEqual(results.length, 1); }); it('searchNodes() returns empty for no match', () => { const results = reader.searchNodes('zzz_no_match_zzz'); assert.deepStrictEqual(results, []); }); // -- getStats ----------------------------------------------------------- it('getStats() returns correct aggregate counts', () => { const stats = reader.getStats(); assert.strictEqual(stats.totalNodes, TEST_NODES.length); assert.strictEqual(stats.totalEdges, TEST_EDGES.length); assert.strictEqual(stats.filesCount, 3); // 3 File nodes assert.deepStrictEqual(stats.languages, ['python']); assert.strictEqual(stats.lastUpdated, '2025-06-15T10:30:00Z'); assert.strictEqual(stats.embeddingsCount, 0); // no embeddings table data // Nodes by kind assert.strictEqual(stats.nodesByKind['File'], 3); assert.strictEqual(stats.nodesByKind['Function'], 3); assert.strictEqual(stats.nodesByKind['Test'], 1); // Edges by kind assert.strictEqual(stats.edgesByKind['CALLS'], 1); assert.strictEqual(stats.edgesByKind['IMPORTS_FROM'], 1); assert.strictEqual(stats.edgesByKind['CONTAINS'], 2); assert.strictEqual(stats.edgesByKind['TESTED_BY'], 1); }); // -- getMetadata -------------------------------------------------------- it('getMetadata() returns stored value', () => { const val = reader.getMetadata('last_updated'); assert.strictEqual(val, '2025-06-15T10:30:00Z'); }); it('getMetadata() returns undefined for missing key', () => { const val = reader.getMetadata('nonexistent_key'); assert.strictEqual(val, undefined); }); // -- getImpactRadius ---------------------------------------------------- it('getImpactRadius() finds changed and impacted nodes', () => { const result = reader.getImpactRadius(['src/auth.py'], 2); // Changed nodes: everything in auth.py (File + login + logout) assert.strictEqual(result.changedNodes.length, 3); const changedNames = result.changedNodes.map((n) => n.name).sort(); assert.deepStrictEqual(changedNames, ['auth.py', 'login', 'logout']); // Impacted nodes: handle_login (calls login), test_login (tested_by), // routes.py (imports_from auth.py), test_auth.py file (contains test_login) assert.ok( result.impactedNodes.length > 0, 'should have impacted nodes' ); const impactedNames = result.impactedNodes.map((n) => n.name); assert.ok( impactedNames.includes('handle_login'), 'handle_login should be impacted (calls login)' ); assert.ok( impactedNames.includes('test_login'), 'test_login should be impacted (tested_by)' ); // Impacted files should include routes.py and/or tests/test_auth.py assert.ok( result.impactedFiles.length > 0, 'should have impacted files' ); // Edges among all involved nodes assert.ok( result.edges.length > 0, 'should have connecting edges' ); }); it('getImpactRadius() with depth 0 returns only seeds, no impacted nodes', () => { const result = reader.getImpactRadius(['src/auth.py'], 0); assert.strictEqual(result.changedNodes.length, 3); assert.strictEqual(result.impactedNodes.length, 0); }); it('getImpactRadius() for non-existent file returns empty', () => { const result = reader.getImpactRadius(['nonexistent.py']); assert.strictEqual(result.changedNodes.length, 0); assert.strictEqual(result.impactedNodes.length, 0); assert.strictEqual(result.impactedFiles.length, 0); }); // -- close / isValid after close ---------------------------------------- it('isValid() returns false after close()', () => { const tmpPath = createTestDb(); const tmpReader = new SqliteReader(tmpPath); assert.strictEqual(tmpReader.isValid(), true); tmpReader.close(); assert.strictEqual(tmpReader.isValid(), false); cleanup(tmpPath); }); // -- constructor retry on bad path -------------------------------------- it('constructor throws after retries for a non-existent path', () => { assert.throws(() => { new SqliteReader('/nonexistent/path/to/database.db'); }); }); });