@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,592 +0,0 @@
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). Tests can also override
61
- // the methods of the NEXT client to be built by setting fields on
62
- // `nextClientOverride`. After one use the override is cleared.
63
- let nextId = 0
64
- // eslint-disable-next-line prefer-arrow-callback
65
- const MockAugust = vi.fn().mockImplementation(function () {
66
- const id = ++nextId
67
- return {
68
- _id: id,
69
- details: vi.fn().mockResolvedValue({ ok: true }),
70
- locks: vi.fn().mockResolvedValue({}),
71
- lock: vi.fn().mockResolvedValue(undefined),
72
- unlock: vi.fn().mockResolvedValue(undefined),
73
- destroy: vi.fn(),
74
- resetTransport: vi.fn(),
75
- end: vi.fn(),
76
- }
77
- })
78
- return {
79
- default: MockAugust,
80
- YaleApiError,
81
- NetworkError,
82
- TimeoutError,
83
- AbortedError,
84
- InvalidAuth,
85
- }
86
- })
87
-
88
- // Helper: build a stub Logging object that records calls.
89
- function makeLog(): Logging & { _calls: { level: string, msg: string }[] } {
90
- const calls: { level: string, msg: string }[] = []
91
- const stub = (() => {}) as any
92
- stub._calls = calls
93
- for (const level of ['info', 'warn', 'error', 'debug', 'success'] as const) {
94
- stub[level] = (msg: string) => calls.push({ level, msg })
95
- }
96
- return stub
97
- }
98
-
99
- // Helper: trivial credentials factory.
100
- async function fakeCredentials(): Promise<credentials> {
101
- return { installId: 'test-install', apiKey: 'test-key' } as unknown as credentials
102
- }
103
-
104
- describe('connectivityManager', () => {
105
- beforeEach(() => {
106
- // Use fake timers so probes can be advanced deterministically without
107
- // waiting for real backoff windows (5s..5min).
108
- vi.useFakeTimers()
109
- })
110
-
111
- afterEach(() => {
112
- vi.useRealTimers()
113
- vi.clearAllMocks()
114
- })
115
-
116
- describe('init / lifecycle', () => {
117
- it('init() builds a client and fires onClientChanged exactly once', async () => {
118
- const onClientChanged = vi.fn()
119
- const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
120
- await m.init()
121
- expect(onClientChanged).toHaveBeenCalledTimes(1)
122
- // The current client is whatever was passed to onClientChanged.
123
- expect(m.getClient()).toBe(onClientChanged.mock.calls[0][0])
124
- expect(m.getState()).toBe('healthy')
125
- })
126
-
127
- it('init() is idempotent — calling twice does not rebuild', async () => {
128
- const onClientChanged = vi.fn()
129
- const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
130
- await m.init()
131
- await m.init()
132
- expect(onClientChanged).toHaveBeenCalledTimes(1)
133
- })
134
-
135
- it('shutdown() destroys the client, clears state, fires onClientChanged(undefined)', async () => {
136
- const onClientChanged = vi.fn()
137
- const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
138
- await m.init()
139
- const client = m.getClient() as any
140
- m.shutdown()
141
- expect(client.destroy).toHaveBeenCalledTimes(1)
142
- expect(m.getClient()).toBeUndefined()
143
- // Last call to onClientChanged was with undefined.
144
- const lastCall = onClientChanged.mock.calls.at(-1)
145
- expect(lastCall?.[0]).toBeUndefined()
146
- })
147
- })
148
-
149
- describe('execute() — happy path and routing', () => {
150
- it('returns the result and stays healthy on success', async () => {
151
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
152
- await m.init()
153
- const result = await m.execute('test', async client => (client as any).details('lock-1'))
154
- expect(result).toEqual({ ok: true })
155
- expect(m.getState()).toBe('healthy')
156
- })
157
-
158
- it('returns undefined and does not invoke fn when offline', async () => {
159
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
160
- await m.init()
161
- ;(m as any).state = 'offline' // force state for this case
162
- const fn = vi.fn()
163
- const result = await m.execute('test', fn)
164
- expect(result).toBeUndefined()
165
- expect(fn).not.toHaveBeenCalled()
166
- })
167
-
168
- it('throws OfflineError when offline and throwOnOffline:true', async () => {
169
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
170
- await m.init()
171
- ;(m as any).state = 'offline'
172
- await expect(
173
- m.execute('user-action', async () => 'ok', { throwOnOffline: true }),
174
- ).rejects.toBeInstanceOf(OfflineError)
175
- })
176
-
177
- it('returns undefined when init() has not been called', async () => {
178
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
179
- const result = await m.execute('test', async () => 'never-called')
180
- expect(result).toBeUndefined()
181
- })
182
- })
183
-
184
- describe('execute() — error classification', () => {
185
- it('rethrows transient (4xx like 422) errors without changing state', async () => {
186
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
187
- await m.init()
188
- const transientErr = Object.assign(new Error('Unprocessable Entity'), { statusCode: 422 })
189
- await expect(
190
- m.execute('test', async () => Promise.reject(transientErr)),
191
- ).rejects.toBe(transientErr)
192
- expect(m.getState()).toBe('healthy')
193
- })
194
-
195
- it('rethrows transient errors even with throwOnOffline:true', async () => {
196
- // throwOnOffline only governs the offline branch; transient errors
197
- // are always rethrown regardless of that option.
198
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
199
- await m.init()
200
- const err = Object.assign(new Error('rate limited'), { statusCode: 429 })
201
- await expect(
202
- m.execute('user', async () => { throw err }, { throwOnOffline: true }),
203
- ).rejects.toBe(err)
204
- })
205
-
206
- it('rebuilds the client on 401 without changing connectivity state', async () => {
207
- const onClientChanged = vi.fn()
208
- const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
209
- await m.init()
210
- expect(onClientChanged).toHaveBeenCalledTimes(1)
211
- const authErr = Object.assign(new Error('Unauthorized'), { statusCode: 401 })
212
- const result = await m.execute('test', async () => Promise.reject(authErr))
213
- expect(result).toBeUndefined()
214
- // Allow the rebuild promise to resolve.
215
- await vi.runAllTimersAsync()
216
- expect(onClientChanged).toHaveBeenCalledTimes(2)
217
- expect(m.getState()).toBe('healthy')
218
- })
219
-
220
- it('treats TimeoutError as network and transitions to degraded', async () => {
221
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
222
- await m.init()
223
- const { TimeoutError } = await import('august-yale')
224
- const result = await m.execute('test', async () => Promise.reject(new TimeoutError('timed out')))
225
- expect(result).toBeUndefined()
226
- expect(m.getState()).toBe('degraded')
227
- })
228
-
229
- it('treats NetworkError as network and transitions to degraded', async () => {
230
- // NetworkError covers all transport-level failures from august-yale —
231
- // socket reset, DNS failure, malformed response, etc. — without us
232
- // having to enumerate undici's 24 error classes.
233
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
234
- await m.init()
235
- const { NetworkError } = await import('august-yale')
236
- const result = await m.execute('test', async () => Promise.reject(new NetworkError('socket hang up', undefined, 'ECONNRESET')))
237
- expect(result).toBeUndefined()
238
- expect(m.getState()).toBe('degraded')
239
- })
240
-
241
- it('treats InvalidAuth as auth and rebuilds without changing state', async () => {
242
- const onClientChanged = vi.fn()
243
- const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
244
- await m.init()
245
- expect(onClientChanged).toHaveBeenCalledTimes(1)
246
- const { InvalidAuth } = await import('august-yale')
247
- const result = await m.execute('test', async () => Promise.reject(new InvalidAuth('session expired')))
248
- expect(result).toBeUndefined()
249
- await vi.runAllTimersAsync()
250
- expect(onClientChanged).toHaveBeenCalledTimes(2)
251
- expect(m.getState()).toBe('healthy')
252
- })
253
-
254
- it('treats AbortedError as transient and does NOT change connectivity state', async () => {
255
- // AbortedError reflects a request racing with our own teardown
256
- // (e.g. inside rebuildClient). Treating it as 'network' would
257
- // make the state machine fight itself: every rebuild produces
258
- // ClientDestroyedError -> degraded -> probe -> rebuild ->
259
- // ClientDestroyedError -> ...
260
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
261
- await m.init()
262
- const { AbortedError } = await import('august-yale')
263
- const err = new AbortedError('client destroyed', undefined, 'UND_ERR_DESTROYED')
264
- await expect(
265
- m.execute('test', async () => Promise.reject(err)),
266
- ).rejects.toBe(err)
267
- // State stays healthy — no probe scheduled.
268
- expect(m.getState()).toBe('healthy')
269
- })
270
-
271
- it('treats 502/503/504 as network errors (HTTP-level fallback)', async () => {
272
- // Most transport failures are now wrapped upstream as NetworkError,
273
- // but if august-yale ever surfaces a clean HTTP 5xx response, we
274
- // still want to recognize 502/503/504 as network-class and probe.
275
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
276
- await m.init()
277
- const err = Object.assign(new Error('Bad Gateway'), { statusCode: 502 })
278
- await m.execute('test', async () => Promise.reject(err))
279
- expect(m.getState()).toBe('degraded')
280
- })
281
- })
282
-
283
- describe('probe-driven recovery', () => {
284
- it('schedules a probe on first network failure and recovers when probe succeeds', async () => {
285
- const onClientChanged = vi.fn()
286
- const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
287
- await m.init()
288
- expect(onClientChanged).toHaveBeenCalledTimes(1)
289
-
290
- // Network failure: degrades and schedules probe.
291
- const { TimeoutError } = await import('august-yale')
292
- await m.execute('test', async () => Promise.reject(new TimeoutError('boom')))
293
- expect(m.getState()).toBe('degraded')
294
-
295
- // Probe calls resetTransport() on the existing client and then its
296
- // .locks(). Default mock = locks resolves, so probe succeeds.
297
-
298
- // Advance time past the first backoff slot (~5s plus jitter).
299
- await vi.advanceTimersByTimeAsync(7_000)
300
-
301
- // Probe success → healthy. onClientChanged was called only once
302
- // (at init). The resetTransport-based design does NOT swap the
303
- // client instance on recovery; it just recycles its transport.
304
- expect(m.getState()).toBe('healthy')
305
- expect(onClientChanged).toHaveBeenCalledTimes(1)
306
- })
307
-
308
- it('escalates from degraded to offline when probe fails, with longer backoff', async () => {
309
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
310
- await m.init()
311
- const client = m.getClient() as any
312
-
313
- const { TimeoutError } = await import('august-yale')
314
-
315
- // Make the existing client's .locks() always fail. With the
316
- // resetTransport-based design, probes reuse this client, so a
317
- // single mockImplementation is sufficient for every probe attempt.
318
- client.locks = vi.fn().mockImplementation(() => new Promise((_resolve, reject) => {
319
- setTimeout(() => reject(new TimeoutError('still down')), 100)
320
- }))
321
-
322
- // Trigger degraded.
323
- await m.execute('test', async () => Promise.reject(new TimeoutError('boom')))
324
- expect(m.getState()).toBe('degraded')
325
-
326
- // Advance past first backoff + probe timeout.
327
- await vi.advanceTimersByTimeAsync(15_000)
328
- expect(m.getState()).toBe('offline')
329
- })
330
-
331
- it('execute() after entering offline returns undefined without calling fn', async () => {
332
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
333
- await m.init()
334
- ;(m as any).state = 'offline'
335
- const fn = vi.fn()
336
- const result = await m.execute('test', fn)
337
- expect(result).toBeUndefined()
338
- expect(fn).not.toHaveBeenCalled()
339
- })
340
-
341
- it('successful execute() call recovers state from degraded to healthy', async () => {
342
- // This case covers the natural-recovery path: a network blip that
343
- // resolves before the probe fires. The next real execute() call
344
- // succeeds and the manager reports healthy.
345
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
346
- await m.init()
347
- const { TimeoutError } = await import('august-yale')
348
- await m.execute('test', async () => Promise.reject(new TimeoutError('blip')))
349
- expect(m.getState()).toBe('degraded')
350
- // Next call succeeds.
351
- const result = await m.execute('test', async () => 'recovered')
352
- expect(result).toBe('recovered')
353
- expect(m.getState()).toBe('healthy')
354
- })
355
-
356
- it('probes call resetTransport() on the existing client (sheds stale sockets)', async () => {
357
- // Regression test for the production bug: the original
358
- // implementation probed against the existing client without
359
- // resetting its socket pool. The pool kept stale half-open sockets
360
- // from before the outage, probes failed on those sockets, system
361
- // stayed offline.
362
- //
363
- // The fix: probe still uses the existing client (so we keep auth
364
- // state), but resetTransport() is called first to throw away the
365
- // dispatcher and its stale connections. New transport, same auth.
366
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
367
- await m.init()
368
- const client = m.getClient() as any
369
-
370
- // Sanity: the mock client has a resetTransport method.
371
- expect(typeof client.resetTransport).toBe('function')
372
-
373
- // Trigger degraded.
374
- const { TimeoutError } = await import('august-yale')
375
- await m.execute('test', async () => Promise.reject(new TimeoutError('boom')))
376
- expect(m.getState()).toBe('degraded')
377
-
378
- // Advance past the first backoff: probe runs, default mock has
379
- // .locks() resolving, recovery completes.
380
- await vi.advanceTimersByTimeAsync(7_000)
381
- expect(m.getState()).toBe('healthy')
382
-
383
- // The probe must have called resetTransport on the existing
384
- // client. This is the structural assertion that locks in the fix.
385
- expect(client.resetTransport).toHaveBeenCalled()
386
- // The same client instance is still in place (we did NOT
387
- // construct a new client — this distinguishes the resetTransport
388
- // approach from the previous fresh-client approach).
389
- expect(m.getClient()).toBe(client)
390
- })
391
-
392
- it('failed probe leaves the existing client in place (no leak, no rotation)', async () => {
393
- // With resetTransport(), failed probes don't construct a temp
394
- // client, so there's nothing to leak. The existing client stays
395
- // the current client throughout the offline period — only its
396
- // transport gets recycled, not the August instance itself.
397
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
398
- await m.init()
399
- const client = m.getClient() as any
400
-
401
- const { TimeoutError } = await import('august-yale')
402
-
403
- // Make the client's .locks() always fail.
404
- client.locks = vi.fn().mockImplementation(() => new Promise((_, reject) =>
405
- setTimeout(() => reject(new TimeoutError('still down')), 100),
406
- ))
407
-
408
- // Trigger degraded.
409
- await m.execute('test', async () => Promise.reject(new TimeoutError('boom')))
410
- expect(m.getState()).toBe('degraded')
411
-
412
- // Advance past first probe → offline.
413
- await vi.advanceTimersByTimeAsync(15_000)
414
- expect(m.getState()).toBe('offline')
415
-
416
- // The existing client is still the current client; nothing got
417
- // destroyed or replaced.
418
- expect(m.getClient()).toBe(client)
419
- expect(client.destroy).not.toHaveBeenCalled()
420
- // resetTransport was called for each probe attempt.
421
- expect(client.resetTransport).toHaveBeenCalled()
422
- })
423
- })
424
-
425
- describe('onPubNubReconnect — fast recovery', () => {
426
- it('triggers an immediate probe when degraded', async () => {
427
- const onClientChanged = vi.fn()
428
- const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
429
- await m.init()
430
-
431
- const { TimeoutError } = await import('august-yale')
432
- await m.execute('test', async () => Promise.reject(new TimeoutError('boom')))
433
- expect(m.getState()).toBe('degraded')
434
-
435
- // PubNub reconnect → immediate probe. Default mock has .locks()
436
- // resolving, so the probe (which calls resetTransport on the
437
- // existing client and then locks()) succeeds.
438
- m.onPubNubReconnect()
439
-
440
- // Probe runs synchronously inside onPubNubReconnect's microtask;
441
- // need to flush promises but no timer advance required.
442
- await vi.runAllTimersAsync()
443
- expect(m.getState()).toBe('healthy')
444
- // onClientChanged was only called once (at init). Recovery via
445
- // resetTransport does not swap the client instance.
446
- expect(onClientChanged).toHaveBeenCalledTimes(1)
447
- })
448
-
449
- it('is a no-op when already healthy', async () => {
450
- const onClientChanged = vi.fn()
451
- const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
452
- await m.init()
453
- const callsAfterInit = onClientChanged.mock.calls.length
454
- m.onPubNubReconnect()
455
- await vi.runAllTimersAsync()
456
- // No probe, no rebuild.
457
- expect(onClientChanged).toHaveBeenCalledTimes(callsAfterInit)
458
- expect(m.getState()).toBe('healthy')
459
- })
460
- })
461
-
462
- describe('rebuild coalescing', () => {
463
- it('two concurrent 401s produce one rebuild, not two', async () => {
464
- const onClientChanged = vi.fn()
465
- const m = new ConnectivityManager(makeLog(), fakeCredentials, onClientChanged)
466
- await m.init()
467
- expect(onClientChanged).toHaveBeenCalledTimes(1)
468
-
469
- const authErr = Object.assign(new Error('Unauthorized'), { statusCode: 401 })
470
-
471
- // Fire two execute()s concurrently — each will see a 401, each will
472
- // request a rebuild. The manager must coalesce these into one rebuild.
473
- await Promise.all([
474
- m.execute('a', async () => Promise.reject(authErr)),
475
- m.execute('b', async () => Promise.reject(authErr)),
476
- ])
477
- await vi.runAllTimersAsync()
478
-
479
- // Initial build + exactly one rebuild = 2 total.
480
- expect(onClientChanged).toHaveBeenCalledTimes(2)
481
- })
482
- })
483
-
484
- describe('state-change listeners', () => {
485
- it('onStateChange fires on transitions and not on no-op transitions', async () => {
486
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
487
- await m.init()
488
- const listener = vi.fn()
489
- m.onStateChange(listener)
490
-
491
- const { TimeoutError } = await import('august-yale')
492
- await m.execute('test', async () => Promise.reject(new TimeoutError('boom')))
493
- // healthy -> degraded fires once.
494
- expect(listener).toHaveBeenCalledWith('degraded', 'healthy')
495
- expect(listener).toHaveBeenCalledTimes(1)
496
-
497
- // A second network error while already degraded should NOT fire
498
- // again (no transition).
499
- await m.execute('test', async () => Promise.reject(new TimeoutError('boom')))
500
- expect(listener).toHaveBeenCalledTimes(1)
501
- })
502
-
503
- it('unsubscribe stops further notifications', async () => {
504
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
505
- await m.init()
506
- const listener = vi.fn()
507
- const unsubscribe = m.onStateChange(listener)
508
- unsubscribe()
509
-
510
- const { TimeoutError } = await import('august-yale')
511
- await m.execute('test', async () => Promise.reject(new TimeoutError('boom')))
512
- expect(listener).not.toHaveBeenCalled()
513
- })
514
-
515
- it('a throwing listener does not block other listeners', async () => {
516
- const m = new ConnectivityManager(makeLog(), fakeCredentials)
517
- await m.init()
518
- const good = vi.fn()
519
- m.onStateChange(() => {
520
- throw new Error('listener bug')
521
- })
522
- m.onStateChange(good)
523
-
524
- const { TimeoutError } = await import('august-yale')
525
- await m.execute('test', async () => Promise.reject(new TimeoutError('boom')))
526
- expect(good).toHaveBeenCalledTimes(1)
527
- })
528
- })
529
-
530
- describe('offline heartbeat', () => {
531
- // Helper: drive the manager into offline state and capture the log.
532
- async function getOfflineWithLog() {
533
- const log = makeLog()
534
- const m = new ConnectivityManager(log, fakeCredentials)
535
- await m.init()
536
- const client = m.getClient() as any
537
-
538
- const { TimeoutError } = await import('august-yale')
539
-
540
- // Make every probe fail by making the existing client's .locks
541
- // always reject. With the resetTransport-based design, probes
542
- // reuse this client across attempts, so a single mockImplementation
543
- // covers every probe in the offline period.
544
- client.locks = vi.fn().mockImplementation(() => new Promise((_resolve, reject) =>
545
- setTimeout(() => reject(new TimeoutError('still down')), 100),
546
- ))
547
-
548
- // Trigger degraded.
549
- await m.execute('test', async () => Promise.reject(new TimeoutError('boom')))
550
-
551
- // Drive past first probe so we land in offline.
552
- await vi.advanceTimersByTimeAsync(15_000)
553
- return { m, log, client }
554
- }
555
-
556
- it('emits a heartbeat after one hour of being offline', async () => {
557
- const { log } = await getOfflineWithLog()
558
-
559
- // Less than an hour: no heartbeat yet.
560
- await vi.advanceTimersByTimeAsync(30 * 60_000)
561
- const beforeHour = log._calls.filter(c => c.msg.includes('still offline')).length
562
- expect(beforeHour).toBe(0)
563
-
564
- // Past one hour: heartbeat fires. Probes are still failing in the
565
- // background, so we filter for the heartbeat-specific message.
566
- await vi.advanceTimersByTimeAsync(35 * 60_000)
567
- const afterHour = log._calls.filter(c => c.msg.includes('still offline')).length
568
- expect(afterHour).toBeGreaterThan(0)
569
- // Heartbeat content includes attempt count and last failure.
570
- const heartbeatLine = log._calls.find(c => c.msg.includes('still offline'))
571
- expect(heartbeatLine?.msg).toMatch(/\d+ probe attempts?/)
572
- expect(heartbeatLine?.msg).toContain('last failure')
573
- })
574
-
575
- it('stops emitting heartbeats after recovery', async () => {
576
- const { m, log, client } = await getOfflineWithLog()
577
-
578
- // Allow recovery on the next probe by making .locks resolve again.
579
- client.locks = vi.fn().mockResolvedValue({})
580
-
581
- // Skip ahead enough for a probe to fire and succeed.
582
- await vi.advanceTimersByTimeAsync(60 * 1000)
583
- expect(m.getState()).toBe('healthy')
584
-
585
- const beforeIdle = log._calls.filter(c => c.msg.includes('still offline')).length
586
- // Advance two more hours — should NOT see new heartbeat lines.
587
- await vi.advanceTimersByTimeAsync(2 * 60 * 60_000)
588
- const afterIdle = log._calls.filter(c => c.msg.includes('still offline')).length
589
- expect(afterIdle).toBe(beforeIdle)
590
- })
591
- })
592
- })