EternalAI/hermes-server/test/integration-test.js

357 lines
14 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.

// 集成测试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);
});