@homebridge-plugins/homebridge-august 3.1.3 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,380 @@
1
+ /* Copyright(C) 2026, homebridge-plugins (https://github.com/homebridge-plugins). All rights reserved.
2
+ *
3
+ * connectivity-manager.test.ts: unit tests for ConnectivityManager.
4
+ *
5
+ * The manager is the centerpiece of the connectivity refactor and the
6
+ * piece most likely to regress under future changes (state machine,
7
+ * timer-driven probes, async rebuild coalescing). These tests cover
8
+ * the contract that the rest of the codebase relies on.
9
+ */
10
+ import type { Logging } from 'homebridge'
11
+
12
+ import type { credentials } from '../src/settings.js'
13
+
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
15
+
16
+ import { ConnectivityManager, OfflineError } from '../src/connectivity-manager.js'
17
+
18
+ // Mock the august-yale module. We need:
19
+ // - August: a constructor we can instantiate, with details() and destroy()
20
+ // - TimeoutError: a real error class so `name === 'TimeoutError'` works
21
+ vi.mock('august-yale', () => {
22
+ class TimeoutError extends Error {
23
+ constructor(message?: string) {
24
+ super(message)
25
+ this.name = 'TimeoutError'
26
+ }
27
+ }
28
+ // Each new August() returns a unique object so tests can assert which
29
+ // client is being used (initial vs. rebuilt).
30
+ let nextId = 0
31
+ // eslint-disable-next-line prefer-arrow-callback
32
+ const MockAugust = vi.fn().mockImplementation(function () {
33
+ const id = ++nextId
34
+ return {
35
+ _id: id,
36
+ details: vi.fn().mockResolvedValue({ ok: true }),
37
+ locks: vi.fn().mockResolvedValue({}),
38
+ lock: vi.fn().mockResolvedValue(undefined),
39
+ unlock: vi.fn().mockResolvedValue(undefined),
40
+ destroy: vi.fn(),
41
+ end: vi.fn(),
42
+ }
43
+ })
44
+ return {
45
+ default: MockAugust,
46
+ TimeoutError,
47
+ }
48
+ })
49
+
50
+ // Helper: build a stub Logging object that records calls.
51
+ function makeLog(): Logging & { _calls: { level: string, msg: string }[] } {
52
+ const calls: { level: string, msg: string }[] = []
53
+ const stub = ((..._args: any[]) => {}) as any
54
+ stub._calls = calls
55
+ for (const level of ['info', 'warn', 'error', 'debug', 'success'] as const) {
56
+ stub[level] = (msg: string) => calls.push({ level, msg })
57
+ }
58
+ return stub
59
+ }
60
+
61
+ // Helper: trivial credentials factory.
62
+ async function fakeCredentials(): Promise<credentials> {
63
+ return { installId: 'test-install', apiKey: 'test-key' } as unknown as credentials
64
+ }
65
+
66
+ describe('ConnectivityManager', () => {
67
+ beforeEach(() => {
68
+ // Use fake timers so probes can be advanced deterministically without
69
+ // waiting for real backoff windows (5s..5min).
70
+ vi.useFakeTimers()
71
+ })
72
+
73
+ afterEach(() => {
74
+ vi.useRealTimers()
75
+ vi.clearAllMocks()
76
+ })
77
+
78
+ describe('init / lifecycle', () => {
79
+ it('init() builds a client and fires onClientChanged exactly once', async () => {
80
+ const onClientChanged = vi.fn()
81
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
82
+ await m.init()
83
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
84
+ // The current client is whatever was passed to onClientChanged.
85
+ expect(m.getClient()).toBe(onClientChanged.mock.calls[0][0])
86
+ expect(m.getState()).toBe('healthy')
87
+ })
88
+
89
+ it('init() is idempotent — calling twice does not rebuild', async () => {
90
+ const onClientChanged = vi.fn()
91
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
92
+ await m.init()
93
+ await m.init()
94
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
95
+ })
96
+
97
+ it('shutdown() destroys the client, clears state, fires onClientChanged(undefined)', async () => {
98
+ const onClientChanged = vi.fn()
99
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
100
+ await m.init()
101
+ const client = m.getClient() as any
102
+ m.shutdown()
103
+ expect(client.destroy).toHaveBeenCalledTimes(1)
104
+ expect(m.getClient()).toBeUndefined()
105
+ // Last call to onClientChanged was with undefined.
106
+ const lastCall = onClientChanged.mock.calls.at(-1)
107
+ expect(lastCall?.[0]).toBeUndefined()
108
+ })
109
+ })
110
+
111
+ describe('execute() — happy path and routing', () => {
112
+ it('returns the result and stays healthy on success', async () => {
113
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
114
+ await m.init()
115
+ const result = await m.execute('test', async client => (client as any).details('lock-1'))
116
+ expect(result).toEqual({ ok: true })
117
+ expect(m.getState()).toBe('healthy')
118
+ })
119
+
120
+ it('returns undefined and does not invoke fn when offline', async () => {
121
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
122
+ await m.init()
123
+ ;(m as any).state = 'offline' // force state for this case
124
+ const fn = vi.fn()
125
+ const result = await m.execute('test', fn)
126
+ expect(result).toBeUndefined()
127
+ expect(fn).not.toHaveBeenCalled()
128
+ })
129
+
130
+ it('throws OfflineError when offline and throwOnOffline:true', async () => {
131
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
132
+ await m.init()
133
+ ;(m as any).state = 'offline'
134
+ await expect(
135
+ m.execute('user-action', async () => 'ok', { throwOnOffline: true }),
136
+ ).rejects.toBeInstanceOf(OfflineError)
137
+ })
138
+
139
+ it('returns undefined when init() has not been called', async () => {
140
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
141
+ const result = await m.execute('test', async () => 'never-called')
142
+ expect(result).toBeUndefined()
143
+ })
144
+ })
145
+
146
+ describe('execute() — error classification', () => {
147
+ it('rethrows transient (4xx like 422) errors without changing state', async () => {
148
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
149
+ await m.init()
150
+ const transientErr = Object.assign(new Error('Unprocessable Entity'), { statusCode: 422 })
151
+ await expect(
152
+ m.execute('test', async () => { throw transientErr }),
153
+ ).rejects.toBe(transientErr)
154
+ expect(m.getState()).toBe('healthy')
155
+ })
156
+
157
+ it('rethrows transient errors even with throwOnOffline:true', async () => {
158
+ // throwOnOffline only governs the offline branch; transient errors
159
+ // are always rethrown regardless of that option.
160
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
161
+ await m.init()
162
+ const err = Object.assign(new Error('rate limited'), { statusCode: 429 })
163
+ await expect(
164
+ m.execute('user', async () => { throw err }, { throwOnOffline: true }),
165
+ ).rejects.toBe(err)
166
+ })
167
+
168
+ it('rebuilds the client on 401 without changing connectivity state', async () => {
169
+ const onClientChanged = vi.fn()
170
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
171
+ await m.init()
172
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
173
+ const authErr = Object.assign(new Error('Unauthorized'), { statusCode: 401 })
174
+ const result = await m.execute('test', async () => { throw authErr })
175
+ expect(result).toBeUndefined()
176
+ // Allow the rebuild promise to resolve.
177
+ await vi.runAllTimersAsync()
178
+ expect(onClientChanged).toHaveBeenCalledTimes(2)
179
+ expect(m.getState()).toBe('healthy')
180
+ })
181
+
182
+ it('treats TimeoutError as network and transitions to degraded', async () => {
183
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
184
+ await m.init()
185
+ const { TimeoutError } = await import('august-yale')
186
+ const result = await m.execute('test', async () => { throw new TimeoutError('timed out') })
187
+ expect(result).toBeUndefined()
188
+ expect(m.getState()).toBe('degraded')
189
+ })
190
+
191
+ it('treats 502/503/504 as network errors', async () => {
192
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
193
+ await m.init()
194
+ const err = Object.assign(new Error('Bad Gateway'), { statusCode: 502 })
195
+ await m.execute('test', async () => { throw err })
196
+ expect(m.getState()).toBe('degraded')
197
+ })
198
+
199
+ it('treats ECONNRESET / ENETUNREACH / ENOTFOUND as network errors', async () => {
200
+ for (const code of ['ECONNRESET', 'ENETUNREACH', 'ENOTFOUND', 'ECONNREFUSED']) {
201
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
202
+ await m.init()
203
+ const err = Object.assign(new Error(code), { code })
204
+ await m.execute('test', async () => { throw err })
205
+ expect(m.getState(), `code=${code}`).toBe('degraded')
206
+ m.shutdown()
207
+ }
208
+ })
209
+ })
210
+
211
+ describe('probe-driven recovery', () => {
212
+ it('schedules a probe on first network failure and recovers when probe succeeds', async () => {
213
+ const onClientChanged = vi.fn()
214
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
215
+ await m.init()
216
+ const client = m.getClient() as any
217
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
218
+
219
+ // Network failure: degrades and schedules probe.
220
+ const { TimeoutError } = await import('august-yale')
221
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
222
+ expect(m.getState()).toBe('degraded')
223
+
224
+ // Probe will call client.locks() — ensure it succeeds.
225
+ client.locks.mockResolvedValue({})
226
+
227
+ // Advance time past the first backoff slot (~5s plus jitter).
228
+ await vi.advanceTimersByTimeAsync(7_000)
229
+
230
+ // Probe success → rebuild + healthy. onClientChanged fires again.
231
+ expect(m.getState()).toBe('healthy')
232
+ expect(onClientChanged).toHaveBeenCalledTimes(2)
233
+ })
234
+
235
+ it('escalates from degraded to offline when probe fails, with longer backoff', async () => {
236
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
237
+ await m.init()
238
+ const client = m.getClient() as any
239
+
240
+ const { TimeoutError } = await import('august-yale')
241
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
242
+ expect(m.getState()).toBe('degraded')
243
+
244
+ // Probe will fail (locks() rejects with timeout).
245
+ client.locks.mockImplementation(() => new Promise((_resolve, reject) => {
246
+ setTimeout(() => reject(new TimeoutError('still down')), 100)
247
+ }))
248
+
249
+ // Advance past first backoff + probe timeout.
250
+ await vi.advanceTimersByTimeAsync(15_000)
251
+ expect(m.getState()).toBe('offline')
252
+ })
253
+
254
+ it('execute() after entering offline returns undefined without calling fn', async () => {
255
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
256
+ await m.init()
257
+ ;(m as any).state = 'offline'
258
+ const fn = vi.fn()
259
+ const result = await m.execute('test', fn)
260
+ expect(result).toBeUndefined()
261
+ expect(fn).not.toHaveBeenCalled()
262
+ })
263
+
264
+ it('successful execute() call recovers state from degraded to healthy', async () => {
265
+ // This case covers the natural-recovery path: a network blip that
266
+ // resolves before the probe fires. The next real execute() call
267
+ // succeeds and the manager reports healthy.
268
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
269
+ await m.init()
270
+ const { TimeoutError } = await import('august-yale')
271
+ await m.execute('test', async () => { throw new TimeoutError('blip') })
272
+ expect(m.getState()).toBe('degraded')
273
+ // Next call succeeds.
274
+ const result = await m.execute('test', async () => 'recovered')
275
+ expect(result).toBe('recovered')
276
+ expect(m.getState()).toBe('healthy')
277
+ })
278
+ })
279
+
280
+ describe('onPubNubReconnect — fast recovery', () => {
281
+ it('triggers an immediate probe when degraded', async () => {
282
+ const onClientChanged = vi.fn()
283
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
284
+ await m.init()
285
+ const client = m.getClient() as any
286
+
287
+ const { TimeoutError } = await import('august-yale')
288
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
289
+ expect(m.getState()).toBe('degraded')
290
+
291
+ client.locks.mockResolvedValue({})
292
+ m.onPubNubReconnect()
293
+
294
+ // Probe runs synchronously inside onPubNubReconnect's microtask;
295
+ // need to flush promises but no timer advance required.
296
+ await vi.runAllTimersAsync()
297
+ expect(m.getState()).toBe('healthy')
298
+ // Initial build + rebuild after successful probe.
299
+ expect(onClientChanged).toHaveBeenCalledTimes(2)
300
+ })
301
+
302
+ it('is a no-op when already healthy', async () => {
303
+ const onClientChanged = vi.fn()
304
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
305
+ await m.init()
306
+ const callsAfterInit = onClientChanged.mock.calls.length
307
+ m.onPubNubReconnect()
308
+ await vi.runAllTimersAsync()
309
+ // No probe, no rebuild.
310
+ expect(onClientChanged).toHaveBeenCalledTimes(callsAfterInit)
311
+ expect(m.getState()).toBe('healthy')
312
+ })
313
+ })
314
+
315
+ describe('rebuild coalescing', () => {
316
+ it('two concurrent 401s produce one rebuild, not two', async () => {
317
+ const onClientChanged = vi.fn()
318
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
319
+ await m.init()
320
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
321
+
322
+ const authErr = Object.assign(new Error('Unauthorized'), { statusCode: 401 })
323
+
324
+ // Fire two execute()s concurrently — each will see a 401, each will
325
+ // request a rebuild. The manager must coalesce these into one rebuild.
326
+ await Promise.all([
327
+ m.execute('a', async () => { throw authErr }),
328
+ m.execute('b', async () => { throw authErr }),
329
+ ])
330
+ await vi.runAllTimersAsync()
331
+
332
+ // Initial build + exactly one rebuild = 2 total.
333
+ expect(onClientChanged).toHaveBeenCalledTimes(2)
334
+ })
335
+ })
336
+
337
+ describe('state-change listeners', () => {
338
+ it('onStateChange fires on transitions and not on no-op transitions', async () => {
339
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
340
+ await m.init()
341
+ const listener = vi.fn()
342
+ m.onStateChange(listener)
343
+
344
+ const { TimeoutError } = await import('august-yale')
345
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
346
+ // healthy -> degraded fires once.
347
+ expect(listener).toHaveBeenCalledWith('degraded', 'healthy')
348
+ expect(listener).toHaveBeenCalledTimes(1)
349
+
350
+ // A second network error while already degraded should NOT fire
351
+ // again (no transition).
352
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
353
+ expect(listener).toHaveBeenCalledTimes(1)
354
+ })
355
+
356
+ it('unsubscribe stops further notifications', async () => {
357
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
358
+ await m.init()
359
+ const listener = vi.fn()
360
+ const unsubscribe = m.onStateChange(listener)
361
+ unsubscribe()
362
+
363
+ const { TimeoutError } = await import('august-yale')
364
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
365
+ expect(listener).not.toHaveBeenCalled()
366
+ })
367
+
368
+ it('a throwing listener does not block other listeners', async () => {
369
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
370
+ await m.init()
371
+ const good = vi.fn()
372
+ m.onStateChange(() => { throw new Error('listener bug') })
373
+ m.onStateChange(good)
374
+
375
+ const { TimeoutError } = await import('august-yale')
376
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
377
+ expect(good).toHaveBeenCalledTimes(1)
378
+ })
379
+ })
380
+ })
@@ -336,13 +336,30 @@ describe('augustMatterPlatform', () => {
336
336
  })
337
337
 
338
338
  describe('fetchAndUpdateMatterLockState', () => {
339
+ // Helper: install a mock connectivity manager whose execute() runs
340
+ // the supplied fn against a mock August client. This mirrors the
341
+ // production path (platform.connectivity.execute(label, fn) ->
342
+ // fn(client)) without spinning up the real ConnectivityManager.
343
+ function installConnectivityMock(p: AugustMatterPlatform, client: any) {
344
+ ;(p as any).connectivity = {
345
+ getState: () => 'healthy',
346
+ execute: vi.fn(async (_label: string, fn: (c: any) => Promise<any>) => {
347
+ try {
348
+ return await fn(client)
349
+ } catch {
350
+ // execute() returns undefined on failure rather than
351
+ // rethrowing (unless throwOnOffline:true). Match that.
352
+ return undefined
353
+ }
354
+ }),
355
+ }
356
+ }
357
+
339
358
  it('should update lockState to 1 (Locked) when lock is locked', async () => {
340
359
  platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
341
360
 
342
361
  const mockDetailsResult = { LockStatus: { state: { locked: true, unlocked: false } } }
343
- platform.augustConfig = {
344
- details: vi.fn().mockResolvedValue(mockDetailsResult),
345
- } as any
362
+ installConnectivityMock(platform, { details: vi.fn().mockResolvedValue(mockDetailsResult) })
346
363
 
347
364
  const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
348
365
 
@@ -355,9 +372,7 @@ describe('augustMatterPlatform', () => {
355
372
  platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
356
373
 
357
374
  const mockDetailsResult = { LockStatus: { state: { locked: false, unlocked: true } } }
358
- platform.augustConfig = {
359
- details: vi.fn().mockResolvedValue(mockDetailsResult),
360
- } as any
375
+ installConnectivityMock(platform, { details: vi.fn().mockResolvedValue(mockDetailsResult) })
361
376
 
362
377
  const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
363
378
 
@@ -370,9 +385,7 @@ describe('augustMatterPlatform', () => {
370
385
  platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
371
386
 
372
387
  const mockDetailsResult = { LockStatus: { state: { locked: false, unlocked: false } } }
373
- platform.augustConfig = {
374
- details: vi.fn().mockResolvedValue(mockDetailsResult),
375
- } as any
388
+ installConnectivityMock(platform, { details: vi.fn().mockResolvedValue(mockDetailsResult) })
376
389
 
377
390
  const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
378
391
 
@@ -384,12 +397,7 @@ describe('augustMatterPlatform', () => {
384
397
  it('should not throw when August API details call fails', async () => {
385
398
  platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
386
399
 
387
- platform.augustConfig = {
388
- details: vi.fn().mockRejectedValue(new Error('API error')),
389
- } as any
390
-
391
- // Mock refreshAugustSession to do nothing so retry also fails gracefully
392
- vi.spyOn(platform as any, 'refreshAugustSession').mockResolvedValue(undefined)
400
+ installConnectivityMock(platform, { details: vi.fn().mockRejectedValue(new Error('API error')) })
393
401
 
394
402
  const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
395
403
 
@@ -400,9 +408,9 @@ describe('augustMatterPlatform', () => {
400
408
  expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled()
401
409
  })
402
410
 
403
- it('should not call updateAccessoryState when augustConfig is not initialized', async () => {
411
+ it('should not call updateAccessoryState when connectivity is not initialized', async () => {
404
412
  platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
405
- // augustConfig is not set (undefined / no details method)
413
+ // connectivity is not set
406
414
 
407
415
  const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
408
416
 
@@ -413,34 +421,18 @@ describe('augustMatterPlatform', () => {
413
421
  expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled()
414
422
  })
415
423
 
416
- it('should retry with session refresh when details call fails', async () => {
424
+ it('should not throw when execute returns undefined (offline / failed)', async () => {
425
+ // The previous "retry with session refresh" test asserted that the
426
+ // matter path called refreshAugustSession() after a 502. That
427
+ // explicit retry layer is gone — ConnectivityManager handles
428
+ // retry/probe/rebuild internally. From the matter path's
429
+ // perspective, the only observable is: execute() either returned
430
+ // a result, or it returned undefined.
417
431
  platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
418
-
419
- const mockDetailsResult = { LockStatus: { state: { locked: true, unlocked: false } } }
420
- const mockDetails = vi.fn()
421
- .mockRejectedValueOnce(new Error('502 Bad Gateway'))
422
- .mockResolvedValueOnce(mockDetailsResult)
423
-
424
- platform.augustConfig = { details: mockDetails } as any
425
-
426
- const refreshSpy = vi.spyOn(platform as any, 'refreshAugustSession').mockResolvedValue(undefined)
427
-
428
- const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
429
-
430
- await platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi)
431
-
432
- expect(refreshSpy).toHaveBeenCalledOnce()
433
- expect(mockMatterApi.updateAccessoryState).toHaveBeenCalledWith('test-uuid', 'doorLock', { lockState: 1 })
434
- })
435
-
436
- it('should not throw when both initial call and retry fail', async () => {
437
- platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
438
-
439
- platform.augustConfig = {
440
- details: vi.fn().mockRejectedValue(new Error('ETIMEDOUT')),
441
- } as any
442
-
443
- vi.spyOn(platform as any, 'refreshAugustSession').mockResolvedValue(undefined)
432
+ ;(platform as any).connectivity = {
433
+ getState: () => 'offline',
434
+ execute: vi.fn().mockResolvedValue(undefined),
435
+ }
444
436
 
445
437
  const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
446
438