forked from datawhale/whale-town-end
- 将技术实现服务从business层迁移到core层 - 创建src/core/zulip/核心服务模块,包含API客户端、连接池等技术服务 - 保留src/business/zulip/业务逻辑,专注游戏相关的业务规则 - 通过依赖注入实现业务层与核心层的解耦 - 更新模块导入关系,确保架构分层清晰 重构后的架构符合单一职责原则,提高了代码的可维护性和可测试性
1134 lines
38 KiB
TypeScript
1134 lines
38 KiB
TypeScript
/**
|
||
* Zulip集成主服务测试
|
||
*
|
||
* 功能描述:
|
||
* - 测试ZulipService的核心功能
|
||
* - 包含属性测试验证玩家登录流程完整性
|
||
* - 包含属性测试验证消息发送流程完整性
|
||
* - 包含属性测试验证位置更新和上下文注入
|
||
*
|
||
* **Feature: zulip-integration, Property 1: 玩家登录流程完整性**
|
||
* **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5**
|
||
*
|
||
* **Feature: zulip-integration, Property 3: 消息发送流程完整性**
|
||
* **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5**
|
||
*
|
||
* **Feature: zulip-integration, Property 6: 位置更新和上下文注入**
|
||
* **Validates: Requirements 4.1, 4.2, 4.3, 4.4**
|
||
*
|
||
* @author angjustinl
|
||
* @version 1.0.0
|
||
* @since 2025-12-31
|
||
*/
|
||
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import * as fc from 'fast-check';
|
||
import {
|
||
ZulipService,
|
||
PlayerLoginRequest,
|
||
ChatMessageRequest,
|
||
PositionUpdateRequest,
|
||
LoginResponse,
|
||
ChatMessageResponse,
|
||
} from './zulip.service';
|
||
import { SessionManagerService, GameSession } from './services/session_manager.service';
|
||
import { MessageFilterService } from './services/message_filter.service';
|
||
import { ZulipEventProcessorService } from './services/zulip_event_processor.service';
|
||
import {
|
||
IZulipClientPoolService,
|
||
IZulipConfigService,
|
||
ZulipClientInstance,
|
||
SendMessageResult,
|
||
} from '../../core/zulip/interfaces/zulip-core.interfaces';
|
||
|
||
describe('ZulipService', () => {
|
||
let service: ZulipService;
|
||
let mockZulipClientPool: jest.Mocked<IZulipClientPoolService>;
|
||
let mockSessionManager: jest.Mocked<SessionManagerService>;
|
||
let mockMessageFilter: jest.Mocked<MessageFilterService>;
|
||
let mockEventProcessor: jest.Mocked<ZulipEventProcessorService>;
|
||
let mockConfigManager: jest.Mocked<IZulipConfigService>;
|
||
|
||
// 创建模拟的Zulip客户端实例
|
||
const createMockClientInstance = (overrides: Partial<ZulipClientInstance> = {}): ZulipClientInstance => ({
|
||
userId: 'test-user-123',
|
||
config: {
|
||
username: 'test@example.com',
|
||
apiKey: 'test-api-key',
|
||
realm: 'https://zulip.example.com',
|
||
},
|
||
client: {},
|
||
queueId: 'queue-123',
|
||
lastEventId: 0,
|
||
createdAt: new Date(),
|
||
lastActivity: new Date(),
|
||
isValid: true,
|
||
...overrides,
|
||
});
|
||
|
||
// 创建模拟的游戏会话
|
||
const createMockSession = (overrides: Partial<GameSession> = {}): GameSession => ({
|
||
socketId: 'socket-123',
|
||
userId: 'user-123',
|
||
username: 'TestPlayer',
|
||
zulipQueueId: 'queue-123',
|
||
currentMap: 'whale_port',
|
||
position: { x: 400, y: 300 },
|
||
lastActivity: new Date(),
|
||
createdAt: new Date(),
|
||
...overrides,
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
jest.clearAllMocks();
|
||
|
||
mockZulipClientPool = {
|
||
createUserClient: jest.fn(),
|
||
getUserClient: jest.fn(),
|
||
hasUserClient: jest.fn(),
|
||
sendMessage: jest.fn(),
|
||
registerEventQueue: jest.fn(),
|
||
deregisterEventQueue: jest.fn(),
|
||
destroyUserClient: jest.fn(),
|
||
getPoolStats: jest.fn(),
|
||
cleanupIdleClients: jest.fn(),
|
||
} as any;
|
||
|
||
mockSessionManager = {
|
||
createSession: jest.fn(),
|
||
getSession: jest.fn(),
|
||
destroySession: jest.fn(),
|
||
updatePlayerPosition: jest.fn(),
|
||
getSocketsInMap: jest.fn(),
|
||
injectContext: jest.fn(),
|
||
cleanupExpiredSessions: jest.fn(),
|
||
} as any;
|
||
|
||
mockMessageFilter = {
|
||
validateMessage: jest.fn(),
|
||
filterContent: jest.fn(),
|
||
checkRateLimit: jest.fn(),
|
||
validatePermission: jest.fn(),
|
||
logViolation: jest.fn(),
|
||
} as any;
|
||
|
||
mockEventProcessor = {
|
||
startEventProcessing: jest.fn(),
|
||
stopEventProcessing: jest.fn(),
|
||
registerEventQueue: jest.fn(),
|
||
unregisterEventQueue: jest.fn(),
|
||
processMessageEvent: jest.fn(),
|
||
setMessageDistributor: jest.fn(),
|
||
getProcessingStats: jest.fn(),
|
||
} as any;
|
||
|
||
mockConfigManager = {
|
||
getStreamByMap: jest.fn(),
|
||
getMapIdByStream: jest.fn(),
|
||
getTopicByObject: jest.fn(),
|
||
getZulipConfig: jest.fn(),
|
||
hasMap: jest.fn(),
|
||
hasStream: jest.fn(),
|
||
getAllMapIds: jest.fn(),
|
||
getAllStreams: jest.fn(),
|
||
reloadConfig: jest.fn(),
|
||
validateConfig: jest.fn(),
|
||
} as any;
|
||
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
ZulipService,
|
||
{
|
||
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
||
useValue: mockZulipClientPool,
|
||
},
|
||
{
|
||
provide: SessionManagerService,
|
||
useValue: mockSessionManager,
|
||
},
|
||
{
|
||
provide: MessageFilterService,
|
||
useValue: mockMessageFilter,
|
||
},
|
||
{
|
||
provide: ZulipEventProcessorService,
|
||
useValue: mockEventProcessor,
|
||
},
|
||
{
|
||
provide: 'ZULIP_CONFIG_SERVICE',
|
||
useValue: mockConfigManager,
|
||
},
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<ZulipService>(ZulipService);
|
||
});
|
||
|
||
it('should be defined', () => {
|
||
expect(service).toBeDefined();
|
||
});
|
||
|
||
describe('handlePlayerLogin - 处理玩家登录', () => {
|
||
it('应该成功处理有效Token的登录请求', async () => {
|
||
const loginRequest: PlayerLoginRequest = {
|
||
token: 'valid_token_123',
|
||
socketId: 'socket-456',
|
||
};
|
||
|
||
const mockSession = createMockSession({
|
||
socketId: 'socket-456',
|
||
userId: 'user_valid_to',
|
||
username: 'Player_lid_to',
|
||
});
|
||
|
||
mockConfigManager.getZulipConfig.mockReturnValue({
|
||
zulipServerUrl: 'https://zulip.example.com',
|
||
});
|
||
|
||
mockZulipClientPool.createUserClient.mockResolvedValue(
|
||
createMockClientInstance({
|
||
userId: 'user_valid_to',
|
||
queueId: 'queue-789',
|
||
})
|
||
);
|
||
|
||
mockSessionManager.createSession.mockResolvedValue(mockSession);
|
||
|
||
const result = await service.handlePlayerLogin(loginRequest);
|
||
|
||
expect(result.success).toBe(true);
|
||
expect(result.userId).toBe('user_valid_to');
|
||
expect(result.username).toBe('Player_valid');
|
||
expect(result.currentMap).toBe('whale_port');
|
||
expect(mockSessionManager.createSession).toHaveBeenCalled();
|
||
});
|
||
|
||
it('应该拒绝无效Token的登录请求', async () => {
|
||
const loginRequest: PlayerLoginRequest = {
|
||
token: 'invalid_token',
|
||
socketId: 'socket-456',
|
||
};
|
||
|
||
const result = await service.handlePlayerLogin(loginRequest);
|
||
|
||
expect(result.success).toBe(false);
|
||
expect(result.error).toBe('Token验证失败');
|
||
expect(mockSessionManager.createSession).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('应该处理空Token的情况', async () => {
|
||
const loginRequest: PlayerLoginRequest = {
|
||
token: '',
|
||
socketId: 'socket-456',
|
||
};
|
||
|
||
const result = await service.handlePlayerLogin(loginRequest);
|
||
|
||
expect(result.success).toBe(false);
|
||
expect(result.error).toBe('Token不能为空');
|
||
});
|
||
|
||
it('应该处理空socketId的情况', async () => {
|
||
const loginRequest: PlayerLoginRequest = {
|
||
token: 'valid_token',
|
||
socketId: '',
|
||
};
|
||
|
||
const result = await service.handlePlayerLogin(loginRequest);
|
||
|
||
expect(result.success).toBe(false);
|
||
expect(result.error).toBe('socketId不能为空');
|
||
});
|
||
it('应该在Zulip客户端创建失败时使用本地模式', async () => {
|
||
const loginRequest: PlayerLoginRequest = {
|
||
token: 'real_user_token_with_zulip_key_123', // 有API Key的Token
|
||
socketId: 'socket-456',
|
||
};
|
||
|
||
const mockSession = createMockSession({
|
||
socketId: 'socket-456',
|
||
userId: 'user_real_user_',
|
||
});
|
||
|
||
mockConfigManager.getZulipConfig.mockReturnValue({
|
||
zulipServerUrl: 'https://zulip.example.com',
|
||
});
|
||
|
||
// 模拟Zulip客户端创建失败
|
||
mockZulipClientPool.createUserClient.mockRejectedValue(new Error('Zulip连接失败'));
|
||
mockSessionManager.createSession.mockResolvedValue(mockSession);
|
||
|
||
const result = await service.handlePlayerLogin(loginRequest);
|
||
|
||
// 应该成功登录(本地模式)
|
||
expect(result.success).toBe(true);
|
||
expect(mockSessionManager.createSession).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('handlePlayerLogout - 处理玩家登出', () => {
|
||
it('应该成功处理玩家登出', async () => {
|
||
const socketId = 'socket-123';
|
||
const mockSession = createMockSession({ socketId, userId: 'user-123' });
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(mockSession);
|
||
mockZulipClientPool.destroyUserClient.mockResolvedValue();
|
||
mockSessionManager.destroySession.mockResolvedValue(undefined);
|
||
|
||
await service.handlePlayerLogout(socketId);
|
||
|
||
expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId);
|
||
expect(mockZulipClientPool.destroyUserClient).toHaveBeenCalledWith('user-123');
|
||
expect(mockSessionManager.destroySession).toHaveBeenCalledWith(socketId);
|
||
});
|
||
|
||
it('应该处理会话不存在的情况', async () => {
|
||
const socketId = 'non-existent-socket';
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(null);
|
||
|
||
await service.handlePlayerLogout(socketId);
|
||
|
||
expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId);
|
||
expect(mockZulipClientPool.destroyUserClient).not.toHaveBeenCalled();
|
||
expect(mockSessionManager.destroySession).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('应该在Zulip客户端清理失败时继续执行会话清理', async () => {
|
||
const socketId = 'socket-123';
|
||
const mockSession = createMockSession({ socketId, userId: 'user-123' });
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(mockSession);
|
||
mockZulipClientPool.destroyUserClient.mockRejectedValue(new Error('清理失败'));
|
||
mockSessionManager.destroySession.mockResolvedValue(undefined);
|
||
|
||
await service.handlePlayerLogout(socketId);
|
||
|
||
expect(mockZulipClientPool.destroyUserClient).toHaveBeenCalledWith('user-123');
|
||
expect(mockSessionManager.destroySession).toHaveBeenCalledWith(socketId);
|
||
});
|
||
});
|
||
|
||
describe('sendChatMessage - 发送聊天消息', () => {
|
||
it('应该成功发送聊天消息', async () => {
|
||
const chatRequest: ChatMessageRequest = {
|
||
socketId: 'socket-123',
|
||
content: 'Hello, world!',
|
||
scope: 'local',
|
||
};
|
||
|
||
const mockSession = createMockSession({
|
||
socketId: 'socket-123',
|
||
userId: 'user-123',
|
||
currentMap: 'tavern',
|
||
});
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(mockSession);
|
||
mockSessionManager.injectContext.mockResolvedValue({
|
||
stream: 'Tavern',
|
||
topic: 'General',
|
||
});
|
||
|
||
mockMessageFilter.validateMessage.mockResolvedValue({
|
||
allowed: true,
|
||
filteredContent: 'Hello, world!',
|
||
});
|
||
|
||
mockZulipClientPool.sendMessage.mockResolvedValue({
|
||
success: true,
|
||
messageId: 12345,
|
||
});
|
||
|
||
const result = await service.sendChatMessage(chatRequest);
|
||
|
||
expect(result.success).toBe(true);
|
||
expect(result.messageId).toBe(12345);
|
||
expect(mockZulipClientPool.sendMessage).toHaveBeenCalledWith(
|
||
'user-123',
|
||
'Tavern',
|
||
'General',
|
||
'Hello, world!'
|
||
);
|
||
});
|
||
|
||
it('应该拒绝会话不存在的消息发送', async () => {
|
||
const chatRequest: ChatMessageRequest = {
|
||
socketId: 'non-existent-socket',
|
||
content: 'Hello, world!',
|
||
scope: 'local',
|
||
};
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(null);
|
||
|
||
const result = await service.sendChatMessage(chatRequest);
|
||
|
||
expect(result.success).toBe(false);
|
||
expect(result.error).toBe('会话不存在,请重新登录');
|
||
expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('应该拒绝未通过验证的消息', async () => {
|
||
const chatRequest: ChatMessageRequest = {
|
||
socketId: 'socket-123',
|
||
content: '敏感词内容',
|
||
scope: 'local',
|
||
};
|
||
|
||
const mockSession = createMockSession({ socketId: 'socket-123' });
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(mockSession);
|
||
mockSessionManager.injectContext.mockResolvedValue({
|
||
stream: 'Tavern',
|
||
topic: 'General',
|
||
});
|
||
|
||
mockMessageFilter.validateMessage.mockResolvedValue({
|
||
allowed: false,
|
||
reason: '消息包含敏感词',
|
||
});
|
||
|
||
const result = await service.sendChatMessage(chatRequest);
|
||
|
||
expect(result.success).toBe(false);
|
||
expect(result.error).toBe('消息包含敏感词');
|
||
expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('应该在Zulip发送失败时仍返回成功(本地模式)', async () => {
|
||
const chatRequest: ChatMessageRequest = {
|
||
socketId: 'socket-123',
|
||
content: 'Hello, world!',
|
||
scope: 'local',
|
||
};
|
||
|
||
const mockSession = createMockSession({ socketId: 'socket-123' });
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(mockSession);
|
||
mockSessionManager.injectContext.mockResolvedValue({
|
||
stream: 'Tavern',
|
||
topic: 'General',
|
||
});
|
||
|
||
mockMessageFilter.validateMessage.mockResolvedValue({
|
||
allowed: true,
|
||
filteredContent: 'Hello, world!',
|
||
});
|
||
|
||
mockZulipClientPool.sendMessage.mockResolvedValue({
|
||
success: false,
|
||
error: 'Zulip服务不可用',
|
||
});
|
||
|
||
const result = await service.sendChatMessage(chatRequest);
|
||
|
||
expect(result.success).toBe(true); // 本地模式下仍返回成功
|
||
});
|
||
});
|
||
|
||
describe('updatePlayerPosition - 更新玩家位置', () => {
|
||
it('应该成功更新玩家位置', async () => {
|
||
const positionRequest: PositionUpdateRequest = {
|
||
socketId: 'socket-123',
|
||
x: 500,
|
||
y: 400,
|
||
mapId: 'tavern',
|
||
};
|
||
|
||
mockSessionManager.updatePlayerPosition.mockResolvedValue(true);
|
||
|
||
const result = await service.updatePlayerPosition(positionRequest);
|
||
|
||
expect(result).toBe(true);
|
||
expect(mockSessionManager.updatePlayerPosition).toHaveBeenCalledWith(
|
||
'socket-123',
|
||
'tavern',
|
||
500,
|
||
400
|
||
);
|
||
});
|
||
|
||
it('应该拒绝空socketId的位置更新', async () => {
|
||
const positionRequest: PositionUpdateRequest = {
|
||
socketId: '',
|
||
x: 500,
|
||
y: 400,
|
||
mapId: 'tavern',
|
||
};
|
||
|
||
const result = await service.updatePlayerPosition(positionRequest);
|
||
|
||
expect(result).toBe(false);
|
||
expect(mockSessionManager.updatePlayerPosition).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('应该拒绝空mapId的位置更新', async () => {
|
||
const positionRequest: PositionUpdateRequest = {
|
||
socketId: 'socket-123',
|
||
x: 500,
|
||
y: 400,
|
||
mapId: '',
|
||
};
|
||
|
||
const result = await service.updatePlayerPosition(positionRequest);
|
||
|
||
expect(result).toBe(false);
|
||
expect(mockSessionManager.updatePlayerPosition).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
/**
|
||
* 属性测试: 玩家登录流程完整性
|
||
*
|
||
* **Feature: zulip-integration, Property 1: 玩家登录流程完整性**
|
||
* **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5**
|
||
*
|
||
* 对于任何有效的游戏Token,系统应该能够验证Token,创建Zulip客户端,
|
||
* 建立会话映射,并返回成功的登录响应
|
||
*/
|
||
describe('Property 1: 玩家登录流程完整性', () => {
|
||
/**
|
||
* 属性: 对于任何有效的Token和socketId,登录应该成功并创建会话
|
||
* 验证需求 1.1: 游戏客户端连接时系统应验证游戏Token的有效性
|
||
* 验证需求 1.2: Token验证成功后系统应获取用户的Zulip API Key
|
||
* 验证需求 1.3: 获取API Key后系统应创建用户专用的Zulip客户端实例
|
||
*/
|
||
it('对于任何有效的Token和socketId,登录应该成功并创建会话', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成有效的Token(不以'invalid'开头)
|
||
fc.string({ minLength: 8, maxLength: 50 })
|
||
.filter(s => !s.startsWith('invalid') && s.trim().length > 0),
|
||
// 生成有效的socketId
|
||
fc.string({ minLength: 5, maxLength: 30 })
|
||
.filter(s => s.trim().length > 0),
|
||
async (token, socketId) => {
|
||
const trimmedToken = token.trim();
|
||
const trimmedSocketId = socketId.trim();
|
||
|
||
const loginRequest: PlayerLoginRequest = {
|
||
token: trimmedToken,
|
||
socketId: trimmedSocketId,
|
||
};
|
||
|
||
const expectedUserId = `user_${trimmedToken.substring(0, 8)}`;
|
||
const expectedUsername = `Player_${expectedUserId.substring(5, 10)}`;
|
||
|
||
const mockSession = createMockSession({
|
||
socketId: trimmedSocketId,
|
||
userId: expectedUserId,
|
||
username: expectedUsername,
|
||
});
|
||
|
||
mockConfigManager.getZulipConfig.mockReturnValue({
|
||
zulipServerUrl: 'https://zulip.example.com',
|
||
});
|
||
|
||
mockSessionManager.createSession.mockResolvedValue(mockSession);
|
||
|
||
const result = await service.handlePlayerLogin(loginRequest);
|
||
|
||
// 验证登录成功
|
||
expect(result.success).toBe(true);
|
||
expect(result.userId).toBe(expectedUserId);
|
||
expect(result.username).toBe(expectedUsername);
|
||
expect(result.currentMap).toBe('whale_port');
|
||
expect(result.sessionId).toBeDefined();
|
||
|
||
// 验证会话创建被调用
|
||
expect(mockSessionManager.createSession).toHaveBeenCalledWith(
|
||
trimmedSocketId,
|
||
expectedUserId,
|
||
expect.any(String), // zulipQueueId
|
||
expectedUsername,
|
||
'whale_port',
|
||
{ x: 400, y: 300 }
|
||
);
|
||
}
|
||
),
|
||
{ numRuns: 100 }
|
||
);
|
||
}, 60000);
|
||
|
||
/**
|
||
* 属性: 对于任何无效的Token,登录应该失败
|
||
* 验证需求 1.1: 游戏客户端连接时系统应验证游戏Token的有效性
|
||
*/
|
||
it('对于任何无效的Token,登录应该失败', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成无效的Token(以'invalid'开头)
|
||
fc.string({ minLength: 1, maxLength: 30 })
|
||
.map(s => `invalid${s}`),
|
||
// 生成有效的socketId
|
||
fc.string({ minLength: 5, maxLength: 30 })
|
||
.filter(s => s.trim().length > 0),
|
||
async (invalidToken, socketId) => {
|
||
const loginRequest: PlayerLoginRequest = {
|
||
token: invalidToken,
|
||
socketId: socketId.trim(),
|
||
};
|
||
|
||
const result = await service.handlePlayerLogin(loginRequest);
|
||
|
||
// 验证登录失败
|
||
expect(result.success).toBe(false);
|
||
expect(result.error).toBe('Token验证失败');
|
||
expect(result.userId).toBeUndefined();
|
||
expect(result.sessionId).toBeUndefined();
|
||
|
||
// 验证没有创建会话
|
||
expect(mockSessionManager.createSession).not.toHaveBeenCalled();
|
||
}
|
||
),
|
||
{ numRuns: 50 }
|
||
);
|
||
}, 30000);
|
||
|
||
/**
|
||
* 属性: 对于空或无效的参数,登录应该返回相应的错误信息
|
||
* 验证需求 1.1: 系统应正确处理无效的登录请求
|
||
*/
|
||
it('对于空或无效的参数,登录应该返回相应的错误信息', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成可能为空或以'invalid'开头的Token
|
||
fc.oneof(
|
||
fc.constant(''), // 空字符串
|
||
fc.constant(' '), // 只有空格
|
||
fc.string({ minLength: 1, maxLength: 50 }).map(s => 'invalid' + s), // 以invalid开头
|
||
),
|
||
// 生成可能为空的socketId
|
||
fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: '' }),
|
||
async (token, socketId) => {
|
||
// 重置mock调用历史
|
||
jest.clearAllMocks();
|
||
|
||
const loginRequest: PlayerLoginRequest = {
|
||
token: token || '',
|
||
socketId: socketId || '',
|
||
};
|
||
|
||
const result = await service.handlePlayerLogin(loginRequest);
|
||
|
||
// 验证登录失败
|
||
expect(result.success).toBe(false);
|
||
expect(result.error).toBeDefined();
|
||
|
||
if (!token || token.trim().length === 0) {
|
||
expect(result.error).toBe('Token不能为空');
|
||
} else if (!socketId || socketId.trim().length === 0) {
|
||
expect(result.error).toBe('socketId不能为空');
|
||
} else if (token.startsWith('invalid')) {
|
||
expect(result.error).toBe('Token验证失败');
|
||
}
|
||
|
||
// 验证没有创建会话
|
||
expect(mockSessionManager.createSession).not.toHaveBeenCalled();
|
||
}
|
||
),
|
||
{ numRuns: 50 }
|
||
);
|
||
}, 30000);
|
||
|
||
/**
|
||
* 属性: 对于有Zulip API Key的用户,应该尝试创建Zulip客户端
|
||
* 验证需求 1.2: Token验证成功后系统应获取用户的Zulip API Key
|
||
* 验证需求 1.3: 获取API Key后系统应创建用户专用的Zulip客户端实例
|
||
*/
|
||
it('对于有Zulip API Key的用户,应该尝试创建Zulip客户端', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成包含特定标识的Token(表示有API Key)
|
||
fc.constantFrom(
|
||
'real_user_token_with_zulip_key_123',
|
||
'token_with_lCPWCPf_key',
|
||
'token_with_W2KhXaQx_key'
|
||
),
|
||
// 生成有效的socketId
|
||
fc.string({ minLength: 5, maxLength: 30 })
|
||
.filter(s => s.trim().length > 0),
|
||
async (tokenWithApiKey, socketId) => {
|
||
const loginRequest: PlayerLoginRequest = {
|
||
token: tokenWithApiKey,
|
||
socketId: socketId.trim(),
|
||
};
|
||
|
||
const mockClientInstance = createMockClientInstance({
|
||
userId: `user_${tokenWithApiKey.substring(0, 8)}`,
|
||
queueId: 'test-queue-123',
|
||
});
|
||
|
||
const mockSession = createMockSession({
|
||
socketId: socketId.trim(),
|
||
zulipQueueId: 'test-queue-123',
|
||
});
|
||
|
||
mockConfigManager.getZulipConfig.mockReturnValue({
|
||
zulipServerUrl: 'https://zulip.example.com',
|
||
});
|
||
|
||
mockZulipClientPool.createUserClient.mockResolvedValue(mockClientInstance);
|
||
mockSessionManager.createSession.mockResolvedValue(mockSession);
|
||
|
||
const result = await service.handlePlayerLogin(loginRequest);
|
||
|
||
// 验证登录成功
|
||
expect(result.success).toBe(true);
|
||
|
||
// 验证尝试创建了Zulip客户端
|
||
expect(mockZulipClientPool.createUserClient).toHaveBeenCalledWith(
|
||
expect.any(String),
|
||
expect.objectContaining({
|
||
username: expect.any(String),
|
||
apiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
|
||
realm: 'https://zulip.example.com',
|
||
})
|
||
);
|
||
}
|
||
),
|
||
{ numRuns: 30 }
|
||
);
|
||
}, 30000);
|
||
});
|
||
/**
|
||
* 属性测试: 消息发送流程完整性
|
||
*
|
||
* **Feature: zulip-integration, Property 3: 消息发送流程完整性**
|
||
* **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5**
|
||
*
|
||
* 对于任何有效的聊天消息请求,系统应该进行内容过滤、权限验证、
|
||
* 上下文注入,并成功发送到对应的Zulip Stream/Topic
|
||
*/
|
||
describe('Property 3: 消息发送流程完整性', () => {
|
||
/**
|
||
* 属性: 对于任何有效会话的消息发送请求,应该成功处理并发送
|
||
* 验证需求 3.1: 游戏客户端发送聊天消息时系统应获取玩家当前位置
|
||
* 验证需求 3.2: 获取位置后系统应根据位置确定目标Stream和Topic
|
||
* 验证需求 3.3: 确定目标后系统应进行消息内容过滤和频率检查
|
||
*/
|
||
it('对于任何有效会话的消息发送请求,应该成功处理并发送', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成有效的socketId
|
||
fc.string({ minLength: 5, maxLength: 30 })
|
||
.filter(s => s.trim().length > 0),
|
||
// 生成有效的消息内容
|
||
fc.string({ minLength: 1, maxLength: 200 })
|
||
.filter(s => s.trim().length > 0 && !/[敏感词|违禁词]/.test(s)),
|
||
// 生成地图和Stream映射
|
||
fc.record({
|
||
mapId: fc.constantFrom('tavern', 'novice_village', 'market'),
|
||
streamName: fc.constantFrom('Tavern', 'Novice Village', 'Market'),
|
||
}),
|
||
async (socketId, content, mapping) => {
|
||
const chatRequest: ChatMessageRequest = {
|
||
socketId: socketId.trim(),
|
||
content: content.trim(),
|
||
scope: 'local',
|
||
};
|
||
|
||
const mockSession = createMockSession({
|
||
socketId: socketId.trim(),
|
||
userId: `user_${socketId.substring(0, 8)}`,
|
||
currentMap: mapping.mapId,
|
||
});
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(mockSession);
|
||
mockSessionManager.injectContext.mockResolvedValue({
|
||
stream: mapping.streamName,
|
||
topic: 'General',
|
||
});
|
||
|
||
mockMessageFilter.validateMessage.mockResolvedValue({
|
||
allowed: true,
|
||
filteredContent: content.trim(),
|
||
});
|
||
|
||
mockZulipClientPool.sendMessage.mockResolvedValue({
|
||
success: true,
|
||
messageId: Math.floor(Math.random() * 1000000),
|
||
});
|
||
|
||
const result = await service.sendChatMessage(chatRequest);
|
||
|
||
// 验证消息发送成功
|
||
expect(result.success).toBe(true);
|
||
expect(result.messageId).toBeDefined();
|
||
|
||
// 验证调用了正确的方法
|
||
expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId.trim());
|
||
expect(mockSessionManager.injectContext).toHaveBeenCalledWith(socketId.trim());
|
||
expect(mockMessageFilter.validateMessage).toHaveBeenCalledWith(
|
||
mockSession.userId,
|
||
content.trim(),
|
||
mapping.streamName,
|
||
mapping.mapId
|
||
);
|
||
expect(mockZulipClientPool.sendMessage).toHaveBeenCalledWith(
|
||
mockSession.userId,
|
||
mapping.streamName,
|
||
'General',
|
||
content.trim()
|
||
);
|
||
}
|
||
),
|
||
{ numRuns: 100 }
|
||
);
|
||
}, 60000);
|
||
|
||
/**
|
||
* 属性: 对于任何不存在的会话,消息发送应该失败
|
||
* 验证需求 3.1: 系统应验证会话的有效性
|
||
*/
|
||
it('对于任何不存在的会话,消息发送应该失败', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成不存在的socketId
|
||
fc.string({ minLength: 5, maxLength: 30 })
|
||
.filter(s => s.trim().length > 0)
|
||
.map(s => `nonexistent_${s}`),
|
||
// 生成任意消息内容
|
||
fc.string({ minLength: 1, maxLength: 200 })
|
||
.filter(s => s.trim().length > 0),
|
||
async (nonExistentSocketId, content) => {
|
||
const chatRequest: ChatMessageRequest = {
|
||
socketId: nonExistentSocketId,
|
||
content: content.trim(),
|
||
scope: 'local',
|
||
};
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(null);
|
||
|
||
const result = await service.sendChatMessage(chatRequest);
|
||
|
||
// 验证消息发送失败
|
||
expect(result.success).toBe(false);
|
||
expect(result.error).toBe('会话不存在,请重新登录');
|
||
|
||
// 验证没有进行后续处理
|
||
expect(mockMessageFilter.validateMessage).not.toHaveBeenCalled();
|
||
expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled();
|
||
}
|
||
),
|
||
{ numRuns: 50 }
|
||
);
|
||
}, 30000);
|
||
|
||
/**
|
||
* 属性: 对于任何未通过验证的消息,发送应该失败
|
||
* 验证需求 3.3: 确定目标后系统应进行消息内容过滤和频率检查
|
||
*/
|
||
it('对于任何未通过验证的消息,发送应该失败', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成有效的socketId
|
||
fc.string({ minLength: 5, maxLength: 30 })
|
||
.filter(s => s.trim().length > 0),
|
||
// 生成可能包含敏感词的消息内容
|
||
fc.string({ minLength: 1, maxLength: 200 })
|
||
.filter(s => s.trim().length > 0),
|
||
// 生成验证失败的原因
|
||
fc.constantFrom(
|
||
'消息包含敏感词',
|
||
'发送频率过快',
|
||
'权限不足',
|
||
'消息长度超限'
|
||
),
|
||
async (socketId, content, failureReason) => {
|
||
const chatRequest: ChatMessageRequest = {
|
||
socketId: socketId.trim(),
|
||
content: content.trim(),
|
||
scope: 'local',
|
||
};
|
||
|
||
const mockSession = createMockSession({
|
||
socketId: socketId.trim(),
|
||
});
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(mockSession);
|
||
mockSessionManager.injectContext.mockResolvedValue({
|
||
stream: 'Tavern',
|
||
topic: 'General',
|
||
});
|
||
|
||
mockMessageFilter.validateMessage.mockResolvedValue({
|
||
allowed: false,
|
||
reason: failureReason,
|
||
});
|
||
|
||
const result = await service.sendChatMessage(chatRequest);
|
||
|
||
// 验证消息发送失败
|
||
expect(result.success).toBe(false);
|
||
expect(result.error).toBe(failureReason);
|
||
|
||
// 验证没有发送到Zulip
|
||
expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled();
|
||
}
|
||
),
|
||
{ numRuns: 50 }
|
||
);
|
||
}, 30000);
|
||
|
||
/**
|
||
* 属性: 即使Zulip发送失败,系统也应该返回成功(本地模式)
|
||
* 验证需求 3.5: 发送消息到Zulip时系统应处理发送失败的情况
|
||
*/
|
||
it('即使Zulip发送失败,系统也应该返回成功(本地模式)', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成有效的socketId
|
||
fc.string({ minLength: 5, maxLength: 30 })
|
||
.filter(s => s.trim().length > 0),
|
||
// 生成有效的消息内容
|
||
fc.string({ minLength: 1, maxLength: 200 })
|
||
.filter(s => s.trim().length > 0),
|
||
// 生成Zulip错误信息
|
||
fc.constantFrom(
|
||
'Zulip服务不可用',
|
||
'网络连接超时',
|
||
'API Key无效',
|
||
'Stream不存在'
|
||
),
|
||
async (socketId, content, zulipError) => {
|
||
const chatRequest: ChatMessageRequest = {
|
||
socketId: socketId.trim(),
|
||
content: content.trim(),
|
||
scope: 'local',
|
||
};
|
||
|
||
const mockSession = createMockSession({
|
||
socketId: socketId.trim(),
|
||
});
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(mockSession);
|
||
mockSessionManager.injectContext.mockResolvedValue({
|
||
stream: 'Tavern',
|
||
topic: 'General',
|
||
});
|
||
|
||
mockMessageFilter.validateMessage.mockResolvedValue({
|
||
allowed: true,
|
||
filteredContent: content.trim(),
|
||
});
|
||
|
||
mockZulipClientPool.sendMessage.mockResolvedValue({
|
||
success: false,
|
||
error: zulipError,
|
||
});
|
||
|
||
const result = await service.sendChatMessage(chatRequest);
|
||
|
||
// 验证本地模式下仍返回成功
|
||
expect(result.success).toBe(true);
|
||
expect(result.messageId).toBeUndefined();
|
||
}
|
||
),
|
||
{ numRuns: 50 }
|
||
);
|
||
}, 30000);
|
||
});
|
||
/**
|
||
* 属性测试: 位置更新和上下文注入
|
||
*
|
||
* **Feature: zulip-integration, Property 6: 位置更新和上下文注入**
|
||
* **Validates: Requirements 4.1, 4.2, 4.3, 4.4**
|
||
*
|
||
* 对于任何位置更新请求,系统应该正确更新玩家位置信息,
|
||
* 并在消息发送时根据位置进行上下文注入
|
||
*/
|
||
describe('Property 6: 位置更新和上下文注入', () => {
|
||
/**
|
||
* 属性: 对于任何有效的位置更新请求,应该成功更新位置
|
||
* 验证需求 4.1: 玩家移动时系统应更新玩家在游戏世界中的位置信息
|
||
* 验证需求 4.2: 更新位置时系统应验证位置的有效性
|
||
*/
|
||
it('对于任何有效的位置更新请求,应该成功更新位置', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成有效的socketId
|
||
fc.string({ minLength: 5, maxLength: 30 })
|
||
.filter(s => s.trim().length > 0),
|
||
// 生成有效的坐标
|
||
fc.record({
|
||
x: fc.integer({ min: 0, max: 2000 }),
|
||
y: fc.integer({ min: 0, max: 2000 }),
|
||
}),
|
||
// 生成有效的地图ID
|
||
fc.constantFrom('tavern', 'novice_village', 'market', 'whale_port'),
|
||
async (socketId, position, mapId) => {
|
||
const positionRequest: PositionUpdateRequest = {
|
||
socketId: socketId.trim(),
|
||
x: position.x,
|
||
y: position.y,
|
||
mapId,
|
||
};
|
||
|
||
mockSessionManager.updatePlayerPosition.mockResolvedValue(true);
|
||
|
||
const result = await service.updatePlayerPosition(positionRequest);
|
||
|
||
// 验证位置更新成功
|
||
expect(result).toBe(true);
|
||
|
||
// 验证调用了正确的方法
|
||
expect(mockSessionManager.updatePlayerPosition).toHaveBeenCalledWith(
|
||
socketId.trim(),
|
||
mapId,
|
||
position.x,
|
||
position.y
|
||
);
|
||
}
|
||
),
|
||
{ numRuns: 100 }
|
||
);
|
||
}, 60000);
|
||
|
||
/**
|
||
* 属性: 对于任何无效的参数,位置更新应该失败
|
||
* 验证需求 4.2: 更新位置时系统应验证位置的有效性
|
||
*/
|
||
it('对于任何无效的参数,位置更新应该失败', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成可能为空的socketId
|
||
fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: '' }),
|
||
// 生成可能为空的mapId
|
||
fc.option(fc.constantFrom('tavern', 'market'), { nil: '' }),
|
||
// 生成坐标
|
||
fc.record({
|
||
x: fc.integer({ min: 0, max: 2000 }),
|
||
y: fc.integer({ min: 0, max: 2000 }),
|
||
}),
|
||
async (socketId, mapId, position) => {
|
||
// 重置mock调用历史
|
||
jest.clearAllMocks();
|
||
|
||
const positionRequest: PositionUpdateRequest = {
|
||
socketId: socketId || '',
|
||
x: position.x,
|
||
y: position.y,
|
||
mapId: mapId || '',
|
||
};
|
||
|
||
const result = await service.updatePlayerPosition(positionRequest);
|
||
|
||
if (!socketId || socketId.trim().length === 0 ||
|
||
!mapId || mapId.trim().length === 0) {
|
||
// 验证位置更新失败
|
||
expect(result).toBe(false);
|
||
|
||
// 验证没有调用SessionManager
|
||
expect(mockSessionManager.updatePlayerPosition).not.toHaveBeenCalled();
|
||
}
|
||
}
|
||
),
|
||
{ numRuns: 50 }
|
||
);
|
||
}, 30000);
|
||
|
||
/**
|
||
* 属性: 位置更新失败时应该正确处理错误
|
||
* 验证需求 4.1: 系统应正确处理位置更新过程中的错误
|
||
*/
|
||
it('位置更新失败时应该正确处理错误', async () => {
|
||
await fc.assert(
|
||
fc.asyncProperty(
|
||
// 生成有效的socketId
|
||
fc.string({ minLength: 5, maxLength: 30 })
|
||
.filter(s => s.trim().length > 0),
|
||
// 生成有效的坐标
|
||
fc.record({
|
||
x: fc.integer({ min: 0, max: 2000 }),
|
||
y: fc.integer({ min: 0, max: 2000 }),
|
||
}),
|
||
// 生成有效的地图ID
|
||
fc.constantFrom('tavern', 'novice_village', 'market'),
|
||
async (socketId, position, mapId) => {
|
||
const positionRequest: PositionUpdateRequest = {
|
||
socketId: socketId.trim(),
|
||
x: position.x,
|
||
y: position.y,
|
||
mapId,
|
||
};
|
||
|
||
// 模拟SessionManager抛出错误
|
||
mockSessionManager.updatePlayerPosition.mockRejectedValue(
|
||
new Error('位置更新失败')
|
||
);
|
||
|
||
const result = await service.updatePlayerPosition(positionRequest);
|
||
|
||
// 验证位置更新失败
|
||
expect(result).toBe(false);
|
||
}
|
||
),
|
||
{ numRuns: 50 }
|
||
);
|
||
}, 30000);
|
||
});
|
||
|
||
describe('processZulipMessage - 处理Zulip消息', () => {
|
||
it('应该正确处理Zulip消息并确定目标玩家', async () => {
|
||
const zulipMessage = {
|
||
id: 12345,
|
||
sender_full_name: 'Alice',
|
||
sender_email: 'alice@example.com',
|
||
content: 'Hello everyone!',
|
||
display_recipient: 'Tavern',
|
||
stream_name: 'Tavern',
|
||
};
|
||
|
||
mockConfigManager.getMapIdByStream.mockReturnValue('tavern');
|
||
mockSessionManager.getSocketsInMap.mockResolvedValue(['socket-1', 'socket-2']);
|
||
|
||
const result = await service.processZulipMessage(zulipMessage);
|
||
|
||
expect(result.targetSockets).toEqual(['socket-1', 'socket-2']);
|
||
expect(result.message.t).toBe('chat_render');
|
||
expect(result.message.from).toBe('Alice');
|
||
expect(result.message.txt).toBe('Hello everyone!');
|
||
expect(result.message.bubble).toBe(true);
|
||
});
|
||
|
||
it('应该在未知Stream时返回空的目标列表', async () => {
|
||
const zulipMessage = {
|
||
id: 12345,
|
||
sender_full_name: 'Alice',
|
||
content: 'Hello!',
|
||
display_recipient: 'UnknownStream',
|
||
};
|
||
|
||
mockConfigManager.getMapIdByStream.mockReturnValue(null);
|
||
|
||
const result = await service.processZulipMessage(zulipMessage);
|
||
|
||
expect(result.targetSockets).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('辅助方法', () => {
|
||
it('getSession - 应该返回会话信息', async () => {
|
||
const socketId = 'socket-123';
|
||
const mockSession = createMockSession({ socketId });
|
||
|
||
mockSessionManager.getSession.mockResolvedValue(mockSession);
|
||
|
||
const result = await service.getSession(socketId);
|
||
|
||
expect(result).toBe(mockSession);
|
||
expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId);
|
||
});
|
||
|
||
it('getSocketsInMap - 应该返回地图中的Socket列表', async () => {
|
||
const mapId = 'tavern';
|
||
const socketIds = ['socket-1', 'socket-2', 'socket-3'];
|
||
|
||
mockSessionManager.getSocketsInMap.mockResolvedValue(socketIds);
|
||
|
||
const result = await service.getSocketsInMap(mapId);
|
||
|
||
expect(result).toBe(socketIds);
|
||
expect(mockSessionManager.getSocketsInMap).toHaveBeenCalledWith(mapId);
|
||
});
|
||
});
|
||
}); |