homebridge-tsvesync
Version:
Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets
312 lines (270 loc) • 9.28 kB
text/typescript
import { PlatformAccessory, Service, Characteristic, Logger } from 'homebridge';
import { mock, mockDeep } from 'jest-mock-extended';
import { BaseAccessory } from '../accessories/base.accessory';
import { TSVESyncPlatform } from '../platform';
import { VeSync } from 'tsvesync';
import { DeviceCapabilities } from '../types';
import { PollingManager } from '../utils/polling-manager';
import { PluginLogger } from '../utils/logger';
import { RetryManager } from '../utils/retry';
jest.mock('../utils/logger');
jest.mock('../utils/retry');
// Create a concrete test class since BaseAccessory is abstract
class TestAccessory extends BaseAccessory {
protected setupService(): void {
this.service = new this.platform.Service.Switch(this.accessory.displayName);
}
protected async updateDeviceSpecificStates(): Promise<void> {
// Test implementation
}
protected getDeviceCapabilities(): DeviceCapabilities {
return {
power: true,
mode: false,
speed: false,
timer: false,
hasBrightness: false,
hasColorTemp: false,
hasColor: false,
hasSpeed: false,
hasMode: false,
hasTimer: false,
hasSchedule: false,
hasHumidity: false,
hasAirQuality: false,
hasWaterLevel: false,
hasChildLock: false,
hasSwingMode: false,
};
}
// Override syncDeviceState to remove retry logic
public async syncDeviceState(): Promise<boolean> {
try {
await this.platform.discoverDevices();
if (!this.device.deviceStatus) {
return false;
}
await this.updateDeviceSpecificStates();
return true;
} catch (error) {
(this as any).logger.error('Failed to sync device state', {}, error);
throw error;
}
}
// Expose handleDeviceError for testing
public async testHandleDeviceError(message: string, error: Error | any): Promise<void> {
return (this as any).handleDeviceError(message, error);
}
}
// Add test factory before the describe block
const createMockDevice = (overrides = {}) => ({
deviceName: 'Test Device',
uuid: '12345',
deviceType: 'outlet',
getDetails: jest.fn(),
...overrides
});
describe('BaseAccessory', () => {
let mockPlatform: jest.Mocked<TSVESyncPlatform>;
let mockAccessory: jest.Mocked<PlatformAccessory>;
let mockDevice: any;
let mockService: jest.Mock;
let mockInfoService: jest.Mock;
let accessory: TestAccessory;
let mockLogger: jest.Mocked<Logger>;
let mockPluginLogger: jest.Mocked<PluginLogger>;
beforeAll(() => {
jest.setTimeout(30000);
});
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
// Setup logger mock
mockLogger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
log: jest.fn(),
prefix: undefined
} as jest.Mocked<Logger>;
// Setup PluginLogger mock
const MockPluginLogger = jest.mocked(PluginLogger);
mockPluginLogger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
stateChange: jest.fn(),
operationStart: jest.fn(),
operationEnd: jest.fn(),
pollingEvent: jest.fn(),
log: mockLogger,
debugMode: true,
formatMessage: jest.fn((message: string) => message),
} as unknown as jest.Mocked<PluginLogger>;
MockPluginLogger.mockImplementation(() => mockPluginLogger);
// Setup service mocks
mockService = jest.fn().mockImplementation(() => ({
getCharacteristic: jest.fn().mockReturnValue({
onSet: jest.fn(),
onGet: jest.fn(),
updateValue: jest.fn(),
}),
setCharacteristic: jest.fn().mockReturnThis(),
}));
mockInfoService = jest.fn().mockImplementation(() => ({
setCharacteristic: jest.fn().mockReturnThis(),
}));
// Setup platform mock
mockPlatform = {
log: mockLogger,
Service: {
Switch: mockService,
AccessoryInformation: mockInfoService,
},
Characteristic: {
Manufacturer: 'Manufacturer',
Model: 'Model',
SerialNumber: 'SerialNumber',
FirmwareRevision: 'FirmwareRevision',
},
api: {
updatePlatformAccessories: jest.fn().mockImplementation((accessories: PlatformAccessory[]) => {}),
},
config: {
platform: 'TSVESync',
name: 'TSVESync',
username: 'test@example.com',
password: 'password123',
debug: true,
},
discoverDevices: jest.fn(),
} as unknown as jest.Mocked<TSVESyncPlatform>;
// Setup accessory mock
const mockGetService = jest.fn().mockImplementation((service) => {
if (service === mockInfoService) {
return {
setCharacteristic: jest.fn().mockReturnThis(),
};
}
return null;
});
mockAccessory = {
displayName: 'Test Device',
context: { device: { details: {} } },
getService: mockGetService,
addService: jest.fn(),
} as unknown as jest.Mocked<PlatformAccessory>;
// Setup device mock
mockDevice = {
deviceName: 'Test Device',
uuid: '12345',
deviceType: 'outlet',
getDetails: jest.fn(),
};
// Create accessory instance
accessory = new TestAccessory(mockPlatform, mockAccessory, mockDevice);
// Mock PluginLogger
(accessory as any).logger = mockPluginLogger;
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
jest.clearAllMocks();
});
describe('error handling', () => {
it('should handle device not found error', async () => {
const error = {
error: {
code: 4041008,
msg: 'Device not found',
},
};
await accessory.testHandleDeviceError('test operation', error);
expect(mockPluginLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Device not found'),
expect.any(Object),
expect.any(Error)
);
});
it('should handle network errors', async () => {
const error = {
code: 'ECONNRESET',
message: 'Connection reset',
};
await accessory.testHandleDeviceError('test operation', error);
expect(mockPluginLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Network error'),
expect.any(Object),
expect.any(Error)
);
});
it('should handle API timeout errors', async () => {
const error = {
code: 'ETIMEDOUT',
message: 'Request timed out',
};
await accessory.testHandleDeviceError('test operation', error);
expect(mockPluginLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Network error'),
expect.any(Object),
expect.any(Error)
);
});
it('should handle invalid response format', async () => {
const error = {
message: 'Invalid response format',
};
await accessory.testHandleDeviceError('test operation', error);
expect(mockPluginLogger.error).toHaveBeenCalledWith(
expect.stringContaining('test operation'),
expect.any(Object),
expect.any(Error)
);
});
});
describe('device state management', () => {
beforeEach(() => {
// Reset mocks
mockPlatform.discoverDevices.mockReset();
mockPluginLogger.error.mockReset();
});
it('should sync device state successfully', async () => {
mockDevice.deviceStatus = 'on';
mockPlatform.discoverDevices.mockResolvedValue(undefined);
await (accessory as any).syncDeviceState();
expect(mockPlatform.discoverDevices).toHaveBeenCalled();
});
it('should handle sync failure', async () => {
const error = new Error('Sync failed');
mockPlatform.discoverDevices.mockRejectedValue(error);
await expect((accessory as any).syncDeviceState()).rejects.toThrow(error);
expect(mockPluginLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to sync device state'),
expect.any(Object),
error
);
});
it('should handle empty device details', async () => {
mockDevice.deviceStatus = undefined;
mockPlatform.discoverDevices.mockResolvedValue(undefined);
const result = await (accessory as any).syncDeviceState();
expect(result).toBe(false);
expect(mockPlatform.discoverDevices).toHaveBeenCalled();
});
});
describe('utility methods', () => {
it('should convert air quality values correctly', () => {
expect((accessory as any).convertAirQualityToHomeKit(10)).toBe(1); // EXCELLENT
expect((accessory as any).convertAirQualityToHomeKit(30)).toBe(2); // GOOD
expect((accessory as any).convertAirQualityToHomeKit(50)).toBe(3); // FAIR
expect((accessory as any).convertAirQualityToHomeKit(100)).toBe(4); // INFERIOR
expect((accessory as any).convertAirQualityToHomeKit(200)).toBe(5); // POOR
});
it('should persist device state', async () => {
await (accessory as any).persistDeviceState('testKey', 'testValue');
expect(mockPlatform.api.updatePlatformAccessories).toHaveBeenCalledWith([mockAccessory]);
expect(mockAccessory.context.device.details.testKey).toBe('testValue');
});
});
});