UNPKG

homebridge-tsvesync

Version:

Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets

431 lines (377 loc) 14.9 kB
// 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); }); } });