/** * 会话管理服务测试 * * 功能描述: * - 测试SessionManagerService的核心功能 * - 包含属性测试验证会话状态一致性 * * @author angjustinl * @version 1.0.0 * @since 2025-12-25 */ import { Test, TestingModule } from '@nestjs/testing'; import * as fc from 'fast-check'; import { SessionManagerService, GameSession, Position } from './session_manager.service'; import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces'; import { AppLoggerService } from '../../../core/utils/logger/logger.service'; import { IRedisService } from '../../../core/redis/redis.interface'; describe('SessionManagerService', () => { let service: SessionManagerService; let mockLogger: jest.Mocked; let mockRedisService: jest.Mocked; let mockConfigManager: jest.Mocked; // 内存存储模拟Redis let memoryStore: Map; let memorySets: Map>; beforeEach(async () => { jest.clearAllMocks(); // 初始化内存存储 memoryStore = new Map(); memorySets = new Map(); mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), } as any; mockConfigManager = { getStreamByMap: jest.fn().mockImplementation((mapId: string) => { const streamMap: Record = { 'whale_port': 'Whale Port', 'pumpkin_valley': 'Pumpkin Valley', 'offer_city': 'Offer City', 'model_factory': 'Model Factory', 'kernel_island': 'Kernel Island', 'moyu_beach': 'Moyu Beach', 'ladder_peak': 'Ladder Peak', 'galaxy_bay': 'Galaxy Bay', 'data_ruins': 'Data Ruins', 'novice_village': 'Novice Village', }; return streamMap[mapId] || 'General'; }), getMapIdByStream: jest.fn(), getTopicByObject: jest.fn().mockReturnValue('General'), getZulipConfig: jest.fn(), hasMap: jest.fn(), hasStream: jest.fn(), getAllMapIds: jest.fn(), getAllStreams: jest.fn(), reloadConfig: jest.fn(), validateConfig: jest.fn(), } as any; // 创建模拟Redis服务,使用内存存储 mockRedisService = { set: jest.fn().mockImplementation(async (key: string, value: string, ttl?: number) => { memoryStore.set(key, { value, expireAt: ttl ? Date.now() + ttl * 1000 : undefined }); }), setex: jest.fn().mockImplementation(async (key: string, ttl: number, value: string) => { memoryStore.set(key, { value, expireAt: Date.now() + ttl * 1000 }); }), get: jest.fn().mockImplementation(async (key: string) => { const item = memoryStore.get(key); if (!item) return null; if (item.expireAt && item.expireAt <= Date.now()) { memoryStore.delete(key); return null; } return item.value; }), del: jest.fn().mockImplementation(async (key: string) => { const existed = memoryStore.has(key); memoryStore.delete(key); return existed; }), exists: jest.fn().mockImplementation(async (key: string) => { return memoryStore.has(key); }), expire: jest.fn().mockImplementation(async (key: string, ttl: number) => { const item = memoryStore.get(key); if (item) { item.expireAt = Date.now() + ttl * 1000; } }), ttl: jest.fn().mockResolvedValue(3600), incr: jest.fn().mockResolvedValue(1), sadd: jest.fn().mockImplementation(async (key: string, member: string) => { if (!memorySets.has(key)) { memorySets.set(key, new Set()); } memorySets.get(key)!.add(member); }), srem: jest.fn().mockImplementation(async (key: string, member: string) => { const set = memorySets.get(key); if (set) { set.delete(member); } }), smembers: jest.fn().mockImplementation(async (key: string) => { const set = memorySets.get(key); return set ? Array.from(set) : []; }), flushall: jest.fn().mockImplementation(async () => { memoryStore.clear(); memorySets.clear(); }), } as any; const module: TestingModule = await Test.createTestingModule({ providers: [ SessionManagerService, { provide: AppLoggerService, useValue: mockLogger, }, { provide: 'REDIS_SERVICE', useValue: mockRedisService, }, { provide: 'ZULIP_CONFIG_SERVICE', useValue: mockConfigManager, }, ], }).compile(); service = module.get(SessionManagerService); }); afterEach(async () => { // 清理内存存储 memoryStore.clear(); memorySets.clear(); // 等待任何正在进行的异步操作完成 await new Promise(resolve => setImmediate(resolve)); }); it('should be defined', () => { expect(service).toBeDefined(); }); describe('createSession - 创建会话', () => { it('应该成功创建新会话', async () => { const session = await service.createSession( 'socket-123', 'user-456', 'queue-789', 'TestUser', ); expect(session).toBeDefined(); expect(session.socketId).toBe('socket-123'); expect(session.userId).toBe('user-456'); expect(session.zulipQueueId).toBe('queue-789'); expect(session.username).toBe('TestUser'); expect(session.currentMap).toBe('novice_village'); }); it('应该在socketId为空时抛出错误', async () => { await expect(service.createSession('', 'user-456', 'queue-789')) .rejects.toThrow('socketId不能为空'); }); it('应该在userId为空时抛出错误', async () => { await expect(service.createSession('socket-123', '', 'queue-789')) .rejects.toThrow('userId不能为空'); }); it('应该在zulipQueueId为空时抛出错误', async () => { await expect(service.createSession('socket-123', 'user-456', '')) .rejects.toThrow('zulipQueueId不能为空'); }); it('应该清理用户已有的旧会话', async () => { // 创建第一个会话 await service.createSession('socket-old', 'user-456', 'queue-old'); // 创建第二个会话(同一用户) const newSession = await service.createSession('socket-new', 'user-456', 'queue-new'); expect(newSession.socketId).toBe('socket-new'); // 旧会话应该被清理 const oldSession = await service.getSession('socket-old'); expect(oldSession).toBeNull(); }); }); describe('getSession - 获取会话', () => { it('应该返回已存在的会话', async () => { await service.createSession('socket-123', 'user-456', 'queue-789'); const session = await service.getSession('socket-123'); expect(session).toBeDefined(); expect(session?.socketId).toBe('socket-123'); }); it('应该在会话不存在时返回null', async () => { const session = await service.getSession('nonexistent'); expect(session).toBeNull(); }); it('应该在socketId为空时返回null', async () => { const session = await service.getSession(''); expect(session).toBeNull(); }); }); describe('getSessionByUserId - 根据用户ID获取会话', () => { it('应该返回用户的会话', async () => { await service.createSession('socket-123', 'user-456', 'queue-789'); const session = await service.getSessionByUserId('user-456'); expect(session).toBeDefined(); expect(session?.userId).toBe('user-456'); }); it('应该在用户没有会话时返回null', async () => { const session = await service.getSessionByUserId('nonexistent'); expect(session).toBeNull(); }); }); describe('updatePlayerPosition - 更新玩家位置', () => { it('应该成功更新位置', async () => { await service.createSession('socket-123', 'user-456', 'queue-789'); const result = await service.updatePlayerPosition('socket-123', 'novice_village', 100, 200); expect(result).toBe(true); const session = await service.getSession('socket-123'); expect(session?.position).toEqual({ x: 100, y: 200 }); }); it('应该在切换地图时更新地图玩家列表', async () => { await service.createSession('socket-123', 'user-456', 'queue-789'); const result = await service.updatePlayerPosition('socket-123', 'tavern', 150, 250); expect(result).toBe(true); const session = await service.getSession('socket-123'); expect(session?.currentMap).toBe('tavern'); // 验证地图玩家列表更新 const tavernPlayers = await service.getSocketsInMap('tavern'); expect(tavernPlayers).toContain('socket-123'); const villagePlayers = await service.getSocketsInMap('novice_village'); expect(villagePlayers).not.toContain('socket-123'); }); it('应该在会话不存在时返回false', async () => { const result = await service.updatePlayerPosition('nonexistent', 'tavern', 100, 200); expect(result).toBe(false); }); }); describe('destroySession - 销毁会话', () => { it('应该成功销毁会话', async () => { await service.createSession('socket-123', 'user-456', 'queue-789'); const result = await service.destroySession('socket-123'); expect(result).toBe(true); const session = await service.getSession('socket-123'); expect(session).toBeNull(); }); it('应该在会话不存在时返回true', async () => { const result = await service.destroySession('nonexistent'); expect(result).toBe(true); }); it('应该清理用户会话映射', async () => { await service.createSession('socket-123', 'user-456', 'queue-789'); await service.destroySession('socket-123'); const session = await service.getSessionByUserId('user-456'); expect(session).toBeNull(); }); }); describe('getSocketsInMap - 获取地图玩家列表', () => { it('应该返回地图中的所有玩家', async () => { await service.createSession('socket-1', 'user-1', 'queue-1'); await service.createSession('socket-2', 'user-2', 'queue-2'); const sockets = await service.getSocketsInMap('novice_village'); expect(sockets).toHaveLength(2); expect(sockets).toContain('socket-1'); expect(sockets).toContain('socket-2'); }); it('应该在地图为空时返回空数组', async () => { const sockets = await service.getSocketsInMap('empty_map'); expect(sockets).toHaveLength(0); }); }); describe('injectContext - 上下文注入', () => { it('应该返回正确的Stream', async () => { await service.createSession('socket-123', 'user-456', 'queue-789'); const context = await service.injectContext('socket-123'); expect(context.stream).toBe('Novice Village'); }); it('应该在会话不存在时返回默认上下文', async () => { const context = await service.injectContext('nonexistent'); expect(context.stream).toBe('General'); }); }); /** * 属性测试: 会话状态一致性 * * **Feature: zulip-integration, Property 6: 会话状态一致性** * **Validates: Requirements 6.1, 6.2, 6.3, 6.5** * * 对于任何玩家会话,系统应该在Redis中正确维护WebSocket ID与Zulip队列ID的映射关系, * 及时更新位置信息,并支持服务重启后的状态恢复 */ describe('Property 6: 会话状态一致性', () => { /** * 属性: 对于任何有效的会话参数,创建会话后应该能够正确获取 * 验证需求 6.1: 玩家登录成功后系统应在Redis中存储WebSocket ID与Zulip队列ID的映射关系 */ it('对于任何有效的会话参数,创建会话后应该能够正确获取', async () => { await fc.assert( fc.asyncProperty( // 生成有效的socketId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成有效的userId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成有效的zulipQueueId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成有效的username fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), async (socketId, userId, zulipQueueId, username) => { // 清理之前的数据 memoryStore.clear(); memorySets.clear(); // 创建会话 const createdSession = await service.createSession( socketId.trim(), userId.trim(), zulipQueueId.trim(), username.trim(), ); // 验证创建的会话 expect(createdSession.socketId).toBe(socketId.trim()); expect(createdSession.userId).toBe(userId.trim()); expect(createdSession.zulipQueueId).toBe(zulipQueueId.trim()); expect(createdSession.username).toBe(username.trim()); // 获取会话并验证一致性 const retrievedSession = await service.getSession(socketId.trim()); expect(retrievedSession).not.toBeNull(); expect(retrievedSession?.socketId).toBe(createdSession.socketId); expect(retrievedSession?.userId).toBe(createdSession.userId); expect(retrievedSession?.zulipQueueId).toBe(createdSession.zulipQueueId); } ), { numRuns: 50, timeout: 5000 } // 添加超时控制 ); }, 30000); /** * 属性: 对于任何位置更新,会话应该正确反映新位置 * 验证需求 6.2: 玩家切换地图时系统应更新玩家的当前位置信息 */ it('对于任何位置更新,会话应该正确反映新位置', async () => { await fc.assert( fc.asyncProperty( // 生成有效的socketId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成有效的userId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成有效的地图ID fc.constantFrom('novice_village', 'tavern', 'market'), // 生成有效的坐标 fc.integer({ min: 0, max: 1000 }), fc.integer({ min: 0, max: 1000 }), async (socketId, userId, mapId, x, y) => { // 清理之前的数据 memoryStore.clear(); memorySets.clear(); // 创建会话 await service.createSession( socketId.trim(), userId.trim(), 'queue-test', ); // 更新位置 const updateResult = await service.updatePlayerPosition( socketId.trim(), mapId, x, y, ); expect(updateResult).toBe(true); // 验证位置更新 const session = await service.getSession(socketId.trim()); expect(session).not.toBeNull(); expect(session?.currentMap).toBe(mapId); expect(session?.position.x).toBe(x); expect(session?.position.y).toBe(y); } ), { numRuns: 50, timeout: 5000 } // 添加超时控制 ); }, 30000); /** * 属性: 对于任何地图切换,玩家应该从旧地图移除并添加到新地图 * 验证需求 6.3: 查询在线玩家时系统应从Redis中获取当前活跃的会话列表 */ it('对于任何地图切换,玩家应该从旧地图移除并添加到新地图', async () => { await fc.assert( fc.asyncProperty( // 生成有效的socketId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成有效的userId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成初始地图和目标地图(确保不同) fc.constantFrom('novice_village', 'tavern', 'market'), fc.constantFrom('novice_village', 'tavern', 'market'), async (socketId, userId, initialMap, targetMap) => { // 清理之前的数据 memoryStore.clear(); memorySets.clear(); // 创建会话(使用初始地图) await service.createSession( socketId.trim(), userId.trim(), 'queue-test', 'TestUser', initialMap, ); // 验证初始地图包含玩家 const initialPlayers = await service.getSocketsInMap(initialMap); expect(initialPlayers).toContain(socketId.trim()); // 如果目标地图不同,切换地图 if (initialMap !== targetMap) { await service.updatePlayerPosition(socketId.trim(), targetMap, 100, 100); // 验证旧地图不再包含玩家 const oldMapPlayers = await service.getSocketsInMap(initialMap); expect(oldMapPlayers).not.toContain(socketId.trim()); // 验证新地图包含玩家 const newMapPlayers = await service.getSocketsInMap(targetMap); expect(newMapPlayers).toContain(socketId.trim()); } } ), { numRuns: 50, timeout: 5000 } // 添加超时控制 ); }, 30000); /** * 属性: 对于任何会话销毁,所有相关数据应该被清理 * 验证需求 6.5: 服务器重启时系统应能够从Redis中恢复会话状态(通过验证销毁后数据被正确清理) */ it('对于任何会话销毁,所有相关数据应该被清理', async () => { await fc.assert( fc.asyncProperty( // 生成有效的socketId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成有效的userId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成有效的地图ID fc.constantFrom('novice_village', 'tavern', 'market'), async (socketId, userId, mapId) => { // 清理之前的数据 memoryStore.clear(); memorySets.clear(); // 创建会话 await service.createSession( socketId.trim(), userId.trim(), 'queue-test', 'TestUser', mapId, ); // 验证会话存在 const sessionBefore = await service.getSession(socketId.trim()); expect(sessionBefore).not.toBeNull(); // 销毁会话 const destroyResult = await service.destroySession(socketId.trim()); expect(destroyResult).toBe(true); // 验证会话被清理 const sessionAfter = await service.getSession(socketId.trim()); expect(sessionAfter).toBeNull(); // 验证用户会话映射被清理 const userSession = await service.getSessionByUserId(userId.trim()); expect(userSession).toBeNull(); // 验证地图玩家列表被清理 const mapPlayers = await service.getSocketsInMap(mapId); expect(mapPlayers).not.toContain(socketId.trim()); } ), { numRuns: 50, timeout: 5000 } // 添加超时控制 ); }, 30000); /** * 属性: 创建-更新-销毁的完整生命周期应该正确管理会话状态 */ it('创建-更新-销毁的完整生命周期应该正确管理会话状态', async () => { await fc.assert( fc.asyncProperty( // 生成有效的socketId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成有效的userId fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // 生成位置更新序列 fc.array( fc.record({ mapId: fc.constantFrom('novice_village', 'tavern', 'market'), x: fc.integer({ min: 0, max: 1000 }), y: fc.integer({ min: 0, max: 1000 }), }), { minLength: 1, maxLength: 5 } ), async (socketId, userId, positionUpdates) => { // 清理之前的数据 memoryStore.clear(); memorySets.clear(); // 1. 创建会话 const session = await service.createSession( socketId.trim(), userId.trim(), 'queue-test', ); expect(session).toBeDefined(); // 2. 执行位置更新序列 for (const update of positionUpdates) { const result = await service.updatePlayerPosition( socketId.trim(), update.mapId, update.x, update.y, ); expect(result).toBe(true); // 验证每次更新后的状态 const currentSession = await service.getSession(socketId.trim()); expect(currentSession?.currentMap).toBe(update.mapId); expect(currentSession?.position.x).toBe(update.x); expect(currentSession?.position.y).toBe(update.y); } // 3. 销毁会话 const destroyResult = await service.destroySession(socketId.trim()); expect(destroyResult).toBe(true); // 4. 验证所有数据被清理 const finalSession = await service.getSession(socketId.trim()); expect(finalSession).toBeNull(); } ), { numRuns: 50, timeout: 5000 } // 添加超时控制 ); }, 30000); }); });