/** * 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, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL ); CREATE VIRTUAL TABLE messages_fts USING fts5( content, content=messages, content_rowid=rowid, tokenize='trigram' ); 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); }); // v0.7.4 P4-4: trigram 中文子串搜索(messages_fts trigram tokenizer) it('trigram 中文子串搜索:≥3 字查询命中非连续子串(P4-4 根治)', () => { insertMessage('m14', 's1', 'user', '人工智能正在改变世界', 7000); // 3 字子串「人工智」/「能正在」—— trigram 任意子串命中 const hit1 = sessionService.searchMessages('人工智'); expect(hit1.some((r: any) => r.lastMessageId === 'm14')).toBe(true); const hit2 = sessionService.searchMessages('智能正'); expect(hit2.some((r: any) => r.lastMessageId === 'm14')).toBe(true); // 4 字整串命中 const hit3 = sessionService.searchMessages('人工智能'); expect(hit3.some((r: any) => r.lastMessageId === 'm14')).toBe(true); // 2 字中文走 LIKE 回退(trigram 无法匹配 2 字) const hit4 = sessionService.searchMessages('正在'); expect(hit4.some((r: any) => r.lastMessageId === 'm14')).toBe(true); // 不存在的子串不命中 expect(sessionService.searchMessages('机器学习')).toHaveLength(0); }); // v0.7.4 P4-4 修正: ≤2 字符查询(trigram 无法构成 3-gram)LIKE 回退 it('≤2 字符查询(2 字英文/单字符)LIKE 回退命中(P4-4 修正)', () => { insertMessage('m15', 's1', 'user', 'AI 助手 ok 完成', 9000); // 2 字英文:trigram 0 命中,LIKE 回退 const hit1 = sessionService.searchMessages('AI'); expect(hit1.some((r: any) => r.lastMessageId === 'm15')).toBe(true); // 2 字英文小写 const hit2 = sessionService.searchMessages('ok'); expect(hit2.some((r: any) => r.lastMessageId === 'm15')).toBe(true); // 单字符英文 const hit3 = sessionService.searchMessages('o'); expect(hit3.some((r: any) => r.lastMessageId === 'm15')).toBe(true); // 2 字中文 LIKE 回退仍生效 const hit4 = sessionService.searchMessages('助手'); expect(hit4.some((r: any) => r.lastMessageId === 'm15')).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)('SessionService — 会话 CRUD 与消息边界', () => { let db: any; let sessionService: any; beforeAll(async () => { const { SessionService } = await import('../session.service'); db = new Database(':memory:'); 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, total_tokens 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, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER ); CREATE TABLE session_summaries ( session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, summarized_until_rowid INTEGER NOT NULL, updated_at INTEGER NOT NULL ); `); sessionService = new SessionService(() => db); }); afterAll(() => { try { db?.close(); } catch { /* ignore */ } }); const createSession = (id: string, title = '会话'): void => { db.prepare(`INSERT INTO sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`).run( id, title, Date.now(), Date.now(), ); }; 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('getSession 返回完整会话信息(pinned/archived 布尔映射)', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at, message_count, total_tokens, pinned, archived) VALUES ('crud_s1', 'CRUD 会话', 1, 2, 5, 123, 1, 0)`, ).run(); const info = sessionService.getSession('crud_s1'); expect(info).toMatchObject({ id: 'crud_s1', title: 'CRUD 会话', messageCount: 5, totalTokens: 123, pinned: true, archived: false, }); }); it('getSession 不存在的会话返回 null', () => { expect(sessionService.getSession('crud_nope')).toBeNull(); }); it('getSession 对归档会话仍可查询(归档不隐藏,P1-4 契约)', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at, archived) VALUES ('crud_arch', '归档会话', 1, 1, 1)`, ).run(); expect(sessionService.getSession('crud_arch')).not.toBeNull(); }); it('list 默认只返回未归档会话,按 pinned 优先 + updated_at 倒序', () => { createSession('crud_l1', '普通'); db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at, pinned) VALUES ('crud_l2', '置顶', 1, 10, 1)`, ).run(); db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at, archived) VALUES ('crud_l3', '归档', 1, 20, 1)`, ).run(); const list = sessionService.list(); const ids = list.map((s: any) => s.id); expect(ids).toContain('crud_l2'); expect(ids).not.toContain('crud_l3'); // 置顶的排最前 expect(ids[0]).toBe('crud_l2'); }); it('list({ archived: true }) 只返回归档会话', () => { const list = sessionService.list({ archived: true }); expect(list.every((s: any) => s.archived)).toBe(true); }); it('rename 成功返回 true 且标题更新;不存在返回 false', () => { createSession('crud_r1', '旧标题'); expect(sessionService.rename('crud_r1', '新标题')).toBe(true); expect(sessionService.getSession('crud_r1').title).toBe('新标题'); expect(sessionService.rename('crud_r2_missing', 'x')).toBe(false); }); it('pin 置顶/取消置顶并返回布尔', () => { createSession('crud_p1'); expect(sessionService.pin('crud_p1', true)).toBe(true); expect(sessionService.getSession('crud_p1').pinned).toBe(true); expect(sessionService.pin('crud_p1', false)).toBe(true); expect(sessionService.getSession('crud_p1').pinned).toBe(false); expect(sessionService.pin('crud_p_missing', true)).toBe(false); }); it('archive 归档/取消归档并返回布尔', () => { createSession('crud_a1'); expect(sessionService.archive('crud_a1', true)).toBe(true); expect(sessionService.getSession('crud_a1').archived).toBe(true); expect(sessionService.archive('crud_a1', false)).toBe(true); expect(sessionService.getSession('crud_a1').archived).toBe(false); }); it('delete 级联删除消息(ON DELETE CASCADE 前提)并返回布尔', () => { // 本 schema 无 FK,直接验证 messages 被删除的调用方语义(真实库有 CASCADE) createSession('crud_del', '将被删除'); insertMessage('crud_del_m1', 'crud_del', 'user', '消息', 1); expect(sessionService.delete('crud_del')).toBe(true); expect(sessionService.getSession('crud_del')).toBeNull(); expect(sessionService.delete('crud_del_missing')).toBe(false); }); it('getMessages 默认全部返回(按 rowid 升序)', () => { createSession('crud_msg', '消息边界'); insertMessage('crud_msg_m1', 'crud_msg', 'user', '第一条', 1); insertMessage('crud_msg_m2', 'crud_msg', 'assistant', '第二条', 2); insertMessage('crud_msg_m3', 'crud_msg', 'user', '第三条', 3); const messages = sessionService.getMessages('crud_msg'); expect(messages.map((m: any) => m.content)).toEqual(['第一条', '第二条', '第三条']); }); it('getMessages 分页:limit + offset', () => { const page = sessionService.getMessages('crud_msg', { limit: 2, offset: 1 }); expect(page.map((m: any) => m.content)).toEqual(['第二条', '第三条']); }); it('getMessages afterRowid 游标:仅返回 rowid 更大的消息', () => { const second = db .prepare(`SELECT rowid AS rid FROM messages WHERE id = 'crud_msg_m2'`) .get() as { rid: number }; const after = sessionService.getMessages('crud_msg', { afterRowid: second.rid }); expect(after.map((m: any) => m.content)).toEqual(['第三条']); }); it('getMessages 返回 rowId 字段(截断/摘要游标语义)', () => { const messages = sessionService.getMessages('crud_msg'); expect(messages[0].rowId).toBeGreaterThan(0); }); it('saveMessage 持久化全部字段并回读(toolCalls/attachments JSON 序列化)', () => { createSession('crud_save', '保存消息'); const saved = sessionService.saveMessage({ sessionId: 'crud_save', role: 'assistant', content: null, reasoningContent: '思考过程', toolCalls: [{ name: 'read_file', args: { path: '/a' } }], iteration: 3, }); expect(saved.content).toBeNull(); expect(saved.toolCalls).toEqual([{ name: 'read_file', args: { path: '/a' } }]); const loaded = sessionService.getMessages('crud_save'); expect(loaded[0].toolCalls).toEqual([{ name: 'read_file', args: { path: '/a' } }]); expect(loaded[0].reasoningContent).toBe('思考过程'); expect(loaded[0].iteration).toBe(3); }); it('saveMessage 累加会话 message_count 并更新 updated_at', () => { createSession('crud_cnt', '计数'); sessionService.saveMessage({ sessionId: 'crud_cnt', role: 'user', content: '1' }); sessionService.saveMessage({ sessionId: 'crud_cnt', role: 'user', content: '2' }); const info = sessionService.getSession('crud_cnt'); expect(info.messageCount).toBe(2); }); it('updateMessageContent 仅允许 user 角色:user 更新成功', () => { insertMessage('crud_um1', 'crud_cnt', 'user', '原内容', 1); expect(sessionService.updateMessageContent('crud_cnt', 'crud_um1', '新内容')).toBe(true); const msg = sessionService.getMessages('crud_cnt').find((m: any) => m.id === 'crud_um1'); expect(msg.content).toBe('新内容'); }); it('updateMessageContent 拒绝非 user 角色消息(返回 false 不改写)', () => { insertMessage('crud_um2', 'crud_cnt', 'assistant', '助手原话', 2); expect(sessionService.updateMessageContent('crud_cnt', 'crud_um2', '篡改')).toBe(false); const msg = sessionService.getMessages('crud_cnt').find((m: any) => m.id === 'crud_um2'); expect(msg.content).toBe('助手原话'); }); it('updateMessageContent 消息不存在或会话不匹配返回 false', () => { expect(sessionService.updateMessageContent('crud_cnt', 'crud_ghost', 'x')).toBe(false); expect(sessionService.updateMessageContent('crud_other_session', 'crud_um1', 'x')).toBe(false); }); it('updateTokenUsage 累加 total_tokens(多次调用叠加)', () => { createSession('crud_tok', 'Token 统计'); sessionService.updateTokenUsage('crud_tok', 100); sessionService.updateTokenUsage('crud_tok', 250); expect(sessionService.getSession('crud_tok').totalTokens).toBe(350); }); it('updateTokenUsage 负数抵扣(token 计费冲正场景)', () => { sessionService.updateTokenUsage('crud_tok', -50); expect(sessionService.getSession('crud_tok').totalTokens).toBe(300); }); it('saveTraceData/getTraceData 往返(traceSteps + tokenUsage 存入 metadata)', () => { createSession('crud_trace', 'Trace'); sessionService.saveTraceData('crud_trace', { traceSteps: [{ id: 'step1', startedAt: 100 }], tokenUsage: { input: 10, output: 20, total: 30 }, }); const data = sessionService.getTraceData('crud_trace'); expect(data).toEqual({ traceSteps: [{ id: 'step1', startedAt: 100 }], tokenUsage: { input: 10, output: 20, total: 30 }, }); }); it('getTraceData 无 metadata 返回 null', () => { createSession('crud_notrace', '无 Trace'); expect(sessionService.getTraceData('crud_notrace')).toBeNull(); }); it('getTraceData 损坏 metadata 返回 null(不抛错)', () => { db.prepare(`UPDATE sessions SET metadata = '{broken' WHERE id = 'crud_notrace'`).run(); expect(sessionService.getTraceData('crud_notrace')).toBeNull(); }); it('getTraceData metadata 无 trace 字段时返回 null({} 或无关字段)', () => { db.prepare(`UPDATE sessions SET metadata = '{"foo":1}' WHERE id = 'crud_notrace'`).run(); expect(sessionService.getTraceData('crud_notrace')).toBeNull(); }); it('truncateMessagesAfter:锚点为首条消息(inclusive)删除全部', () => { createSession('crud_tr1', '截断一'); insertMessage('crud_tr1_a', 'crud_tr1', 'user', '首条', 1); insertMessage('crud_tr1_b', 'crud_tr1', 'assistant', '次条', 2); insertMessage('crud_tr1_c', 'crud_tr1', 'user', '末条', 3); expect(sessionService.truncateMessagesAfter('crud_tr1', 'crud_tr1_a', true)).toBe(true); expect(sessionService.getMessages('crud_tr1')).toHaveLength(0); }); it('truncateMessagesAfter:锚点为末条(inclusive)仅删锚点', () => { createSession('crud_tr2', '截断二'); insertMessage('crud_tr2_a', 'crud_tr2', 'user', '首条', 1); insertMessage('crud_tr2_b', 'crud_tr2', 'assistant', '次条', 2); insertMessage('crud_tr2_c', 'crud_tr2', 'user', '末条', 3); expect(sessionService.truncateMessagesAfter('crud_tr2', 'crud_tr2_c', true)).toBe(true); const remaining = sessionService.getMessages('crud_tr2'); expect(remaining).toHaveLength(2); expect(remaining[1].content).toBe('次条'); }); it('truncateMessagesAfter:inclusive=false 保留锚点本身', () => { createSession('crud_tr3', '截断三'); insertMessage('crud_tr3_a', 'crud_tr3', 'user', '锚点', 1); insertMessage('crud_tr3_b', 'crud_tr3', 'assistant', '后一条', 2); insertMessage('crud_tr3_c', 'crud_tr3', 'user', '后两条', 3); expect(sessionService.truncateMessagesAfter('crud_tr3', 'crud_tr3_a', false)).toBe(true); const remaining = sessionService.getMessages('crud_tr3'); expect(remaining.map((m: any) => m.content)).toEqual(['锚点']); }); it('truncateMessagesAfter:锚点不存在返回 false 且无副作用', () => { createSession('crud_tr4', '截断四'); insertMessage('crud_tr4_a', 'crud_tr4', 'user', '唯一消息', 1); const before = sessionService.getMessages('crud_tr4').length; expect(sessionService.truncateMessagesAfter('crud_tr4', 'crud_ghost', true)).toBe(false); expect(sessionService.getMessages('crud_tr4').length).toBe(before); }); it('truncateMessagesAfter:inclusive 变体对摘要游标的联动(游标落在删除区则删摘要)', () => { createSession('crud_tr5', '截断五'); insertMessage('crud_tr5_a', 'crud_tr5', 'user', 'a', 1); insertMessage('crud_tr5_b', 'crud_tr5', 'assistant', 'b', 2); insertMessage('crud_tr5_c', 'crud_tr5', 'user', 'c', 3); const cursor = ( db.prepare(`SELECT MAX(rowid) AS r FROM messages WHERE session_id = 'crud_tr5'`).get() as { r: number; } ).r; db.prepare( `INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at) VALUES ('crud_tr5', '摘要', ?, ?)`, ).run(cursor, Date.now()); // inclusive=true 删 b、c(rowid >= b 的 rowid)→ 游标被覆盖 → 摘要清除 expect(sessionService.truncateMessagesAfter('crud_tr5', 'crud_tr5_b', true)).toBe(true); const summaries = db .prepare(`SELECT session_id FROM session_summaries WHERE session_id = 'crud_tr5'`) .all(); expect(summaries).toHaveLength(0); }); it('clearMessages 清空消息、重置计数并置空 metadata(B-2/A1 契约)', () => { createSession('crud_clear', '清空'); insertMessage('crud_clear_a', 'crud_clear', 'user', '1', 1); insertMessage('crud_clear_b', 'crud_clear', 'user', '2', 2); sessionService.saveTraceData('crud_clear', { traceSteps: [{ id: 's' }], tokenUsage: {} }); expect(() => sessionService.clearMessages('crud_clear')).not.toThrow(); expect(sessionService.getMessages('crud_clear')).toHaveLength(0); expect(sessionService.getSession('crud_clear').messageCount).toBe(0); expect(sessionService.getTraceData('crud_clear')).toBeNull(); }); }); describe.skipIf(!dbAvailable)('searchMessages — 更多查询形态与边界', () => { let db: any; let sessionService: any; beforeAll(async () => { const { SessionService } = await import('../session.service'); db = new Database(':memory:'); 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, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL ); CREATE VIRTUAL TABLE messages_fts USING fts5( content, content=messages, content_rowid=rowid, tokenize='trigram' ); 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 */ } }); const insertMessage = ( id: string, sessionId: string, role: string, content: string, ts: number, ): void => { db.prepare( `INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`, ).run(id, sessionId, role, content, ts); }; it('英文短语(含空格)按整体短语命中', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q1', '短语会话', 1, 1)`, ).run(); insertMessage('q1_m1', 'q1', 'assistant', 'the quick brown fox jumps', 100); insertMessage('q1_m2', 'q1', 'user', 'the lazy dog sleeps', 200); // 2 字以上英文 → 引号短语匹配 const hits = sessionService.searchMessages('quick brown'); expect(hits.some((r: any) => r.lastMessageId === 'q1_m1')).toBe(true); expect(hits.some((r: any) => r.lastMessageId === 'q1_m2')).toBe(false); }); it('中英混排查询(无空格短语)可命中混合内容', () => { insertMessage('q1_m3', 'q1', 'assistant', '配置 config修改 混合内容', 300); const hits = sessionService.searchMessages('config修改'); expect(hits.some((r: any) => r.lastMessageId === 'q1_m3')).toBe(true); }); it('FTS 保留字运算符被转义(AND/OR/NOT 不触发语法错误)', () => { insertMessage('q1_m4', 'q1', 'user', 'and or not 关键字测试', 400); expect(() => sessionService.searchMessages('and or not')).not.toThrow(); // 作为普通文本命中 const hits = sessionService.searchMessages('and or not'); expect(hits.some((r: any) => r.lastMessageId === 'q1_m4')).toBe(true); }); it('查询中的星号/连字符不触发 FTS 语法错误', () => { insertMessage('q1_m5', 'q1', 'user', 'version-2 star*text', 500); expect(() => sessionService.searchMessages('version-2')).not.toThrow(); expect(() => sessionService.searchMessages('star*')).not.toThrow(); }); it('超长查询(>200 字符)不抛错且按短语匹配', () => { const longContent = '超长内容 ' + 'abc '.repeat(80); insertMessage('q1_m6', 'q1', 'user', longContent, 600); const longQuery = 'abc '.repeat(30).trim(); expect(() => sessionService.searchMessages(longQuery)).not.toThrow(); }); it('scanLimit 边界:仅扫描前 N 条匹配(聚合结果受限制)', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q2', '扫描上限', 1, 1)`, ).run(); // 同一会话 5 条匹配 + 另一会话 1 条匹配 for (let i = 1; i <= 5; i++) { insertMessage(`q2_m${i}`, 'q2', 'user', `scanlimit-hit 第 ${i} 次`, i * 10); } db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q3', '另一个', 1, 1)`, ).run(); insertMessage('q3_m1', 'q3', 'user', 'scanlimit-hit 在别的会话', 999); const full = sessionService.searchMessages('scanlimit-hit'); expect(full.length).toBe(2); // 两个会话都被聚合 // scanLimit=1:只扫描最新 1 条 → 仅 q3 被聚合(q3_m1 时间最新) const limited = sessionService.searchMessages('scanlimit-hit', { scanLimit: 1 }); expect(limited).toHaveLength(1); expect(limited[0].sessionId).toBe('q3'); }); it('maxSessions 边界:聚合结果被截断到上限', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q4', 's4', 1, 1)`, ).run(); db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q5', 's5', 1, 1)`, ).run(); insertMessage('q4_m1', 'q4', 'user', 'maxsessions-hit', 10); insertMessage('q5_m1', 'q5', 'user', 'maxsessions-hit', 20); const capped = sessionService.searchMessages('maxsessions-hit', { maxSessions: 1 }); expect(capped).toHaveLength(1); expect(capped[0].sessionId).toBe('q5'); // 时间最新的优先 }); it('matchCount 聚合计数正确(同一会话多次匹配)', () => { const hits = sessionService.searchMessages('scanlimit-hit'); const q2 = hits.find((r: any) => r.sessionId === 'q2'); expect(q2.matchCount).toBe(5); }); it('snippet 含高亮标记且截取自消息开头', () => { const hits = sessionService.searchMessages('maxsessions-hit'); // snippet() 使用 '[','] ' 包裹命中的前 12 个 trigram token —— 命中词前缀可见 expect(hits[0].snippet).toContain('[maxsessions'); expect(hits[0].snippet).toContain(']'); }); it('2 字英文查询走 LIKE 回退且大小写不敏感', () => { insertMessage('q6_m1', 'q6', 'user', 'SQLite database is good', 1); db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q6', 'like', 1, 1)`, ).run(); // 'sq' 为 2 字 → trigram 无法构成 3-gram → LIKE 回退;SQLite LIKE 对 ASCII 大小写不敏感 const hits = sessionService.searchMessages('sq'); expect(hits.some((r: any) => r.lastMessageId === 'q6_m1')).toBe(true); }); it('LIKE 回退时 % 与 _ 通配符被转义(不误匹配任意内容)', () => { insertMessage('q7_m1', 'q7', 'user', '进度 100% 完成', 1); db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q7', 'pct', 1, 1)`, ).run(); // 2 字查询 "%_" 中 % 与 _ 均被转义 → 仅匹配字面 "%_";若无转义会命中全部 const hits = sessionService.searchMessages('%_'); expect(hits).toHaveLength(0); // 字面 % 命中(2 字英文 "% " 不可行 —— 用 "%、" 中文标点 + % 组合验证转义不报错) expect(() => sessionService.searchMessages('100%')).not.toThrow(); }); it('归档会话仍参与搜索(搜索不因归档缺结果)', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at, archived) VALUES ('q8', '归档可搜', 1, 1, 1)`, ).run(); insertMessage('q8_m1', 'q8', 'user', 'archivedkeyword 归档内容', 1); const hits = sessionService.searchMessages('archivedkeyword'); expect(hits.some((r: any) => r.sessionId === 'q8')).toBe(true); }); it('已删除会话的标题回退为占位文案((已删除会话))', () => { // 消息引用已不存在的会话 id(真实库 FK 会阻止,此处验证 title 补全兜底) insertMessage('q9_m1', 'q9_ghost', 'user', 'ghostkeyword 消息', 1); const hits = sessionService.searchMessages('ghostkeyword'); expect(hits.some((r: any) => r.sessionTitle === '(已删除会话)')).toBe(true); }); it('单字符 CJK 查询走 LIKE 回退命中子串', () => { insertMessage('q10_m1', 'q10', 'user', '编程语言爱', 1); db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q10', '单字', 1, 1)`, ).run(); const hits = sessionService.searchMessages('语'); expect(hits.some((r: any) => r.lastMessageId === 'q10_m1')).toBe(true); }); it('结果按 created_at 倒序(最新匹配排前)', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q11', '排序', 1, 1)`, ).run(); insertMessage('q11_m1', 'q11', 'user', 'orderkeyword 早', 10); insertMessage('q11_m2', 'q11', 'assistant', 'orderkeyword 晚', 20); const hits = sessionService.searchMessages('orderkeyword'); expect(hits[0].lastMessageId).toBe('q11_m2'); expect(hits[0].lastRole).toBe('assistant'); }); it('相同内容多会话命中时返回多个聚合结果', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q12', '甲', 1, 1)`, ).run(); db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q13', '乙', 1, 1)`, ).run(); insertMessage('q12_m1', 'q12', 'user', 'sharedkw 内容', 1); insertMessage('q13_m1', 'q13', 'user', 'sharedkw 内容', 2); const hits = sessionService.searchMessages('sharedkw'); expect(hits).toHaveLength(2); }); }); 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); }); }); describe.skipIf(!dbAvailable)('saveMessage id 透传与 updateMessageContent(R1 回归)', () => { let db: any; let sessionService: any; beforeAll(async () => { const { SessionService } = await import('../session.service'); db = new Database(':memory:'); 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, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL ); `); sessionService = new SessionService(() => db); }); afterAll(() => { try { db?.close(); } catch { /* ignore */ } }); it('saveMessage 透传 id 时使用该 id 落库(R1 修复)', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('r1s', 'r1', 1, 1)`, ).run(); const saved = sessionService.saveMessage({ sessionId: 'r1s', role: 'user', content: '前端生成的消息', id: 'custom-user-id', }); expect(saved.id).toBe('custom-user-id'); const rows = db.prepare(`SELECT id FROM messages WHERE id = 'custom-user-id'`).all(); expect(rows).toHaveLength(1); }); it('saveMessage 未传 id 时仍由 DB 生成 nanoid', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('r2s', 'r2', 1, 1)`, ).run(); const saved = sessionService.saveMessage({ sessionId: 'r2s', role: 'user', content: 'DB 生成 id', }); expect(saved.id).toMatch(/^msg_/); }); it('updateMessageContent 按透传 id 可命中(R1 完整链路)', () => { db.prepare( `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('r3s', 'r3', 1, 1)`, ).run(); sessionService.saveMessage({ sessionId: 'r3s', role: 'user', content: '原始内容', id: 'edit-target-id', }); const updated = sessionService.updateMessageContent('r3s', 'edit-target-id', '编辑后'); expect(updated).toBe(true); const row = db.prepare(`SELECT content FROM messages WHERE id = 'edit-target-id'`).get() as { content: string; }; expect(row.content).toBe('编辑后'); }); });