- 新增 kbRetriever.js: VikingDB search_knowledge 纯检索替代 Ark chat/completions, doubao-seed-rerank 重排, RAG payload 语气引导缓解音色差异 - 新增 redisClient.js: Redis 连接管理 + 5轮对话历史 + KB缓存双写 - toolExecutor.js: 产品别名扩展25条, 全库检索topK=25, 检索阈值0.01, 精简 buildDeterministicKnowledgeQuery - nativeVoiceGateway.js: isPureChitchat扩展, KB保护窗口60s, prequery参数调优 - realtimeDialogRouting.js: resolveReply感知KB保护窗口, fast-path适配raw模式 - app.js: 健康检查新增 redis/reranker/kbRetrievalMode - 新增测试: alias A/B测试, KB retriever测试, Redis客户端测试, raw模式集成测试
295 lines
11 KiB
JavaScript
295 lines
11 KiB
JavaScript
/**
|
||
* redisClient.js 单元测试
|
||
* 覆盖:连接状态、对话历史读写、KB缓存读写、降级行为、TTL/LTRIM逻辑
|
||
*
|
||
* 运行方式: node --test tests/test_redis_client.js
|
||
* 注意: 需要本地Redis可用(redis://127.0.0.1:6379),否则降级测试仍会通过
|
||
*/
|
||
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
// 加载 .env
|
||
const envPath = path.join(__dirname, '../.env');
|
||
if (fs.existsSync(envPath)) {
|
||
fs.readFileSync(envPath, 'utf8').split('\n').forEach(line => {
|
||
const trimmed = line.trim();
|
||
if (!trimmed || trimmed.startsWith('#')) return;
|
||
const idx = trimmed.indexOf('=');
|
||
if (idx > 0) {
|
||
const key = trimmed.slice(0, idx).trim();
|
||
let val = trimmed.slice(idx + 1).trim();
|
||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||
val = val.slice(1, -1);
|
||
}
|
||
if (!process.env[key]) process.env[key] = val;
|
||
}
|
||
});
|
||
}
|
||
|
||
const { after } = require('node:test');
|
||
const redisClient = require('../services/redisClient');
|
||
|
||
const TEST_SESSION_ID = `test_session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||
const TEST_KB_CACHE_KEY = `test_kb_cache_${Date.now()}`;
|
||
|
||
// 测试结束后断开 Redis,防止进程挂起
|
||
after(async () => {
|
||
await redisClient.disconnect();
|
||
});
|
||
|
||
// ================================================================
|
||
// 1. 连接与可用性
|
||
// ================================================================
|
||
describe('redisClient — 连接与可用性', () => {
|
||
it('createClient 应返回客户端实例', () => {
|
||
const client = redisClient.createClient();
|
||
assert.ok(client, 'createClient should return a client');
|
||
});
|
||
|
||
it('getClient 应返回同一个实例(单例模式)', () => {
|
||
const c1 = redisClient.getClient();
|
||
const c2 = redisClient.getClient();
|
||
assert.strictEqual(c1, c2, 'getClient should return singleton');
|
||
});
|
||
|
||
it('isAvailable 应返回 boolean', () => {
|
||
const result = redisClient.isAvailable();
|
||
assert.equal(typeof result, 'boolean', 'isAvailable should return boolean');
|
||
});
|
||
});
|
||
|
||
// ================================================================
|
||
// 2. 对话历史 — pushMessage + getRecentHistory
|
||
// ================================================================
|
||
describe('redisClient — 对话历史读写', () => {
|
||
const sessionId = TEST_SESSION_ID;
|
||
|
||
beforeEach(async () => {
|
||
await redisClient.clearSession(sessionId);
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await redisClient.clearSession(sessionId);
|
||
});
|
||
|
||
it('pushMessage 写入后 getRecentHistory 应能读取', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
const ok = await redisClient.pushMessage(sessionId, { role: 'user', content: '你好' });
|
||
assert.equal(ok, true, 'pushMessage should return true');
|
||
|
||
const history = await redisClient.getRecentHistory(sessionId, 5);
|
||
assert.ok(Array.isArray(history), 'getRecentHistory should return array');
|
||
assert.equal(history.length, 1, 'Should have 1 message');
|
||
assert.equal(history[0].role, 'user');
|
||
assert.equal(history[0].content, '你好');
|
||
});
|
||
|
||
it('多条消息应保持时间顺序(最旧在前)', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
await redisClient.pushMessage(sessionId, { role: 'user', content: '第1条' });
|
||
await redisClient.pushMessage(sessionId, { role: 'assistant', content: '第2条' });
|
||
await redisClient.pushMessage(sessionId, { role: 'user', content: '第3条' });
|
||
|
||
const history = await redisClient.getRecentHistory(sessionId, 5);
|
||
assert.equal(history.length, 3);
|
||
assert.equal(history[0].content, '第1条', '最旧的应在前面');
|
||
assert.equal(history[1].content, '第2条');
|
||
assert.equal(history[2].content, '第3条', '最新的应在后面');
|
||
});
|
||
|
||
it('超过10条应自动截断(LTRIM),只保留最近10条', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
for (let i = 1; i <= 15; i++) {
|
||
await redisClient.pushMessage(sessionId, { role: i % 2 === 1 ? 'user' : 'assistant', content: `第${i}条` });
|
||
}
|
||
|
||
const history = await redisClient.getRecentHistory(sessionId, 10);
|
||
assert.ok(history.length <= 10, `Should have at most 10 messages, got ${history.length}`);
|
||
// 最旧的应该是第6条(前5条被截断)
|
||
assert.equal(history[0].content, '第6条', '最旧的应该是第6条');
|
||
assert.equal(history[history.length - 1].content, '第15条', '最新的应该是第15条');
|
||
});
|
||
|
||
it('getRecentHistory maxRounds 参数应限制返回数量', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
for (let i = 1; i <= 8; i++) {
|
||
await redisClient.pushMessage(sessionId, { role: i % 2 === 1 ? 'user' : 'assistant', content: `消息${i}` });
|
||
}
|
||
|
||
const history2 = await redisClient.getRecentHistory(sessionId, 2);
|
||
assert.ok(history2.length <= 4, `maxRounds=2 should return at most 4 messages, got ${history2.length}`);
|
||
});
|
||
|
||
it('clearSession 后 getRecentHistory 应返回空', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
await redisClient.pushMessage(sessionId, { role: 'user', content: '会被清除' });
|
||
await redisClient.clearSession(sessionId);
|
||
const history = await redisClient.getRecentHistory(sessionId, 5);
|
||
assert.equal(history.length, 0, 'Should be empty after clear');
|
||
});
|
||
|
||
it('消息应包含 ts 时间戳', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
const before = Date.now();
|
||
await redisClient.pushMessage(sessionId, { role: 'user', content: '带时间戳' });
|
||
const after = Date.now();
|
||
|
||
const history = await redisClient.getRecentHistory(sessionId, 1);
|
||
assert.ok(history[0].ts >= before && history[0].ts <= after, 'ts should be within time range');
|
||
});
|
||
});
|
||
|
||
// ================================================================
|
||
// 3. KB缓存读写
|
||
// ================================================================
|
||
describe('redisClient — KB缓存读写', () => {
|
||
const cacheKey = TEST_KB_CACHE_KEY;
|
||
|
||
afterEach(async () => {
|
||
if (redisClient.isAvailable()) {
|
||
try {
|
||
const client = redisClient.getClient();
|
||
await client.del(`kb_cache:${cacheKey}`);
|
||
} catch {}
|
||
}
|
||
});
|
||
|
||
it('setKbCache + getKbCache 应正确读写', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
const result = { hit: true, query: '测试', results: [{ content: '测试内容' }] };
|
||
const ok = await redisClient.setKbCache(cacheKey, result);
|
||
assert.equal(ok, true, 'setKbCache should return true');
|
||
|
||
const cached = await redisClient.getKbCache(cacheKey);
|
||
assert.ok(cached, 'getKbCache should return data');
|
||
assert.equal(cached.hit, true);
|
||
assert.equal(cached.query, '测试');
|
||
});
|
||
|
||
it('不存在的key应返回null', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
const cached = await redisClient.getKbCache('nonexistent_key_' + Date.now());
|
||
assert.equal(cached, null, 'Should return null for nonexistent key');
|
||
});
|
||
});
|
||
|
||
// ================================================================
|
||
// 4. 降级行为(Redis不可用时)
|
||
// ================================================================
|
||
describe('redisClient — 降级行为', () => {
|
||
it('pushMessage 在 Redis 不可用时应返回 false 而非报错', async () => {
|
||
// 即使 Redis 可用,这也验证接口契约
|
||
const result = await redisClient.pushMessage('fake_session', { role: 'user', content: 'test' });
|
||
assert.equal(typeof result, 'boolean', 'Should return boolean');
|
||
});
|
||
|
||
it('getRecentHistory 在不存在的 session 应返回空数组', async () => {
|
||
const result = await redisClient.getRecentHistory('nonexistent_session_' + Date.now(), 5);
|
||
if (result === null) {
|
||
// Redis不可用的降级
|
||
assert.equal(result, null);
|
||
} else {
|
||
assert.ok(Array.isArray(result), 'Should return array');
|
||
assert.equal(result.length, 0, 'Should be empty for nonexistent session');
|
||
}
|
||
});
|
||
|
||
it('clearSession 对不存在的 session 不应报错', async () => {
|
||
const result = await redisClient.clearSession('nonexistent_session_' + Date.now());
|
||
assert.equal(typeof result, 'boolean', 'Should return boolean');
|
||
});
|
||
|
||
it('getKbCache 在 Redis 不可用时应返回 null', async () => {
|
||
const result = await redisClient.getKbCache('test_key');
|
||
// 无论 Redis 是否可用,都不应抛出异常
|
||
assert.ok(result === null || typeof result === 'object', 'Should return null or object');
|
||
});
|
||
|
||
it('setKbCache 在 Redis 不可用时应返回 false', async () => {
|
||
const result = await redisClient.setKbCache('test_key', { hit: false });
|
||
assert.equal(typeof result, 'boolean', 'Should return boolean');
|
||
});
|
||
});
|
||
|
||
// ================================================================
|
||
// 5. 数据完整性
|
||
// ================================================================
|
||
describe('redisClient — 数据完整性', () => {
|
||
const sessionId = TEST_SESSION_ID + '_integrity';
|
||
|
||
afterEach(async () => {
|
||
await redisClient.clearSession(sessionId);
|
||
});
|
||
|
||
it('特殊字符消息应正确存取', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
const specialContent = '产品价格:¥299.00 "双引号" \'单引号\' \n换行 \t制表符 emoji🎉';
|
||
await redisClient.pushMessage(sessionId, { role: 'user', content: specialContent });
|
||
|
||
const history = await redisClient.getRecentHistory(sessionId, 1);
|
||
assert.equal(history[0].content, specialContent, 'Should preserve special characters');
|
||
});
|
||
|
||
it('空内容消息应正确存取', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
await redisClient.pushMessage(sessionId, { role: 'user', content: '' });
|
||
const history = await redisClient.getRecentHistory(sessionId, 1);
|
||
assert.equal(history[0].content, '', 'Should handle empty content');
|
||
});
|
||
|
||
it('source 字段应正确保存', async () => {
|
||
if (!redisClient.isAvailable()) {
|
||
console.log(' ⏭️ Redis不可用,跳过');
|
||
return;
|
||
}
|
||
|
||
await redisClient.pushMessage(sessionId, { role: 'assistant', content: '回答', source: 'voice_tool' });
|
||
const history = await redisClient.getRecentHistory(sessionId, 1);
|
||
assert.equal(history[0].source, 'voice_tool', 'Should preserve source field');
|
||
});
|
||
});
|
||
|
||
console.log('\n=== redisClient 测试加载完成 ===\n');
|