/** * FTS5 会话内容搜索 + 审计日志导出 回归测试(v0.5.0) * * 覆盖: * 1. messages_fts 虚表创建与触发器实时同步(INSERT / DELETE) * 2. searchMessages:短语命中、跨会话聚合、FTS 运算符输入安全、snippet 生成 * 3. 审计导出:JSONL 行格式、CSV RFC 4180 转义(逗号/引号/换行) * * 运行要求:better-sqlite3 为 Electron ABI 构建,需 test:electron 模式 * (cross-env ELECTRON_RUN_AS_NODE=1 electron ...)执行;系统 Node 下自动跳过。 */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; let dbAvailable = true; let Database: typeof import('better-sqlite3'); try { // eslint-disable-next-line @typescript-eslint/no-require-imports Database = require('better-sqlite3'); const probe = new Database(':memory:'); probe.close(); } catch { dbAvailable = false; } describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => { let db: any; let dir: string; let sessionService: any; beforeAll(async () => { const { SessionService } = await import('../session.service'); dir = mkdtempSync(join(tmpdir(), 'metona-search-')); db = new Database(':memory:'); // 与 DatabaseService.createTables 一致的最小 schema(含 FTS 虚表 + 触发器) db.exec(` CREATE TABLE sessions ( id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}' ); CREATE TABLE messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, created_at INTEGER NOT NULL ); CREATE VIRTUAL TABLE messages_fts USING fts5( content, content=messages, content_rowid=rowid ); CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content); END; CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content); END; `); sessionService = new SessionService(() => db); }); afterAll(() => { try { db?.close(); } catch { /* ignore */ } try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); const insertMessage = ( id: string, sessionId: string, role: string, content: string | null, ts: number, ): void => { db.prepare( `INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`, ).run(id, sessionId, role, content, ts); }; it('FTS5 可用且插入消息实时进入索引', () => { // FTS5 编译可用性(better-sqlite3 预编译含 FTS5,此处防御性验证) insertMessage('m1', 's1', 'user', 'Hello FTS5 world', 1000); const rows = db .prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'hello'`) .all() as Array<{ c: number }>; expect(rows[0].c).toBe(1); }); it('searchMessages 按会话聚合返回(最新匹配 + 匹配计数 + snippet)', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s1', '会话一', 1, 1)`, ).run(); db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s2', '会话二', 1, 1)`, ).run(); insertMessage('m2', 's1', 'assistant', 'uniquekeyword 出现了一次', 2000); insertMessage('m3', 's2', 'user', 'uniquekeyword 在另一个会话', 3000); insertMessage('m4', 's2', 'assistant', 'uniquekeyword 再次出现', 4000); const results = sessionService.searchMessages('uniquekeyword'); expect(results).toHaveLength(2); // s2 的最新匹配(m4)先返回(按时间倒序聚合) expect(results[0].sessionId).toBe('s2'); expect(results[0].matchCount).toBe(2); expect(results[0].lastMessageId).toBe('m4'); expect(results[0].sessionTitle).toBe('会话二'); expect(results[0].snippet).toContain('uniquekeyword'); // s1 单条匹配 expect(results[1].sessionId).toBe('s1'); expect(results[1].matchCount).toBe(1); }); it('删除消息后索引同步(搜索不再命中)', () => { db.prepare(`DELETE FROM messages WHERE id = 'm2'`).run(); const results = sessionService.searchMessages('uniquekeyword'); expect(results).toHaveLength(1); expect(results[0].sessionId).toBe('s2'); }); it('FTS 运算符/引号输入安全(不抛错,按短语匹配)', () => { insertMessage('m5', 's1', 'user', '包含 AND OR NOT 与 "引号" 的消息', 5000); // 含 FTS 保留字与引号的查询不抛异常 expect(() => sessionService.searchMessages('AND OR NOT')).not.toThrow(); expect(() => sessionService.searchMessages('x"y')).not.toThrow(); // 引号内容作为短语可命中 const hit = sessionService.searchMessages('引号'); expect(hit.some((r: any) => r.lastMessageId === 'm5')).toBe(true); }); it('空查询与无匹配返回空数组', () => { expect(sessionService.searchMessages('')).toHaveLength(0); expect(sessionService.searchMessages(' ')).toHaveLength(0); expect(sessionService.searchMessages('不存在的关键词xyz')).toHaveLength(0); }); it('NULL content 消息不进索引(assistant 仅有 tool_calls 场景)', () => { insertMessage('m6', 's1', 'assistant', null, 6000); const rows = db .prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'nulltest'`) .all() as Array<{ c: number }>; expect(rows[0].c).toBe(0); // FTS 行数与 messages 行数一致(NULL 行在 FTS 中占位但不索引内容) const fts = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number }).c; const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c; expect(fts).toBe(msg); }); }); describe.skipIf(!dbAvailable)('审计日志导出(v0.5.0)', () => { let db: any; let dir: string; let auditService: any; beforeAll(async () => { const { AuditService } = await import('../audit.service'); dir = mkdtempSync(join(tmpdir(), 'metona-audit-export-')); db = new Database(':memory:'); db.exec(` CREATE TABLE audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, iteration INTEGER, event_type TEXT NOT NULL, actor TEXT NOT NULL DEFAULT 'system', target TEXT NOT NULL, details TEXT, outcome TEXT, duration_ms INTEGER, created_at INTEGER NOT NULL, prev_hash TEXT, current_hash TEXT ); `); auditService = new AuditService(() => db); }); afterAll(() => { try { db?.close(); } catch { /* ignore */ } try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('exportJSONL 每行一条 JSON 记录(含链式哈希字段)', () => { auditService.log({ sessionId: 's1', eventType: 'tool_call', actor: 'agent', target: 'run_command', details: { command: 'ls' }, outcome: 'success', durationMs: 10, }); auditService.log({ sessionId: 's1', eventType: 'session_end', actor: 'agent', target: 'session', outcome: 'success', }); const jsonl = auditService.exportJSONL(); const lines = jsonl.trim().split('\n'); expect(lines).toHaveLength(2); const first = JSON.parse(lines[0]); expect(first.event_type).toBe('tool_call'); expect(first.session_id).toBe('s1'); expect(first.current_hash).toMatch(/^[0-9a-f]{64}$/); // 第二条 prev_hash 等于第一条 current_hash(链完整保留在导出中) const second = JSON.parse(lines[1]); expect(second.prev_hash).toBe(first.current_hash); }); it('exportCSV 含表头且 RFC 4180 转义(逗号/引号/换行字段)', () => { auditService.log({ sessionId: 's2', eventType: 'error', actor: 'agent', target: 'target,with,commas', details: { msg: '含"双引号"和\n换行' }, outcome: 'error', }); const csv = auditService.exportCSV(); const lines = csv.split('\n'); // 表头 expect(lines[0]).toBe( 'id,session_id,iteration,event_type,actor,target,details,outcome,duration_ms,created_at,prev_hash,current_hash', ); // 含特殊字符的 target 字段被双引号包裹 expect(csv).toContain('"target,with,commas"'); // details 存储为 JSON 字符串(内部双引号为 \" 转义),CSV 再转义后为 \""(反斜杠保留 + 引号翻倍) expect(csv).toContain('\\""双引号\\""'); // 记录数与 JSONL 一致 const parsed = auditService .exportJSONL() .trim() .split('\n') .map((l: string) => JSON.parse(l)); expect(parsed.length).toBeGreaterThanOrEqual(3); }); });