@homebridge-plugins/homebridge-august
Version:
The August plugin allows you to access your August & Yale device(s) from HomeKit.
591 lines (516 loc) • 24.8 kB
text/typescript
/* Copyright(C) 2026, homebridge-plugins (https://github.com/homebridge-plugins). All rights reserved.
*
* connectivity-manager.test.ts: unit tests for ConnectivityManager.
*
* The manager is the centerpiece of the connectivity refactor and the
* piece most likely to regress under future changes (state machine,
* timer-driven probes, async rebuild coalescing). These tests cover
* the contract that the rest of the codebase relies on.
*/
import type { Logging } from 'homebridge'
import type { credentials } from '../src/settings.js'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ConnectivityManager, OfflineError } from '../src/connectivity-manager.js'
// Mock the august-yale module. We need:
// - August: a constructor we can instantiate, with details() and destroy()
// - The full exception hierarchy that classify() now uses for routing:
// NetworkError, TimeoutError (subclass), AbortedError, InvalidAuth.
vi.mock('august-yale', () => {
class YaleApiError extends Error {
public originalError?: Error
constructor(message?: string, originalError?: Error) {
super(message)
this.name = 'YaleApiError'
this.originalError = originalError
}
}
class NetworkError extends YaleApiError {
public code?: string
constructor(message?: string, originalError?: Error, code?: string) {
super(message, originalError)
this.name = 'NetworkError'
this.code = code
}
}
class TimeoutError extends NetworkError {
constructor(message?: string, originalError?: Error, code?: string) {
super(message, originalError, code)
this.name = 'TimeoutError'
}
}
class AbortedError extends YaleApiError {
public code?: string
constructor(message?: string, originalError?: Error, code?: string) {
super(message, originalError)
this.name = 'AbortedError'
this.code = code
}
}
class InvalidAuth extends YaleApiError {
constructor(message?: string, originalError?: Error) {
super(message, originalError)
this.name = 'InvalidAuth'
}
}
// Each new August() returns a unique object so tests can assert which
// client is being used (initial vs. rebuilt). Tests can also override
// the methods of the NEXT client to be built by setting fields on
// `nextClientOverride`. After one use the override is cleared.
let nextId = 0
// eslint-disable-next-line prefer-arrow-callback
const MockAugust = vi.fn().mockImplementation(function () {
const id = ++nextId
return {
_id: id,
details: vi.fn().mockResolvedValue({ ok: true }),
locks: vi.fn().mockResolvedValue({}),
lock: vi.fn().mockResolvedValue(undefined),
unlock: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn(),
resetTransport: vi.fn(),
end: vi.fn(),
}
})
return {
default: MockAugust,
YaleApiError,
NetworkError,
TimeoutError,
AbortedError,
InvalidAuth,
}
})
// Helper: build a stub Logging object that records calls.
function makeLog(): Logging & { _calls: { level: string, msg: string }[] } {
const calls: { level: string, msg: string }[] = []
const stub = ((..._args: any[]) => {}) as any
stub._calls = calls
for (const level of ['info', 'warn', 'error', 'debug', 'success'] as const) {
stub[level] = (msg: string) => calls.push({ level, msg })
}
return stub
}
// Helper: trivial credentials factory.
async function fakeCredentials(): Promise<credentials> {
return { installId: 'test-install', apiKey: 'test-key' } as unknown as credentials
}
describe('ConnectivityManager', () => {
beforeEach(() => {
// Use fake timers so probes can be advanced deterministically without
// waiting for real backoff windows (5s..5min).
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe('init / lifecycle', () => {
it('init() builds a client and fires onClientChanged exactly once', async () => {
const onClientChanged = vi.fn()
const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
await m.init()
expect(onClientChanged).toHaveBeenCalledTimes(1)
// The current client is whatever was passed to onClientChanged.
expect(m.getClient()).toBe(onClientChanged.mock.calls[0][0])
expect(m.getState()).toBe('healthy')
})
it('init() is idempotent — calling twice does not rebuild', async () => {
const onClientChanged = vi.fn()
const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
await m.init()
await m.init()
expect(onClientChanged).toHaveBeenCalledTimes(1)
})
it('shutdown() destroys the client, clears state, fires onClientChanged(undefined)', async () => {
const onClientChanged = vi.fn()
const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
await m.init()
const client = m.getClient() as any
m.shutdown()
expect(client.destroy).toHaveBeenCalledTimes(1)
expect(m.getClient()).toBeUndefined()
// Last call to onClientChanged was with undefined.
const lastCall = onClientChanged.mock.calls.at(-1)
expect(lastCall?.[0]).toBeUndefined()
})
})
describe('execute() — happy path and routing', () => {
it('returns the result and stays healthy on success', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const result = await m.execute('test', async client => (client as any).details('lock-1'))
expect(result).toEqual({ ok: true })
expect(m.getState()).toBe('healthy')
})
it('returns undefined and does not invoke fn when offline', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
;(m as any).state = 'offline' // force state for this case
const fn = vi.fn()
const result = await m.execute('test', fn)
expect(result).toBeUndefined()
expect(fn).not.toHaveBeenCalled()
})
it('throws OfflineError when offline and throwOnOffline:true', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
;(m as any).state = 'offline'
await expect(
m.execute('user-action', async () => 'ok', { throwOnOffline: true }),
).rejects.toBeInstanceOf(OfflineError)
})
it('returns undefined when init() has not been called', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
const result = await m.execute('test', async () => 'never-called')
expect(result).toBeUndefined()
})
})
describe('execute() — error classification', () => {
it('rethrows transient (4xx like 422) errors without changing state', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const transientErr = Object.assign(new Error('Unprocessable Entity'), { statusCode: 422 })
await expect(
m.execute('test', async () => { throw transientErr }),
).rejects.toBe(transientErr)
expect(m.getState()).toBe('healthy')
})
it('rethrows transient errors even with throwOnOffline:true', async () => {
// throwOnOffline only governs the offline branch; transient errors
// are always rethrown regardless of that option.
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const err = Object.assign(new Error('rate limited'), { statusCode: 429 })
await expect(
m.execute('user', async () => { throw err }, { throwOnOffline: true }),
).rejects.toBe(err)
})
it('rebuilds the client on 401 without changing connectivity state', async () => {
const onClientChanged = vi.fn()
const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
await m.init()
expect(onClientChanged).toHaveBeenCalledTimes(1)
const authErr = Object.assign(new Error('Unauthorized'), { statusCode: 401 })
const result = await m.execute('test', async () => { throw authErr })
expect(result).toBeUndefined()
// Allow the rebuild promise to resolve.
await vi.runAllTimersAsync()
expect(onClientChanged).toHaveBeenCalledTimes(2)
expect(m.getState()).toBe('healthy')
})
it('treats TimeoutError as network and transitions to degraded', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const { TimeoutError } = await import('august-yale')
const result = await m.execute('test', async () => { throw new TimeoutError('timed out') })
expect(result).toBeUndefined()
expect(m.getState()).toBe('degraded')
})
it('treats NetworkError as network and transitions to degraded', async () => {
// NetworkError covers all transport-level failures from august-yale —
// socket reset, DNS failure, malformed response, etc. — without us
// having to enumerate undici's 24 error classes.
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const { NetworkError } = await import('august-yale')
const result = await m.execute('test', async () => { throw new NetworkError('socket hang up', undefined, 'ECONNRESET') })
expect(result).toBeUndefined()
expect(m.getState()).toBe('degraded')
})
it('treats InvalidAuth as auth and rebuilds without changing state', async () => {
const onClientChanged = vi.fn()
const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
await m.init()
expect(onClientChanged).toHaveBeenCalledTimes(1)
const { InvalidAuth } = await import('august-yale')
const result = await m.execute('test', async () => { throw new InvalidAuth('session expired') })
expect(result).toBeUndefined()
await vi.runAllTimersAsync()
expect(onClientChanged).toHaveBeenCalledTimes(2)
expect(m.getState()).toBe('healthy')
})
it('treats AbortedError as transient and does NOT change connectivity state', async () => {
// AbortedError reflects a request racing with our own teardown
// (e.g. inside rebuildClient). Treating it as 'network' would
// make the state machine fight itself: every rebuild produces
// ClientDestroyedError -> degraded -> probe -> rebuild ->
// ClientDestroyedError -> ...
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const { AbortedError } = await import('august-yale')
const err = new AbortedError('client destroyed', undefined, 'UND_ERR_DESTROYED')
await expect(
m.execute('test', async () => { throw err }),
).rejects.toBe(err)
// State stays healthy — no probe scheduled.
expect(m.getState()).toBe('healthy')
})
it('treats 502/503/504 as network errors (HTTP-level fallback)', async () => {
// Most transport failures are now wrapped upstream as NetworkError,
// but if august-yale ever surfaces a clean HTTP 5xx response, we
// still want to recognize 502/503/504 as network-class and probe.
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const err = Object.assign(new Error('Bad Gateway'), { statusCode: 502 })
await m.execute('test', async () => { throw err })
expect(m.getState()).toBe('degraded')
})
})
describe('probe-driven recovery', () => {
it('schedules a probe on first network failure and recovers when probe succeeds', async () => {
const onClientChanged = vi.fn()
const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
await m.init()
expect(onClientChanged).toHaveBeenCalledTimes(1)
// Network failure: degrades and schedules probe.
const { TimeoutError } = await import('august-yale')
await m.execute('test', async () => { throw new TimeoutError('boom') })
expect(m.getState()).toBe('degraded')
// Probe calls resetTransport() on the existing client and then its
// .locks(). Default mock = locks resolves, so probe succeeds.
// Advance time past the first backoff slot (~5s plus jitter).
await vi.advanceTimersByTimeAsync(7_000)
// Probe success → healthy. onClientChanged was called only once
// (at init). The resetTransport-based design does NOT swap the
// client instance on recovery; it just recycles its transport.
expect(m.getState()).toBe('healthy')
expect(onClientChanged).toHaveBeenCalledTimes(1)
})
it('escalates from degraded to offline when probe fails, with longer backoff', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const client = m.getClient() as any
const { TimeoutError } = await import('august-yale')
// Make the existing client's .locks() always fail. With the
// resetTransport-based design, probes reuse this client, so a
// single mockImplementation is sufficient for every probe attempt.
client.locks = vi.fn().mockImplementation(() => new Promise((_resolve, reject) => {
setTimeout(() => reject(new TimeoutError('still down')), 100)
}))
// Trigger degraded.
await m.execute('test', async () => { throw new TimeoutError('boom') })
expect(m.getState()).toBe('degraded')
// Advance past first backoff + probe timeout.
await vi.advanceTimersByTimeAsync(15_000)
expect(m.getState()).toBe('offline')
})
it('execute() after entering offline returns undefined without calling fn', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
;(m as any).state = 'offline'
const fn = vi.fn()
const result = await m.execute('test', fn)
expect(result).toBeUndefined()
expect(fn).not.toHaveBeenCalled()
})
it('successful execute() call recovers state from degraded to healthy', async () => {
// This case covers the natural-recovery path: a network blip that
// resolves before the probe fires. The next real execute() call
// succeeds and the manager reports healthy.
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const { TimeoutError } = await import('august-yale')
await m.execute('test', async () => { throw new TimeoutError('blip') })
expect(m.getState()).toBe('degraded')
// Next call succeeds.
const result = await m.execute('test', async () => 'recovered')
expect(result).toBe('recovered')
expect(m.getState()).toBe('healthy')
})
it('probes call resetTransport() on the existing client (sheds stale sockets)', async () => {
// Regression test for the production bug: the original
// implementation probed against the existing client without
// resetting its socket pool. The pool kept stale half-open sockets
// from before the outage, probes failed on those sockets, system
// stayed offline.
//
// The fix: probe still uses the existing client (so we keep auth
// state), but resetTransport() is called first to throw away the
// dispatcher and its stale connections. New transport, same auth.
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const client = m.getClient() as any
// Sanity: the mock client has a resetTransport method.
expect(typeof client.resetTransport).toBe('function')
// Trigger degraded.
const { TimeoutError } = await import('august-yale')
await m.execute('test', async () => { throw new TimeoutError('boom') })
expect(m.getState()).toBe('degraded')
// Advance past the first backoff: probe runs, default mock has
// .locks() resolving, recovery completes.
await vi.advanceTimersByTimeAsync(7_000)
expect(m.getState()).toBe('healthy')
// The probe must have called resetTransport on the existing
// client. This is the structural assertion that locks in the fix.
expect(client.resetTransport).toHaveBeenCalled()
// The same client instance is still in place (we did NOT
// construct a new client — this distinguishes the resetTransport
// approach from the previous fresh-client approach).
expect(m.getClient()).toBe(client)
})
it('failed probe leaves the existing client in place (no leak, no rotation)', async () => {
// With resetTransport(), failed probes don't construct a temp
// client, so there's nothing to leak. The existing client stays
// the current client throughout the offline period — only its
// transport gets recycled, not the August instance itself.
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const client = m.getClient() as any
const { TimeoutError } = await import('august-yale')
// Make the client's .locks() always fail.
client.locks = vi.fn().mockImplementation(() => new Promise((_, reject) =>
setTimeout(() => reject(new TimeoutError('still down')), 100),
))
// Trigger degraded.
await m.execute('test', async () => { throw new TimeoutError('boom') })
expect(m.getState()).toBe('degraded')
// Advance past first probe → offline.
await vi.advanceTimersByTimeAsync(15_000)
expect(m.getState()).toBe('offline')
// The existing client is still the current client; nothing got
// destroyed or replaced.
expect(m.getClient()).toBe(client)
expect(client.destroy).not.toHaveBeenCalled()
// resetTransport was called for each probe attempt.
expect(client.resetTransport).toHaveBeenCalled()
})
})
describe('onPubNubReconnect — fast recovery', () => {
it('triggers an immediate probe when degraded', async () => {
const onClientChanged = vi.fn()
const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
await m.init()
const { TimeoutError } = await import('august-yale')
await m.execute('test', async () => { throw new TimeoutError('boom') })
expect(m.getState()).toBe('degraded')
// PubNub reconnect → immediate probe. Default mock has .locks()
// resolving, so the probe (which calls resetTransport on the
// existing client and then locks()) succeeds.
m.onPubNubReconnect()
// Probe runs synchronously inside onPubNubReconnect's microtask;
// need to flush promises but no timer advance required.
await vi.runAllTimersAsync()
expect(m.getState()).toBe('healthy')
// onClientChanged was only called once (at init). Recovery via
// resetTransport does not swap the client instance.
expect(onClientChanged).toHaveBeenCalledTimes(1)
})
it('is a no-op when already healthy', async () => {
const onClientChanged = vi.fn()
const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
await m.init()
const callsAfterInit = onClientChanged.mock.calls.length
m.onPubNubReconnect()
await vi.runAllTimersAsync()
// No probe, no rebuild.
expect(onClientChanged).toHaveBeenCalledTimes(callsAfterInit)
expect(m.getState()).toBe('healthy')
})
})
describe('rebuild coalescing', () => {
it('two concurrent 401s produce one rebuild, not two', async () => {
const onClientChanged = vi.fn()
const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
await m.init()
expect(onClientChanged).toHaveBeenCalledTimes(1)
const authErr = Object.assign(new Error('Unauthorized'), { statusCode: 401 })
// Fire two execute()s concurrently — each will see a 401, each will
// request a rebuild. The manager must coalesce these into one rebuild.
await Promise.all([
m.execute('a', async () => { throw authErr }),
m.execute('b', async () => { throw authErr }),
])
await vi.runAllTimersAsync()
// Initial build + exactly one rebuild = 2 total.
expect(onClientChanged).toHaveBeenCalledTimes(2)
})
})
describe('state-change listeners', () => {
it('onStateChange fires on transitions and not on no-op transitions', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const listener = vi.fn()
m.onStateChange(listener)
const { TimeoutError } = await import('august-yale')
await m.execute('test', async () => { throw new TimeoutError('boom') })
// healthy -> degraded fires once.
expect(listener).toHaveBeenCalledWith('degraded', 'healthy')
expect(listener).toHaveBeenCalledTimes(1)
// A second network error while already degraded should NOT fire
// again (no transition).
await m.execute('test', async () => { throw new TimeoutError('boom') })
expect(listener).toHaveBeenCalledTimes(1)
})
it('unsubscribe stops further notifications', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const listener = vi.fn()
const unsubscribe = m.onStateChange(listener)
unsubscribe()
const { TimeoutError } = await import('august-yale')
await m.execute('test', async () => { throw new TimeoutError('boom') })
expect(listener).not.toHaveBeenCalled()
})
it('a throwing listener does not block other listeners', async () => {
const m = new ConnectivityManager(makeLog(), fakeCredentials)
await m.init()
const good = vi.fn()
m.onStateChange(() => { throw new Error('listener bug') })
m.onStateChange(good)
const { TimeoutError } = await import('august-yale')
await m.execute('test', async () => { throw new TimeoutError('boom') })
expect(good).toHaveBeenCalledTimes(1)
})
})
describe('offline heartbeat', () => {
// Helper: drive the manager into offline state and capture the log.
async function getOfflineWithLog() {
const log = makeLog()
const m = new ConnectivityManager(log, fakeCredentials)
await m.init()
const client = m.getClient() as any
const { TimeoutError } = await import('august-yale')
// Make every probe fail by making the existing client's .locks
// always reject. With the resetTransport-based design, probes
// reuse this client across attempts, so a single mockImplementation
// covers every probe in the offline period.
client.locks = vi.fn().mockImplementation(() => new Promise((_resolve, reject) =>
setTimeout(() => reject(new TimeoutError('still down')), 100),
))
// Trigger degraded.
await m.execute('test', async () => { throw new TimeoutError('boom') })
// Drive past first probe so we land in offline.
await vi.advanceTimersByTimeAsync(15_000)
return { m, log, client }
}
it('emits a heartbeat after one hour of being offline', async () => {
const { log } = await getOfflineWithLog()
// Less than an hour: no heartbeat yet.
await vi.advanceTimersByTimeAsync(30 * 60_000)
const beforeHour = log._calls.filter(c => c.msg.includes('still offline')).length
expect(beforeHour).toBe(0)
// Past one hour: heartbeat fires. Probes are still failing in the
// background, so we filter for the heartbeat-specific message.
await vi.advanceTimersByTimeAsync(35 * 60_000)
const afterHour = log._calls.filter(c => c.msg.includes('still offline')).length
expect(afterHour).toBeGreaterThan(0)
// Heartbeat content includes attempt count and last failure.
const heartbeatLine = log._calls.find(c => c.msg.includes('still offline'))
expect(heartbeatLine?.msg).toMatch(/\d+ probe attempts?/)
expect(heartbeatLine?.msg).toContain('last failure')
})
it('stops emitting heartbeats after recovery', async () => {
const { m, log, client } = await getOfflineWithLog()
// Allow recovery on the next probe by making .locks resolve again.
client.locks = vi.fn().mockResolvedValue({})
// Skip ahead enough for a probe to fire and succeed.
await vi.advanceTimersByTimeAsync(60 * 1000)
expect(m.getState()).toBe('healthy')
const beforeIdle = log._calls.filter(c => c.msg.includes('still offline')).length
// Advance two more hours — should NOT see new heartbeat lines.
await vi.advanceTimersByTimeAsync(2 * 60 * 60_000)
const afterIdle = log._calls.filter(c => c.msg.includes('still offline')).length
expect(afterIdle).toBe(beforeIdle)
})
})
})