@homebridge-plugins/homebridge-august 3.1.3 → 3.2.0

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,449 @@
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
+ // - The full exception hierarchy that classify() now uses for routing:
21
+ // NetworkError, TimeoutError (subclass), AbortedError, InvalidAuth.
22
+ vi.mock('august-yale', () => {
23
+ class YaleApiError extends Error {
24
+ public originalError?: Error
25
+ constructor(message?: string, originalError?: Error) {
26
+ super(message)
27
+ this.name = 'YaleApiError'
28
+ this.originalError = originalError
29
+ }
30
+ }
31
+ class NetworkError extends YaleApiError {
32
+ public code?: string
33
+ constructor(message?: string, originalError?: Error, code?: string) {
34
+ super(message, originalError)
35
+ this.name = 'NetworkError'
36
+ this.code = code
37
+ }
38
+ }
39
+ class TimeoutError extends NetworkError {
40
+ constructor(message?: string, originalError?: Error, code?: string) {
41
+ super(message, originalError, code)
42
+ this.name = 'TimeoutError'
43
+ }
44
+ }
45
+ class AbortedError extends YaleApiError {
46
+ public code?: string
47
+ constructor(message?: string, originalError?: Error, code?: string) {
48
+ super(message, originalError)
49
+ this.name = 'AbortedError'
50
+ this.code = code
51
+ }
52
+ }
53
+ class InvalidAuth extends YaleApiError {
54
+ constructor(message?: string, originalError?: Error) {
55
+ super(message, originalError)
56
+ this.name = 'InvalidAuth'
57
+ }
58
+ }
59
+ // Each new August() returns a unique object so tests can assert which
60
+ // client is being used (initial vs. rebuilt).
61
+ let nextId = 0
62
+ // eslint-disable-next-line prefer-arrow-callback
63
+ const MockAugust = vi.fn().mockImplementation(function () {
64
+ const id = ++nextId
65
+ return {
66
+ _id: id,
67
+ details: vi.fn().mockResolvedValue({ ok: true }),
68
+ locks: vi.fn().mockResolvedValue({}),
69
+ lock: vi.fn().mockResolvedValue(undefined),
70
+ unlock: vi.fn().mockResolvedValue(undefined),
71
+ destroy: vi.fn(),
72
+ end: vi.fn(),
73
+ }
74
+ })
75
+ return {
76
+ default: MockAugust,
77
+ YaleApiError,
78
+ NetworkError,
79
+ TimeoutError,
80
+ AbortedError,
81
+ InvalidAuth,
82
+ }
83
+ })
84
+
85
+ // Helper: build a stub Logging object that records calls.
86
+ function makeLog(): Logging & { _calls: { level: string, msg: string }[] } {
87
+ const calls: { level: string, msg: string }[] = []
88
+ const stub = ((..._args: any[]) => {}) as any
89
+ stub._calls = calls
90
+ for (const level of ['info', 'warn', 'error', 'debug', 'success'] as const) {
91
+ stub[level] = (msg: string) => calls.push({ level, msg })
92
+ }
93
+ return stub
94
+ }
95
+
96
+ // Helper: trivial credentials factory.
97
+ async function fakeCredentials(): Promise<credentials> {
98
+ return { installId: 'test-install', apiKey: 'test-key' } as unknown as credentials
99
+ }
100
+
101
+ describe('ConnectivityManager', () => {
102
+ beforeEach(() => {
103
+ // Use fake timers so probes can be advanced deterministically without
104
+ // waiting for real backoff windows (5s..5min).
105
+ vi.useFakeTimers()
106
+ })
107
+
108
+ afterEach(() => {
109
+ vi.useRealTimers()
110
+ vi.clearAllMocks()
111
+ })
112
+
113
+ describe('init / lifecycle', () => {
114
+ it('init() builds a client and fires onClientChanged exactly once', async () => {
115
+ const onClientChanged = vi.fn()
116
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
117
+ await m.init()
118
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
119
+ // The current client is whatever was passed to onClientChanged.
120
+ expect(m.getClient()).toBe(onClientChanged.mock.calls[0][0])
121
+ expect(m.getState()).toBe('healthy')
122
+ })
123
+
124
+ it('init() is idempotent — calling twice does not rebuild', async () => {
125
+ const onClientChanged = vi.fn()
126
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
127
+ await m.init()
128
+ await m.init()
129
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
130
+ })
131
+
132
+ it('shutdown() destroys the client, clears state, fires onClientChanged(undefined)', async () => {
133
+ const onClientChanged = vi.fn()
134
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
135
+ await m.init()
136
+ const client = m.getClient() as any
137
+ m.shutdown()
138
+ expect(client.destroy).toHaveBeenCalledTimes(1)
139
+ expect(m.getClient()).toBeUndefined()
140
+ // Last call to onClientChanged was with undefined.
141
+ const lastCall = onClientChanged.mock.calls.at(-1)
142
+ expect(lastCall?.[0]).toBeUndefined()
143
+ })
144
+ })
145
+
146
+ describe('execute() — happy path and routing', () => {
147
+ it('returns the result and stays healthy on success', async () => {
148
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
149
+ await m.init()
150
+ const result = await m.execute('test', async client => (client as any).details('lock-1'))
151
+ expect(result).toEqual({ ok: true })
152
+ expect(m.getState()).toBe('healthy')
153
+ })
154
+
155
+ it('returns undefined and does not invoke fn when offline', async () => {
156
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
157
+ await m.init()
158
+ ;(m as any).state = 'offline' // force state for this case
159
+ const fn = vi.fn()
160
+ const result = await m.execute('test', fn)
161
+ expect(result).toBeUndefined()
162
+ expect(fn).not.toHaveBeenCalled()
163
+ })
164
+
165
+ it('throws OfflineError when offline and throwOnOffline:true', async () => {
166
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
167
+ await m.init()
168
+ ;(m as any).state = 'offline'
169
+ await expect(
170
+ m.execute('user-action', async () => 'ok', { throwOnOffline: true }),
171
+ ).rejects.toBeInstanceOf(OfflineError)
172
+ })
173
+
174
+ it('returns undefined when init() has not been called', async () => {
175
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
176
+ const result = await m.execute('test', async () => 'never-called')
177
+ expect(result).toBeUndefined()
178
+ })
179
+ })
180
+
181
+ describe('execute() — error classification', () => {
182
+ it('rethrows transient (4xx like 422) errors without changing state', async () => {
183
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
184
+ await m.init()
185
+ const transientErr = Object.assign(new Error('Unprocessable Entity'), { statusCode: 422 })
186
+ await expect(
187
+ m.execute('test', async () => { throw transientErr }),
188
+ ).rejects.toBe(transientErr)
189
+ expect(m.getState()).toBe('healthy')
190
+ })
191
+
192
+ it('rethrows transient errors even with throwOnOffline:true', async () => {
193
+ // throwOnOffline only governs the offline branch; transient errors
194
+ // are always rethrown regardless of that option.
195
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
196
+ await m.init()
197
+ const err = Object.assign(new Error('rate limited'), { statusCode: 429 })
198
+ await expect(
199
+ m.execute('user', async () => { throw err }, { throwOnOffline: true }),
200
+ ).rejects.toBe(err)
201
+ })
202
+
203
+ it('rebuilds the client on 401 without changing connectivity state', async () => {
204
+ const onClientChanged = vi.fn()
205
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
206
+ await m.init()
207
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
208
+ const authErr = Object.assign(new Error('Unauthorized'), { statusCode: 401 })
209
+ const result = await m.execute('test', async () => { throw authErr })
210
+ expect(result).toBeUndefined()
211
+ // Allow the rebuild promise to resolve.
212
+ await vi.runAllTimersAsync()
213
+ expect(onClientChanged).toHaveBeenCalledTimes(2)
214
+ expect(m.getState()).toBe('healthy')
215
+ })
216
+
217
+ it('treats TimeoutError as network and transitions to degraded', async () => {
218
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
219
+ await m.init()
220
+ const { TimeoutError } = await import('august-yale')
221
+ const result = await m.execute('test', async () => { throw new TimeoutError('timed out') })
222
+ expect(result).toBeUndefined()
223
+ expect(m.getState()).toBe('degraded')
224
+ })
225
+
226
+ it('treats NetworkError as network and transitions to degraded', async () => {
227
+ // NetworkError covers all transport-level failures from august-yale —
228
+ // socket reset, DNS failure, malformed response, etc. — without us
229
+ // having to enumerate undici's 24 error classes.
230
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
231
+ await m.init()
232
+ const { NetworkError } = await import('august-yale')
233
+ const result = await m.execute('test', async () => { throw new NetworkError('socket hang up', undefined, 'ECONNRESET') })
234
+ expect(result).toBeUndefined()
235
+ expect(m.getState()).toBe('degraded')
236
+ })
237
+
238
+ it('treats InvalidAuth as auth and rebuilds without changing state', async () => {
239
+ const onClientChanged = vi.fn()
240
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
241
+ await m.init()
242
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
243
+ const { InvalidAuth } = await import('august-yale')
244
+ const result = await m.execute('test', async () => { throw new InvalidAuth('session expired') })
245
+ expect(result).toBeUndefined()
246
+ await vi.runAllTimersAsync()
247
+ expect(onClientChanged).toHaveBeenCalledTimes(2)
248
+ expect(m.getState()).toBe('healthy')
249
+ })
250
+
251
+ it('treats AbortedError as transient and does NOT change connectivity state', async () => {
252
+ // AbortedError reflects a request racing with our own teardown
253
+ // (e.g. inside rebuildClient). Treating it as 'network' would
254
+ // make the state machine fight itself: every rebuild produces
255
+ // ClientDestroyedError -> degraded -> probe -> rebuild ->
256
+ // ClientDestroyedError -> ...
257
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
258
+ await m.init()
259
+ const { AbortedError } = await import('august-yale')
260
+ const err = new AbortedError('client destroyed', undefined, 'UND_ERR_DESTROYED')
261
+ await expect(
262
+ m.execute('test', async () => { throw err }),
263
+ ).rejects.toBe(err)
264
+ // State stays healthy — no probe scheduled.
265
+ expect(m.getState()).toBe('healthy')
266
+ })
267
+
268
+ it('treats 502/503/504 as network errors (HTTP-level fallback)', async () => {
269
+ // Most transport failures are now wrapped upstream as NetworkError,
270
+ // but if august-yale ever surfaces a clean HTTP 5xx response, we
271
+ // still want to recognize 502/503/504 as network-class and probe.
272
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
273
+ await m.init()
274
+ const err = Object.assign(new Error('Bad Gateway'), { statusCode: 502 })
275
+ await m.execute('test', async () => { throw err })
276
+ expect(m.getState()).toBe('degraded')
277
+ })
278
+ })
279
+
280
+ describe('probe-driven recovery', () => {
281
+ it('schedules a probe on first network failure and recovers when probe succeeds', 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
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
287
+
288
+ // Network failure: degrades and schedules probe.
289
+ const { TimeoutError } = await import('august-yale')
290
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
291
+ expect(m.getState()).toBe('degraded')
292
+
293
+ // Probe will call client.locks() — ensure it succeeds.
294
+ client.locks.mockResolvedValue({})
295
+
296
+ // Advance time past the first backoff slot (~5s plus jitter).
297
+ await vi.advanceTimersByTimeAsync(7_000)
298
+
299
+ // Probe success → rebuild + healthy. onClientChanged fires again.
300
+ expect(m.getState()).toBe('healthy')
301
+ expect(onClientChanged).toHaveBeenCalledTimes(2)
302
+ })
303
+
304
+ it('escalates from degraded to offline when probe fails, with longer backoff', async () => {
305
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
306
+ await m.init()
307
+ const client = m.getClient() as any
308
+
309
+ const { TimeoutError } = await import('august-yale')
310
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
311
+ expect(m.getState()).toBe('degraded')
312
+
313
+ // Probe will fail (locks() rejects with timeout).
314
+ client.locks.mockImplementation(() => new Promise((_resolve, reject) => {
315
+ setTimeout(() => reject(new TimeoutError('still down')), 100)
316
+ }))
317
+
318
+ // Advance past first backoff + probe timeout.
319
+ await vi.advanceTimersByTimeAsync(15_000)
320
+ expect(m.getState()).toBe('offline')
321
+ })
322
+
323
+ it('execute() after entering offline returns undefined without calling fn', async () => {
324
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
325
+ await m.init()
326
+ ;(m as any).state = 'offline'
327
+ const fn = vi.fn()
328
+ const result = await m.execute('test', fn)
329
+ expect(result).toBeUndefined()
330
+ expect(fn).not.toHaveBeenCalled()
331
+ })
332
+
333
+ it('successful execute() call recovers state from degraded to healthy', async () => {
334
+ // This case covers the natural-recovery path: a network blip that
335
+ // resolves before the probe fires. The next real execute() call
336
+ // succeeds and the manager reports healthy.
337
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
338
+ await m.init()
339
+ const { TimeoutError } = await import('august-yale')
340
+ await m.execute('test', async () => { throw new TimeoutError('blip') })
341
+ expect(m.getState()).toBe('degraded')
342
+ // Next call succeeds.
343
+ const result = await m.execute('test', async () => 'recovered')
344
+ expect(result).toBe('recovered')
345
+ expect(m.getState()).toBe('healthy')
346
+ })
347
+ })
348
+
349
+ describe('onPubNubReconnect — fast recovery', () => {
350
+ it('triggers an immediate probe when degraded', async () => {
351
+ const onClientChanged = vi.fn()
352
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
353
+ await m.init()
354
+ const client = m.getClient() as any
355
+
356
+ const { TimeoutError } = await import('august-yale')
357
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
358
+ expect(m.getState()).toBe('degraded')
359
+
360
+ client.locks.mockResolvedValue({})
361
+ m.onPubNubReconnect()
362
+
363
+ // Probe runs synchronously inside onPubNubReconnect's microtask;
364
+ // need to flush promises but no timer advance required.
365
+ await vi.runAllTimersAsync()
366
+ expect(m.getState()).toBe('healthy')
367
+ // Initial build + rebuild after successful probe.
368
+ expect(onClientChanged).toHaveBeenCalledTimes(2)
369
+ })
370
+
371
+ it('is a no-op when already healthy', async () => {
372
+ const onClientChanged = vi.fn()
373
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
374
+ await m.init()
375
+ const callsAfterInit = onClientChanged.mock.calls.length
376
+ m.onPubNubReconnect()
377
+ await vi.runAllTimersAsync()
378
+ // No probe, no rebuild.
379
+ expect(onClientChanged).toHaveBeenCalledTimes(callsAfterInit)
380
+ expect(m.getState()).toBe('healthy')
381
+ })
382
+ })
383
+
384
+ describe('rebuild coalescing', () => {
385
+ it('two concurrent 401s produce one rebuild, not two', async () => {
386
+ const onClientChanged = vi.fn()
387
+ const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
388
+ await m.init()
389
+ expect(onClientChanged).toHaveBeenCalledTimes(1)
390
+
391
+ const authErr = Object.assign(new Error('Unauthorized'), { statusCode: 401 })
392
+
393
+ // Fire two execute()s concurrently — each will see a 401, each will
394
+ // request a rebuild. The manager must coalesce these into one rebuild.
395
+ await Promise.all([
396
+ m.execute('a', async () => { throw authErr }),
397
+ m.execute('b', async () => { throw authErr }),
398
+ ])
399
+ await vi.runAllTimersAsync()
400
+
401
+ // Initial build + exactly one rebuild = 2 total.
402
+ expect(onClientChanged).toHaveBeenCalledTimes(2)
403
+ })
404
+ })
405
+
406
+ describe('state-change listeners', () => {
407
+ it('onStateChange fires on transitions and not on no-op transitions', async () => {
408
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
409
+ await m.init()
410
+ const listener = vi.fn()
411
+ m.onStateChange(listener)
412
+
413
+ const { TimeoutError } = await import('august-yale')
414
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
415
+ // healthy -> degraded fires once.
416
+ expect(listener).toHaveBeenCalledWith('degraded', 'healthy')
417
+ expect(listener).toHaveBeenCalledTimes(1)
418
+
419
+ // A second network error while already degraded should NOT fire
420
+ // again (no transition).
421
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
422
+ expect(listener).toHaveBeenCalledTimes(1)
423
+ })
424
+
425
+ it('unsubscribe stops further notifications', async () => {
426
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
427
+ await m.init()
428
+ const listener = vi.fn()
429
+ const unsubscribe = m.onStateChange(listener)
430
+ unsubscribe()
431
+
432
+ const { TimeoutError } = await import('august-yale')
433
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
434
+ expect(listener).not.toHaveBeenCalled()
435
+ })
436
+
437
+ it('a throwing listener does not block other listeners', async () => {
438
+ const m = new ConnectivityManager(makeLog(), fakeCredentials)
439
+ await m.init()
440
+ const good = vi.fn()
441
+ m.onStateChange(() => { throw new Error('listener bug') })
442
+ m.onStateChange(good)
443
+
444
+ const { TimeoutError } = await import('august-yale')
445
+ await m.execute('test', async () => { throw new TimeoutError('boom') })
446
+ expect(good).toHaveBeenCalledTimes(1)
447
+ })
448
+ })
449
+ })
@@ -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