@homebridge-plugins/homebridge-august 3.1.1 → 3.1.3

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,668 @@
1
+ /* Copyright(C) 2021-2024, donavanbecker (https://github.com/donavanbecker). All rights reserved.
2
+ *
3
+ * platform.matter.test.ts: homebridge-august Matter platform tests.
4
+ */
5
+ import type { API, Logging, MatterAccessory, PlatformAccessory } from 'homebridge'
6
+
7
+ import type { AugustPlatformConfig } from '../src/settings.js'
8
+
9
+ import { readFileSync } from 'node:fs'
10
+
11
+ import August from 'august-yale'
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
+
14
+ import { AugustMatterPlatform } from '../src/platform.matter.js'
15
+
16
+ // Mock the august-yale module
17
+ vi.mock('august-yale', () => {
18
+ // eslint-disable-next-line prefer-arrow-callback
19
+ const MockAugust = vi.fn().mockImplementation(function () {
20
+ return {
21
+ end: vi.fn(),
22
+ lock: vi.fn().mockResolvedValue(undefined),
23
+ unlock: vi.fn().mockResolvedValue(undefined),
24
+ details: vi.fn(),
25
+ }
26
+ })
27
+
28
+ const MockConstructor = MockAugust as any
29
+ MockConstructor.details = vi.fn()
30
+ MockConstructor.authorize = vi.fn()
31
+ MockConstructor.validate = vi.fn()
32
+ MockConstructor.subscribe = vi.fn().mockResolvedValue(() => {}) // returns unsubscribe fn by default
33
+
34
+ return {
35
+ default: MockConstructor,
36
+ }
37
+ })
38
+
39
+ // Mock file system
40
+ vi.mock('node:fs', () => ({
41
+ readFileSync: vi.fn(),
42
+ writeFileSync: vi.fn(),
43
+ }))
44
+
45
+ describe('augustMatterPlatform', () => {
46
+ let platform: AugustMatterPlatform
47
+ let mockApi: API
48
+ let mockLog: Logging
49
+ let mockConfig: AugustPlatformConfig
50
+ let mockMatterApi: any
51
+
52
+ beforeEach(() => {
53
+ vi.clearAllMocks()
54
+
55
+ mockMatterApi = {
56
+ uuid: { generate: vi.fn((id: string) => `matter-uuid-${id}`) },
57
+ deviceTypes: { DoorLock: { deviceType: 11 } },
58
+ types: {
59
+ DoorLock: {
60
+ LockState: {
61
+ NotFullyLocked: 0,
62
+ Locked: 1,
63
+ Unlocked: 2,
64
+ },
65
+ },
66
+ },
67
+ registerPlatformAccessories: vi.fn().mockResolvedValue(undefined),
68
+ updatePlatformAccessories: vi.fn().mockResolvedValue(undefined),
69
+ unregisterPlatformAccessories: vi.fn().mockResolvedValue(undefined),
70
+ updateAccessoryState: vi.fn().mockResolvedValue(undefined),
71
+ getAccessoryState: vi.fn().mockResolvedValue(undefined),
72
+ }
73
+
74
+ mockApi = {
75
+ hap: {
76
+ uuid: { generate: vi.fn(() => 'hap-uuid') },
77
+ },
78
+ user: {
79
+ configPath: vi.fn(() => '/mock/config.json'),
80
+ },
81
+ on: vi.fn(),
82
+ registerPlatformAccessories: vi.fn(),
83
+ updatePlatformAccessories: vi.fn(),
84
+ unregisterPlatformAccessories: vi.fn(),
85
+ publishExternalAccessories: vi.fn(),
86
+ isMatterAvailable: vi.fn(() => true),
87
+ isMatterEnabled: vi.fn(() => true),
88
+ matter: mockMatterApi,
89
+ } as unknown as API
90
+
91
+ mockLog = {
92
+ info: vi.fn(),
93
+ warn: vi.fn(),
94
+ error: vi.fn(),
95
+ debug: vi.fn(),
96
+ success: vi.fn(),
97
+ } as any
98
+
99
+ mockConfig = {
100
+ platform: 'August',
101
+ name: 'Test August',
102
+ credentials: {
103
+ augustId: 'test@example.com',
104
+ password: 'testpassword',
105
+ countryCode: 'US',
106
+ installId: 'test-install-id',
107
+ isValidated: true,
108
+ },
109
+ options: {
110
+ logging: 'debug',
111
+ },
112
+ }
113
+
114
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
115
+ platforms: [mockConfig],
116
+ }))
117
+ })
118
+
119
+ afterEach(() => {
120
+ vi.restoreAllMocks()
121
+ })
122
+
123
+ describe('configureAccessory', () => {
124
+ it('should stage (not immediately unregister) cached HAP accessories for deferred cleanup', async () => {
125
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
126
+
127
+ const hapAccessory = {
128
+ UUID: 'hap-uuid-1',
129
+ displayName: 'Front Door',
130
+ } as unknown as PlatformAccessory
131
+
132
+ await platform.configureAccessory(hapAccessory)
133
+
134
+ // HAP accessory should NOT be unregistered immediately; deferred until Matter registration succeeds
135
+ expect(mockApi.unregisterPlatformAccessories).not.toHaveBeenCalled()
136
+ })
137
+
138
+ it('should unregister staged HAP accessory after successful Matter registration', async () => {
139
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
140
+
141
+ const device = {
142
+ lockId: 'lock-abc',
143
+ LockName: 'Front Door',
144
+ SerialNumber: 'SN123',
145
+ skuNumber: 'AUG-SL05',
146
+ currentFirmwareVersion: '1.2.3',
147
+ hide_device: false,
148
+ homeKitEnabled: false,
149
+ } as any
150
+
151
+ // Stage a HAP accessory with UUID matching what matterApi.uuid.generate will return for this lockId
152
+ const hapAccessory = {
153
+ UUID: 'matter-uuid-lock-abc',
154
+ displayName: 'Front Door',
155
+ } as unknown as PlatformAccessory
156
+ await platform.configureAccessory(hapAccessory)
157
+
158
+ await (platform as any).Lock(device)
159
+
160
+ // After Matter registration succeeds, the staged HAP accessory should be unregistered
161
+ expect(mockApi.unregisterPlatformAccessories).toHaveBeenCalledWith(
162
+ 'homebridge-august',
163
+ 'August',
164
+ [hapAccessory],
165
+ )
166
+ })
167
+
168
+ it('should not add HAP accessories to the accessories cache', async () => {
169
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
170
+
171
+ const hapAccessory = {
172
+ UUID: 'hap-uuid-2',
173
+ displayName: 'Back Door',
174
+ } as unknown as PlatformAccessory
175
+
176
+ await platform.configureAccessory(hapAccessory)
177
+
178
+ // accessories array from the HAP base class should remain empty
179
+ expect(platform.accessories).toHaveLength(0)
180
+ })
181
+ })
182
+
183
+ describe('configureMatterAccessory', () => {
184
+ it('should store Matter accessories in the matterAccessories map', () => {
185
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
186
+
187
+ const matterAccessory = {
188
+ UUID: 'matter-uuid-lock1',
189
+ displayName: 'Front Door',
190
+ deviceType: { deviceType: 11 },
191
+ serialNumber: 'ABC123',
192
+ manufacturer: 'August Home Inc.',
193
+ model: 'AUG-SL05',
194
+ context: { lockId: 'lock1' },
195
+ } as unknown as MatterAccessory
196
+
197
+ platform.configureMatterAccessory(matterAccessory)
198
+
199
+ expect(platform.matterAccessories.has('matter-uuid-lock1')).toBe(true)
200
+ expect(platform.matterAccessories.get('matter-uuid-lock1')).toBe(matterAccessory)
201
+ })
202
+ })
203
+
204
+ describe('lock (Matter accessory registration)', () => {
205
+ it('should register a new Matter DoorLock accessory when Matter API is available', async () => {
206
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
207
+
208
+ const device = {
209
+ lockId: 'lock-abc',
210
+ LockName: 'Front Door',
211
+ SerialNumber: 'SN123',
212
+ skuNumber: 'AUG-SL05',
213
+ currentFirmwareVersion: '1.2.3',
214
+ hide_device: false,
215
+ homeKitEnabled: false,
216
+ } as any
217
+
218
+ // Expose the protected Lock method for testing
219
+ await (platform as any).Lock(device)
220
+
221
+ expect(mockMatterApi.registerPlatformAccessories).toHaveBeenCalledOnce()
222
+ const [pluginName, platformName, accessories] = mockMatterApi.registerPlatformAccessories.mock.calls[0]
223
+ expect(pluginName).toBe('homebridge-august')
224
+ expect(platformName).toBe('August')
225
+ expect(accessories).toHaveLength(1)
226
+ expect(accessories[0].deviceType).toBe(mockMatterApi.deviceTypes.DoorLock)
227
+ expect(accessories[0].manufacturer).toBe('August Home Inc.')
228
+ expect(accessories[0].serialNumber).toBe('SN123')
229
+ })
230
+
231
+ it('should restore an existing cached Matter accessory', async () => {
232
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
233
+
234
+ const uuid = 'matter-uuid-lock-abc'
235
+ const cachedAccessory: MatterAccessory = {
236
+ UUID: uuid,
237
+ displayName: 'Front Door',
238
+ deviceType: { deviceType: 11 } as any,
239
+ serialNumber: 'SN123',
240
+ manufacturer: 'August Home Inc.',
241
+ model: 'AUG-SL05',
242
+ context: { lockId: 'lock-abc' },
243
+ }
244
+ platform.configureMatterAccessory(cachedAccessory)
245
+
246
+ const device = {
247
+ lockId: 'lock-abc',
248
+ LockName: 'Front Door',
249
+ SerialNumber: 'SN123',
250
+ skuNumber: 'AUG-SL05',
251
+ currentFirmwareVersion: '1.2.3',
252
+ hide_device: false,
253
+ homeKitEnabled: false,
254
+ } as any
255
+
256
+ await (platform as any).Lock(device)
257
+
258
+ // re-registers to update handlers
259
+ expect(mockMatterApi.registerPlatformAccessories).toHaveBeenCalledOnce()
260
+ // Should have logged the "Restoring" message
261
+ expect(mockLog.info).toHaveBeenCalledWith(expect.stringContaining('Restoring existing Matter accessory'))
262
+ })
263
+
264
+ it('should unregister stale Matter accessory when device is hidden', async () => {
265
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
266
+
267
+ const uuid = 'matter-uuid-hidden-lock'
268
+ mockMatterApi.uuid.generate.mockReturnValueOnce(uuid)
269
+
270
+ const cachedAccessory: MatterAccessory = {
271
+ UUID: uuid,
272
+ displayName: 'Hidden Lock',
273
+ deviceType: { deviceType: 11 } as any,
274
+ serialNumber: 'SN456',
275
+ manufacturer: 'August Home Inc.',
276
+ model: 'AUG-SL05',
277
+ context: { lockId: 'hidden-lock' },
278
+ }
279
+ platform.configureMatterAccessory(cachedAccessory)
280
+
281
+ const device = {
282
+ lockId: 'hidden-lock',
283
+ LockName: 'Hidden Lock',
284
+ SerialNumber: 'SN456',
285
+ hide_device: true, // hidden device
286
+ homeKitEnabled: false,
287
+ } as any
288
+
289
+ await (platform as any).Lock(device)
290
+
291
+ // Should unregister the stale accessory
292
+ expect(mockMatterApi.unregisterPlatformAccessories).toHaveBeenCalledWith(
293
+ 'homebridge-august',
294
+ 'August',
295
+ [cachedAccessory],
296
+ )
297
+ expect(platform.matterAccessories.has(uuid)).toBe(false)
298
+ expect(mockMatterApi.registerPlatformAccessories).not.toHaveBeenCalled()
299
+ })
300
+
301
+ it('should not register when Matter API is unavailable', async () => {
302
+ const apiWithoutMatter = {
303
+ ...mockApi,
304
+ matter: null as any,
305
+ }
306
+ platform = new AugustMatterPlatform(mockLog, mockConfig, apiWithoutMatter as any)
307
+
308
+ const device = {
309
+ lockId: 'lock-xyz',
310
+ LockName: 'Some Lock',
311
+ hide_device: false,
312
+ homeKitEnabled: false,
313
+ } as any
314
+
315
+ await (platform as any).Lock(device)
316
+
317
+ expect(mockMatterApi.registerPlatformAccessories).not.toHaveBeenCalled()
318
+ })
319
+
320
+ it('should set initial lockState to 0 (NotFullyLocked) until first poll resolves', async () => {
321
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
322
+
323
+ const device = {
324
+ lockId: 'lock-nullstate',
325
+ LockName: 'Null State Lock',
326
+ SerialNumber: 'SN789',
327
+ hide_device: false,
328
+ homeKitEnabled: false,
329
+ } as any
330
+
331
+ await (platform as any).Lock(device)
332
+
333
+ const registeredAccessory = mockMatterApi.registerPlatformAccessories.mock.calls[0][2][0] as MatterAccessory
334
+ expect(registeredAccessory.clusters?.doorLock?.lockState).toBe(0)
335
+ })
336
+ })
337
+
338
+ describe('fetchAndUpdateMatterLockState', () => {
339
+ it('should update lockState to 1 (Locked) when lock is locked', async () => {
340
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
341
+
342
+ const mockDetailsResult = { LockStatus: { state: { locked: true, unlocked: false } } }
343
+ platform.augustConfig = {
344
+ details: vi.fn().mockResolvedValue(mockDetailsResult),
345
+ } as any
346
+
347
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
348
+
349
+ await platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi)
350
+
351
+ expect(mockMatterApi.updateAccessoryState).toHaveBeenCalledWith('test-uuid', 'doorLock', { lockState: 1 })
352
+ })
353
+
354
+ it('should update lockState to 2 (Unlocked) when lock is unlocked', async () => {
355
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
356
+
357
+ const mockDetailsResult = { LockStatus: { state: { locked: false, unlocked: true } } }
358
+ platform.augustConfig = {
359
+ details: vi.fn().mockResolvedValue(mockDetailsResult),
360
+ } as any
361
+
362
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
363
+
364
+ await platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi)
365
+
366
+ expect(mockMatterApi.updateAccessoryState).toHaveBeenCalledWith('test-uuid', 'doorLock', { lockState: 2 })
367
+ })
368
+
369
+ it('should update lockState to 0 (NotFullyLocked) for ambiguous state', async () => {
370
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
371
+
372
+ const mockDetailsResult = { LockStatus: { state: { locked: false, unlocked: false } } }
373
+ platform.augustConfig = {
374
+ details: vi.fn().mockResolvedValue(mockDetailsResult),
375
+ } as any
376
+
377
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
378
+
379
+ await platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi)
380
+
381
+ expect(mockMatterApi.updateAccessoryState).toHaveBeenCalledWith('test-uuid', 'doorLock', { lockState: 0 })
382
+ })
383
+
384
+ it('should not throw when August API details call fails', async () => {
385
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
386
+
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)
393
+
394
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
395
+
396
+ await expect(
397
+ platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi),
398
+ ).resolves.toBeUndefined()
399
+
400
+ expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled()
401
+ })
402
+
403
+ it('should not call updateAccessoryState when augustConfig is not initialized', async () => {
404
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
405
+ // augustConfig is not set (undefined / no details method)
406
+
407
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
408
+
409
+ await expect(
410
+ platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi),
411
+ ).resolves.toBeUndefined()
412
+
413
+ expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled()
414
+ })
415
+
416
+ it('should retry with session refresh when details call fails', async () => {
417
+ 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)
444
+
445
+ const device = { lockId: 'lock-test', LockName: 'Test Lock' } as any
446
+
447
+ await expect(
448
+ platform.fetchAndUpdateMatterLockState(device, 'test-uuid', mockMatterApi),
449
+ ).resolves.toBeUndefined()
450
+
451
+ expect(mockMatterApi.updateAccessoryState).not.toHaveBeenCalled()
452
+ })
453
+ })
454
+
455
+ describe('pubNub subscription lifecycle (Matter path equivalent of PR #206)', () => {
456
+ it('should capture unsubscribe function from August.subscribe()', async () => {
457
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
458
+
459
+ const mockUnsubscribe = vi.fn()
460
+ vi.mocked(August.subscribe).mockResolvedValueOnce(mockUnsubscribe)
461
+
462
+ const device = {
463
+ lockId: 'lock-sub',
464
+ LockName: 'Sub Lock',
465
+ SerialNumber: 'SN001',
466
+ hide_device: false,
467
+ homeKitEnabled: false,
468
+ } as any
469
+
470
+ await (platform as any).Lock(device)
471
+
472
+ // The unsubscribe should be stored in the internal map
473
+ const uuid = mockMatterApi.uuid.generate('lock-sub')
474
+ const stored = (platform as any).matterPubNubUnsubscribes.get(uuid)
475
+ expect(stored).toBe(mockUnsubscribe)
476
+ })
477
+
478
+ it('should tear down PubNub subscription when device is hidden (stale accessory removal)', async () => {
479
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
480
+
481
+ const uuid = 'matter-uuid-hidden-sub'
482
+ mockMatterApi.uuid.generate.mockReturnValueOnce(uuid)
483
+
484
+ const mockUnsubscribe = vi.fn()
485
+ ;(platform as any).matterPubNubUnsubscribes.set(uuid, mockUnsubscribe)
486
+
487
+ const cachedAccessory: MatterAccessory = {
488
+ UUID: uuid,
489
+ displayName: 'Hidden Lock',
490
+ deviceType: { deviceType: 11 } as any,
491
+ serialNumber: 'SN002',
492
+ manufacturer: 'August Home Inc.',
493
+ model: 'AUG-SL05',
494
+ context: { lockId: 'hidden-sub' },
495
+ }
496
+ platform.configureMatterAccessory(cachedAccessory)
497
+
498
+ const device = {
499
+ lockId: 'hidden-sub',
500
+ LockName: 'Hidden Lock',
501
+ hide_device: true,
502
+ homeKitEnabled: false,
503
+ } as any
504
+
505
+ await (platform as any).Lock(device)
506
+
507
+ expect(mockUnsubscribe).toHaveBeenCalledOnce()
508
+ expect((platform as any).matterPubNubUnsubscribes.has(uuid)).toBe(false)
509
+ })
510
+ })
511
+
512
+ describe('polling subscription lifecycle', () => {
513
+ it('should track the polling subscription so it can be disposed', async () => {
514
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
515
+
516
+ const device = {
517
+ lockId: 'lock-poll-track',
518
+ LockName: 'Tracked Lock',
519
+ SerialNumber: 'SN100',
520
+ hide_device: false,
521
+ homeKitEnabled: false,
522
+ } as any
523
+
524
+ await (platform as any).Lock(device)
525
+
526
+ const uuid = mockMatterApi.uuid.generate('lock-poll-track')
527
+ const stored = (platform as any).matterPollingSubscriptions.get(uuid)
528
+ expect(stored).toBeDefined()
529
+ expect(typeof stored.unsubscribe).toBe('function')
530
+ })
531
+
532
+ it('should dispose previous polling subscription before starting a new one (idempotent)', async () => {
533
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
534
+
535
+ const device = {
536
+ lockId: 'lock-poll-idem',
537
+ LockName: 'Idempotent Lock',
538
+ SerialNumber: 'SN101',
539
+ hide_device: false,
540
+ homeKitEnabled: false,
541
+ } as any
542
+
543
+ await (platform as any).Lock(device)
544
+
545
+ const uuid = mockMatterApi.uuid.generate('lock-poll-idem')
546
+ const firstSub = (platform as any).matterPollingSubscriptions.get(uuid)
547
+ const firstUnsubSpy = vi.spyOn(firstSub, 'unsubscribe')
548
+
549
+ // Call polling again (simulating re-registration)
550
+ ;(platform as any).startMatterStatusPolling(device, uuid, mockMatterApi)
551
+
552
+ // Previous subscription should have been disposed
553
+ expect(firstUnsubSpy).toHaveBeenCalledOnce()
554
+ // A new subscription should be stored
555
+ const secondSub = (platform as any).matterPollingSubscriptions.get(uuid)
556
+ expect(secondSub).toBeDefined()
557
+ expect(secondSub).not.toBe(firstSub)
558
+ })
559
+
560
+ it('should dispose polling subscription when device is hidden', async () => {
561
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
562
+
563
+ const uuid = 'matter-uuid-hidden-poll'
564
+ mockMatterApi.uuid.generate.mockReturnValueOnce(uuid)
565
+
566
+ const fakeSubscription = { unsubscribe: vi.fn() }
567
+ ;(platform as any).matterPollingSubscriptions.set(uuid, fakeSubscription)
568
+
569
+ const device = {
570
+ lockId: 'hidden-poll',
571
+ LockName: 'Hidden Poll Lock',
572
+ hide_device: true,
573
+ homeKitEnabled: false,
574
+ } as any
575
+
576
+ await (platform as any).Lock(device)
577
+
578
+ expect(fakeSubscription.unsubscribe).toHaveBeenCalledOnce()
579
+ expect((platform as any).matterPollingSubscriptions.has(uuid)).toBe(false)
580
+ })
581
+
582
+ it('should not start polling when refreshRate is 0', async () => {
583
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
584
+ ;(platform as any).platformRefreshRate = 0
585
+
586
+ const device = {
587
+ lockId: 'lock-no-poll',
588
+ LockName: 'No Poll Lock',
589
+ SerialNumber: 'SN102',
590
+ hide_device: false,
591
+ homeKitEnabled: false,
592
+ } as any
593
+
594
+ await (platform as any).Lock(device)
595
+
596
+ const uuid = mockMatterApi.uuid.generate('lock-no-poll')
597
+ expect((platform as any).matterPollingSubscriptions.has(uuid)).toBe(false)
598
+ })
599
+ })
600
+
601
+ describe('pendingHapCleanup sweep after discovery', () => {
602
+ it('should unregister staged HAP accessories that were not handled during discovery', async () => {
603
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
604
+
605
+ // Stage two HAP accessories from a previous session
606
+ const orphanedHap1 = { UUID: 'hap-orphan-1', displayName: 'Removed Lock 1' } as unknown as PlatformAccessory
607
+ const orphanedHap2 = { UUID: 'hap-orphan-2', displayName: 'Removed Lock 2' } as unknown as PlatformAccessory
608
+ await platform.configureAccessory(orphanedHap1)
609
+ await platform.configureAccessory(orphanedHap2)
610
+
611
+ // Stub super.discoverDevices so it's a no-op (doesn't touch pendingHapCleanup)
612
+ const baseProto = Object.getPrototypeOf(Object.getPrototypeOf(platform))
613
+ vi.spyOn(baseProto, 'discoverDevices').mockResolvedValue(undefined as never)
614
+
615
+ await platform.discoverDevices()
616
+
617
+ // Both staged HAP accessories should have been unregistered since neither
618
+ // had a matching Lock() call during discovery
619
+ expect(mockApi.unregisterPlatformAccessories).toHaveBeenCalledWith(
620
+ 'homebridge-august',
621
+ 'August',
622
+ expect.arrayContaining([orphanedHap1, orphanedHap2]),
623
+ )
624
+ // Map should be empty after the sweep
625
+ expect((platform as any).pendingHapCleanup.size).toBe(0)
626
+ })
627
+
628
+ it('should leave handled HAP accessories alone (those already removed by Lock())', async () => {
629
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
630
+
631
+ // Simulate a successful Lock() having already cleaned its entry
632
+ // (pendingHapCleanup is empty)
633
+ const baseProto = Object.getPrototypeOf(Object.getPrototypeOf(platform))
634
+ vi.spyOn(baseProto, 'discoverDevices').mockResolvedValue(undefined as never)
635
+
636
+ await platform.discoverDevices()
637
+
638
+ // unregisterPlatformAccessories should not be called — nothing to sweep
639
+ expect(mockApi.unregisterPlatformAccessories).not.toHaveBeenCalled()
640
+ })
641
+
642
+ it('should only sweep the orphans, not the locks that were successfully migrated', async () => {
643
+ platform = new AugustMatterPlatform(mockLog, mockConfig, mockApi)
644
+
645
+ // One lock that will be migrated (has matching active lock in account)
646
+ // and one orphan that no longer exists in the account
647
+ const migrated = { UUID: 'hap-migrated', displayName: 'Still Active' } as unknown as PlatformAccessory
648
+ const orphan = { UUID: 'hap-orphan', displayName: 'Deleted From Account' } as unknown as PlatformAccessory
649
+ await platform.configureAccessory(migrated)
650
+ await platform.configureAccessory(orphan)
651
+
652
+ // Simulate a successful Lock() call for the 'migrated' UUID by removing
653
+ // that entry from pendingHapCleanup directly (mimicking what Lock() does).
654
+ const baseProto = Object.getPrototypeOf(Object.getPrototypeOf(platform))
655
+ vi.spyOn(baseProto, 'discoverDevices').mockImplementation(async () => {
656
+ (platform as any).pendingHapCleanup.delete('hap-migrated')
657
+ })
658
+
659
+ await platform.discoverDevices()
660
+
661
+ // Only the orphan should have been unregistered in the sweep
662
+ expect(mockApi.unregisterPlatformAccessories).toHaveBeenCalledOnce()
663
+ const swept = (mockApi.unregisterPlatformAccessories as any).mock.calls[0][2]
664
+ expect(swept).toContain(orphan)
665
+ expect(swept).not.toContain(migrated)
666
+ })
667
+ })
668
+ })