// 集成测试:EternalAI → hermes-server 端到端同步流程 // 前提:EternalAI (3001) 和 hermes-server (3002) 均已运行,SYNC_SECRET 已同步 // 前提:hermes-server 的 .env 已配置 ETERNALALAI_BASE_URL=http://localhost:3001 const BASE_ETERNAL = 'http://localhost:3001'; const BASE_HERMES = 'http://localhost:3002'; const HERMES_ADMIN_TOKEN = 'dev_only_admin_token_change_me'; let passed = 0; let failed = 0; async function assert(name, fn) { try { await fn(); passed++; console.log(` ✓ ${name}`); } catch (err) { failed++; console.log(` ✗ ${name}`); console.log(` ${err.message}`); } } function assertEqual(actual, expected, msg) { if (actual !== expected) { throw new Error(`${msg || ''} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } } function assertTruthy(val, msg) { if (!val) throw new Error(msg || 'expected truthy value'); } // 清理 hermes-server 上的所有 profile(避免重复运行导致冲突) async function cleanupHermesProfiles() { try { const res = await fetch(`${BASE_HERMES}/api/profiles`, { headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` }, }); if (!res.ok) return; const data = await res.json(); for (const p of data.profiles || []) { try { await fetch(`${BASE_HERMES}/api/profiles/${p.profileId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` }, }); } catch {} } console.log(` 已清理 hermes-server 上 ${data.profiles?.length || 0} 个 profile`); } catch (err) { console.log(` 清理 hermes-server 跳过: ${err.message}`); } } async function main() { const prisma = require('../../src/lib/prisma'); const { hashPassword } = require('../../src/lib/auth'); // ===== 清理数据库 ===== console.log('\n===== 准备:清理数据库 ====='); await prisma.order.deleteMany(); await prisma.apiKey.deleteMany(); await prisma.role.deleteMany(); await prisma.admin.deleteMany(); await prisma.user.deleteMany(); await prisma.systemConfig.deleteMany({ where: { key: 'HERMES_WEBHOOK_URL' } }); // 确保 SYNC_SECRET 存在 const crypto = require('crypto'); await prisma.systemConfig.upsert({ where: { key: 'SYNC_SECRET' }, update: {}, create: { key: 'SYNC_SECRET', value: crypto.randomBytes(32).toString('hex') }, }); // P1 修复:清理 hermes-server 上的残留 profile(避免重复运行测试失败) console.log('\n===== 准备:清理 hermes-server 数据 ====='); await cleanupHermesProfiles(); // 创建测试用户和管理员 const user = await prisma.user.create({ data: { account: 'itest_user', password: hashPassword('Test123456'), isCreator: true }, }); const admin = await prisma.admin.create({ data: { account: 'itest_admin', password: hashPassword('admin123') }, }); console.log(` 用户: ${user.id}, 管理员: ${admin.id}`); // ===== 测试开始 ===== console.log('\n===== 测试:EternalAI → hermes-server 集成 ====='); let adminToken, userToken, roleId; // 1. 管理员登录 await assert('管理员登录', async () => { const res = await fetch(`${BASE_ETERNAL}/api/admin-auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account: 'itest_admin', password: 'admin123' }), }); assertEqual(res.status, 200, '管理员登录状态码'); const data = await res.json(); assertTruthy(data.token, '管理员 token'); adminToken = data.token; }); // 2. 用户登录 await assert('用户登录', async () => { const res = await fetch(`${BASE_ETERNAL}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account: 'itest_user', password: 'Test123456' }), }); assertEqual(res.status, 200, '用户登录状态码'); const data = await res.json(); assertTruthy(data.token, '用户 token'); userToken = data.token; }); // 3. 用户创建角色 await assert('用户创建角色', async () => { const res = await fetch(`${BASE_ETERNAL}/api/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userToken}` }, body: JSON.stringify({ displayName: '集成测试角色', personality: '温柔善良', background: '来自星辰大海', speechStyle: '轻声细语', greeting: '你好呀,我是星灵', soulMd: '# SOUL\n\n我是星灵,来自星辰大海的守护者。\n我温柔善良,喜欢倾听你的故事。', }), }); assertEqual(res.status, 200, '创建角色状态码'); const data = await res.json(); assertTruthy(data.role.id, '角色 ID'); assertEqual(data.role.reviewStatus, 'pending_review', '初始审核状态'); roleId = data.role.id; }); // 4. 管理员审核通过 await assert('管理员审核通过', async () => { const res = await fetch(`${BASE_ETERNAL}/api/admin/reviews/${roleId}/approve`, { method: 'POST', headers: { Authorization: `Bearer ${adminToken}` }, }); assertEqual(res.status, 200, '审核状态码'); const data = await res.json(); assertEqual(data.role.reviewStatus, 'approved', '审核后状态'); }); // 5. 配置 Hermes webhook URL 指向真实 hermes-server await assert('配置 HERMES_WEBHOOK_URL 指向 hermes-server', async () => { const res = await fetch(`${BASE_ETERNAL}/api/admin/config/HERMES_WEBHOOK_URL`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` }, body: JSON.stringify({ value: `${BASE_HERMES}/api/sync` }), }); assertEqual(res.status, 200, '配置状态码'); }); // 6. 管理员发起同步 → hermes-server 接收 → 回调拉取文件 → 创建 profile → 返回二维码 let syncResponse; await assert('管理员发起同步(真实 hermes-server)', async () => { const res = await fetch(`${BASE_ETERNAL}/api/admin/sync/${roleId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` }, body: JSON.stringify({ profileName: 'star-spirit', modelKey: 'sk-test-key', provider: 'openrouter', multimediaModelKey: 'sk-multi-key', multimediaProvider: 'openrouter', enableSchedule: false, }), }); assertEqual(res.status, 200, '同步状态码'); const data = await res.json(); assertEqual(data.role.reviewStatus, 'synced', '同步后状态'); assertTruthy(data.role.qrCodeUrl, '二维码 URL'); assertTruthy(data.profileId, 'profileId'); assertTruthy(data.profileId.startsWith('hermes_'), 'profileId 前缀'); syncResponse = data; console.log(` profileId: ${data.profileId}`); console.log(` qrCodeUrl: ${data.role.qrCodeUrl}`); }); // 7. 验证 hermes-server 健康检查 await assert('hermes-server 健康检查', async () => { const res = await fetch(`${BASE_HERMES}/api/health`); assertEqual(res.status, 200, '健康检查状态码'); const data = await res.json(); assertEqual(data.status, 'ok', '健康状态'); // P2 修复后:health 不再泄露 syncSecretConfigured,改用 storageOk/status assertEqual(data.profileCount, 1, 'profile 数量'); }); // 8. 验证 hermes-server profile 列表 await assert('hermes-server profile 列表', async () => { const res = await fetch(`${BASE_HERMES}/api/profiles`, { headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` }, }); assertEqual(res.status, 200, 'profile 列表状态码'); const data = await res.json(); assertEqual(data.total, 1, 'profile 总数'); assertEqual(data.profiles[0].profileName, 'star-spirit', 'profile 名称'); assertEqual(data.profiles[0].roleId, roleId, 'roleId 关联'); // P0 修复后:sourceBaseUrl 来自 hermes-server 的 ETERNALAI_BASE_URL 环境变量 assertEqual(data.profiles[0].sourceBaseUrl, BASE_ETERNAL, 'sourceBaseUrl 来自环境变量'); }); // 9. 验证 hermes-server profile 详情 await assert('hermes-server profile 详情', async () => { const res = await fetch(`${BASE_HERMES}/api/profiles/${syncResponse.profileId}`, { headers: { Authorization: `Bearer ${HERMES_ADMIN_TOKEN}` }, }); assertEqual(res.status, 200, 'profile 详情状态码'); const data = await res.json(); assertEqual(data.profile.profileId, syncResponse.profileId, 'profileId'); assertEqual(data.profile.modelKey, 'sk-test-key', 'modelKey'); assertEqual(data.profile.provider, 'openrouter', 'provider'); assertEqual(data.profile.enableSchedule, false, 'enableSchedule'); assertEqual(data.profile.boundWechat, null, '未绑定状态'); }); // 10. 验证 hermes-server 提供 SOUL.md 下载 await assert('hermes-server 提供 SOUL.md 下载', async () => { const res = await fetch(`${BASE_HERMES}/api/profiles/${syncResponse.profileId}/SOUL.md`); assertEqual(res.status, 200, 'SOUL.md 状态码'); const text = await res.text(); assertTruthy(text.includes('星灵'), 'SOUL.md 内容包含角色名'); assertTruthy(text.includes('星辰大海'), 'SOUL.md 内容包含背景'); }); // 11. 验证 hermes-server 提供 config.yaml 下载 await assert('hermes-server 提供 config.yaml 下载', async () => { const res = await fetch(`${BASE_HERMES}/api/profiles/${syncResponse.profileId}/config.yaml`); assertEqual(res.status, 200, 'config.yaml 状态码'); const text = await res.text(); assertTruthy(text.includes('model:'), 'config.yaml 包含 model 字段'); assertTruthy(text.includes('temperature:'), 'config.yaml 包含 temperature 字段'); }); // 12. 验证二维码图片可访问 await assert('二维码图片可访问', async () => { const res = await fetch(syncResponse.role.qrCodeUrl); assertEqual(res.status, 200, '二维码图片状态码'); assertEqual(res.headers.get('content-type'), 'image/png', '二维码图片类型'); const buffer = await res.arrayBuffer(); assertTruthy(buffer.byteLength > 100, '二维码图片大小'); }); // 13. 验证绑定页面可访问 await assert('绑定页面可访问', async () => { const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`); assertEqual(res.status, 200, '绑定页面状态码'); const html = await res.text(); assertTruthy(html.includes('star-spirit'), '绑定页面包含 profile 名称'); assertTruthy(html.includes('确认绑定'), '绑定页面包含绑定按钮'); }); // 14. 执行绑定 await assert('执行绑定', async () => { const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ wechatId: 'wx_test_12345' }), }); assertEqual(res.status, 200, '绑定状态码'); const data = await res.json(); assertEqual(data.profileId, syncResponse.profileId, '绑定返回 profileId'); assertTruthy(data.boundAt, '绑定时间'); }); // 15. 验证已绑定后再次绑定返回 400 await assert('重复绑定返回 400', async () => { const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ wechatId: 'wx_other' }), }); assertEqual(res.status, 400, '重复绑定状态码'); }); // 16. 验证已绑定页面显示 await assert('已绑定页面显示', async () => { const res = await fetch(`${BASE_HERMES}/api/bind/${syncResponse.profileId}`); const html = await res.text(); assertTruthy(html.includes('已成功绑定'), '已绑定页面提示'); }); // 17. 验证 EternalAI 角色库显示已同步角色 await assert('EternalAI 角色库显示已同步角色', async () => { const res = await fetch(`${BASE_ETERNAL}/api/roles`); const data = await res.json(); const found = data.roles.find((r) => r.id === roleId); assertTruthy(found, '角色库包含已同步角色'); }); // 18. 验证 SYNC_SECRET 写保护(不能通过 PUT 修改) await assert('SYNC_SECRET 写保护', async () => { const res = await fetch(`${BASE_ETERNAL}/api/admin/config/SYNC_SECRET`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` }, body: JSON.stringify({ value: 'hacked' }), }); assertEqual(res.status, 403, '写保护状态码'); }); // 19. 验证无效 sync_token 被拒绝 await assert('无效 sync_token 被拒绝', async () => { const res = await fetch(`${BASE_HERMES}/api/sync`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileName: 'test', syncToken: 'invalid.token.here', roleId: 'fake-id', }), }); assertEqual(res.status, 401, '无效 token 状态码'); }); // 20. 验证缺少参数被拒绝 await assert('缺少参数被拒绝', async () => { const res = await fetch(`${BASE_HERMES}/api/sync`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileName: 'test' }), }); assertEqual(res.status, 400, '缺少参数状态码'); }); // ===== 清理 ===== console.log('\n===== 清理 ====='); // 清理 hermes-server 上的 profile await cleanupHermesProfiles(); // 清理 EternalAI 数据库 await prisma.order.deleteMany(); await prisma.apiKey.deleteMany(); await prisma.role.deleteMany(); await prisma.admin.deleteMany(); await prisma.user.deleteMany(); await prisma.systemConfig.deleteMany({ where: { key: 'HERMES_WEBHOOK_URL' } }); await prisma.$disconnect(); // ===== 结果 ===== console.log(`\n===== 结果: ${passed} passed, ${failed} failed =====`); process.exit(failed > 0 ? 1 : 0); } main().catch((err) => { console.error('测试运行失败:', err); process.exit(1); });