import { describe, test, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; import { initSchema, getSchemaVersion } from '../src/db/schema.js'; import { createMemoryDatabase } from '../src/db/connection.js'; import { storeMemory } from '../src/commands/store.js'; import { searchMemories } from '../src/commands/search.js'; import { listMemories } from '../src/commands/list.js'; import { pruneMemories } from '../src/commands/prune.js'; import { deleteMemories } from '../src/commands/delete.js'; describe('Database Layer', () => { let db; beforeEach(() => { // Use in-memory database for speed db = new Database(':memory:'); }); afterEach(() => { if (db) { db.close(); } }); describe('Schema Initialization', () => { test('creates memories table with correct schema', () => { initSchema(db); const tables = db.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='memories'" ).all(); expect(tables).toHaveLength(1); expect(tables[0].name).toBe('memories'); // Check columns const columns = db.prepare('PRAGMA table_info(memories)').all(); const columnNames = columns.map(c => c.name); expect(columnNames).toContain('id'); expect(columnNames).toContain('content'); expect(columnNames).toContain('created_at'); expect(columnNames).toContain('entered_by'); expect(columnNames).toContain('expires_at'); }); test('creates tags table with correct schema', () => { initSchema(db); const tables = db.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='tags'" ).all(); expect(tables).toHaveLength(1); const columns = db.prepare('PRAGMA table_info(tags)').all(); const columnNames = columns.map(c => c.name); expect(columnNames).toContain('id'); expect(columnNames).toContain('name'); expect(columnNames).toContain('created_at'); }); test('creates memory_tags junction table', () => { initSchema(db); const tables = db.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='memory_tags'" ).all(); expect(tables).toHaveLength(1); const columns = db.prepare('PRAGMA table_info(memory_tags)').all(); const columnNames = columns.map(c => c.name); expect(columnNames).toContain('memory_id'); expect(columnNames).toContain('tag_id'); }); test('creates metadata table with schema_version', () => { initSchema(db); const version = db.prepare( "SELECT value FROM metadata WHERE key = 'schema_version'" ).get(); expect(version).toBeDefined(); expect(version.value).toBe('1'); }); test('creates indexes on memories(created_at, expires_at)', () => { initSchema(db); const indexes = db.prepare( "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='memories'" ).all(); const indexNames = indexes.map(i => i.name); expect(indexNames).toContain('idx_memories_created'); expect(indexNames).toContain('idx_memories_expires'); }); test('creates indexes on tags(name) and memory_tags(tag_id)', () => { initSchema(db); const tagIndexes = db.prepare( "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='tags'" ).all(); expect(tagIndexes.some(i => i.name === 'idx_tags_name')).toBe(true); const junctionIndexes = db.prepare( "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='memory_tags'" ).all(); expect(junctionIndexes.some(i => i.name === 'idx_memory_tags_tag')).toBe(true); }); test('enables WAL mode for better concurrency', () => { initSchema(db); const journalMode = db.pragma('journal_mode', { simple: true }); // In-memory databases return 'memory' instead of 'wal' // This is expected behavior for :memory: databases expect(['wal', 'memory']).toContain(journalMode); }); }); describe('Connection Management', () => { test('opens database connection', () => { const testDb = createMemoryDatabase(); expect(testDb).toBeDefined(); // Should be able to query const result = testDb.prepare('SELECT 1 as test').get(); expect(result.test).toBe(1); testDb.close(); }); test('initializes schema on first run', () => { const testDb = createMemoryDatabase(); // Check that tables exist const tables = testDb.prepare( "SELECT name FROM sqlite_master WHERE type='table'" ).all(); const tableNames = tables.map(t => t.name); expect(tableNames).toContain('memories'); expect(tableNames).toContain('tags'); expect(tableNames).toContain('memory_tags'); expect(tableNames).toContain('metadata'); testDb.close(); }); test('skips schema creation if already initialized', () => { const testDb = new Database(':memory:'); // Initialize twice initSchema(testDb); initSchema(testDb); // Should still have correct schema version const version = getSchemaVersion(testDb); expect(version).toBe(1); testDb.close(); }); test('sets pragmas (WAL, cache_size, synchronous)', () => { const testDb = createMemoryDatabase(); const journalMode = testDb.pragma('journal_mode', { simple: true }); // In-memory databases return 'memory' instead of 'wal' expect(['wal', 'memory']).toContain(journalMode); const synchronous = testDb.pragma('synchronous', { simple: true }); expect(synchronous).toBe(1); // NORMAL testDb.close(); }); test('closes connection properly', () => { const testDb = createMemoryDatabase(); expect(() => testDb.close()).not.toThrow(); expect(testDb.open).toBe(false); }); }); }); describe('Store Command', () => { let db; beforeEach(() => { db = createMemoryDatabase(); }); afterEach(() => { if (db) { db.close(); } }); test('stores memory with tags', () => { const result = storeMemory(db, { content: 'Docker uses bridge networks by default', tags: 'docker,networking', entered_by: 'test' }); expect(result.id).toBeDefined(); expect(result.content).toBe('Docker uses bridge networks by default'); // Verify in database const memory = db.prepare('SELECT * FROM memories WHERE id = ?').get(result.id); expect(memory.content).toBe('Docker uses bridge networks by default'); expect(memory.entered_by).toBe('test'); // Verify tags const tags = db.prepare(` SELECT t.name FROM tags t JOIN memory_tags mt ON t.id = mt.tag_id WHERE mt.memory_id = ? ORDER BY t.name `).all(result.id); expect(tags.map(t => t.name)).toEqual(['docker', 'networking']); }); test('rejects content over 10KB', () => { const longContent = 'x'.repeat(10001); expect(() => { storeMemory(db, { content: longContent }); }).toThrow('Content exceeds 10KB limit'); }); test('normalizes tags to lowercase', () => { storeMemory(db, { content: 'Test memory', tags: 'Docker,NETWORKING,KuberNeteS' }); const tags = db.prepare('SELECT name FROM tags ORDER BY name').all(); expect(tags.map(t => t.name)).toEqual(['docker', 'kubernetes', 'networking']); }); test('handles missing tags gracefully', () => { const result = storeMemory(db, { content: 'Memory without tags' }); expect(result.id).toBeDefined(); const tags = db.prepare(` SELECT t.name FROM tags t JOIN memory_tags mt ON t.id = mt.tag_id WHERE mt.memory_id = ? `).all(result.id); expect(tags).toHaveLength(0); }); test('handles expiration date parsing', () => { const futureDate = new Date(Date.now() + 86400000); // Tomorrow const result = storeMemory(db, { content: 'Temporary memory', expires_at: futureDate.toISOString() }); const memory = db.prepare('SELECT expires_at FROM memories WHERE id = ?').get(result.id); expect(memory.expires_at).toBeGreaterThan(Math.floor(Date.now() / 1000)); }); test('deduplicates tags across memories', () => { storeMemory(db, { content: 'Memory 1', tags: 'docker,networking' }); storeMemory(db, { content: 'Memory 2', tags: 'docker,kubernetes' }); const tags = db.prepare('SELECT name FROM tags ORDER BY name').all(); expect(tags.map(t => t.name)).toEqual(['docker', 'kubernetes', 'networking']); }); test('rejects empty content', () => { expect(() => { storeMemory(db, { content: '' }); }).toThrow(); // Just check that it throws, message might vary }); test('rejects expiration in the past', () => { const pastDate = new Date(Date.now() - 86400000); // Yesterday expect(() => { storeMemory(db, { content: 'Test', expires_at: pastDate.toISOString() }); }).toThrow('Expiration date must be in the future'); }); }); describe('Search Command', () => { let db; beforeEach(() => { db = createMemoryDatabase(); // Seed with test data storeMemory(db, { content: 'Docker uses bridge networks by default', tags: 'docker,networking' }); storeMemory(db, { content: 'Kubernetes pods share network namespace', tags: 'kubernetes,networking' }); storeMemory(db, { content: 'PostgreSQL requires explicit vacuum', tags: 'postgresql,database' }); }); afterEach(() => { if (db) { db.close(); } }); test('finds memories by content (case-insensitive)', () => { const results = searchMemories(db, 'docker'); expect(results).toHaveLength(1); expect(results[0].content).toContain('Docker'); }); test('filters by tags (AND logic)', () => { const results = searchMemories(db, '', { tags: ['networking'] }); expect(results).toHaveLength(2); const contents = results.map(r => r.content); expect(contents).toContain('Docker uses bridge networks by default'); expect(contents).toContain('Kubernetes pods share network namespace'); }); test('filters by tags (OR logic with anyTag)', () => { const results = searchMemories(db, '', { tags: ['docker', 'postgresql'], anyTag: true }); expect(results).toHaveLength(2); const contents = results.map(r => r.content); expect(contents).toContain('Docker uses bridge networks by default'); expect(contents).toContain('PostgreSQL requires explicit vacuum'); }); test('filters by date range (after/before)', () => { const now = Date.now(); // Add a memory from "yesterday" db.prepare('UPDATE memories SET created_at = ? WHERE id = 1').run( Math.floor((now - 86400000) / 1000) ); // Search for memories after yesterday const results = searchMemories(db, '', { after: Math.floor((now - 43200000) / 1000) // 12 hours ago }); expect(results.length).toBeGreaterThanOrEqual(2); }); test('filters by entered_by (agent)', () => { storeMemory(db, { content: 'Memory from investigate agent', entered_by: 'investigate-agent' }); const results = searchMemories(db, '', { entered_by: 'investigate-agent' }); expect(results).toHaveLength(1); expect(results[0].entered_by).toBe('investigate-agent'); }); test('excludes expired memories automatically', () => { // Add expired memory (bypass CHECK constraint by inserting with created_at in past) const pastTimestamp = Math.floor((Date.now() - 86400000) / 1000); // Yesterday db.prepare('INSERT INTO memories (content, created_at, expires_at) VALUES (?, ?, ?)').run( 'Expired memory', pastTimestamp - 86400, // created_at even earlier pastTimestamp // expires_at in past but after created_at ); const results = searchMemories(db, 'expired'); expect(results).toHaveLength(0); }); test('respects limit option', () => { // Add more memories for (let i = 0; i < 10; i++) { storeMemory(db, { content: `Memory ${i}`, tags: 'test' }); } const results = searchMemories(db, '', { limit: 5 }); expect(results).toHaveLength(5); }); test('orders by created_at DESC', () => { const results = searchMemories(db, ''); // Results should be in descending order (newest first) for (let i = 1; i < results.length; i++) { expect(results[i - 1].created_at).toBeGreaterThanOrEqual(results[i].created_at); } }); test('returns memory with tags joined', () => { const results = searchMemories(db, 'docker'); expect(results).toHaveLength(1); expect(results[0].tags).toBeTruthy(); expect(results[0].tags).toContain('docker'); expect(results[0].tags).toContain('networking'); }); }); describe('Integration Tests', () => { let db; beforeEach(() => { db = createMemoryDatabase(); }); afterEach(() => { if (db) { db.close(); } }); describe('Full Workflow', () => { test('store → search → retrieve workflow', () => { // Store const stored = storeMemory(db, { content: 'Docker uses bridge networks', tags: 'docker,networking' }); expect(stored.id).toBeDefined(); // Search const results = searchMemories(db, 'docker'); expect(results).toHaveLength(1); expect(results[0].content).toBe('Docker uses bridge networks'); // List const all = listMemories(db); expect(all).toHaveLength(1); expect(all[0].tags).toContain('docker'); }); test('store multiple → list → filter by tags', () => { storeMemory(db, { content: 'Memory 1', tags: 'docker,networking' }); storeMemory(db, { content: 'Memory 2', tags: 'kubernetes,networking' }); storeMemory(db, { content: 'Memory 3', tags: 'postgresql,database' }); const all = listMemories(db); expect(all).toHaveLength(3); const networkingOnly = listMemories(db, { tags: ['networking'] }); expect(networkingOnly).toHaveLength(2); }); test('store with expiration → prune → verify removed', () => { // Store non-expired storeMemory(db, { content: 'Active memory' }); // Store expired (manually set to past by updating both timestamps) const expired = storeMemory(db, { content: 'Expired memory' }); const pastCreated = Math.floor((Date.now() - 172800000) / 1000); // 2 days ago const pastExpired = Math.floor((Date.now() - 86400000) / 1000); // 1 day ago db.prepare('UPDATE memories SET created_at = ?, expires_at = ? WHERE id = ?').run( pastCreated, pastExpired, expired.id ); // Verify both exist const before = listMemories(db); expect(before).toHaveLength(1); // Expired is filtered out // Prune const result = pruneMemories(db); expect(result.count).toBe(1); expect(result.deleted).toBe(true); // Verify expired removed const all = db.prepare('SELECT * FROM memories').all(); expect(all).toHaveLength(1); expect(all[0].content).toBe('Active memory'); }); }); describe('Performance', () => { test('searches 100 memories in <50ms (Phase 1 target)', () => { // Insert 100 memories for (let i = 0; i < 100; i++) { storeMemory(db, { content: `Memory ${i} about docker and networking`, tags: i % 2 === 0 ? 'docker' : 'networking' }); } const start = Date.now(); const results = searchMemories(db, 'docker'); const duration = Date.now() - start; expect(results.length).toBeGreaterThan(0); expect(duration).toBeLessThan(50); }); test('stores 100 memories in <1 second', () => { const start = Date.now(); for (let i = 0; i < 100; i++) { storeMemory(db, { content: `Memory ${i}`, tags: 'test' }); } const duration = Date.now() - start; expect(duration).toBeLessThan(1000); }); }); describe('Edge Cases', () => { test('handles empty search query', () => { storeMemory(db, { content: 'Test memory' }); const results = searchMemories(db, ''); expect(results).toHaveLength(1); }); test('handles no results found', () => { storeMemory(db, { content: 'Test memory' }); const results = searchMemories(db, 'nonexistent'); expect(results).toHaveLength(0); }); test('handles special characters in content', () => { const specialContent = 'Test with special chars: @#$%^&*()'; storeMemory(db, { content: specialContent }); const results = searchMemories(db, 'special chars'); expect(results).toHaveLength(1); expect(results[0].content).toBe(specialContent); }); test('handles unicode in content and tags', () => { storeMemory(db, { content: 'Unicode test: café, 日本語, emoji 🚀', tags: 'café,日本語' }); const results = searchMemories(db, 'café'); expect(results).toHaveLength(1); }); test('handles very long tag lists', () => { const manyTags = Array.from({ length: 20 }, (_, i) => `tag${i}`).join(','); const stored = storeMemory(db, { content: 'Memory with many tags', tags: manyTags }); const results = searchMemories(db, '', { tags: ['tag5'] }); expect(results).toHaveLength(1); }); }); }); describe('Delete Command', () => { let db; beforeEach(() => { db = createMemoryDatabase(); // Seed with test data storeMemory(db, { content: 'Test memory 1', tags: 'test,demo', entered_by: 'test-agent' }); storeMemory(db, { content: 'Test memory 2', tags: 'test,sample', entered_by: 'test-agent' }); storeMemory(db, { content: 'Production memory', tags: 'prod,important', entered_by: 'prod-agent' }); storeMemory(db, { content: 'Docker networking notes', tags: 'docker,networking', entered_by: 'manual' }); }); afterEach(() => { if (db) { db.close(); } }); describe('Delete by IDs', () => { test('deletes memories by single ID', () => { const result = deleteMemories(db, { ids: [1] }); expect(result.count).toBe(1); expect(result.deleted).toBe(true); const remaining = db.prepare('SELECT * FROM memories').all(); expect(remaining).toHaveLength(3); expect(remaining.find(m => m.id === 1)).toBeUndefined(); }); test('deletes memories by comma-separated IDs', () => { const result = deleteMemories(db, { ids: [1, 2] }); expect(result.count).toBe(2); expect(result.deleted).toBe(true); const remaining = db.prepare('SELECT * FROM memories').all(); expect(remaining).toHaveLength(2); }); test('handles non-existent IDs gracefully', () => { const result = deleteMemories(db, { ids: [999, 1000] }); expect(result.count).toBe(0); expect(result.deleted).toBe(true); }); test('handles mix of valid and invalid IDs', () => { const result = deleteMemories(db, { ids: [1, 999, 2] }); expect(result.count).toBe(2); }); }); describe('Delete by Tags', () => { test('deletes memories by single tag', () => { const result = deleteMemories(db, { tags: ['test'] }); expect(result.count).toBe(2); expect(result.deleted).toBe(true); const remaining = db.prepare('SELECT * FROM memories').all(); expect(remaining).toHaveLength(2); expect(remaining.find(m => m.content.includes('Test'))).toBeUndefined(); }); test('deletes memories by multiple tags (AND logic)', () => { const result = deleteMemories(db, { tags: ['test', 'demo'] }); expect(result.count).toBe(1); expect(result.deleted).toBe(true); const memory = db.prepare('SELECT * FROM memories WHERE id = 1').get(); expect(memory).toBeUndefined(); }); test('deletes memories by tags with OR logic (anyTag)', () => { const result = deleteMemories(db, { tags: ['demo', 'docker'], anyTag: true }); expect(result.count).toBe(2); // Memory 1 (demo) and Memory 4 (docker) expect(result.deleted).toBe(true); }); test('returns zero count when no tags match', () => { const result = deleteMemories(db, { tags: ['nonexistent'] }); expect(result.count).toBe(0); }); }); describe('Delete by Content (LIKE query)', () => { test('deletes memories matching LIKE query', () => { const result = deleteMemories(db, { query: 'Test' }); expect(result.count).toBe(2); expect(result.deleted).toBe(true); }); test('case-insensitive LIKE matching', () => { const result = deleteMemories(db, { query: 'DOCKER' }); expect(result.count).toBe(1); expect(result.deleted).toBe(true); }); test('handles partial matches', () => { const result = deleteMemories(db, { query: 'memory' }); expect(result.count).toBe(3); // Matches "Test memory 1", "Test memory 2", "Production memory" }); }); describe('Delete by Date Range', () => { test('deletes memories before date', () => { const now = Date.now(); // Update memory 1 to be from yesterday db.prepare('UPDATE memories SET created_at = ? WHERE id = 1').run( Math.floor((now - 86400000) / 1000) ); const result = deleteMemories(db, { before: Math.floor(now / 1000) }); expect(result.count).toBeGreaterThanOrEqual(1); }); test('deletes memories after date', () => { const yesterday = Math.floor((Date.now() - 86400000) / 1000); const result = deleteMemories(db, { after: yesterday }); expect(result.count).toBeGreaterThanOrEqual(3); }); test('deletes memories in date range (after + before)', () => { const now = Date.now(); const yesterday = Math.floor((now - 86400000) / 1000); const tomorrow = Math.floor((now + 86400000) / 1000); // Set specific timestamps db.prepare('UPDATE memories SET created_at = ? WHERE id = 1').run(yesterday - 86400); db.prepare('UPDATE memories SET created_at = ? WHERE id = 2').run(yesterday); db.prepare('UPDATE memories SET created_at = ? WHERE id = 3').run(Math.floor(now / 1000)); const result = deleteMemories(db, { after: yesterday - 3600, // After memory 1 before: Math.floor(now / 1000) - 3600 // Before memory 3 }); expect(result.count).toBe(1); // Only memory 2 }); }); describe('Delete by Agent', () => { test('deletes memories by entered_by agent', () => { const result = deleteMemories(db, { entered_by: 'test-agent' }); expect(result.count).toBe(2); expect(result.deleted).toBe(true); const remaining = db.prepare('SELECT * FROM memories').all(); expect(remaining.every(m => m.entered_by !== 'test-agent')).toBe(true); }); test('combination: agent + tags', () => { const result = deleteMemories(db, { entered_by: 'test-agent', tags: ['demo'] }); expect(result.count).toBe(1); // Only memory 1 }); }); describe('Expired Memory Handling', () => { test('excludes expired memories by default', () => { // Create expired memory const pastCreated = Math.floor((Date.now() - 172800000) / 1000); const pastExpired = Math.floor((Date.now() - 86400000) / 1000); db.prepare('INSERT INTO memories (content, created_at, expires_at, entered_by) VALUES (?, ?, ?, ?)').run( 'Expired test memory', pastCreated, pastExpired, 'test-agent' ); const result = deleteMemories(db, { entered_by: 'test-agent' }); expect(result.count).toBe(2); // Only non-expired test-agent memories }); test('includes expired with includeExpired flag', () => { // Create expired memory const pastCreated = Math.floor((Date.now() - 172800000) / 1000); const pastExpired = Math.floor((Date.now() - 86400000) / 1000); db.prepare('INSERT INTO memories (content, created_at, expires_at, entered_by) VALUES (?, ?, ?, ?)').run( 'Expired test memory', pastCreated, pastExpired, 'test-agent' ); const result = deleteMemories(db, { entered_by: 'test-agent', includeExpired: true }); expect(result.count).toBe(3); // All test-agent memories including expired }); test('deletes only expired with expiredOnly flag', () => { // Create expired memory const pastCreated = Math.floor((Date.now() - 172800000) / 1000); const pastExpired = Math.floor((Date.now() - 86400000) / 1000); db.prepare('INSERT INTO memories (content, created_at, expires_at, entered_by) VALUES (?, ?, ?, ?)').run( 'Expired memory', pastCreated, pastExpired, 'test-agent' ); const result = deleteMemories(db, { expiredOnly: true }); expect(result.count).toBe(1); // Verify non-expired still exist const remaining = db.prepare('SELECT * FROM memories').all(); expect(remaining).toHaveLength(4); }); }); describe('Dry Run Mode', () => { test('dry-run returns memories without deleting', () => { const result = deleteMemories(db, { tags: ['test'], dryRun: true }); expect(result.count).toBe(2); expect(result.deleted).toBe(false); expect(result.memories).toHaveLength(2); // Verify nothing was deleted const all = db.prepare('SELECT * FROM memories').all(); expect(all).toHaveLength(4); }); test('dry-run includes memory details', () => { const result = deleteMemories(db, { ids: [1], dryRun: true }); expect(result.memories[0]).toHaveProperty('id'); expect(result.memories[0]).toHaveProperty('content'); expect(result.memories[0]).toHaveProperty('created_at'); }); }); describe('Safety Features', () => { test('requires at least one filter criterion', () => { expect(() => { deleteMemories(db, {}); }).toThrow('At least one filter criterion is required'); }); test('handles empty result set gracefully', () => { const result = deleteMemories(db, { tags: ['nonexistent'] }); expect(result.count).toBe(0); expect(result.deleted).toBe(true); }); }); describe('Combination Filters', () => { test('combines tags + query', () => { const result = deleteMemories(db, { tags: ['test'], query: 'memory 1' }); expect(result.count).toBe(1); // Only "Test memory 1" }); test('combines agent + date range', () => { const now = Date.now(); const yesterday = Math.floor((now - 86400000) / 1000); db.prepare('UPDATE memories SET created_at = ? WHERE id = 1').run(yesterday); const result = deleteMemories(db, { entered_by: 'test-agent', after: yesterday - 3600 }); expect(result.count).toBeGreaterThanOrEqual(1); }); test('combines all filter types', () => { const result = deleteMemories(db, { tags: ['test'], query: 'memory', entered_by: 'test-agent', dryRun: true }); expect(result.count).toBe(2); expect(result.deleted).toBe(false); }); }); });