Files
whale-town-end/src/business/location_broadcast/location_broadcast.gateway.spec.ts
moyin cbf4120ddd refactor: 更新WebSocket相关测试和location_broadcast模块
- 更新location_broadcast网关以支持原生WebSocket
- 修改WebSocket认证守卫和中间件
- 更新相关的测试文件和规范
- 添加WebSocket测试工具
- 完善Zulip服务的测试覆盖

技术改进:
- 统一WebSocket实现架构
- 优化性能监控和限流中间件
- 更新测试用例以适配新的WebSocket实现
2026-01-09 17:02:43 +08:00

613 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 位置广播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<string>;
connectionTimeout?: NodeJS.Timeout;
isAlive?: boolean;
emit: jest.Mock;
to: jest.Mock;
join: jest.Mock;
leave: jest.Mock;
rooms: Set<string>;
}
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<string>(),
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>(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<string>(),
} 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<string>(),
} 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<string>(),
} 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);
});
});
});