UNPKG

@homebridge-plugins/homebridge-august

Version:

The August plugin allows you to access your August & Yale device(s) from HomeKit.

294 lines 17.3 kB
import { beforeEach, describe, expect, it, vi } from 'vitest'; // Create a simplified test implementation that mimics the updateCharacteristic behavior class MockDeviceWithCharacteristics { accessory = { context: {}, displayName: 'Test Lock' }; debugLog = vi.fn(); debugWarnLog = vi.fn(); infoLog = vi.fn(); async updateCharacteristic(Service, ServiceName, Characteristic, CharacteristicValue, CharacteristicName, Value, StatusMatch, StatusDoesNotMatch) { if (CharacteristicValue === undefined) { await this.debugLog(`${CharacteristicName}: ${CharacteristicValue}`); } else { const contextKey = `${ServiceName}${CharacteristicName}`; await this.debugWarnLog(`context before: ${this.accessory.context[contextKey]}`); const contextBefore = this.accessory.context[contextKey]; this.accessory.context[contextKey] = CharacteristicValue; await this.debugWarnLog(`context after: ${this.accessory.context[contextKey]}`); await this.debugLog(`updateCharacteristic ${CharacteristicName}: ${CharacteristicValue} (${CharacteristicValue === Value ? StatusMatch : StatusDoesNotMatch})`); if (contextBefore !== CharacteristicValue) { // Value actually changed - push update to HomeKit Service.updateCharacteristic(Characteristic, CharacteristicValue); // Only log state change messages when we had a prior known value (not initial discovery) if (contextBefore !== undefined && StatusMatch && StatusDoesNotMatch) { await this.infoLog(`was ${CharacteristicValue === Value ? StatusMatch : StatusDoesNotMatch}`); } } } } } // Create a simplified test implementation that mimics the statusCode behavior class MockDevice { debugLog = vi.fn(); debugErrorLog = vi.fn(); /** * Check if error is a network timeout or session expiration that requires session refresh * Handles 502 Bad Gateway, 401 Unauthorized, 503 Service Unavailable, and network timeouts */ isTimeoutError(error) { // Check for august-yale TimeoutError by name (stable contract — works regardless // of message format changes) and Node.js network timeouts by message if (error.name === 'TimeoutError' || error.message?.includes('ETIMEDOUT')) { return true; } // Check for status codes that indicate session expiration or temporary server issues // 502 Bad Gateway - typically indicates session has expired or server issues // 503 Service Unavailable - temporary server issues // 401 Unauthorized - session has expired if (error.statusCode && (error.statusCode === 502 || error.statusCode === 503 || error.statusCode === 401)) { return true; } // Check for error messages containing these status codes (from tiny-json-http error format) // Format: "POST failed with: 502" or "GET failed with: 401" if (error.message) { const sessionErrorPatterns = [ /failed with: 502/i, /failed with: 503/i, /failed with: 401/i, /Bad Gateway/i, /Service Unavailable/i, /Unauthorized/i, ]; return sessionErrorPatterns.some(pattern => pattern.test(error.message)); } return false; } async statusCode(action, error) { const statusCodeString = error.message || ''; // Convert statusCode to a string, handle undefined/null // Check if the error is an AggregateError or doesn't contain a status code if (error.constructor?.name === 'AggregateError' || !/^\d{3}/.test(statusCodeString)) { await this.debugErrorLog(`${action} failed with ${error.constructor?.name || 'Error'}: ${statusCodeString}`); return; } const statusCode = statusCodeString.slice(0, 3); const logMap = { 100: `Command successfully sent, statusCode: ${statusCodeString}`, 200: `Request successful, statusCode: ${statusCodeString}`, 400: `Bad Request, statusCode: ${statusCodeString}`, 429: `Too Many Requests, exceeded the number of requests allowed for a given time window, statusCode: ${statusCodeString}`, }; const logMessage = logMap[statusCode] ?? `Unknown statusCode: ${statusCodeString}, Submit Bugs Here: https://tinyurl.com/AugustYaleBug`; await this.debugLog(logMessage); if (!logMap[statusCode]) { await this.debugErrorLog(`failed ${action}, Error: ${error}`); } } } describe('statusCode error handling', () => { let mockDevice; beforeEach(() => { mockDevice = new MockDevice(); }); describe('isTimeoutError method', () => { it('should detect august-yale TimeoutError by error.name', () => { const error = { message: 'Request timed out after 30000ms', name: 'TimeoutError' }; expect(mockDevice.isTimeoutError(error)).toBe(true); }); it('should detect august-yale TimeoutError regardless of message content', () => { const error = { message: 'anything', name: 'TimeoutError' }; expect(mockDevice.isTimeoutError(error)).toBe(true); }); it('should detect ETIMEDOUT network errors', () => { const error = { message: 'ETIMEDOUT connection timed out' }; expect(mockDevice.isTimeoutError(error)).toBe(true); }); it('should detect 502 Bad Gateway status code', () => { const error = { message: 'POST failed with: 502', statusCode: 502 }; expect(mockDevice.isTimeoutError(error)).toBe(true); }); it('should detect 502 in error message', () => { const error = { message: 'POST failed with: 502' }; expect(mockDevice.isTimeoutError(error)).toBe(true); }); it('should detect 503 Service Unavailable status code', () => { const error = { message: 'GET failed with: 503', statusCode: 503 }; expect(mockDevice.isTimeoutError(error)).toBe(true); }); it('should detect 503 in error message', () => { const error = { message: 'Service Unavailable' }; expect(mockDevice.isTimeoutError(error)).toBe(true); }); it('should detect 401 Unauthorized status code', () => { const error = { message: 'POST failed with: 401', statusCode: 401 }; expect(mockDevice.isTimeoutError(error)).toBe(true); }); it('should detect 401 in error message', () => { const error = { message: 'Unauthorized access' }; expect(mockDevice.isTimeoutError(error)).toBe(true); }); it('should detect Bad Gateway in error message', () => { const error = { message: 'Bad Gateway' }; expect(mockDevice.isTimeoutError(error)).toBe(true); }); it('should return false for non-timeout errors', () => { const error = { message: '404 Not Found' }; expect(mockDevice.isTimeoutError(error)).toBe(false); }); it('should return false for 400 Bad Request', () => { const error = { message: '400 Bad Request', statusCode: 400 }; expect(mockDevice.isTimeoutError(error)).toBe(false); }); it('should return false for 429 Too Many Requests', () => { const error = { message: '429 Too Many Requests', statusCode: 429 }; expect(mockDevice.isTimeoutError(error)).toBe(false); }); it('should return false for empty message', () => { const error = { message: '' }; expect(mockDevice.isTimeoutError(error)).toBe(false); }); it('should return false for errors with non-timeout name', () => { const error = { message: 'something failed', name: 'Error' }; expect(mockDevice.isTimeoutError(error)).toBe(false); }); it('should not match 401 in non-HTTP status contexts', () => { const error = { message: 'Expected 4012 records but found 401' }; expect(mockDevice.isTimeoutError(error)).toBe(false); }); it('should not match 502 in non-HTTP status contexts', () => { const error = { message: 'Lock ID 50234 not found' }; expect(mockDevice.isTimeoutError(error)).toBe(false); }); }); describe('statusCode method', () => { it('should handle standard HTTP status codes', async () => { await mockDevice.statusCode('test action', { message: '400 Bad Request' }); expect(mockDevice.debugLog).toHaveBeenCalledWith('Bad Request, statusCode: 400 Bad Request'); expect(mockDevice.debugErrorLog).not.toHaveBeenCalled(); }); it('should handle AggregateError specifically', async () => { await mockDevice.statusCode('refreshStatus', { message: 'Multiple errors occurred', constructor: { name: 'AggregateError' }, }); expect(mockDevice.debugErrorLog).toHaveBeenCalledWith('refreshStatus failed with AggregateError: Multiple errors occurred'); expect(mockDevice.debugLog).not.toHaveBeenCalled(); }); it('should handle error with empty message', async () => { await mockDevice.statusCode('refreshStatus', { message: '' }); expect(mockDevice.debugErrorLog).toHaveBeenCalledWith('refreshStatus failed with Object: '); expect(mockDevice.debugLog).not.toHaveBeenCalled(); }); it('should handle error message without numeric status code', async () => { await mockDevice.statusCode('refreshStatus', { message: 'Network connection failed' }); expect(mockDevice.debugErrorLog).toHaveBeenCalledWith('refreshStatus failed with Object: Network connection failed'); expect(mockDevice.debugLog).not.toHaveBeenCalled(); }); it('should handle 429 Too Many Requests status code', async () => { await mockDevice.statusCode('test action', { message: '429 Too Many Requests' }); expect(mockDevice.debugLog).toHaveBeenCalledWith('Too Many Requests, exceeded the number of requests allowed for a given time window, statusCode: 429 Too Many Requests'); expect(mockDevice.debugErrorLog).not.toHaveBeenCalled(); }); it('should handle unknown numeric status codes', async () => { await mockDevice.statusCode('test action', { message: '503 Service Unavailable' }); expect(mockDevice.debugLog).toHaveBeenCalledWith('Unknown statusCode: 503 Service Unavailable, Submit Bugs Here: https://tinyurl.com/AugustYaleBug'); expect(mockDevice.debugErrorLog).toHaveBeenCalledWith('failed test action, Error: [object Object]'); }); }); }); describe('updateCharacteristic', () => { let device; let mockService; const CHARACTERISTIC = 'LockCurrentState'; beforeEach(() => { device = new MockDeviceWithCharacteristics(); mockService = { updateCharacteristic: vi.fn(), }; }); it('should skip HomeKit update when CharacteristicValue is undefined', async () => { await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, undefined, 'LockCurrentState'); expect(mockService.updateCharacteristic).not.toHaveBeenCalled(); expect(device.debugLog).toHaveBeenCalledWith('LockCurrentState: undefined'); }); describe('initial state discovery (no prior context)', () => { it('should push value to HomeKit on first update', async () => { await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, 0, 'LockCurrentState', 1, 'Locked', 'Unlocked'); expect(mockService.updateCharacteristic).toHaveBeenCalledWith(CHARACTERISTIC, 0); }); it('should NOT log info message on initial discovery', async () => { await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, 0, 'LockCurrentState', 1, 'Locked', 'Unlocked'); expect(device.infoLog).not.toHaveBeenCalled(); }); it('should store the value in context for future comparisons', async () => { await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, 0, 'LockCurrentState', 1, 'Locked', 'Unlocked'); expect(device.accessory.context.LockMechanismLockCurrentState).toBe(0); }); }); describe('subsequent updates with unchanged value', () => { it('should NOT push to HomeKit when value has not changed', async () => { // Seed context as if a previous update occurred device.accessory.context.LockMechanismLockCurrentState = 1; await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, 1, 'LockCurrentState', 1, 'Locked', 'Unlocked'); expect(mockService.updateCharacteristic).not.toHaveBeenCalled(); }); it('should NOT log info message when value has not changed', async () => { device.accessory.context.LockMechanismLockCurrentState = 1; await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, 1, 'LockCurrentState', 1, 'Locked', 'Unlocked'); expect(device.infoLog).not.toHaveBeenCalled(); }); }); describe('genuine state change', () => { it('should push to HomeKit when value changes', async () => { device.accessory.context.LockMechanismLockCurrentState = 1; await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, 0, 'LockCurrentState', 1, 'Locked', 'Unlocked'); expect(mockService.updateCharacteristic).toHaveBeenCalledWith(CHARACTERISTIC, 0); }); it('should log "was Unlocked" when lock changes from secured to unsecured', async () => { device.accessory.context.LockMechanismLockCurrentState = 1; await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, 0, 'LockCurrentState', 1, 'Locked', 'Unlocked'); expect(device.infoLog).toHaveBeenCalledWith('was Unlocked'); }); it('should log "was Locked" when lock changes from unsecured to secured', async () => { device.accessory.context.LockMechanismLockCurrentState = 0; await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, 1, 'LockCurrentState', 1, 'Locked', 'Unlocked'); expect(device.infoLog).toHaveBeenCalledWith('was Locked'); }); it('should update context to new value', async () => { device.accessory.context.LockMechanismLockCurrentState = 1; await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, 0, 'LockCurrentState', 1, 'Locked', 'Unlocked'); expect(device.accessory.context.LockMechanismLockCurrentState).toBe(0); }); }); describe('contact sensor state changes', () => { it('should log "was Closed" when door changes to closed', async () => { device.accessory.context.ContactSensorContactSensorState = 1; await device.updateCharacteristic(mockService, 'ContactSensor', 'ContactSensorState', 0, 'ContactSensorState', 1, 'Opened', 'Closed'); expect(device.infoLog).toHaveBeenCalledWith('was Closed'); }); it('should log "was Opened" when door changes to open', async () => { device.accessory.context.ContactSensorContactSensorState = 0; await device.updateCharacteristic(mockService, 'ContactSensor', 'ContactSensorState', 1, 'ContactSensorState', 1, 'Opened', 'Closed'); expect(device.infoLog).toHaveBeenCalledWith('was Opened'); }); }); describe('updates without StatusMatch/StatusDoesNotMatch', () => { it('should push to HomeKit but not log info when no status strings provided', async () => { device.accessory.context.BatteryBatteryLevel = 90; await device.updateCharacteristic(mockService, 'Battery', 'BatteryLevel', 85, 'BatteryLevel'); expect(mockService.updateCharacteristic).toHaveBeenCalledWith('BatteryLevel', 85); expect(device.infoLog).not.toHaveBeenCalled(); }); }); describe('startup simulation (seeded context matches API response)', () => { it('should not push to HomeKit or log when seeded context matches first API value', async () => { // Simulate constructor seeding: accessory.context.LockMechanismLockCurrentState ??= SECURED device.accessory.context.LockMechanismLockCurrentState = 1; // First API response returns same state (SECURED = 1) await device.updateCharacteristic(mockService, 'LockMechanism', CHARACTERISTIC, 1, 'LockCurrentState', 1, 'Locked', 'Unlocked'); expect(mockService.updateCharacteristic).not.toHaveBeenCalled(); expect(device.infoLog).not.toHaveBeenCalled(); }); }); }); //# sourceMappingURL=device.test.js.map