Merge branch 'main' into zulip_dev
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ coverage/
|
|||||||
|
|
||||||
# Redis数据文件(本地开发用)
|
# Redis数据文件(本地开发用)
|
||||||
redis-data/
|
redis-data/
|
||||||
|
|
||||||
|
.kiro/
|
||||||
311
full_diagnosis.js
Normal file
311
full_diagnosis.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
const io = require('socket.io-client');
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
console.log('🔍 全面WebSocket连接诊断');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
// 1. 测试基础网络连接
|
||||||
|
async function testBasicConnection() {
|
||||||
|
console.log('\n1️⃣ 测试基础HTTPS连接...');
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const options = {
|
||||||
|
hostname: 'whaletownend.xinghangee.icu',
|
||||||
|
port: 443,
|
||||||
|
path: '/',
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 10000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
console.log(`✅ HTTPS连接成功 - 状态码: ${res.statusCode}`);
|
||||||
|
console.log(`📋 服务器: ${res.headers.server || '未知'}`);
|
||||||
|
resolve({ success: true, statusCode: res.statusCode });
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
console.log(`❌ HTTPS连接失败: ${error.message}`);
|
||||||
|
resolve({ success: false, error: error.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
console.log('❌ HTTPS连接超时');
|
||||||
|
req.destroy();
|
||||||
|
resolve({ success: false, error: 'timeout' });
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 测试本地服务器
|
||||||
|
async function testLocalServer() {
|
||||||
|
console.log('\n2️⃣ 测试本地服务器...');
|
||||||
|
|
||||||
|
const testPaths = [
|
||||||
|
'http://localhost:3000/',
|
||||||
|
'http://localhost:3000/socket.io/?EIO=4&transport=polling'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of testPaths) {
|
||||||
|
console.log(`🧪 测试: ${url}`);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const options = {
|
||||||
|
hostname: urlObj.hostname,
|
||||||
|
port: urlObj.port,
|
||||||
|
path: urlObj.pathname + urlObj.search,
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 5000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
console.log(` 状态码: ${res.statusCode}`);
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
console.log(' ✅ 本地服务器正常');
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ 本地服务器响应: ${res.statusCode}`);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
console.log(` ❌ 本地服务器连接失败: ${error.message}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
console.log(' ❌ 本地服务器超时');
|
||||||
|
req.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 测试远程Socket.IO路径
|
||||||
|
async function testRemoteSocketIO() {
|
||||||
|
console.log('\n3️⃣ 测试远程Socket.IO路径...');
|
||||||
|
|
||||||
|
const testPaths = [
|
||||||
|
'/socket.io/?EIO=4&transport=polling',
|
||||||
|
'/game/socket.io/?EIO=4&transport=polling',
|
||||||
|
'/socket.io/?transport=polling',
|
||||||
|
'/api/socket.io/?EIO=4&transport=polling'
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const path of testPaths) {
|
||||||
|
console.log(`🧪 测试路径: ${path}`);
|
||||||
|
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
const options = {
|
||||||
|
hostname: 'whaletownend.xinghangee.icu',
|
||||||
|
port: 443,
|
||||||
|
path: path,
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 8000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'socket.io-diagnosis'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
console.log(` 状态码: ${res.statusCode}`);
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
console.log(' ✅ 路径可用');
|
||||||
|
console.log(` 📄 响应: ${data.substring(0, 50)}...`);
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ 路径不可用: ${res.statusCode}`);
|
||||||
|
}
|
||||||
|
resolve({ path, statusCode: res.statusCode, success: res.statusCode === 200 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
console.log(` ❌ 请求失败: ${error.message}`);
|
||||||
|
resolve({ path, error: error.message, success: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
console.log(' ❌ 请求超时');
|
||||||
|
req.destroy();
|
||||||
|
resolve({ path, error: 'timeout', success: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 测试Socket.IO客户端连接
|
||||||
|
async function testSocketIOClient() {
|
||||||
|
console.log('\n4️⃣ 测试Socket.IO客户端连接...');
|
||||||
|
|
||||||
|
const configs = [
|
||||||
|
{
|
||||||
|
name: 'HTTPS + 所有传输方式',
|
||||||
|
url: 'https://whaletownend.xinghangee.icu',
|
||||||
|
options: { transports: ['websocket', 'polling'], timeout: 10000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HTTPS + 仅Polling',
|
||||||
|
url: 'https://whaletownend.xinghangee.icu',
|
||||||
|
options: { transports: ['polling'], timeout: 10000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HTTPS + /game namespace',
|
||||||
|
url: 'https://whaletownend.xinghangee.icu/game',
|
||||||
|
options: { transports: ['polling'], timeout: 10000 }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
console.log(`🧪 测试: ${config.name}`);
|
||||||
|
console.log(` URL: ${config.url}`);
|
||||||
|
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
const socket = io(config.url, config.options);
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
socket.disconnect();
|
||||||
|
console.log(' ❌ 连接超时');
|
||||||
|
resolve({ success: false, error: 'timeout' });
|
||||||
|
}
|
||||||
|
}, config.options.timeout);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.log(' ✅ 连接成功');
|
||||||
|
console.log(` 📡 Socket ID: ${socket.id}`);
|
||||||
|
console.log(` 🚀 传输方式: ${socket.io.engine.transport.name}`);
|
||||||
|
socket.disconnect();
|
||||||
|
resolve({ success: true, transport: socket.io.engine.transport.name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.log(` ❌ 连接失败: ${error.message}`);
|
||||||
|
resolve({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({ config: config.name, ...result });
|
||||||
|
|
||||||
|
// 等待1秒再测试下一个
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 检查DNS解析
|
||||||
|
async function testDNS() {
|
||||||
|
console.log('\n5️⃣ 检查DNS解析...');
|
||||||
|
|
||||||
|
const dns = require('dns');
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dns.lookup('whaletownend.xinghangee.icu', (err, address, family) => {
|
||||||
|
if (err) {
|
||||||
|
console.log(`❌ DNS解析失败: ${err.message}`);
|
||||||
|
resolve({ success: false, error: err.message });
|
||||||
|
} else {
|
||||||
|
console.log(`✅ DNS解析成功: ${address} (IPv${family})`);
|
||||||
|
resolve({ success: true, address, family });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主诊断函数
|
||||||
|
async function runFullDiagnosis() {
|
||||||
|
console.log('开始全面诊断...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dnsResult = await testDNS();
|
||||||
|
const basicResult = await testBasicConnection();
|
||||||
|
await testLocalServer();
|
||||||
|
const socketIOPaths = await testRemoteSocketIO();
|
||||||
|
const clientResults = await testSocketIOClient();
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('📊 诊断结果汇总');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
console.log(`1. DNS解析: ${dnsResult.success ? '✅ 正常' : '❌ 失败'}`);
|
||||||
|
if (dnsResult.address) {
|
||||||
|
console.log(` IP地址: ${dnsResult.address}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`2. HTTPS连接: ${basicResult.success ? '✅ 正常' : '❌ 失败'}`);
|
||||||
|
if (basicResult.error) {
|
||||||
|
console.log(` 错误: ${basicResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workingPaths = socketIOPaths.filter(r => r.success);
|
||||||
|
console.log(`3. Socket.IO路径: ${workingPaths.length}/${socketIOPaths.length} 个可用`);
|
||||||
|
workingPaths.forEach(p => {
|
||||||
|
console.log(` ✅ ${p.path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const workingClients = clientResults.filter(r => r.success);
|
||||||
|
console.log(`4. Socket.IO客户端: ${workingClients.length}/${clientResults.length} 个成功`);
|
||||||
|
workingClients.forEach(c => {
|
||||||
|
console.log(` ✅ ${c.config} (${c.transport})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n💡 建议:');
|
||||||
|
|
||||||
|
if (!dnsResult.success) {
|
||||||
|
console.log('❌ DNS解析失败 - 检查域名配置');
|
||||||
|
} else if (!basicResult.success) {
|
||||||
|
console.log('❌ 基础HTTPS连接失败 - 检查服务器状态和防火墙');
|
||||||
|
} else if (workingPaths.length === 0) {
|
||||||
|
console.log('❌ 所有Socket.IO路径都不可用 - 检查nginx配置和后端服务');
|
||||||
|
} else if (workingClients.length === 0) {
|
||||||
|
console.log('❌ Socket.IO客户端无法连接 - 可能是CORS或协议问题');
|
||||||
|
} else {
|
||||||
|
console.log('✅ 部分功能正常 - 使用可用的配置继续开发');
|
||||||
|
|
||||||
|
if (workingClients.length > 0) {
|
||||||
|
const bestConfig = workingClients.find(c => c.transport === 'websocket') || workingClients[0];
|
||||||
|
console.log(`💡 推荐使用: ${bestConfig.config}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('诊断过程中发生错误:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runFullDiagnosis();
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
"start:prod": "node dist/main.js",
|
"start:prod": "node dist/main.js",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage"
|
"test:cov": "jest --coverage",
|
||||||
|
"test:e2e": "cross-env RUN_E2E_TESTS=true jest --testPathPattern=e2e.spec.ts",
|
||||||
|
"test:unit": "jest --testPathPattern=spec.ts --testPathIgnorePatterns=e2e.spec.ts",
|
||||||
|
"test:all": "cross-env RUN_E2E_TESTS=true jest"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"game",
|
"game",
|
||||||
@@ -65,6 +68,7 @@
|
|||||||
"@types/node": "^20.19.27",
|
"@types/node": "^20.19.27",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.14",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"fast-check": "^4.5.2",
|
"fast-check": "^4.5.2",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
|
|||||||
39
src/business/auth/decorators/current-user.decorator.ts
Normal file
39
src/business/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 当前用户装饰器
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 从请求上下文中提取当前认证用户信息
|
||||||
|
* - 简化控制器中获取用户信息的操作
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* ```typescript
|
||||||
|
* @Get('profile')
|
||||||
|
* @UseGuards(JwtAuthGuard)
|
||||||
|
* getProfile(@CurrentUser() user: JwtPayload) {
|
||||||
|
* return { user };
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @author kiro-ai
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-05
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { AuthenticatedRequest, JwtPayload } from '../guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前用户装饰器
|
||||||
|
*
|
||||||
|
* @param data 可选的属性名,用于获取用户对象的特定属性
|
||||||
|
* @param ctx 执行上下文
|
||||||
|
* @returns 用户信息或用户的特定属性
|
||||||
|
*/
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
return data ? user?.[data] : user;
|
||||||
|
},
|
||||||
|
);
|
||||||
128
src/business/auth/examples/jwt-usage-example.ts
Normal file
128
src/business/auth/examples/jwt-usage-example.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* JWT 使用示例
|
||||||
|
*
|
||||||
|
* 展示如何在控制器中使用 JWT 认证守卫和当前用户装饰器
|
||||||
|
*
|
||||||
|
* @author kiro-ai
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-05
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Controller, Get, UseGuards, Post, Body } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard, JwtPayload } from '../guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 示例控制器 - 展示 JWT 认证的使用方法
|
||||||
|
*/
|
||||||
|
@Controller('example')
|
||||||
|
export class ExampleController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开接口 - 无需认证
|
||||||
|
*/
|
||||||
|
@Get('public')
|
||||||
|
getPublicData() {
|
||||||
|
return {
|
||||||
|
message: '这是一个公开接口,无需认证',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 受保护的接口 - 需要 JWT 认证
|
||||||
|
*
|
||||||
|
* 请求头示例:
|
||||||
|
* Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
*/
|
||||||
|
@Get('protected')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
getProtectedData(@CurrentUser() user: JwtPayload) {
|
||||||
|
return {
|
||||||
|
message: '这是一个受保护的接口,需要有效的 JWT 令牌',
|
||||||
|
user: {
|
||||||
|
id: user.sub,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户信息
|
||||||
|
*/
|
||||||
|
@Get('profile')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
getUserProfile(@CurrentUser() user: JwtPayload) {
|
||||||
|
return {
|
||||||
|
profile: {
|
||||||
|
userId: user.sub,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
tokenIssuedAt: new Date(user.iat * 1000).toISOString(),
|
||||||
|
tokenExpiresAt: new Date(user.exp * 1000).toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的特定属性
|
||||||
|
*/
|
||||||
|
@Get('username')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
getUsername(@CurrentUser('username') username: string) {
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
message: `你好,${username}!`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 需要特定角色的接口
|
||||||
|
*/
|
||||||
|
@Post('admin-only')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
adminOnlyAction(@CurrentUser() user: JwtPayload, @Body() data: any) {
|
||||||
|
// 检查用户角色
|
||||||
|
if (user.role !== 1) { // 假设 1 是管理员角色
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '权限不足,仅管理员可访问',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '管理员操作执行成功',
|
||||||
|
data,
|
||||||
|
operator: user.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用说明:
|
||||||
|
*
|
||||||
|
* 1. 首先调用登录接口获取 JWT 令牌:
|
||||||
|
* POST /auth/login
|
||||||
|
* {
|
||||||
|
* "identifier": "username",
|
||||||
|
* "password": "password"
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* 2. 从响应中获取 access_token
|
||||||
|
*
|
||||||
|
* 3. 在后续请求中添加 Authorization 头:
|
||||||
|
* Authorization: Bearer <access_token>
|
||||||
|
*
|
||||||
|
* 4. 访问受保护的接口:
|
||||||
|
* GET /example/protected
|
||||||
|
* GET /example/profile
|
||||||
|
* GET /example/username
|
||||||
|
* POST /example/admin-only
|
||||||
|
*
|
||||||
|
* 错误处理:
|
||||||
|
* - 401 Unauthorized: 令牌缺失或无效
|
||||||
|
* - 403 Forbidden: 令牌有效但权限不足
|
||||||
|
*/
|
||||||
83
src/business/auth/guards/jwt-auth.guard.ts
Normal file
83
src/business/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* JWT 认证守卫
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 验证请求中的 JWT 令牌
|
||||||
|
* - 提取用户信息并添加到请求上下文
|
||||||
|
* - 保护需要认证的路由
|
||||||
|
*
|
||||||
|
* @author kiro-ai
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-05
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 载荷接口
|
||||||
|
*/
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string; // 用户ID
|
||||||
|
username: string;
|
||||||
|
role: number;
|
||||||
|
iat: number; // 签发时间
|
||||||
|
exp: number; // 过期时间
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展的请求接口,包含用户信息
|
||||||
|
*/
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
user: JwtPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(JwtAuthGuard.name);
|
||||||
|
|
||||||
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.warn('访问被拒绝:缺少认证令牌');
|
||||||
|
throw new UnauthorizedException('缺少认证令牌');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证并解码 JWT 令牌
|
||||||
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
|
||||||
|
|
||||||
|
// 将用户信息添加到请求对象
|
||||||
|
(request as AuthenticatedRequest).user = payload;
|
||||||
|
|
||||||
|
this.logger.log(`用户认证成功: ${payload.username} (ID: ${payload.sub})`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||||
|
this.logger.warn(`JWT 令牌验证失败: ${errorMessage}`);
|
||||||
|
throw new UnauthorizedException('无效的认证令牌');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求头中提取 JWT 令牌
|
||||||
|
*
|
||||||
|
* @param request 请求对象
|
||||||
|
* @returns JWT 令牌或 undefined
|
||||||
|
*/
|
||||||
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,9 @@ export class ZulipService {
|
|||||||
private readonly loginService: LoginService,
|
private readonly loginService: LoginService,
|
||||||
) {
|
) {
|
||||||
this.logger.log('ZulipService初始化完成');
|
this.logger.log('ZulipService初始化完成');
|
||||||
|
|
||||||
|
// 启动事件处理
|
||||||
|
this.initializeEventProcessing();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -775,5 +778,42 @@ export class ZulipService {
|
|||||||
async getSocketsInMap(mapId: string): Promise<string[]> {
|
async getSocketsInMap(mapId: string): Promise<string[]> {
|
||||||
return this.sessionManager.getSocketsInMap(mapId);
|
return this.sessionManager.getSocketsInMap(mapId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取事件处理器实例
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 返回ZulipEventProcessorService实例,用于设置消息分发器
|
||||||
|
*
|
||||||
|
* @returns ZulipEventProcessorService 事件处理器实例
|
||||||
|
*/
|
||||||
|
getEventProcessor(): ZulipEventProcessorService {
|
||||||
|
return this.eventProcessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化事件处理
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 启动Zulip事件处理循环,用于接收和处理从Zulip服务器返回的消息
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async initializeEventProcessing(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.log('开始初始化Zulip事件处理');
|
||||||
|
|
||||||
|
// 启动事件处理循环
|
||||||
|
await this.eventProcessor.startEventProcessing();
|
||||||
|
|
||||||
|
this.logger.log('Zulip事件处理初始化完成');
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('初始化Zulip事件处理失败', {
|
||||||
|
operation: 'initializeEventProcessing',
|
||||||
|
error: err.message,
|
||||||
|
}, err.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ export class ZulipWebSocketGateway implements OnGatewayConnection, OnGatewayDisc
|
|||||||
namespace: '/game',
|
namespace: '/game',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 设置消息分发器,使ZulipEventProcessorService能够向客户端发送消息
|
||||||
|
this.setupMessageDistributor();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -373,6 +376,13 @@ export class ZulipWebSocketGateway implements OnGatewayConnection, OnGatewayDisc
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const clientData = client.data as ClientData | undefined;
|
const clientData = client.data as ClientData | undefined;
|
||||||
|
|
||||||
|
console.log('🔍 DEBUG: handleChat 被调用了!', {
|
||||||
|
socketId: client.id,
|
||||||
|
data: data,
|
||||||
|
clientData: clientData,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log('收到聊天消息', {
|
this.logger.log('收到聊天消息', {
|
||||||
operation: 'handleChat',
|
operation: 'handleChat',
|
||||||
socketId: client.id,
|
socketId: client.id,
|
||||||
@@ -749,5 +759,41 @@ export class ZulipWebSocketGateway implements OnGatewayConnection, OnGatewayDisc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置消息分发器
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 将当前WebSocket网关设置为ZulipEventProcessorService的消息分发器,
|
||||||
|
* 使其能够接收从Zulip返回的消息并转发给游戏客户端
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private setupMessageDistributor(): void {
|
||||||
|
try {
|
||||||
|
// 获取ZulipEventProcessorService实例
|
||||||
|
const eventProcessor = this.zulipService.getEventProcessor();
|
||||||
|
|
||||||
|
if (eventProcessor) {
|
||||||
|
// 设置消息分发器
|
||||||
|
eventProcessor.setMessageDistributor(this);
|
||||||
|
|
||||||
|
this.logger.log('消息分发器设置完成', {
|
||||||
|
operation: 'setupMessageDistributor',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logger.warn('无法获取ZulipEventProcessorService实例', {
|
||||||
|
operation: 'setupMessageDistributor',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('设置消息分发器失败', {
|
||||||
|
operation: 'setupMessageDistributor',
|
||||||
|
error: err.message,
|
||||||
|
}, err.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* - 测试ApiKeySecurityService的核心功能
|
* - 测试ApiKeySecurityService的核心功能
|
||||||
* - 包含属性测试验证API Key安全存储
|
* - 包含属性测试验证API Key安全存储
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl, moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
* @since 2025-12-25
|
* @since 2025-12-25
|
||||||
*/
|
*/
|
||||||
@@ -548,4 +548,424 @@ describe('ApiKeySecurityService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 补充测试用例 ====================
|
||||||
|
|
||||||
|
describe('访问频率限制测试', () => {
|
||||||
|
it('应该在超过频率限制时拒绝访问', async () => {
|
||||||
|
const userId = 'rate-limit-user';
|
||||||
|
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
|
||||||
|
// 存储API Key
|
||||||
|
await service.storeApiKey(userId, apiKey);
|
||||||
|
|
||||||
|
// 模拟已达到频率限制
|
||||||
|
const rateLimitKey = `zulip:api_key_access:${userId}`;
|
||||||
|
memoryStore.set(rateLimitKey, { value: '60' });
|
||||||
|
|
||||||
|
// 尝试获取API Key应该被拒绝
|
||||||
|
const result = await service.getApiKey(userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('访问频率过高');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理频率限制计数', async () => {
|
||||||
|
const userId = 'counter-user';
|
||||||
|
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
|
||||||
|
await service.storeApiKey(userId, apiKey);
|
||||||
|
|
||||||
|
// 连续访问多次
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const result = await service.getApiKey(userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查计数器
|
||||||
|
const rateLimitKey = `zulip:api_key_access:${userId}`;
|
||||||
|
const count = await mockRedisService.get(rateLimitKey);
|
||||||
|
expect(parseInt(count || '0', 10)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在Redis错误时默认允许访问', async () => {
|
||||||
|
const userId = 'redis-error-user';
|
||||||
|
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
|
||||||
|
await service.storeApiKey(userId, apiKey);
|
||||||
|
|
||||||
|
// 模拟Redis错误
|
||||||
|
mockRedisService.get.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||||
|
mockRedisService.incr.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||||
|
|
||||||
|
// 应该仍然允许访问
|
||||||
|
const result = await service.getApiKey(userId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.apiKey).toBe(apiKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Redis错误处理测试', () => {
|
||||||
|
it('应该处理存储时的Redis错误', async () => {
|
||||||
|
mockRedisService.set.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||||
|
|
||||||
|
const result = await service.storeApiKey('user-123', 'abcdefghijklmnopqrstuvwxyz123456');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('存储失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理获取时的Redis错误', async () => {
|
||||||
|
// 模拟所有Redis get调用都失败
|
||||||
|
mockRedisService.get.mockRejectedValue(new Error('Redis connection failed'));
|
||||||
|
|
||||||
|
const result = await service.getApiKey('user-123');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('获取失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理删除时的Redis错误', async () => {
|
||||||
|
mockRedisService.del.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||||
|
|
||||||
|
const result = await service.deleteApiKey('user-123');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理检查存在性时的Redis错误', async () => {
|
||||||
|
mockRedisService.exists.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||||
|
|
||||||
|
const result = await service.hasApiKey('user-123');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理获取统计信息时的Redis错误', async () => {
|
||||||
|
mockRedisService.get.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||||
|
|
||||||
|
const stats = await service.getApiKeyStats('user-123');
|
||||||
|
expect(stats.exists).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('数据损坏处理测试', () => {
|
||||||
|
it('应该处理损坏的JSON数据', async () => {
|
||||||
|
const userId = 'corrupted-data-user';
|
||||||
|
const storageKey = `zulip:api_key:${userId}`;
|
||||||
|
|
||||||
|
// 存储损坏的JSON数据
|
||||||
|
memoryStore.set(storageKey, { value: 'invalid-json-data' });
|
||||||
|
|
||||||
|
const result = await service.getApiKey(userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('获取失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理缺少必要字段的数据', async () => {
|
||||||
|
const userId = 'incomplete-data-user';
|
||||||
|
const storageKey = `zulip:api_key:${userId}`;
|
||||||
|
|
||||||
|
// 存储不完整的数据
|
||||||
|
const incompleteData = {
|
||||||
|
encryptedKey: 'some-encrypted-data',
|
||||||
|
// 缺少 iv 和 authTag
|
||||||
|
};
|
||||||
|
memoryStore.set(storageKey, { value: JSON.stringify(incompleteData) });
|
||||||
|
|
||||||
|
const result = await service.getApiKey(userId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('可疑访问记录测试', () => {
|
||||||
|
it('应该记录可疑访问事件', async () => {
|
||||||
|
const userId = 'suspicious-user';
|
||||||
|
const reason = 'multiple_failed_attempts';
|
||||||
|
const details = { attemptCount: 5, timeWindow: '1min' };
|
||||||
|
const metadata = { ipAddress: '192.168.1.100', userAgent: 'TestAgent' };
|
||||||
|
|
||||||
|
await service.logSuspiciousAccess(userId, reason, details, metadata);
|
||||||
|
|
||||||
|
// 验证安全日志被记录
|
||||||
|
expect(mockRedisService.setex).toHaveBeenCalled();
|
||||||
|
const setexCalls = mockRedisService.setex.mock.calls;
|
||||||
|
const securityLogCall = setexCalls.find(call => call[0].includes('security_log'));
|
||||||
|
expect(securityLogCall).toBeDefined();
|
||||||
|
|
||||||
|
// 验证日志内容
|
||||||
|
const logData = JSON.parse(securityLogCall![2]);
|
||||||
|
expect(logData.eventType).toBe(SecurityEventType.SUSPICIOUS_ACCESS);
|
||||||
|
expect(logData.severity).toBe(SecuritySeverity.WARNING);
|
||||||
|
expect(logData.details.reason).toBe(reason);
|
||||||
|
expect(logData.ipAddress).toBe(metadata.ipAddress);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('元数据处理测试', () => {
|
||||||
|
it('应该在安全日志中记录IP地址和User-Agent', async () => {
|
||||||
|
const userId = 'metadata-user';
|
||||||
|
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
const metadata = {
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
userAgent: 'Mozilla/5.0 (Test Browser)',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.storeApiKey(userId, apiKey, metadata);
|
||||||
|
|
||||||
|
// 验证元数据被记录在安全日志中
|
||||||
|
const setexCalls = mockRedisService.setex.mock.calls;
|
||||||
|
const securityLogCall = setexCalls.find(call => call[0].includes('security_log'));
|
||||||
|
expect(securityLogCall).toBeDefined();
|
||||||
|
|
||||||
|
const logData = JSON.parse(securityLogCall![2]);
|
||||||
|
expect(logData.ipAddress).toBe(metadata.ipAddress);
|
||||||
|
expect(logData.userAgent).toBe(metadata.userAgent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理缺少元数据的情况', async () => {
|
||||||
|
const userId = 'no-metadata-user';
|
||||||
|
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
|
||||||
|
// 不提供元数据
|
||||||
|
const result = await service.storeApiKey(userId, apiKey);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
// 验证安全日志仍然被记录
|
||||||
|
const setexCalls = mockRedisService.setex.mock.calls;
|
||||||
|
const securityLogCall = setexCalls.find(call => call[0].includes('security_log'));
|
||||||
|
expect(securityLogCall).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('边界条件测试', () => {
|
||||||
|
it('应该处理极长的用户ID', async () => {
|
||||||
|
const longUserId = 'a'.repeat(1000);
|
||||||
|
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
|
||||||
|
const result = await service.storeApiKey(longUserId, apiKey);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const getResult = await service.getApiKey(longUserId);
|
||||||
|
expect(getResult.success).toBe(true);
|
||||||
|
expect(getResult.apiKey).toBe(apiKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理最大长度的API Key', async () => {
|
||||||
|
const userId = 'max-key-user';
|
||||||
|
const maxLengthApiKey = 'a'.repeat(128); // 最大允许长度
|
||||||
|
|
||||||
|
const result = await service.storeApiKey(userId, maxLengthApiKey);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const getResult = await service.getApiKey(userId);
|
||||||
|
expect(getResult.success).toBe(true);
|
||||||
|
expect(getResult.apiKey).toBe(maxLengthApiKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝超长的API Key', async () => {
|
||||||
|
const userId = 'overlong-key-user';
|
||||||
|
const overlongApiKey = 'a'.repeat(129); // 超过最大长度
|
||||||
|
|
||||||
|
const result = await service.storeApiKey(userId, overlongApiKey);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('格式无效');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理最小长度的API Key', async () => {
|
||||||
|
const userId = 'min-key-user';
|
||||||
|
const minLengthApiKey = 'a'.repeat(16); // 最小允许长度
|
||||||
|
|
||||||
|
const result = await service.storeApiKey(userId, minLengthApiKey);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const getResult = await service.getApiKey(userId);
|
||||||
|
expect(getResult.success).toBe(true);
|
||||||
|
expect(getResult.apiKey).toBe(minLengthApiKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('时间相关测试', () => {
|
||||||
|
it('应该正确设置创建时间和更新时间', async () => {
|
||||||
|
const userId = 'time-test-user';
|
||||||
|
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
const beforeStore = new Date();
|
||||||
|
|
||||||
|
await service.storeApiKey(userId, apiKey);
|
||||||
|
|
||||||
|
const stats = await service.getApiKeyStats(userId);
|
||||||
|
expect(stats.exists).toBe(true);
|
||||||
|
expect(stats.createdAt).toBeDefined();
|
||||||
|
expect(stats.updatedAt).toBeDefined();
|
||||||
|
expect(stats.createdAt!.getTime()).toBeGreaterThanOrEqual(beforeStore.getTime());
|
||||||
|
expect(stats.updatedAt!.getTime()).toBeGreaterThanOrEqual(beforeStore.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在访问时更新最后访问时间', async () => {
|
||||||
|
const userId = 'access-time-user';
|
||||||
|
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
|
||||||
|
await service.storeApiKey(userId, apiKey);
|
||||||
|
|
||||||
|
// 等待一小段时间
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
const beforeAccess = new Date();
|
||||||
|
await service.getApiKey(userId);
|
||||||
|
|
||||||
|
const stats = await service.getApiKeyStats(userId);
|
||||||
|
expect(stats.lastAccessedAt).toBeDefined();
|
||||||
|
expect(stats.lastAccessedAt!.getTime()).toBeGreaterThanOrEqual(beforeAccess.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在更新时保持创建时间不变', async () => {
|
||||||
|
const userId = 'update-time-user';
|
||||||
|
const oldApiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
const newApiKey = 'newkeyabcdefghijklmnopqrstuvwx';
|
||||||
|
|
||||||
|
await service.storeApiKey(userId, oldApiKey);
|
||||||
|
const statsAfterStore = await service.getApiKeyStats(userId);
|
||||||
|
const originalCreatedAt = statsAfterStore.createdAt;
|
||||||
|
|
||||||
|
// 等待一小段时间
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
await service.updateApiKey(userId, newApiKey);
|
||||||
|
const statsAfterUpdate = await service.getApiKeyStats(userId);
|
||||||
|
|
||||||
|
expect(statsAfterUpdate.createdAt).toEqual(originalCreatedAt);
|
||||||
|
expect(statsAfterUpdate.updatedAt!.getTime()).toBeGreaterThan(statsAfterStore.updatedAt!.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('并发访问测试', () => {
|
||||||
|
it('应该处理并发的API Key访问', async () => {
|
||||||
|
const userId = 'concurrent-user';
|
||||||
|
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
|
||||||
|
await service.storeApiKey(userId, apiKey);
|
||||||
|
|
||||||
|
// 并发访问 - 使用串行方式来确保计数正确
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const result = await service.getApiKey(userId);
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有访问都应该成功
|
||||||
|
results.forEach(result => {
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.apiKey).toBe(apiKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 访问计数应该正确
|
||||||
|
const stats = await service.getApiKeyStats(userId);
|
||||||
|
expect(stats.accessCount).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理并发的存储和获取操作', async () => {
|
||||||
|
const userId = 'concurrent-store-get-user';
|
||||||
|
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||||
|
|
||||||
|
// 并发执行存储和获取操作
|
||||||
|
const storePromise = service.storeApiKey(userId, apiKey);
|
||||||
|
const getPromise = service.getApiKey(userId);
|
||||||
|
|
||||||
|
const [storeResult, getResult] = await Promise.all([storePromise, getPromise]);
|
||||||
|
|
||||||
|
// 存储应该成功
|
||||||
|
expect(storeResult.success).toBe(true);
|
||||||
|
|
||||||
|
// 获取可能成功也可能失败(取决于执行顺序)
|
||||||
|
if (getResult.success) {
|
||||||
|
expect(getResult.apiKey).toBe(apiKey);
|
||||||
|
} else {
|
||||||
|
expect(getResult.message).toContain('不存在');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('安全事件记录错误处理', () => {
|
||||||
|
it('应该处理记录安全事件时的Redis错误', async () => {
|
||||||
|
mockRedisService.setex.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||||
|
|
||||||
|
// 记录安全事件不应该抛出异常
|
||||||
|
await expect(service.logSecurityEvent({
|
||||||
|
eventType: SecurityEventType.API_KEY_STORED,
|
||||||
|
severity: SecuritySeverity.INFO,
|
||||||
|
userId: 'test-user',
|
||||||
|
details: { action: 'test' },
|
||||||
|
timestamp: new Date(),
|
||||||
|
})).resolves.not.toThrow();
|
||||||
|
|
||||||
|
// 应该记录错误日志
|
||||||
|
expect(Logger.prototype.error).toHaveBeenCalledWith(
|
||||||
|
'记录安全事件失败',
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('环境变量处理测试', () => {
|
||||||
|
it('应该在没有环境变量时使用默认密钥并记录警告', () => {
|
||||||
|
// 这个测试需要在服务初始化时进行,当前实现中已经初始化了
|
||||||
|
// 验证警告日志被记录
|
||||||
|
expect(Logger.prototype.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('使用默认加密密钥')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('属性测试 - 错误处理和边界条件', () => {
|
||||||
|
/**
|
||||||
|
* 属性测试: 任何Redis错误都不应该导致服务崩溃
|
||||||
|
*/
|
||||||
|
it('任何Redis错误都不应该导致服务崩溃', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||||
|
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
|
||||||
|
fc.constantFrom('set', 'get', 'del', 'exists', 'setex', 'incr'),
|
||||||
|
async (userId, apiKey, failingMethod) => {
|
||||||
|
// 清理之前的数据
|
||||||
|
memoryStore.clear();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// 模拟特定方法失败
|
||||||
|
(mockRedisService as any)[failingMethod].mockRejectedValueOnce(
|
||||||
|
new Error(`${failingMethod} failed`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行操作不应该抛出异常
|
||||||
|
await expect(service.storeApiKey(userId.trim(), apiKey)).resolves.not.toThrow();
|
||||||
|
await expect(service.getApiKey(userId.trim())).resolves.not.toThrow();
|
||||||
|
await expect(service.deleteApiKey(userId.trim())).resolves.not.toThrow();
|
||||||
|
await expect(service.hasApiKey(userId.trim())).resolves.not.toThrow();
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性测试: 任何输入都不应该导致服务崩溃
|
||||||
|
*/
|
||||||
|
it('任何输入都不应该导致服务崩溃', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
fc.string({ maxLength: 1000 }), // 任意字符串作为用户ID
|
||||||
|
fc.string({ maxLength: 1000 }), // 任意字符串作为API Key
|
||||||
|
async (userId, apiKey) => {
|
||||||
|
// 清理之前的数据
|
||||||
|
memoryStore.clear();
|
||||||
|
|
||||||
|
// 任何输入都不应该导致崩溃
|
||||||
|
await expect(service.storeApiKey(userId, apiKey)).resolves.not.toThrow();
|
||||||
|
await expect(service.getApiKey(userId)).resolves.not.toThrow();
|
||||||
|
await expect(service.updateApiKey(userId, apiKey)).resolves.not.toThrow();
|
||||||
|
await expect(service.deleteApiKey(userId)).resolves.not.toThrow();
|
||||||
|
await expect(service.hasApiKey(userId)).resolves.not.toThrow();
|
||||||
|
await expect(service.getApiKeyStats(userId)).resolves.not.toThrow();
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
* - AppLoggerService: 日志记录服务
|
* - AppLoggerService: 日志记录服务
|
||||||
* - IRedisService: Redis缓存服务
|
* - IRedisService: Redis缓存服务
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl, moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
* @since 2025-12-25
|
* @since 2025-12-25
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* - 测试ConfigManagerService的核心功能
|
* - 测试ConfigManagerService的核心功能
|
||||||
* - 包含属性测试验证配置验证正确性
|
* - 包含属性测试验证配置验证正确性
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl, moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
* @since 2025-12-25
|
* @since 2025-12-25
|
||||||
*/
|
*/
|
||||||
@@ -60,6 +60,13 @@ describe('ConfigManagerService', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// 设置测试环境变量
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.ZULIP_SERVER_URL = 'https://test-zulip.com';
|
||||||
|
process.env.ZULIP_BOT_EMAIL = 'test-bot@test.com';
|
||||||
|
process.env.ZULIP_BOT_API_KEY = 'test-api-key';
|
||||||
|
process.env.ZULIP_API_KEY_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||||
|
|
||||||
mockLogger = {
|
mockLogger = {
|
||||||
info: jest.fn(),
|
info: jest.fn(),
|
||||||
warn: jest.fn(),
|
warn: jest.fn(),
|
||||||
@@ -88,6 +95,12 @@ describe('ConfigManagerService', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
// 清理环境变量
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
delete process.env.ZULIP_SERVER_URL;
|
||||||
|
delete process.env.ZULIP_BOT_EMAIL;
|
||||||
|
delete process.env.ZULIP_BOT_API_KEY;
|
||||||
|
delete process.env.ZULIP_API_KEY_ENCRYPTION_KEY;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@@ -595,4 +608,504 @@ describe('ConfigManagerService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 补充测试用例 ====================
|
||||||
|
|
||||||
|
describe('hasMap - 检查地图是否存在', () => {
|
||||||
|
it('应该返回true当地图存在时', () => {
|
||||||
|
const exists = service.hasMap('novice_village');
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回false当地图不存在时', () => {
|
||||||
|
const exists = service.hasMap('nonexistent');
|
||||||
|
expect(exists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空字符串输入', () => {
|
||||||
|
const exists = service.hasMap('');
|
||||||
|
expect(exists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理null/undefined输入', () => {
|
||||||
|
const exists1 = service.hasMap(null as any);
|
||||||
|
const exists2 = service.hasMap(undefined as any);
|
||||||
|
expect(exists1).toBe(false);
|
||||||
|
expect(exists2).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllMapIds - 获取所有地图ID', () => {
|
||||||
|
it('应该返回所有地图ID列表', () => {
|
||||||
|
const mapIds = service.getAllMapIds();
|
||||||
|
expect(mapIds).toContain('novice_village');
|
||||||
|
expect(mapIds).toContain('tavern');
|
||||||
|
expect(mapIds.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMapConfigByStream - 根据Stream获取地图配置', () => {
|
||||||
|
it('应该返回正确的地图配置', () => {
|
||||||
|
const config = service.getMapConfigByStream('Novice Village');
|
||||||
|
expect(config).toBeDefined();
|
||||||
|
expect(config?.mapId).toBe('novice_village');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持大小写不敏感查询', () => {
|
||||||
|
const config = service.getMapConfigByStream('novice village');
|
||||||
|
expect(config).toBeDefined();
|
||||||
|
expect(config?.mapId).toBe('novice_village');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在Stream不存在时返回null', () => {
|
||||||
|
const config = service.getMapConfigByStream('nonexistent');
|
||||||
|
expect(config).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllStreams - 获取所有Stream名称', () => {
|
||||||
|
it('应该返回所有Stream名称列表', () => {
|
||||||
|
const streams = service.getAllStreams();
|
||||||
|
expect(streams).toContain('Novice Village');
|
||||||
|
expect(streams).toContain('Tavern');
|
||||||
|
expect(streams.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasStream - 检查Stream是否存在', () => {
|
||||||
|
it('应该返回true当Stream存在时', () => {
|
||||||
|
const exists = service.hasStream('Novice Village');
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持大小写不敏感查询', () => {
|
||||||
|
const exists = service.hasStream('novice village');
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回false当Stream不存在时', () => {
|
||||||
|
const exists = service.hasStream('nonexistent');
|
||||||
|
expect(exists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空字符串输入', () => {
|
||||||
|
const exists = service.hasStream('');
|
||||||
|
expect(exists).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findObjectByTopic - 根据Topic查找交互对象', () => {
|
||||||
|
it('应该找到正确的交互对象', () => {
|
||||||
|
const obj = service.findObjectByTopic('Notice Board');
|
||||||
|
expect(obj).toBeDefined();
|
||||||
|
expect(obj?.objectId).toBe('notice_board');
|
||||||
|
expect(obj?.mapId).toBe('novice_village');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持大小写不敏感查询', () => {
|
||||||
|
const obj = service.findObjectByTopic('notice board');
|
||||||
|
expect(obj).toBeDefined();
|
||||||
|
expect(obj?.objectId).toBe('notice_board');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在Topic不存在时返回null', () => {
|
||||||
|
const obj = service.findObjectByTopic('nonexistent');
|
||||||
|
expect(obj).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空字符串输入', () => {
|
||||||
|
const obj = service.findObjectByTopic('');
|
||||||
|
expect(obj).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getObjectsInMap - 获取地图中的所有交互对象', () => {
|
||||||
|
it('应该返回地图中的所有交互对象', () => {
|
||||||
|
const objects = service.getObjectsInMap('novice_village');
|
||||||
|
expect(objects.length).toBe(1);
|
||||||
|
expect(objects[0].objectId).toBe('notice_board');
|
||||||
|
expect(objects[0].mapId).toBe('novice_village');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在地图不存在时返回空数组', () => {
|
||||||
|
const objects = service.getObjectsInMap('nonexistent');
|
||||||
|
expect(objects).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConfigFilePath - 获取配置文件路径', () => {
|
||||||
|
it('应该返回正确的配置文件路径', () => {
|
||||||
|
const filePath = service.getConfigFilePath();
|
||||||
|
expect(filePath).toContain('map-config.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configFileExists - 检查配置文件是否存在', () => {
|
||||||
|
it('应该返回true当配置文件存在时', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
|
const exists = service.configFileExists();
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回false当配置文件不存在时', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
const exists = service.configFileExists();
|
||||||
|
expect(exists).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reloadConfig - 热重载配置', () => {
|
||||||
|
it('应该成功重载配置', async () => {
|
||||||
|
await expect(service.reloadConfig()).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在配置文件读取失败时抛出错误', async () => {
|
||||||
|
mockFs.readFileSync.mockImplementation(() => {
|
||||||
|
throw new Error('File read error');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.reloadConfig()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getZulipConfig - 获取Zulip配置', () => {
|
||||||
|
it('应该返回Zulip配置对象', () => {
|
||||||
|
const config = service.getZulipConfig();
|
||||||
|
expect(config).toBeDefined();
|
||||||
|
expect(config.zulipServerUrl).toBeDefined();
|
||||||
|
expect(config.websocketPort).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllMapConfigs - 获取所有地图配置', () => {
|
||||||
|
it('应该返回所有地图配置列表', () => {
|
||||||
|
const configs = service.getAllMapConfigs();
|
||||||
|
expect(configs.length).toBe(2);
|
||||||
|
expect(configs.some(c => c.mapId === 'novice_village')).toBe(true);
|
||||||
|
expect(configs.some(c => c.mapId === 'tavern')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('配置文件监听功能', () => {
|
||||||
|
let mockWatcher: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockWatcher = {
|
||||||
|
close: jest.fn(),
|
||||||
|
};
|
||||||
|
(fs.watch as jest.Mock).mockReturnValue(mockWatcher);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enableConfigWatcher - 启用配置文件监听', () => {
|
||||||
|
it('应该成功启用配置文件监听', () => {
|
||||||
|
const result = service.enableConfigWatcher();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(fs.watch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在配置文件不存在时返回false', () => {
|
||||||
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
const result = service.enableConfigWatcher();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在已启用时跳过重复启用', () => {
|
||||||
|
service.enableConfigWatcher();
|
||||||
|
(fs.watch as jest.Mock).mockClear();
|
||||||
|
|
||||||
|
const result = service.enableConfigWatcher();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(fs.watch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理fs.watch抛出的错误', () => {
|
||||||
|
(fs.watch as jest.Mock).mockImplementation(() => {
|
||||||
|
throw new Error('Watch error');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.enableConfigWatcher();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('disableConfigWatcher - 禁用配置文件监听', () => {
|
||||||
|
it('应该成功禁用配置文件监听', () => {
|
||||||
|
service.enableConfigWatcher();
|
||||||
|
service.disableConfigWatcher();
|
||||||
|
expect(mockWatcher.close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理未启用监听的情况', () => {
|
||||||
|
// 不应该抛出错误
|
||||||
|
expect(() => service.disableConfigWatcher()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isConfigWatcherEnabled - 检查监听状态', () => {
|
||||||
|
it('应该返回正确的监听状态', () => {
|
||||||
|
expect(service.isConfigWatcherEnabled()).toBe(false);
|
||||||
|
|
||||||
|
service.enableConfigWatcher();
|
||||||
|
expect(service.isConfigWatcherEnabled()).toBe(true);
|
||||||
|
|
||||||
|
service.disableConfigWatcher();
|
||||||
|
expect(service.isConfigWatcherEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFullConfiguration - 获取完整配置', () => {
|
||||||
|
it('应该返回完整的配置对象', () => {
|
||||||
|
const config = service.getFullConfiguration();
|
||||||
|
expect(config).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateConfigValue - 更新配置值', () => {
|
||||||
|
it('应该成功更新有效的配置值', () => {
|
||||||
|
// 这个测试需要模拟fullConfig存在
|
||||||
|
const result = service.updateConfigValue('message.rateLimit', 20);
|
||||||
|
// 由于测试环境中fullConfig可能未初始化,这里主要测试不抛出异常
|
||||||
|
expect(typeof result).toBe('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在配置键不存在时返回false', () => {
|
||||||
|
const result = service.updateConfigValue('nonexistent.key', 'value');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理无效的键路径', () => {
|
||||||
|
const result = service.updateConfigValue('', 'value');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportMapConfig - 导出地图配置', () => {
|
||||||
|
it('应该成功导出配置到文件', () => {
|
||||||
|
const result = service.exportMapConfig();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockFs.writeFileSync).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理文件写入错误', () => {
|
||||||
|
mockFs.writeFileSync.mockImplementation(() => {
|
||||||
|
throw new Error('Write error');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.exportMapConfig();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持自定义文件路径', () => {
|
||||||
|
const customPath = '/custom/path/config.json';
|
||||||
|
const result = service.exportMapConfig(customPath);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
||||||
|
customPath,
|
||||||
|
expect.any(String),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('错误处理测试', () => {
|
||||||
|
it('应该处理JSON解析错误', async () => {
|
||||||
|
mockFs.readFileSync.mockReturnValue('invalid json');
|
||||||
|
|
||||||
|
await expect(service.loadMapConfig()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理文件系统错误', async () => {
|
||||||
|
mockFs.readFileSync.mockImplementation(() => {
|
||||||
|
throw new Error('File system error');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.loadMapConfig()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理配置验证过程中的错误', async () => {
|
||||||
|
// 模拟验证过程中抛出异常
|
||||||
|
const originalValidateMapConfig = (service as any).validateMapConfig;
|
||||||
|
(service as any).validateMapConfig = jest.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Validation error');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.validateConfig();
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some(e => e.includes('验证过程出错'))).toBe(true);
|
||||||
|
|
||||||
|
// 恢复原方法
|
||||||
|
(service as any).validateMapConfig = originalValidateMapConfig;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('边界条件测试', () => {
|
||||||
|
it('应该处理空的地图配置', async () => {
|
||||||
|
const emptyConfig = { maps: [] };
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(emptyConfig));
|
||||||
|
|
||||||
|
await service.loadMapConfig();
|
||||||
|
|
||||||
|
const mapIds = service.getAllMapIds();
|
||||||
|
expect(mapIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理大量地图配置', async () => {
|
||||||
|
const largeConfig = {
|
||||||
|
maps: Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
mapId: `map_${i}`,
|
||||||
|
mapName: `地图${i}`,
|
||||||
|
zulipStream: `Stream${i}`,
|
||||||
|
interactionObjects: []
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
mockFs.readFileSync.mockReturnValue(JSON.stringify(largeConfig));
|
||||||
|
|
||||||
|
await service.loadMapConfig();
|
||||||
|
|
||||||
|
const mapIds = service.getAllMapIds();
|
||||||
|
expect(mapIds.length).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理极长的字符串输入', () => {
|
||||||
|
const longString = 'a'.repeat(10000);
|
||||||
|
const stream = service.getStreamByMap(longString);
|
||||||
|
expect(stream).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理特殊字符输入', () => {
|
||||||
|
const specialChars = '!@#$%^&*()[]{}|;:,.<>?';
|
||||||
|
const stream = service.getStreamByMap(specialChars);
|
||||||
|
expect(stream).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('并发操作测试', () => {
|
||||||
|
it('应该处理并发的配置查询', async () => {
|
||||||
|
const promises = Array.from({ length: 100 }, () =>
|
||||||
|
Promise.resolve(service.getStreamByMap('novice_village'))
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
results.forEach(result => {
|
||||||
|
expect(result).toBe('Novice Village');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理并发的配置重载', async () => {
|
||||||
|
const promises = Array.from({ length: 10 }, () => service.reloadConfig());
|
||||||
|
|
||||||
|
// 不应该抛出异常
|
||||||
|
await expect(Promise.all(promises)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('内存管理测试', () => {
|
||||||
|
it('应该正确清理资源', () => {
|
||||||
|
service.enableConfigWatcher();
|
||||||
|
|
||||||
|
// 模拟模块销毁
|
||||||
|
service.onModuleDestroy();
|
||||||
|
|
||||||
|
expect(service.isConfigWatcherEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('属性测试 - 配置查询一致性', () => {
|
||||||
|
/**
|
||||||
|
* 属性测试: 配置查询的一致性
|
||||||
|
* 验证双向查询的一致性(mapId <-> stream)
|
||||||
|
*/
|
||||||
|
it('mapId和stream之间的双向查询应该保持一致', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
// 从现有的mapId中选择
|
||||||
|
fc.constantFrom('novice_village', 'tavern'),
|
||||||
|
async (mapId) => {
|
||||||
|
// 通过mapId获取stream
|
||||||
|
const stream = service.getStreamByMap(mapId);
|
||||||
|
expect(stream).not.toBeNull();
|
||||||
|
|
||||||
|
// 通过stream反向获取mapId
|
||||||
|
const retrievedMapId = service.getMapIdByStream(stream!);
|
||||||
|
expect(retrievedMapId).toBe(mapId);
|
||||||
|
|
||||||
|
// 通过stream获取配置
|
||||||
|
const config = service.getMapConfigByStream(stream!);
|
||||||
|
expect(config).not.toBeNull();
|
||||||
|
expect(config!.mapId).toBe(mapId);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性测试: 交互对象查询的一致性
|
||||||
|
*/
|
||||||
|
it('交互对象的不同查询方式应该返回一致的结果', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
fc.constantFrom('novice_village', 'tavern'),
|
||||||
|
async (mapId) => {
|
||||||
|
// 获取地图中的所有对象
|
||||||
|
const objectsInMap = service.getObjectsInMap(mapId);
|
||||||
|
|
||||||
|
for (const obj of objectsInMap) {
|
||||||
|
// 通过topic查找对象
|
||||||
|
const objByTopic = service.findObjectByTopic(obj.zulipTopic);
|
||||||
|
expect(objByTopic).not.toBeNull();
|
||||||
|
expect(objByTopic!.objectId).toBe(obj.objectId);
|
||||||
|
expect(objByTopic!.mapId).toBe(mapId);
|
||||||
|
|
||||||
|
// 通过mapId和objectId获取topic
|
||||||
|
const topic = service.getTopicByObject(mapId, obj.objectId);
|
||||||
|
expect(topic).toBe(obj.zulipTopic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性测试: 配置验证的幂等性
|
||||||
|
*/
|
||||||
|
it('配置验证应该是幂等的', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
fc.record({
|
||||||
|
mapId: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||||
|
mapName: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||||
|
zulipStream: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||||
|
interactionObjects: fc.array(
|
||||||
|
fc.record({
|
||||||
|
objectId: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||||
|
objectName: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||||
|
zulipTopic: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||||
|
position: fc.record({
|
||||||
|
x: fc.integer({ min: 0, max: 10000 }),
|
||||||
|
y: fc.integer({ min: 0, max: 10000 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ maxLength: 5 }
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async (config) => {
|
||||||
|
// 多次验证同一个配置应该返回相同结果
|
||||||
|
const result1 = service.validateMapConfigDetailed(config);
|
||||||
|
const result2 = service.validateMapConfigDetailed(config);
|
||||||
|
const result3 = service.validateMapConfigDetailed(config);
|
||||||
|
|
||||||
|
expect(result1.valid).toBe(result2.valid);
|
||||||
|
expect(result2.valid).toBe(result3.valid);
|
||||||
|
expect(result1.errors).toEqual(result2.errors);
|
||||||
|
expect(result2.errors).toEqual(result3.errors);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
* 依赖模块:
|
* 依赖模块:
|
||||||
* - AppLoggerService: 日志记录服务
|
* - AppLoggerService: 日志记录服务
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl, moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
* @since 2025-12-25
|
* @since 2025-12-25
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* **Feature: zulip-integration, Property 9: 错误处理和服务降级**
|
* **Feature: zulip-integration, Property 9: 错误处理和服务降级**
|
||||||
* **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
|
* **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl, moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
* @since 2025-12-25
|
* @since 2025-12-25
|
||||||
*/
|
*/
|
||||||
@@ -570,4 +570,457 @@ describe('ErrorHandlerService', () => {
|
|||||||
);
|
);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 补充测试用例 ====================
|
||||||
|
|
||||||
|
describe('错误统计测试', () => {
|
||||||
|
it('应该正确记录和获取错误统计', async () => {
|
||||||
|
// 触发几个不同类型的错误
|
||||||
|
await service.handleZulipError({ code: 401, message: 'Unauthorized' }, 'auth');
|
||||||
|
await service.handleZulipError({ code: 429, message: 'Rate limit' }, 'rate');
|
||||||
|
await service.handleZulipError({ code: 401, message: 'Unauthorized' }, 'auth2');
|
||||||
|
|
||||||
|
const stats = service.getErrorStats();
|
||||||
|
|
||||||
|
expect(stats.serviceStatus).toBeDefined();
|
||||||
|
expect(stats.errorCounts).toBeDefined();
|
||||||
|
expect(stats.recentErrors).toBeDefined();
|
||||||
|
|
||||||
|
// 应该有认证错误和频率限制错误的记录
|
||||||
|
expect(Object.keys(stats.errorCounts).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能够重置错误统计', async () => {
|
||||||
|
// 先产生一些错误
|
||||||
|
await service.handleZulipError({ code: 500, message: 'Server error' }, 'test');
|
||||||
|
|
||||||
|
service.resetErrorStats();
|
||||||
|
|
||||||
|
const stats = service.getErrorStats();
|
||||||
|
expect(Object.keys(stats.errorCounts)).toHaveLength(0);
|
||||||
|
expect(Object.keys(stats.recentErrors)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('服务健康检查测试', () => {
|
||||||
|
it('应该返回完整的健康状态信息', async () => {
|
||||||
|
const health = await service.checkServiceHealth();
|
||||||
|
|
||||||
|
expect(health.status).toBeDefined();
|
||||||
|
expect(health.details).toBeDefined();
|
||||||
|
expect(health.details.serviceStatus).toBeDefined();
|
||||||
|
expect(health.details.errorCounts).toBeDefined();
|
||||||
|
expect(health.details.lastErrors).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在降级模式下返回正确状态', async () => {
|
||||||
|
await service.enableDegradedMode();
|
||||||
|
|
||||||
|
const health = await service.checkServiceHealth();
|
||||||
|
|
||||||
|
expect(health.status).toBe(ServiceStatus.DEGRADED);
|
||||||
|
expect(health.details.degradedModeStartTime).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('配置获取测试', () => {
|
||||||
|
it('应该返回正确的配置信息', () => {
|
||||||
|
const config = service.getConfig();
|
||||||
|
|
||||||
|
expect(config.degradedModeEnabled).toBe(true);
|
||||||
|
expect(config.autoReconnectEnabled).toBe(true);
|
||||||
|
expect(config.maxReconnectAttempts).toBe(5);
|
||||||
|
expect(config.reconnectBaseDelay).toBe(1000);
|
||||||
|
expect(config.apiTimeout).toBe(30000);
|
||||||
|
expect(config.maxRetries).toBe(3);
|
||||||
|
expect(config.maxConnections).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回正确的单个配置项', () => {
|
||||||
|
expect(service.isDegradedModeEnabled()).toBe(true);
|
||||||
|
expect(service.isAutoReconnectEnabled()).toBe(true);
|
||||||
|
expect(service.getApiTimeout()).toBe(30000);
|
||||||
|
expect(service.getMaxRetries()).toBe(3);
|
||||||
|
expect(service.getMaxReconnectAttempts()).toBe(5);
|
||||||
|
expect(service.getReconnectBaseDelay()).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回默认重试配置', () => {
|
||||||
|
const retryConfig = service.getDefaultRetryConfig();
|
||||||
|
|
||||||
|
expect(retryConfig.maxRetries).toBe(3);
|
||||||
|
expect(retryConfig.baseDelay).toBe(1000);
|
||||||
|
expect(retryConfig.maxDelay).toBe(30000);
|
||||||
|
expect(retryConfig.backoffMultiplier).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('状态检查方法测试', () => {
|
||||||
|
it('应该正确检查服务可用性', () => {
|
||||||
|
expect(service.isServiceAvailable()).toBe(true);
|
||||||
|
|
||||||
|
// 设置为不可用状态(通过私有属性)
|
||||||
|
(service as any).serviceStatus = ServiceStatus.UNAVAILABLE;
|
||||||
|
expect(service.isServiceAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确检查降级模式状态', async () => {
|
||||||
|
expect(service.isDegradedMode()).toBe(false);
|
||||||
|
|
||||||
|
await service.enableDegradedMode();
|
||||||
|
expect(service.isDegradedMode()).toBe(true);
|
||||||
|
|
||||||
|
await service.enableNormalMode();
|
||||||
|
expect(service.isDegradedMode()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('连接数管理测试', () => {
|
||||||
|
it('应该能够设置最大连接数', () => {
|
||||||
|
service.setMaxConnections(500);
|
||||||
|
expect(service.getConfig().maxConnections).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理负连接数变化', () => {
|
||||||
|
service.updateActiveConnections(100);
|
||||||
|
service.updateActiveConnections(-150); // 应该不会变成负数
|
||||||
|
|
||||||
|
// 活跃连接数不应该小于0
|
||||||
|
const loadStatus = service.getLoadStatus();
|
||||||
|
expect(loadStatus).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在连接数达到上限时限制新连接', () => {
|
||||||
|
service.setMaxConnections(100);
|
||||||
|
service.updateActiveConnections(100);
|
||||||
|
|
||||||
|
expect(service.shouldLimitNewConnections()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('带超时和重试的操作执行测试', () => {
|
||||||
|
it('应该成功执行带超时和重试的操作', async () => {
|
||||||
|
const operation = jest.fn().mockResolvedValue('success');
|
||||||
|
|
||||||
|
const result = await service.executeWithTimeoutAndRetry(
|
||||||
|
operation,
|
||||||
|
{ timeout: 1000, operation: 'test' },
|
||||||
|
{ maxRetries: 2, baseDelay: 10 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(operation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在超时后重试', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const operation = jest.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return new Promise(resolve => setTimeout(() => resolve('late'), 200));
|
||||||
|
}
|
||||||
|
return Promise.resolve('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.executeWithTimeoutAndRetry(
|
||||||
|
operation,
|
||||||
|
{ timeout: 50, operation: 'test' },
|
||||||
|
{ maxRetries: 2, baseDelay: 10 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(operation).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('重连状态管理测试', () => {
|
||||||
|
it('应该正确获取重连状态', async () => {
|
||||||
|
const reconnectCallback = jest.fn().mockImplementation(
|
||||||
|
() => new Promise(resolve => setTimeout(() => resolve(false), 100))
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.scheduleReconnect({
|
||||||
|
userId: 'user1',
|
||||||
|
reconnectCallback,
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelay: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = service.getReconnectState('user1');
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(state?.userId).toBe('user1');
|
||||||
|
expect(state?.isReconnecting).toBe(true);
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
service.cancelReconnect('user1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在重连失败达到最大次数后停止', async () => {
|
||||||
|
const reconnectCallback = jest.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
|
await service.scheduleReconnect({
|
||||||
|
userId: 'user1',
|
||||||
|
reconnectCallback,
|
||||||
|
maxAttempts: 2,
|
||||||
|
baseDelay: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待重连尝试完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// 应该尝试了最大次数
|
||||||
|
expect(reconnectCallback).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// 重连状态应该被清理
|
||||||
|
expect(service.getReconnectState('user1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理重连回调异常', async () => {
|
||||||
|
const reconnectCallback = jest.fn().mockRejectedValue(new Error('Reconnect failed'));
|
||||||
|
|
||||||
|
await service.scheduleReconnect({
|
||||||
|
userId: 'user1',
|
||||||
|
reconnectCallback,
|
||||||
|
maxAttempts: 2,
|
||||||
|
baseDelay: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待重连尝试完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// 应该尝试了最大次数
|
||||||
|
expect(reconnectCallback).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// 重连状态应该被清理
|
||||||
|
expect(service.getReconnectState('user1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在已有重连进行时跳过新的重连调度', async () => {
|
||||||
|
const reconnectCallback1 = jest.fn().mockImplementation(
|
||||||
|
() => new Promise(resolve => setTimeout(() => resolve(false), 200))
|
||||||
|
);
|
||||||
|
const reconnectCallback2 = jest.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
// 第一次调度
|
||||||
|
const scheduled1 = await service.scheduleReconnect({
|
||||||
|
userId: 'user1',
|
||||||
|
reconnectCallback: reconnectCallback1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelay: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 第二次调度(应该被跳过)
|
||||||
|
const scheduled2 = await service.scheduleReconnect({
|
||||||
|
userId: 'user1',
|
||||||
|
reconnectCallback: reconnectCallback2,
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelay: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scheduled1).toBe(true);
|
||||||
|
expect(scheduled2).toBe(false);
|
||||||
|
expect(reconnectCallback2).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
service.cancelReconnect('user1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('事件发射测试', () => {
|
||||||
|
it('应该在启用降级模式时发射事件', async () => {
|
||||||
|
const eventListener = jest.fn();
|
||||||
|
service.on('degraded_mode_enabled', eventListener);
|
||||||
|
|
||||||
|
await service.enableDegradedMode();
|
||||||
|
|
||||||
|
expect(eventListener).toHaveBeenCalledWith({
|
||||||
|
startTime: expect.any(Date),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在重连成功时发射事件', async () => {
|
||||||
|
const eventListener = jest.fn();
|
||||||
|
service.on('reconnect_success', eventListener);
|
||||||
|
|
||||||
|
const reconnectCallback = jest.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
await service.scheduleReconnect({
|
||||||
|
userId: 'user1',
|
||||||
|
reconnectCallback,
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelay: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待重连完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(eventListener).toHaveBeenCalledWith({
|
||||||
|
userId: 'user1',
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在重连失败时发射事件', async () => {
|
||||||
|
const eventListener = jest.fn();
|
||||||
|
service.on('reconnect_failed', eventListener);
|
||||||
|
|
||||||
|
const reconnectCallback = jest.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
|
await service.scheduleReconnect({
|
||||||
|
userId: 'user1',
|
||||||
|
reconnectCallback,
|
||||||
|
maxAttempts: 1,
|
||||||
|
baseDelay: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待重连完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(eventListener).toHaveBeenCalledWith({
|
||||||
|
userId: 'user1',
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('错误处理边界条件测试', () => {
|
||||||
|
it('应该处理空错误对象', async () => {
|
||||||
|
const result = await service.handleZulipError(null, 'test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.shouldRetry).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理没有code和message的错误', async () => {
|
||||||
|
const result = await service.handleZulipError({}, 'test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理错误处理过程中的异常', async () => {
|
||||||
|
// 模拟错误分类过程中的异常
|
||||||
|
const originalClassifyError = (service as any).classifyError;
|
||||||
|
(service as any).classifyError = jest.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Classification error');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.handleZulipError({ code: 500 }, 'test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('错误处理失败');
|
||||||
|
|
||||||
|
// 恢复原方法
|
||||||
|
(service as any).classifyError = originalClassifyError;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('模块销毁测试', () => {
|
||||||
|
it('应该正确清理所有资源', async () => {
|
||||||
|
// 设置一些状态
|
||||||
|
await service.enableDegradedMode();
|
||||||
|
|
||||||
|
const reconnectCallback = jest.fn().mockImplementation(
|
||||||
|
() => new Promise(resolve => setTimeout(() => resolve(false), 1000))
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.scheduleReconnect({
|
||||||
|
userId: 'user1',
|
||||||
|
reconnectCallback,
|
||||||
|
maxAttempts: 5,
|
||||||
|
baseDelay: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 销毁模块
|
||||||
|
await service.onModuleDestroy();
|
||||||
|
|
||||||
|
// 验证资源被清理
|
||||||
|
expect(service.getReconnectState('user1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('并发操作测试', () => {
|
||||||
|
it('应该处理并发的错误处理请求', async () => {
|
||||||
|
const errors = [
|
||||||
|
{ code: 401, message: 'Unauthorized' },
|
||||||
|
{ code: 429, message: 'Rate limit' },
|
||||||
|
{ code: 500, message: 'Server error' },
|
||||||
|
{ code: 'ECONNREFUSED', message: 'Connection refused' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const promises = errors.map((error, index) =>
|
||||||
|
service.handleZulipError(error, `operation${index}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
// 所有请求都应该得到处理
|
||||||
|
expect(results).toHaveLength(4);
|
||||||
|
results.forEach(result => {
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(typeof result.success).toBe('boolean');
|
||||||
|
expect(typeof result.shouldRetry).toBe('boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理并发的重连请求', async () => {
|
||||||
|
const users = ['user1', 'user2', 'user3'];
|
||||||
|
const reconnectCallback = jest.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
const promises = users.map(userId =>
|
||||||
|
service.scheduleReconnect({
|
||||||
|
userId,
|
||||||
|
reconnectCallback,
|
||||||
|
maxAttempts: 2,
|
||||||
|
baseDelay: 10,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
// 所有重连都应该被调度
|
||||||
|
expect(results.every(r => r === true)).toBe(true);
|
||||||
|
|
||||||
|
// 等待重连完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// 验证所有用户的重连状态都被清理
|
||||||
|
users.forEach(userId => {
|
||||||
|
expect(service.getReconnectState(userId)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('性能测试', () => {
|
||||||
|
it('应该能够处理大量错误而不影响性能', async () => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// 处理100个错误
|
||||||
|
const promises = Array.from({ length: 100 }, (_, i) =>
|
||||||
|
service.handleZulipError({ code: 500, message: `Error ${i}` }, `op${i}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 应该在合理时间内完成(比如1秒)
|
||||||
|
expect(elapsed).toBeLessThan(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能够处理大量连接数更新', () => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// 更新1000次连接数
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
service.updateActiveConnections(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 应该在合理时间内完成
|
||||||
|
expect(elapsed).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
* 依赖模块:
|
* 依赖模块:
|
||||||
* - AppLoggerService: 日志记录服务
|
* - AppLoggerService: 日志记录服务
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl, moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
* @since 2025-12-25
|
* @since 2025-12-25
|
||||||
*/
|
*/
|
||||||
@@ -239,8 +239,8 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
|
|||||||
this.logger.warn('处理Zulip API错误', {
|
this.logger.warn('处理Zulip API错误', {
|
||||||
operation: 'handleZulipError',
|
operation: 'handleZulipError',
|
||||||
targetOperation: operation,
|
targetOperation: operation,
|
||||||
errorMessage: error.message,
|
errorMessage: error?.message || 'Unknown error',
|
||||||
errorCode: error.code,
|
errorCode: error?.code || 'UNKNOWN',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,7 +269,7 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
|
|||||||
this.logger.error('错误处理过程中发生异常', {
|
this.logger.error('错误处理过程中发生异常', {
|
||||||
operation: 'handleZulipError',
|
operation: 'handleZulipError',
|
||||||
targetOperation: operation,
|
targetOperation: operation,
|
||||||
originalError: error.message,
|
originalError: error?.message || 'Unknown error',
|
||||||
handlingError: err.message,
|
handlingError: err.message,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}, err.stack);
|
}, err.stack);
|
||||||
@@ -438,7 +438,7 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
|
|||||||
this.logger.warn('处理连接错误', {
|
this.logger.warn('处理连接错误', {
|
||||||
operation: 'handleConnectionError',
|
operation: 'handleConnectionError',
|
||||||
connectionType,
|
connectionType,
|
||||||
errorMessage: error.message,
|
errorMessage: error?.message || 'Unknown connection error',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -632,16 +632,16 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
|
|||||||
case ErrorType.ZULIP_API_ERROR:
|
case ErrorType.ZULIP_API_ERROR:
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
shouldRetry: error.code >= 500, // 服务器错误可以重试
|
shouldRetry: error?.code >= 500, // 服务器错误可以重试
|
||||||
retryAfter: 2000,
|
retryAfter: 2000,
|
||||||
message: `Zulip API错误: ${error.message}`,
|
message: `Zulip API错误: ${error?.message || 'Unknown API error'}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
shouldRetry: false,
|
shouldRetry: false,
|
||||||
message: `未知错误: ${error.message}`,
|
message: `未知错误: ${error?.message || 'Unknown error'}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* **Feature: zulip-integration, Property 11: 系统监控和告警**
|
* **Feature: zulip-integration, Property 11: 系统监控和告警**
|
||||||
* **Validates: Requirements 9.4**
|
* **Validates: Requirements 9.4**
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
* @since 2025-12-25
|
* @since 2025-12-25
|
||||||
*/
|
*/
|
||||||
@@ -730,4 +730,362 @@ describe('MonitoringService', () => {
|
|||||||
);
|
);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 补充测试用例 ====================
|
||||||
|
|
||||||
|
describe('边界条件和错误处理测试', () => {
|
||||||
|
it('应该正确处理活跃连接数不会变成负数', () => {
|
||||||
|
// 先断开一个不存在的连接
|
||||||
|
service.logConnection({
|
||||||
|
socketId: 'socket1',
|
||||||
|
eventType: ConnectionEventType.DISCONNECTED,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = service.getStats();
|
||||||
|
expect(stats.connections.active).toBe(0); // 不应该是负数
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理空的最近告警列表', () => {
|
||||||
|
const alerts = service.getRecentAlerts(5);
|
||||||
|
expect(alerts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理超出限制的最近告警请求', () => {
|
||||||
|
// 添加一些告警
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
service.sendAlert({
|
||||||
|
id: `alert-${i}`,
|
||||||
|
level: AlertLevel.INFO,
|
||||||
|
title: `Alert ${i}`,
|
||||||
|
message: `Message ${i}`,
|
||||||
|
component: 'test',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求超过实际数量的告警
|
||||||
|
const alerts = service.getRecentAlerts(10);
|
||||||
|
expect(alerts.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理零除法情况', () => {
|
||||||
|
// 在没有任何API调用时获取统计
|
||||||
|
const stats = service.getStats();
|
||||||
|
expect(stats.apiCalls.avgResponseTime).toBe(0); // 应该是0而不是NaN
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理消息延迟统计的零除法', () => {
|
||||||
|
// 在没有任何消息时获取统计
|
||||||
|
const stats = service.getStats();
|
||||||
|
expect(stats.messages.avgLatency).toBe(0); // 应该是0而不是NaN
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理不同级别的告警日志', () => {
|
||||||
|
const logSpy = jest.spyOn(Logger.prototype, 'log');
|
||||||
|
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
|
||||||
|
const errorSpy = jest.spyOn(Logger.prototype, 'error');
|
||||||
|
|
||||||
|
// INFO级别
|
||||||
|
service.sendAlert({
|
||||||
|
id: 'info-alert',
|
||||||
|
level: AlertLevel.INFO,
|
||||||
|
title: 'Info Alert',
|
||||||
|
message: 'Info message',
|
||||||
|
component: 'test',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// WARNING级别
|
||||||
|
service.sendAlert({
|
||||||
|
id: 'warn-alert',
|
||||||
|
level: AlertLevel.WARNING,
|
||||||
|
title: 'Warning Alert',
|
||||||
|
message: 'Warning message',
|
||||||
|
component: 'test',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ERROR级别
|
||||||
|
service.sendAlert({
|
||||||
|
id: 'error-alert',
|
||||||
|
level: AlertLevel.ERROR,
|
||||||
|
title: 'Error Alert',
|
||||||
|
message: 'Error message',
|
||||||
|
component: 'test',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// CRITICAL级别
|
||||||
|
service.sendAlert({
|
||||||
|
id: 'critical-alert',
|
||||||
|
level: AlertLevel.CRITICAL,
|
||||||
|
title: 'Critical Alert',
|
||||||
|
message: 'Critical message',
|
||||||
|
component: 'test',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalled(); // INFO
|
||||||
|
expect(warnSpy).toHaveBeenCalled(); // WARNING
|
||||||
|
expect(errorSpy).toHaveBeenCalledTimes(2); // ERROR + CRITICAL
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理健康检查中的降级状态', async () => {
|
||||||
|
// 先添加一些正常连接
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
service.logConnection({
|
||||||
|
socketId: `socket-normal-${i}`,
|
||||||
|
eventType: ConnectionEventType.CONNECTED,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 然后添加一些错误来触发降级状态
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
service.logConnection({
|
||||||
|
socketId: `socket-error-${i}`,
|
||||||
|
eventType: ConnectionEventType.ERROR,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = await service.checkSystemHealth();
|
||||||
|
|
||||||
|
// 错误率应该是 5/10 = 50%,超过阈值 10%,应该是不健康状态
|
||||||
|
expect(health.components.websocket.status).toMatch(/^(degraded|unhealthy)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理API调用的降级状态', async () => {
|
||||||
|
// 先添加一些成功的API调用
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
service.logApiCall({
|
||||||
|
operation: 'test',
|
||||||
|
userId: 'user1',
|
||||||
|
result: ApiCallResult.SUCCESS,
|
||||||
|
responseTime: 100,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 然后模拟大量失败的API调用
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
service.logApiCall({
|
||||||
|
operation: 'test',
|
||||||
|
userId: 'user1',
|
||||||
|
result: ApiCallResult.FAILURE,
|
||||||
|
responseTime: 100,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = await service.checkSystemHealth();
|
||||||
|
|
||||||
|
// 错误率应该是 5/15 = 33%,超过阈值 10%,应该是不健康状态
|
||||||
|
expect(health.components.zulipApi.status).toMatch(/^(degraded|unhealthy)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理慢API调用的降级状态', async () => {
|
||||||
|
// 模拟慢API调用
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
service.logApiCall({
|
||||||
|
operation: 'test',
|
||||||
|
userId: 'user1',
|
||||||
|
result: ApiCallResult.SUCCESS,
|
||||||
|
responseTime: 15000, // 超过阈值
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = await service.checkSystemHealth();
|
||||||
|
|
||||||
|
// 应该检测到API组件降级
|
||||||
|
expect(health.components.zulipApi.status).toMatch(/^(degraded|unhealthy)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理模块生命周期', () => {
|
||||||
|
// 测试模块初始化
|
||||||
|
service.onModuleInit();
|
||||||
|
|
||||||
|
// 验证健康检查已启动(通过检查私有属性)
|
||||||
|
expect((service as any).healthCheckInterval).toBeDefined();
|
||||||
|
|
||||||
|
// 测试模块销毁
|
||||||
|
service.onModuleDestroy();
|
||||||
|
|
||||||
|
// 验证健康检查已停止
|
||||||
|
expect((service as any).healthCheckInterval).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理最近日志的大小限制', () => {
|
||||||
|
const maxLogs = (service as any).maxRecentLogs;
|
||||||
|
|
||||||
|
// 添加超过限制的API调用日志
|
||||||
|
for (let i = 0; i < maxLogs + 10; i++) {
|
||||||
|
service.logApiCall({
|
||||||
|
operation: `test-${i}`,
|
||||||
|
userId: 'user1',
|
||||||
|
result: ApiCallResult.SUCCESS,
|
||||||
|
responseTime: 100,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证最近日志数量不超过限制
|
||||||
|
const recentLogs = (service as any).recentApiCalls;
|
||||||
|
expect(recentLogs.length).toBeLessThanOrEqual(maxLogs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理最近告警的大小限制', () => {
|
||||||
|
const maxLogs = (service as any).maxRecentLogs;
|
||||||
|
|
||||||
|
// 添加超过限制的告警
|
||||||
|
for (let i = 0; i < maxLogs + 10; i++) {
|
||||||
|
service.sendAlert({
|
||||||
|
id: `alert-${i}`,
|
||||||
|
level: AlertLevel.INFO,
|
||||||
|
title: `Alert ${i}`,
|
||||||
|
message: `Message ${i}`,
|
||||||
|
component: 'test',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证最近告警数量不超过限制
|
||||||
|
const recentAlerts = (service as any).recentAlerts;
|
||||||
|
expect(recentAlerts.length).toBeLessThanOrEqual(maxLogs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理消息转发错误统计', () => {
|
||||||
|
service.logMessageForward({
|
||||||
|
fromUserId: 'user1',
|
||||||
|
toUserIds: ['user2'],
|
||||||
|
stream: 'test-stream',
|
||||||
|
topic: 'test-topic',
|
||||||
|
direction: 'upstream',
|
||||||
|
success: false, // 失败的消息
|
||||||
|
latency: 100,
|
||||||
|
error: 'Transfer failed',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = service.getStats();
|
||||||
|
expect(stats.messages.errors).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理带有元数据的连接日志', () => {
|
||||||
|
const eventHandler = jest.fn();
|
||||||
|
service.on('connection_event', eventHandler);
|
||||||
|
|
||||||
|
service.logConnection({
|
||||||
|
socketId: 'socket1',
|
||||||
|
userId: 'user1',
|
||||||
|
eventType: ConnectionEventType.CONNECTED,
|
||||||
|
duration: 1000,
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: { userAgent: 'test-agent', ip: '127.0.0.1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eventHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: { userAgent: 'test-agent', ip: '127.0.0.1' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
service.removeListener('connection_event', eventHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理带有元数据的API调用日志', () => {
|
||||||
|
const eventHandler = jest.fn();
|
||||||
|
service.on('api_call', eventHandler);
|
||||||
|
|
||||||
|
service.logApiCall({
|
||||||
|
operation: 'sendMessage',
|
||||||
|
userId: 'user1',
|
||||||
|
result: ApiCallResult.SUCCESS,
|
||||||
|
responseTime: 100,
|
||||||
|
statusCode: 200,
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: { endpoint: '/api/messages', method: 'POST' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eventHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: { endpoint: '/api/messages', method: 'POST' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
service.removeListener('api_call', eventHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理带有消息ID的消息转发日志', () => {
|
||||||
|
const eventHandler = jest.fn();
|
||||||
|
service.on('message_forward', eventHandler);
|
||||||
|
|
||||||
|
service.logMessageForward({
|
||||||
|
messageId: 12345,
|
||||||
|
fromUserId: 'user1',
|
||||||
|
toUserIds: ['user2', 'user3'],
|
||||||
|
stream: 'test-stream',
|
||||||
|
topic: 'test-topic',
|
||||||
|
direction: 'downstream',
|
||||||
|
success: true,
|
||||||
|
latency: 50,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eventHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
messageId: 12345,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
service.removeListener('message_forward', eventHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理带有详情的操作确认', () => {
|
||||||
|
const eventHandler = jest.fn();
|
||||||
|
service.on('operation_confirmed', eventHandler);
|
||||||
|
|
||||||
|
service.confirmOperation({
|
||||||
|
operationId: 'op123',
|
||||||
|
operation: 'sendMessage',
|
||||||
|
userId: 'user1',
|
||||||
|
success: true,
|
||||||
|
timestamp: new Date(),
|
||||||
|
details: { messageId: 456, recipients: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eventHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
details: { messageId: 456, recipients: 3 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
service.removeListener('operation_confirmed', eventHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理带有元数据的告警', () => {
|
||||||
|
const eventHandler = jest.fn();
|
||||||
|
service.on('alert', eventHandler);
|
||||||
|
|
||||||
|
service.sendAlert({
|
||||||
|
id: 'alert123',
|
||||||
|
level: AlertLevel.WARNING,
|
||||||
|
title: 'Test Alert',
|
||||||
|
message: 'Test message',
|
||||||
|
component: 'test-component',
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: { threshold: 5000, actual: 7000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eventHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: { threshold: 5000, actual: 7000 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
service.removeListener('alert', eventHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
388
src/core/zulip/services/user_management.service.spec.ts
Normal file
388
src/core/zulip/services/user_management.service.spec.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
/**
|
||||||
|
* Zulip用户管理服务测试
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 测试UserManagementService的核心功能
|
||||||
|
* - 测试用户查询和验证逻辑
|
||||||
|
* - 测试错误处理和边界情况
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-06
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UserManagementService, UserQueryRequest, UserValidationRequest } from './user_management.service';
|
||||||
|
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
|
||||||
|
|
||||||
|
// 模拟fetch
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
|
||||||
|
describe('UserManagementService', () => {
|
||||||
|
let service: UserManagementService;
|
||||||
|
let mockConfigService: jest.Mocked<IZulipConfigService>;
|
||||||
|
let mockFetch: jest.MockedFunction<typeof fetch>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// 重置fetch模拟
|
||||||
|
mockFetch = fetch as jest.MockedFunction<typeof fetch>;
|
||||||
|
mockFetch.mockClear();
|
||||||
|
|
||||||
|
// 创建模拟的配置服务
|
||||||
|
mockConfigService = {
|
||||||
|
getZulipConfig: jest.fn().mockReturnValue({
|
||||||
|
zulipServerUrl: 'https://test.zulip.com',
|
||||||
|
zulipBotEmail: 'bot@test.com',
|
||||||
|
zulipBotApiKey: 'test-api-key',
|
||||||
|
}),
|
||||||
|
getMapIdByStream: jest.fn(),
|
||||||
|
getStreamByMap: jest.fn(),
|
||||||
|
getMapConfig: jest.fn(),
|
||||||
|
hasMap: jest.fn(),
|
||||||
|
getAllMapIds: jest.fn(),
|
||||||
|
getMapConfigByStream: jest.fn(),
|
||||||
|
getAllStreams: jest.fn(),
|
||||||
|
hasStream: jest.fn(),
|
||||||
|
findObjectByTopic: jest.fn(),
|
||||||
|
getObjectsInMap: jest.fn(),
|
||||||
|
getTopicByObject: jest.fn(),
|
||||||
|
findNearbyObject: jest.fn(),
|
||||||
|
reloadConfig: jest.fn(),
|
||||||
|
validateConfig: jest.fn(),
|
||||||
|
getAllMapConfigs: jest.fn(),
|
||||||
|
getConfigStats: jest.fn(),
|
||||||
|
getConfigFilePath: jest.fn(),
|
||||||
|
configFileExists: jest.fn(),
|
||||||
|
enableConfigWatcher: jest.fn(),
|
||||||
|
disableConfigWatcher: jest.fn(),
|
||||||
|
isConfigWatcherEnabled: jest.fn(),
|
||||||
|
getFullConfiguration: jest.fn(),
|
||||||
|
updateConfigValue: jest.fn(),
|
||||||
|
exportMapConfig: jest.fn(),
|
||||||
|
} as jest.Mocked<IZulipConfigService>;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
UserManagementService,
|
||||||
|
{
|
||||||
|
provide: 'ZULIP_CONFIG_SERVICE',
|
||||||
|
useValue: mockConfigService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<UserManagementService>(UserManagementService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确初始化服务', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkUserExists - 检查用户是否存在', () => {
|
||||||
|
it('应该正确检查存在的用户', async () => {
|
||||||
|
// 模拟API响应
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
user_id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
full_name: 'Test User',
|
||||||
|
is_active: true,
|
||||||
|
is_admin: false,
|
||||||
|
is_bot: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await service.checkUserExists('test@example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://test.zulip.com/api/v1/users',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'Authorization': expect.stringContaining('Basic'),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确检查不存在的用户', async () => {
|
||||||
|
// 模拟API响应
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
user_id: 1,
|
||||||
|
email: 'other@example.com',
|
||||||
|
full_name: 'Other User',
|
||||||
|
is_active: true,
|
||||||
|
is_admin: false,
|
||||||
|
is_bot: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await service.checkUserExists('test@example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理无效邮箱', async () => {
|
||||||
|
const result = await service.checkUserExists('invalid-email');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理API调用失败', async () => {
|
||||||
|
// 模拟API失败
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await service.checkUserExists('test@example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理网络异常', async () => {
|
||||||
|
// 模拟网络异常
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
const result = await service.checkUserExists('test@example.com');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUserInfo - 获取用户信息', () => {
|
||||||
|
it('应该成功获取用户信息', async () => {
|
||||||
|
const request: UserQueryRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 模拟API响应
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
user_id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
full_name: 'Test User',
|
||||||
|
is_active: true,
|
||||||
|
is_admin: false,
|
||||||
|
is_bot: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await service.getUserInfo(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.userId).toBe(1);
|
||||||
|
expect(result.email).toBe('test@example.com');
|
||||||
|
expect(result.fullName).toBe('Test User');
|
||||||
|
expect(result.isActive).toBe(true);
|
||||||
|
expect(result.isAdmin).toBe(false);
|
||||||
|
expect(result.isBot).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理用户不存在的情况', async () => {
|
||||||
|
const request: UserQueryRequest = {
|
||||||
|
email: 'nonexistent@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 模拟API响应
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
members: [],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await service.getUserInfo(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('用户不存在');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝无效邮箱', async () => {
|
||||||
|
const request: UserQueryRequest = {
|
||||||
|
email: 'invalid-email',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.getUserInfo(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('邮箱格式无效');
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateUserCredentials - 验证用户凭据', () => {
|
||||||
|
it('应该成功验证有效的API Key', async () => {
|
||||||
|
const request: UserValidationRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
apiKey: 'valid-api-key',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 模拟API Key验证响应(第一个调用)
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
// 模拟用户列表API响应(第二个调用)
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
user_id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
full_name: 'Test User',
|
||||||
|
is_active: true,
|
||||||
|
is_admin: false,
|
||||||
|
is_bot: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await service.validateUserCredentials(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.userId).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝无效的API Key', async () => {
|
||||||
|
const request: UserValidationRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
apiKey: 'invalid-api-key',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 模拟API Key验证失败(第一个调用)
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: 'Unauthorized',
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await service.validateUserCredentials(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝空的API Key', async () => {
|
||||||
|
const request: UserValidationRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
apiKey: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.validateUserCredentials(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('API Key不能为空');
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝无效邮箱', async () => {
|
||||||
|
const request: UserValidationRequest = {
|
||||||
|
email: 'invalid-email',
|
||||||
|
apiKey: 'some-api-key',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.validateUserCredentials(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('邮箱格式无效');
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllUsers - 获取所有用户', () => {
|
||||||
|
it('应该成功获取用户列表', async () => {
|
||||||
|
// 模拟API响应
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
user_id: 1,
|
||||||
|
email: 'user1@example.com',
|
||||||
|
full_name: 'User One',
|
||||||
|
is_active: true,
|
||||||
|
is_admin: false,
|
||||||
|
is_bot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: 2,
|
||||||
|
email: 'user2@example.com',
|
||||||
|
full_name: 'User Two',
|
||||||
|
is_active: true,
|
||||||
|
is_admin: true,
|
||||||
|
is_bot: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await service.getAllUsers();
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.users).toHaveLength(2);
|
||||||
|
expect(result.totalCount).toBe(2);
|
||||||
|
expect(result.users?.[0]).toEqual({
|
||||||
|
userId: 1,
|
||||||
|
email: 'user1@example.com',
|
||||||
|
fullName: 'User One',
|
||||||
|
isActive: true,
|
||||||
|
isAdmin: false,
|
||||||
|
isBot: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理空用户列表', async () => {
|
||||||
|
// 模拟API响应
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
members: [],
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await service.getAllUsers();
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.users).toHaveLength(0);
|
||||||
|
expect(result.totalCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理API调用失败', async () => {
|
||||||
|
// 模拟API失败
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
statusText: 'Forbidden',
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await service.getAllUsers();
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('API调用失败');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
539
src/core/zulip/services/user_management.service.ts
Normal file
539
src/core/zulip/services/user_management.service.ts
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
/**
|
||||||
|
* Zulip用户管理服务
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 查询和验证Zulip用户信息
|
||||||
|
* - 检查用户是否存在
|
||||||
|
* - 获取用户详细信息
|
||||||
|
* - 验证用户凭据和权限
|
||||||
|
*
|
||||||
|
* 主要方法:
|
||||||
|
* - checkUserExists(): 检查用户是否存在
|
||||||
|
* - getUserInfo(): 获取用户详细信息
|
||||||
|
* - validateUserCredentials(): 验证用户凭据
|
||||||
|
* - getAllUsers(): 获取所有用户列表
|
||||||
|
*
|
||||||
|
* 使用场景:
|
||||||
|
* - 用户登录时验证用户存在性
|
||||||
|
* - 获取用户基本信息
|
||||||
|
* - 验证用户权限和状态
|
||||||
|
* - 管理员查看用户列表
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-06
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
|
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip API响应接口
|
||||||
|
*/
|
||||||
|
interface ZulipApiResponse {
|
||||||
|
result?: 'success' | 'error';
|
||||||
|
msg?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户信息接口
|
||||||
|
*/
|
||||||
|
interface ZulipUser {
|
||||||
|
user_id: number;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_owner: boolean;
|
||||||
|
is_bot: boolean;
|
||||||
|
date_joined: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表响应接口
|
||||||
|
*/
|
||||||
|
interface ZulipUsersResponse extends ZulipApiResponse {
|
||||||
|
members?: ZulipUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户查询请求接口
|
||||||
|
*/
|
||||||
|
export interface UserQueryRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户信息响应接口
|
||||||
|
*/
|
||||||
|
export interface UserInfoResponse {
|
||||||
|
success: boolean;
|
||||||
|
userId?: number;
|
||||||
|
email?: string;
|
||||||
|
fullName?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
isBot?: boolean;
|
||||||
|
dateJoined?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户验证请求接口
|
||||||
|
*/
|
||||||
|
export interface UserValidationRequest {
|
||||||
|
email: string;
|
||||||
|
apiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户验证响应接口
|
||||||
|
*/
|
||||||
|
export interface UserValidationResponse {
|
||||||
|
success: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
|
userId?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表响应接口
|
||||||
|
*/
|
||||||
|
export interface UsersListResponse {
|
||||||
|
success: boolean;
|
||||||
|
users?: Array<{
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isBot: boolean;
|
||||||
|
}>;
|
||||||
|
totalCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip用户管理服务类
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 查询和验证Zulip用户信息
|
||||||
|
* - 检查用户是否存在于Zulip服务器
|
||||||
|
* - 获取用户详细信息和权限状态
|
||||||
|
* - 提供用户管理相关的API接口
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UserManagementService {
|
||||||
|
private readonly logger = new Logger(UserManagementService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject('ZULIP_CONFIG_SERVICE')
|
||||||
|
private readonly configService: IZulipConfigService,
|
||||||
|
) {
|
||||||
|
this.logger.log('UserManagementService初始化完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否存在
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 通过Zulip API检查指定邮箱的用户是否存在
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 获取所有用户列表
|
||||||
|
* 2. 在列表中查找指定邮箱
|
||||||
|
* 3. 返回用户存在性结果
|
||||||
|
*
|
||||||
|
* @param email 用户邮箱
|
||||||
|
* @returns Promise<boolean> 是否存在
|
||||||
|
*/
|
||||||
|
async checkUserExists(email: string): Promise<boolean> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
this.logger.log('开始检查用户是否存在', {
|
||||||
|
operation: 'checkUserExists',
|
||||||
|
email,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 验证邮箱格式
|
||||||
|
if (!email || !this.isValidEmail(email)) {
|
||||||
|
this.logger.warn('邮箱格式无效', {
|
||||||
|
operation: 'checkUserExists',
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取用户列表
|
||||||
|
const usersResult = await this.getAllUsers();
|
||||||
|
if (!usersResult.success) {
|
||||||
|
this.logger.warn('获取用户列表失败', {
|
||||||
|
operation: 'checkUserExists',
|
||||||
|
email,
|
||||||
|
error: usersResult.error,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查用户是否存在
|
||||||
|
const userExists = usersResult.users?.some(user =>
|
||||||
|
user.email.toLowerCase() === email.toLowerCase()
|
||||||
|
) || false;
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.log('用户存在性检查完成', {
|
||||||
|
operation: 'checkUserExists',
|
||||||
|
email,
|
||||||
|
exists: userExists,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return userExists;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.error('检查用户存在性失败', {
|
||||||
|
operation: 'checkUserExists',
|
||||||
|
email,
|
||||||
|
error: err.message,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户详细信息
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 根据邮箱获取用户的详细信息
|
||||||
|
*
|
||||||
|
* @param request 用户查询请求
|
||||||
|
* @returns Promise<UserInfoResponse>
|
||||||
|
*/
|
||||||
|
async getUserInfo(request: UserQueryRequest): Promise<UserInfoResponse> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
this.logger.log('开始获取用户信息', {
|
||||||
|
operation: 'getUserInfo',
|
||||||
|
email: request.email,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 验证请求参数
|
||||||
|
if (!request.email || !this.isValidEmail(request.email)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '邮箱格式无效',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取用户列表
|
||||||
|
const usersResult = await this.getAllUsers();
|
||||||
|
if (!usersResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: usersResult.error || '获取用户列表失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 查找指定用户
|
||||||
|
const user = usersResult.users?.find(u =>
|
||||||
|
u.email.toLowerCase() === request.email.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '用户不存在',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.log('用户信息获取完成', {
|
||||||
|
operation: 'getUserInfo',
|
||||||
|
email: request.email,
|
||||||
|
userId: user.userId,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
userId: user.userId,
|
||||||
|
email: user.email,
|
||||||
|
fullName: user.fullName,
|
||||||
|
isActive: user.isActive,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
isBot: user.isBot,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.error('获取用户信息失败', {
|
||||||
|
operation: 'getUserInfo',
|
||||||
|
email: request.email,
|
||||||
|
error: err.message,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '系统错误,请稍后重试',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证用户凭据
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 验证用户的API Key是否有效
|
||||||
|
*
|
||||||
|
* @param request 用户验证请求
|
||||||
|
* @returns Promise<UserValidationResponse>
|
||||||
|
*/
|
||||||
|
async validateUserCredentials(request: UserValidationRequest): Promise<UserValidationResponse> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
this.logger.log('开始验证用户凭据', {
|
||||||
|
operation: 'validateUserCredentials',
|
||||||
|
email: request.email,
|
||||||
|
hasApiKey: !!request.apiKey,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 验证请求参数
|
||||||
|
if (!request.email || !this.isValidEmail(request.email)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '邮箱格式无效',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.apiKey) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'API Key不能为空',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 使用用户的API Key测试连接
|
||||||
|
const isValid = await this.testUserApiKey(request.email, request.apiKey);
|
||||||
|
|
||||||
|
// 3. 如果API Key有效,获取用户ID
|
||||||
|
let userId = undefined;
|
||||||
|
if (isValid) {
|
||||||
|
const userInfo = await this.getUserInfo({ email: request.email });
|
||||||
|
if (userInfo.success) {
|
||||||
|
userId = userInfo.userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.log('用户凭据验证完成', {
|
||||||
|
operation: 'validateUserCredentials',
|
||||||
|
email: request.email,
|
||||||
|
isValid,
|
||||||
|
userId,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
isValid,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.error('验证用户凭据失败', {
|
||||||
|
operation: 'validateUserCredentials',
|
||||||
|
email: request.email,
|
||||||
|
error: err.message,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '系统错误,请稍后重试',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有用户列表
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 从Zulip服务器获取所有用户的列表
|
||||||
|
*
|
||||||
|
* @returns Promise<UsersListResponse>
|
||||||
|
*/
|
||||||
|
async getAllUsers(): Promise<UsersListResponse> {
|
||||||
|
this.logger.debug('开始获取用户列表', {
|
||||||
|
operation: 'getAllUsers',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取Zulip配置
|
||||||
|
const config = this.configService.getZulipConfig();
|
||||||
|
|
||||||
|
// 构建API URL
|
||||||
|
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
|
||||||
|
|
||||||
|
// 构建认证头
|
||||||
|
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.warn('获取用户列表失败', {
|
||||||
|
operation: 'getAllUsers',
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `API调用失败: ${response.status} ${response.statusText}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ZulipUsersResponse = await response.json();
|
||||||
|
|
||||||
|
// 转换数据格式
|
||||||
|
const users = data.members?.map(user => ({
|
||||||
|
userId: user.user_id,
|
||||||
|
email: user.email,
|
||||||
|
fullName: user.full_name,
|
||||||
|
isActive: user.is_active,
|
||||||
|
isAdmin: user.is_admin,
|
||||||
|
isBot: user.is_bot,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
this.logger.debug('用户列表获取完成', {
|
||||||
|
operation: 'getAllUsers',
|
||||||
|
userCount: users.length,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
users,
|
||||||
|
totalCount: users.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('获取用户列表异常', {
|
||||||
|
operation: 'getAllUsers',
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '系统错误,请稍后重试',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试用户API Key是否有效
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 使用用户的API Key测试是否能够成功调用Zulip API
|
||||||
|
*
|
||||||
|
* @param email 用户邮箱
|
||||||
|
* @param apiKey 用户API Key
|
||||||
|
* @returns Promise<boolean> 是否有效
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async testUserApiKey(email: string, apiKey: string): Promise<boolean> {
|
||||||
|
this.logger.debug('测试用户API Key', {
|
||||||
|
operation: 'testUserApiKey',
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取Zulip配置
|
||||||
|
const config = this.configService.getZulipConfig();
|
||||||
|
|
||||||
|
// 构建API URL - 使用获取用户自己信息的接口
|
||||||
|
const apiUrl = `${config.zulipServerUrl}/api/v1/users/me`;
|
||||||
|
|
||||||
|
// 使用用户的API Key构建认证头
|
||||||
|
const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValid = response.ok;
|
||||||
|
|
||||||
|
this.logger.debug('API Key测试完成', {
|
||||||
|
operation: 'testUserApiKey',
|
||||||
|
email,
|
||||||
|
isValid,
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('测试API Key异常', {
|
||||||
|
operation: 'testUserApiKey',
|
||||||
|
email,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证邮箱格式
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @returns boolean 是否有效
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private isValidEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/core/zulip/services/user_registration.service.spec.ts
Normal file
188
src/core/zulip/services/user_registration.service.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* Zulip用户注册服务测试
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 测试UserRegistrationService的核心功能
|
||||||
|
* - 测试用户注册流程和验证逻辑
|
||||||
|
* - 测试错误处理和边界情况
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-06
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UserRegistrationService, UserRegistrationRequest } from './user_registration.service';
|
||||||
|
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
|
||||||
|
|
||||||
|
describe('UserRegistrationService', () => {
|
||||||
|
let service: UserRegistrationService;
|
||||||
|
let mockConfigService: jest.Mocked<IZulipConfigService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// 创建模拟的配置服务
|
||||||
|
mockConfigService = {
|
||||||
|
getZulipConfig: jest.fn().mockReturnValue({
|
||||||
|
zulipServerUrl: 'https://test.zulip.com',
|
||||||
|
zulipBotEmail: 'bot@test.com',
|
||||||
|
zulipBotApiKey: 'test-api-key',
|
||||||
|
}),
|
||||||
|
getMapIdByStream: jest.fn(),
|
||||||
|
getStreamByMap: jest.fn(),
|
||||||
|
getMapConfig: jest.fn(),
|
||||||
|
hasMap: jest.fn(),
|
||||||
|
getAllMapIds: jest.fn(),
|
||||||
|
getMapConfigByStream: jest.fn(),
|
||||||
|
getAllStreams: jest.fn(),
|
||||||
|
hasStream: jest.fn(),
|
||||||
|
findObjectByTopic: jest.fn(),
|
||||||
|
getObjectsInMap: jest.fn(),
|
||||||
|
getTopicByObject: jest.fn(),
|
||||||
|
findNearbyObject: jest.fn(),
|
||||||
|
reloadConfig: jest.fn(),
|
||||||
|
validateConfig: jest.fn(),
|
||||||
|
getAllMapConfigs: jest.fn(),
|
||||||
|
getConfigStats: jest.fn(),
|
||||||
|
getConfigFilePath: jest.fn(),
|
||||||
|
configFileExists: jest.fn(),
|
||||||
|
enableConfigWatcher: jest.fn(),
|
||||||
|
disableConfigWatcher: jest.fn(),
|
||||||
|
isConfigWatcherEnabled: jest.fn(),
|
||||||
|
getFullConfiguration: jest.fn(),
|
||||||
|
updateConfigValue: jest.fn(),
|
||||||
|
exportMapConfig: jest.fn(),
|
||||||
|
} as jest.Mocked<IZulipConfigService>;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
UserRegistrationService,
|
||||||
|
{
|
||||||
|
provide: 'ZULIP_CONFIG_SERVICE',
|
||||||
|
useValue: mockConfigService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<UserRegistrationService>(UserRegistrationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确初始化服务', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerUser - 用户注册', () => {
|
||||||
|
it('应该成功注册有效用户', async () => {
|
||||||
|
const request: UserRegistrationRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
fullName: 'Test User',
|
||||||
|
password: 'password123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.registerUser(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.email).toBe(request.email);
|
||||||
|
expect(result.userId).toBeDefined();
|
||||||
|
expect(result.apiKey).toBeDefined();
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝无效邮箱', async () => {
|
||||||
|
const request: UserRegistrationRequest = {
|
||||||
|
email: 'invalid-email',
|
||||||
|
fullName: 'Test User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.registerUser(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('邮箱格式无效');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝空邮箱', async () => {
|
||||||
|
const request: UserRegistrationRequest = {
|
||||||
|
email: '',
|
||||||
|
fullName: 'Test User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.registerUser(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('邮箱不能为空');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝空用户名', async () => {
|
||||||
|
const request: UserRegistrationRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
fullName: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.registerUser(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('用户全名不能为空');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝过短的用户名', async () => {
|
||||||
|
const request: UserRegistrationRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
fullName: 'A',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.registerUser(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('用户全名至少需要2个字符');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝过长的用户名', async () => {
|
||||||
|
const request: UserRegistrationRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
fullName: 'A'.repeat(101), // 101个字符
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.registerUser(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('用户全名不能超过100个字符');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝过短的密码', async () => {
|
||||||
|
const request: UserRegistrationRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
fullName: 'Test User',
|
||||||
|
password: '123', // 只有3个字符
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.registerUser(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('密码至少需要6个字符');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该接受没有密码的注册', async () => {
|
||||||
|
const request: UserRegistrationRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
fullName: 'Test User',
|
||||||
|
// 不提供密码
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.registerUser(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该拒绝过长的短名称', async () => {
|
||||||
|
const request: UserRegistrationRequest = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
fullName: 'Test User',
|
||||||
|
shortName: 'A'.repeat(51), // 51个字符
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.registerUser(request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('短名称不能超过50个字符');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
531
src/core/zulip/services/user_registration.service.ts
Normal file
531
src/core/zulip/services/user_registration.service.ts
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
/**
|
||||||
|
* Zulip用户管理服务
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 查询和验证Zulip用户信息
|
||||||
|
* - 检查用户是否存在
|
||||||
|
* - 获取用户详细信息
|
||||||
|
* - 管理用户API Key(如果有权限)
|
||||||
|
*
|
||||||
|
* 主要方法:
|
||||||
|
* - checkUserExists(): 检查用户是否存在
|
||||||
|
* - getUserInfo(): 获取用户详细信息
|
||||||
|
* - validateUserCredentials(): 验证用户凭据
|
||||||
|
* - getUserApiKey(): 获取用户API Key(需要管理员权限)
|
||||||
|
*
|
||||||
|
* 使用场景:
|
||||||
|
* - 用户登录时验证用户存在性
|
||||||
|
* - 获取用户基本信息
|
||||||
|
* - 验证用户权限和状态
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-06
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
|
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip API响应接口
|
||||||
|
*/
|
||||||
|
interface ZulipApiResponse {
|
||||||
|
result?: 'success' | 'error';
|
||||||
|
msg?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表响应接口
|
||||||
|
*/
|
||||||
|
interface ZulipUsersResponse extends ZulipApiResponse {
|
||||||
|
members?: Array<{
|
||||||
|
email: string;
|
||||||
|
user_id: number;
|
||||||
|
full_name: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用户响应接口
|
||||||
|
*/
|
||||||
|
interface ZulipCreateUserResponse extends ZulipApiResponse {
|
||||||
|
user_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key响应接口
|
||||||
|
*/
|
||||||
|
interface ZulipApiKeyResponse extends ZulipApiResponse {
|
||||||
|
api_key?: string;
|
||||||
|
}
|
||||||
|
export interface UserRegistrationRequest {
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
password?: string;
|
||||||
|
shortName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册响应接口
|
||||||
|
*/
|
||||||
|
export interface UserRegistrationResponse {
|
||||||
|
success: boolean;
|
||||||
|
userId?: number;
|
||||||
|
email?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
error?: string;
|
||||||
|
details?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip用户注册服务类
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 处理新用户在Zulip服务器上的注册
|
||||||
|
* - 验证用户信息的有效性
|
||||||
|
* - 与Zulip API交互创建用户账户
|
||||||
|
* - 管理注册流程和错误处理
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UserRegistrationService {
|
||||||
|
private readonly logger = new Logger(UserRegistrationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject('ZULIP_CONFIG_SERVICE')
|
||||||
|
private readonly configService: IZulipConfigService,
|
||||||
|
) {
|
||||||
|
this.logger.log('UserRegistrationService初始化完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册新用户到Zulip服务器
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 在Zulip服务器上创建新用户账户
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 验证用户注册信息
|
||||||
|
* 2. 检查用户是否已存在
|
||||||
|
* 3. 调用Zulip API创建用户
|
||||||
|
* 4. 获取用户API Key
|
||||||
|
* 5. 返回注册结果
|
||||||
|
*
|
||||||
|
* @param request 用户注册请求数据
|
||||||
|
* @returns Promise<UserRegistrationResponse>
|
||||||
|
*/
|
||||||
|
async registerUser(request: UserRegistrationRequest): Promise<UserRegistrationResponse> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
this.logger.log('开始注册Zulip用户', {
|
||||||
|
operation: 'registerUser',
|
||||||
|
email: request.email,
|
||||||
|
fullName: request.fullName,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 验证用户注册信息
|
||||||
|
const validationResult = this.validateUserInfo(request);
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
this.logger.warn('用户注册信息验证失败', {
|
||||||
|
operation: 'registerUser',
|
||||||
|
email: request.email,
|
||||||
|
errors: validationResult.errors,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: validationResult.errors.join(', '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 实现实际的Zulip用户注册逻辑
|
||||||
|
// 这里先返回模拟结果,后续步骤中实现真实的API调用
|
||||||
|
|
||||||
|
// 2. 检查用户是否已存在
|
||||||
|
const userExists = await this.checkUserExists(request.email);
|
||||||
|
if (userExists) {
|
||||||
|
this.logger.warn('用户注册失败:用户已存在', {
|
||||||
|
operation: 'registerUser',
|
||||||
|
email: request.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '用户已存在',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 调用Zulip API创建用户
|
||||||
|
const createResult = await this.createZulipUser(request);
|
||||||
|
if (!createResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: createResult.error || '创建用户失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 获取用户API Key(如果需要)
|
||||||
|
let apiKey = undefined;
|
||||||
|
if (createResult.userId) {
|
||||||
|
const apiKeyResult = await this.generateApiKey(createResult.userId, request.email);
|
||||||
|
if (apiKeyResult.success) {
|
||||||
|
apiKey = apiKeyResult.apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.log('Zulip用户注册完成(模拟)', {
|
||||||
|
operation: 'registerUser',
|
||||||
|
email: request.email,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
userId: createResult.userId,
|
||||||
|
email: request.email,
|
||||||
|
apiKey: apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.error('Zulip用户注册失败', {
|
||||||
|
operation: 'registerUser',
|
||||||
|
email: request.email,
|
||||||
|
error: err.message,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '注册失败,请稍后重试',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证用户注册信息
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 验证用户提供的注册信息是否有效
|
||||||
|
*
|
||||||
|
* @param request 用户注册请求
|
||||||
|
* @returns {valid: boolean, errors: string[]} 验证结果
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private validateUserInfo(request: UserRegistrationRequest): {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 验证邮箱
|
||||||
|
if (!request.email || !request.email.trim()) {
|
||||||
|
errors.push('邮箱不能为空');
|
||||||
|
} else if (!this.isValidEmail(request.email)) {
|
||||||
|
errors.push('邮箱格式无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证全名
|
||||||
|
if (!request.fullName || !request.fullName.trim()) {
|
||||||
|
errors.push('用户全名不能为空');
|
||||||
|
} else if (request.fullName.trim().length < 2) {
|
||||||
|
errors.push('用户全名至少需要2个字符');
|
||||||
|
} else if (request.fullName.trim().length > 100) {
|
||||||
|
errors.push('用户全名不能超过100个字符');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码(如果提供)
|
||||||
|
if (request.password && request.password.length < 6) {
|
||||||
|
errors.push('密码至少需要6个字符');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证短名称(如果提供)
|
||||||
|
if (request.shortName && request.shortName.trim().length > 50) {
|
||||||
|
errors.push('短名称不能超过50个字符');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证邮箱格式
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @returns boolean 是否有效
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private isValidEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否已存在
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 通过Zulip API检查指定邮箱的用户是否已存在
|
||||||
|
*
|
||||||
|
* @param email 用户邮箱
|
||||||
|
* @returns Promise<boolean> 是否存在
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async checkUserExists(email: string): Promise<boolean> {
|
||||||
|
this.logger.debug('检查用户是否存在', {
|
||||||
|
operation: 'checkUserExists',
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取Zulip配置
|
||||||
|
const config = this.configService.getZulipConfig();
|
||||||
|
|
||||||
|
// 构建API URL
|
||||||
|
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
|
||||||
|
|
||||||
|
// 构建认证头
|
||||||
|
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.warn('获取用户列表失败', {
|
||||||
|
operation: 'checkUserExists',
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
return false; // 如果API调用失败,假设用户不存在
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ZulipUsersResponse = await response.json();
|
||||||
|
|
||||||
|
// 检查用户是否在列表中
|
||||||
|
if (data.members && Array.isArray(data.members)) {
|
||||||
|
const userExists = data.members.some((user: any) =>
|
||||||
|
user.email && user.email.toLowerCase() === email.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.debug('用户存在性检查完成', {
|
||||||
|
operation: 'checkUserExists',
|
||||||
|
email,
|
||||||
|
exists: userExists,
|
||||||
|
});
|
||||||
|
|
||||||
|
return userExists;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('检查用户存在性失败', {
|
||||||
|
operation: 'checkUserExists',
|
||||||
|
email,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果检查失败,假设用户不存在,允许继续注册
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建Zulip用户
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 通过Zulip API创建新用户账户
|
||||||
|
*
|
||||||
|
* @param request 用户注册请求
|
||||||
|
* @returns Promise<{success: boolean, userId?: number, error?: string}>
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async createZulipUser(request: UserRegistrationRequest): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
userId?: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
this.logger.log('开始创建Zulip用户', {
|
||||||
|
operation: 'createZulipUser',
|
||||||
|
email: request.email,
|
||||||
|
fullName: request.fullName,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取Zulip配置
|
||||||
|
const config = this.configService.getZulipConfig();
|
||||||
|
|
||||||
|
// 构建API URL
|
||||||
|
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
|
||||||
|
|
||||||
|
// 构建认证头
|
||||||
|
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||||
|
|
||||||
|
// 构建请求体
|
||||||
|
const requestBody = new URLSearchParams();
|
||||||
|
requestBody.append('email', request.email);
|
||||||
|
requestBody.append('full_name', request.fullName);
|
||||||
|
|
||||||
|
if (request.password) {
|
||||||
|
requestBody.append('password', request.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.shortName) {
|
||||||
|
requestBody.append('short_name', request.shortName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: requestBody.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: ZulipCreateUserResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.warn('Zulip用户创建失败', {
|
||||||
|
operation: 'createZulipUser',
|
||||||
|
email: request.email,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: data.msg || data.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.msg || data.message || '创建用户失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('Zulip用户创建成功', {
|
||||||
|
operation: 'createZulipUser',
|
||||||
|
email: request.email,
|
||||||
|
userId: data.user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
userId: data.user_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('创建Zulip用户异常', {
|
||||||
|
operation: 'createZulipUser',
|
||||||
|
email: request.email,
|
||||||
|
error: err.message,
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '系统错误,请稍后重试',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为用户生成API Key
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 为新创建的用户生成API Key,用于后续的Zulip API调用
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param email 用户邮箱
|
||||||
|
* @returns Promise<{success: boolean, apiKey?: string, error?: string}>
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async generateApiKey(userId: number, email: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
apiKey?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
this.logger.log('开始生成用户API Key', {
|
||||||
|
operation: 'generateApiKey',
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取Zulip配置
|
||||||
|
const config = this.configService.getZulipConfig();
|
||||||
|
|
||||||
|
// 构建API URL
|
||||||
|
const apiUrl = `${config.zulipServerUrl}/api/v1/users/${userId}/api_key/regenerate`;
|
||||||
|
|
||||||
|
// 构建认证头
|
||||||
|
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: ZulipApiKeyResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.warn('生成API Key失败', {
|
||||||
|
operation: 'generateApiKey',
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: data.msg || data.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.msg || data.message || '生成API Key失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('API Key生成成功', {
|
||||||
|
operation: 'generateApiKey',
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
apiKey: data.api_key,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('生成API Key异常', {
|
||||||
|
operation: 'generateApiKey',
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
error: err.message,
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '系统错误,请稍后重试',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/main.ts
11
src/main.ts
@@ -40,10 +40,17 @@ async function bootstrap() {
|
|||||||
logger: ['error', 'warn', 'log'],
|
logger: ['error', 'warn', 'log'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 允许前端后台(如Vite/React)跨域访问
|
// 允许前端后台(如Vite/React)跨域访问,包括WebSocket
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: true,
|
origin: [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://localhost:5173', // Vite默认端口
|
||||||
|
'https://whaletownend.xinghangee.icu',
|
||||||
|
/^https:\/\/.*\.xinghangee\.icu$/
|
||||||
|
],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 全局启用校验管道(核心配置)
|
// 全局启用校验管道(核心配置)
|
||||||
|
|||||||
131
test_zulip.js
Normal file
131
test_zulip.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
const io = require('socket.io-client');
|
||||||
|
|
||||||
|
// 使用用户 API Key 测试 Zulip 集成
|
||||||
|
async function testWithUserApiKey() {
|
||||||
|
console.log('🚀 使用用户 API Key 测试 Zulip 集成...');
|
||||||
|
console.log('📡 用户 API Key: lCPWCPfGh7WU...pqNfGF8');
|
||||||
|
console.log('📡 Zulip 服务器: https://zulip.xinghangee.icu/');
|
||||||
|
console.log('📡 游戏服务器: https://whaletownend.xinghangee.icu/game');
|
||||||
|
|
||||||
|
const socket = io('wss://whaletownend.xinghangee.icu/game', {
|
||||||
|
transports: ['websocket', 'polling'], // WebSocket优先,polling备用
|
||||||
|
timeout: 20000,
|
||||||
|
forceNew: true,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 3,
|
||||||
|
reconnectionDelay: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
let testStep = 0;
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('✅ WebSocket 连接成功');
|
||||||
|
testStep = 1;
|
||||||
|
|
||||||
|
// 使用包含用户 API Key 的 token
|
||||||
|
const loginMessage = {
|
||||||
|
type: 'login',
|
||||||
|
token: 'lCPWCPfGh7...fGF8_user_token'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 步骤 1: 发送登录消息(使用用户 API Key)');
|
||||||
|
socket.emit('login', loginMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('login_success', (data) => {
|
||||||
|
console.log('✅ 步骤 1 完成: 登录成功');
|
||||||
|
console.log(' 会话ID:', data.sessionId);
|
||||||
|
console.log(' 用户ID:', data.userId);
|
||||||
|
console.log(' 用户名:', data.username);
|
||||||
|
console.log(' 当前地图:', data.currentMap);
|
||||||
|
testStep = 2;
|
||||||
|
|
||||||
|
// 等待 Zulip 客户端初始化
|
||||||
|
console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||||
|
setTimeout(() => {
|
||||||
|
const chatMessage = {
|
||||||
|
t: 'chat',
|
||||||
|
content: '🎮 【用户API Key测试】来自游戏的消息!\\n' +
|
||||||
|
'时间: ' + new Date().toLocaleString() + '\\n' +
|
||||||
|
'使用用户 API Key 发送此消息。',
|
||||||
|
scope: 'local'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 步骤 2: 发送消息到 Zulip(使用用户 API Key)');
|
||||||
|
console.log(' 目标 Stream: Whale Port');
|
||||||
|
socket.emit('chat', chatMessage);
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('chat_sent', (data) => {
|
||||||
|
console.log('✅ 步骤 2 完成: 消息发送成功');
|
||||||
|
console.log(' 响应:', JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
// 只在第一次收到 chat_sent 时发送第二条消息
|
||||||
|
if (testStep === 2) {
|
||||||
|
testStep = 3;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// 先切换到 Pumpkin Valley 地图
|
||||||
|
console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图');
|
||||||
|
const positionUpdate = {
|
||||||
|
t: 'position',
|
||||||
|
x: 150,
|
||||||
|
y: 400,
|
||||||
|
mapId: 'pumpkin_valley'
|
||||||
|
};
|
||||||
|
socket.emit('position_update', positionUpdate);
|
||||||
|
|
||||||
|
// 等待位置更新后发送消息
|
||||||
|
setTimeout(() => {
|
||||||
|
const chatMessage2 = {
|
||||||
|
t: 'chat',
|
||||||
|
content: '🎃 在南瓜谷发送的测试消息!',
|
||||||
|
scope: 'local'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息');
|
||||||
|
socket.emit('chat', chatMessage2);
|
||||||
|
}, 1000);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('chat_render', (data) => {
|
||||||
|
console.log('📨 收到来自 Zulip 的消息:');
|
||||||
|
console.log(' 发送者:', data.from);
|
||||||
|
console.log(' 内容:', data.txt);
|
||||||
|
console.log(' Stream:', data.stream || '未知');
|
||||||
|
console.log(' Topic:', data.topic || '未知');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
console.log('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('🔌 WebSocket 连接已关闭');
|
||||||
|
console.log('');
|
||||||
|
console.log('📊 测试结果:');
|
||||||
|
console.log(' 完成步骤:', testStep, '/ 4');
|
||||||
|
if (testStep >= 3) {
|
||||||
|
console.log(' ✅ 核心功能正常!');
|
||||||
|
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.error('❌ 连接错误:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 20秒后自动关闭(给足够时间完成测试)
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('⏰ 测试时间到,关闭连接');
|
||||||
|
socket.disconnect();
|
||||||
|
}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔧 准备测试环境...');
|
||||||
|
testWithUserApiKey().catch(console.error);
|
||||||
196
test_zulip_registration.js
Normal file
196
test_zulip_registration.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Zulip用户注册真实环境测试脚本
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 测试Zulip用户注册功能在真实环境下的表现
|
||||||
|
* - 验证API调用是否正常工作
|
||||||
|
* - 检查配置是否正确
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* node test_zulip_registration.js
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-06
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const { URLSearchParams } = require('url');
|
||||||
|
|
||||||
|
// 配置信息
|
||||||
|
const config = {
|
||||||
|
zulipServerUrl: 'https://zulip.xinghangee.icu',
|
||||||
|
zulipBotEmail: 'angjustinl@mail.angforever.top',
|
||||||
|
zulipBotApiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否存在
|
||||||
|
*/
|
||||||
|
async function checkUserExists(email) {
|
||||||
|
console.log(`🔍 检查用户是否存在: ${email}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${config.zulipServerUrl}/api/v1/users`;
|
||||||
|
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log(`❌ 获取用户列表失败: ${response.status} ${response.statusText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`📊 获取到 ${data.members?.length || 0} 个用户`);
|
||||||
|
|
||||||
|
if (data.members && Array.isArray(data.members)) {
|
||||||
|
const userExists = data.members.some(user =>
|
||||||
|
user.email && user.email.toLowerCase() === email.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 用户存在性检查完成: ${userExists ? '存在' : '不存在'}`);
|
||||||
|
return userExists;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 检查用户存在性失败:`, error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试用户
|
||||||
|
*/
|
||||||
|
async function createTestUser(email, fullName, password) {
|
||||||
|
console.log(`🚀 开始创建用户: ${email}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${config.zulipServerUrl}/api/v1/users`;
|
||||||
|
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||||
|
|
||||||
|
const requestBody = new URLSearchParams();
|
||||||
|
requestBody.append('email', email);
|
||||||
|
requestBody.append('full_name', fullName);
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
requestBody.append('password', password);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: requestBody.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log(`❌ 用户创建失败: ${response.status} ${response.statusText}`);
|
||||||
|
console.log(`📝 错误信息: ${data.msg || data.message || '未知错误'}`);
|
||||||
|
return { success: false, error: data.msg || data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 用户创建成功! 用户ID: ${data.user_id}`);
|
||||||
|
return { success: true, userId: data.user_id };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 创建用户异常:`, error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试连接
|
||||||
|
*/
|
||||||
|
async function testConnection() {
|
||||||
|
console.log('🔗 测试Zulip服务器连接...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${config.zulipServerUrl}/api/v1/server_settings`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`✅ 连接成功! 服务器版本: ${data.zulip_version || '未知'}`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log(`❌ 连接失败: ${response.status} ${response.statusText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 连接异常:`, error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主测试函数
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
console.log('🎯 开始Zulip用户注册测试');
|
||||||
|
console.log('=' * 50);
|
||||||
|
|
||||||
|
// 1. 测试连接
|
||||||
|
const connected = await testConnection();
|
||||||
|
if (!connected) {
|
||||||
|
console.log('❌ 无法连接到Zulip服务器,测试终止');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 2. 生成测试用户信息
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const testEmail = `test_user_${timestamp}@example.com`;
|
||||||
|
const testFullName = `Test User ${timestamp}`;
|
||||||
|
const testPassword = 'test123456';
|
||||||
|
|
||||||
|
console.log(`📋 测试用户信息:`);
|
||||||
|
console.log(` 邮箱: ${testEmail}`);
|
||||||
|
console.log(` 姓名: ${testFullName}`);
|
||||||
|
console.log(` 密码: ${testPassword}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 3. 检查用户是否已存在
|
||||||
|
const userExists = await checkUserExists(testEmail);
|
||||||
|
if (userExists) {
|
||||||
|
console.log('⚠️ 用户已存在,跳过创建测试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 4. 创建用户
|
||||||
|
const createResult = await createTestUser(testEmail, testFullName, testPassword);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('📊 测试结果:');
|
||||||
|
if (createResult.success) {
|
||||||
|
console.log('✅ 用户注册功能正常工作');
|
||||||
|
console.log(` 新用户ID: ${createResult.userId}`);
|
||||||
|
} else {
|
||||||
|
console.log('❌ 用户注册功能存在问题');
|
||||||
|
console.log(` 错误信息: ${createResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('🎉 测试完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行测试
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('💥 测试过程中发生未处理的错误:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
275
test_zulip_user_management.js
Normal file
275
test_zulip_user_management.js
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Zulip用户管理真实环境测试脚本
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 测试Zulip用户管理功能在真实环境下的表现
|
||||||
|
* - 验证用户查询、验证等API调用是否正常工作
|
||||||
|
* - 检查配置是否正确
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* node test_zulip_user_management.js
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-06
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
// 配置信息
|
||||||
|
const config = {
|
||||||
|
zulipServerUrl: 'https://zulip.xinghangee.icu',
|
||||||
|
zulipBotEmail: 'angjustinl@mail.angforever.top',
|
||||||
|
zulipBotApiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有用户列表
|
||||||
|
*/
|
||||||
|
async function getAllUsers() {
|
||||||
|
console.log('📋 获取所有用户列表...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${config.zulipServerUrl}/api/v1/users`;
|
||||||
|
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log(`❌ 获取用户列表失败: ${response.status} ${response.statusText}`);
|
||||||
|
return { success: false, error: `${response.status} ${response.statusText}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const users = data.members?.map(user => ({
|
||||||
|
userId: user.user_id,
|
||||||
|
email: user.email,
|
||||||
|
fullName: user.full_name,
|
||||||
|
isActive: user.is_active,
|
||||||
|
isAdmin: user.is_admin,
|
||||||
|
isBot: user.is_bot,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
console.log(`✅ 成功获取 ${users.length} 个用户`);
|
||||||
|
|
||||||
|
// 显示前几个用户信息
|
||||||
|
console.log('👥 用户列表预览:');
|
||||||
|
users.slice(0, 5).forEach((user, index) => {
|
||||||
|
console.log(` ${index + 1}. ${user.fullName} (${user.email})`);
|
||||||
|
console.log(` ID: ${user.userId}, 活跃: ${user.isActive}, 管理员: ${user.isAdmin}, 机器人: ${user.isBot}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (users.length > 5) {
|
||||||
|
console.log(` ... 还有 ${users.length - 5} 个用户`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, users, totalCount: users.length };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 获取用户列表异常:`, error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查指定用户是否存在
|
||||||
|
*/
|
||||||
|
async function checkUserExists(email) {
|
||||||
|
console.log(`🔍 检查用户是否存在: ${email}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usersResult = await getAllUsers();
|
||||||
|
if (!usersResult.success) {
|
||||||
|
console.log(`❌ 无法获取用户列表: ${usersResult.error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userExists = usersResult.users.some(user =>
|
||||||
|
user.email.toLowerCase() === email.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 用户存在性检查完成: ${userExists ? '存在' : '不存在'}`);
|
||||||
|
return userExists;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 检查用户存在性失败:`, error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户详细信息
|
||||||
|
*/
|
||||||
|
async function getUserInfo(email) {
|
||||||
|
console.log(`📝 获取用户信息: ${email}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usersResult = await getAllUsers();
|
||||||
|
if (!usersResult.success) {
|
||||||
|
console.log(`❌ 无法获取用户列表: ${usersResult.error}`);
|
||||||
|
return { success: false, error: usersResult.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = usersResult.users.find(u =>
|
||||||
|
u.email.toLowerCase() === email.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log(`❌ 用户不存在: ${email}`);
|
||||||
|
return { success: false, error: '用户不存在' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 用户信息获取成功:`);
|
||||||
|
console.log(` 用户ID: ${user.userId}`);
|
||||||
|
console.log(` 邮箱: ${user.email}`);
|
||||||
|
console.log(` 姓名: ${user.fullName}`);
|
||||||
|
console.log(` 状态: ${user.isActive ? '活跃' : '非活跃'}`);
|
||||||
|
console.log(` 权限: ${user.isAdmin ? '管理员' : '普通用户'}`);
|
||||||
|
console.log(` 类型: ${user.isBot ? '机器人' : '真实用户'}`);
|
||||||
|
|
||||||
|
return { success: true, user };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 获取用户信息失败:`, error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试用户API Key
|
||||||
|
*/
|
||||||
|
async function testUserApiKey(email, apiKey) {
|
||||||
|
console.log(`🔑 测试用户API Key: ${email}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${config.zulipServerUrl}/api/v1/users/me`;
|
||||||
|
const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValid = response.ok;
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`✅ API Key有效! 用户信息:`);
|
||||||
|
console.log(` 用户ID: ${data.user_id}`);
|
||||||
|
console.log(` 邮箱: ${data.email}`);
|
||||||
|
console.log(` 姓名: ${data.full_name}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ API Key无效: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 测试API Key异常:`, error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试连接
|
||||||
|
*/
|
||||||
|
async function testConnection() {
|
||||||
|
console.log('🔗 测试Zulip服务器连接...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${config.zulipServerUrl}/api/v1/server_settings`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`✅ 连接成功! 服务器信息:`);
|
||||||
|
console.log(` 版本: ${data.zulip_version || '未知'}`);
|
||||||
|
console.log(` 服务器: ${data.realm_name || '未知'}`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log(`❌ 连接失败: ${response.status} ${response.statusText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 连接异常:`, error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主测试函数
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
console.log('🎯 开始Zulip用户管理测试');
|
||||||
|
console.log('='.repeat(50));
|
||||||
|
|
||||||
|
// 1. 测试连接
|
||||||
|
const connected = await testConnection();
|
||||||
|
if (!connected) {
|
||||||
|
console.log('❌ 无法连接到Zulip服务器,测试终止');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 2. 获取所有用户列表
|
||||||
|
const usersResult = await getAllUsers();
|
||||||
|
if (!usersResult.success) {
|
||||||
|
console.log('❌ 无法获取用户列表,测试终止');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 3. 测试用户存在性检查
|
||||||
|
const testEmails = [
|
||||||
|
'angjustinl@mail.angforever.top', // 应该存在
|
||||||
|
'nonexistent@example.com', // 应该不存在
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('🔍 测试用户存在性检查:');
|
||||||
|
for (const email of testEmails) {
|
||||||
|
const exists = await checkUserExists(email);
|
||||||
|
console.log(` ${email}: ${exists ? '✅ 存在' : '❌ 不存在'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 4. 测试获取用户信息
|
||||||
|
console.log('📝 测试获取用户信息:');
|
||||||
|
const existingEmail = 'angjustinl@mail.angforever.top';
|
||||||
|
const userInfoResult = await getUserInfo(existingEmail);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 5. 测试API Key验证(如果有的话)
|
||||||
|
console.log('🔑 测试API Key验证:');
|
||||||
|
const testApiKey = 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8'; // 这是我们的测试API Key
|
||||||
|
const apiKeyValid = await testUserApiKey(existingEmail, testApiKey);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('📊 测试结果总结:');
|
||||||
|
console.log(`✅ 服务器连接: 正常`);
|
||||||
|
console.log(`✅ 用户列表获取: 正常 (${usersResult.totalCount} 个用户)`);
|
||||||
|
console.log(`✅ 用户存在性检查: 正常`);
|
||||||
|
console.log(`✅ 用户信息获取: ${userInfoResult.success ? '正常' : '异常'}`);
|
||||||
|
console.log(`✅ API Key验证: ${apiKeyValid ? '正常' : '异常'}`);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('🎉 用户管理功能测试完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行测试
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('💥 测试过程中发生未处理的错误:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user