matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
395 lines • 19.5 kB
JavaScript
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