/** * 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); }); it('删除 NULL content 消息不抛错且索引保持一致(truncateAfter 安全性)', () => { // 模拟真实会话混合场景:user 文本 + assistant(NULL, 仅 tool_calls) + tool 结果 + assistant 文本 insertMessage('m7', 's3', 'user', 'ftsdelete-test 用户消息', 7000); insertMessage('m8', 's3', 'assistant', null, 7100); insertMessage('m9', 's3', 'tool', '工具结果包含 ftsdelete-test 关键词', 7200); insertMessage('m10', 's3', 'assistant', '最终回答', 7300); db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s3', '会话三', 1, 1)`, ).run(); const ftsBefore = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number }) .c; const msgBefore = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c; expect(ftsBefore).toBe(msgBefore); // 删除 NULL content 行 — delete 触发器以 old.content=NULL 执行 'delete' 命令 expect(() => db.prepare(`DELETE FROM messages WHERE id = 'm8'`).run()).not.toThrow(); // 行数仍一致 const ftsAfter = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number }) .c; const msgAfter = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c; expect(ftsAfter).toBe(msgAfter); expect(ftsAfter).toBe(ftsBefore - 1); // 索引仍可正常查询(未被 NULL 删除破坏) const hit = sessionService.searchMessages('ftsdelete-test'); expect(hit.some((r: any) => r.sessionId === 's3')).toBe(true); }); it('混合 NULL/非 NULL 的批量删除(truncateAfter 全场景)不抛错', () => { // 再插入一批混合消息 insertMessage('m11', 's3', 'assistant', null, 8000); insertMessage('m12', 's3', 'user', 'ftsdelete-test 第二轮', 8100); insertMessage('m13', 's3', 'assistant', null, 8200); // 批量删除(模拟 truncateMessagesAfter 的 rowid 范围删除) expect(() => db .prepare( `DELETE FROM messages WHERE session_id = 's3' AND rowid >= (SELECT rowid FROM messages WHERE id = 'm7')`, ) .run(), ).not.toThrow(); // s3 全删后行数一致,其他会话索引不受影响 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); const s3Hit = sessionService.searchMessages('ftsdelete-test'); expect(s3Hit.every((r: any) => r.sessionId !== 's3')).toBe(true); // 其他会话仍可搜索 expect(sessionService.searchMessages('uniquekeyword').length).toBeGreaterThanOrEqual(1); }); }); 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); }); });