/** * 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; let mockSessionManager: jest.Mocked; let mockMessageFilter: jest.Mocked; let mockEventProcessor: jest.Mocked; let mockConfigManager: jest.Mocked; // 创建模拟的Zulip客户端实例 const createMockClientInstance = (overrides: Partial = {}): 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 => ({ 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); }); 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); }); }); });