Files
whale-town-end/src/business/zulip/services/session-manager.service.spec.ts
angjustinl 55cfda0532 feat(zulip): 添加全面的 Zulip 集成系统
* **新增 Zulip 模块**:包含完整的集成服务,涵盖客户端池(client pool)、会话管理及事件处理。
* **新增 WebSocket 网关**:用于处理 Zulip 的实时事件监听与双向通信。
* **新增安全服务**:支持 API 密钥加密存储及凭据的安全管理。
* **新增配置管理服务**:支持配置热加载(hot-reload),实现动态配置更新。
* **新增错误处理与监控服务**:提升系统的可靠性与可观测性。
* **新增消息过滤服务**:用于内容校验及速率限制(流控)。
* **新增流初始化与会话清理服务**:优化资源管理与回收。
* **完善测试覆盖**:包含单元测试及端到端(e2e)集成测试。
* **完善详细文档**:包括 API 参考手册、配置指南及集成概述。
* **新增地图配置系统**:实现游戏地点与 Zulip Stream(频道)及 Topic(话题)的逻辑映射。
* **新增环境变量配置**:涵盖 Zulip 服务器地址、身份验证及监控相关设置。
* **更新 App 模块**:注册并启用新的 Zulip 集成模块。
* **更新 Redis 接口**:以支持增强型的会话管理功能。
* **实现 WebSocket 协议支持**:确保与 Zulip 之间的实时双向通信。
2025-12-25 22:22:30 +08:00

615 lines
21 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 开发团队
* @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 { ConfigManagerService } from './config-manager.service';
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<ConfigManagerService>;
// 内存存储模拟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';
}),
getTopicByObject: jest.fn().mockReturnValue('General'),
getMapConfig: jest.fn(),
getAllMaps: 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: ConfigManagerService,
useValue: mockConfigManager,
},
],
}).compile();
service = module.get<SessionManagerService>(SessionManagerService);
});
afterEach(async () => {
// 清理内存存储
memoryStore.clear();
memorySets.clear();
});
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: 100 }
);
}, 60000);
/**
* 属性: 对于任何位置更新,会话应该正确反映新位置
* 验证需求 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: 100 }
);
}, 60000);
/**
* 属性: 对于任何地图切换,玩家应该从旧地图移除并添加到新地图
* 验证需求 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: 100 }
);
}, 60000);
/**
* 属性: 对于任何会话销毁,所有相关数据应该被清理
* 验证需求 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: 100 }
);
}, 60000);
/**
* 属性: 创建-更新-销毁的完整生命周期应该正确管理会话状态
*/
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: 100 }
);
}, 60000);
});
});