/** * 位置广播WebSocket网关集成测试 * * 功能描述: * - 测试WebSocket网关的实时通信功能 * - 验证消息处理和广播机制 * - 确保认证和连接管理的正确性 * - 提供完整的WebSocket功能测试 * * 测试范围: * - WebSocket连接和断开处理 * - 消息路由和事件处理 * - 认证守卫和权限验证 * - 实时广播和错误处理 * * @author moyin * @version 1.0.0 * @since 2026-01-08 * @lastModified 2026-01-08 */ import { Test, TestingModule } from '@nestjs/testing'; import { WsException } from '@nestjs/websockets'; import * as WebSocket from 'ws'; import { LocationBroadcastGateway } from './location_broadcast.gateway'; // 扩展的WebSocket接口,与gateway中的定义保持一致,添加测试所需的mock方法 interface TestExtendedWebSocket extends WebSocket { id: string; userId?: string; sessionIds?: Set; connectionTimeout?: NodeJS.Timeout; isAlive?: boolean; emit: jest.Mock; to: jest.Mock; join: jest.Mock; leave: jest.Mock; rooms: Set; } import { JoinSessionMessage, LeaveSessionMessage, PositionUpdateMessage, HeartbeatMessage, } from './dto/websocket_message.dto'; import { Position } from '../../core/location_broadcast_core/position.interface'; import { SessionUser, SessionUserStatus } from '../../core/location_broadcast_core/session.interface'; // 模拟原生WebSocket const mockSocket = { id: 'socket123', readyState: WebSocket.OPEN, send: jest.fn(), close: jest.fn(), terminate: jest.fn(), ping: jest.fn(), pong: jest.fn(), on: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), sessionIds: new Set(), isAlive: true, } as any; const mockServer = { clients: new Set(), on: jest.fn(), emit: jest.fn(), } as any; describe('LocationBroadcastGateway', () => { let gateway: LocationBroadcastGateway; let mockLocationBroadcastCore: any; beforeEach(async () => { // 使用假定时器 jest.useFakeTimers(); // 创建模拟的核心服务 mockLocationBroadcastCore = { addUserToSession: jest.fn(), removeUserFromSession: jest.fn(), getSessionUsers: jest.fn(), getSessionPositions: jest.fn(), setUserPosition: jest.fn(), getUserPosition: jest.fn(), cleanupUserData: jest.fn(), }; // 创建模拟的LoginCoreService const mockLoginCoreService = { validateToken: jest.fn(), getUserFromToken: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ LocationBroadcastGateway, { provide: 'ILocationBroadcastCore', useValue: mockLocationBroadcastCore, }, { provide: 'LoginCoreService', useValue: mockLoginCoreService, }, ], }) .overrideGuard(require('./websocket_auth.guard').WebSocketAuthGuard) .useValue({ canActivate: jest.fn(() => true), }) .compile(); gateway = module.get(LocationBroadcastGateway); gateway.server = mockServer; }); afterEach(() => { // 清理所有定时器和间隔 jest.clearAllTimers(); jest.clearAllMocks(); // 清理gateway中的定时器 if (gateway) { // 清理心跳间隔 const heartbeatInterval = (gateway as any).heartbeatInterval; if (heartbeatInterval) { clearInterval(heartbeatInterval); (gateway as any).heartbeatInterval = null; } // 清理所有客户端的连接超时 const clients = (gateway as any).clients; if (clients) { clients.forEach((client: any) => { if (client.connectionTimeout) { clearTimeout(client.connectionTimeout); client.connectionTimeout = null; } }); clients.clear(); } } // 恢复真实定时器 jest.useRealTimers(); }); afterAll(() => { // 确保所有定时器都被清理 jest.clearAllTimers(); jest.useRealTimers(); }); describe('afterInit', () => { it('应该正确初始化WebSocket服务器', () => { gateway.afterInit(mockServer); // 验证初始化完成(主要是确保不抛出异常) expect(true).toBe(true); }); }); describe('handleConnection', () => { it('应该处理客户端连接', () => { gateway.handleConnection(mockSocket); expect(mockSocket.send).toHaveBeenCalledWith( expect.stringContaining('welcome') ); }); it('应该设置连接超时', () => { gateway.handleConnection(mockSocket); expect((mockSocket as any).connectionTimeout).toBeDefined(); }); }); describe('handleDisconnect', () => { it('应该处理客户端断开连接', async () => { const authenticatedSocket = { ...mockSocket, userId: 'user123', user: { sub: 'user123', username: 'testuser' }, } as TestExtendedWebSocket; mockLocationBroadcastCore.cleanupUserData.mockResolvedValue(undefined); await gateway.handleDisconnect(authenticatedSocket); expect(mockLocationBroadcastCore.cleanupUserData).toHaveBeenCalledWith('user123'); }); it('应该清理连接超时', async () => { const timeout = setTimeout(() => {}, 1000); (mockSocket as any).connectionTimeout = timeout; await gateway.handleDisconnect(mockSocket); // 验证超时被清理(这里主要是确保不抛出异常) expect(true).toBe(true); }); it('应该处理断开连接时的异常', async () => { const authenticatedSocket = { ...mockSocket, userId: 'user123', } as TestExtendedWebSocket; mockLocationBroadcastCore.cleanupUserData.mockRejectedValue(new Error('清理失败')); // 应该不抛出异常 await expect(gateway.handleDisconnect(authenticatedSocket)).resolves.toBeUndefined(); }); }); describe('handleJoinSession', () => { const mockJoinMessage: JoinSessionMessage = { type: 'join_session', sessionId: 'session123', token: 'test_token', initialPosition: { mapId: 'plaza', x: 100, y: 200, }, }; const mockAuthenticatedSocket = { ...mockSocket, userId: 'user123', user: { sub: 'user123', username: 'testuser' }, emit: jest.fn(), to: jest.fn().mockReturnThis(), join: jest.fn(), leave: jest.fn(), rooms: new Set(), } as TestExtendedWebSocket; const mockSessionUsers: SessionUser[] = [ { userId: 'user123', socketId: 'socket123', joinedAt: Date.now(), lastSeen: Date.now(), status: SessionUserStatus.ONLINE, metadata: {}, }, ]; const mockPositions: Position[] = [ { userId: 'user123', x: 100, y: 200, mapId: 'plaza', timestamp: Date.now(), metadata: {}, }, ]; it('应该成功处理加入会话请求', async () => { mockLocationBroadcastCore.addUserToSession.mockResolvedValue(undefined); mockLocationBroadcastCore.setUserPosition.mockResolvedValue(undefined); mockLocationBroadcastCore.getSessionUsers.mockResolvedValue(mockSessionUsers); mockLocationBroadcastCore.getSessionPositions.mockResolvedValue(mockPositions); await gateway.handleJoinSession(mockAuthenticatedSocket, mockJoinMessage); expect(mockLocationBroadcastCore.addUserToSession).toHaveBeenCalledWith( mockJoinMessage.sessionId, mockAuthenticatedSocket.userId, mockAuthenticatedSocket.id, ); expect(mockLocationBroadcastCore.setUserPosition).toHaveBeenCalledWith( mockAuthenticatedSocket.userId, expect.objectContaining({ userId: mockAuthenticatedSocket.userId, x: mockJoinMessage.initialPosition!.x, y: mockJoinMessage.initialPosition!.y, mapId: mockJoinMessage.initialPosition!.mapId, }), ); expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith( expect.stringContaining('session_joined') ); }); it('应该在没有初始位置时成功加入会话', async () => { const messageWithoutPosition = { ...mockJoinMessage }; delete messageWithoutPosition.initialPosition; mockLocationBroadcastCore.addUserToSession.mockResolvedValue(undefined); mockLocationBroadcastCore.getSessionUsers.mockResolvedValue(mockSessionUsers); mockLocationBroadcastCore.getSessionPositions.mockResolvedValue([]); await gateway.handleJoinSession(mockAuthenticatedSocket, messageWithoutPosition); expect(mockLocationBroadcastCore.setUserPosition).not.toHaveBeenCalled(); expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith( expect.stringContaining('session_joined') ); }); it('应该在加入会话失败时发送错误消息', async () => { mockLocationBroadcastCore.addUserToSession.mockRejectedValue(new Error('加入失败')); await gateway.handleJoinSession(mockAuthenticatedSocket, mockJoinMessage); expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith( expect.stringContaining('error') ); }); }); describe('handleLeaveSession', () => { const mockLeaveMessage: LeaveSessionMessage = { type: 'leave_session', sessionId: 'session123', reason: 'user_left', }; const mockAuthenticatedSocket = { ...mockSocket, userId: 'user123', user: { sub: 'user123', username: 'testuser' }, emit: jest.fn(), to: jest.fn().mockReturnThis(), join: jest.fn(), leave: jest.fn(), rooms: new Set(), } as TestExtendedWebSocket; it('应该成功处理离开会话请求', async () => { mockLocationBroadcastCore.removeUserFromSession.mockResolvedValue(undefined); await gateway.handleLeaveSession(mockAuthenticatedSocket, mockLeaveMessage); expect(mockLocationBroadcastCore.removeUserFromSession).toHaveBeenCalledWith( mockLeaveMessage.sessionId, mockAuthenticatedSocket.userId, ); expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith( expect.stringContaining('leave_session_success') ); }); it('应该在离开会话失败时发送错误消息', async () => { mockLocationBroadcastCore.removeUserFromSession.mockRejectedValue(new Error('离开失败')); await gateway.handleLeaveSession(mockAuthenticatedSocket, mockLeaveMessage); expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith( expect.stringContaining('error') ); }); }); describe('handlePositionUpdate', () => { const mockPositionMessage: PositionUpdateMessage = { type: 'position_update', mapId: 'plaza', x: 150, y: 250, timestamp: Date.now(), }; const mockAuthenticatedSocket = { ...mockSocket, userId: 'user123', user: { sub: 'user123', username: 'testuser' }, rooms: new Set(['socket123', 'session123']), // 用户在会话中 emit: jest.fn(), to: jest.fn().mockReturnThis(), join: jest.fn(), leave: jest.fn(), } as TestExtendedWebSocket; it('应该成功处理位置更新请求', async () => { mockLocationBroadcastCore.setUserPosition.mockResolvedValue(undefined); await gateway.handlePositionUpdate(mockAuthenticatedSocket, mockPositionMessage); expect(mockLocationBroadcastCore.setUserPosition).toHaveBeenCalledWith( mockAuthenticatedSocket.userId, expect.objectContaining({ userId: mockAuthenticatedSocket.userId, x: mockPositionMessage.x, y: mockPositionMessage.y, mapId: mockPositionMessage.mapId, }), ); expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith( expect.stringContaining('position_update_success') ); }); it('应该在位置更新失败时发送错误消息', async () => { mockLocationBroadcastCore.setUserPosition.mockRejectedValue(new Error('更新失败')); await gateway.handlePositionUpdate(mockAuthenticatedSocket, mockPositionMessage); expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith( expect.stringContaining('error') ); }); }); describe('handleHeartbeat', () => { const mockHeartbeatMessage: HeartbeatMessage = { type: 'heartbeat', timestamp: Date.now(), sequence: 1, }; it('应该成功处理心跳请求', async () => { const timeout = setTimeout(() => {}, 1000); (mockSocket as any).connectionTimeout = timeout; await gateway.handleHeartbeat(mockSocket, mockHeartbeatMessage); expect(mockSocket.send).toHaveBeenCalledWith( expect.stringContaining('heartbeat_response') ); }); it('应该重置连接超时', async () => { const originalTimeout = setTimeout(() => {}, 1000); (mockSocket as any).connectionTimeout = originalTimeout; await gateway.handleHeartbeat(mockSocket, mockHeartbeatMessage); // 验证新的超时被设置 expect((mockSocket as any).connectionTimeout).toBeDefined(); expect((mockSocket as any).connectionTimeout).not.toBe(originalTimeout); }); it('应该处理心跳异常而不断开连接', async () => { // 模拟心跳处理异常 const originalEmit = mockSocket.emit; mockSocket.emit = jest.fn().mockImplementation(() => { throw new Error('心跳异常'); }); // 应该不抛出异常 await expect(gateway.handleHeartbeat(mockSocket, mockHeartbeatMessage)) .resolves.toBeUndefined(); mockSocket.emit = originalEmit; }); }); describe('handleUserDisconnection', () => { const mockAuthenticatedSocket = { ...mockSocket, userId: 'user123', user: { sub: 'user123', username: 'testuser' }, rooms: new Set(['socket123', 'session123', 'session456']), sessionIds: new Set(['session123', 'session456']), // Add this line emit: jest.fn(), to: jest.fn().mockReturnThis(), join: jest.fn(), leave: jest.fn(), } as TestExtendedWebSocket; it('应该清理用户在所有会话中的数据', async () => { mockLocationBroadcastCore.removeUserFromSession.mockResolvedValue(undefined); mockLocationBroadcastCore.cleanupUserData.mockResolvedValue(undefined); await (gateway as any).handleUserDisconnection(mockAuthenticatedSocket, 'connection_lost'); expect(mockLocationBroadcastCore.removeUserFromSession).toHaveBeenCalledTimes(2); expect(mockLocationBroadcastCore.removeUserFromSession).toHaveBeenCalledWith('session123', 'user123'); expect(mockLocationBroadcastCore.removeUserFromSession).toHaveBeenCalledWith('session456', 'user123'); expect(mockLocationBroadcastCore.cleanupUserData).toHaveBeenCalledWith('user123'); }); it('应该处理清理过程中的错误', async () => { mockLocationBroadcastCore.removeUserFromSession.mockResolvedValue(undefined); mockLocationBroadcastCore.cleanupUserData.mockResolvedValue(undefined); // 应该不抛出异常 await expect((gateway as any).handleUserDisconnection(mockAuthenticatedSocket, 'connection_lost')) .resolves.toBeUndefined(); }); }); describe('WebSocket异常过滤器', () => { it('应该正确格式化WebSocket异常', () => { // 直接测试异常处理逻辑,而不是依赖过滤器类 const errorResponse = { type: 'error', code: 'TEST_ERROR', message: '测试错误', }; expect(errorResponse.type).toBe('error'); expect(errorResponse.code).toBe('TEST_ERROR'); expect(errorResponse.message).toBe('测试错误'); }); }); describe('集成测试场景', () => { it('应该处理完整的用户会话流程', async () => { const authenticatedSocket = { ...mockSocket, userId: 'user123', user: { sub: 'user123', username: 'testuser' }, emit: jest.fn(), to: jest.fn().mockReturnThis(), join: jest.fn(), leave: jest.fn(), rooms: new Set(), } as TestExtendedWebSocket; // 1. 用户加入会话 const joinMessage: JoinSessionMessage = { type: 'join_session', sessionId: 'session123', token: 'test_token', initialPosition: { mapId: 'plaza', x: 100, y: 200 }, }; mockLocationBroadcastCore.addUserToSession.mockResolvedValue(undefined); mockLocationBroadcastCore.setUserPosition.mockResolvedValue(undefined); mockLocationBroadcastCore.getSessionUsers.mockResolvedValue([]); mockLocationBroadcastCore.getSessionPositions.mockResolvedValue([]); await gateway.handleJoinSession(authenticatedSocket, joinMessage); // 2. 用户更新位置 const positionMessage: PositionUpdateMessage = { type: 'position_update', mapId: 'plaza', x: 150, y: 250, }; authenticatedSocket.rooms.add('session123'); await gateway.handlePositionUpdate(authenticatedSocket, positionMessage); // 3. 用户离开会话 const leaveMessage: LeaveSessionMessage = { type: 'leave_session', sessionId: 'session123', }; mockLocationBroadcastCore.removeUserFromSession.mockResolvedValue(undefined); await gateway.handleLeaveSession(authenticatedSocket, leaveMessage); // 验证完整流程 expect(mockLocationBroadcastCore.addUserToSession).toHaveBeenCalled(); expect(mockLocationBroadcastCore.setUserPosition).toHaveBeenCalledTimes(2); // 初始位置 + 更新位置 expect(mockLocationBroadcastCore.removeUserFromSession).toHaveBeenCalled(); }); it('应该处理并发用户的位置广播', async () => { const user1Socket = { ...mockSocket, id: 'socket1', userId: 'user1', rooms: new Set(['socket1', 'session123']), emit: jest.fn(), to: jest.fn().mockReturnThis(), join: jest.fn(), leave: jest.fn(), } as TestExtendedWebSocket; const user2Socket = { ...mockSocket, id: 'socket2', userId: 'user2', rooms: new Set(['socket2', 'session123']), emit: jest.fn(), to: jest.fn().mockReturnThis(), join: jest.fn(), leave: jest.fn(), } as TestExtendedWebSocket; mockLocationBroadcastCore.setUserPosition.mockResolvedValue(undefined); // 用户1更新位置 const position1: PositionUpdateMessage = { type: 'position_update', mapId: 'plaza', x: 100, y: 200, }; // 用户2更新位置 const position2: PositionUpdateMessage = { type: 'position_update', mapId: 'plaza', x: 150, y: 250, }; await Promise.all([ gateway.handlePositionUpdate(user1Socket, position1), gateway.handlePositionUpdate(user2Socket, position2), ]); expect(mockLocationBroadcastCore.setUserPosition).toHaveBeenCalledTimes(2); }); }); });