homebridge-tsvesync
Version:
Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets
431 lines (377 loc) • 14.9 kB
text/typescript
// Set up mocks before imports
jest.mock('../utils/device-factory');
jest.mock('tsvesync');
import { API, Logger, PlatformAccessory, Service as ServiceType, Characteristic as CharacteristicType } from 'homebridge';
import { VeSync } from 'tsvesync';
import { TSVESyncPlatform } from '../platform';
import { TEST_CONFIG, canRunIntegrationTests } from './setup';
import { createMockLogger, createMockOutlet, createMockVeSync } from './utils/test-helpers';
import { PLATFORM_NAME, PLUGIN_NAME } from '../settings';
import { DeviceFactory } from '../utils/device-factory';
import { BaseAccessory } from '../accessories/base.accessory';
// Import the real VeSync module for integration tests
const RealVeSync = jest.requireActual('tsvesync').VeSync;
const mockDeviceFactory = jest.mocked(DeviceFactory);
describe('TSVESyncPlatform', () => {
let platform: TSVESyncPlatform;
let mockAPI: jest.Mocked<API>;
let mockLogger: ReturnType<typeof createMockLogger>;
describe('mock tests', () => {
let mockVeSync: jest.Mocked<VeSync>;
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
// Setup VeSync mock
mockVeSync = createMockVeSync();
// Setup API mock
mockAPI = {
version: 2.0,
serverVersion: '1.0.0',
user: {
configPath: jest.fn(),
storagePath: jest.fn(),
persistPath: jest.fn(),
},
hapLegacyTypes: {},
platformAccessory: jest.fn().mockImplementation((name, uuid) => ({
UUID: uuid,
displayName: name,
context: {},
services: new Map(),
addService: jest.fn(),
removeService: jest.fn(),
getService: jest.fn(),
getServiceById: jest.fn(),
})),
versionGreaterOrEqual: jest.fn(),
registerAccessory: jest.fn(),
registerPlatform: jest.fn(),
publishCameraAccessories: jest.fn(),
registerPlatformAccessories: jest.fn(),
unregisterPlatformAccessories: jest.fn(),
publishExternalAccessories: jest.fn(),
updatePlatformAccessories: jest.fn(),
registerPlatformAccessory: jest.fn(),
on: jest.fn(),
emit: jest.fn(),
hap: {
Service: {
AccessoryInformation: jest.fn(),
Outlet: jest.fn().mockImplementation(() => ({
getCharacteristic: jest.fn().mockReturnValue({
onSet: jest.fn(),
onGet: jest.fn(),
updateValue: jest.fn(),
}),
setCharacteristic: jest.fn().mockReturnThis(),
})),
AirPurifier: jest.fn(),
HumiditySensor: jest.fn(),
Fan: jest.fn(),
} as unknown as typeof ServiceType,
Characteristic: {
On: 'On',
Active: 'Active',
Name: 'Name',
Model: 'Model',
Manufacturer: 'Manufacturer',
SerialNumber: 'SerialNumber',
FirmwareRevision: 'FirmwareRevision',
OutletInUse: 'OutletInUse',
Voltage: 'Voltage',
ElectricCurrent: 'ElectricCurrent',
PowerMeterVisible: 'PowerMeterVisible',
} as unknown as typeof CharacteristicType,
uuid: {
generate: jest.fn().mockImplementation((id) => `test-uuid-${id}`),
},
},
} as unknown as jest.Mocked<API>;
// Setup logger mock
mockLogger = createMockLogger();
// Create platform instance
platform = new TSVESyncPlatform(
mockLogger,
{
name: 'Test Platform',
username: 'test@example.com',
password: 'test-password',
platform: PLATFORM_NAME,
},
mockAPI
);
// Replace VeSync client
(platform as any).client = mockVeSync;
});
describe('ensureLogin', () => {
it('should handle successful login', async () => {
mockVeSync.login.mockResolvedValueOnce(true);
const result = await (platform as any).ensureLogin();
expect(result).toBe(true);
expect(mockVeSync.login).toHaveBeenCalledTimes(1);
expect((platform as any).loginBackoffTime).toBe(1000); // Reset to base backoff
});
it('should handle login failure', async () => {
mockVeSync.login.mockResolvedValueOnce(false);
const result = await (platform as any).ensureLogin();
expect(result).toBe(false);
expect(mockLogger.error).toHaveBeenCalledWith(
'{ensureLogin} Failed to log in to VeSync: Failed to login to VeSync'
);
expect((platform as any).loginBackoffTime).toBeGreaterThan(1000); // Increased backoff
});
it('should respect backoff timing', async () => {
// Set a backoff time
(platform as any).loginBackoffTime = 5000;
(platform as any).lastLoginAttempt = new Date();
const loginPromise = (platform as any).ensureLogin();
await jest.advanceTimersByTimeAsync(2500); // Advance halfway through backoff
expect(mockVeSync.login).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(2500); // Complete backoff
await loginPromise;
expect(mockVeSync.login).toHaveBeenCalled();
});
});
describe('discoverDevices', () => {
beforeEach(() => {
// Set platform as initialized
(platform as any).isInitialized = true;
// Ensure login is fresh to avoid login attempts during tests
(platform as any).lastLogin = new Date();
});
it('should update device states successfully', async () => {
// Create a mock outlet
const mockOutlet = createMockOutlet({
deviceName: 'Test Outlet',
deviceType: 'wifi-switch-1.3',
cid: '123',
uuid: '123',
});
// Setup mock VeSync client to return the outlet
mockVeSync.outlets = [mockOutlet];
mockVeSync.login.mockResolvedValue(true);
mockVeSync.getDevices.mockResolvedValue(true);
await platform.discoverDevices();
expect(mockVeSync.getDevices).toHaveBeenCalled();
expect(mockLogger.error).not.toHaveBeenCalled();
});
it('should handle device update failure', async () => {
mockVeSync.login.mockResolvedValue(true);
mockVeSync.getDevices.mockResolvedValue(false);
await platform.discoverDevices();
expect(mockVeSync.getDevices).toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalledWith(
'{updateDeviceStates} Failed to update device states: Failed to get devices from VeSync'
);
});
it('should retry on session expiry', async () => {
// Reset lastLogin to force a new login
(platform as any).lastLogin = new Date(0);
// First login attempt fails
mockVeSync.login.mockResolvedValueOnce(false);
// Second login attempt succeeds
mockVeSync.login.mockResolvedValueOnce(true);
// getDevices call succeeds
mockVeSync.getDevices.mockResolvedValue(true);
await platform.discoverDevices();
expect(mockVeSync.login).toHaveBeenCalledTimes(1);
expect(mockVeSync.getDevices).toHaveBeenCalled();
});
});
it('should handle device initialization', async () => {
// Create a mock outlet
const mockOutlet = createMockOutlet({
deviceName: 'Test Outlet',
deviceType: 'wifi-switch-1.3',
cid: '123',
uuid: '123',
});
// Setup mock VeSync client to return the outlet
mockVeSync.outlets = [mockOutlet];
mockVeSync.getDevices.mockResolvedValue(true);
// Mock the DeviceFactory.createAccessory implementation
mockDeviceFactory.createAccessory.mockImplementation((platform, accessory, device) => {
const baseAccessory = {
service: new mockAPI.hap.Service.Outlet(),
platform,
accessory,
device,
initialize: jest.fn().mockResolvedValue(undefined),
updateCharacteristicValue: jest.fn(),
} as unknown as BaseAccessory;
return baseAccessory;
});
// Initialize platform and discover devices
await platform.discoverDevices();
// Should register new accessories during discovery
expect(mockAPI.registerPlatformAccessories).toHaveBeenCalledWith(
PLUGIN_NAME,
PLATFORM_NAME,
expect.arrayContaining([
expect.objectContaining({
UUID: expect.stringContaining('test-uuid-123'),
displayName: 'Test Outlet'
})
])
);
// Should not register the same accessory twice
mockAPI.registerPlatformAccessories.mockClear();
await platform.discoverDevices();
expect(mockAPI.registerPlatformAccessories).not.toHaveBeenCalled();
});
it('should handle device removal', async () => {
// Create a mock outlet
const mockOutlet = createMockOutlet({
deviceName: 'Test Outlet',
deviceType: 'wifi-switch-1.3',
cid: '123',
uuid: '123',
});
// Setup mock VeSync client to return the outlet
mockVeSync.outlets = [mockOutlet];
mockVeSync.getDevices.mockResolvedValue(true);
// Initialize platform and discover devices
await platform.discoverDevices();
// Should register the accessory
expect(mockAPI.registerPlatformAccessories).toHaveBeenCalledWith(
PLUGIN_NAME,
PLATFORM_NAME,
expect.arrayContaining([
expect.objectContaining({
UUID: expect.stringContaining('test-uuid-123'),
displayName: 'Test Outlet'
})
])
);
// Second update with no devices
mockVeSync.outlets = [];
mockVeSync.getDevices.mockResolvedValue(true);
// Discover devices again
await platform.discoverDevices();
// Should unregister the accessory
expect(mockAPI.unregisterPlatformAccessories).toHaveBeenCalledWith(
PLUGIN_NAME,
PLATFORM_NAME,
expect.arrayContaining([
expect.objectContaining({
UUID: expect.stringContaining('test-uuid-123'),
displayName: 'Test Outlet'
})
])
);
});
});
// Run integration tests if credentials are available
if (canRunIntegrationTests()) {
describe('integration tests', () => {
let realPlatform: TSVESyncPlatform;
beforeEach(() => {
// Use real implementations for integration tests
const VeSyncMock = jest.mocked(VeSync);
VeSyncMock.mockImplementation((username, password, timezone, debug, redact, apiUrl, logger) => {
return new RealVeSync(username, password, timezone, debug, redact, apiUrl, logger);
});
// Create platform with real credentials
const config = {
platform: 'TSVESync',
name: 'TSVESync',
username: TEST_CONFIG.username!,
password: TEST_CONFIG.password!,
debug: true,
apiUrl: TEST_CONFIG.apiUrl,
};
if (process.env.DEBUG) {
console.log('Integration test config:', {
username: config.username,
debug: config.debug,
hasPassword: !!config.password,
});
}
// Create the platform with real VeSync implementation
realPlatform = new TSVESyncPlatform(mockLogger, config, mockAPI);
// Reset login state and backoff for tests
(realPlatform as any).lastLogin = new Date(0);
(realPlatform as any).lastLoginAttempt = new Date(0);
(realPlatform as any).loginBackoffTime = 0;
// Access the VeSync client directly to verify it's configured correctly
const platformClient = (realPlatform as any).client;
if (platformClient && process.env.DEBUG) {
console.log('VeSync client:', {
isInitialized: true,
hasLogin: typeof platformClient.login === 'function',
hasGetDevices: typeof platformClient.getDevices === 'function',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
username: platformClient.username,
hasPassword: !!platformClient.password,
apiUrl: platformClient.apiUrl,
});
}
});
afterEach(() => {
// Reset the mock implementations
jest.resetAllMocks();
});
it('should connect to VeSync API', async () => {
try {
const result = await (realPlatform as any).ensureLogin(true); // Force new login
if (process.env.DEBUG) {
console.log('Login result:', result);
}
if (!result) {
// Try to get client state
const client = (realPlatform as any).client;
if (client && process.env.DEBUG) {
console.log('Client state:', {
lastLogin: (realPlatform as any).lastLogin,
lastLoginAttempt: (realPlatform as any).lastLoginAttempt,
loginBackoffTime: (realPlatform as any).loginBackoffTime,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
}
}
expect(result).toBe(true);
} catch (error) {
if (process.env.DEBUG) {
console.log('Login attempt error:', error);
}
throw error;
}
}, 10000);
it('should fetch devices', async () => {
try {
// First login
const loginResult = await (realPlatform as any).ensureLogin(true); // Force new login
if (process.env.DEBUG) {
console.log('Login result:', loginResult);
}
expect(loginResult).toBe(true);
// Then fetch devices
await realPlatform.discoverDevices();
if (process.env.DEBUG) {
console.log('Device update completed');
}
// Log found devices
const client = (realPlatform as any).client;
if (client && process.env.DEBUG) {
const devices = [
...(client.fans || []),
...(client.outlets || []),
...(client.switches || []),
...(client.bulbs || []),
...(client.humidifiers || []),
...(client.purifiers || []),
];
console.log('Found devices:', devices.length);
devices.forEach(device => {
console.log(`- ${device.deviceName} (${device.deviceType})`);
});
}
} catch (error) {
if (process.env.DEBUG) {
console.log('Device fetch error:', error);
}
throw error;
}
}, 10000);
});
}
});