/** * 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 => { 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 = (client: ClientSocket, event: string, timeout = 5000): Promise => { 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(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(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(client, 'login_success'); client.emit('login', { type: 'login', token: 'valid_test_token_456' }); await loginPromise; // 发送聊天消息 const chatPromise = waitForEvent(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(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(client, 'login_success'); client.emit('login', { type: 'login', token: 'valid_test_token_789' }); await loginPromise; // 发送空消息 const errorPromise = waitForEvent(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(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(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(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(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(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(client, 'login_success'); client.emit('login', { type: 'login', token: 'valid_error_test_1' }); await loginPromise; // 发送无效格式的聊天消息 const errorPromise = waitForEvent(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(client, 'login_success'); client.emit('login', { type: 'login', token: 'valid_duplicate_test' }); await loginPromise1; // 尝试重复登录 const errorPromise = waitForEvent(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(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(client, 'login_success'); client.emit('login', { type: 'login', token: 'valid_scope_test' }); await loginPromise; // 发送缺少scope的消息 const errorPromise = waitForEvent(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(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(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(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(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(client, 'login_success'); client.emit('login', { type: 'login', token: `valid_format_normal_${Date.now()}_${testId}` }); await loginPromise; const chatPromise = waitForEvent(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(client, 'login_success'); client.emit('login', { type: 'login', token: `valid_format_long_${Date.now()}_${testId}` }); await loginPromise; // 使用不重复的长消息内容,避免触发重复字符检测 const longContent = '这是一条较长的测试消息,用于验证系统能够正确处理长消息内容。' + '消息系统应该能够处理各种长度的消息,包括较长的消息。' + '这条消息包含多种字符和标点符号,以确保系统的兼容性。' + '测试消息继续延长,以达到足够的长度进行测试。' + '系统应该能够正确处理这样的消息而不会出现问题。'; const chatPromise = waitForEvent(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(client, 'login_success'); client.emit('login', { type: 'login', token: `valid_format_special_${Date.now()}_${testId}` }); await loginPromise; const specialContent = '你好!@#$%^&*()_+{}|:"<>?~`-=[]\\;\',./'; const chatPromise = waitForEvent(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(client, 'login_success'); client.emit('login', { type: 'login', token: `valid_format_unicode_${Date.now()}_${testId}` }); await loginPromise; const unicodeContent = '🎮 游戏消息 🎯 测试 🚀'; const chatPromise = waitForEvent(client, 'chat_sent'); client.emit('chat', { t: 'chat', content: unicodeContent, scope: 'local' }); const response = await chatPromise; expect(response.t).toBe('chat_sent'); }); }); });