P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
1019 lines
40 KiB
TypeScript
1019 lines
40 KiB
TypeScript
/**
|
||
* 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('编辑后');
|
||
});
|
||
});
|