范围:src/business/chat/ - 实现 ChatService 聊天业务服务(登录/登出/消息发送/位置更新) - 实现 ChatSessionService 会话管理服务(会话创建/销毁/上下文注入) - 实现 ChatFilterService 消息过滤服务(频率限制/敏感词/权限验证) - 实现 ChatCleanupService 会话清理服务(定时清理过期会话) - 添加完整的单元测试覆盖 - 添加模块 README 文档
438 lines
14 KiB
TypeScript
438 lines
14 KiB
TypeScript
/**
|
|
* 聊天业务服务测试
|
|
*
|
|
* 测试范围:
|
|
* - 玩家登录/登出流程
|
|
* - 聊天消息发送和广播
|
|
* - 位置更新和会话管理
|
|
* - Token验证和错误处理
|
|
*
|
|
* @author moyin
|
|
* @version 1.0.0
|
|
* @since 2026-01-14
|
|
* @lastModified 2026-01-14
|
|
*/
|
|
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { Logger } from '@nestjs/common';
|
|
import { ChatService } from './chat.service';
|
|
import { ChatSessionService } from './services/chat_session.service';
|
|
import { ChatFilterService } from './services/chat_filter.service';
|
|
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
|
|
|
describe('ChatService', () => {
|
|
let service: ChatService;
|
|
let sessionService: jest.Mocked<ChatSessionService>;
|
|
let filterService: jest.Mocked<ChatFilterService>;
|
|
let zulipClientPool: any;
|
|
let apiKeySecurityService: any;
|
|
let loginCoreService: jest.Mocked<LoginCoreService>;
|
|
let mockWebSocketGateway: any;
|
|
|
|
beforeEach(async () => {
|
|
// Mock依赖
|
|
const mockSessionService = {
|
|
createSession: jest.fn(),
|
|
getSession: jest.fn(),
|
|
destroySession: jest.fn(),
|
|
updatePlayerPosition: jest.fn(),
|
|
injectContext: jest.fn(),
|
|
getSocketsInMap: jest.fn(),
|
|
};
|
|
|
|
const mockFilterService = {
|
|
validateMessage: jest.fn(),
|
|
filterContent: jest.fn(),
|
|
checkRateLimit: jest.fn(),
|
|
validatePermission: jest.fn(),
|
|
};
|
|
|
|
const mockZulipClientPool = {
|
|
createUserClient: jest.fn(),
|
|
destroyUserClient: jest.fn(),
|
|
sendMessage: jest.fn(),
|
|
};
|
|
|
|
const mockApiKeySecurityService = {
|
|
getApiKey: jest.fn(),
|
|
deleteApiKey: jest.fn(),
|
|
};
|
|
|
|
const mockLoginCoreService = {
|
|
verifyToken: jest.fn(),
|
|
};
|
|
|
|
mockWebSocketGateway = {
|
|
broadcastToMap: jest.fn(),
|
|
sendToPlayer: jest.fn(),
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
ChatService,
|
|
{
|
|
provide: ChatSessionService,
|
|
useValue: mockSessionService,
|
|
},
|
|
{
|
|
provide: ChatFilterService,
|
|
useValue: mockFilterService,
|
|
},
|
|
{
|
|
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
|
useValue: mockZulipClientPool,
|
|
},
|
|
{
|
|
provide: 'API_KEY_SECURITY_SERVICE',
|
|
useValue: mockApiKeySecurityService,
|
|
},
|
|
{
|
|
provide: LoginCoreService,
|
|
useValue: mockLoginCoreService,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<ChatService>(ChatService);
|
|
sessionService = module.get(ChatSessionService);
|
|
filterService = module.get(ChatFilterService);
|
|
zulipClientPool = module.get('ZULIP_CLIENT_POOL_SERVICE');
|
|
apiKeySecurityService = module.get('API_KEY_SECURITY_SERVICE');
|
|
loginCoreService = module.get(LoginCoreService);
|
|
|
|
// 设置WebSocket网关
|
|
service.setWebSocketGateway(mockWebSocketGateway);
|
|
|
|
// 禁用日志输出
|
|
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
|
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('初始化', () => {
|
|
it('应该成功创建服务实例', () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
it('应该成功设置WebSocket网关', () => {
|
|
const newGateway = { broadcastToMap: jest.fn(), sendToPlayer: jest.fn() };
|
|
service.setWebSocketGateway(newGateway);
|
|
expect(service['websocketGateway']).toBe(newGateway);
|
|
});
|
|
});
|
|
|
|
describe('handlePlayerLogin', () => {
|
|
const validToken = 'valid.jwt.token';
|
|
const socketId = 'socket_123';
|
|
|
|
it('应该成功处理玩家登录', async () => {
|
|
const userInfo = {
|
|
sub: 'user_123',
|
|
username: 'testuser',
|
|
email: 'test@example.com',
|
|
role: 1,
|
|
type: 'access' as 'access' | 'refresh',
|
|
};
|
|
|
|
loginCoreService.verifyToken.mockResolvedValue(userInfo);
|
|
sessionService.createSession.mockResolvedValue({
|
|
socketId,
|
|
userId: userInfo.sub,
|
|
username: userInfo.username,
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date(),
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.userId).toBe(userInfo.sub);
|
|
expect(result.username).toBe(userInfo.username);
|
|
expect(loginCoreService.verifyToken).toHaveBeenCalledWith(validToken, 'access');
|
|
expect(sessionService.createSession).toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该拒绝空Token', async () => {
|
|
const result = await service.handlePlayerLogin({ token: '', socketId });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Token或socketId不能为空');
|
|
expect(loginCoreService.verifyToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该拒绝空socketId', async () => {
|
|
const result = await service.handlePlayerLogin({ token: validToken, socketId: '' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Token或socketId不能为空');
|
|
});
|
|
|
|
it('应该处理Token验证失败', async () => {
|
|
loginCoreService.verifyToken.mockResolvedValue(null);
|
|
|
|
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Token验证失败');
|
|
});
|
|
|
|
it('应该处理Token验证异常', async () => {
|
|
loginCoreService.verifyToken.mockRejectedValue(new Error('Token expired'));
|
|
|
|
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Token验证失败');
|
|
});
|
|
|
|
it('应该处理会话创建失败', async () => {
|
|
const userInfo = { sub: 'user_123', username: 'testuser', email: 'test@example.com', role: 1, type: 'access' as 'access' | 'refresh' };
|
|
loginCoreService.verifyToken.mockResolvedValue(userInfo);
|
|
sessionService.createSession.mockRejectedValue(new Error('Redis error'));
|
|
|
|
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('登录失败,请稍后重试');
|
|
});
|
|
});
|
|
|
|
describe('handlePlayerLogout', () => {
|
|
const socketId = 'socket_123';
|
|
const userId = 'user_123';
|
|
|
|
it('应该成功处理玩家登出', async () => {
|
|
sessionService.getSession.mockResolvedValue({
|
|
socketId,
|
|
userId,
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date(),
|
|
createdAt: new Date(),
|
|
});
|
|
zulipClientPool.destroyUserClient.mockResolvedValue(undefined);
|
|
apiKeySecurityService.deleteApiKey.mockResolvedValue(undefined);
|
|
sessionService.destroySession.mockResolvedValue(true);
|
|
|
|
await service.handlePlayerLogout(socketId, 'manual');
|
|
|
|
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
|
expect(zulipClientPool.destroyUserClient).toHaveBeenCalledWith(userId);
|
|
expect(apiKeySecurityService.deleteApiKey).toHaveBeenCalledWith(userId);
|
|
expect(sessionService.destroySession).toHaveBeenCalledWith(socketId);
|
|
});
|
|
|
|
it('应该处理会话不存在的情况', async () => {
|
|
sessionService.getSession.mockResolvedValue(null);
|
|
|
|
await service.handlePlayerLogout(socketId);
|
|
|
|
expect(sessionService.destroySession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该处理Zulip客户端清理失败', async () => {
|
|
sessionService.getSession.mockResolvedValue({
|
|
socketId,
|
|
userId,
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date(),
|
|
createdAt: new Date(),
|
|
});
|
|
zulipClientPool.destroyUserClient.mockRejectedValue(new Error('Zulip error'));
|
|
sessionService.destroySession.mockResolvedValue(true);
|
|
|
|
await service.handlePlayerLogout(socketId);
|
|
|
|
expect(sessionService.destroySession).toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该处理API Key清理失败', async () => {
|
|
sessionService.getSession.mockResolvedValue({
|
|
socketId,
|
|
userId,
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date(),
|
|
createdAt: new Date(),
|
|
});
|
|
apiKeySecurityService.deleteApiKey.mockRejectedValue(new Error('Redis error'));
|
|
sessionService.destroySession.mockResolvedValue(true);
|
|
|
|
await service.handlePlayerLogout(socketId);
|
|
|
|
expect(sessionService.destroySession).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('sendChatMessage', () => {
|
|
const socketId = 'socket_123';
|
|
const userId = 'user_123';
|
|
const content = 'Hello, world!';
|
|
|
|
beforeEach(() => {
|
|
sessionService.getSession.mockResolvedValue({
|
|
socketId,
|
|
userId,
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date(),
|
|
createdAt: new Date(),
|
|
});
|
|
sessionService.injectContext.mockResolvedValue({
|
|
stream: 'Whale Port',
|
|
topic: 'General',
|
|
});
|
|
filterService.validateMessage.mockResolvedValue({
|
|
allowed: true,
|
|
filteredContent: content,
|
|
});
|
|
sessionService.getSocketsInMap.mockResolvedValue([socketId, 'socket_456']);
|
|
apiKeySecurityService.getApiKey.mockResolvedValue({
|
|
success: true,
|
|
apiKey: 'test_api_key',
|
|
});
|
|
});
|
|
|
|
it('应该成功发送聊天消息', async () => {
|
|
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.messageId).toBeDefined();
|
|
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
|
expect(filterService.validateMessage).toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该拒绝不存在的会话', async () => {
|
|
sessionService.getSession.mockResolvedValue(null);
|
|
|
|
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('会话不存在,请重新登录');
|
|
});
|
|
|
|
it('应该拒绝被过滤的消息', async () => {
|
|
filterService.validateMessage.mockResolvedValue({
|
|
allowed: false,
|
|
reason: '消息包含敏感词',
|
|
});
|
|
|
|
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('消息包含敏感词');
|
|
});
|
|
|
|
it('应该处理消息发送异常', async () => {
|
|
sessionService.getSession.mockRejectedValue(new Error('Redis error'));
|
|
|
|
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('消息发送失败,请稍后重试');
|
|
});
|
|
});
|
|
|
|
describe('updatePlayerPosition', () => {
|
|
const socketId = 'socket_123';
|
|
const mapId = 'whale_port';
|
|
const x = 500;
|
|
const y = 400;
|
|
|
|
it('应该成功更新玩家位置', async () => {
|
|
sessionService.updatePlayerPosition.mockResolvedValue(true);
|
|
|
|
const result = await service.updatePlayerPosition({ socketId, mapId, x, y });
|
|
|
|
expect(result).toBe(true);
|
|
expect(sessionService.updatePlayerPosition).toHaveBeenCalledWith(socketId, mapId, x, y);
|
|
});
|
|
|
|
it('应该拒绝空socketId', async () => {
|
|
const result = await service.updatePlayerPosition({ socketId: '', mapId, x, y });
|
|
|
|
expect(result).toBe(false);
|
|
expect(sessionService.updatePlayerPosition).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该拒绝空mapId', async () => {
|
|
const result = await service.updatePlayerPosition({ socketId, mapId: '', x, y });
|
|
|
|
expect(result).toBe(false);
|
|
expect(sessionService.updatePlayerPosition).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该处理更新失败', async () => {
|
|
sessionService.updatePlayerPosition.mockRejectedValue(new Error('Redis error'));
|
|
|
|
const result = await service.updatePlayerPosition({ socketId, mapId, x, y });
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getChatHistory', () => {
|
|
it('应该返回聊天历史', async () => {
|
|
const result = await service.getChatHistory({ mapId: 'whale_port' });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages).toBeDefined();
|
|
expect(Array.isArray(result.messages)).toBe(true);
|
|
});
|
|
|
|
it('应该支持分页查询', async () => {
|
|
const result = await service.getChatHistory({ mapId: 'whale_port', limit: 10, offset: 0 });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.count).toBeLessThanOrEqual(10);
|
|
});
|
|
});
|
|
|
|
describe('getSession', () => {
|
|
const socketId = 'socket_123';
|
|
|
|
it('应该返回会话信息', async () => {
|
|
const mockSession = {
|
|
socketId,
|
|
userId: 'user_123',
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date(),
|
|
createdAt: new Date(),
|
|
};
|
|
sessionService.getSession.mockResolvedValue(mockSession);
|
|
|
|
const result = await service.getSession(socketId);
|
|
|
|
expect(result).toEqual(mockSession);
|
|
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
|
});
|
|
|
|
it('应该处理会话不存在', async () => {
|
|
sessionService.getSession.mockResolvedValue(null);
|
|
|
|
const result = await service.getSession(socketId);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
});
|