Files
bigwo/test2/server/tests/test_redis_client.js

295 lines
11 KiB
JavaScript
Raw Permalink Normal View History

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