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