@homebridge-plugins/homebridge-august 3.4.0 → 3.4.2-beta.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.
@@ -1,313 +0,0 @@
1
- /* Copyright(C) 2026, homebridge-plugins (https://github.com/homebridge-plugins). All rights reserved.
2
- *
3
- * platform.polling.test.ts: tests for AugustPlatform.startPolling().
4
- *
5
- * The platform-level serial poller is what turns the ConnectivityManager
6
- * state machine into observable behavior. Critical properties:
7
- *
8
- * - SERIAL iteration so a network outage produces ONE timeout per
9
- * cycle, not N (one per registered lock). This is the behavior
10
- * change that fixes the log-spam cascade after router restarts.
11
- *
12
- * - Short-circuit on the first failure in a cycle. Once execute()
13
- * returns undefined, the rest of the locks in the cycle are not
14
- * polled — the network is clearly bad and there's no value in
15
- * hammering N more endpoints.
16
- *
17
- * - Skip entirely when the manager reports 'offline'. The manager's
18
- * probe schedule (with backoff + jitter) drives recovery — the
19
- * poll interval should not.
20
- *
21
- * - Skip locks mid-push (lockUpdateInProgress) so a stale poll result
22
- * doesn't race the user's lock/unlock command.
23
- *
24
- * - Idempotent startPolling — calling twice does not stack timers.
25
- *
26
- * - No-op when platformRefreshRate === 0.
27
- */
28
- import type { API, Logging } from 'homebridge'
29
-
30
- import type { AugustPlatformConfig } from '../src/settings.js'
31
-
32
- import { readFileSync } from 'node:fs'
33
-
34
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
35
-
36
- import { AugustPlatform } from '../src/Platform.HAP.js'
37
-
38
- // Same mock conventions as the rest of the suite.
39
- vi.mock('august-yale', () => {
40
- // eslint-disable-next-line prefer-arrow-callback
41
- const MockAugust = vi.fn().mockImplementation(function () {
42
- return { destroy: vi.fn(), end: vi.fn(), details: vi.fn(), locks: vi.fn() }
43
- })
44
- const MockConstructor = MockAugust as any
45
- MockConstructor.details = vi.fn()
46
- MockConstructor.authorize = vi.fn()
47
- MockConstructor.validate = vi.fn()
48
- return { default: MockConstructor }
49
- })
50
-
51
- vi.mock('node:fs', () => ({
52
- readFileSync: vi.fn(),
53
- writeFileSync: vi.fn(),
54
- }))
55
-
56
- // Helper: install a stub connectivity manager on the platform whose
57
- // execute() runs the supplied fn against a fake "client" passed through
58
- // from the test. Returns the underlying mock execute fn for assertions.
59
- function installConnectivity(
60
- platform: AugustPlatform,
61
- client: any,
62
- state: 'healthy' | 'degraded' | 'offline' | 'recovering' = 'healthy',
63
- ) {
64
- const execute = vi.fn(async (_label: string, fn: (c: any) => Promise<any>) => {
65
- try {
66
- return await fn(client)
67
- } catch {
68
- return undefined
69
- }
70
- })
71
- ;(platform as any).connectivity = {
72
- getState: () => state,
73
- execute,
74
- }
75
- return execute
76
- }
77
-
78
- // Helper: register N stub LockMechanism objects on the platform. Each
79
- // has its own lockId and a vi.fn() applyRefresh. Returns the array of
80
- // stubs in registration order.
81
- function registerLocks(platform: AugustPlatform, count: number) {
82
- const locks: any[] = []
83
- for (let i = 0; i < count; i++) {
84
- const lock = {
85
- device: { lockId: `lock-${i}` },
86
- accessory: { displayName: `Lock ${i}` },
87
- lockUpdateInProgress: false,
88
- applyRefresh: vi.fn().mockResolvedValue(undefined),
89
- }
90
- ;(platform as any).lockMechanisms.set(lock.device.lockId, lock)
91
- locks.push(lock)
92
- }
93
- return locks
94
- }
95
-
96
- describe('augustPlatform.startPolling', () => {
97
- let platform: AugustPlatform
98
- let mockApi: API
99
- let mockLog: Logging
100
- let mockConfig: AugustPlatformConfig
101
-
102
- beforeEach(() => {
103
- vi.clearAllMocks()
104
- vi.useFakeTimers()
105
-
106
- // Quiet the package.json read in the platform constructor.
107
- vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ version: '1.0.0-test' }))
108
-
109
- mockApi = {
110
- hap: { uuid: { generate: vi.fn(() => 'mock-uuid') } },
111
- user: { configPath: vi.fn(() => '/mock/config.json') },
112
- on: vi.fn(),
113
- registerPlatformAccessories: vi.fn(),
114
- updatePlatformAccessories: vi.fn(),
115
- unregisterPlatformAccessories: vi.fn(),
116
- publishExternalAccessories: vi.fn(),
117
- } as unknown as API
118
-
119
- mockLog = {
120
- info: vi.fn(),
121
- warn: vi.fn(),
122
- error: vi.fn(),
123
- debug: vi.fn(),
124
- success: vi.fn(),
125
- } as any
126
-
127
- mockConfig = {
128
- platform: 'August',
129
- name: 'Test August',
130
- credentials: {
131
- augustId: 'test@example.com',
132
- password: 'testpassword',
133
- countryCode: 'US',
134
- installId: 'test-install-id',
135
- isValidated: true,
136
- },
137
- options: { logging: 'debug' },
138
- }
139
-
140
- platform = new AugustPlatform(mockLog, mockConfig, mockApi)
141
- // Force a deterministic poll interval (1s) so tests can advance
142
- // time precisely.
143
- ;(platform as any).platformRefreshRate = 1
144
- })
145
-
146
- afterEach(() => {
147
- platform.stopPolling()
148
- vi.useRealTimers()
149
- })
150
-
151
- describe('cycle iteration', () => {
152
- it('iterates locks serially in registration order, calling applyRefresh on each', async () => {
153
- const client = {
154
- details: vi.fn().mockImplementation(async (lockId: string) => ({ lockId })),
155
- }
156
- installConnectivity(platform, client)
157
- const locks = registerLocks(platform, 3)
158
-
159
- platform.startPolling()
160
- // Advance to the first tick.
161
- await vi.advanceTimersByTimeAsync(1_000)
162
-
163
- // All three locks polled.
164
- expect(client.details).toHaveBeenCalledTimes(3)
165
- expect(client.details).toHaveBeenNthCalledWith(1, 'lock-0')
166
- expect(client.details).toHaveBeenNthCalledWith(2, 'lock-1')
167
- expect(client.details).toHaveBeenNthCalledWith(3, 'lock-2')
168
- // applyRefresh fired for each.
169
- expect(locks[0].applyRefresh).toHaveBeenCalledTimes(1)
170
- expect(locks[1].applyRefresh).toHaveBeenCalledTimes(1)
171
- expect(locks[2].applyRefresh).toHaveBeenCalledTimes(1)
172
- })
173
-
174
- it('short-circuits the rest of the cycle when execute returns undefined', async () => {
175
- // First lock fails (execute returns undefined), the next two should
176
- // NOT be polled in this cycle.
177
- const client = {
178
- details: vi.fn().mockImplementation(async (lockId: string) => {
179
- if (lockId === 'lock-0') {
180
- throw new Error('network down')
181
- }
182
- return { lockId }
183
- }),
184
- }
185
- installConnectivity(platform, client)
186
- const locks = registerLocks(platform, 3)
187
-
188
- platform.startPolling()
189
- await vi.advanceTimersByTimeAsync(1_000)
190
-
191
- // Only the first lock's details() was called; the other two were
192
- // skipped. Note: execute() is what returns undefined; the inner
193
- // details() function only ran for lock-0.
194
- expect(client.details).toHaveBeenCalledTimes(1)
195
- expect(locks[0].applyRefresh).not.toHaveBeenCalled()
196
- expect(locks[1].applyRefresh).not.toHaveBeenCalled()
197
- expect(locks[2].applyRefresh).not.toHaveBeenCalled()
198
- })
199
-
200
- it('skips entire cycle when connectivity is offline', async () => {
201
- const client = { details: vi.fn() }
202
- installConnectivity(platform, client, 'offline')
203
- const locks = registerLocks(platform, 3)
204
-
205
- platform.startPolling()
206
- await vi.advanceTimersByTimeAsync(1_000)
207
-
208
- expect(client.details).not.toHaveBeenCalled()
209
- for (const lock of locks) {
210
- expect(lock.applyRefresh).not.toHaveBeenCalled()
211
- }
212
- })
213
-
214
- it('skips locks with lockUpdateInProgress=true but continues to the next', async () => {
215
- const client = {
216
- details: vi.fn().mockImplementation(async (lockId: string) => ({ lockId })),
217
- }
218
- installConnectivity(platform, client)
219
- const locks = registerLocks(platform, 3)
220
- // Middle lock is mid-push.
221
- locks[1].lockUpdateInProgress = true
222
-
223
- platform.startPolling()
224
- await vi.advanceTimersByTimeAsync(1_000)
225
-
226
- expect(client.details).toHaveBeenCalledTimes(2)
227
- expect(client.details).toHaveBeenNthCalledWith(1, 'lock-0')
228
- expect(client.details).toHaveBeenNthCalledWith(2, 'lock-2')
229
- expect(locks[0].applyRefresh).toHaveBeenCalledTimes(1)
230
- expect(locks[1].applyRefresh).not.toHaveBeenCalled()
231
- expect(locks[2].applyRefresh).toHaveBeenCalledTimes(1)
232
- })
233
-
234
- it('continues polling on subsequent ticks', async () => {
235
- const client = {
236
- details: vi.fn().mockImplementation(async (lockId: string) => ({ lockId })),
237
- }
238
- installConnectivity(platform, client)
239
- registerLocks(platform, 1)
240
-
241
- platform.startPolling()
242
- await vi.advanceTimersByTimeAsync(1_000)
243
- await vi.advanceTimersByTimeAsync(1_000)
244
- await vi.advanceTimersByTimeAsync(1_000)
245
-
246
- // 3 ticks × 1 lock = 3 calls.
247
- expect(client.details).toHaveBeenCalledTimes(3)
248
- })
249
-
250
- it('keeps the loop alive even if applyRefresh throws synchronously', async () => {
251
- const client = {
252
- details: vi.fn().mockImplementation(async (lockId: string) => ({ lockId })),
253
- }
254
- installConnectivity(platform, client)
255
- const locks = registerLocks(platform, 1)
256
- // applyRefresh throws on the first call but resolves on the second.
257
- locks[0].applyRefresh
258
- .mockRejectedValueOnce(new Error('boom'))
259
- .mockResolvedValueOnce(undefined)
260
-
261
- platform.startPolling()
262
- await vi.advanceTimersByTimeAsync(1_000)
263
- await vi.advanceTimersByTimeAsync(1_000)
264
-
265
- // Both ticks fired.
266
- expect(locks[0].applyRefresh).toHaveBeenCalledTimes(2)
267
- })
268
- })
269
-
270
- describe('lifecycle', () => {
271
- it('startPolling is idempotent — calling twice does not stack timers', async () => {
272
- const client = { details: vi.fn().mockResolvedValue({}) }
273
- installConnectivity(platform, client)
274
- registerLocks(platform, 1)
275
-
276
- platform.startPolling()
277
- platform.startPolling() // second call should be a no-op
278
-
279
- await vi.advanceTimersByTimeAsync(1_000)
280
-
281
- // If timers were stacked we'd see 2 calls per tick.
282
- expect(client.details).toHaveBeenCalledTimes(1)
283
- })
284
-
285
- it('is a no-op when platformRefreshRate is 0', async () => {
286
- const client = { details: vi.fn() }
287
- installConnectivity(platform, client)
288
- registerLocks(platform, 1)
289
- ;(platform as any).platformRefreshRate = 0
290
-
291
- platform.startPolling()
292
- await vi.advanceTimersByTimeAsync(60_000)
293
-
294
- expect(client.details).not.toHaveBeenCalled()
295
- })
296
-
297
- it('stopPolling clears the timer', async () => {
298
- const client = { details: vi.fn().mockResolvedValue({}) }
299
- installConnectivity(platform, client)
300
- registerLocks(platform, 1)
301
-
302
- platform.startPolling()
303
- await vi.advanceTimersByTimeAsync(1_000)
304
- expect(client.details).toHaveBeenCalledTimes(1)
305
-
306
- platform.stopPolling()
307
- await vi.advanceTimersByTimeAsync(5_000)
308
-
309
- // No additional calls after stopPolling.
310
- expect(client.details).toHaveBeenCalledTimes(1)
311
- })
312
- })
313
- })