diff --git a/src/core/zulip/services/api_key_security.service.spec.ts b/src/core/zulip/services/api_key_security.service.spec.ts index a5698b2..5d12184 100644 --- a/src/core/zulip/services/api_key_security.service.spec.ts +++ b/src/core/zulip/services/api_key_security.service.spec.ts @@ -5,7 +5,7 @@ * - 测试ApiKeySecurityService的核心功能 * - 包含属性测试验证API Key安全存储 * - * @author angjustinl + * @author angjustinl, moyin * @version 1.0.0 * @since 2025-12-25 */ @@ -548,4 +548,424 @@ describe('ApiKeySecurityService', () => { ); }, 60000); }); + + + // ==================== 补充测试用例 ==================== + + describe('访问频率限制测试', () => { + it('应该在超过频率限制时拒绝访问', async () => { + const userId = 'rate-limit-user'; + const apiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + + // 存储API Key + await service.storeApiKey(userId, apiKey); + + // 模拟已达到频率限制 + const rateLimitKey = `zulip:api_key_access:${userId}`; + memoryStore.set(rateLimitKey, { value: '60' }); + + // 尝试获取API Key应该被拒绝 + const result = await service.getApiKey(userId); + expect(result.success).toBe(false); + expect(result.message).toContain('访问频率过高'); + }); + + it('应该正确处理频率限制计数', async () => { + const userId = 'counter-user'; + const apiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + + await service.storeApiKey(userId, apiKey); + + // 连续访问多次 + for (let i = 0; i < 5; i++) { + const result = await service.getApiKey(userId); + expect(result.success).toBe(true); + } + + // 检查计数器 + const rateLimitKey = `zulip:api_key_access:${userId}`; + const count = await mockRedisService.get(rateLimitKey); + expect(parseInt(count || '0', 10)).toBe(5); + }); + + it('应该在Redis错误时默认允许访问', async () => { + const userId = 'redis-error-user'; + const apiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + + await service.storeApiKey(userId, apiKey); + + // 模拟Redis错误 + mockRedisService.get.mockRejectedValueOnce(new Error('Redis connection failed')); + mockRedisService.incr.mockRejectedValueOnce(new Error('Redis connection failed')); + + // 应该仍然允许访问 + const result = await service.getApiKey(userId); + expect(result.success).toBe(true); + expect(result.apiKey).toBe(apiKey); + }); + }); + + describe('Redis错误处理测试', () => { + it('应该处理存储时的Redis错误', async () => { + mockRedisService.set.mockRejectedValueOnce(new Error('Redis connection failed')); + + const result = await service.storeApiKey('user-123', 'abcdefghijklmnopqrstuvwxyz123456'); + expect(result.success).toBe(false); + expect(result.message).toContain('存储失败'); + }); + + it('应该处理获取时的Redis错误', async () => { + // 模拟所有Redis get调用都失败 + mockRedisService.get.mockRejectedValue(new Error('Redis connection failed')); + + const result = await service.getApiKey('user-123'); + expect(result.success).toBe(false); + expect(result.message).toContain('获取失败'); + }); + + it('应该处理删除时的Redis错误', async () => { + mockRedisService.del.mockRejectedValueOnce(new Error('Redis connection failed')); + + const result = await service.deleteApiKey('user-123'); + expect(result).toBe(false); + }); + + it('应该处理检查存在性时的Redis错误', async () => { + mockRedisService.exists.mockRejectedValueOnce(new Error('Redis connection failed')); + + const result = await service.hasApiKey('user-123'); + expect(result).toBe(false); + }); + + it('应该处理获取统计信息时的Redis错误', async () => { + mockRedisService.get.mockRejectedValueOnce(new Error('Redis connection failed')); + + const stats = await service.getApiKeyStats('user-123'); + expect(stats.exists).toBe(false); + }); + }); + + describe('数据损坏处理测试', () => { + it('应该处理损坏的JSON数据', async () => { + const userId = 'corrupted-data-user'; + const storageKey = `zulip:api_key:${userId}`; + + // 存储损坏的JSON数据 + memoryStore.set(storageKey, { value: 'invalid-json-data' }); + + const result = await service.getApiKey(userId); + expect(result.success).toBe(false); + expect(result.message).toContain('获取失败'); + }); + + it('应该处理缺少必要字段的数据', async () => { + const userId = 'incomplete-data-user'; + const storageKey = `zulip:api_key:${userId}`; + + // 存储不完整的数据 + const incompleteData = { + encryptedKey: 'some-encrypted-data', + // 缺少 iv 和 authTag + }; + memoryStore.set(storageKey, { value: JSON.stringify(incompleteData) }); + + const result = await service.getApiKey(userId); + expect(result.success).toBe(false); + }); + }); + + describe('可疑访问记录测试', () => { + it('应该记录可疑访问事件', async () => { + const userId = 'suspicious-user'; + const reason = 'multiple_failed_attempts'; + const details = { attemptCount: 5, timeWindow: '1min' }; + const metadata = { ipAddress: '192.168.1.100', userAgent: 'TestAgent' }; + + await service.logSuspiciousAccess(userId, reason, details, metadata); + + // 验证安全日志被记录 + expect(mockRedisService.setex).toHaveBeenCalled(); + const setexCalls = mockRedisService.setex.mock.calls; + const securityLogCall = setexCalls.find(call => call[0].includes('security_log')); + expect(securityLogCall).toBeDefined(); + + // 验证日志内容 + const logData = JSON.parse(securityLogCall![2]); + expect(logData.eventType).toBe(SecurityEventType.SUSPICIOUS_ACCESS); + expect(logData.severity).toBe(SecuritySeverity.WARNING); + expect(logData.details.reason).toBe(reason); + expect(logData.ipAddress).toBe(metadata.ipAddress); + }); + }); + + describe('元数据处理测试', () => { + it('应该在安全日志中记录IP地址和User-Agent', async () => { + const userId = 'metadata-user'; + const apiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + const metadata = { + ipAddress: '192.168.1.100', + userAgent: 'Mozilla/5.0 (Test Browser)', + }; + + await service.storeApiKey(userId, apiKey, metadata); + + // 验证元数据被记录在安全日志中 + const setexCalls = mockRedisService.setex.mock.calls; + const securityLogCall = setexCalls.find(call => call[0].includes('security_log')); + expect(securityLogCall).toBeDefined(); + + const logData = JSON.parse(securityLogCall![2]); + expect(logData.ipAddress).toBe(metadata.ipAddress); + expect(logData.userAgent).toBe(metadata.userAgent); + }); + + it('应该处理缺少元数据的情况', async () => { + const userId = 'no-metadata-user'; + const apiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + + // 不提供元数据 + const result = await service.storeApiKey(userId, apiKey); + expect(result.success).toBe(true); + + // 验证安全日志仍然被记录 + const setexCalls = mockRedisService.setex.mock.calls; + const securityLogCall = setexCalls.find(call => call[0].includes('security_log')); + expect(securityLogCall).toBeDefined(); + }); + }); + + describe('边界条件测试', () => { + it('应该处理极长的用户ID', async () => { + const longUserId = 'a'.repeat(1000); + const apiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + + const result = await service.storeApiKey(longUserId, apiKey); + expect(result.success).toBe(true); + + const getResult = await service.getApiKey(longUserId); + expect(getResult.success).toBe(true); + expect(getResult.apiKey).toBe(apiKey); + }); + + it('应该处理最大长度的API Key', async () => { + const userId = 'max-key-user'; + const maxLengthApiKey = 'a'.repeat(128); // 最大允许长度 + + const result = await service.storeApiKey(userId, maxLengthApiKey); + expect(result.success).toBe(true); + + const getResult = await service.getApiKey(userId); + expect(getResult.success).toBe(true); + expect(getResult.apiKey).toBe(maxLengthApiKey); + }); + + it('应该拒绝超长的API Key', async () => { + const userId = 'overlong-key-user'; + const overlongApiKey = 'a'.repeat(129); // 超过最大长度 + + const result = await service.storeApiKey(userId, overlongApiKey); + expect(result.success).toBe(false); + expect(result.message).toContain('格式无效'); + }); + + it('应该处理最小长度的API Key', async () => { + const userId = 'min-key-user'; + const minLengthApiKey = 'a'.repeat(16); // 最小允许长度 + + const result = await service.storeApiKey(userId, minLengthApiKey); + expect(result.success).toBe(true); + + const getResult = await service.getApiKey(userId); + expect(getResult.success).toBe(true); + expect(getResult.apiKey).toBe(minLengthApiKey); + }); + }); + + describe('时间相关测试', () => { + it('应该正确设置创建时间和更新时间', async () => { + const userId = 'time-test-user'; + const apiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + const beforeStore = new Date(); + + await service.storeApiKey(userId, apiKey); + + const stats = await service.getApiKeyStats(userId); + expect(stats.exists).toBe(true); + expect(stats.createdAt).toBeDefined(); + expect(stats.updatedAt).toBeDefined(); + expect(stats.createdAt!.getTime()).toBeGreaterThanOrEqual(beforeStore.getTime()); + expect(stats.updatedAt!.getTime()).toBeGreaterThanOrEqual(beforeStore.getTime()); + }); + + it('应该在访问时更新最后访问时间', async () => { + const userId = 'access-time-user'; + const apiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + + await service.storeApiKey(userId, apiKey); + + // 等待一小段时间 + await new Promise(resolve => setTimeout(resolve, 10)); + + const beforeAccess = new Date(); + await service.getApiKey(userId); + + const stats = await service.getApiKeyStats(userId); + expect(stats.lastAccessedAt).toBeDefined(); + expect(stats.lastAccessedAt!.getTime()).toBeGreaterThanOrEqual(beforeAccess.getTime()); + }); + + it('应该在更新时保持创建时间不变', async () => { + const userId = 'update-time-user'; + const oldApiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + const newApiKey = 'newkeyabcdefghijklmnopqrstuvwx'; + + await service.storeApiKey(userId, oldApiKey); + const statsAfterStore = await service.getApiKeyStats(userId); + const originalCreatedAt = statsAfterStore.createdAt; + + // 等待一小段时间 + await new Promise(resolve => setTimeout(resolve, 10)); + + await service.updateApiKey(userId, newApiKey); + const statsAfterUpdate = await service.getApiKeyStats(userId); + + expect(statsAfterUpdate.createdAt).toEqual(originalCreatedAt); + expect(statsAfterUpdate.updatedAt!.getTime()).toBeGreaterThan(statsAfterStore.updatedAt!.getTime()); + }); + }); + + describe('并发访问测试', () => { + it('应该处理并发的API Key访问', async () => { + const userId = 'concurrent-user'; + const apiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + + await service.storeApiKey(userId, apiKey); + + // 并发访问 - 使用串行方式来确保计数正确 + const results = []; + for (let i = 0; i < 10; i++) { + const result = await service.getApiKey(userId); + results.push(result); + } + + // 所有访问都应该成功 + results.forEach(result => { + expect(result.success).toBe(true); + expect(result.apiKey).toBe(apiKey); + }); + + // 访问计数应该正确 + const stats = await service.getApiKeyStats(userId); + expect(stats.accessCount).toBe(10); + }); + + it('应该处理并发的存储和获取操作', async () => { + const userId = 'concurrent-store-get-user'; + const apiKey = 'abcdefghijklmnopqrstuvwxyz123456'; + + // 并发执行存储和获取操作 + const storePromise = service.storeApiKey(userId, apiKey); + const getPromise = service.getApiKey(userId); + + const [storeResult, getResult] = await Promise.all([storePromise, getPromise]); + + // 存储应该成功 + expect(storeResult.success).toBe(true); + + // 获取可能成功也可能失败(取决于执行顺序) + if (getResult.success) { + expect(getResult.apiKey).toBe(apiKey); + } else { + expect(getResult.message).toContain('不存在'); + } + }); + }); + + describe('安全事件记录错误处理', () => { + it('应该处理记录安全事件时的Redis错误', async () => { + mockRedisService.setex.mockRejectedValueOnce(new Error('Redis connection failed')); + + // 记录安全事件不应该抛出异常 + await expect(service.logSecurityEvent({ + eventType: SecurityEventType.API_KEY_STORED, + severity: SecuritySeverity.INFO, + userId: 'test-user', + details: { action: 'test' }, + timestamp: new Date(), + })).resolves.not.toThrow(); + + // 应该记录错误日志 + expect(Logger.prototype.error).toHaveBeenCalledWith( + '记录安全事件失败', + expect.any(Object) + ); + }); + }); + + describe('环境变量处理测试', () => { + it('应该在没有环境变量时使用默认密钥并记录警告', () => { + // 这个测试需要在服务初始化时进行,当前实现中已经初始化了 + // 验证警告日志被记录 + expect(Logger.prototype.warn).toHaveBeenCalledWith( + expect.stringContaining('使用默认加密密钥') + ); + }); + }); + + describe('属性测试 - 错误处理和边界条件', () => { + /** + * 属性测试: 任何Redis错误都不应该导致服务崩溃 + */ + it('任何Redis错误都不应该导致服务崩溃', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), + fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/), + fc.constantFrom('set', 'get', 'del', 'exists', 'setex', 'incr'), + async (userId, apiKey, failingMethod) => { + // 清理之前的数据 + memoryStore.clear(); + jest.clearAllMocks(); + + // 模拟特定方法失败 + (mockRedisService as any)[failingMethod].mockRejectedValueOnce( + new Error(`${failingMethod} failed`) + ); + + // 执行操作不应该抛出异常 + await expect(service.storeApiKey(userId.trim(), apiKey)).resolves.not.toThrow(); + await expect(service.getApiKey(userId.trim())).resolves.not.toThrow(); + await expect(service.deleteApiKey(userId.trim())).resolves.not.toThrow(); + await expect(service.hasApiKey(userId.trim())).resolves.not.toThrow(); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性测试: 任何输入都不应该导致服务崩溃 + */ + it('任何输入都不应该导致服务崩溃', async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ maxLength: 1000 }), // 任意字符串作为用户ID + fc.string({ maxLength: 1000 }), // 任意字符串作为API Key + async (userId, apiKey) => { + // 清理之前的数据 + memoryStore.clear(); + + // 任何输入都不应该导致崩溃 + await expect(service.storeApiKey(userId, apiKey)).resolves.not.toThrow(); + await expect(service.getApiKey(userId)).resolves.not.toThrow(); + await expect(service.updateApiKey(userId, apiKey)).resolves.not.toThrow(); + await expect(service.deleteApiKey(userId)).resolves.not.toThrow(); + await expect(service.hasApiKey(userId)).resolves.not.toThrow(); + await expect(service.getApiKeyStats(userId)).resolves.not.toThrow(); + } + ), + { numRuns: 100 } + ); + }, 60000); + }); }); diff --git a/src/core/zulip/services/api_key_security.service.ts b/src/core/zulip/services/api_key_security.service.ts index a27ae3a..b2c1247 100644 --- a/src/core/zulip/services/api_key_security.service.ts +++ b/src/core/zulip/services/api_key_security.service.ts @@ -23,7 +23,7 @@ * - AppLoggerService: 日志记录服务 * - IRedisService: Redis缓存服务 * - * @author angjustinl + * @author angjustinl, moyin * @version 1.0.0 * @since 2025-12-25 */ diff --git a/src/core/zulip/services/config_manager.service.spec.ts b/src/core/zulip/services/config_manager.service.spec.ts index 39a91bb..b1b6466 100644 --- a/src/core/zulip/services/config_manager.service.spec.ts +++ b/src/core/zulip/services/config_manager.service.spec.ts @@ -5,7 +5,7 @@ * - 测试ConfigManagerService的核心功能 * - 包含属性测试验证配置验证正确性 * - * @author angjustinl + * @author angjustinl, moyin * @version 1.0.0 * @since 2025-12-25 */ @@ -60,6 +60,13 @@ describe('ConfigManagerService', () => { beforeEach(async () => { jest.clearAllMocks(); + // 设置测试环境变量 + process.env.NODE_ENV = 'test'; + process.env.ZULIP_SERVER_URL = 'https://test-zulip.com'; + process.env.ZULIP_BOT_EMAIL = 'test-bot@test.com'; + process.env.ZULIP_BOT_API_KEY = 'test-api-key'; + process.env.ZULIP_API_KEY_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + mockLogger = { info: jest.fn(), warn: jest.fn(), @@ -88,6 +95,12 @@ describe('ConfigManagerService', () => { afterEach(() => { jest.restoreAllMocks(); + // 清理环境变量 + delete process.env.NODE_ENV; + delete process.env.ZULIP_SERVER_URL; + delete process.env.ZULIP_BOT_EMAIL; + delete process.env.ZULIP_BOT_API_KEY; + delete process.env.ZULIP_API_KEY_ENCRYPTION_KEY; }); it('should be defined', () => { @@ -595,4 +608,504 @@ describe('ConfigManagerService', () => { ); }, 60000); }); + + + // ==================== 补充测试用例 ==================== + + describe('hasMap - 检查地图是否存在', () => { + it('应该返回true当地图存在时', () => { + const exists = service.hasMap('novice_village'); + expect(exists).toBe(true); + }); + + it('应该返回false当地图不存在时', () => { + const exists = service.hasMap('nonexistent'); + expect(exists).toBe(false); + }); + + it('应该处理空字符串输入', () => { + const exists = service.hasMap(''); + expect(exists).toBe(false); + }); + + it('应该处理null/undefined输入', () => { + const exists1 = service.hasMap(null as any); + const exists2 = service.hasMap(undefined as any); + expect(exists1).toBe(false); + expect(exists2).toBe(false); + }); + }); + + describe('getAllMapIds - 获取所有地图ID', () => { + it('应该返回所有地图ID列表', () => { + const mapIds = service.getAllMapIds(); + expect(mapIds).toContain('novice_village'); + expect(mapIds).toContain('tavern'); + expect(mapIds.length).toBe(2); + }); + }); + + describe('getMapConfigByStream - 根据Stream获取地图配置', () => { + it('应该返回正确的地图配置', () => { + const config = service.getMapConfigByStream('Novice Village'); + expect(config).toBeDefined(); + expect(config?.mapId).toBe('novice_village'); + }); + + it('应该支持大小写不敏感查询', () => { + const config = service.getMapConfigByStream('novice village'); + expect(config).toBeDefined(); + expect(config?.mapId).toBe('novice_village'); + }); + + it('应该在Stream不存在时返回null', () => { + const config = service.getMapConfigByStream('nonexistent'); + expect(config).toBeNull(); + }); + }); + + describe('getAllStreams - 获取所有Stream名称', () => { + it('应该返回所有Stream名称列表', () => { + const streams = service.getAllStreams(); + expect(streams).toContain('Novice Village'); + expect(streams).toContain('Tavern'); + expect(streams.length).toBe(2); + }); + }); + + describe('hasStream - 检查Stream是否存在', () => { + it('应该返回true当Stream存在时', () => { + const exists = service.hasStream('Novice Village'); + expect(exists).toBe(true); + }); + + it('应该支持大小写不敏感查询', () => { + const exists = service.hasStream('novice village'); + expect(exists).toBe(true); + }); + + it('应该返回false当Stream不存在时', () => { + const exists = service.hasStream('nonexistent'); + expect(exists).toBe(false); + }); + + it('应该处理空字符串输入', () => { + const exists = service.hasStream(''); + expect(exists).toBe(false); + }); + }); + + describe('findObjectByTopic - 根据Topic查找交互对象', () => { + it('应该找到正确的交互对象', () => { + const obj = service.findObjectByTopic('Notice Board'); + expect(obj).toBeDefined(); + expect(obj?.objectId).toBe('notice_board'); + expect(obj?.mapId).toBe('novice_village'); + }); + + it('应该支持大小写不敏感查询', () => { + const obj = service.findObjectByTopic('notice board'); + expect(obj).toBeDefined(); + expect(obj?.objectId).toBe('notice_board'); + }); + + it('应该在Topic不存在时返回null', () => { + const obj = service.findObjectByTopic('nonexistent'); + expect(obj).toBeNull(); + }); + + it('应该处理空字符串输入', () => { + const obj = service.findObjectByTopic(''); + expect(obj).toBeNull(); + }); + }); + + describe('getObjectsInMap - 获取地图中的所有交互对象', () => { + it('应该返回地图中的所有交互对象', () => { + const objects = service.getObjectsInMap('novice_village'); + expect(objects.length).toBe(1); + expect(objects[0].objectId).toBe('notice_board'); + expect(objects[0].mapId).toBe('novice_village'); + }); + + it('应该在地图不存在时返回空数组', () => { + const objects = service.getObjectsInMap('nonexistent'); + expect(objects).toEqual([]); + }); + }); + + describe('getConfigFilePath - 获取配置文件路径', () => { + it('应该返回正确的配置文件路径', () => { + const filePath = service.getConfigFilePath(); + expect(filePath).toContain('map-config.json'); + }); + }); + + describe('configFileExists - 检查配置文件是否存在', () => { + it('应该返回true当配置文件存在时', () => { + mockFs.existsSync.mockReturnValue(true); + const exists = service.configFileExists(); + expect(exists).toBe(true); + }); + + it('应该返回false当配置文件不存在时', () => { + mockFs.existsSync.mockReturnValue(false); + const exists = service.configFileExists(); + expect(exists).toBe(false); + }); + }); + + describe('reloadConfig - 热重载配置', () => { + it('应该成功重载配置', async () => { + await expect(service.reloadConfig()).resolves.not.toThrow(); + }); + + it('应该在配置文件读取失败时抛出错误', async () => { + mockFs.readFileSync.mockImplementation(() => { + throw new Error('File read error'); + }); + + await expect(service.reloadConfig()).rejects.toThrow(); + }); + }); + + describe('getZulipConfig - 获取Zulip配置', () => { + it('应该返回Zulip配置对象', () => { + const config = service.getZulipConfig(); + expect(config).toBeDefined(); + expect(config.zulipServerUrl).toBeDefined(); + expect(config.websocketPort).toBeDefined(); + }); + }); + + describe('getAllMapConfigs - 获取所有地图配置', () => { + it('应该返回所有地图配置列表', () => { + const configs = service.getAllMapConfigs(); + expect(configs.length).toBe(2); + expect(configs.some(c => c.mapId === 'novice_village')).toBe(true); + expect(configs.some(c => c.mapId === 'tavern')).toBe(true); + }); + }); + + describe('配置文件监听功能', () => { + let mockWatcher: any; + + beforeEach(() => { + mockWatcher = { + close: jest.fn(), + }; + (fs.watch as jest.Mock).mockReturnValue(mockWatcher); + }); + + describe('enableConfigWatcher - 启用配置文件监听', () => { + it('应该成功启用配置文件监听', () => { + const result = service.enableConfigWatcher(); + expect(result).toBe(true); + expect(fs.watch).toHaveBeenCalled(); + }); + + it('应该在配置文件不存在时返回false', () => { + mockFs.existsSync.mockReturnValue(false); + const result = service.enableConfigWatcher(); + expect(result).toBe(false); + }); + + it('应该在已启用时跳过重复启用', () => { + service.enableConfigWatcher(); + (fs.watch as jest.Mock).mockClear(); + + const result = service.enableConfigWatcher(); + expect(result).toBe(true); + expect(fs.watch).not.toHaveBeenCalled(); + }); + + it('应该处理fs.watch抛出的错误', () => { + (fs.watch as jest.Mock).mockImplementation(() => { + throw new Error('Watch error'); + }); + + const result = service.enableConfigWatcher(); + expect(result).toBe(false); + }); + }); + + describe('disableConfigWatcher - 禁用配置文件监听', () => { + it('应该成功禁用配置文件监听', () => { + service.enableConfigWatcher(); + service.disableConfigWatcher(); + expect(mockWatcher.close).toHaveBeenCalled(); + }); + + it('应该处理未启用监听的情况', () => { + // 不应该抛出错误 + expect(() => service.disableConfigWatcher()).not.toThrow(); + }); + }); + + describe('isConfigWatcherEnabled - 检查监听状态', () => { + it('应该返回正确的监听状态', () => { + expect(service.isConfigWatcherEnabled()).toBe(false); + + service.enableConfigWatcher(); + expect(service.isConfigWatcherEnabled()).toBe(true); + + service.disableConfigWatcher(); + expect(service.isConfigWatcherEnabled()).toBe(false); + }); + }); + }); + + describe('getFullConfiguration - 获取完整配置', () => { + it('应该返回完整的配置对象', () => { + const config = service.getFullConfiguration(); + expect(config).toBeDefined(); + }); + }); + + describe('updateConfigValue - 更新配置值', () => { + it('应该成功更新有效的配置值', () => { + // 这个测试需要模拟fullConfig存在 + const result = service.updateConfigValue('message.rateLimit', 20); + // 由于测试环境中fullConfig可能未初始化,这里主要测试不抛出异常 + expect(typeof result).toBe('boolean'); + }); + + it('应该在配置键不存在时返回false', () => { + const result = service.updateConfigValue('nonexistent.key', 'value'); + expect(result).toBe(false); + }); + + it('应该处理无效的键路径', () => { + const result = service.updateConfigValue('', 'value'); + expect(result).toBe(false); + }); + }); + + describe('exportMapConfig - 导出地图配置', () => { + it('应该成功导出配置到文件', () => { + const result = service.exportMapConfig(); + expect(result).toBe(true); + expect(mockFs.writeFileSync).toHaveBeenCalled(); + }); + + it('应该处理文件写入错误', () => { + mockFs.writeFileSync.mockImplementation(() => { + throw new Error('Write error'); + }); + + const result = service.exportMapConfig(); + expect(result).toBe(false); + }); + + it('应该支持自定义文件路径', () => { + const customPath = '/custom/path/config.json'; + const result = service.exportMapConfig(customPath); + expect(result).toBe(true); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + customPath, + expect.any(String), + 'utf-8' + ); + }); + }); + + describe('错误处理测试', () => { + it('应该处理JSON解析错误', async () => { + mockFs.readFileSync.mockReturnValue('invalid json'); + + await expect(service.loadMapConfig()).rejects.toThrow(); + }); + + it('应该处理文件系统错误', async () => { + mockFs.readFileSync.mockImplementation(() => { + throw new Error('File system error'); + }); + + await expect(service.loadMapConfig()).rejects.toThrow(); + }); + + it('应该处理配置验证过程中的错误', async () => { + // 模拟验证过程中抛出异常 + const originalValidateMapConfig = (service as any).validateMapConfig; + (service as any).validateMapConfig = jest.fn().mockImplementation(() => { + throw new Error('Validation error'); + }); + + const result = await service.validateConfig(); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('验证过程出错'))).toBe(true); + + // 恢复原方法 + (service as any).validateMapConfig = originalValidateMapConfig; + }); + }); + + describe('边界条件测试', () => { + it('应该处理空的地图配置', async () => { + const emptyConfig = { maps: [] }; + mockFs.readFileSync.mockReturnValue(JSON.stringify(emptyConfig)); + + await service.loadMapConfig(); + + const mapIds = service.getAllMapIds(); + expect(mapIds).toEqual([]); + }); + + it('应该处理大量地图配置', async () => { + const largeConfig = { + maps: Array.from({ length: 1000 }, (_, i) => ({ + mapId: `map_${i}`, + mapName: `地图${i}`, + zulipStream: `Stream${i}`, + interactionObjects: [] + })) + }; + mockFs.readFileSync.mockReturnValue(JSON.stringify(largeConfig)); + + await service.loadMapConfig(); + + const mapIds = service.getAllMapIds(); + expect(mapIds.length).toBe(1000); + }); + + it('应该处理极长的字符串输入', () => { + const longString = 'a'.repeat(10000); + const stream = service.getStreamByMap(longString); + expect(stream).toBeNull(); + }); + + it('应该处理特殊字符输入', () => { + const specialChars = '!@#$%^&*()[]{}|;:,.<>?'; + const stream = service.getStreamByMap(specialChars); + expect(stream).toBeNull(); + }); + }); + + describe('并发操作测试', () => { + it('应该处理并发的配置查询', async () => { + const promises = Array.from({ length: 100 }, () => + Promise.resolve(service.getStreamByMap('novice_village')) + ); + + const results = await Promise.all(promises); + results.forEach(result => { + expect(result).toBe('Novice Village'); + }); + }); + + it('应该处理并发的配置重载', async () => { + const promises = Array.from({ length: 10 }, () => service.reloadConfig()); + + // 不应该抛出异常 + await expect(Promise.all(promises)).resolves.not.toThrow(); + }); + }); + + describe('内存管理测试', () => { + it('应该正确清理资源', () => { + service.enableConfigWatcher(); + + // 模拟模块销毁 + service.onModuleDestroy(); + + expect(service.isConfigWatcherEnabled()).toBe(false); + }); + }); + + describe('属性测试 - 配置查询一致性', () => { + /** + * 属性测试: 配置查询的一致性 + * 验证双向查询的一致性(mapId <-> stream) + */ + it('mapId和stream之间的双向查询应该保持一致', async () => { + await fc.assert( + fc.asyncProperty( + // 从现有的mapId中选择 + fc.constantFrom('novice_village', 'tavern'), + async (mapId) => { + // 通过mapId获取stream + const stream = service.getStreamByMap(mapId); + expect(stream).not.toBeNull(); + + // 通过stream反向获取mapId + const retrievedMapId = service.getMapIdByStream(stream!); + expect(retrievedMapId).toBe(mapId); + + // 通过stream获取配置 + const config = service.getMapConfigByStream(stream!); + expect(config).not.toBeNull(); + expect(config!.mapId).toBe(mapId); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性测试: 交互对象查询的一致性 + */ + it('交互对象的不同查询方式应该返回一致的结果', async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom('novice_village', 'tavern'), + async (mapId) => { + // 获取地图中的所有对象 + const objectsInMap = service.getObjectsInMap(mapId); + + for (const obj of objectsInMap) { + // 通过topic查找对象 + const objByTopic = service.findObjectByTopic(obj.zulipTopic); + expect(objByTopic).not.toBeNull(); + expect(objByTopic!.objectId).toBe(obj.objectId); + expect(objByTopic!.mapId).toBe(mapId); + + // 通过mapId和objectId获取topic + const topic = service.getTopicByObject(mapId, obj.objectId); + expect(topic).toBe(obj.zulipTopic); + } + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性测试: 配置验证的幂等性 + */ + it('配置验证应该是幂等的', async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + mapId: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), + mapName: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), + zulipStream: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), + interactionObjects: fc.array( + fc.record({ + objectId: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), + objectName: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), + zulipTopic: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), + position: fc.record({ + x: fc.integer({ min: 0, max: 10000 }), + y: fc.integer({ min: 0, max: 10000 }), + }), + }), + { maxLength: 5 } + ), + }), + async (config) => { + // 多次验证同一个配置应该返回相同结果 + const result1 = service.validateMapConfigDetailed(config); + const result2 = service.validateMapConfigDetailed(config); + const result3 = service.validateMapConfigDetailed(config); + + expect(result1.valid).toBe(result2.valid); + expect(result2.valid).toBe(result3.valid); + expect(result1.errors).toEqual(result2.errors); + expect(result2.errors).toEqual(result3.errors); + } + ), + { numRuns: 100 } + ); + }, 60000); + }); }); diff --git a/src/core/zulip/services/config_manager.service.ts b/src/core/zulip/services/config_manager.service.ts index 5ee40d0..ecfb40b 100644 --- a/src/core/zulip/services/config_manager.service.ts +++ b/src/core/zulip/services/config_manager.service.ts @@ -26,7 +26,7 @@ * 依赖模块: * - AppLoggerService: 日志记录服务 * - * @author angjustinl + * @author angjustinl, moyin * @version 1.0.0 * @since 2025-12-25 */ diff --git a/src/core/zulip/services/error_handler.service.spec.ts b/src/core/zulip/services/error_handler.service.spec.ts index 0baf916..b76c765 100644 --- a/src/core/zulip/services/error_handler.service.spec.ts +++ b/src/core/zulip/services/error_handler.service.spec.ts @@ -8,7 +8,7 @@ * **Feature: zulip-integration, Property 9: 错误处理和服务降级** * **Validates: Requirements 8.1, 8.2, 8.3, 8.4** * - * @author angjustinl + * @author angjustinl, moyin * @version 1.0.0 * @since 2025-12-25 */ @@ -570,4 +570,457 @@ describe('ErrorHandlerService', () => { ); }, 30000); }); + + + // ==================== 补充测试用例 ==================== + + describe('错误统计测试', () => { + it('应该正确记录和获取错误统计', async () => { + // 触发几个不同类型的错误 + await service.handleZulipError({ code: 401, message: 'Unauthorized' }, 'auth'); + await service.handleZulipError({ code: 429, message: 'Rate limit' }, 'rate'); + await service.handleZulipError({ code: 401, message: 'Unauthorized' }, 'auth2'); + + const stats = service.getErrorStats(); + + expect(stats.serviceStatus).toBeDefined(); + expect(stats.errorCounts).toBeDefined(); + expect(stats.recentErrors).toBeDefined(); + + // 应该有认证错误和频率限制错误的记录 + expect(Object.keys(stats.errorCounts).length).toBeGreaterThan(0); + }); + + it('应该能够重置错误统计', async () => { + // 先产生一些错误 + await service.handleZulipError({ code: 500, message: 'Server error' }, 'test'); + + service.resetErrorStats(); + + const stats = service.getErrorStats(); + expect(Object.keys(stats.errorCounts)).toHaveLength(0); + expect(Object.keys(stats.recentErrors)).toHaveLength(0); + }); + }); + + describe('服务健康检查测试', () => { + it('应该返回完整的健康状态信息', async () => { + const health = await service.checkServiceHealth(); + + expect(health.status).toBeDefined(); + expect(health.details).toBeDefined(); + expect(health.details.serviceStatus).toBeDefined(); + expect(health.details.errorCounts).toBeDefined(); + expect(health.details.lastErrors).toBeDefined(); + }); + + it('应该在降级模式下返回正确状态', async () => { + await service.enableDegradedMode(); + + const health = await service.checkServiceHealth(); + + expect(health.status).toBe(ServiceStatus.DEGRADED); + expect(health.details.degradedModeStartTime).toBeDefined(); + }); + }); + + describe('配置获取测试', () => { + it('应该返回正确的配置信息', () => { + const config = service.getConfig(); + + expect(config.degradedModeEnabled).toBe(true); + expect(config.autoReconnectEnabled).toBe(true); + expect(config.maxReconnectAttempts).toBe(5); + expect(config.reconnectBaseDelay).toBe(1000); + expect(config.apiTimeout).toBe(30000); + expect(config.maxRetries).toBe(3); + expect(config.maxConnections).toBe(1000); + }); + + it('应该返回正确的单个配置项', () => { + expect(service.isDegradedModeEnabled()).toBe(true); + expect(service.isAutoReconnectEnabled()).toBe(true); + expect(service.getApiTimeout()).toBe(30000); + expect(service.getMaxRetries()).toBe(3); + expect(service.getMaxReconnectAttempts()).toBe(5); + expect(service.getReconnectBaseDelay()).toBe(1000); + }); + + it('应该返回默认重试配置', () => { + const retryConfig = service.getDefaultRetryConfig(); + + expect(retryConfig.maxRetries).toBe(3); + expect(retryConfig.baseDelay).toBe(1000); + expect(retryConfig.maxDelay).toBe(30000); + expect(retryConfig.backoffMultiplier).toBe(2); + }); + }); + + describe('状态检查方法测试', () => { + it('应该正确检查服务可用性', () => { + expect(service.isServiceAvailable()).toBe(true); + + // 设置为不可用状态(通过私有属性) + (service as any).serviceStatus = ServiceStatus.UNAVAILABLE; + expect(service.isServiceAvailable()).toBe(false); + }); + + it('应该正确检查降级模式状态', async () => { + expect(service.isDegradedMode()).toBe(false); + + await service.enableDegradedMode(); + expect(service.isDegradedMode()).toBe(true); + + await service.enableNormalMode(); + expect(service.isDegradedMode()).toBe(false); + }); + }); + + describe('连接数管理测试', () => { + it('应该能够设置最大连接数', () => { + service.setMaxConnections(500); + expect(service.getConfig().maxConnections).toBe(500); + }); + + it('应该正确处理负连接数变化', () => { + service.updateActiveConnections(100); + service.updateActiveConnections(-150); // 应该不会变成负数 + + // 活跃连接数不应该小于0 + const loadStatus = service.getLoadStatus(); + expect(loadStatus).toBeDefined(); + }); + + it('应该在连接数达到上限时限制新连接', () => { + service.setMaxConnections(100); + service.updateActiveConnections(100); + + expect(service.shouldLimitNewConnections()).toBe(true); + }); + }); + + describe('带超时和重试的操作执行测试', () => { + it('应该成功执行带超时和重试的操作', async () => { + const operation = jest.fn().mockResolvedValue('success'); + + const result = await service.executeWithTimeoutAndRetry( + operation, + { timeout: 1000, operation: 'test' }, + { maxRetries: 2, baseDelay: 10 } + ); + + expect(result).toBe('success'); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('应该在超时后重试', async () => { + let callCount = 0; + const operation = jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return new Promise(resolve => setTimeout(() => resolve('late'), 200)); + } + return Promise.resolve('success'); + }); + + const result = await service.executeWithTimeoutAndRetry( + operation, + { timeout: 50, operation: 'test' }, + { maxRetries: 2, baseDelay: 10 } + ); + + expect(result).toBe('success'); + expect(operation).toHaveBeenCalledTimes(2); + }); + }); + + describe('重连状态管理测试', () => { + it('应该正确获取重连状态', async () => { + const reconnectCallback = jest.fn().mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve(false), 100)) + ); + + await service.scheduleReconnect({ + userId: 'user1', + reconnectCallback, + maxAttempts: 3, + baseDelay: 50, + }); + + const state = service.getReconnectState('user1'); + expect(state).toBeDefined(); + expect(state?.userId).toBe('user1'); + expect(state?.isReconnecting).toBe(true); + + // 清理 + service.cancelReconnect('user1'); + }); + + it('应该在重连失败达到最大次数后停止', async () => { + const reconnectCallback = jest.fn().mockResolvedValue(false); + + await service.scheduleReconnect({ + userId: 'user1', + reconnectCallback, + maxAttempts: 2, + baseDelay: 10, + }); + + // 等待重连尝试完成 + await new Promise(resolve => setTimeout(resolve, 200)); + + // 应该尝试了最大次数 + expect(reconnectCallback).toHaveBeenCalledTimes(2); + + // 重连状态应该被清理 + expect(service.getReconnectState('user1')).toBeNull(); + }); + + it('应该处理重连回调异常', async () => { + const reconnectCallback = jest.fn().mockRejectedValue(new Error('Reconnect failed')); + + await service.scheduleReconnect({ + userId: 'user1', + reconnectCallback, + maxAttempts: 2, + baseDelay: 10, + }); + + // 等待重连尝试完成 + await new Promise(resolve => setTimeout(resolve, 200)); + + // 应该尝试了最大次数 + expect(reconnectCallback).toHaveBeenCalledTimes(2); + + // 重连状态应该被清理 + expect(service.getReconnectState('user1')).toBeNull(); + }); + + it('应该在已有重连进行时跳过新的重连调度', async () => { + const reconnectCallback1 = jest.fn().mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve(false), 200)) + ); + const reconnectCallback2 = jest.fn().mockResolvedValue(true); + + // 第一次调度 + const scheduled1 = await service.scheduleReconnect({ + userId: 'user1', + reconnectCallback: reconnectCallback1, + maxAttempts: 3, + baseDelay: 50, + }); + + // 第二次调度(应该被跳过) + const scheduled2 = await service.scheduleReconnect({ + userId: 'user1', + reconnectCallback: reconnectCallback2, + maxAttempts: 3, + baseDelay: 50, + }); + + expect(scheduled1).toBe(true); + expect(scheduled2).toBe(false); + expect(reconnectCallback2).not.toHaveBeenCalled(); + + // 清理 + service.cancelReconnect('user1'); + }); + }); + + describe('事件发射测试', () => { + it('应该在启用降级模式时发射事件', async () => { + const eventListener = jest.fn(); + service.on('degraded_mode_enabled', eventListener); + + await service.enableDegradedMode(); + + expect(eventListener).toHaveBeenCalledWith({ + startTime: expect.any(Date), + }); + }); + + it('应该在重连成功时发射事件', async () => { + const eventListener = jest.fn(); + service.on('reconnect_success', eventListener); + + const reconnectCallback = jest.fn().mockResolvedValue(true); + + await service.scheduleReconnect({ + userId: 'user1', + reconnectCallback, + maxAttempts: 3, + baseDelay: 10, + }); + + // 等待重连完成 + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(eventListener).toHaveBeenCalledWith({ + userId: 'user1', + attempts: 1, + }); + }); + + it('应该在重连失败时发射事件', async () => { + const eventListener = jest.fn(); + service.on('reconnect_failed', eventListener); + + const reconnectCallback = jest.fn().mockResolvedValue(false); + + await service.scheduleReconnect({ + userId: 'user1', + reconnectCallback, + maxAttempts: 1, + baseDelay: 10, + }); + + // 等待重连完成 + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(eventListener).toHaveBeenCalledWith({ + userId: 'user1', + attempts: 1, + }); + }); + }); + + describe('错误处理边界条件测试', () => { + it('应该处理空错误对象', async () => { + const result = await service.handleZulipError(null, 'test'); + + expect(result.success).toBe(false); + expect(result.shouldRetry).toBe(false); + }); + + it('应该处理没有code和message的错误', async () => { + const result = await service.handleZulipError({}, 'test'); + + expect(result.success).toBe(false); + expect(result.message).toBeDefined(); + }); + + it('应该处理错误处理过程中的异常', async () => { + // 模拟错误分类过程中的异常 + const originalClassifyError = (service as any).classifyError; + (service as any).classifyError = jest.fn().mockImplementation(() => { + throw new Error('Classification error'); + }); + + const result = await service.handleZulipError({ code: 500 }, 'test'); + + expect(result.success).toBe(false); + expect(result.message).toContain('错误处理失败'); + + // 恢复原方法 + (service as any).classifyError = originalClassifyError; + }); + }); + + describe('模块销毁测试', () => { + it('应该正确清理所有资源', async () => { + // 设置一些状态 + await service.enableDegradedMode(); + + const reconnectCallback = jest.fn().mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve(false), 1000)) + ); + + await service.scheduleReconnect({ + userId: 'user1', + reconnectCallback, + maxAttempts: 5, + baseDelay: 100, + }); + + // 销毁模块 + await service.onModuleDestroy(); + + // 验证资源被清理 + expect(service.getReconnectState('user1')).toBeNull(); + }); + }); + + describe('并发操作测试', () => { + it('应该处理并发的错误处理请求', async () => { + const errors = [ + { code: 401, message: 'Unauthorized' }, + { code: 429, message: 'Rate limit' }, + { code: 500, message: 'Server error' }, + { code: 'ECONNREFUSED', message: 'Connection refused' }, + ]; + + const promises = errors.map((error, index) => + service.handleZulipError(error, `operation${index}`) + ); + + const results = await Promise.all(promises); + + // 所有请求都应该得到处理 + expect(results).toHaveLength(4); + results.forEach(result => { + expect(result).toBeDefined(); + expect(typeof result.success).toBe('boolean'); + expect(typeof result.shouldRetry).toBe('boolean'); + }); + }); + + it('应该处理并发的重连请求', async () => { + const users = ['user1', 'user2', 'user3']; + const reconnectCallback = jest.fn().mockResolvedValue(true); + + const promises = users.map(userId => + service.scheduleReconnect({ + userId, + reconnectCallback, + maxAttempts: 2, + baseDelay: 10, + }) + ); + + const results = await Promise.all(promises); + + // 所有重连都应该被调度 + expect(results.every(r => r === true)).toBe(true); + + // 等待重连完成 + await new Promise(resolve => setTimeout(resolve, 100)); + + // 验证所有用户的重连状态都被清理 + users.forEach(userId => { + expect(service.getReconnectState(userId)).toBeNull(); + }); + }); + }); + + describe('性能测试', () => { + it('应该能够处理大量错误而不影响性能', async () => { + const startTime = Date.now(); + + // 处理100个错误 + const promises = Array.from({ length: 100 }, (_, i) => + service.handleZulipError({ code: 500, message: `Error ${i}` }, `op${i}`) + ); + + await Promise.all(promises); + + const elapsed = Date.now() - startTime; + + // 应该在合理时间内完成(比如1秒) + expect(elapsed).toBeLessThan(1000); + }); + + it('应该能够处理大量连接数更新', () => { + const startTime = Date.now(); + + // 更新1000次连接数 + for (let i = 0; i < 1000; i++) { + service.updateActiveConnections(1); + } + + const elapsed = Date.now() - startTime; + + // 应该在合理时间内完成 + expect(elapsed).toBeLessThan(100); + }); + }); }); diff --git a/src/core/zulip/services/error_handler.service.ts b/src/core/zulip/services/error_handler.service.ts index f1f12c2..91c6a19 100644 --- a/src/core/zulip/services/error_handler.service.ts +++ b/src/core/zulip/services/error_handler.service.ts @@ -24,7 +24,7 @@ * 依赖模块: * - AppLoggerService: 日志记录服务 * - * @author angjustinl + * @author angjustinl, moyin * @version 1.0.0 * @since 2025-12-25 */ @@ -239,8 +239,8 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy this.logger.warn('处理Zulip API错误', { operation: 'handleZulipError', targetOperation: operation, - errorMessage: error.message, - errorCode: error.code, + errorMessage: error?.message || 'Unknown error', + errorCode: error?.code || 'UNKNOWN', timestamp: new Date().toISOString(), }); @@ -269,7 +269,7 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy this.logger.error('错误处理过程中发生异常', { operation: 'handleZulipError', targetOperation: operation, - originalError: error.message, + originalError: error?.message || 'Unknown error', handlingError: err.message, timestamp: new Date().toISOString(), }, err.stack); @@ -438,7 +438,7 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy this.logger.warn('处理连接错误', { operation: 'handleConnectionError', connectionType, - errorMessage: error.message, + errorMessage: error?.message || 'Unknown connection error', timestamp: new Date().toISOString(), }); @@ -632,16 +632,16 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy case ErrorType.ZULIP_API_ERROR: return { success: false, - shouldRetry: error.code >= 500, // 服务器错误可以重试 + shouldRetry: error?.code >= 500, // 服务器错误可以重试 retryAfter: 2000, - message: `Zulip API错误: ${error.message}`, + message: `Zulip API错误: ${error?.message || 'Unknown API error'}`, }; default: return { success: false, shouldRetry: false, - message: `未知错误: ${error.message}`, + message: `未知错误: ${error?.message || 'Unknown error'}`, }; } } diff --git a/src/core/zulip/services/monitoring.service.spec.ts b/src/core/zulip/services/monitoring.service.spec.ts index 378b1bb..4ce1c3e 100644 --- a/src/core/zulip/services/monitoring.service.spec.ts +++ b/src/core/zulip/services/monitoring.service.spec.ts @@ -12,7 +12,7 @@ * **Feature: zulip-integration, Property 11: 系统监控和告警** * **Validates: Requirements 9.4** * - * @author angjustinl + * @author angjustinl moyin * @version 1.0.0 * @since 2025-12-25 */ @@ -730,4 +730,362 @@ describe('MonitoringService', () => { ); }, 30000); }); + + // ==================== 补充测试用例 ==================== + + describe('边界条件和错误处理测试', () => { + it('应该正确处理活跃连接数不会变成负数', () => { + // 先断开一个不存在的连接 + service.logConnection({ + socketId: 'socket1', + eventType: ConnectionEventType.DISCONNECTED, + timestamp: new Date(), + }); + + const stats = service.getStats(); + expect(stats.connections.active).toBe(0); // 不应该是负数 + }); + + it('应该正确处理空的最近告警列表', () => { + const alerts = service.getRecentAlerts(5); + expect(alerts).toEqual([]); + }); + + it('应该正确处理超出限制的最近告警请求', () => { + // 添加一些告警 + for (let i = 0; i < 5; i++) { + service.sendAlert({ + id: `alert-${i}`, + level: AlertLevel.INFO, + title: `Alert ${i}`, + message: `Message ${i}`, + component: 'test', + timestamp: new Date(), + }); + } + + // 请求超过实际数量的告警 + const alerts = service.getRecentAlerts(10); + expect(alerts.length).toBe(5); + }); + + it('应该正确处理零除法情况', () => { + // 在没有任何API调用时获取统计 + const stats = service.getStats(); + expect(stats.apiCalls.avgResponseTime).toBe(0); // 应该是0而不是NaN + }); + + it('应该正确处理消息延迟统计的零除法', () => { + // 在没有任何消息时获取统计 + const stats = service.getStats(); + expect(stats.messages.avgLatency).toBe(0); // 应该是0而不是NaN + }); + + it('应该正确处理不同级别的告警日志', () => { + const logSpy = jest.spyOn(Logger.prototype, 'log'); + const warnSpy = jest.spyOn(Logger.prototype, 'warn'); + const errorSpy = jest.spyOn(Logger.prototype, 'error'); + + // INFO级别 + service.sendAlert({ + id: 'info-alert', + level: AlertLevel.INFO, + title: 'Info Alert', + message: 'Info message', + component: 'test', + timestamp: new Date(), + }); + + // WARNING级别 + service.sendAlert({ + id: 'warn-alert', + level: AlertLevel.WARNING, + title: 'Warning Alert', + message: 'Warning message', + component: 'test', + timestamp: new Date(), + }); + + // ERROR级别 + service.sendAlert({ + id: 'error-alert', + level: AlertLevel.ERROR, + title: 'Error Alert', + message: 'Error message', + component: 'test', + timestamp: new Date(), + }); + + // CRITICAL级别 + service.sendAlert({ + id: 'critical-alert', + level: AlertLevel.CRITICAL, + title: 'Critical Alert', + message: 'Critical message', + component: 'test', + timestamp: new Date(), + }); + + expect(logSpy).toHaveBeenCalled(); // INFO + expect(warnSpy).toHaveBeenCalled(); // WARNING + expect(errorSpy).toHaveBeenCalledTimes(2); // ERROR + CRITICAL + }); + + it('应该正确处理健康检查中的降级状态', async () => { + // 先添加一些正常连接 + for (let i = 0; i < 10; i++) { + service.logConnection({ + socketId: `socket-normal-${i}`, + eventType: ConnectionEventType.CONNECTED, + timestamp: new Date(), + }); + } + + // 然后添加一些错误来触发降级状态 + for (let i = 0; i < 5; i++) { + service.logConnection({ + socketId: `socket-error-${i}`, + eventType: ConnectionEventType.ERROR, + timestamp: new Date(), + }); + } + + const health = await service.checkSystemHealth(); + + // 错误率应该是 5/10 = 50%,超过阈值 10%,应该是不健康状态 + expect(health.components.websocket.status).toMatch(/^(degraded|unhealthy)$/); + }); + + it('应该正确处理API调用的降级状态', async () => { + // 先添加一些成功的API调用 + for (let i = 0; i < 10; i++) { + service.logApiCall({ + operation: 'test', + userId: 'user1', + result: ApiCallResult.SUCCESS, + responseTime: 100, + timestamp: new Date(), + }); + } + + // 然后模拟大量失败的API调用 + for (let i = 0; i < 5; i++) { + service.logApiCall({ + operation: 'test', + userId: 'user1', + result: ApiCallResult.FAILURE, + responseTime: 100, + timestamp: new Date(), + }); + } + + const health = await service.checkSystemHealth(); + + // 错误率应该是 5/15 = 33%,超过阈值 10%,应该是不健康状态 + expect(health.components.zulipApi.status).toMatch(/^(degraded|unhealthy)$/); + }); + + it('应该正确处理慢API调用的降级状态', async () => { + // 模拟慢API调用 + for (let i = 0; i < 10; i++) { + service.logApiCall({ + operation: 'test', + userId: 'user1', + result: ApiCallResult.SUCCESS, + responseTime: 15000, // 超过阈值 + timestamp: new Date(), + }); + } + + const health = await service.checkSystemHealth(); + + // 应该检测到API组件降级 + expect(health.components.zulipApi.status).toMatch(/^(degraded|unhealthy)$/); + }); + + it('应该正确处理模块生命周期', () => { + // 测试模块初始化 + service.onModuleInit(); + + // 验证健康检查已启动(通过检查私有属性) + expect((service as any).healthCheckInterval).toBeDefined(); + + // 测试模块销毁 + service.onModuleDestroy(); + + // 验证健康检查已停止 + expect((service as any).healthCheckInterval).toBeNull(); + }); + + it('应该正确处理最近日志的大小限制', () => { + const maxLogs = (service as any).maxRecentLogs; + + // 添加超过限制的API调用日志 + for (let i = 0; i < maxLogs + 10; i++) { + service.logApiCall({ + operation: `test-${i}`, + userId: 'user1', + result: ApiCallResult.SUCCESS, + responseTime: 100, + timestamp: new Date(), + }); + } + + // 验证最近日志数量不超过限制 + const recentLogs = (service as any).recentApiCalls; + expect(recentLogs.length).toBeLessThanOrEqual(maxLogs); + }); + + it('应该正确处理最近告警的大小限制', () => { + const maxLogs = (service as any).maxRecentLogs; + + // 添加超过限制的告警 + for (let i = 0; i < maxLogs + 10; i++) { + service.sendAlert({ + id: `alert-${i}`, + level: AlertLevel.INFO, + title: `Alert ${i}`, + message: `Message ${i}`, + component: 'test', + timestamp: new Date(), + }); + } + + // 验证最近告警数量不超过限制 + const recentAlerts = (service as any).recentAlerts; + expect(recentAlerts.length).toBeLessThanOrEqual(maxLogs); + }); + + it('应该正确处理消息转发错误统计', () => { + service.logMessageForward({ + fromUserId: 'user1', + toUserIds: ['user2'], + stream: 'test-stream', + topic: 'test-topic', + direction: 'upstream', + success: false, // 失败的消息 + latency: 100, + error: 'Transfer failed', + timestamp: new Date(), + }); + + const stats = service.getStats(); + expect(stats.messages.errors).toBe(1); + }); + + it('应该正确处理带有元数据的连接日志', () => { + const eventHandler = jest.fn(); + service.on('connection_event', eventHandler); + + service.logConnection({ + socketId: 'socket1', + userId: 'user1', + eventType: ConnectionEventType.CONNECTED, + duration: 1000, + timestamp: new Date(), + metadata: { userAgent: 'test-agent', ip: '127.0.0.1' }, + }); + + expect(eventHandler).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { userAgent: 'test-agent', ip: '127.0.0.1' }, + }) + ); + + service.removeListener('connection_event', eventHandler); + }); + + it('应该正确处理带有元数据的API调用日志', () => { + const eventHandler = jest.fn(); + service.on('api_call', eventHandler); + + service.logApiCall({ + operation: 'sendMessage', + userId: 'user1', + result: ApiCallResult.SUCCESS, + responseTime: 100, + statusCode: 200, + timestamp: new Date(), + metadata: { endpoint: '/api/messages', method: 'POST' }, + }); + + expect(eventHandler).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { endpoint: '/api/messages', method: 'POST' }, + }) + ); + + service.removeListener('api_call', eventHandler); + }); + + it('应该正确处理带有消息ID的消息转发日志', () => { + const eventHandler = jest.fn(); + service.on('message_forward', eventHandler); + + service.logMessageForward({ + messageId: 12345, + fromUserId: 'user1', + toUserIds: ['user2', 'user3'], + stream: 'test-stream', + topic: 'test-topic', + direction: 'downstream', + success: true, + latency: 50, + timestamp: new Date(), + }); + + expect(eventHandler).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 12345, + }) + ); + + service.removeListener('message_forward', eventHandler); + }); + + it('应该正确处理带有详情的操作确认', () => { + const eventHandler = jest.fn(); + service.on('operation_confirmed', eventHandler); + + service.confirmOperation({ + operationId: 'op123', + operation: 'sendMessage', + userId: 'user1', + success: true, + timestamp: new Date(), + details: { messageId: 456, recipients: 3 }, + }); + + expect(eventHandler).toHaveBeenCalledWith( + expect.objectContaining({ + details: { messageId: 456, recipients: 3 }, + }) + ); + + service.removeListener('operation_confirmed', eventHandler); + }); + + it('应该正确处理带有元数据的告警', () => { + const eventHandler = jest.fn(); + service.on('alert', eventHandler); + + service.sendAlert({ + id: 'alert123', + level: AlertLevel.WARNING, + title: 'Test Alert', + message: 'Test message', + component: 'test-component', + timestamp: new Date(), + metadata: { threshold: 5000, actual: 7000 }, + }); + + expect(eventHandler).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { threshold: 5000, actual: 7000 }, + }) + ); + + service.removeListener('alert', eventHandler); + }); + }); });