@homebridge-plugins/homebridge-august
Version:
The August plugin allows you to access your August & Yale device(s) from HomeKit.
643 lines (524 loc) • 21.7 kB
text/typescript
/* Copyright(C) 2021-2024, donavanbecker (https://github.com/donavanbecker). All rights reserved.
*
* platform.test.ts: homebridge-august platform tests.
*/
import type { API, Logging, PlatformAccessory } from 'homebridge'
import type { AugustPlatformConfig } from '../src/settings.js'
import { readFileSync, writeFileSync } from 'node:fs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AugustPlatform } from '../src/Platform.HAP.js'
// Mock the august-yale module
vi.mock('august-yale', () => {
// eslint-disable-next-line prefer-arrow-callback
const MockAugust = vi.fn().mockImplementation(function () {
return { end: vi.fn() }
})
// Type assertion to add static methods
const MockConstructor = MockAugust as any
MockConstructor.details = vi.fn()
MockConstructor.authorize = vi.fn()
MockConstructor.validate = vi.fn()
return {
default: MockConstructor,
}
})
// Mock file system
vi.mock('node:fs', () => ({
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
}))
describe('augustPlatform', () => {
let platform: AugustPlatform
let mockApi: API
let mockLog: Logging
let mockConfig: AugustPlatformConfig
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
// Create mock API
mockApi = {
hap: {
uuid: {
generate: vi.fn(() => 'mock-uuid'),
},
},
user: {
configPath: vi.fn(() => '/mock/config.json'),
},
on: vi.fn(),
registerPlatformAccessories: vi.fn(),
updatePlatformAccessories: vi.fn(),
unregisterPlatformAccessories: vi.fn(),
publishExternalAccessories: vi.fn(),
} as unknown as API
// Create mock logger
mockLog = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
success: vi.fn(),
} as any
// Create mock config
mockConfig = {
platform: 'August',
name: 'Test August',
credentials: {
augustId: 'test@example.com',
password: 'testpassword',
countryCode: 'US',
installId: 'test-install-id',
isValidated: true,
},
options: {
logging: 'debug',
},
}
// Mock readFileSync for config file
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
platforms: [mockConfig],
}))
})
it('should normalize Canadian country code to US for API compatibility', async () => {
const config: AugustPlatformConfig = {
platform: 'August',
name: 'Test August',
credentials: {
installId: 'test-install-id',
augustId: 'test@example.com',
password: 'test-password',
countryCode: 'CA',
isValidated: true,
},
options: {
logging: 'debug',
},
}
platform = new AugustPlatform(mockLog, config, mockApi)
// Access the private method for testing
const normalizeMethod = (platform as any).normalizeCredentialsForApi.bind(platform)
const normalizedCredentials = await normalizeMethod(config.credentials)
expect(normalizedCredentials.countryCode).toBe('US')
expect(normalizedCredentials.augustId).toBe('test@example.com')
expect(normalizedCredentials.password).toBe('test-password')
})
it('should normalize Mexican country code to US for API compatibility', async () => {
const config: AugustPlatformConfig = {
platform: 'August',
name: 'Test August',
credentials: {
installId: 'test-install-id',
augustId: 'test@example.com',
password: 'test-password',
countryCode: 'MX',
isValidated: true,
},
options: {
logging: 'debug',
},
}
platform = new AugustPlatform(mockLog, config, mockApi)
// Access the private method for testing
const normalizeMethod = (platform as any).normalizeCredentialsForApi.bind(platform)
const normalizedCredentials = await normalizeMethod(config.credentials)
expect(normalizedCredentials.countryCode).toBe('US')
expect(normalizedCredentials.augustId).toBe('test@example.com')
expect(normalizedCredentials.password).toBe('test-password')
})
it('should respect disableCountryCodeNormalization option', async () => {
const config: AugustPlatformConfig = {
platform: 'August',
name: 'Test August',
credentials: {
installId: 'test-install-id',
augustId: 'test@example.com',
password: 'test-password',
countryCode: 'CA',
isValidated: true,
},
options: {
logging: 'debug',
disableCountryCodeNormalization: true,
},
}
platform = new AugustPlatform(mockLog, config, mockApi)
// Access the private method for testing
const normalizeMethod = (platform as any).normalizeCredentialsForApi.bind(platform)
const normalizedCredentials = await normalizeMethod(config.credentials)
expect(normalizedCredentials.countryCode).toBe('CA') // Should remain unchanged
expect(normalizedCredentials.augustId).toBe('test@example.com')
expect(normalizedCredentials.password).toBe('test-password')
})
it('should handle case-insensitive country codes', async () => {
const config: AugustPlatformConfig = {
platform: 'August',
name: 'Test August',
credentials: {
installId: 'test-install-id',
augustId: 'test@example.com',
password: 'test-password',
countryCode: 'ca', // lowercase
isValidated: true,
},
options: {
logging: 'debug',
},
}
platform = new AugustPlatform(mockLog, config, mockApi)
// Access the private method for testing
const normalizeMethod = (platform as any).normalizeCredentialsForApi.bind(platform)
const normalizedCredentials = await normalizeMethod(config.credentials)
expect(normalizedCredentials.countryCode).toBe('US')
})
it('should not change US country code', async () => {
const config: AugustPlatformConfig = {
platform: 'August',
name: 'Test August',
credentials: {
installId: 'test-install-id',
augustId: 'test@example.com',
password: 'test-password',
countryCode: 'US',
isValidated: true,
},
options: {
logging: 'debug',
},
}
platform = new AugustPlatform(mockLog, config, mockApi)
// Access the private method for testing
const normalizeMethod = (platform as any).normalizeCredentialsForApi.bind(platform)
const normalizedCredentials = await normalizeMethod(config.credentials)
expect(normalizedCredentials.countryCode).toBe('US')
})
it('should preserve other credential properties when normalizing', async () => {
const config: AugustPlatformConfig = {
platform: 'August',
name: 'Test August',
credentials: {
apiKey: 'test-api-key',
pnSubKey: 'test-pn-sub-key',
installId: 'test-install-id',
augustId: 'test@example.com',
password: 'test-password',
countryCode: 'CA',
isValidated: true,
validateCode: 'test-code',
},
options: {
logging: 'debug',
},
}
platform = new AugustPlatform(mockLog, config, mockApi)
// Access the private method for testing
const normalizeMethod = (platform as any).normalizeCredentialsForApi.bind(platform)
const normalizedCredentials = await normalizeMethod(config.credentials)
expect(normalizedCredentials.apiKey).toBe('test-api-key')
expect(normalizedCredentials.pnSubKey).toBe('test-pn-sub-key')
expect(normalizedCredentials.installId).toBe('test-install-id')
expect(normalizedCredentials.augustId).toBe('test@example.com')
expect(normalizedCredentials.password).toBe('test-password')
expect(normalizedCredentials.countryCode).toBe('US') // Should be normalized
expect(normalizedCredentials.isValidated).toBe(true)
expect(normalizedCredentials.validateCode).toBe('test-code')
})
it('should provide public method to get normalized credentials', async () => {
const config: AugustPlatformConfig = {
platform: 'August',
name: 'Test August',
credentials: {
installId: 'test-install-id',
augustId: 'test@example.com',
password: 'test-password',
countryCode: 'CA',
isValidated: true,
},
options: {
logging: 'debug',
},
}
platform = new AugustPlatform(mockLog, config, mockApi)
// Test the public method
const normalizedCredentials = await platform.getNormalizedCredentials()
expect(normalizedCredentials.countryCode).toBe('US')
expect(normalizedCredentials.augustId).toBe('test@example.com')
})
it('should handle null credentials gracefully', async () => {
const config: AugustPlatformConfig = {
platform: 'August',
name: 'Test August',
credentials: {
installId: 'test-install-id',
augustId: 'test@example.com',
password: 'test-password',
countryCode: 'CA',
isValidated: true,
},
options: {
logging: 'debug',
},
}
platform = new AugustPlatform(mockLog, config, mockApi)
// Access the private method for testing
const normalizeMethod = (platform as any).normalizeCredentialsForApi.bind(platform)
await expect(normalizeMethod(null)).rejects.toThrow('Credentials cannot be null or undefined')
await expect(normalizeMethod(undefined)).rejects.toThrow('Credentials cannot be null or undefined')
})
it('should use cached credentials for performance', async () => {
const config: AugustPlatformConfig = {
platform: 'August',
name: 'Test August',
credentials: {
installId: 'test-install-id',
augustId: 'test@example.com',
password: 'test-password',
countryCode: 'CA',
isValidated: true,
},
options: {
logging: 'debug',
},
}
platform = new AugustPlatform(mockLog, config, mockApi)
// First call should normalize and cache
const firstCall = await platform.getNormalizedCredentials()
expect(firstCall.countryCode).toBe('US')
// Second call should use cache
const secondCall = await platform.getNormalizedCredentials()
expect(secondCall.countryCode).toBe('US')
expect(secondCall).toBe(firstCall) // Should be the same object reference
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('discoverDevices', () => {
it('should handle 401 authentication errors and trigger re-authentication', async () => {
// Create platform instance
platform = new AugustPlatform(mockLog, mockConfig, mockApi)
// Mock August.details to throw 401 error
const August = await import('august-yale')
const detailsMock = vi.fn().mockRejectedValueOnce(new Error('GET failed with: 401'))
August.default.details = detailsMock
// Mock validated method to avoid actual re-authentication
const validateSpy = vi.spyOn(platform, 'validated').mockImplementation(async () => {
// Don't change the flag back to true, just simulate the method being called
})
// Call discoverDevices
await expect(platform.discoverDevices()).resolves.toBeUndefined()
// Verify that validation was reset and re-authentication was attempted
expect(platform.config.credentials!.isValidated).toBe(false)
expect(writeFileSync).toHaveBeenCalled()
expect(validateSpy).toHaveBeenCalled()
})
it('should handle "unauthorized" errors and trigger re-authentication', async () => {
// Create platform instance
platform = new AugustPlatform(mockLog, mockConfig, mockApi)
// Mock August.details to throw unauthorized error
const August = await import('august-yale')
const detailsMock = vi.fn().mockRejectedValueOnce(new Error('Request failed: Unauthorized'))
August.default.details = detailsMock
// Mock validated method
const validateSpy = vi.spyOn(platform, 'validated').mockImplementation(async () => {
})
// Call discoverDevices
await expect(platform.discoverDevices()).resolves.toBeUndefined()
// Verify re-authentication was triggered
expect(validateSpy).toHaveBeenCalled()
})
it('should re-throw non-authentication errors', async () => {
// Create platform instance
platform = new AugustPlatform(mockLog, mockConfig, mockApi)
// Mock August.details to throw non-401 error
const August = await import('august-yale')
const networkError = new Error('Network timeout')
const detailsMock = vi.fn().mockRejectedValueOnce(networkError)
August.default.details = detailsMock
// Call discoverDevices and expect it to throw
await expect(platform.discoverDevices()).rejects.toThrow('Failed to discover devices: Network timeout')
})
it('should process devices successfully when no errors occur', async () => {
// Create platform instance
platform = new AugustPlatform(mockLog, mockConfig, mockApi)
// Mock successful August.details response
const August = await import('august-yale')
const mockDevice = {
lockId: 'test-lock-id',
LockName: 'Test Lock',
SerialNumber: '123456',
} as any // Use any to avoid complex type requirements
const detailsMock = vi.fn().mockResolvedValueOnce([mockDevice])
August.default.details = detailsMock
// Mock the Lock method to avoid actual device processing
const lockSpy = vi.spyOn(platform as any, 'Lock').mockImplementation(async () => {})
// Call discoverDevices
await expect(platform.discoverDevices()).resolves.toBeUndefined()
// Verify successful processing
expect(detailsMock).toHaveBeenCalled()
// Due to the existing logic, single device arrays get wrapped: [devices] becomes [[device]]
// So Lock gets called with [device] instead of device
expect(lockSpy).toHaveBeenCalledWith([mockDevice])
})
it('should exclude locks matching excludeLockIds', async () => {
const configWithExcludes: AugustPlatformConfig = {
...mockConfig,
options: {
logging: 'debug',
excludeLockIds: ['excluded-lock-id'],
},
}
platform = new AugustPlatform(mockLog, configWithExcludes, mockApi)
const August = await import('august-yale')
const mockDeviceIncluded = { lockId: 'included-lock-id', LockName: 'Included Lock' } as any
const mockDeviceExcluded = { lockId: 'excluded-lock-id', LockName: 'Excluded Lock' } as any
August.default.details = vi.fn().mockResolvedValueOnce([mockDeviceIncluded, mockDeviceExcluded])
const lockSpy = vi.spyOn(platform as any, 'Lock').mockImplementation(async () => {})
await platform.discoverDevices()
// Only the non-excluded lock should be processed
expect(lockSpy).toHaveBeenCalledTimes(1)
expect(lockSpy).toHaveBeenCalledWith(expect.objectContaining({ lockId: 'included-lock-id' }))
})
it('should handle case-insensitive excludeLockIds matching', async () => {
const configWithExcludes: AugustPlatformConfig = {
...mockConfig,
options: {
logging: 'debug',
excludeLockIds: ['EXCLUDED-LOCK-ID'],
},
}
platform = new AugustPlatform(mockLog, configWithExcludes, mockApi)
const August = await import('august-yale')
const mockDeviceIncluded = { lockId: 'included-lock-id', LockName: 'Included Lock' } as any
const mockDeviceExcluded = { lockId: 'excluded-lock-id', LockName: 'Excluded Lock' } as any
August.default.details = vi.fn().mockResolvedValueOnce([mockDeviceIncluded, mockDeviceExcluded])
const lockSpy = vi.spyOn(platform as any, 'Lock').mockImplementation(async () => {})
await platform.discoverDevices()
expect(lockSpy).toHaveBeenCalledTimes(1)
expect(lockSpy).toHaveBeenCalledWith(expect.objectContaining({ lockId: 'included-lock-id' }))
})
it('should unregister cached accessories for excluded lock IDs', async () => {
const configWithExcludes: AugustPlatformConfig = {
...mockConfig,
options: {
logging: 'debug',
excludeLockIds: ['excluded-lock-id'],
},
}
// Make uuid.generate return predictable UUIDs based on input
vi.mocked(mockApi.hap.uuid.generate).mockImplementation((input: any) => `uuid-${input}`)
platform = new AugustPlatform(mockLog, configWithExcludes, mockApi)
// Simulate a cached accessory for the excluded lock
const mockAccessory = {
UUID: 'uuid-excluded-lock-id',
displayName: 'Excluded Lock',
} as unknown as PlatformAccessory
platform.accessories.push(mockAccessory)
const August = await import('august-yale')
const mockDevice = { lockId: 'included-lock-id', LockName: 'Included Lock' } as any
August.default.details = vi.fn().mockResolvedValueOnce([mockDevice])
vi.spyOn(platform as any, 'Lock').mockImplementation(async () => {})
await platform.discoverDevices()
// Verify the cached accessory was unregistered
expect(mockApi.unregisterPlatformAccessories).toHaveBeenCalledWith(
'homebridge-august',
'August',
[mockAccessory],
)
// Verify the accessory was removed from the accessories array
expect(platform.accessories.find(a => a.UUID === 'uuid-excluded-lock-id')).toBeUndefined()
})
it('should not filter when excludeLockIds is empty', async () => {
const configWithEmptyExcludes: AugustPlatformConfig = {
...mockConfig,
options: {
logging: 'debug',
excludeLockIds: [],
},
}
platform = new AugustPlatform(mockLog, configWithEmptyExcludes, mockApi)
const August = await import('august-yale')
const mockDeviceA = { lockId: 'lock-a', LockName: 'Lock A' } as any
const mockDeviceB = { lockId: 'lock-b', LockName: 'Lock B' } as any
August.default.details = vi.fn().mockResolvedValueOnce([mockDeviceA, mockDeviceB])
const lockSpy = vi.spyOn(platform as any, 'Lock').mockImplementation(async () => {})
await platform.discoverDevices()
expect(lockSpy).toHaveBeenCalledTimes(2)
})
it('should exclude locks when using per-device config', async () => {
const configWithBoth: AugustPlatformConfig = {
...mockConfig,
options: {
logging: 'debug',
excludeLockIds: ['excluded-lock-id'],
devices: [
{ lockId: 'included-lock-id', configLockName: 'My Lock', overrideHomeKitEnabled: false } as any,
],
},
}
platform = new AugustPlatform(mockLog, configWithBoth, mockApi)
const August = await import('august-yale')
const mockDeviceIncluded = { lockId: 'included-lock-id', LockName: 'Included Lock' } as any
const mockDeviceExcluded = { lockId: 'excluded-lock-id', LockName: 'Excluded Lock' } as any
August.default.details = vi.fn().mockResolvedValueOnce([mockDeviceIncluded, mockDeviceExcluded])
const lockSpy = vi.spyOn(platform as any, 'Lock').mockImplementation(async () => {})
await platform.discoverDevices()
// Only the included lock should reach the Lock method
expect(lockSpy).toHaveBeenCalledTimes(1)
})
})
describe('validated method', () => {
it('should handle undefined credentials in config file gracefully', async () => {
// Mock config file with undefined credentials
const mockConfigFile = {
platforms: [
{
platform: 'August',
name: 'Test August',
// credentials is undefined here
},
],
}
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfigFile))
platform = new AugustPlatform(mockLog, mockConfig, mockApi)
// This should not throw an error when trying to set installId
await expect(platform.validated()).resolves.toBeUndefined()
})
it('should handle null credentials in config file gracefully', async () => {
// Mock config file with null credentials
const mockConfigFile = {
platforms: [
{
platform: 'August',
name: 'Test August',
credentials: null,
},
],
}
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfigFile))
platform = new AugustPlatform(mockLog, mockConfig, mockApi)
// This should not throw an error when trying to set installId
await expect(platform.validated()).resolves.toBeUndefined()
})
it('should initialize credentials object and save installId when credentials are missing', async () => {
// Mock config file with undefined credentials
const mockConfigFile = {
platforms: [
{
platform: 'August',
name: 'Test August',
// credentials is undefined here
},
],
}
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfigFile))
platform = new AugustPlatform(mockLog, mockConfig, mockApi)
await platform.validated()
// Verify that writeFileSync was called (meaning the config was updated)
expect(writeFileSync).toHaveBeenCalled()
// Verify the credentials object was created and populated
const writeCall = vi.mocked(writeFileSync).mock.calls[0]
const savedConfig = JSON.parse(writeCall[1] as string)
expect(savedConfig.platforms[0].credentials).toBeDefined()
expect(savedConfig.platforms[0].credentials.installId).toBeDefined()
})
})
})