refactor:重构Zulip模块按业务功能模块化架构

- 将技术实现服务从business层迁移到core层
- 创建src/core/zulip/核心服务模块,包含API客户端、连接池等技术服务
- 保留src/business/zulip/业务逻辑,专注游戏相关的业务规则
- 通过依赖注入实现业务层与核心层的解耦
- 更新模块导入关系,确保架构分层清晰

重构后的架构符合单一职责原则,提高了代码的可维护性和可测试性
This commit is contained in:
moyin
2025-12-31 15:44:36 +08:00
parent 5140bd1a54
commit 2d10131838
36 changed files with 2773 additions and 125 deletions

View File

@@ -0,0 +1,620 @@
/**
* 会话管理服务测试
*
* 功能描述:
* - 测试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/interfaces/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();
});
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);
});
});