/** * 聊天会话管理服务测试 * * 测试范围: * - 会话创建和销毁 * - 位置更新和地图切换 * - 上下文注入和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); 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); }); }); });