Files
whale-town-end-v2/src/business/chat/chat.service.spec.ts

771 lines
25 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.
/**
* 聊天业务服务测试
*
* 测试范围:
* - 玩家登录/登出流程
* - 聊天消息发送和广播
* - 位置更新和会话管理
* - 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<ChatSessionService>;
let filterService: jest.Mocked<ChatFilterService>;
let zulipClientPool: any;
let apiKeySecurityService: any;
let loginCoreService: jest.Mocked<LoginCoreService>;
let mockWebSocketGateway: any;
let economyService: jest.Mocked<Pick<EconomyService, 'spend' | 'earn'>>;
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>(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();
});
});
});