@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.
- package/CHANGELOG.md +18 -0
- package/dist/connectivity-manager.d.ts +89 -0
- package/dist/connectivity-manager.d.ts.map +1 -0
- package/dist/connectivity-manager.js +283 -0
- package/dist/connectivity-manager.js.map +1 -0
- package/dist/devices/device.d.ts +0 -9
- package/dist/devices/device.d.ts.map +1 -1
- package/dist/devices/device.js +0 -32
- package/dist/devices/device.js.map +1 -1
- package/dist/devices/lock.d.ts +20 -1
- package/dist/devices/lock.d.ts.map +1 -1
- package/dist/devices/lock.js +133 -103
- package/dist/devices/lock.js.map +1 -1
- package/dist/package.json +3 -3
- package/dist/platform.d.ts +26 -19
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +101 -52
- package/dist/platform.js.map +1 -1
- package/dist/platform.matter.d.ts +6 -9
- package/dist/platform.matter.d.ts.map +1 -1
- package/dist/platform.matter.js +25 -31
- package/dist/platform.matter.js.map +1 -1
- package/docs/variables/default.html +1 -1
- package/package.json +3 -3
- package/test/connectivity-manager.test.ts +449 -0
- package/test/platform.matter.test.ts +36 -44
- package/test/platform.polling.test.ts +313 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
411
|
+
it('should not call updateAccessoryState when connectivity is not initialized', async () => {
|
|
404
412
|
platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
|
|
405
|
-
//
|
|
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
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
|