Files
whale-town-end/src/business/zulip/services/session_manager.service.spec.ts
moyin cbf4120ddd refactor: 更新WebSocket相关测试和location_broadcast模块
- 更新location_broadcast网关以支持原生WebSocket
- 修改WebSocket认证守卫和中间件
- 更新相关的测试文件和规范
- 添加WebSocket测试工具
- 完善Zulip服务的测试覆盖

技术改进:
- 统一WebSocket实现架构
- 优化性能监控和限流中间件
- 更新测试用例以适配新的WebSocket实现
2026-01-09 17:02:43 +08:00

624 lines
22 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.
/**
* 会话管理服务测试
*
* 功能描述:
* - 测试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<AppLoggerService>;
let mockRedisService: jest.Mocked<IRedisService>;
let mockConfigManager: jest.Mocked<IZulipConfigService>;
// 内存存储模拟Redis
let memoryStore: Map<string, { value: string; expireAt?: number }>;
let memorySets: Map<string, Set<string>>;
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<string, string> = {
'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>(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);
});
});