范围:src/business/chat/ - 实现 ChatService 聊天业务服务(登录/登出/消息发送/位置更新) - 实现 ChatSessionService 会话管理服务(会话创建/销毁/上下文注入) - 实现 ChatFilterService 消息过滤服务(频率限制/敏感词/权限验证) - 实现 ChatCleanupService 会话清理服务(定时清理过期会话) - 添加完整的单元测试覆盖 - 添加模块 README 文档
610 lines
18 KiB
TypeScript
610 lines
18 KiB
TypeScript
/**
|
|
* 聊天会话管理服务测试
|
|
*
|
|
* 测试范围:
|
|
* - 会话创建和销毁
|
|
* - 位置更新和地图切换
|
|
* - 上下文注入和Stream/Topic映射
|
|
* - 过期会话清理
|
|
*
|
|
* @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 { ChatSessionService } from './chat_session.service';
|
|
|
|
describe('ChatSessionService', () => {
|
|
let service: ChatSessionService;
|
|
let redisService: any;
|
|
let configManager: any;
|
|
|
|
beforeEach(async () => {
|
|
const mockRedisService = {
|
|
get: jest.fn(),
|
|
setex: jest.fn(),
|
|
del: jest.fn(),
|
|
sadd: jest.fn(),
|
|
srem: jest.fn(),
|
|
smembers: jest.fn(),
|
|
expire: jest.fn(),
|
|
};
|
|
|
|
const mockConfigManager = {
|
|
getStreamByMap: jest.fn(),
|
|
findNearbyObject: jest.fn(),
|
|
getAllMapIds: jest.fn(),
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
ChatSessionService,
|
|
{
|
|
provide: 'REDIS_SERVICE',
|
|
useValue: mockRedisService,
|
|
},
|
|
{
|
|
provide: 'ZULIP_CONFIG_SERVICE',
|
|
useValue: mockConfigManager,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<ChatSessionService>(ChatSessionService);
|
|
redisService = module.get('REDIS_SERVICE');
|
|
configManager = module.get('ZULIP_CONFIG_SERVICE');
|
|
|
|
// 禁用日志输出
|
|
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
|
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('初始化', () => {
|
|
it('应该成功创建服务实例', () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('createSession', () => {
|
|
const socketId = 'socket_123';
|
|
const userId = 'user_123';
|
|
const zulipQueueId = 'queue_123';
|
|
const username = 'testuser';
|
|
|
|
beforeEach(() => {
|
|
redisService.get.mockResolvedValue(null);
|
|
redisService.setex.mockResolvedValue('OK');
|
|
redisService.sadd.mockResolvedValue(1);
|
|
redisService.expire.mockResolvedValue(1);
|
|
});
|
|
|
|
it('应该成功创建会话', async () => {
|
|
const session = await service.createSession(socketId, userId, zulipQueueId, username);
|
|
|
|
expect(session).toBeDefined();
|
|
expect(session.socketId).toBe(socketId);
|
|
expect(session.userId).toBe(userId);
|
|
expect(session.username).toBe(username);
|
|
expect(session.zulipQueueId).toBe(zulipQueueId);
|
|
expect(redisService.setex).toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该使用默认地图和位置', async () => {
|
|
const session = await service.createSession(socketId, userId, zulipQueueId);
|
|
|
|
expect(session.currentMap).toBe('novice_village');
|
|
expect(session.position).toEqual({ x: 400, y: 300 });
|
|
});
|
|
|
|
it('应该使用提供的初始地图和位置', async () => {
|
|
const initialMap = 'whale_port';
|
|
const initialPosition = { x: 500, y: 400 };
|
|
|
|
const session = await service.createSession(
|
|
socketId,
|
|
userId,
|
|
zulipQueueId,
|
|
username,
|
|
initialMap,
|
|
initialPosition
|
|
);
|
|
|
|
expect(session.currentMap).toBe(initialMap);
|
|
expect(session.position).toEqual(initialPosition);
|
|
});
|
|
|
|
it('应该拒绝空socketId', async () => {
|
|
await expect(service.createSession('', userId, zulipQueueId)).rejects.toThrow('参数不能为空');
|
|
});
|
|
|
|
it('应该拒绝空userId', async () => {
|
|
await expect(service.createSession(socketId, '', zulipQueueId)).rejects.toThrow('参数不能为空');
|
|
});
|
|
|
|
it('应该拒绝空zulipQueueId', async () => {
|
|
await expect(service.createSession(socketId, userId, '')).rejects.toThrow('参数不能为空');
|
|
});
|
|
|
|
it('应该清理旧会话', async () => {
|
|
const oldSocketId = 'old_socket_123';
|
|
redisService.get.mockResolvedValueOnce(oldSocketId);
|
|
redisService.get.mockResolvedValueOnce(JSON.stringify({
|
|
socketId: oldSocketId,
|
|
userId,
|
|
username,
|
|
zulipQueueId,
|
|
currentMap: 'novice_village',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date().toISOString(),
|
|
createdAt: new Date().toISOString(),
|
|
}));
|
|
|
|
await service.createSession(socketId, userId, zulipQueueId, username);
|
|
|
|
expect(redisService.del).toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该添加到地图玩家列表', async () => {
|
|
await service.createSession(socketId, userId, zulipQueueId, username);
|
|
|
|
expect(redisService.sadd).toHaveBeenCalledWith(
|
|
expect.stringContaining('chat:map_players:'),
|
|
socketId
|
|
);
|
|
});
|
|
|
|
it('应该生成默认用户名', async () => {
|
|
const session = await service.createSession(socketId, userId, zulipQueueId);
|
|
|
|
expect(session.username).toBe(`user_${userId}`);
|
|
});
|
|
});
|
|
|
|
describe('getSession', () => {
|
|
const socketId = 'socket_123';
|
|
const mockSessionData = {
|
|
socketId,
|
|
userId: 'user_123',
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date().toISOString(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
it('应该返回会话信息', async () => {
|
|
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
|
redisService.setex.mockResolvedValue('OK');
|
|
|
|
const session = await service.getSession(socketId);
|
|
|
|
expect(session).toBeDefined();
|
|
expect(session?.socketId).toBe(socketId);
|
|
expect(session?.userId).toBe(mockSessionData.userId);
|
|
});
|
|
|
|
it('应该更新最后活动时间', async () => {
|
|
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
|
redisService.setex.mockResolvedValue('OK');
|
|
|
|
await service.getSession(socketId);
|
|
|
|
expect(redisService.setex).toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该处理会话不存在', async () => {
|
|
redisService.get.mockResolvedValue(null);
|
|
|
|
const session = await service.getSession(socketId);
|
|
|
|
expect(session).toBeNull();
|
|
});
|
|
|
|
it('应该拒绝空socketId', async () => {
|
|
const session = await service.getSession('');
|
|
|
|
expect(session).toBeNull();
|
|
});
|
|
|
|
it('应该处理Redis错误', async () => {
|
|
redisService.get.mockRejectedValue(new Error('Redis error'));
|
|
|
|
const session = await service.getSession(socketId);
|
|
|
|
expect(session).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('injectContext', () => {
|
|
const socketId = 'socket_123';
|
|
const mockSessionData = {
|
|
socketId,
|
|
userId: 'user_123',
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date().toISOString(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
|
redisService.setex.mockResolvedValue('OK');
|
|
configManager.getStreamByMap.mockReturnValue('Whale Port');
|
|
configManager.findNearbyObject.mockReturnValue(null);
|
|
});
|
|
|
|
it('应该返回正确的Stream', async () => {
|
|
const context = await service.injectContext(socketId);
|
|
|
|
expect(context.stream).toBe('Whale Port');
|
|
});
|
|
|
|
it('应该使用默认Topic', async () => {
|
|
const context = await service.injectContext(socketId);
|
|
|
|
expect(context.topic).toBe('General');
|
|
});
|
|
|
|
it('应该根据附近对象设置Topic', async () => {
|
|
configManager.findNearbyObject.mockReturnValue({
|
|
zulipTopic: 'Tavern',
|
|
});
|
|
|
|
const context = await service.injectContext(socketId);
|
|
|
|
expect(context.topic).toBe('Tavern');
|
|
});
|
|
|
|
it('应该支持指定地图ID', async () => {
|
|
configManager.getStreamByMap.mockReturnValue('Market');
|
|
|
|
const context = await service.injectContext(socketId, 'market');
|
|
|
|
expect(configManager.getStreamByMap).toHaveBeenCalledWith('market');
|
|
});
|
|
|
|
it('应该处理会话不存在', async () => {
|
|
redisService.get.mockResolvedValue(null);
|
|
|
|
const context = await service.injectContext(socketId);
|
|
|
|
expect(context.stream).toBe('General');
|
|
});
|
|
|
|
it('应该处理地图没有对应Stream', async () => {
|
|
configManager.getStreamByMap.mockReturnValue(null);
|
|
|
|
const context = await service.injectContext(socketId);
|
|
|
|
expect(context.stream).toBe('General');
|
|
});
|
|
});
|
|
|
|
describe('getSocketsInMap', () => {
|
|
const mapId = 'whale_port';
|
|
|
|
it('应该返回地图中的所有Socket', async () => {
|
|
const sockets = ['socket_1', 'socket_2', 'socket_3'];
|
|
redisService.smembers.mockResolvedValue(sockets);
|
|
|
|
const result = await service.getSocketsInMap(mapId);
|
|
|
|
expect(result).toEqual(sockets);
|
|
});
|
|
|
|
it('应该处理空地图', async () => {
|
|
redisService.smembers.mockResolvedValue([]);
|
|
|
|
const result = await service.getSocketsInMap(mapId);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('应该处理Redis错误', async () => {
|
|
redisService.smembers.mockRejectedValue(new Error('Redis error'));
|
|
|
|
const result = await service.getSocketsInMap(mapId);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('updatePlayerPosition', () => {
|
|
const socketId = 'socket_123';
|
|
const mapId = 'whale_port';
|
|
const x = 500;
|
|
const y = 400;
|
|
const mockSessionData = {
|
|
socketId,
|
|
userId: 'user_123',
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'novice_village',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date().toISOString(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
|
redisService.setex.mockResolvedValue('OK');
|
|
redisService.srem.mockResolvedValue(1);
|
|
redisService.sadd.mockResolvedValue(1);
|
|
redisService.expire.mockResolvedValue(1);
|
|
});
|
|
|
|
it('应该成功更新位置', async () => {
|
|
const result = await service.updatePlayerPosition(socketId, mapId, x, y);
|
|
|
|
expect(result).toBe(true);
|
|
expect(redisService.setex).toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该更新地图玩家列表当切换地图', async () => {
|
|
await service.updatePlayerPosition(socketId, mapId, x, y);
|
|
|
|
expect(redisService.srem).toHaveBeenCalled();
|
|
expect(redisService.sadd).toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该不更新地图玩家列表当在同一地图', async () => {
|
|
const sameMapData = { ...mockSessionData, currentMap: mapId };
|
|
redisService.get.mockResolvedValue(JSON.stringify(sameMapData));
|
|
|
|
await service.updatePlayerPosition(socketId, mapId, x, y);
|
|
|
|
expect(redisService.srem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该拒绝空socketId', async () => {
|
|
const result = await service.updatePlayerPosition('', mapId, x, y);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('应该拒绝空mapId', async () => {
|
|
const result = await service.updatePlayerPosition(socketId, '', x, y);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('应该处理会话不存在', async () => {
|
|
redisService.get.mockResolvedValue(null);
|
|
|
|
const result = await service.updatePlayerPosition(socketId, mapId, x, y);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('应该处理Redis错误', async () => {
|
|
redisService.get.mockRejectedValue(new Error('Redis error'));
|
|
|
|
const result = await service.updatePlayerPosition(socketId, mapId, x, y);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('destroySession', () => {
|
|
const socketId = 'socket_123';
|
|
const mockSessionData = {
|
|
socketId,
|
|
userId: 'user_123',
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date().toISOString(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
|
redisService.srem.mockResolvedValue(1);
|
|
redisService.del.mockResolvedValue(1);
|
|
});
|
|
|
|
it('应该成功销毁会话', async () => {
|
|
const result = await service.destroySession(socketId);
|
|
|
|
expect(result).toBe(true);
|
|
expect(redisService.del).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('应该从地图玩家列表移除', async () => {
|
|
await service.destroySession(socketId);
|
|
|
|
expect(redisService.srem).toHaveBeenCalled();
|
|
});
|
|
|
|
it('应该删除用户会话映射', async () => {
|
|
await service.destroySession(socketId);
|
|
|
|
expect(redisService.del).toHaveBeenCalledWith(
|
|
expect.stringContaining('chat:user_session:')
|
|
);
|
|
});
|
|
|
|
it('应该处理会话不存在', async () => {
|
|
redisService.get.mockResolvedValue(null);
|
|
|
|
const result = await service.destroySession(socketId);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('应该拒绝空socketId', async () => {
|
|
const result = await service.destroySession('');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('应该处理Redis错误', async () => {
|
|
redisService.get.mockRejectedValue(new Error('Redis error'));
|
|
|
|
const result = await service.destroySession(socketId);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('cleanupExpiredSessions', () => {
|
|
beforeEach(() => {
|
|
configManager.getAllMapIds.mockReturnValue(['novice_village', 'whale_port']);
|
|
});
|
|
|
|
it('应该清理过期会话', async () => {
|
|
const expiredSession = {
|
|
socketId: 'socket_123',
|
|
userId: 'user_123',
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
redisService.smembers.mockResolvedValue(['socket_123']);
|
|
redisService.get.mockResolvedValueOnce(JSON.stringify(expiredSession));
|
|
redisService.get.mockResolvedValueOnce(JSON.stringify(expiredSession));
|
|
redisService.srem.mockResolvedValue(1);
|
|
redisService.del.mockResolvedValue(1);
|
|
|
|
const result = await service.cleanupExpiredSessions(30);
|
|
|
|
expect(result.cleanedCount).toBeGreaterThanOrEqual(1);
|
|
expect(result.zulipQueueIds).toContain('queue_123');
|
|
});
|
|
|
|
it('应该不清理未过期会话', async () => {
|
|
const activeSession = {
|
|
socketId: 'socket_123',
|
|
userId: 'user_123',
|
|
username: 'testuser',
|
|
zulipQueueId: 'queue_123',
|
|
currentMap: 'whale_port',
|
|
position: { x: 400, y: 300 },
|
|
lastActivity: new Date().toISOString(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
redisService.smembers.mockResolvedValue(['socket_123']);
|
|
redisService.get.mockResolvedValue(JSON.stringify(activeSession));
|
|
|
|
const result = await service.cleanupExpiredSessions(30);
|
|
|
|
expect(result.cleanedCount).toBe(0);
|
|
});
|
|
|
|
it('应该处理多个地图', async () => {
|
|
redisService.smembers.mockResolvedValue([]);
|
|
|
|
const result = await service.cleanupExpiredSessions(30);
|
|
|
|
expect(redisService.smembers).toHaveBeenCalledTimes(2);
|
|
expect(result.cleanedCount).toBe(0);
|
|
});
|
|
|
|
it('应该使用默认地图当配置为空', async () => {
|
|
configManager.getAllMapIds.mockReturnValue([]);
|
|
redisService.smembers.mockResolvedValue([]);
|
|
|
|
const result = await service.cleanupExpiredSessions(30);
|
|
|
|
expect(result.cleanedCount).toBe(0);
|
|
});
|
|
|
|
it('应该处理清理过程中的错误', async () => {
|
|
redisService.smembers.mockRejectedValue(new Error('Redis error'));
|
|
|
|
const result = await service.cleanupExpiredSessions(30);
|
|
|
|
expect(result.cleanedCount).toBe(0);
|
|
expect(result.zulipQueueIds).toEqual([]);
|
|
});
|
|
|
|
it('应该清理不存在的会话数据', async () => {
|
|
redisService.smembers.mockResolvedValue(['socket_123']);
|
|
redisService.get.mockResolvedValue(null);
|
|
redisService.srem.mockResolvedValue(1);
|
|
|
|
const result = await service.cleanupExpiredSessions(30);
|
|
|
|
expect(redisService.srem).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('边界情况', () => {
|
|
it('应该处理极大的坐标值', async () => {
|
|
const socketId = 'socket_123';
|
|
const userId = 'user_123';
|
|
const zulipQueueId = 'queue_123';
|
|
|
|
redisService.get.mockResolvedValue(null);
|
|
redisService.setex.mockResolvedValue('OK');
|
|
redisService.sadd.mockResolvedValue(1);
|
|
redisService.expire.mockResolvedValue(1);
|
|
|
|
const session = await service.createSession(
|
|
socketId,
|
|
userId,
|
|
zulipQueueId,
|
|
'testuser',
|
|
'whale_port',
|
|
{ x: 999999, y: 999999 }
|
|
);
|
|
|
|
expect(session.position).toEqual({ x: 999999, y: 999999 });
|
|
});
|
|
|
|
it('应该处理负坐标值', async () => {
|
|
const socketId = 'socket_123';
|
|
const userId = 'user_123';
|
|
const zulipQueueId = 'queue_123';
|
|
|
|
redisService.get.mockResolvedValue(null);
|
|
redisService.setex.mockResolvedValue('OK');
|
|
redisService.sadd.mockResolvedValue(1);
|
|
redisService.expire.mockResolvedValue(1);
|
|
|
|
const session = await service.createSession(
|
|
socketId,
|
|
userId,
|
|
zulipQueueId,
|
|
'testuser',
|
|
'whale_port',
|
|
{ x: -100, y: -100 }
|
|
);
|
|
|
|
expect(session.position).toEqual({ x: -100, y: -100 });
|
|
});
|
|
|
|
it('应该处理特殊字符的用户名', async () => {
|
|
const socketId = 'socket_123';
|
|
const userId = 'user_123';
|
|
const zulipQueueId = 'queue_123';
|
|
const username = 'test@user#123';
|
|
|
|
redisService.get.mockResolvedValue(null);
|
|
redisService.setex.mockResolvedValue('OK');
|
|
redisService.sadd.mockResolvedValue(1);
|
|
redisService.expire.mockResolvedValue(1);
|
|
|
|
const session = await service.createSession(socketId, userId, zulipQueueId, username);
|
|
|
|
expect(session.username).toBe(username);
|
|
});
|
|
});
|
|
});
|