UNPKG

matterbridge-roborock-vacuum-plugin

Version:
395 lines 19.5 kB
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn().mockReturnValue({ sendMail: vi.fn().mockResolvedValue(undefined), }), }, })); import { DeviceConnectionError, DeviceInitializationError } from '../../errors/index.js'; import { ProtocolVersion } from '../../roborockCommunication/enums/protocolVersion.js'; import { DeviceModel, HeaderMessage, ResponseMessage, } from '../../roborockCommunication/models/index.js'; import { ConnectionService } from '../../services/connectionService.js'; import { asPartial, asType, makeLocalClientStub, makeLogger, makeMockClientRouter } from '../testUtils.js'; describe('ConnectionService', () => { let service; let mockClientManager; let mockLogger; let mockClientRouter; let mockMessageRoutingService; const mockDevice = { duid: 'test-duid-123', name: 'Test Vacuum', localKey: 'test-local-key', pv: '1.0', specs: { protocol: '1.0', model: 'roborock.vacuum.a187' }, store: { pv: '1.0', model: 'roborock.vacuum.a187' }, }; const mockUserData = asPartial({ rriot: { u: 'user-id-123', s: 'session-token-abc', h: 'h-value', k: 'k-value', r: { r: 'r-value', a: 'a-value', m: 'm-value', l: 'l-value', }, }, }); beforeEach(() => { vi.clearAllMocks(); mockLogger = createMockLogger(); mockClientRouter = createMockClientRouter(); mockClientManager = createMockClientManager(mockClientRouter); mockMessageRoutingService = createMockMessageRoutingService(); service = new ConnectionService(mockClientManager, mockLogger, mockMessageRoutingService); }); function createMockLogger() { return makeLogger(); } function createMockClientRouter() { return makeMockClientRouter(); } function createMockClientManager(mockClientRouter) { return { get: vi.fn().mockReturnValue(mockClientRouter), destroy: vi.fn(), destroyAll: vi.fn(), logger: mockLogger, clients: new Map(), }; } function createMockMessageRoutingService() { return { registerMessageDispatcher: vi.fn(), }; } describe('setDeviceNotify', () => { it('should set the device notification callback', () => { const mockCallback = vi.fn(); service.setDeviceNotify(mockCallback); expect(service.deviceNotify).toBe(mockCallback); }); }); describe('waitForConnection', () => { it('should return immediately when connection is already established', async () => { const checkConnection = vi.fn().mockReturnValue(true); const attempts = await service.waitForConnection(checkConnection, 5, 0); expect(attempts).toBe(0); expect(checkConnection).toHaveBeenCalledTimes(2); }); it('should retry until connection is established', async () => { let callCount = 0; const checkConnection = vi.fn(() => { callCount++; return callCount >= 3; }); const attempts = await service.waitForConnection(checkConnection, 5, 0); expect(attempts).toBe(2); expect(checkConnection).toHaveBeenCalledTimes(4); }); it('should throw error when max attempts exceeded', async () => { const checkConnection = vi.fn().mockReturnValue(false); await expect(service.waitForConnection(checkConnection, 3, 0)).rejects.toThrow('Connection timeout after 3 attempts'); expect(checkConnection).toHaveBeenCalledTimes(5); }); it('should use default max attempts and delay', async () => { const checkConnection = vi.fn().mockReturnValue(true); const attempts = await service.waitForConnection(checkConnection); expect(attempts).toBe(0); expect(checkConnection).toHaveBeenCalledTimes(2); }); }); describe('initializeMessageClient', () => { it('should throw DeviceInitializationError when ClientManager is not initialized', async () => { const serviceWithoutManager = new ConnectionService(asType(undefined), asPartial(mockLogger), asPartial(mockMessageRoutingService)); await expect(serviceWithoutManager.initializeMessageClient(mockDevice, mockUserData)).rejects.toThrow(DeviceInitializationError); }); it('should successfully initialize MQTT client and connect', async () => { vi.spyOn(mockClientRouter, 'isReady').mockReturnValue(true); mockClientRouter.get = vi.fn().mockResolvedValue(mockClientRouter); await service.initializeMessageClient(mockDevice, mockUserData); expect(mockClientManager.get).toHaveBeenCalledWith(mockUserData); expect(mockClientRouter.registerDevice).toHaveBeenCalledWith(mockDevice.duid, mockDevice.localKey, mockDevice.pv, undefined); expect(mockClientRouter.connect).toHaveBeenCalled(); expect(mockLogger.debug).toHaveBeenCalledWith('clientRouter connected for device:', mockDevice.duid); }); it('should register DisconnectNotificationListener when email notification is enabled', async () => { vi.spyOn(mockClientRouter, 'isReady').mockReturnValue(true); const configManager = asPartial({ isEmailNotificationEnabled: true, emailNotificationSettings: asPartial({ smtpHost: 'smtp.example.com', smtpPort: 587, smtpSecure: false, smtpUser: 'user@example.com', smtpPassword: 'pass', recipient: 'to@example.com', }), }); const serviceWithEmail = new ConnectionService(mockClientManager, mockLogger, mockMessageRoutingService, configManager); await serviceWithEmail.initializeMessageClient(mockDevice, mockUserData); expect(mockClientRouter.registerConnectionListener).toHaveBeenCalled(); }); it('should ignore battery protocol messages', async () => { vi.spyOn(mockClientRouter, 'isConnected').mockReturnValue(true); const mockCallback = vi.fn(); service.setDeviceNotify(mockCallback); await service.initializeMessageClient(mockDevice, mockUserData); await service.initializeMessageClientForLocal(mockDevice); const listenerCall = vi.mocked(mockClientRouter.registerMessageListener).mock.calls[0][0]; const mockMessage = new ResponseMessage(mockDevice.duid, new HeaderMessage('1.0', 2, 0, Date.now(), 0), undefined); mockMessage.isForProtocol = vi.fn().mockReturnValue(true); listenerCall.onMessage(mockMessage); expect(mockCallback).not.toHaveBeenCalled(); }); it('should not call deviceNotify when callback is not set', async () => { vi.spyOn(mockClientRouter, 'isConnected').mockReturnValue(true); service.setDeviceNotify(vi.fn()); await service.initializeMessageClient(mockDevice, mockUserData); await service.initializeMessageClientForLocal(mockDevice); service.deviceNotify = undefined; const listenerCall = vi.mocked(mockClientRouter.registerMessageListener).mock.calls[0][0]; const mockMessage = new ResponseMessage(mockDevice.duid, new HeaderMessage('1.0', 4, 0, Date.now(), 0), undefined); mockMessage.isForProtocol = vi.fn().mockReturnValue(false); expect(() => listenerCall.onMessage(mockMessage)).not.toThrow(); }); it('should throw DeviceConnectionError when connection times out', async () => { vi.spyOn(mockClientRouter, 'isConnected').mockReturnValue(false); vi.spyOn(asType(service), 'waitForConnection').mockRejectedValue(new Error('Connection timeout')); await expect(service.initializeMessageClient(mockDevice, mockUserData)).rejects.toThrow(DeviceConnectionError); expect(mockLogger.error).toHaveBeenCalled(); }); it('should wrap non-DeviceError exceptions in DeviceInitializationError', async () => { asType(mockClientManager).get.mockImplementation(() => { throw new Error('Unexpected error'); }); await expect(service.initializeMessageClient(mockDevice, mockUserData)).rejects.toThrow(DeviceInitializationError); expect(mockLogger.error).toHaveBeenCalledWith('Failed to initialize message client:', expect.any(Error)); }); it('should re-throw DeviceError without wrapping', async () => { vi.spyOn(mockClientRouter, 'isConnected').mockReturnValue(false); vi.spyOn(asType(service), 'waitForConnection').mockRejectedValue(new Error('Connection timeout')); await expect(service.initializeMessageClient(mockDevice, mockUserData)).rejects.toThrow(DeviceConnectionError); }); }); describe('getMessageClient', () => { it('should return undefined when message client is not initialized', () => { expect(service.getMessageClient()).toBeUndefined(); }); it('should return the message client when initialized', () => { service.clientRouter = asPartial(mockClientRouter); expect(service.getMessageClient()).toBe(mockClientRouter); }); }); describe('shutdown', () => { it('should clear message client and device notify callback', async () => { service.clientRouter = asPartial(mockClientRouter); service.deviceNotify = vi.fn(); await service.shutdown(); expect(service.clientRouter).toBeUndefined(); expect(service.deviceNotify).toBeUndefined(); }); it('should handle shutdown when nothing is initialized', async () => { await expect(service.shutdown()).resolves.not.toThrow(); }); }); describe('integration scenarios', () => { it('should handle complete initialization flow with retries', async () => { let connectionAttempts = 0; vi.spyOn(mockClientRouter, 'isConnected').mockImplementation(() => { connectionAttempts++; return connectionAttempts >= 2; }); await service.initializeMessageClient(mockDevice, mockUserData); expect(mockClientRouter.isReady).toHaveBeenCalled(); }); }); }); describe('ConnectionService additional coverage', () => { let service; let mockClientManager; let mockLogger; let mockClientRouter; let mockLocalClient; let mockMessageRoutingService; const mockDevice = { duid: 'test-duid-123', name: 'Test Vacuum', localKey: 'test-local-key', pv: '1.0', store: { pv: '1.0', model: DeviceModel.S6 }, specs: asPartial({ model: DeviceModel.S6 }), }; beforeEach(() => { mockLogger = makeLogger(); mockClientRouter = makeMockClientRouter(); mockLocalClient = makeLocalClientStub(); mockClientManager = asPartial({ get: vi.fn().mockReturnValue(mockClientRouter) }); mockMessageRoutingService = { registerMessageDispatcher: vi.fn() }; service = new ConnectionService(mockClientManager, mockLogger, mockMessageRoutingService); }); it('should handle B01 protocol and UDP client setup', async () => { const device = asPartial({ ...mockDevice, specs: asPartial({ protocol: ProtocolVersion.V1, model: DeviceModel.S6, hasRealTimeConnection: false, }), pv: 'B01', duid: 'b01-duid', deviceStatus: { 101: { 81: { ipAddress: '1.2.3.4' } } }, }); service.setDeviceNotify(vi.fn()); service.clientRouter = asPartial(mockClientRouter); const result = await service.initializeMessageClientForLocal(device); expect(result).toBe(true); }); it('should fallback to UDP broadcast if setupLocalClient fails', async () => { const device = asPartial({ ...mockDevice, specs: asPartial({ protocol: ProtocolVersion.V1, model: DeviceModel.S6, hasRealTimeConnection: false, }), pv: 'B01', duid: 'b01-duid', deviceStatus: { 101: { 82: { ipAddress: '1.2.3.4' } } }, }); service.setDeviceNotify(vi.fn()); service.clientRouter = asPartial(mockClientRouter); const result = await service.initializeMessageClientForLocal(device); expect(result).toBe(true); }); it('should return false if clientRouter is not initialized', async () => { service.clientRouter = undefined; const result = await service.initializeMessageClientForLocal(mockDevice); expect(result).toBe(false); }); it('should return false if getNetworkInfo returns no ip', async () => { mockClientRouter.get = vi.fn().mockResolvedValue({ ip: undefined }); service.clientRouter = asPartial(mockClientRouter); const result = await service.initializeMessageClientForLocal(mockDevice); expect(result).toBe(false); }); it('should return true when local client connects successfully', async () => { service.clientRouter = asPartial(mockClientRouter); vi.spyOn(mockClientRouter, 'registerClient').mockReturnValue(asType(mockLocalClient)); const result = await asType(service).setupLocalClient.call(service, { duid: 'duid', specs: mockDevice.specs }, '1.2.3.4'); expect(result).toBe(true); }); it('should return false and log error if registerClient returns undefined', async () => { service.clientRouter = asPartial(mockClientRouter); vi.spyOn(mockClientRouter, 'registerClient').mockReturnValue(asType(undefined)); const result = await asType(service).setupLocalClient.call(service, { duid: 'duid', specs: mockDevice.specs }, '1.2.3.4'); expect(result).toBe(false); expect(mockLogger.error).toHaveBeenCalledWith('Failed to create local client for device duid at IP 1.2.3.4'); }); it('should return false and log error if exception is thrown', async () => { service.clientRouter = asPartial(mockClientRouter); vi.spyOn(mockClientRouter, 'registerClient').mockImplementation(() => { throw new Error('fail'); }); const result = await asType(service).setupLocalClient.call(service, { duid: 'duid', specs: mockDevice.specs }, '1.2.3.4'); expect(result).toBe(false); expect(mockLogger.error).toHaveBeenCalledWith('Error setting up local client for device duid at IP 1.2.3.4:', expect.any(Error)); }); it('should log error if disconnect throws', async () => { service.clientRouter = asPartial({ disconnect: vi.fn().mockImplementation(() => { throw new Error('fail'); }), }); await service.shutdown(); expect(mockLogger.error).toHaveBeenCalledWith('Error disconnecting message client:', expect.any(Error)); }); it('should log error if local client disconnect throws', async () => { const badClient = asPartial({ disconnect: vi.fn().mockImplementation(() => { throw new Error('fail'); }), }); service.localClientMap.set('test-duid', badClient); await service.shutdown(); expect(mockLogger.error).toHaveBeenCalledWith('Error disconnecting local client test-duid:', expect.any(Error)); }); it('should clear ipMap and localClientMap on shutdown', async () => { service.ipMap.set('a', '1.2.3.4'); service.localClientMap.set('a', asPartial({})); await service.shutdown(); expect(service.ipMap.size).toBe(0); expect(service.localClientMap.size).toBe(0); }); }); describe('ConnectionService - sendTestEmailNotification', () => { let mockLogger; let mockClientManager; let mockMessageRoutingService; beforeEach(() => { vi.clearAllMocks(); mockLogger = makeLogger(); mockClientManager = asPartial({ get: vi.fn() }); mockMessageRoutingService = asPartial({ registerMessageDispatcher: vi.fn() }); }); afterEach(() => { vi.clearAllMocks(); }); it('should do nothing when configManager is undefined', async () => { const service = new ConnectionService(mockClientManager, mockLogger, mockMessageRoutingService); await expect(service.sendTestEmailNotification()).resolves.toBeUndefined(); }); it('should do nothing when email notification is disabled', async () => { const configManager = asPartial({ isEmailNotificationEnabled: false }); const service = new ConnectionService(mockClientManager, mockLogger, mockMessageRoutingService, configManager); await expect(service.sendTestEmailNotification()).resolves.toBeUndefined(); }); it('should do nothing when emailNotificationSettings is undefined', async () => { const configManager = asPartial({ isEmailNotificationEnabled: true, emailNotificationSettings: undefined, }); const service = new ConnectionService(mockClientManager, mockLogger, mockMessageRoutingService, configManager); await expect(service.sendTestEmailNotification()).resolves.toBeUndefined(); }); it('should send test email when email notification is enabled with valid settings', async () => { const settings = asPartial({ smtpHost: 'smtp.example.com', smtpPort: 587, smtpSecure: false, smtpUser: 'user@example.com', smtpPassword: 'pass', recipient: 'to@example.com', }); const configManager = asPartial({ isEmailNotificationEnabled: true, emailNotificationSettings: settings, }); const service = new ConnectionService(mockClientManager, mockLogger, mockMessageRoutingService, configManager); await expect(service.sendTestEmailNotification()).resolves.toBeUndefined(); }); it('should reuse cached emailService on second call', async () => { const settings = asPartial({ smtpHost: 'smtp.example.com', smtpPort: 587, smtpSecure: false, smtpUser: 'user@example.com', smtpPassword: 'pass', recipient: 'to@example.com', }); const configManager = asPartial({ isEmailNotificationEnabled: true, emailNotificationSettings: settings, }); const service = new ConnectionService(mockClientManager, mockLogger, mockMessageRoutingService, configManager); await service.sendTestEmailNotification(); const firstInstance = service['emailService']; await service.sendTestEmailNotification(); expect(service['emailService']).toBe(firstInstance); }); }); //# sourceMappingURL=connectionService.test.js.map