970 lines
28 KiB
JavaScript
970 lines
28 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|