357 lines
14 KiB
JavaScript
357 lines
14 KiB
JavaScript
// 集成测试: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);
|
||
});
|