500 lines
17 KiB
TypeScript
500 lines
17 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
});
|