Files
bigwo/test2/server/tests/test_redis_client.js
User 56940676f6 feat(kb): VikingDB纯检索+重排+Redis上下文+全库搜索+别名扩展+KB保护窗口+RAG语气引导
- 新增 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模式集成测试
2026-03-26 14:30:32 +08:00

295 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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');