/** * 聊天业务服务测试 * * 测试范围: * - 玩家登录/登出流程 * - 聊天消息发送和广播 * - 位置更新和会话管理 * - Token验证和错误处理 * * @author moyin * @version 1.0.1 * @since 2026-01-14 * @lastModified 2026-01-19 * * 修改记录: * - 2026-01-19 moyin: 修复handlePlayerLogout测试,删除不再调用的deleteApiKey断言和过时测试用例 */ 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'; import { AccountProfileService } from '../auth/account_profile.service'; import { EconomyService } from '../player/economy.service'; describe('ChatService', () => { let service: ChatService; let sessionService: jest.Mocked; let filterService: jest.Mocked; let zulipClientPool: any; let apiKeySecurityService: any; let loginCoreService: jest.Mocked; let mockWebSocketGateway: any; let economyService: jest.Mocked>; beforeEach(async () => { // Mock依赖 const mockSessionService = { createSession: jest.fn(), getSession: jest.fn(), destroySession: jest.fn(), updatePlayerPosition: jest.fn(), injectContext: jest.fn(), getSocketsInMap: jest.fn(), getSocketIdByUserId: jest.fn(), addFriend: jest.fn(), createFriendRequest: jest.fn(), acceptFriendRequest: jest.fn(), rejectFriendRequest: jest.fn(), removeFriend: jest.fn(), getFriends: jest.fn(), getFriendRequests: 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(), getUserClient: jest.fn(), }; const mockApiKeySecurityService = { getApiKey: jest.fn(), deleteApiKey: jest.fn(), }; const mockLoginCoreService = { verifyToken: jest.fn(), }; const mockAccountProfileService = { getAccountProfile: jest.fn(), }; const mockEconomyService = { spend: jest.fn(), earn: jest.fn(), }; const mockZulipAccountsService = { findByGameUserId: jest.fn(), }; mockWebSocketGateway = { broadcastToMap: jest.fn(), broadcastToAll: 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, }, { provide: AccountProfileService, useValue: mockAccountProfileService, }, { provide: EconomyService, useValue: mockEconomyService, }, { provide: 'ZulipAccountsService', useValue: mockZulipAccountsService, }, ], }).compile(); service = module.get(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); economyService = module.get(EconomyService); // 设置默认的mock行为 // ZulipAccountsService默认返回null(用户没有Zulip账号) const zulipAccountsService = module.get('ZulipAccountsService'); zulipAccountsService.findByGameUserId.mockResolvedValue(null); // ZulipClientPool的getUserClient默认返回null zulipClientPool.getUserClient.mockResolvedValue(null); // 设置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(), broadcastToAll: 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); sessionService.destroySession.mockResolvedValue(true); await service.handlePlayerLogout(socketId, 'manual'); expect(sessionService.getSession).toHaveBeenCalledWith(socketId); expect(zulipClientPool.destroyUserClient).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(); }); }); 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 () => { const result = await service.sendChatMessage({ socketId, content, scope: 'global' }); expect(result.success).toBe(true); expect(mockWebSocketGateway.broadcastToAll).toHaveBeenCalledWith( expect.objectContaining({ t: 'chat_render', from: 'testuser', fromUserId: userId, txt: content, scope: 'global', }), socketId, ); expect(sessionService.getSocketsInMap).not.toHaveBeenCalled(); expect(economyService.spend).not.toHaveBeenCalled(); }); it('应该由服务端固定扣除 100 鲸币后发布世界公告', async () => { sessionService.getSession.mockResolvedValue({ socketId, userId: '123', username: 'testuser', zulipQueueId: 'queue_123', currentMap: 'whale_port', position: { x: 400, y: 300 }, lastActivity: new Date(), createdAt: new Date(), }); economyService.spend.mockResolvedValue({ user_id: '123', balance: 900, currency: 'whale_coin', }); const result = await service.sendChatMessage({ socketId, content, scope: 'local', worldBulletin: true, ...({ cost: 1 } as any), }); expect(result).toEqual(expect.objectContaining({ success: true, charged: 100, balance: 900 })); expect(economyService.spend).toHaveBeenCalledWith( BigInt(123), 100, 'world_bulletin', expect.stringMatching(/^game_/), '发布世界公告', ); expect(mockWebSocketGateway.broadcastToAll).toHaveBeenCalledWith( expect.objectContaining({ scope: 'global', worldBulletin: true }), undefined, ); }); it('应该在鲸币余额不足时拒绝公告且不广播', async () => { sessionService.getSession.mockResolvedValue({ socketId, userId: '123', username: 'testuser', zulipQueueId: 'queue_123', currentMap: 'whale_port', position: { x: 400, y: 300 }, lastActivity: new Date(), createdAt: new Date(), }); economyService.spend.mockRejectedValue(new Error('鲸币余额不足')); const result = await service.sendChatMessage({ socketId, content, scope: 'global', worldBulletin: true }); expect(result).toEqual({ success: false, error: '鲸币余额不足,发布世界公告需要 100 鲸币' }); expect(mockWebSocketGateway.broadcastToAll).not.toHaveBeenCalled(); }); it('应该在公告广播失败时退回已扣鲸币', async () => { sessionService.getSession.mockResolvedValue({ socketId, userId: '123', username: 'testuser', zulipQueueId: 'queue_123', currentMap: 'whale_port', position: { x: 400, y: 300 }, lastActivity: new Date(), createdAt: new Date(), }); economyService.spend.mockResolvedValue({ user_id: '123', balance: 900, currency: 'whale_coin' }); economyService.earn.mockResolvedValue({ user_id: '123', balance: 1000, currency: 'whale_coin' }); mockWebSocketGateway.broadcastToAll.mockImplementation(() => { throw new Error('广播失败'); }); const result = await service.sendChatMessage({ socketId, content, scope: 'global', worldBulletin: true }); expect(result).toEqual({ success: false, error: '广播失败' }); expect(economyService.earn).toHaveBeenCalledWith( BigInt(123), 100, 'world_bulletin_refund', expect.stringMatching(/^game_/), '世界公告发送失败退款', ); }); it('应该只在显式请求时广播角色气泡', async () => { await service.sendChatMessage({ socketId, content, scope: 'global' }); expect(mockWebSocketGateway.broadcastToAll).toHaveBeenLastCalledWith( expect.objectContaining({ bubble: false, }), socketId, ); await service.sendChatMessage({ socketId, content: 'bubble hello', scope: 'global', bubble: true }); expect(mockWebSocketGateway.broadcastToAll).toHaveBeenLastCalledWith( expect.objectContaining({ bubble: true, }), socketId, ); }); it('应该将私聊消息只发送给发送者和目标玩家', async () => { sessionService.getSocketIdByUserId.mockResolvedValue('socket_target'); const result = await service.sendChatMessage({ socketId, content, scope: 'private', targetUserId: 'user_target', targetUsername: 'targetuser', }); expect(result.success).toBe(true); expect(sessionService.getSocketIdByUserId).toHaveBeenCalledWith('user_target'); expect(mockWebSocketGateway.sendToPlayer).toHaveBeenCalledWith( socketId, expect.objectContaining({ scope: 'private', fromUserId: userId, toUserId: 'user_target', toUsername: 'targetuser', }), ); expect(mockWebSocketGateway.sendToPlayer).toHaveBeenCalledWith( 'socket_target', expect.objectContaining({ scope: 'private', fromUserId: userId, toUserId: 'user_target', }), ); expect(mockWebSocketGateway.broadcastToAll).not.toHaveBeenCalled(); }); it('应该拒绝缺少目标用户的私聊', async () => { const result = await service.sendChatMessage({ socketId, content, scope: 'private' }); expect(result.success).toBe(false); expect(result.error).toBe('请选择悄悄话对象'); expect(mockWebSocketGateway.sendToPlayer).not.toHaveBeenCalled(); }); it('应该拒绝目标离线的私聊', async () => { sessionService.getSocketIdByUserId.mockResolvedValue(null); const result = await service.sendChatMessage({ socketId, content, scope: 'private', targetUserId: 'user_target', }); expect(result.success).toBe(false); expect(result.error).toBe('悄悄话对象不在线'); }); 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, { appearance: undefined, }); }); 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('friends', () => { const socketId = 'socket_123'; const userId = 'user_123'; 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(), }); }); it('应该添加好友', async () => { const friend = { userId: 'user_friend', username: 'friend', online: true }; sessionService.addFriend.mockResolvedValue(friend); const result = await service.addFriend({ socketId, friendUserId: friend.userId, friendUsername: friend.username, }); expect(result.success).toBe(true); expect(result.friend).toEqual(friend); expect(sessionService.addFriend).toHaveBeenCalledWith(userId, friend.userId, friend.username); }); it('应该获取好友列表', async () => { const friends = [{ userId: 'user_friend', username: 'friend', online: false }]; const requests = [{ userId: 'requester', username: 'requester', createdAt: '2026-07-01T00:00:00.000Z' }]; sessionService.getFriends.mockResolvedValue(friends); sessionService.getFriendRequests.mockResolvedValue(requests); const result = await service.getFriends(socketId); expect(result.success).toBe(true); expect(result.friends).toEqual(friends); expect(result.requests).toEqual(requests); expect(sessionService.getFriends).toHaveBeenCalledWith(userId); expect(sessionService.getFriendRequests).toHaveBeenCalledWith(userId); }); it('应该发送好友请求并实时通知在线目标', async () => { const friendRequest = { userId, username: 'testuser', createdAt: '2026-07-01T00:00:00.000Z' }; sessionService.createFriendRequest.mockResolvedValue(friendRequest); sessionService.getSocketIdByUserId.mockResolvedValue('target_socket'); const result = await service.requestFriend({ socketId, friendUserId: 'user_friend', }); expect(result.success).toBe(true); expect(sessionService.createFriendRequest).toHaveBeenCalledWith(userId, 'testuser', 'user_friend'); expect(mockWebSocketGateway.sendToPlayer).toHaveBeenCalledWith('target_socket', { t: 'friend_request_received', request: friendRequest, }); }); it('应该接受好友请求并通知发起方', async () => { const friend = { userId: 'requester', username: 'requester', online: true }; const reciprocalFriend = { userId, username: 'testuser', online: true }; sessionService.acceptFriendRequest.mockResolvedValue({ friend, reciprocalFriend }); sessionService.getSocketIdByUserId.mockResolvedValue('requester_socket'); const result = await service.acceptFriendRequest({ socketId, friendUserId: 'requester', }); expect(result.success).toBe(true); expect(result.friend).toEqual(friend); expect(sessionService.acceptFriendRequest).toHaveBeenCalledWith(userId, 'requester', 'testuser'); expect(mockWebSocketGateway.sendToPlayer).toHaveBeenCalledWith('requester_socket', { t: 'friend_request_accepted', friend: reciprocalFriend, }); }); it('应该拒绝好友请求并通知发起方', async () => { sessionService.rejectFriendRequest.mockResolvedValue(undefined); sessionService.getSocketIdByUserId.mockResolvedValue('requester_socket'); const result = await service.rejectFriendRequest({ socketId, friendUserId: 'requester', }); expect(result.success).toBe(true); expect(sessionService.rejectFriendRequest).toHaveBeenCalledWith(userId, 'requester'); expect(mockWebSocketGateway.sendToPlayer).toHaveBeenCalledWith('requester_socket', { t: 'friend_request_rejected', userId, username: 'testuser', }); }); it('应该移除好友', async () => { sessionService.removeFriend.mockResolvedValue(undefined); const result = await service.removeFriend({ socketId, friendUserId: 'user_friend', }); expect(result.success).toBe(true); expect(sessionService.removeFriend).toHaveBeenCalledWith(userId, 'user_friend'); }); it('应该在未登录时拒绝好友操作', async () => { sessionService.getSession.mockResolvedValue(null); const result = await service.getFriends(socketId); expect(result.success).toBe(false); expect(result.error).toBe('会话不存在,请重新登录'); }); }); 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(); }); }); });