879 lines
44 KiB
JavaScript
879 lines
44 KiB
JavaScript
const test = require('node:test');
|
||
const assert = require('node:assert');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
|
||
const sessionsPath = require.resolve('../reader/ai-sessions.js');
|
||
const imagesPath = require.resolve('../reader/ai-images.js');
|
||
|
||
const dirs = [];
|
||
|
||
function tmp(tag) {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-ai-sessions-${tag}-`));
|
||
dirs.push(dir);
|
||
return dir;
|
||
}
|
||
|
||
function fresh(tag = 'x') {
|
||
const dir = tmp(tag);
|
||
return at(dir);
|
||
}
|
||
|
||
function at(dir) {
|
||
delete require.cache[sessionsPath];
|
||
delete require.cache[imagesPath];
|
||
const images = require(imagesPath);
|
||
const sessions = require(sessionsPath);
|
||
images.init(dir);
|
||
sessions.init(dir);
|
||
return { sessions, images, dir };
|
||
}
|
||
|
||
function sessionDir(dir) {
|
||
return path.join(dir, 'reader-ai-sessions');
|
||
}
|
||
|
||
function sessionFile(dir, id) {
|
||
return path.join(sessionDir(dir), `${id}.json`);
|
||
}
|
||
|
||
function imageDir(dir) {
|
||
return path.join(dir, 'reader-ai-images');
|
||
}
|
||
|
||
// 最小可解码 JPEG:SOI + APP0 + 一段填充 + EOI。put 只校验 SOI 魔数,内容差异即哈希差异。
|
||
function jpeg(seed, size = 64) {
|
||
const bytes = Buffer.alloc(size, seed & 0xff);
|
||
bytes[0] = 0xff;
|
||
bytes[1] = 0xd8;
|
||
bytes[2] = 0xff;
|
||
bytes[3] = 0xe0;
|
||
bytes[size - 2] = 0xff;
|
||
bytes[size - 1] = 0xd9;
|
||
return bytes;
|
||
}
|
||
|
||
// atomic-file 通过 fd 写入,因此统计 openSync('<file>.tmp','w') 而不是 writeFileSync 的路径
|
||
function countWrites(matcher, fn) {
|
||
const originalOpen = fs.openSync;
|
||
let writes = 0;
|
||
fs.openSync = function counting(target, flags, ...rest) {
|
||
if (matcher(String(target)) && String(flags).startsWith('w')) writes++;
|
||
return originalOpen.call(this, target, flags, ...rest);
|
||
};
|
||
try { fn(); } finally { fs.openSync = originalOpen; }
|
||
return writes;
|
||
}
|
||
|
||
function round(s, id, question, answer) {
|
||
s.appendUser(id, { text: question, task: 'ask' });
|
||
const placeholder = s.appendAssistant(id, {});
|
||
return s.finishAssistant(id, placeholder.id, { text: answer });
|
||
}
|
||
|
||
test.after(() => {
|
||
for (const dir of dirs) {
|
||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||
}
|
||
});
|
||
|
||
// --- 基本读写 ---
|
||
|
||
test('会话按 per-session 文件落盘,索引可枚举', () => {
|
||
const { sessions, dir } = fresh('basic');
|
||
const a = sessions.create({ entryId: 'e1', title: '甲会话' });
|
||
const b = sessions.create({ entryId: 'e2' });
|
||
assert.match(a.id, /^chat_[a-z0-9]+_[a-z0-9]+$/, '会话 ID 必须是可安全拼进文件名的形状');
|
||
assert.strictEqual(a.entryId, 'e1');
|
||
assert.strictEqual(b.entryId, 'e2');
|
||
const files = fs.readdirSync(sessionDir(dir)).sort();
|
||
assert.deepStrictEqual(files, ['index.json', `${a.id}.json`, `${b.id}.json`].sort(),
|
||
'每个会话应独占一个文件,加上一份索引');
|
||
assert.deepStrictEqual(sessions.list().map((row) => row.id).sort(), [a.id, b.id].sort());
|
||
assert.deepStrictEqual(sessions.list({ entryId: 'e1' }).map((row) => row.id), [a.id]);
|
||
assert.deepStrictEqual(sessions.list({ entryId: 'e404' }), []);
|
||
});
|
||
|
||
test('未指定 entryId 的会话落到 GLOBAL_ENTRY_ID,与独立笔记伪条目不同', () => {
|
||
const { sessions } = fresh('global');
|
||
const g = sessions.create({});
|
||
assert.strictEqual(g.entryId, 'system:global-chat');
|
||
assert.notStrictEqual(sessions.GLOBAL_ENTRY_ID, 'system:standalone-notes',
|
||
'全局会话与独立笔记是两个伪条目,混用会让笔记与会话互相污染');
|
||
});
|
||
|
||
test('list 按 pinned desc, updatedAt desc 排序', () => {
|
||
const { sessions } = fresh('sort');
|
||
const a = sessions.create({ title: 'a' });
|
||
const b = sessions.create({ title: 'b' });
|
||
const c = sessions.create({ title: 'c' });
|
||
sessions.rename(b.id, 'b2');
|
||
sessions.setPinned(a.id, true);
|
||
const ids = sessions.list().map((row) => row.id);
|
||
assert.strictEqual(ids[0], a.id, 'pinned 的会话必须排最前');
|
||
assert.ok(ids.indexOf(b.id) < ids.indexOf(c.id), '未 pin 的按 updatedAt 倒序');
|
||
});
|
||
|
||
test('一轮对话可完整读回,占位回答在 finish 时才成为消息', () => {
|
||
const { sessions } = fresh('round');
|
||
const meta = sessions.create({ entryId: 'e1' });
|
||
sessions.appendUser(meta.id, { text: '这是问题', task: 'ask' });
|
||
assert.strictEqual(sessions.messages(meta.id).messages.length, 1);
|
||
const placeholder = sessions.appendAssistant(meta.id, {});
|
||
assert.strictEqual(sessions.messages(meta.id).messages.length, 1,
|
||
'占位回答不落盘,否则流式过程中每个增量都会整文件重写');
|
||
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: '这是回答' });
|
||
assert.strictEqual(done.id, placeholder.id, 'finish 必须复用占位 ID,渲染层靠它对齐流式片段');
|
||
const read = sessions.messages(meta.id);
|
||
assert.deepStrictEqual(read.messages.map((m) => m.role), ['user', 'assistant']);
|
||
assert.strictEqual(read.messages[1].text, '这是回答');
|
||
assert.strictEqual(read.meta.messageCount, 2);
|
||
assert.strictEqual(read.meta.title, '这是问题', '首轮问题应自动成为标题');
|
||
});
|
||
|
||
test('finishAssistant 可记录取消与错误,且 cancelled 保留残片', () => {
|
||
const { sessions } = fresh('finish');
|
||
const meta = sessions.create({});
|
||
sessions.appendUser(meta.id, { text: 'q' });
|
||
const p1 = sessions.appendAssistant(meta.id, {});
|
||
const cancelled = sessions.finishAssistant(meta.id, p1.id, { text: '半句', cancelled: true });
|
||
assert.strictEqual(cancelled.cancelled, true);
|
||
assert.strictEqual(cancelled.text, '半句', '中途停止时残片必须保留,用户看到的就是它');
|
||
sessions.appendUser(meta.id, { text: 'q2' });
|
||
const p2 = sessions.appendAssistant(meta.id, {});
|
||
const failed = sessions.finishAssistant(meta.id, p2.id, { text: '', error: '接口超时' });
|
||
assert.strictEqual(failed.error, '接口超时');
|
||
assert.throws(() => sessions.finishAssistant(meta.id, 'msg_nope', { text: 'x' }), /会话消息不存在/);
|
||
});
|
||
|
||
test('clear 清空消息但保留会话,remove 删除会话与索引行', () => {
|
||
const { sessions, dir } = fresh('lifecycle');
|
||
const meta = sessions.create({ entryId: 'e1', title: '保留' });
|
||
round(sessions, meta.id, '问', '答');
|
||
const cleared = sessions.clear(meta.id);
|
||
assert.strictEqual(cleared.messageCount, 0);
|
||
assert.strictEqual(cleared.droppedMessages, 0);
|
||
assert.strictEqual(cleared.title, '保留', 'clear 不应丢标题');
|
||
assert.strictEqual(sessions.list().length, 1);
|
||
assert.strictEqual(sessions.remove(meta.id), true);
|
||
assert.strictEqual(sessions.remove(meta.id), false);
|
||
assert.strictEqual(sessions.list().length, 0);
|
||
assert.ok(!fs.existsSync(sessionFile(dir, meta.id)));
|
||
assert.throws(() => sessions.messages(meta.id), /会话不存在/);
|
||
});
|
||
|
||
test('messages 支持 limit 与 before 游标翻页', () => {
|
||
const { sessions } = fresh('paging');
|
||
const meta = sessions.create({});
|
||
for (let i = 0; i < 5; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||
const tail = sessions.messages(meta.id, { limit: 4 });
|
||
assert.strictEqual(tail.messages.length, 4);
|
||
assert.strictEqual(tail.hasMore, true);
|
||
assert.strictEqual(tail.messages[3].text, '答4');
|
||
const older = sessions.messages(meta.id, { limit: 4, before: tail.messages[0].id });
|
||
assert.strictEqual(older.messages[older.messages.length - 1].id !== tail.messages[0].id, true,
|
||
'before 必须是排他的,否则翻页会重复一条');
|
||
assert.throws(() => sessions.messages(meta.id, { before: 'msg_missing' }), /会话消息不存在/);
|
||
});
|
||
|
||
// --- 字段限长 ---
|
||
|
||
test('受限字段超长时截断而不是抛错', () => {
|
||
const { sessions } = fresh('limits');
|
||
const L = sessions.LIMITS;
|
||
const meta = sessions.create({ title: '标'.repeat(L.title + 50) });
|
||
assert.strictEqual(meta.title.length, L.title, `title 应截到 ${L.title}`);
|
||
const user = sessions.appendUser(meta.id, { text: '问'.repeat(L.question + 100) });
|
||
assert.strictEqual(user.text.length, L.question, `user 文本应截到 question=${L.question}`);
|
||
const p = sessions.appendAssistant(meta.id, {});
|
||
const done = sessions.finishAssistant(meta.id, p.id, {
|
||
text: '回答',
|
||
error: '错'.repeat(L.errorText + 100)
|
||
});
|
||
assert.strictEqual(done.text, '回答');
|
||
assert.strictEqual(done.error.length, L.errorText, `error 应截到 ${L.errorText}`);
|
||
const renamed = sessions.rename(meta.id, '新'.repeat(L.title + 10));
|
||
assert.strictEqual(renamed.title.length, L.title);
|
||
});
|
||
|
||
test('超过 20000 字的 assistant 正文经 append、finish 与磁盘恢复后保持完整', () => {
|
||
const { sessions, dir } = fresh('long-assistant');
|
||
const meta = sessions.create({});
|
||
sessions.appendUser(meta.id, { text: '请生成长回答' });
|
||
const initial = `占位开头${'初'.repeat(21000)}占位结尾`;
|
||
const placeholder = sessions.appendAssistant(meta.id, { text: initial });
|
||
assert.strictEqual(placeholder.text, initial, 'appendAssistant 不应静默截断 assistant 正文');
|
||
const answer = `回答开头${'答'.repeat(25000)}回答结尾`;
|
||
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: answer });
|
||
assert.strictEqual(done.text, answer, 'finishAssistant 不应静默截断 assistant 正文');
|
||
const raw = JSON.parse(fs.readFileSync(sessionFile(dir, meta.id), 'utf8'));
|
||
assert.strictEqual(raw.messages[1].text, answer, '超过 20000 字的回答必须完整落盘');
|
||
const restored = at(dir).sessions.messages(meta.id);
|
||
assert.strictEqual(restored.messages[1].text, answer, '超过 20000 字的回答必须从磁盘完整读回');
|
||
});
|
||
|
||
test('标题规范化去掉控制字符与换行,自动标题取首轮问题前 40 字', () => {
|
||
const { sessions } = fresh('title');
|
||
const meta = sessions.create({ title: ' 带\n换行\u0007和控制符 ' });
|
||
assert.strictEqual(meta.title, '带 换行 和控制符', '标题会进索引 JSON,控制字符必须清掉');
|
||
const auto = sessions.create({});
|
||
sessions.appendUser(auto.id, { text: '标'.repeat(100) });
|
||
assert.strictEqual(sessions.messages(auto.id).meta.title.length, 40, '自动标题只取前 40 字');
|
||
});
|
||
|
||
test('非法上下文与任务被拒,contextRef 只在 user 消息上存在', () => {
|
||
const { sessions } = fresh('shape');
|
||
const meta = sessions.create({});
|
||
assert.throws(() => sessions.appendUser(meta.id, {
|
||
text: 'q', contextRef: { scope: 'whatever' }
|
||
}), /会话上下文范围无效/);
|
||
assert.throws(() => sessions.appendUser(meta.id, { text: 'q', task: 'hack' }), /会话任务类型无效/);
|
||
const withContext = sessions.appendUser(meta.id, {
|
||
text: 'q',
|
||
contextRef: { scope: 'page', text: '正文内容', clipped: true, locator: { page: 3 }, documentKey: 'dk', fileIndex: 2 }
|
||
});
|
||
assert.strictEqual(withContext.contextRef.chars, 4);
|
||
assert.strictEqual(withContext.contextRef.hash, sessions.hashContext('正文内容'));
|
||
assert.strictEqual(withContext.contextRef.hash.length, 32, 'hash 固定 32 hex,用于判断上下文是否变了');
|
||
assert.strictEqual(withContext.contextRef.clipped, true);
|
||
assert.deepStrictEqual(withContext.contextRef.locator, { page: 3 });
|
||
const p = sessions.appendAssistant(meta.id, { contextRef: { scope: 'page', text: 'x' } });
|
||
assert.strictEqual(p.contextRef, null, 'assistant 消息不应带 contextRef');
|
||
assert.throws(() => sessions.appendUser(meta.id, {
|
||
text: 'q', contextRef: { scope: 'page', locator: { blob: 'x'.repeat(60000) } }
|
||
}), /定位信息/);
|
||
});
|
||
|
||
// --- 上限策略 ---
|
||
|
||
test('会话数达上限抛中文错误,且不删除已有会话', () => {
|
||
const { sessions, dir } = fresh('total');
|
||
const total = sessions.LIMITS.sessionsTotal;
|
||
const ids = [];
|
||
for (let i = 0; i < total; i++) ids.push(sessions.create({ title: `会话${i}` }).id);
|
||
const before = fs.readdirSync(sessionDir(dir)).length;
|
||
assert.throws(() => sessions.create({ title: '溢出' }), /会话数量已达上限/,
|
||
'达到上限必须拒绝新建,绝不能静默删用户的旧会话');
|
||
assert.strictEqual(fs.readdirSync(sessionDir(dir)).length, before, '拒绝新建不应改动磁盘');
|
||
assert.strictEqual(sessions.list().length, total);
|
||
sessions.remove(ids[0]);
|
||
assert.ok(sessions.create({ title: '腾出位置后可建' }).id, '删掉一个后应能继续新建');
|
||
});
|
||
|
||
test('单会话消息达上限成对淘汰,首轮保留且不出现孤立 assistant', () => {
|
||
const { sessions } = fresh('evict');
|
||
const max = sessions.LIMITS.messagesPerSession;
|
||
const meta = sessions.create({});
|
||
sessions.appendUser(meta.id, { text: '首轮问题', contextRef: { scope: 'document', text: '正文' } });
|
||
const first = sessions.appendAssistant(meta.id, {});
|
||
sessions.finishAssistant(meta.id, first.id, { text: '首轮回答' });
|
||
for (let i = 0; i < max; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||
const read = sessions.messages(meta.id, { limit: max });
|
||
assert.ok(read.meta.messageCount <= max, `消息数不得超过 ${max}`);
|
||
assert.strictEqual(read.messages[0].text, '首轮问题', '承载正文的首轮必须永久保留');
|
||
assert.ok(read.messages[0].contextRef, '首轮的 contextRef 不能被淘汰掉');
|
||
assert.ok(read.meta.droppedMessages > 0, 'droppedMessages 必须累加,供历史里的省略标记使用');
|
||
assert.strictEqual(read.meta.droppedMessages % 2, 0, '成对淘汰时丢弃数应为偶数');
|
||
assert.strictEqual(
|
||
read.meta.messageCount + read.meta.droppedMessages,
|
||
(max + 1) * 2,
|
||
'保留数加丢弃数应等于写入总数,说明没有额外丢失'
|
||
);
|
||
const roles = read.messages.map((m) => m.role);
|
||
for (let i = 1; i < roles.length; i++) {
|
||
assert.notStrictEqual(roles[i], roles[i - 1],
|
||
'成对淘汰后不允许出现连续同角色,否则历史里会有没有提问的孤立回答');
|
||
}
|
||
});
|
||
|
||
// appendUser 之后、finishAssistant 之前的这个中间态就是构造请求时 historyFor 看到的状态。
|
||
// 逐条丢弃在整轮结束时会被第二次丢弃恰好补回来,只有在中间态才能看出角色错位。
|
||
test('淘汰后的中间态也不出现连续同角色', () => {
|
||
const { sessions } = fresh('evict-mid');
|
||
const max = sessions.LIMITS.messagesPerSession;
|
||
const meta = sessions.create({});
|
||
for (let i = 0; i < max / 2; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||
assert.strictEqual(sessions.messages(meta.id, { limit: max }).meta.messageCount, max, '先填满到上限');
|
||
sessions.appendUser(meta.id, { text: '新问题' });
|
||
const read = sessions.messages(meta.id, { limit: max });
|
||
assert.strictEqual(read.meta.droppedMessages, 2, '一次淘汰应成对丢弃两条,而不是单条');
|
||
const roles = read.messages.map((m) => m.role);
|
||
for (let i = 1; i < roles.length; i++) {
|
||
assert.notStrictEqual(roles[i], roles[i - 1],
|
||
'中间态出现连续同角色,说明淘汰是逐条而非成对,这会让历史里出现两条相邻的回答');
|
||
}
|
||
assert.strictEqual(roles[0], 'user');
|
||
assert.strictEqual(read.messages[0].text, '问0', '首轮提问必须还在');
|
||
assert.strictEqual(roles[roles.length - 1], 'user');
|
||
const out = sessions.historyFor(meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
|
||
assert.strictEqual(
|
||
out.messages.length,
|
||
read.messages.length,
|
||
'中间态角色已经交替,historyFor 不该再需要合并任何消息;需要合并说明淘汰留下了错位'
|
||
);
|
||
});
|
||
|
||
test('图像数达上限时丢弃最早消息的图像引用但保留文本', () => {
|
||
const { sessions, images } = fresh('img-evict');
|
||
const max = sessions.LIMITS.imagesPerSession;
|
||
const meta = sessions.create({});
|
||
const ids = [];
|
||
for (let i = 0; i <= max; i++) {
|
||
const put = images.put(jpeg(i + 1), 'image/jpeg');
|
||
ids.push(put.imageId);
|
||
sessions.appendUser(meta.id, {
|
||
text: `第 ${i} 张`,
|
||
images: [{ imageId: put.imageId, mimeType: 'image/jpeg', width: 10, height: 10, bytes: put.bytes }]
|
||
});
|
||
const p = sessions.appendAssistant(meta.id, {});
|
||
sessions.finishAssistant(meta.id, p.id, { text: `答 ${i}` });
|
||
}
|
||
const read = sessions.messages(meta.id, { limit: 500 });
|
||
const totalImages = read.messages.reduce((n, m) => n + m.images.length, 0);
|
||
assert.ok(totalImages <= max, `会话内图像引用不得超过 ${max}`);
|
||
assert.strictEqual(read.messages[0].text, '第 0 张', '丢图像引用不应丢文本');
|
||
assert.strictEqual(read.messages[0].images.length, 0, '最早的图像引用先被丢弃');
|
||
assert.ok(!sessions.imageIds().includes(ids[0]), '被丢弃的引用不应再出现在 GC 白名单里');
|
||
});
|
||
|
||
// --- 文件隔离与写放大 ---
|
||
|
||
test('追加消息只重写自己那个会话文件', () => {
|
||
const { sessions, dir } = fresh('isolate');
|
||
const a = sessions.create({ title: 'a' });
|
||
const b = sessions.create({ title: 'b' });
|
||
const bFile = sessionFile(dir, b.id);
|
||
const before = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
|
||
round(sessions, a.id, '问', '答');
|
||
const after = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
|
||
assert.strictEqual(after, before, '写 a 不应碰 b 的字节,否则 100 个会话就是 100 倍写放大');
|
||
const writesToB = countWrites((t) => t === `${bFile}.tmp`, () => {
|
||
round(sessions, a.id, '问2', '答2');
|
||
});
|
||
assert.strictEqual(writesToB, 0, '其他会话文件的写入次数必须是 0');
|
||
});
|
||
|
||
test('一轮对话对会话文件只写两次,索引写入不随消息数增长', () => {
|
||
const { sessions, dir } = fresh('writes');
|
||
const meta = sessions.create({});
|
||
const file = sessionFile(dir, meta.id);
|
||
const isSession = (t) => t === `${file}.tmp`;
|
||
const oneRound = countWrites(isSession, () => {
|
||
sessions.appendUser(meta.id, { text: '问' });
|
||
const p = sessions.appendAssistant(meta.id, {});
|
||
for (let i = 0; i < 50; i++) sessions.appendAssistant; // 流式增量不经过存储层
|
||
sessions.finishAssistant(meta.id, p.id, { text: '答'.repeat(500) });
|
||
});
|
||
assert.strictEqual(oneRound, 2, '一轮对话最多两次会话文件写入:appendUser 一次,finishAssistant 一次');
|
||
// 对照:两轮就是四次,证明计数器没失灵
|
||
const twoRounds = countWrites(isSession, () => {
|
||
round(sessions, meta.id, '问2', '答2');
|
||
round(sessions, meta.id, '问3', '答3');
|
||
});
|
||
assert.strictEqual(twoRounds, 4, '两轮应写四次,用于对照说明上面的 2 不是计数器失灵');
|
||
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||
const idxWrites = countWrites((t) => t === `${idxFile}.tmp`, () => {
|
||
round(sessions, meta.id, '问4', '答4');
|
||
});
|
||
assert.strictEqual(idxWrites, 2, '索引跟着会话文件一起更新,一轮两次');
|
||
});
|
||
|
||
test('会话文件解析结果按内容缓存,同 mtime 同体积但内容不同也不会读到旧值', () => {
|
||
const { sessions, dir } = fresh('cache');
|
||
const meta = sessions.create({});
|
||
round(sessions, meta.id, '原始问题', '原始回答');
|
||
const file = sessionFile(dir, meta.id);
|
||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||
// utimesSync 会丢掉 mtime 的小数毫秒,先钉成整秒再取基准,否则构造本身对不上
|
||
const fixed = new Date(1700000000000);
|
||
fs.utimesSync(file, fixed, fixed);
|
||
const stat = fs.statSync(file);
|
||
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '原始问题');
|
||
// 等长替换 + 还原 mtime:按 mtime+size 判定的缓存在这里会返回旧内容
|
||
raw.messages[0].text = '篡改问题';
|
||
const rewritten = JSON.stringify(raw, null, 2);
|
||
assert.strictEqual(Buffer.byteLength(rewritten, 'utf8'), stat.size, '构造用例要求体积不变');
|
||
fs.writeFileSync(file, rewritten);
|
||
fs.utimesSync(file, fixed, fixed);
|
||
assert.strictEqual(fs.statSync(file).mtimeMs, stat.mtimeMs, '构造用例要求 mtime 不变');
|
||
assert.strictEqual(fs.statSync(file).size, stat.size, '构造用例要求体积不变');
|
||
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '篡改问题',
|
||
'缓存必须按内容哈希失效,按 mtime+size 会漏掉同毫秒内的改写');
|
||
});
|
||
|
||
// --- 损坏自愈 ---
|
||
|
||
test('主文件损坏时从 .bak 恢复', () => {
|
||
const { sessions, dir } = fresh('bak');
|
||
const meta = sessions.create({ entryId: 'e1' });
|
||
round(sessions, meta.id, '问', '答');
|
||
const file = sessionFile(dir, meta.id);
|
||
fs.copyFileSync(file, `${file}.bak`);
|
||
fs.writeFileSync(file, '{ 这不是 JSON');
|
||
const read = sessions.messages(meta.id);
|
||
assert.strictEqual(read.messages.length, 2, '.bak 完好时必须恢复出完整对话');
|
||
assert.strictEqual(read.messages[0].text, '问');
|
||
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
|
||
assert.strictEqual(corrupt.length, 1, '损坏的主文件应被隔离留档而不是直接删除');
|
||
});
|
||
|
||
test('主文件与 .bak 都损坏时隔离成 .corrupt- 并返回空会话', () => {
|
||
const { sessions, dir } = fresh('corrupt');
|
||
const meta = sessions.create({ entryId: 'e1', title: '标题' });
|
||
round(sessions, meta.id, '问', '答');
|
||
const file = sessionFile(dir, meta.id);
|
||
fs.writeFileSync(file, 'broken');
|
||
fs.writeFileSync(`${file}.bak`, 'also broken');
|
||
const read = sessions.messages(meta.id);
|
||
assert.deepStrictEqual(read.messages, [], '两份都坏只能返回空,不能抛错让界面卡死');
|
||
assert.strictEqual(read.meta.entryId, 'e1', '空会话应从索引行恢复出归属,否则会变成孤立会话');
|
||
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
|
||
assert.strictEqual(corrupt.length, 1);
|
||
round(sessions, meta.id, '新问', '新答');
|
||
assert.strictEqual(sessions.messages(meta.id).messages.length, 2, '自愈后应能继续写入');
|
||
});
|
||
|
||
test('index.json 丢失后扫目录重建,list 结果正确', () => {
|
||
const { sessions, dir } = fresh('reindex');
|
||
const a = sessions.create({ entryId: 'e1', title: '甲' });
|
||
const b = sessions.create({ entryId: 'e2', title: '乙' });
|
||
round(sessions, a.id, '问', '答');
|
||
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||
fs.unlinkSync(idxFile);
|
||
const reloaded = at(dir).sessions;
|
||
const rows = reloaded.list();
|
||
assert.strictEqual(rows.length, 2, '索引是派生缓存,丢失后必须能从会话文件重建');
|
||
const rowA = rows.find((r) => r.id === a.id);
|
||
assert.strictEqual(rowA.title, '甲');
|
||
assert.strictEqual(rowA.entryId, 'e1');
|
||
assert.strictEqual(rowA.messageCount, 2);
|
||
assert.ok(rowA.bytes > 0, '重建的索引行应带上真实体积');
|
||
assert.strictEqual(rows.find((r) => r.id === b.id).messageCount, 0);
|
||
assert.ok(fs.existsSync(idxFile), '重建结果应回写磁盘');
|
||
});
|
||
|
||
test('index.json 损坏时重建,且损坏的索引不会让会话消失', () => {
|
||
const { sessions, dir } = fresh('reindex2');
|
||
const a = sessions.create({ title: '甲' });
|
||
fs.writeFileSync(path.join(sessionDir(dir), 'index.json'), '[[[not json');
|
||
const reloaded = at(dir).sessions;
|
||
assert.deepStrictEqual(reloaded.list().map((r) => r.id), [a.id]);
|
||
});
|
||
|
||
test('索引里混入非法行时被丢弃,真实会话仍在', () => {
|
||
const { sessions, dir } = fresh('reindex3');
|
||
const a = sessions.create({ title: '甲' });
|
||
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
|
||
idx.sessions.push({ id: '../escape', title: '恶意' });
|
||
idx.sessions.push({ id: '__proto__', title: '污染' });
|
||
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
|
||
const rows = at(dir).sessions.list();
|
||
assert.deepStrictEqual(rows.map((r) => r.id), [a.id], '索引里的非法 ID 必须被丢弃');
|
||
});
|
||
|
||
test('会话文件里的坏消息被逐条丢弃,其余消息仍可读', () => {
|
||
const { sessions, dir } = fresh('bad-msg');
|
||
const meta = sessions.create({});
|
||
round(sessions, meta.id, '好问题', '好回答');
|
||
const file = sessionFile(dir, meta.id);
|
||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||
raw.messages.splice(1, 0, { role: 'nobody', text: '坏消息' }, null, 'not an object');
|
||
raw.messages.push({ role: 'assistant', text: '尾部回答', images: 'not an array', task: '非法' });
|
||
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||
const read = at(dir).sessions.messages(meta.id);
|
||
assert.deepStrictEqual(read.messages.map((m) => m.text), ['好问题', '好回答', '尾部回答'],
|
||
'会话内容部分来自模型输出,坏条目要丢弃而不是让整个会话读不出来');
|
||
assert.deepStrictEqual(read.messages[2].images, []);
|
||
assert.strictEqual(read.messages[2].task, null);
|
||
});
|
||
|
||
// --- ID 校验 ---
|
||
|
||
test('非法会话 ID 被拒,不产生任何文件', () => {
|
||
const { sessions, dir } = fresh('ids');
|
||
const bad = ['', '../escape', 'a/b', 'a\\b', '__proto__', 'prototype', 'constructor',
|
||
'.', '..', '-lead', 'x'.repeat(200), 'a.b', 'a:b', null, undefined, 'chat_ok\u0000'];
|
||
for (const id of bad) {
|
||
assert.throws(() => sessions.messages(id), /会话 ID 无效/, `会话 ID ${JSON.stringify(id)} 必须被拒`);
|
||
assert.throws(() => sessions.remove(id), /会话 ID 无效/);
|
||
assert.throws(() => sessions.appendUser(id, { text: 'x' }), /会话 ID 无效/);
|
||
}
|
||
assert.ok(!fs.existsSync(sessionDir(dir)) || !fs.readdirSync(sessionDir(dir)).some((n) => n !== 'index.json'),
|
||
'被拒的 ID 不应在磁盘上留下文件');
|
||
});
|
||
|
||
test('非法 entryId 与 messageId 被拒', () => {
|
||
const { sessions } = fresh('ids2');
|
||
for (const entryId of ['../x', 'a/b', '__proto__', 'x'.repeat(200), '.', '..']) {
|
||
assert.throws(() => sessions.create({ entryId }), /会话条目 ID 无效/);
|
||
}
|
||
const meta = sessions.create({ entryId: 'e1' });
|
||
for (const msgId of ['', '../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
|
||
assert.throws(() => sessions.finishAssistant(meta.id, msgId, { text: 'x' }), /会话消息 ID 无效/);
|
||
}
|
||
for (const msgId of ['../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
|
||
assert.throws(() => sessions.messages(meta.id, { before: msgId }), /会话消息 ID 无效/);
|
||
}
|
||
assert.doesNotThrow(() => sessions.messages(meta.id, { before: '' }), '空游标等价于不传,取最新一页');
|
||
});
|
||
|
||
test('finishAssistant 不能跨会话认领占位消息', () => {
|
||
const { sessions } = fresh('cross');
|
||
const a = sessions.create({});
|
||
const b = sessions.create({});
|
||
sessions.appendUser(a.id, { text: 'q' });
|
||
const p = sessions.appendAssistant(a.id, {});
|
||
assert.throws(() => sessions.finishAssistant(b.id, p.id, { text: '越界' }), /会话消息不存在/,
|
||
'占位消息必须绑定会话,否则渲染层可以把回答写进别的会话');
|
||
});
|
||
|
||
// --- historyFor ---
|
||
|
||
function history(sessions, id, budget) {
|
||
return sessions.historyFor(id, budget);
|
||
}
|
||
|
||
test('historyFor 始终 pin 首条 user 消息', () => {
|
||
const { sessions } = fresh('hist-pin');
|
||
const meta = sessions.create({});
|
||
sessions.appendUser(meta.id, { text: `正文${'甲'.repeat(300)}`, contextRef: { scope: 'document', text: 'x' } });
|
||
const p = sessions.appendAssistant(meta.id, {});
|
||
sessions.finishAssistant(meta.id, p.id, { text: '首答' });
|
||
for (let i = 0; i < 20; i++) round(sessions, meta.id, `问${i}`.repeat(20), `答${i}`.repeat(20));
|
||
const out = history(sessions, meta.id, { maxChars: 600, maxMessages: 6, maxMessageChars: 400 });
|
||
assert.strictEqual(out.messages[0].role, 'user');
|
||
assert.ok(out.messages[0].text.includes('正文'),
|
||
'承载正文的首条 user 必须永远在历史里,否则后续追问会失去参照');
|
||
assert.ok(out.messages.length < 42, '预算内应确实丢掉了中间轮次');
|
||
assert.ok(out.dropped > 0);
|
||
});
|
||
|
||
test('historyFor 的省略标记折进现有消息而不是新增消息', () => {
|
||
const { sessions } = fresh('hist-mark');
|
||
const meta = sessions.create({});
|
||
for (let i = 0; i < 12; i++) round(sessions, meta.id, `问题${i}`.repeat(20), `回答${i}`.repeat(20));
|
||
const out = history(sessions, meta.id, { maxChars: 500, maxMessages: 5, maxMessageChars: 400 });
|
||
const marked = out.messages.filter((m) => /已省略较早的 \d+ 轮对话/.test(m.text));
|
||
assert.strictEqual(marked.length, 1, '省略标记只应出现一次');
|
||
assert.strictEqual(marked[0], out.messages[0], '标记必须折进最旧那条保留消息');
|
||
assert.ok(marked[0].text.length > '[……已省略较早的 1 轮对话……]\n'.length,
|
||
'标记是折进去的,所以这条消息里还应有原本的正文,而不是一条只有标记的空消息');
|
||
assert.ok(out.messages.every((m) => m.role === 'user' || m.role === 'assistant'),
|
||
'不允许为了放标记而造出 system 之类的新角色');
|
||
});
|
||
|
||
test('historyFor 总字符不超过 maxChars', () => {
|
||
const { sessions } = fresh('hist-total');
|
||
const meta = sessions.create({});
|
||
for (let i = 0; i < 15; i++) round(sessions, meta.id, `问${i}`.repeat(50), `答${i}`.repeat(50));
|
||
for (const maxChars of [200, 500, 1200, 3000]) {
|
||
const out = history(sessions, meta.id, { maxChars, maxMessages: 30, maxMessageChars: 2000 });
|
||
const total = out.messages.reduce((n, m) => n + m.text.length, 0);
|
||
assert.ok(total <= maxChars, `maxChars=${maxChars} 时实际 ${total} 字符,超预算会直接被接口拒绝`);
|
||
}
|
||
});
|
||
|
||
test('historyFor 单条超限中间挖空且保留尾部', () => {
|
||
const { sessions } = fresh('hist-clip');
|
||
const meta = sessions.create({});
|
||
const head = '开头标记';
|
||
const tail = '结尾标记';
|
||
sessions.appendUser(meta.id, { text: `${head}${'填'.repeat(3000)}${tail}` });
|
||
const p = sessions.appendAssistant(meta.id, {});
|
||
sessions.finishAssistant(meta.id, p.id, { text: '答' });
|
||
const out = history(sessions, meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 600 });
|
||
const first = out.messages[0];
|
||
assert.ok(first.text.length <= 600, '单条应被压到 maxMessageChars 以内');
|
||
assert.ok(first.text.startsWith(head), '挖空必须保留头部');
|
||
assert.ok(first.text.endsWith(tail), '挖空必须保留尾部,尾部往往是真正的提问');
|
||
assert.ok(first.text.includes('中间内容已省略'), '挖空处要有可见标记');
|
||
assert.strictEqual(first.truncated, true, 'truncated 标记供界面提示用户');
|
||
});
|
||
|
||
test('historyFor 裁剪后若首条是 assistant 则丢掉它', () => {
|
||
const { sessions, dir } = fresh('hist-lead');
|
||
const meta = sessions.create({});
|
||
round(sessions, meta.id, '问', '答');
|
||
// 恶性输入:直接把会话文件改成 assistant 领头,模拟历史数据或模型侧写坏
|
||
const file = sessionFile(dir, meta.id);
|
||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||
raw.messages = [
|
||
{ id: 'msg_a1', role: 'assistant', text: '孤立回答', createdAt: 1 },
|
||
{ id: 'msg_u1', role: 'user', text: '真正的提问', createdAt: 2 },
|
||
{ id: 'msg_a2', role: 'assistant', text: '真正的回答', createdAt: 3 }
|
||
];
|
||
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||
const out = at(dir).sessions.historyFor(meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 400 });
|
||
assert.strictEqual(out.messages[0].role, 'user',
|
||
'Anthropic 的 /messages 要求 messages[0].role === "user",领头的 assistant 必须丢掉');
|
||
assert.ok(!out.messages.some((m) => m.text.includes('孤立回答')));
|
||
});
|
||
|
||
test('historyFor 不产生连续同角色消息', () => {
|
||
const { sessions, dir } = fresh('hist-roles');
|
||
const meta = sessions.create({});
|
||
round(sessions, meta.id, '问', '答');
|
||
const file = sessionFile(dir, meta.id);
|
||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||
raw.messages = [
|
||
{ id: 'msg_u1', role: 'user', text: '问一', createdAt: 1 },
|
||
{ id: 'msg_u2', role: 'user', text: '问二', createdAt: 2 },
|
||
{ id: 'msg_u3', role: 'user', text: '问三', createdAt: 3 },
|
||
{ id: 'msg_a1', role: 'assistant', text: '答一', createdAt: 4 },
|
||
{ id: 'msg_a2', role: 'assistant', text: '答二', createdAt: 5 },
|
||
{ id: 'msg_u4', role: 'user', text: '问四', createdAt: 6 }
|
||
];
|
||
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||
const reloaded = at(dir).sessions;
|
||
for (const budget of [
|
||
{ maxChars: 4000, maxMessages: 10, maxMessageChars: 400 },
|
||
{ maxChars: 20, maxMessages: 3, maxMessageChars: 50 },
|
||
{ maxChars: 4000, maxMessages: 2, maxMessageChars: 400 }
|
||
]) {
|
||
const out = reloaded.historyFor(meta.id, budget);
|
||
const roles = out.messages.map((m) => m.role);
|
||
for (let i = 1; i < roles.length; i++) {
|
||
assert.notStrictEqual(roles[i], roles[i - 1],
|
||
`预算 ${JSON.stringify(budget)} 下出现了连续同角色,Anthropic 会直接 400`);
|
||
}
|
||
if (roles.length) assert.strictEqual(roles[0], 'user');
|
||
assert.ok(out.messages.find((m) => m.text.includes('问一')), '首条 user 仍应被 pin');
|
||
}
|
||
});
|
||
|
||
test('historyFor 把已被物理丢弃的轮次计入省略标记', () => {
|
||
const { sessions } = fresh('hist-dropped');
|
||
const max = sessions.LIMITS.messagesPerSession;
|
||
const meta = sessions.create({});
|
||
for (let i = 0; i < max; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||
const read = sessions.messages(meta.id, { limit: max });
|
||
assert.ok(read.meta.droppedMessages > 0);
|
||
const out = history(sessions, meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
|
||
assert.ok(out.dropped >= read.meta.droppedMessages,
|
||
'磁盘上已丢的轮次也要计入 dropped,否则模型会以为它看到了完整对话');
|
||
assert.match(out.messages[0].text, /已省略较早的 \d+ 轮对话/);
|
||
});
|
||
|
||
test('historyFor 在空会话与单条会话上不炸', () => {
|
||
const { sessions } = fresh('hist-edge');
|
||
const meta = sessions.create({});
|
||
assert.deepStrictEqual(sessions.historyFor(meta.id, {}), { messages: [], dropped: 0 });
|
||
sessions.appendUser(meta.id, { text: '只有一条' });
|
||
const out = sessions.historyFor(meta.id, { maxChars: 5, maxMessages: 1, maxMessageChars: 50 });
|
||
assert.strictEqual(out.messages.length, 1, '首条 user 即使超预算也要留下,否则请求没有内容可发');
|
||
assert.strictEqual(out.messages[0].role, 'user');
|
||
});
|
||
|
||
// --- imageIds 与 GC ---
|
||
|
||
test('imageIds 遍历全部会话文件,索引缺行也能找到引用', () => {
|
||
const { sessions, images, dir } = fresh('gc-scan');
|
||
const a = sessions.create({ title: '甲' });
|
||
const b = sessions.create({ title: '乙' });
|
||
const imgA = images.put(jpeg(11), 'image/jpeg');
|
||
const imgB = images.put(jpeg(22), 'image/jpeg');
|
||
sessions.appendUser(a.id, { text: '带图甲', images: [{ imageId: imgA.imageId, bytes: imgA.bytes }] });
|
||
sessions.appendUser(b.id, { text: '带图乙', images: [{ imageId: imgB.imageId, bytes: imgB.bytes }] });
|
||
// 故意让索引缺掉 b:只读索引的 GC 会把 b 引用的图当垃圾删掉
|
||
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
|
||
idx.sessions = idx.sessions.filter((row) => row.id !== b.id);
|
||
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
|
||
const reloaded = at(dir);
|
||
assert.ok(!reloaded.sessions.list().some((row) => row.id === b.id) || true);
|
||
const ids = reloaded.sessions.imageIds();
|
||
assert.ok(ids.includes(imgB.imageId),
|
||
'imageIds 必须扫目录而不是读索引,索引损坏时只读索引会静默删掉仍被引用的图');
|
||
assert.ok(ids.includes(imgA.imageId));
|
||
const removed = reloaded.images.cleanup(ids, { graceMs: 0 });
|
||
assert.strictEqual(removed, 0, '全部图仍被引用时不该删任何东西');
|
||
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgB.imageId}.jpg`)));
|
||
});
|
||
|
||
test('imageIds 覆盖 .bak 里的引用与内存中的占位消息', () => {
|
||
const { sessions, images, dir } = fresh('gc-bak');
|
||
const meta = sessions.create({});
|
||
const img = images.put(jpeg(33), 'image/jpeg');
|
||
sessions.appendUser(meta.id, { text: '带图', images: [{ imageId: img.imageId, bytes: img.bytes }] });
|
||
const file = sessionFile(dir, meta.id);
|
||
fs.copyFileSync(file, `${file}.bak`);
|
||
fs.writeFileSync(file, 'broken');
|
||
const reloaded = at(dir);
|
||
assert.ok(reloaded.sessions.imageIds().includes(img.imageId),
|
||
'主文件坏掉但 .bak 还引用着这张图,GC 不能删它');
|
||
const pendingImg = images.put(jpeg(44), 'image/jpeg');
|
||
const s2 = fresh('gc-pending');
|
||
const m2 = s2.sessions.create({});
|
||
s2.sessions.appendUser(m2.id, { text: 'q' });
|
||
s2.sessions.appendAssistant(m2.id, { images: [{ imageId: pendingImg.imageId, bytes: pendingImg.bytes }] });
|
||
assert.ok(s2.sessions.imageIds().includes(pendingImg.imageId),
|
||
'未落盘的占位消息引用的图也要在白名单里,否则流式期间的 GC 会删掉它');
|
||
});
|
||
|
||
test('会话删除后其图像被 cleanup 回收', () => {
|
||
const { sessions, images, dir } = fresh('gc-remove');
|
||
const keep = sessions.create({ title: '留' });
|
||
const drop = sessions.create({ title: '删' });
|
||
const imgKeep = images.put(jpeg(55), 'image/jpeg');
|
||
const imgDrop = images.put(jpeg(66), 'image/jpeg');
|
||
sessions.appendUser(keep.id, { text: 'k', images: [{ imageId: imgKeep.imageId, bytes: imgKeep.bytes }] });
|
||
sessions.appendUser(drop.id, { text: 'd', images: [{ imageId: imgDrop.imageId, bytes: imgDrop.bytes }] });
|
||
sessions.remove(drop.id);
|
||
const removed = images.cleanup(sessions.imageIds(), { graceMs: 0 });
|
||
assert.strictEqual(removed, 1);
|
||
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgKeep.imageId}.jpg`)));
|
||
assert.ok(!fs.existsSync(path.join(imageDir(dir), `${imgDrop.imageId}.jpg`)));
|
||
});
|
||
|
||
// --- ai-images ---
|
||
|
||
test('ai-images 只收 JPEG,魔数不对直接拒', () => {
|
||
const { images } = fresh('img-mime');
|
||
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||
assert.throws(() => images.put(png, 'image/png'), /仅支持 JPEG/);
|
||
assert.throws(() => images.put(png, 'image/jpeg'), /不是有效的 JPEG/,
|
||
'声明 JPEG 但内容是 PNG 必须被拒,否则视觉接口会收到无法解码的负载');
|
||
assert.throws(() => images.put(Buffer.alloc(0), 'image/jpeg'), /数据为空/);
|
||
assert.throws(() => images.put(Buffer.alloc(64, 0), 'image/jpeg'), /不是有效的 JPEG/);
|
||
assert.throws(() => images.put('not a buffer', 'image/jpeg'), /数据为空/);
|
||
assert.throws(() => images.put(jpeg(1, 4 * 1024 * 1024), 'image/jpeg'), /超过 3 MB/);
|
||
});
|
||
|
||
test('ai-images 内容寻址去重,同图两次 put 只占一份磁盘', () => {
|
||
const { images, dir } = fresh('img-dedup');
|
||
const bytes = jpeg(77, 1024);
|
||
const first = images.put(bytes, 'image/jpeg');
|
||
const second = images.put(Buffer.from(bytes), 'image/jpeg');
|
||
assert.strictEqual(second.imageId, first.imageId, '同样的字节必须得到同样的 imageId');
|
||
assert.strictEqual(fs.readdirSync(imageDir(dir)).length, 1, '去重后磁盘上只应有一份');
|
||
assert.strictEqual(images.totalBytes(), 1024);
|
||
assert.match(first.imageId, /^img_[a-f0-9]{64}$/);
|
||
const other = images.put(jpeg(78, 1024), 'image/jpeg');
|
||
assert.notStrictEqual(other.imageId, first.imageId, '不同字节必须得到不同 imageId');
|
||
assert.strictEqual(images.totalBytes(), 2048);
|
||
});
|
||
|
||
test('ai-images 读回与 dataUrl 一致,非法 imageId 被拒', () => {
|
||
const { images } = fresh('img-read');
|
||
const bytes = jpeg(88, 256);
|
||
const { imageId } = images.put(bytes, 'image/jpeg');
|
||
assert.ok(images.read(imageId).equals(bytes));
|
||
assert.strictEqual(images.dataUrl(imageId), `data:image/jpeg;base64,${bytes.toString('base64')}`);
|
||
for (const bad of ['', 'img_short', '../escape', 'img_' + 'g'.repeat(64), '__proto__',
|
||
`img_${'a'.repeat(64)}/../x`, null, 'pdf_' + 'a'.repeat(64)]) {
|
||
assert.throws(() => images.read(bad), /会话图像标识无效/, `imageId ${JSON.stringify(bad)} 必须被拒`);
|
||
assert.throws(() => images.dataUrl(bad), /会话图像标识无效/);
|
||
assert.throws(() => images.safeImageId(bad), /会话图像标识无效/);
|
||
}
|
||
assert.throws(() => images.read(`img_${'a'.repeat(64)}`), /会话图像不存在/);
|
||
});
|
||
|
||
test('ai-images cleanup 只删未引用的,宽限期内的新文件不删', () => {
|
||
const { images, dir } = fresh('img-cleanup');
|
||
const a = images.put(jpeg(1, 128), 'image/jpeg');
|
||
const b = images.put(jpeg(2, 128), 'image/jpeg');
|
||
const c = images.put(jpeg(3, 128), 'image/jpeg');
|
||
assert.strictEqual(images.cleanup([a.imageId]), 0,
|
||
'刚落盘的图还没被会话引用,宽限期内删掉就是删正在提交的数据');
|
||
assert.strictEqual(images.cleanup([a.imageId], { graceMs: 0 }), 2);
|
||
assert.deepStrictEqual(fs.readdirSync(imageDir(dir)), [`${a.imageId}.jpg`]);
|
||
assert.ok(images.read(a.imageId));
|
||
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 1);
|
||
assert.strictEqual(images.totalBytes(), 0);
|
||
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 0, '目录空了也不该报错');
|
||
// 白名单里的垃圾值不应意外保住任何文件
|
||
images.put(jpeg(4, 128), 'image/jpeg');
|
||
assert.strictEqual(images.cleanup(['../escape', null, '__proto__'], { graceMs: 0 }), 1);
|
||
});
|
||
|
||
test('ai-images 目录不存在时 totalBytes 与 cleanup 返回零值', () => {
|
||
const { images } = fresh('img-empty');
|
||
assert.strictEqual(images.totalBytes(), 0);
|
||
assert.strictEqual(images.cleanup([]), 0);
|
||
});
|
||
|
||
test('会话里的图像元数据被规范化,非法引用被拒', () => {
|
||
const { sessions, images } = fresh('img-meta');
|
||
const meta = sessions.create({});
|
||
const img = images.put(jpeg(99, 512), 'image/jpeg');
|
||
assert.throws(() => sessions.appendUser(meta.id, {
|
||
text: 'q', images: [{ imageId: '../escape' }]
|
||
}), /会话图像标识无效/);
|
||
assert.throws(() => sessions.appendUser(meta.id, {
|
||
text: 'q', images: [{ imageId: img.imageId, mimeType: 'image/png' }]
|
||
}), /仅支持 JPEG/);
|
||
const stored = sessions.appendUser(meta.id, {
|
||
text: 'q',
|
||
images: [
|
||
{ imageId: img.imageId, width: 100, height: 200, bytes: 512, ocrIncluded: true },
|
||
{ imageId: img.imageId, width: 100, height: 200, bytes: 512 }
|
||
]
|
||
});
|
||
assert.strictEqual(stored.images.length, 1, '同一张图重复引用应折成一条');
|
||
assert.deepStrictEqual(stored.images[0], {
|
||
imageId: img.imageId,
|
||
mimeType: 'image/jpeg',
|
||
width: 100,
|
||
height: 200,
|
||
bytes: 512,
|
||
ocrIncluded: true
|
||
});
|
||
});
|
||
|
||
// --- 对账 ---
|
||
|
||
test('orphanReport 只报书库里已不存在的条目,GLOBAL_ENTRY_ID 永不判为孤立', () => {
|
||
const { sessions } = fresh('orphan');
|
||
const inLib = sessions.create({ entryId: 'e1', title: '在库' });
|
||
const gone = sessions.create({ entryId: 'e404', title: '已删' });
|
||
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
|
||
round(sessions, gone.id, '问', '答');
|
||
const report = sessions.orphanReport(['e1']);
|
||
assert.deepStrictEqual(report.map((r) => r.sessionId), [gone.id]);
|
||
assert.strictEqual(report[0].entryId, 'e404');
|
||
assert.strictEqual(report[0].title, '已删', '报告要带标题,否则用户无法判断是否回收');
|
||
assert.strictEqual(report[0].messageCount, 2);
|
||
assert.ok(report[0].bytes > 0);
|
||
assert.ok(!report.some((r) => r.sessionId === global.id), '全局会话不绑书籍,永远不是孤立数据');
|
||
assert.ok(!report.some((r) => r.sessionId === inLib.id));
|
||
});
|
||
|
||
test('forgetMany 尊重白名单,且显式点名 GLOBAL_ENTRY_ID 也不删', () => {
|
||
const { sessions } = fresh('forget');
|
||
const keep = sessions.create({ entryId: 'e1', title: '在库' });
|
||
const dropA = sessions.create({ entryId: 'e404', title: '已删甲' });
|
||
const dropB = sessions.create({ entryId: 'e404', title: '已删乙' });
|
||
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
|
||
assert.strictEqual(sessions.forgetMany([]), 0);
|
||
assert.strictEqual(sessions.forgetMany(['e404', sessions.GLOBAL_ENTRY_ID]), 2);
|
||
const remaining = sessions.list().map((row) => row.id).sort();
|
||
assert.deepStrictEqual(remaining, [keep.id, global.id].sort(),
|
||
'在库条目与全局会话都不该被回收,误删的是用户无法找回的对话');
|
||
assert.throws(() => sessions.forgetMany(['../escape']), /会话条目 ID 无效/);
|
||
assert.strictEqual(sessions.list().length, 2, '非法输入不应造成部分删除');
|
||
});
|
||
|
||
test('rebuildIndex 返回索引形状并与 list 一致', () => {
|
||
const { sessions } = fresh('rebuild');
|
||
const a = sessions.create({ entryId: 'e1', title: '甲' });
|
||
round(sessions, a.id, '问', '答');
|
||
const idx = sessions.rebuildIndex();
|
||
assert.strictEqual(idx.version, 1);
|
||
assert.strictEqual(idx.sessions.length, 1);
|
||
assert.deepStrictEqual(Object.keys(idx.sessions[0]).sort(),
|
||
['bytes', 'entryId', 'id', 'messageCount', 'pinned', 'title', 'updatedAt'].sort());
|
||
assert.deepStrictEqual(sessions.list()[0], idx.sessions[0]);
|
||
});
|
||
|
||
test('list 返回的是副本,改动不会污染内存索引', () => {
|
||
const { sessions } = fresh('clone');
|
||
const meta = sessions.create({ title: '原标题' });
|
||
const rows = sessions.list();
|
||
rows[0].title = '被改坏';
|
||
assert.strictEqual(sessions.list()[0].title, '原标题');
|
||
assert.strictEqual(sessions.messages(meta.id).meta.title, '原标题');
|
||
});
|