forked from datawhale/whale-town-end
- 将技术实现服务从business层迁移到core层 - 创建src/core/zulip/核心服务模块,包含API客户端、连接池等技术服务 - 保留src/business/zulip/业务逻辑,专注游戏相关的业务规则 - 通过依赖注入实现业务层与核心层的解耦 - 更新模块导入关系,确保架构分层清晰 重构后的架构符合单一职责原则,提高了代码的可维护性和可测试性
610 lines
20 KiB
TypeScript
610 lines
20 KiB
TypeScript
/**
|
||
* Zulip集成系统端到端测试
|
||
*
|
||
* 功能描述:
|
||
* - 测试完整的登录到聊天流程
|
||
* - 测试多用户并发聊天场景
|
||
* - 测试错误场景和降级处理
|
||
*
|
||
* **验证需求: 所有需求**
|
||
*
|
||
* @author angjustinl
|
||
* @version 1.0.0
|
||
* @since 2025-12-25
|
||
*/
|
||
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { INestApplication } from '@nestjs/common';
|
||
import { io, Socket as ClientSocket } from 'socket.io-client';
|
||
import { AppModule } from '../../app.module';
|
||
|
||
// 如果没有设置 RUN_E2E_TESTS 环境变量,跳过这些测试
|
||
const describeE2E = process.env.RUN_E2E_TESTS === 'true' ? describe : describe.skip;
|
||
|
||
describeE2E('Zulip Integration E2E Tests', () => {
|
||
let app: INestApplication;
|
||
let serverUrl: string;
|
||
|
||
beforeAll(async () => {
|
||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||
imports: [AppModule],
|
||
}).compile();
|
||
|
||
app = moduleFixture.createNestApplication();
|
||
await app.init();
|
||
await app.listen(0); // 使用随机端口
|
||
|
||
const address = app.getHttpServer().address();
|
||
const port = address.port;
|
||
serverUrl = `http://localhost:${port}`;
|
||
}, 30000);
|
||
|
||
afterAll(async () => {
|
||
if (app) {
|
||
await app.close();
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 创建WebSocket客户端连接
|
||
*/
|
||
const createClient = (): Promise<ClientSocket> => {
|
||
return new Promise((resolve, reject) => {
|
||
const client = io(`${serverUrl}/game`, {
|
||
transports: ['websocket'],
|
||
autoConnect: true,
|
||
});
|
||
|
||
client.on('connect', () => resolve(client));
|
||
client.on('connect_error', (err: any) => reject(err));
|
||
|
||
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 等待指定事件
|
||
*/
|
||
const waitForEvent = <T>(client: ClientSocket, event: string, timeout = 5000): Promise<T> => {
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
reject(new Error(`Timeout waiting for event: ${event}`));
|
||
}, timeout);
|
||
|
||
client.once(event, (data: T) => {
|
||
clearTimeout(timer);
|
||
resolve(data);
|
||
});
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 测试套件1: 完整的登录到聊天流程测试
|
||
* 验证需求: 1.1, 1.2, 1.3, 2.1, 4.1, 4.2, 5.1
|
||
*/
|
||
describe('完整的登录到聊天流程测试', () => {
|
||
let client: ClientSocket;
|
||
|
||
afterEach(async () => {
|
||
if (client?.connected) {
|
||
client.disconnect();
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 测试: WebSocket连接建立
|
||
* 验证需求 1.1: 游戏客户端发送登录包时系统应验证游戏Token并建立WebSocket连接
|
||
*/
|
||
it('应该成功建立WebSocket连接', async () => {
|
||
client = await createClient();
|
||
expect(client.connected).toBe(true);
|
||
});
|
||
|
||
/**
|
||
* 测试: 有效Token登录成功
|
||
* 验证需求 1.1, 1.2: 验证Token并分配唯一会话ID
|
||
*/
|
||
it('应该使用有效Token成功登录', async () => {
|
||
client = await createClient();
|
||
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_test_token_123' });
|
||
|
||
const response = await loginPromise;
|
||
|
||
expect(response.t).toBe('login_success');
|
||
expect(response.sessionId).toBeDefined();
|
||
expect(response.userId).toBeDefined();
|
||
expect(response.currentMap).toBeDefined();
|
||
});
|
||
|
||
/**
|
||
* 测试: 无效Token登录失败
|
||
* 验证需求 1.1: 系统应验证游戏Token
|
||
*/
|
||
it('应该拒绝无效Token的登录请求', async () => {
|
||
client = await createClient();
|
||
|
||
const errorPromise = waitForEvent<any>(client, 'login_error');
|
||
client.emit('login', { type: 'login', token: 'invalid_token' });
|
||
|
||
const response = await errorPromise;
|
||
|
||
expect(response.t).toBe('login_error');
|
||
expect(response.message).toBeDefined();
|
||
});
|
||
|
||
/**
|
||
* 测试: 登录后发送聊天消息
|
||
* 验证需求 4.1, 4.2: 玩家发送聊天消息时系统应根据位置确定目标Stream
|
||
*/
|
||
it('应该在登录后成功发送聊天消息', async () => {
|
||
client = await createClient();
|
||
|
||
// 先登录
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_test_token_456' });
|
||
await loginPromise;
|
||
|
||
// 发送聊天消息
|
||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||
client.emit('chat', { t: 'chat', content: 'Hello World!', scope: 'local' });
|
||
|
||
const response = await chatPromise;
|
||
|
||
expect(response.t).toBe('chat_sent');
|
||
});
|
||
|
||
/**
|
||
* 测试: 未登录时发送消息被拒绝
|
||
* 验证需求 7.2: 系统应验证玩家是否有权限
|
||
*/
|
||
it('应该拒绝未登录用户的聊天消息', async () => {
|
||
client = await createClient();
|
||
|
||
const errorPromise = waitForEvent<any>(client, 'chat_error');
|
||
client.emit('chat', { t: 'chat', content: 'Hello', scope: 'local' });
|
||
|
||
const response = await errorPromise;
|
||
|
||
expect(response.t).toBe('chat_error');
|
||
expect(response.message).toBe('请先登录');
|
||
});
|
||
|
||
/**
|
||
* 测试: 空消息内容被拒绝
|
||
* 验证需求 4.3: 系统应过滤消息内容
|
||
*/
|
||
it('应该拒绝空内容的聊天消息', async () => {
|
||
client = await createClient();
|
||
|
||
// 先登录
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_test_token_789' });
|
||
await loginPromise;
|
||
|
||
// 发送空消息
|
||
const errorPromise = waitForEvent<any>(client, 'chat_error');
|
||
client.emit('chat', { t: 'chat', content: ' ', scope: 'local' });
|
||
|
||
const response = await errorPromise;
|
||
|
||
expect(response.t).toBe('chat_error');
|
||
expect(response.message).toBe('消息内容不能为空');
|
||
});
|
||
|
||
/**
|
||
* 测试: 位置更新
|
||
* 验证需求 6.2: 玩家切换地图时系统应更新位置信息
|
||
*/
|
||
it('应该成功更新玩家位置', async () => {
|
||
client = await createClient();
|
||
|
||
// 先登录
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_test_token_position' });
|
||
await loginPromise;
|
||
|
||
// 更新位置 - 位置更新不返回确认消息,只需确保不报错
|
||
client.emit('position_update', { t: 'position', x: 100, y: 200, mapId: 'tavern' });
|
||
|
||
// 等待一小段时间确保消息被处理
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
// 如果没有错误,测试通过
|
||
expect(client.connected).toBe(true);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 测试套件2: 多用户并发聊天测试
|
||
* 验证需求: 5.2, 5.5, 6.1, 6.3
|
||
*/
|
||
describe('多用户并发聊天测试', () => {
|
||
const clients: ClientSocket[] = [];
|
||
|
||
afterEach(async () => {
|
||
// 断开所有客户端
|
||
for (const client of clients) {
|
||
if (client?.connected) {
|
||
client.disconnect();
|
||
}
|
||
}
|
||
clients.length = 0;
|
||
});
|
||
|
||
/**
|
||
* 测试: 多用户同时连接
|
||
* 验证需求 6.1: 系统应在Redis中存储会话映射关系
|
||
*/
|
||
it('应该支持多用户同时连接', async () => {
|
||
const userCount = 5;
|
||
|
||
for (let i = 0; i < userCount; i++) {
|
||
const client = await createClient();
|
||
clients.push(client);
|
||
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: `valid_multi_user_${i}` });
|
||
await loginPromise;
|
||
}
|
||
|
||
// 验证所有客户端都已连接并登录
|
||
expect(clients.length).toBe(userCount);
|
||
for (const client of clients) {
|
||
expect(client.connected).toBe(true);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 测试: 多用户并发发送消息
|
||
* 验证需求 4.1, 4.2: 多用户同时发送消息
|
||
*/
|
||
it('应该正确处理多用户并发发送消息', async () => {
|
||
const userCount = 3;
|
||
|
||
// 创建并登录多个用户(使用完全不同的token前缀避免userId冲突)
|
||
// userId是从token前8个字符生成的,所以每个用户需要不同的前缀
|
||
const userPrefixes = ['userAAA1', 'userBBB2', 'userCCC3'];
|
||
|
||
for (let i = 0; i < userCount; i++) {
|
||
const client = await createClient();
|
||
clients.push(client);
|
||
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
// 使用不同的前缀确保每个用户有唯一的userId
|
||
client.emit('login', { type: 'login', token: `${userPrefixes[i]}_concurrent_${Date.now()}` });
|
||
await loginPromise;
|
||
|
||
// 添加小延迟确保会话完全建立
|
||
await new Promise(resolve => setTimeout(resolve, 50));
|
||
}
|
||
|
||
// 顺序发送消息(避免并发会话问题)
|
||
for (let i = 0; i < clients.length; i++) {
|
||
const client = clients[i];
|
||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||
client.emit('chat', {
|
||
t: 'chat',
|
||
content: `Message from user ${i}`,
|
||
scope: 'local'
|
||
});
|
||
const result = await chatPromise;
|
||
expect(result.t).toBe('chat_sent');
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 测试: 用户断开连接后资源清理
|
||
* 验证需求 1.3: 客户端断开连接时系统应清理相关资源
|
||
*/
|
||
it('应该在用户断开连接后正确清理资源', async () => {
|
||
const client = await createClient();
|
||
clients.push(client);
|
||
|
||
// 登录
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_cleanup_test' });
|
||
await loginPromise;
|
||
|
||
// 断开连接
|
||
client.disconnect();
|
||
|
||
// 等待清理完成
|
||
await new Promise(resolve => setTimeout(resolve, 200));
|
||
|
||
expect(client.connected).toBe(false);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 测试套件3: 错误场景和降级测试
|
||
* 验证需求: 8.1, 8.2, 8.3, 8.4, 8.5
|
||
*/
|
||
describe('错误场景和降级测试', () => {
|
||
let client: ClientSocket;
|
||
|
||
afterEach(async () => {
|
||
if (client?.connected) {
|
||
client.disconnect();
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 测试: 无效消息格式处理
|
||
* 验证需求 8.5: 系统应记录详细错误日志
|
||
*/
|
||
it('应该正确处理无效的消息格式', async () => {
|
||
client = await createClient();
|
||
|
||
// 登录
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_error_test_1' });
|
||
await loginPromise;
|
||
|
||
// 发送无效格式的聊天消息
|
||
const errorPromise = waitForEvent<any>(client, 'chat_error');
|
||
client.emit('chat', { invalid: 'format' });
|
||
|
||
const response = await errorPromise;
|
||
|
||
expect(response.t).toBe('chat_error');
|
||
expect(response.message).toBe('消息格式无效');
|
||
});
|
||
|
||
/**
|
||
* 测试: 重复登录处理
|
||
* 验证需求 1.1: 系统应正确处理重复登录
|
||
*/
|
||
it('应该拒绝已登录用户的重复登录请求', async () => {
|
||
client = await createClient();
|
||
|
||
// 第一次登录
|
||
const loginPromise1 = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_duplicate_test' });
|
||
await loginPromise1;
|
||
|
||
// 尝试重复登录
|
||
const errorPromise = waitForEvent<any>(client, 'login_error');
|
||
client.emit('login', { type: 'login', token: 'another_token' });
|
||
|
||
const response = await errorPromise;
|
||
|
||
expect(response.t).toBe('login_error');
|
||
expect(response.message).toBe('您已经登录');
|
||
});
|
||
|
||
/**
|
||
* 测试: 空Token登录处理
|
||
* 验证需求 1.1: 系统应验证Token
|
||
*/
|
||
it('应该拒绝空Token的登录请求', async () => {
|
||
client = await createClient();
|
||
|
||
const errorPromise = waitForEvent<any>(client, 'login_error');
|
||
client.emit('login', { type: 'login', token: '' });
|
||
|
||
const response = await errorPromise;
|
||
|
||
expect(response.t).toBe('login_error');
|
||
});
|
||
|
||
/**
|
||
* 测试: 缺少scope的聊天消息
|
||
* 验证需求 4.1: 系统应正确验证消息格式
|
||
*/
|
||
it('应该拒绝缺少scope的聊天消息', async () => {
|
||
client = await createClient();
|
||
|
||
// 登录
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_scope_test' });
|
||
await loginPromise;
|
||
|
||
// 发送缺少scope的消息
|
||
const errorPromise = waitForEvent<any>(client, 'chat_error');
|
||
client.emit('chat', { t: 'chat', content: 'Hello' });
|
||
|
||
const response = await errorPromise;
|
||
|
||
expect(response.t).toBe('chat_error');
|
||
expect(response.message).toBe('消息格式无效');
|
||
});
|
||
|
||
/**
|
||
* 测试: 无效位置更新处理
|
||
* 验证需求 6.2: 系统应正确验证位置数据
|
||
*/
|
||
it('应该忽略无效的位置更新', async () => {
|
||
client = await createClient();
|
||
|
||
// 登录
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_position_error_test' });
|
||
await loginPromise;
|
||
|
||
// 发送无效位置更新(缺少mapId)
|
||
client.emit('position_update', { t: 'position', x: 100, y: 200 });
|
||
|
||
// 等待处理
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
// 连接应该保持正常
|
||
expect(client.connected).toBe(true);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 测试套件4: 连接生命周期测试
|
||
* 验证需求: 1.3, 1.4, 6.4
|
||
*/
|
||
describe('连接生命周期测试', () => {
|
||
/**
|
||
* 测试: 连接-登录-断开完整流程
|
||
* 验证需求 1.1, 1.2, 1.3: 完整的连接生命周期
|
||
*/
|
||
it('应该正确处理完整的连接生命周期', async () => {
|
||
// 1. 建立连接
|
||
const client = await createClient();
|
||
expect(client.connected).toBe(true);
|
||
|
||
// 2. 登录
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_lifecycle_test' });
|
||
const loginResponse = await loginPromise;
|
||
expect(loginResponse.t).toBe('login_success');
|
||
|
||
// 3. 发送消息
|
||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||
client.emit('chat', { t: 'chat', content: 'Lifecycle test message', scope: 'local' });
|
||
const chatResponse = await chatPromise;
|
||
expect(chatResponse.t).toBe('chat_sent');
|
||
|
||
// 4. 断开连接
|
||
client.disconnect();
|
||
|
||
// 等待断开完成
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
expect(client.connected).toBe(false);
|
||
});
|
||
|
||
/**
|
||
* 测试: 快速连接断开
|
||
* 验证需求 1.3: 系统应正确处理快速断开
|
||
*/
|
||
it('应该正确处理快速连接断开', async () => {
|
||
const client = await createClient();
|
||
expect(client.connected).toBe(true);
|
||
|
||
// 立即断开
|
||
client.disconnect();
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
expect(client.connected).toBe(false);
|
||
});
|
||
|
||
/**
|
||
* 测试: 登录后立即断开
|
||
* 验证需求 1.3: 系统应清理会话资源
|
||
*/
|
||
it('应该正确处理登录后立即断开', async () => {
|
||
const client = await createClient();
|
||
|
||
// 登录
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: 'valid_quick_disconnect' });
|
||
await loginPromise;
|
||
|
||
// 立即断开
|
||
client.disconnect();
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 200));
|
||
expect(client.connected).toBe(false);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 测试套件5: 消息格式验证测试
|
||
* 验证需求: 5.3, 5.4
|
||
*/
|
||
describe('消息格式验证测试', () => {
|
||
let client: ClientSocket;
|
||
let testId: number = 0;
|
||
|
||
afterEach(async () => {
|
||
if (client?.connected) {
|
||
client.disconnect();
|
||
}
|
||
// 等待清理完成
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
});
|
||
|
||
/**
|
||
* 测试: 正常消息格式
|
||
* 验证需求 5.3, 5.4: 消息应包含所有必需信息
|
||
*/
|
||
it('应该接受正确格式的聊天消息', async () => {
|
||
testId++;
|
||
client = await createClient();
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: `valid_format_normal_${Date.now()}_${testId}` });
|
||
await loginPromise;
|
||
|
||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||
client.emit('chat', {
|
||
t: 'chat',
|
||
content: 'Test message with correct format',
|
||
scope: 'local'
|
||
});
|
||
|
||
const response = await chatPromise;
|
||
expect(response.t).toBe('chat_sent');
|
||
});
|
||
|
||
/**
|
||
* 测试: 长消息处理
|
||
* 验证需求 4.1: 系统应正确处理各种长度的消息
|
||
*/
|
||
it('应该正确处理较长的消息内容', async () => {
|
||
testId++;
|
||
client = await createClient();
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: `valid_format_long_${Date.now()}_${testId}` });
|
||
await loginPromise;
|
||
|
||
// 使用不重复的长消息内容,避免触发重复字符检测
|
||
const longContent = '这是一条较长的测试消息,用于验证系统能够正确处理长消息内容。' +
|
||
'消息系统应该能够处理各种长度的消息,包括较长的消息。' +
|
||
'这条消息包含多种字符和标点符号,以确保系统的兼容性。' +
|
||
'测试消息继续延长,以达到足够的长度进行测试。' +
|
||
'系统应该能够正确处理这样的消息而不会出现问题。';
|
||
|
||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||
client.emit('chat', { t: 'chat', content: longContent, scope: 'local' });
|
||
|
||
const response = await chatPromise;
|
||
expect(response.t).toBe('chat_sent');
|
||
});
|
||
|
||
/**
|
||
* 测试: 特殊字符消息
|
||
* 验证需求 4.1: 系统应正确处理特殊字符
|
||
*/
|
||
it('应该正确处理包含特殊字符的消息', async () => {
|
||
testId++;
|
||
client = await createClient();
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: `valid_format_special_${Date.now()}_${testId}` });
|
||
await loginPromise;
|
||
|
||
const specialContent = '你好!@#$%^&*()_+{}|:"<>?~`-=[]\\;\',./';
|
||
|
||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||
client.emit('chat', { t: 'chat', content: specialContent, scope: 'local' });
|
||
|
||
const response = await chatPromise;
|
||
expect(response.t).toBe('chat_sent');
|
||
});
|
||
|
||
/**
|
||
* 测试: Unicode消息
|
||
* 验证需求 4.1: 系统应正确处理Unicode字符
|
||
*/
|
||
it('应该正确处理Unicode消息', async () => {
|
||
testId++;
|
||
client = await createClient();
|
||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||
client.emit('login', { type: 'login', token: `valid_format_unicode_${Date.now()}_${testId}` });
|
||
await loginPromise;
|
||
|
||
const unicodeContent = '🎮 游戏消息 🎯 测试 🚀';
|
||
|
||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||
client.emit('chat', { t: 'chat', content: unicodeContent, scope: 'local' });
|
||
|
||
const response = await chatPromise;
|
||
expect(response.t).toBe('chat_sent');
|
||
});
|
||
});
|
||
});
|