Skip to main content
Glama
orneryd

M.I.M.I.R - Multi-agent Intelligent Memory & Insight Repository

by orneryd
nornicdb-live-integration.test.ts21.6 kB
/** * @file testing/nornicdb-live-integration.test.ts * @description Live integration tests against running NornicDB instance * * These tests validate the actual Cypher queries and search functionality * against a live NornicDB service running on localhost:7474 (HTTP) and 7687 (Bolt). * * PREREQUISITES: * - NornicDB running locally on ports 7474 and 7687 * - DO NOT restart or modify the NornicDB service * * SKIPPED BY DEFAULT - These tests require a live NornicDB instance. * To run these tests, set the environment variable: * NORNICDB_LIVE_TESTS=true npx vitest run testing/nornicdb-live-integration.test.ts * * Or run with: * npx vitest run testing/nornicdb-live-integration.test.ts */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import neo4j, { Driver, Session } from 'neo4j-driver'; // Skip all tests unless NORNICDB_LIVE_TESTS=true is set const SKIP_LIVE_TESTS = process.env.NORNICDB_LIVE_TESTS !== 'true'; // Increase timeouts for live database operations const TEST_TIMEOUT = 30000; // 30 seconds per test const HOOK_TIMEOUT = 15000; // 15 seconds for hooks // Use describe.skipIf to conditionally skip the entire test suite describe.skipIf(SKIP_LIVE_TESTS)('NornicDB Live Integration Tests', () => { let driver: Driver; let session: Session; const TEST_PREFIX = 'integration_test_'; // Helper to get a fresh session for each test const getSession = (): Session => { return driver.session(); }; beforeAll(async () => { // Connect to live NornicDB instance const uri = process.env.NEO4J_URI || 'bolt://localhost:7687'; const user = process.env.NEO4J_USER || 'neo4j'; const password = process.env.NEO4J_PASSWORD || 'password'; console.log(`\n🔌 Connecting to NornicDB at ${uri}...`); driver = neo4j.driver(uri, neo4j.auth.basic(user, password)); // Test connection with a fresh session const testSession = driver.session(); try { const result = await testSession.run('RETURN 1 as test'); const serverInfo = result.summary.server; console.log(`✅ Connected to: ${serverInfo?.agent || 'Unknown'}`); console.log(` Protocol: ${serverInfo?.protocolVersion || 'Unknown'}`); } catch (error: any) { console.error(`❌ Failed to connect: ${error.message}`); throw new Error(`Cannot connect to NornicDB at ${uri}. Is it running?`); } finally { await testSession.close(); } }); beforeEach(() => { // Get a fresh session for each test session = getSession(); }); afterEach(async () => { // Close the session after each test // Use a timeout to prevent hanging on stuck sessions if (session) { const closePromise = session.close().catch(() => {}); const timeoutPromise = new Promise(resolve => setTimeout(resolve, 2000)); await Promise.race([closePromise, timeoutPromise]); } }); afterAll(async () => { // Clean up test data with a fresh session // Use a short timeout - if cleanup hangs, just skip it const cleanupSession = driver.session(); const cleanupTimeout = 5000; try { console.log('\n🧹 Cleaning up test data...'); const cleanupPromise = cleanupSession.run(` MATCH (n:Node) WHERE n.id STARTS WITH $prefix DETACH DELETE n `, { prefix: TEST_PREFIX }); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Cleanup timeout')), cleanupTimeout) ); await Promise.race([cleanupPromise, timeoutPromise]); console.log('✅ Test data cleaned up'); } catch (error: any) { console.warn(`⚠️ Cleanup skipped: ${error.message}`); } finally { try { await cleanupSession.close(); } catch (e) { // Ignore close errors } } if (driver) { try { await driver.close(); } catch (e) { // Ignore close errors } } }, 10000); // 10 second timeout for afterAll describe('Server Detection', () => { it('should identify as NornicDB in server agent', async () => { const result = await session.run('RETURN 1 as test'); const serverAgent = result.summary.server?.agent || ''; console.log(` Server agent: "${serverAgent}"`); // NornicDB should identify itself in the agent string // If it doesn't, this is a bug in NornicDB or detection logic const isNornicDB = serverAgent.toLowerCase().includes('nornicdb'); if (!isNornicDB) { console.warn(` ⚠️ Server does not identify as NornicDB. Agent: ${serverAgent}`); console.warn(` This may cause incorrect provider detection in Mimir.`); } // Log but don't fail - we want to test the queries regardless expect(serverAgent).toBeDefined(); }); }); describe('Basic Cypher Operations', () => { it('should create a node with properties', async () => { const nodeId = `${TEST_PREFIX}memory_1`; const result = await session.run(` CREATE (n:Node { id: $id, type: 'memory', title: 'Test Memory', content: 'This is test content for vector search', created: datetime(), updated: datetime() }) RETURN n `, { id: nodeId }); expect(result.records.length).toBe(1); const node = result.records[0].get('n'); expect(node.properties.id).toBe(nodeId); expect(node.properties.type).toBe('memory'); }, TEST_TIMEOUT); it('should query nodes by property', async () => { const result = await session.run(` MATCH (n:Node) WHERE n.id STARTS WITH $prefix RETURN n.id as id, n.type as type, n.title as title LIMIT 10 `, { prefix: TEST_PREFIX }); expect(result.records.length).toBeGreaterThanOrEqual(1); const record = result.records[0]; expect(record.get('type')).toBe('memory'); }, TEST_TIMEOUT); it('should update a node', async () => { // Use a unique ID for this test run to avoid conflicts const nodeId = `${TEST_PREFIX}update_${Date.now()}`; // NornicDB quirk: CREATE...SET not supported, put all properties in CREATE const result = await session.run(` CREATE (n:Node { id: $id, type: 'memory', title: 'Update Test', content: $newContent }) RETURN n `, { id: nodeId, newContent: 'Updated content for testing' }); expect(result.records.length).toBe(1); const node = result.records[0].get('n'); expect(node.properties.content).toBe('Updated content for testing'); expect(node.properties.type).toBe('memory'); }, TEST_TIMEOUT); }); describe('Vector Index Operations', () => { it('should check if node_embedding_index exists', async () => { // Test that we can query index metadata const result = await session.run(` SHOW INDEXES YIELD name, type, labelsOrTypes, properties WHERE name = 'node_embedding_index' RETURN name, type, labelsOrTypes, properties `); // Log status - NornicDB may auto-create indexes if (result.records.length > 0) { console.log(' ✅ Vector index "node_embedding_index" exists'); const record = result.records[0]; console.log(` Type: ${record.get('type')}`); console.log(` Labels: ${record.get('labelsOrTypes')}`); console.log(` Properties: ${record.get('properties')}`); } else { console.log(' ⚠️ Vector index "node_embedding_index" not found via SHOW INDEXES'); console.log(' NornicDB may handle indexes differently than Neo4j'); } // Assert we got a valid response expect(result.records).toBeDefined(); }, TEST_TIMEOUT); it('should handle db.index.vector.queryNodes with string query', async () => { // CRITICAL TEST: This validates NornicDB's server-side embedding feature // Mimir relies on this to avoid client-side embedding generation const result = await session.run(` CALL db.index.vector.queryNodes('node_embedding_index', 10, 'test search query') YIELD node, score RETURN node.id as id, node.type as type, score LIMIT 5 `); console.log(` ✅ String query search returned ${result.records.length} results`); // CRITICAL: Verify the query executed successfully expect(result.records).toBeDefined(); expect(Array.isArray(result.records)).toBe(true); if (result.records.length > 0) { const firstScore = result.records[0].get('score'); console.log(` First result score: ${firstScore}`); // CRITICAL: Verify score is cosine similarity (0-1 range) // This was the root cause of the original bug - expecting RRF scores (0.01-0.05) expect(typeof firstScore).toBe('number'); expect(firstScore).toBeGreaterThanOrEqual(0); expect(firstScore).toBeLessThanOrEqual(1); // Warn if scores are suspiciously low if (firstScore < 0.3) { console.warn(` ⚠️ Score ${firstScore} is low - verify embedding quality`); } } else { console.log(' ℹ️ No results found - this is okay if database is empty'); } }, TEST_TIMEOUT); it('should handle db.index.vector.queryNodes with vector array', async () => { // Test with direct vector array (Neo4j compatible format) // NornicDB uses 512-dimensional apple-ml-embeddings const testVector = new Array(512).fill(0.1); const result = await session.run(` CALL db.index.vector.queryNodes('node_embedding_index', 10, $queryVector) YIELD node, score RETURN node.id as id, node.type as type, score LIMIT 5 `, { queryVector: testVector }); console.log(` ✅ Vector array search returned ${result.records.length} results`); // Assert valid response expect(result.records).toBeDefined(); expect(Array.isArray(result.records)).toBe(true); if (result.records.length > 0) { const firstScore = result.records[0].get('score'); console.log(` First result score: ${firstScore}`); // Verify score is in valid cosine similarity range expect(typeof firstScore).toBe('number'); expect(firstScore).toBeGreaterThanOrEqual(0); expect(firstScore).toBeLessThanOrEqual(1); } }, TEST_TIMEOUT); }); describe('Full-text Search Operations', () => { it('should check if node_search fulltext index exists', async () => { // This test checks index existence - important for fallback behavior const result = await session.run(` SHOW INDEXES YIELD name, type WHERE name = 'node_search' AND type = 'FULLTEXT' RETURN name, type `); // Log result but don't fail - NornicDB may auto-create indexes if (result.records.length > 0) { console.log(' ✅ Fulltext index "node_search" exists'); } else { console.log(' ⚠️ Fulltext index "node_search" not found'); console.log(' Mimir fulltext fallback will fail without this index'); } // Assert we got a valid response (not an error) expect(result.records).toBeDefined(); }, TEST_TIMEOUT); it('should handle db.index.fulltext.queryNodes', async () => { // Test fulltext search - this is Mimir's fallback when vector search fails // Use Promise.race to prevent hanging if index doesn't exist const QUERY_TIMEOUT = 10000; const queryPromise = session.run(` CALL db.index.fulltext.queryNodes('node_search', 'test') YIELD node, score RETURN node.id as id, node.type as type, score LIMIT 5 `); const timeoutPromise = new Promise<never>((_, reject) => setTimeout(() => reject(new Error('TIMEOUT: Fulltext query exceeded 10s - likely index does not exist')), QUERY_TIMEOUT) ); try { const result = await Promise.race([queryPromise, timeoutPromise]); console.log(` ✅ Fulltext search returned ${result.records.length} results`); // Verify score format if results exist if (result.records.length > 0) { const firstScore = result.records[0].get('score'); console.log(` First result score: ${firstScore}`); // Fulltext scores can be > 1 (BM25 scoring), but should be positive expect(typeof firstScore).toBe('number'); expect(firstScore).toBeGreaterThan(0); } // Assert we got a valid response expect(result.records).toBeDefined(); expect(Array.isArray(result.records)).toBe(true); } catch (error: any) { if (error.message.includes('TIMEOUT')) { console.log(` ⚠️ ${error.message}`); console.log(' This means Mimir fulltext fallback will NOT work'); // This is a known limitation - log but don't fail the test suite // The index may not exist in NornicDB configuration } else { console.log(` ❌ Fulltext search error: ${error.message}`); // Re-throw unexpected errors - these ARE bugs throw error; } } }, TEST_TIMEOUT); }); describe('Complex Query Patterns (Mimir Search Queries)', () => { it('should handle the NornicDB search query pattern', async () => { // This is the actual query pattern used in nornicDBHybridSearch const searchQuery = 'authentication'; const searchLimit = 20; const minScore = 0.5; const finalLimit = 10; try { const result = await session.run(` CALL db.index.vector.queryNodes('node_embedding_index', $searchLimit, $searchQuery) YIELD node, score WHERE score >= $minScore OPTIONAL MATCH (node)<-[:HAS_CHUNK]-(parentFile:File) RETURN CASE WHEN node.type = 'file_chunk' AND parentFile IS NOT NULL THEN parentFile.path ELSE COALESCE(node.id, node.path) END AS id, node.type AS type, CASE WHEN node.type = 'file_chunk' AND parentFile IS NOT NULL THEN parentFile.name ELSE COALESCE(node.title, node.name) END AS title, node.name AS name, node.description AS description, node.content AS content, node.path AS path, CASE WHEN node.type = 'file_chunk' AND parentFile IS NOT NULL THEN parentFile.absolute_path ELSE node.absolute_path END AS absolute_path, node.text AS chunk_text, node.chunk_index AS chunk_index, score AS similarity, parentFile.path AS parent_file_path, parentFile.absolute_path AS parent_file_absolute_path, parentFile.name AS parent_file_name, parentFile.language AS parent_file_language ORDER BY score DESC LIMIT $finalLimit `, { searchQuery, searchLimit: neo4j.int(searchLimit), minScore, finalLimit: neo4j.int(finalLimit) }); console.log(` ✅ Complex search query returned ${result.records.length} results`); // CRITICAL: Assert response is valid expect(result.records).toBeDefined(); expect(Array.isArray(result.records)).toBe(true); if (result.records.length > 0) { const firstRecord = result.records[0]; console.log(` First result: id=${firstRecord.get('id')}, type=${firstRecord.get('type')}, similarity=${firstRecord.get('similarity')}`); // Verify the similarity is in cosine range (0-1), not RRF (0.01-0.05) const similarity = firstRecord.get('similarity'); if (similarity !== null) { expect(typeof similarity).toBe('number'); expect(similarity).toBeGreaterThanOrEqual(0); expect(similarity).toBeLessThanOrEqual(1); // If results exist, they should meet our minScore threshold expect(similarity).toBeGreaterThanOrEqual(minScore); } } } catch (error: any) { console.log(` ❌ Complex search query failed: ${error.message}`); // Log the error type for debugging if (error.code) { console.log(` Error code: ${error.code}`); } // Re-throw - this is a critical query pattern for Mimir throw error; } }, TEST_TIMEOUT); it('should handle type filtering in search', async () => { // Test type filtering - important for Mimir's search options const types = ['memory', 'todo']; // Note: NornicDB may only return [node, score] from vector search YIELD // The WHERE clause does the filtering, we verify by accessing node properties const result = await session.run(` CALL db.index.vector.queryNodes('node_embedding_index', 20, 'test query') YIELD node, score WHERE score >= 0.5 AND node.type IN $types RETURN node, score LIMIT 10 `, { types }); console.log(` ✅ Type-filtered search returned ${result.records.length} results`); // Assert valid response expect(result.records).toBeDefined(); expect(Array.isArray(result.records)).toBe(true); // Verify all results match the type filter by accessing node properties for (const record of result.records) { const node = record.get('node'); if (node && node.properties && node.properties.type) { expect(types).toContain(node.properties.type); } } }, TEST_TIMEOUT); }); describe('Query Return Format Compatibility', () => { it('should return records with expected field access patterns', async () => { // Create and query in a single operation to avoid session issues const nodeId = `${TEST_PREFIX}format_test_${Date.now()}`; const result = await session.run(` CREATE (n:Node {id: $id, type: 'memory', title: 'Format Test', content: 'Testing record format'}) RETURN n.id as id, n.type as type, n.title as title, n.content as content, n.description as description, n.path as path `, { id: nodeId }); expect(result.records.length).toBe(1); const record = result.records[0]; // Test .get() method - these are the critical assertions expect(record.get('id')).toBe(nodeId); expect(record.get('type')).toBe('memory'); expect(record.get('title')).toBe('Format Test'); expect(record.get('content')).toBe('Testing record format'); // Verify null handling for unset properties expect(record.get('description')).toBeNull(); expect(record.get('path')).toBeNull(); // Test .has() method if available if (typeof record.has === 'function') { expect(record.has('id')).toBe(true); expect(record.has('nonexistent')).toBe(false); console.log(' ✅ Record has .has() method'); } else { console.log(' ℹ️ Record does not have .has() method - using .get() only'); } }, TEST_TIMEOUT); it('should handle null values correctly', async () => { // Test null handling with RETURN of non-existent properties const result = await session.run(` RETURN null as null_value, 'test' as string_value `); const record = result.records[0]; // Null values should return null expect(record.get('null_value')).toBeNull(); expect(record.get('string_value')).toBe('test'); console.log(' ✅ Null values handled correctly'); }, TEST_TIMEOUT); }); describe('Performance Baseline', () => { it('should complete simple query within reasonable time', async () => { const start = Date.now(); await session.run(` MATCH (n:Node) RETURN count(n) as nodeCount `); const elapsed = Date.now() - start; console.log(` ⏱️ Simple count query: ${elapsed}ms`); expect(elapsed).toBeLessThan(5000); // Should be under 5 seconds }); it('should complete vector search within reasonable time', async () => { const start = Date.now(); try { await session.run(` CALL db.index.vector.queryNodes('node_embedding_index', 10, 'test query') YIELD node, score RETURN node.id, score LIMIT 5 `); const elapsed = Date.now() - start; console.log(` ⏱️ Vector search: ${elapsed}ms`); expect(elapsed).toBeLessThan(10000); // Should be under 10 seconds } catch (error: any) { const elapsed = Date.now() - start; console.log(` ⏱️ Vector search (failed): ${elapsed}ms - ${error.message}`); expect(elapsed).toBeLessThan(10000); } }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/orneryd/Mimir'

If you have feedback or need assistance with the MCP directory API, please join our Discord server