Files
whale-town-end/src/business/zulip/zulip.service.spec.ts
moyin 03f0cd6bab test(zulip): 添加zulip业务模块完整测试覆盖
范围:src/business/zulip/
- 添加chat.controller.spec.ts控制器测试
- 添加clean_websocket.gateway.spec.ts网关测试
- 添加dynamic_config.controller.spec.ts配置控制器测试
- 添加services/zulip_accounts_business.service.spec.ts业务服务测试
- 添加websocket相关控制器测试文件
- 添加zulip.module.spec.ts模块测试
- 添加zulip_accounts.controller.spec.ts账户控制器测试
- 实现严格一对一测试映射,测试覆盖率达到100%
2026-01-12 19:41:48 +08:00

1159 lines
39 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.
/**
* 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**
*
* 最近修改:
* - 2026-01-12: 测试修复 - 修复消息内容断言使用stringContaining匹配包含游戏消息ID的内容 (修改者: moyin)
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-31
* @lastModified 2026-01-12
*/
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_core/zulip_core.interfaces';
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
import { LoginCoreService } from '../../core/login_core/login_core.service';
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>;
let mockLoginCoreService: jest.Mocked<LoginCoreService>;
// 创建模拟的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;
mockLoginCoreService = {
verifyToken: jest.fn(),
generateTokens: jest.fn(),
refreshTokens: jest.fn(),
revokeToken: jest.fn(),
validateTokenPayload: 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,
},
{
provide: 'API_KEY_SECURITY_SERVICE',
useValue: {
extractApiKey: jest.fn(),
validateApiKey: jest.fn(),
encryptApiKey: jest.fn(),
decryptApiKey: jest.fn(),
getApiKey: jest.fn().mockResolvedValue({
success: true,
apiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
}),
},
},
{
provide: LoginCoreService,
useValue: mockLoginCoreService,
},
],
}).compile();
service = module.get<ZulipService>(ZulipService);
// 配置LoginCoreService的默认mock行为
mockLoginCoreService.verifyToken.mockImplementation(async (token: string) => {
// 模拟token验证逻辑
if (token.startsWith('invalid')) {
throw new Error('Invalid token');
}
// 从token中提取用户信息模拟JWT解析
const userId = `user_${token.substring(0, 8)}`;
const username = `Player_${userId.substring(5, 10)}`;
const email = `${userId}@example.com`;
return {
sub: userId,
username,
email,
role: 1, // 数字类型的角色
type: 'access' as const,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
iss: 'whale-town',
aud: 'whale-town-users',
};
});
});
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).toMatch(/^game_\d+_user-123$/);
expect(mockZulipClientPool.sendMessage).toHaveBeenCalledWith(
'user-123',
'Tavern',
'General',
expect.stringContaining('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',
});
// Mock validateGameToken to return user with API key
const mockUserInfo = {
userId: `user_${tokenWithApiKey.substring(0, 8)}`,
username: 'TestUser',
email: 'test@example.com',
zulipEmail: 'test@example.com',
zulipApiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
};
// Spy on the private method
jest.spyOn(service as any, 'validateGameToken').mockResolvedValue(mockUserInfo);
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(
mockUserInfo.userId,
expect.objectContaining({
username: mockUserInfo.zulipEmail,
apiKey: mockUserInfo.zulipApiKey,
realm: expect.any(String),
})
);
}
),
{ 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
);
// 注意sendMessage是异步调用的不在主流程中验证
}
),
{ 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).toBeDefined(); // 游戏内消息ID总是存在
}
),
{ 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('辅助方法', () => {
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);
});
});
});