@dhis2/app-service-offline 3.17.0 → 3.17.1

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.
Files changed (37) hide show
  1. package/.gitignore +5 -0
  2. package/build/cjs/declarations.d.js +1 -0
  3. package/build/es/declarations.d.js +1 -0
  4. package/d2.config.js +9 -0
  5. package/jest.config.js +14 -0
  6. package/package.json +3 -3
  7. package/src/__tests__/integration.test.tsx +341 -0
  8. package/src/declarations.d.ts +1 -0
  9. package/src/index.ts +12 -0
  10. package/src/lib/__tests__/cacheable-section-state.test.tsx +45 -0
  11. package/src/lib/__tests__/clear-sensitive-caches.test.ts +182 -0
  12. package/src/lib/__tests__/network-status.test.tsx +496 -0
  13. package/src/lib/__tests__/offline-provider.test.tsx +116 -0
  14. package/src/lib/__tests__/use-cacheable-section.test.tsx +280 -0
  15. package/src/lib/__tests__/use-online-status-message.test.tsx +27 -0
  16. package/src/lib/cacheable-section-state.tsx +269 -0
  17. package/src/lib/cacheable-section.tsx +193 -0
  18. package/src/lib/clear-sensitive-caches.ts +92 -0
  19. package/src/lib/dhis2-connection-status/dev-debug-log.ts +20 -0
  20. package/src/lib/dhis2-connection-status/dhis2-connection-status.test.tsx +947 -0
  21. package/src/lib/dhis2-connection-status/dhis2-connection-status.tsx +241 -0
  22. package/src/lib/dhis2-connection-status/index.ts +4 -0
  23. package/src/lib/dhis2-connection-status/is-ping-available.test.ts +32 -0
  24. package/src/lib/dhis2-connection-status/is-ping-available.ts +31 -0
  25. package/src/lib/dhis2-connection-status/smart-interval.ts +206 -0
  26. package/src/lib/dhis2-connection-status/use-ping-query.ts +14 -0
  27. package/src/lib/global-state-service.tsx +110 -0
  28. package/src/lib/network-status.ts +80 -0
  29. package/src/lib/offline-interface.tsx +57 -0
  30. package/src/lib/offline-provider.tsx +43 -0
  31. package/src/lib/online-status-message.tsx +47 -0
  32. package/src/setupRTL.ts +1 -0
  33. package/src/types.ts +66 -0
  34. package/src/utils/__tests__/render-counter.test.tsx +45 -0
  35. package/src/utils/render-counter.tsx +22 -0
  36. package/src/utils/test-mocks.ts +47 -0
  37. package/tsconfig.json +10 -0
@@ -0,0 +1,947 @@
1
+ import { ConfigProvider } from '@dhis2/app-service-config'
2
+ import { renderHook, act } from '@testing-library/react'
3
+ import React from 'react'
4
+ import { mockOfflineInterface } from '../../utils/test-mocks'
5
+ import { OfflineProvider } from '../offline-provider'
6
+ import {
7
+ getLastConnectedKey,
8
+ useDhis2ConnectionStatus,
9
+ } from './dhis2-connection-status'
10
+ import {
11
+ DEFAULT_INCREMENT_FACTOR,
12
+ DEFAULT_MAX_DELAY_MS,
13
+ DEFAULT_INITIAL_DELAY_MS,
14
+ } from './smart-interval'
15
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
16
+ import { usePingQuery } from './use-ping-query'
17
+
18
+ // important that this name starts with 'mock' to be hoisted correctly
19
+ const mockPing = jest.fn().mockImplementation(() => Promise.resolve())
20
+
21
+ jest.mock('./use-ping-query.ts', () => ({
22
+ usePingQuery: () => mockPing,
23
+ }))
24
+
25
+ const failedPing = () =>
26
+ Promise.reject({
27
+ message: 'this is a network error',
28
+ type: 'network',
29
+ })
30
+
31
+ const FIRST_INTERVAL_MS = DEFAULT_INITIAL_DELAY_MS
32
+ const SECOND_INTERVAL_MS = FIRST_INTERVAL_MS * DEFAULT_INCREMENT_FACTOR
33
+ const THIRD_INTERVAL_MS = SECOND_INTERVAL_MS * DEFAULT_INCREMENT_FACTOR
34
+ const FOURTH_INTERVAL_MS = THIRD_INTERVAL_MS * DEFAULT_INCREMENT_FACTOR
35
+
36
+ // Explanation: The length of the Nth interval is:
37
+ // initialDelay * incrementFactor ^ (N - 1)
38
+ // Using some algebra and the law of logs, the Nth interval
39
+ // which is longer than the max delay is:
40
+ // N >= (ln (maxDelay / initialDelay) / ln (incrementFactor)) + 1
41
+ // => then use Math.ceil to handle the 'greater than' effect
42
+ const INTERVALS_TO_REACH_MAX_DELAY = Math.ceil(
43
+ Math.log(DEFAULT_MAX_DELAY_MS / DEFAULT_INITIAL_DELAY_MS) /
44
+ Math.log(DEFAULT_INCREMENT_FACTOR) +
45
+ 1
46
+ )
47
+
48
+ const wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
49
+ <ConfigProvider
50
+ config={{
51
+ baseUrl: '..',
52
+ apiVersion: 42,
53
+ // ensure this is a server version where pings are enabled
54
+ serverVersion: { major: 2, minor: 40, patch: 0, full: 'n/a' },
55
+ }}
56
+ >
57
+ <OfflineProvider offlineInterface={mockOfflineInterface}>
58
+ {children}
59
+ </OfflineProvider>
60
+ </ConfigProvider>
61
+ )
62
+
63
+ /**
64
+ * Assert on the delay of the last time setTimeoutSpy was called with
65
+ * the `callbackAndRestart()` function in smartInterval.
66
+ *
67
+ * This is useful because sometimes jest (or something) uses `setTimeout`
68
+ * too with a `_flushCallback` function, which gets in the way of using
69
+ * an assertion like:
70
+ * `expect(setTimeoutSpy).toHaveBeenLastCalledWith(..., expectedDelay)`
71
+ */
72
+ const assertLastDelay = (
73
+ setTimeoutSpy: jest.SpyInstance,
74
+ expectedDelay: number
75
+ ) => {
76
+ const calls = setTimeoutSpy.mock.calls
77
+ for (let i = calls.length - 1; i >= 0; i--) {
78
+ if (calls[i][0].name === 'callbackAndRestart') {
79
+ expect(calls[i][1]).toBe(expectedDelay)
80
+ return
81
+ }
82
+ }
83
+ }
84
+
85
+ const testCurrentDate = new Date('Fri, 03 Feb 2023 13:52:31 GMT')
86
+ beforeAll(() => {
87
+ jest.useFakeTimers()
88
+ jest.spyOn(Date, 'now').mockReturnValue(testCurrentDate.getTime())
89
+ })
90
+ beforeEach(() => {
91
+ // standby state is initialized to window visibility, which is 'false' by
92
+ // default in tests. mock that here:
93
+ jest.spyOn(document, 'hasFocus').mockReturnValue(true)
94
+ })
95
+ afterEach(() => {
96
+ jest.clearAllMocks()
97
+ // for lastConnected:
98
+ localStorage.clear()
99
+ })
100
+ afterAll(() => {
101
+ jest.useRealTimers()
102
+ jest.resetAllMocks()
103
+ })
104
+
105
+ describe('initialization to the right values based on offline interface', () => {
106
+ test('when latestIsConnected is true', () => {
107
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
108
+ wrapper: wrapper,
109
+ })
110
+
111
+ expect(result.current.isConnected).toBe(true)
112
+ expect(result.current.isDisconnected).toBe(false)
113
+ expect(result.current.lastConnected).toBe(null)
114
+ })
115
+
116
+ test('when latestIsConnected is false', () => {
117
+ const customMockOfflineInterface = {
118
+ ...mockOfflineInterface,
119
+ latestIsConnected: false,
120
+ }
121
+ const customWrapper: React.FC<{ children?: React.ReactNode }> = ({
122
+ children,
123
+ }) => (
124
+ <OfflineProvider offlineInterface={customMockOfflineInterface}>
125
+ {children}
126
+ </OfflineProvider>
127
+ )
128
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
129
+ wrapper: customWrapper,
130
+ })
131
+
132
+ expect(result.current.isConnected).toBe(false)
133
+ expect(result.current.isDisconnected).toBe(true)
134
+ // If localStorage is clear, sets 'lastConnected' to `now` as a best
135
+ // effort to provide useful information.
136
+ // There will be more detailed testing of lastConnected below
137
+ expect(result.current.lastConnected).toEqual(testCurrentDate)
138
+ })
139
+
140
+ // This might happen in the unlikely circumstance that the provider
141
+ // renders before the offlineInterface has received a value for
142
+ // lastIsConnected. Normally, the ServerVersionProvider in the app
143
+ // adapter delays rendering the App Runtime provider (including the
144
+ // OfflineProvider) until the offline interface is ready, which should
145
+ // avoid this case.
146
+ test('when latestIsConnected is null', () => {
147
+ const customMockOfflineInterface = {
148
+ ...mockOfflineInterface,
149
+ latestIsConnected: null,
150
+ }
151
+ const customWrapper: React.FC<{ children?: React.ReactNode }> = ({
152
+ children,
153
+ }) => (
154
+ <OfflineProvider offlineInterface={customMockOfflineInterface}>
155
+ {children}
156
+ </OfflineProvider>
157
+ )
158
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
159
+ wrapper: customWrapper,
160
+ })
161
+
162
+ expect(result.current.isConnected).toBe(true)
163
+ expect(result.current.isDisconnected).toBe(false)
164
+ expect(result.current.lastConnected).toBe(null)
165
+ })
166
+ })
167
+
168
+ describe('interval behavior', () => {
169
+ test('the ping delay increases when idle until the max is reached', async () => {
170
+ const setTimeoutSpy = jest.spyOn(window, 'setTimeout')
171
+
172
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
173
+ wrapper: wrapper,
174
+ })
175
+
176
+ expect(result.current.isConnected).toBe(true)
177
+ expect(mockPing).not.toHaveBeenCalled()
178
+ assertLastDelay(setTimeoutSpy, FIRST_INTERVAL_MS)
179
+
180
+ // 500ms before first interval
181
+ jest.advanceTimersByTime(FIRST_INTERVAL_MS - 500)
182
+ expect(mockPing).not.toHaveBeenCalled()
183
+ // 500ms after first interval
184
+ jest.advanceTimersByTime(1000)
185
+ expect(mockPing).toHaveBeenCalledTimes(1)
186
+ assertLastDelay(setTimeoutSpy, SECOND_INTERVAL_MS)
187
+
188
+ // 500ms before second interval
189
+ jest.advanceTimersByTime(SECOND_INTERVAL_MS - 1000)
190
+ expect(mockPing).toHaveBeenCalledTimes(1)
191
+ // 500ms after second interval
192
+ jest.advanceTimersByTime(1000)
193
+ expect(mockPing).toHaveBeenCalledTimes(2)
194
+ assertLastDelay(setTimeoutSpy, THIRD_INTERVAL_MS)
195
+
196
+ // 500ms before third interval
197
+ jest.advanceTimersByTime(THIRD_INTERVAL_MS - 1000)
198
+ expect(mockPing).toHaveBeenCalledTimes(2)
199
+ // 500ms after third interval
200
+ jest.advanceTimersByTime(1000)
201
+ expect(mockPing).toHaveBeenCalledTimes(3)
202
+ assertLastDelay(setTimeoutSpy, FOURTH_INTERVAL_MS)
203
+
204
+ // Run a number of intervals to reach the max delay -
205
+ // this number is calculated above to work for any default values.
206
+ // Since three have already elapsed, there will be some extra too
207
+ for (let i = 0; i < INTERVALS_TO_REACH_MAX_DELAY; i++) {
208
+ // Wrap in act to await async side effects of interval execution
209
+ // and pings
210
+ await act(async () => {
211
+ jest.runOnlyPendingTimers()
212
+ })
213
+ }
214
+
215
+ // Timeout should no longer be incrementing; max has been reached
216
+ expect(mockPing).toHaveBeenCalledTimes(3 + INTERVALS_TO_REACH_MAX_DELAY)
217
+ assertLastDelay(setTimeoutSpy, DEFAULT_MAX_DELAY_MS)
218
+
219
+ // Run a few more intervals to make sure it stays at max
220
+ for (let i = 0; i < 3; i++) {
221
+ await act(async () => {
222
+ jest.runOnlyPendingTimers()
223
+ })
224
+ }
225
+
226
+ // Expect continued use of the max delay
227
+ expect(mockPing).toHaveBeenCalledTimes(6 + INTERVALS_TO_REACH_MAX_DELAY)
228
+ assertLastDelay(setTimeoutSpy, DEFAULT_MAX_DELAY_MS)
229
+ })
230
+
231
+ describe('pings are delayed when offlineInterface sends status updates', () => {
232
+ test('updates postpone pings', () => {
233
+ renderHook(() => useDhis2ConnectionStatus(), {
234
+ wrapper: wrapper,
235
+ })
236
+
237
+ // get onUpdate function passed to mockOfflineInterface
238
+ const { onUpdate } =
239
+ mockOfflineInterface.subscribeToDhis2ConnectionStatus.mock
240
+ .calls[0][0]
241
+
242
+ // invoke it at a few intervals, before pings are scheduled
243
+ for (let i = 0; i < 3; i++) {
244
+ jest.advanceTimersByTime(DEFAULT_INITIAL_DELAY_MS - 2000)
245
+ onUpdate({ isConnected: true })
246
+ }
247
+
248
+ // expect ping mock not to have been called
249
+ expect(mockPing).not.toHaveBeenCalled()
250
+ })
251
+
252
+ test('if the status is the same, the ping delay is reset to the current', () => {
253
+ const setTimeoutSpy = jest.spyOn(window, 'setTimeout')
254
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
255
+
256
+ // get onUpdate function passed to mockOfflineInterface
257
+ const { onUpdate } =
258
+ mockOfflineInterface.subscribeToDhis2ConnectionStatus.mock
259
+ .calls[0][0]
260
+
261
+ // let two intervals pass to allow delay to increase
262
+ jest.advanceTimersByTime(FIRST_INTERVAL_MS + 50)
263
+ jest.advanceTimersByTime(SECOND_INTERVAL_MS)
264
+
265
+ // ...delay should now be 'THIRD_INTERVAL_MS'
266
+ assertLastDelay(setTimeoutSpy, THIRD_INTERVAL_MS)
267
+ expect(mockPing).toHaveBeenCalledTimes(2)
268
+
269
+ // simulate updates from the SW/offline interface several times
270
+ // invoke it at a few intervals, before pings are scheduled
271
+ for (let i = 0; i < 3; i++) {
272
+ jest.advanceTimersByTime(THIRD_INTERVAL_MS - 2000)
273
+ onUpdate({ isConnected: true })
274
+ }
275
+
276
+ // ping mock should STILL only have been called twice
277
+ expect(mockPing).toHaveBeenCalledTimes(2)
278
+
279
+ // the delay should still be THIRD_INTERVAL_MS
280
+ assertLastDelay(setTimeoutSpy, THIRD_INTERVAL_MS)
281
+
282
+ // The timer works as normal for the next tick --
283
+ // 500ms before the fourth interval:
284
+ jest.advanceTimersByTime(THIRD_INTERVAL_MS - 500)
285
+ expect(mockPing).toHaveBeenCalledTimes(2)
286
+ // 500ms after the fourth interval
287
+ jest.advanceTimersByTime(1000)
288
+ expect(mockPing).toHaveBeenCalledTimes(3)
289
+ })
290
+ })
291
+
292
+ describe('the ping interval resets to initial if the detected connection status changes', () => {
293
+ test('this happens when the offline interface issues an update', async () => {
294
+ const setTimeoutSpy = jest.spyOn(window, 'setTimeout')
295
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
296
+ wrapper: wrapper,
297
+ })
298
+ // get onUpdate function passed to mockOfflineInterface
299
+ const { onUpdate } =
300
+ mockOfflineInterface.subscribeToDhis2ConnectionStatus.mock
301
+ .calls[0][0]
302
+
303
+ expect(result.current.isConnected).toBe(true)
304
+
305
+ // Get to third interval
306
+ // (Wrap in `act` to await async side effects of the executions)
307
+ await act(async () => {
308
+ jest.runOnlyPendingTimers()
309
+ jest.runOnlyPendingTimers()
310
+ })
311
+ expect(mockPing).toHaveBeenCalledTimes(2)
312
+ assertLastDelay(setTimeoutSpy, THIRD_INTERVAL_MS)
313
+
314
+ // Trigger connection status change from offline interface
315
+ await act(async () => {
316
+ onUpdate({ isConnected: false })
317
+ })
318
+
319
+ // Expect "first interval delay" to be set up
320
+ assertLastDelay(setTimeoutSpy, FIRST_INTERVAL_MS)
321
+ expect(result.current.isConnected).toBe(false)
322
+
323
+ // Mock an error for the next ping to maintain `isConnected: false`
324
+ mockPing.mockImplementationOnce(() =>
325
+ Promise.reject({
326
+ message: 'this is a network error',
327
+ type: 'network',
328
+ })
329
+ )
330
+ // Advance past "first interval" -- make sure incrementing resumes
331
+ // while still 'isConnected: false'
332
+ await act(async () => {
333
+ jest.advanceTimersByTime(FIRST_INTERVAL_MS + 50)
334
+ })
335
+
336
+ // Expect another execution with the incremented interval
337
+ expect(mockPing).toHaveBeenCalledTimes(3)
338
+ assertLastDelay(setTimeoutSpy, SECOND_INTERVAL_MS)
339
+ })
340
+
341
+ test('this happens if a ping detects a status change', async () => {
342
+ const setTimeoutSpy = jest.spyOn(window, 'setTimeout')
343
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
344
+ wrapper: wrapper,
345
+ })
346
+
347
+ expect(result.current.isConnected).toBe(true)
348
+
349
+ // Get to third interval
350
+ jest.runOnlyPendingTimers()
351
+ jest.runOnlyPendingTimers()
352
+ expect(mockPing).toHaveBeenCalledTimes(2)
353
+ assertLastDelay(setTimeoutSpy, THIRD_INTERVAL_MS)
354
+
355
+ // Mock a network error
356
+ mockPing.mockImplementationOnce(() =>
357
+ Promise.reject({
358
+ message: 'this is a network error',
359
+ type: 'network',
360
+ })
361
+ )
362
+
363
+ await act(async () => {
364
+ jest.advanceTimersByTime(THIRD_INTERVAL_MS + 50)
365
+ })
366
+
367
+ expect(result.current.isConnected).toBe(false)
368
+ expect(mockPing).toHaveBeenCalledTimes(3)
369
+ assertLastDelay(setTimeoutSpy, FIRST_INTERVAL_MS)
370
+ })
371
+ })
372
+ })
373
+
374
+ describe('pings aren\'t sent when the app is not focused; "standby behavior"', () => {
375
+ test("it doesn't ping when the app loses focus and is never refocused", () => {
376
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
377
+
378
+ window.dispatchEvent(new Event('blur'))
379
+
380
+ // This recursively executes all timers -- if it's not in standby,
381
+ // it will enter a loop
382
+ jest.runAllTimers()
383
+
384
+ expect(mockPing).not.toHaveBeenCalled()
385
+ })
386
+
387
+ test("it doesn't ping if the app is never focused (even upon startup)", () => {
388
+ jest.spyOn(document, 'hasFocus').mockReturnValue(false)
389
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
390
+
391
+ // This recursively executes all timers
392
+ jest.runAllTimers()
393
+
394
+ expect(mockPing).not.toHaveBeenCalled()
395
+ })
396
+
397
+ test('if the app is defocused and refocused between two pings, pings happen normally', () => {
398
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
399
+
400
+ window.dispatchEvent(new Event('blur'))
401
+ // wait half of the first interval
402
+ jest.advanceTimersByTime(FIRST_INTERVAL_MS / 2)
403
+ window.dispatchEvent(new Event('focus'))
404
+
405
+ // wait for just over the second half of the first interval
406
+ jest.advanceTimersByTime(FIRST_INTERVAL_MS / 2 + 50)
407
+
408
+ // ping should execute normally
409
+ expect(mockPing).toHaveBeenCalledTimes(1)
410
+ })
411
+
412
+ test('if the app is defocused until after a scheduled ping, that ping is not sent until the app is refocused', () => {
413
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
414
+
415
+ window.dispatchEvent(new Event('blur'))
416
+
417
+ // wait for twice the first interval
418
+ jest.advanceTimersByTime(FIRST_INTERVAL_MS * 2)
419
+
420
+ // no pings should be sent since it's in standby
421
+ expect(mockPing).not.toHaveBeenCalled()
422
+
423
+ // refocus the page
424
+ window.dispatchEvent(new Event('focus'))
425
+
426
+ // ping should execute immediately
427
+ expect(mockPing).toHaveBeenCalledTimes(1)
428
+ })
429
+ })
430
+
431
+ describe('it pings when an offline event is detected', () => {
432
+ test('if the app is focused, it pings immediately', () => {
433
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
434
+
435
+ window.dispatchEvent(new Event('offline'))
436
+
437
+ // ping should execute immediately
438
+ expect(mockPing).toHaveBeenCalledTimes(1)
439
+ })
440
+
441
+ test('if the app is not focused, it does not ping immediately, but pings immediately when refocused', () => {
442
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
443
+
444
+ window.dispatchEvent(new Event('blur'))
445
+ window.dispatchEvent(new Event('offline'))
446
+
447
+ // ping should not execute, but should be queued for refocus
448
+ expect(mockPing).toHaveBeenCalledTimes(0)
449
+
450
+ // upon refocus, the ping should execute immediately
451
+ // despite a full interval not elapsing
452
+ window.dispatchEvent(new Event('focus'))
453
+ expect(mockPing).toHaveBeenCalledTimes(1)
454
+ })
455
+
456
+ describe('interval handling when pinging upon refocusing after offline event is detected while not focused', () => {
457
+ test('if the app is refocused before the next "scheduled" ping, the timeout to the next ping is not increased', () => {
458
+ const setTimeoutSpy = jest.spyOn(window, 'setTimeout')
459
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
460
+
461
+ window.dispatchEvent(new Event('blur'))
462
+ window.dispatchEvent(new Event('offline'))
463
+ window.dispatchEvent(new Event('focus'))
464
+ // upon refocus, the ping should execute immediately
465
+ // despite a full interval not elapsing
466
+ expect(mockPing).toHaveBeenCalledTimes(1)
467
+
468
+ // The delay should be the initial again -- it shouldn't increment
469
+ assertLastDelay(setTimeoutSpy, FIRST_INTERVAL_MS)
470
+ })
471
+
472
+ test('same as previous, but interval is reset if status changes', async () => {
473
+ const setTimeoutSpy = jest.spyOn(window, 'setTimeout')
474
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
475
+ wrapper: wrapper,
476
+ })
477
+
478
+ expect(result.current.isConnected).toBe(true)
479
+
480
+ // Get to third interval
481
+ jest.runOnlyPendingTimers()
482
+ jest.runOnlyPendingTimers()
483
+ expect(mockPing).toHaveBeenCalledTimes(2)
484
+ assertLastDelay(setTimeoutSpy, THIRD_INTERVAL_MS)
485
+
486
+ // Mock a network error
487
+ mockPing.mockImplementationOnce(() =>
488
+ Promise.reject({
489
+ message: 'this is a network error',
490
+ type: 'network',
491
+ })
492
+ )
493
+
494
+ // Blur, trigger 'offline' event, and refocus to trigger a ping
495
+ window.dispatchEvent(new Event('blur'))
496
+ window.dispatchEvent(new Event('offline'))
497
+ await act(async () => {
498
+ window.dispatchEvent(new Event('focus'))
499
+ })
500
+
501
+ expect(result.current.isConnected).toBe(false)
502
+ expect(mockPing).toHaveBeenCalledTimes(3)
503
+ assertLastDelay(setTimeoutSpy, FIRST_INTERVAL_MS)
504
+ })
505
+
506
+ test('if the app is refocused after the next "scheduled" ping, increase the interval to the next ping if the status hasn\'t changed', () => {
507
+ const setTimeoutSpy = jest.spyOn(window, 'setTimeout')
508
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
509
+
510
+ window.dispatchEvent(new Event('blur'))
511
+ window.dispatchEvent(new Event('offline'))
512
+
513
+ // Elapse twice one interval - it should enter full standby
514
+ jest.advanceTimersByTime(FIRST_INTERVAL_MS * 2)
515
+ expect(mockPing).toHaveBeenCalledTimes(0)
516
+
517
+ // Refocusing should trigger a ping from the full standby,
518
+ // not just the offline event
519
+ window.dispatchEvent(new Event('focus'))
520
+ expect(mockPing).toHaveBeenCalledTimes(1)
521
+
522
+ // The delay should increment this time, as it would from normal standby
523
+ assertLastDelay(setTimeoutSpy, SECOND_INTERVAL_MS)
524
+ })
525
+
526
+ test('the same as previous, but the interval is reset if status has changed', async () => {
527
+ const setTimeoutSpy = jest.spyOn(window, 'setTimeout')
528
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
529
+ wrapper: wrapper,
530
+ })
531
+
532
+ expect(result.current.isConnected).toBe(true)
533
+
534
+ // Get to third interval
535
+ jest.runOnlyPendingTimers()
536
+ jest.runOnlyPendingTimers()
537
+ expect(mockPing).toHaveBeenCalledTimes(2)
538
+ assertLastDelay(setTimeoutSpy, THIRD_INTERVAL_MS)
539
+
540
+ // Blur and elapse twice the third interval --
541
+ // it should enter full standby
542
+ window.dispatchEvent(new Event('blur'))
543
+ window.dispatchEvent(new Event('offline'))
544
+ jest.advanceTimersByTime(THIRD_INTERVAL_MS * 2)
545
+
546
+ // Mock a network error for the next ping
547
+ mockPing.mockImplementationOnce(() =>
548
+ Promise.reject({
549
+ message: 'this is a network error',
550
+ type: 'network',
551
+ })
552
+ )
553
+
554
+ // Trigger a ping by refocusing
555
+ await act(async () => {
556
+ window.dispatchEvent(new Event('focus'))
557
+ })
558
+
559
+ expect(result.current.isConnected).toBe(false)
560
+ expect(mockPing).toHaveBeenCalledTimes(3)
561
+ assertLastDelay(setTimeoutSpy, FIRST_INTERVAL_MS)
562
+ })
563
+ })
564
+ })
565
+
566
+ describe('it pings when an online event is detected', () => {
567
+ test('if the app is focused, it pings immediately', () => {
568
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
569
+
570
+ window.dispatchEvent(new Event('offline'))
571
+ expect(mockPing).toHaveBeenCalledTimes(1)
572
+
573
+ window.dispatchEvent(new Event('online'))
574
+ expect(mockPing).toHaveBeenCalledTimes(2)
575
+ })
576
+
577
+ test('pings are throttled', () => {
578
+ renderHook(() => useDhis2ConnectionStatus(), { wrapper })
579
+
580
+ window.dispatchEvent(new Event('offline'))
581
+ window.dispatchEvent(new Event('online'))
582
+ window.dispatchEvent(new Event('offline'))
583
+ expect(mockPing).toHaveBeenCalledTimes(3)
584
+
585
+ window.dispatchEvent(new Event('online'))
586
+ // Another ping should NOT be sent immediately after this latest
587
+ // online event
588
+ expect(mockPing).toHaveBeenCalledTimes(3)
589
+ // (Not testing throttle time here because further pings may interfere)
590
+ })
591
+ })
592
+
593
+ describe('lastConnected status', () => {
594
+ test('it sets lastConnected in localStorage when it becomes disconnected', async () => {
595
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
596
+ wrapper: wrapper,
597
+ })
598
+
599
+ expect(result.current.isConnected).toBe(true)
600
+
601
+ // Mock a network error for the next ping
602
+ mockPing.mockImplementationOnce(failedPing)
603
+
604
+ // Trigger a ping (to fail and switch to disconnected)
605
+ await act(async () => {
606
+ jest.runOnlyPendingTimers()
607
+ })
608
+ expect(mockPing).toHaveBeenCalledTimes(1)
609
+
610
+ // Expect 'disconnected' status now
611
+ expect(result.current.isConnected).toBe(false)
612
+ expect(result.current.isDisconnected).toBe(true)
613
+
614
+ // Check localStorage for the dummy date
615
+ const localStorageDate = localStorage.getItem(getLastConnectedKey())
616
+ expect(localStorageDate).toBe(testCurrentDate.toUTCString())
617
+
618
+ // Check hook return value
619
+ expect(result.current.lastConnected).toBeInstanceOf(Date)
620
+ expect(result.current.lastConnected).toEqual(testCurrentDate)
621
+ })
622
+
623
+ test('lastConnected becomes null when it becomes connected again', async () => {
624
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
625
+ wrapper: wrapper,
626
+ })
627
+ expect(result.current.isConnected).toBe(true)
628
+
629
+ // Mock a network error for the next ping
630
+ mockPing.mockImplementationOnce(failedPing)
631
+
632
+ // Trigger an immediate ping (to fail and switch to disconnected)
633
+ await act(async () => {
634
+ jest.runOnlyPendingTimers()
635
+ })
636
+ expect(mockPing).toHaveBeenCalledTimes(1)
637
+
638
+ // Verify hook return value
639
+ expect(result.current.isConnected).toBe(false)
640
+ expect(result.current.lastConnected).toEqual(testCurrentDate)
641
+
642
+ // Trigger a successful ping to go back online
643
+ await act(async () => {
644
+ jest.runOnlyPendingTimers()
645
+ })
646
+ expect(mockPing).toHaveBeenCalledTimes(2)
647
+ expect(result.current.isConnected).toBe(true)
648
+ expect(result.current.lastConnected).toBe(null)
649
+ })
650
+
651
+ test('lastConnected persists in localStorage if unmounted while disconnected', async () => {
652
+ const { result, unmount } = renderHook(
653
+ () => useDhis2ConnectionStatus(),
654
+ { wrapper: wrapper }
655
+ )
656
+
657
+ // Mock a network error for the next ping to trigger 'disconnected'
658
+ mockPing.mockImplementationOnce(failedPing)
659
+ await act(async () => {
660
+ jest.runOnlyPendingTimers()
661
+ })
662
+ expect(result.current.isConnected).toBe(false)
663
+
664
+ // Unmount
665
+ unmount()
666
+
667
+ // Expect value to persist in localStorage
668
+ const localStorageDate = localStorage.getItem(getLastConnectedKey())
669
+ expect(localStorageDate).toBe(testCurrentDate.toUTCString())
670
+ })
671
+
672
+ test('lastConnected is cleared from localStorage after unmounting while connected', async () => {
673
+ const { result, unmount } = renderHook(
674
+ () => useDhis2ConnectionStatus(),
675
+ { wrapper }
676
+ )
677
+
678
+ expect(result.current.isConnected).toBe(true)
679
+
680
+ // Mock a network error for the next ping to trigger disconnected
681
+ mockPing.mockImplementationOnce(failedPing)
682
+ await act(async () => {
683
+ jest.runOnlyPendingTimers()
684
+ })
685
+ expect(result.current.isConnected).toBe(false)
686
+
687
+ // Check localStorage for the dummy date
688
+ const localStorageDate = localStorage.getItem(getLastConnectedKey())
689
+ expect(localStorageDate).toBe(testCurrentDate.toUTCString())
690
+
691
+ // Trigger another ping to go back to connected
692
+ await act(async () => {
693
+ jest.runOnlyPendingTimers()
694
+ })
695
+ expect(result.current.isConnected).toBe(true)
696
+
697
+ // Unmount and expect localStorage to be clear for next session
698
+ unmount()
699
+ expect(localStorage.getItem(getLastConnectedKey())).toBe(null)
700
+ })
701
+
702
+ describe('starting while disconnected', () => {
703
+ test('it sets lastConnected to `now` if nothing is found in localStorage', async () => {
704
+ // use a custom offlineInterface with `latestIsConnected: false`
705
+ // to initialize the `isConnected` state to false
706
+ const customMockOfflineInterface = {
707
+ ...mockOfflineInterface,
708
+ latestIsConnected: false,
709
+ }
710
+ const customWrapper: React.FC<{ children?: React.ReactNode }> = ({
711
+ children,
712
+ }) => (
713
+ <OfflineProvider offlineInterface={customMockOfflineInterface}>
714
+ {children}
715
+ </OfflineProvider>
716
+ )
717
+
718
+ // render hook with custom wrapper
719
+ renderHook(() => useDhis2ConnectionStatus(), {
720
+ wrapper: customWrapper,
721
+ })
722
+
723
+ // expect correct lastConnected time (mocked Date.now())
724
+ expect(localStorage.getItem(getLastConnectedKey())).toBe(
725
+ testCurrentDate.toUTCString()
726
+ )
727
+ })
728
+
729
+ test('if a value is already in localStorage, it uses that without overwriting', async () => {
730
+ // seed localStorage with an imaginary 'lastConnected' value from last session
731
+ const testPreviousDate = new Date('2023-01-01')
732
+ localStorage.setItem(
733
+ getLastConnectedKey(),
734
+ testPreviousDate.toUTCString()
735
+ )
736
+
737
+ // render hook with custom wrapper
738
+ const customMockOfflineInterface = {
739
+ ...mockOfflineInterface,
740
+ latestIsConnected: false,
741
+ }
742
+ const customWrapper: React.FC<{ children?: React.ReactNode }> = ({
743
+ children,
744
+ }) => (
745
+ <OfflineProvider offlineInterface={customMockOfflineInterface}>
746
+ {children}
747
+ </OfflineProvider>
748
+ )
749
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
750
+ wrapper: customWrapper,
751
+ })
752
+
753
+ // On render, the hook should retain last connected
754
+ expect(result.current.lastConnected).not.toBe(null)
755
+ expect(result.current.lastConnected).toEqual(testPreviousDate)
756
+ // should be the same in localStorage too
757
+ expect(localStorage.getItem(getLastConnectedKey())).toBe(
758
+ testPreviousDate.toUTCString()
759
+ )
760
+ })
761
+ })
762
+
763
+ test("it doesn't change lastConnected if already disconnected", async () => {
764
+ // seed localStorage with an imaginary 'lastConnected' value from last session
765
+ const testPreviousDate = new Date('2023-01-01')
766
+ localStorage.setItem(
767
+ getLastConnectedKey(),
768
+ testPreviousDate.toUTCString()
769
+ )
770
+
771
+ // render hook with custom wrapper
772
+ const customMockOfflineInterface = {
773
+ ...mockOfflineInterface,
774
+ latestIsConnected: false,
775
+ }
776
+ const customWrapper: React.FC<{ children?: React.ReactNode }> = ({
777
+ children,
778
+ }) => (
779
+ <ConfigProvider
780
+ config={{
781
+ baseUrl: '..',
782
+ apiVersion: 42,
783
+ serverVersion: {
784
+ major: 2,
785
+ minor: 40,
786
+ patch: 0,
787
+ full: 'n/a',
788
+ },
789
+ }}
790
+ >
791
+ <OfflineProvider offlineInterface={customMockOfflineInterface}>
792
+ {children}
793
+ </OfflineProvider>
794
+ </ConfigProvider>
795
+ )
796
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
797
+ wrapper: customWrapper,
798
+ })
799
+
800
+ // As in previous test, the hook should retain last connected
801
+ expect(result.current.lastConnected).toEqual(testPreviousDate)
802
+
803
+ // Mock a network error for the next ping and trigger
804
+ mockPing.mockImplementationOnce(failedPing)
805
+ await act(async () => {
806
+ jest.runOnlyPendingTimers()
807
+ })
808
+ expect(mockPing).toHaveBeenCalledTimes(1)
809
+
810
+ // Expect the same lastConnected as before
811
+ expect(result.current.lastConnected).toEqual(testPreviousDate)
812
+ // should be the same in localStorage too
813
+ expect(localStorage.getItem(getLastConnectedKey())).toBe(
814
+ testPreviousDate.toUTCString()
815
+ )
816
+
817
+ // Verify the same with a signal from the service worker
818
+ // get onUpdate function passed to mockOfflineInterface
819
+ const { onUpdate } =
820
+ mockOfflineInterface.subscribeToDhis2ConnectionStatus.mock
821
+ .calls[0][0]
822
+ await act(async () => {
823
+ onUpdate({ isConnected: false })
824
+ })
825
+
826
+ // Expect the same lastConnected as before
827
+ expect(result.current.lastConnected).toEqual(testPreviousDate)
828
+ })
829
+
830
+ test('lastConnected is saved specifically to an app if a name is provided', async () => {
831
+ // seed localStorage with an imaginary 'lastConnected' value from last session
832
+ const testAppName = 'test-app-name'
833
+ const lastConnectedKey = getLastConnectedKey(testAppName)
834
+ const testPreviousDate = new Date('2023-01-01')
835
+ localStorage.setItem(lastConnectedKey, testPreviousDate.toUTCString())
836
+
837
+ // render hook with custom wrapper to start disconnected with app name
838
+ const customMockOfflineInterface = {
839
+ ...mockOfflineInterface,
840
+ latestIsConnected: false,
841
+ }
842
+ const customWrapper: React.FC<{ children?: React.ReactNode }> = ({
843
+ children,
844
+ }) => (
845
+ <ConfigProvider
846
+ config={{
847
+ baseUrl: '..',
848
+ apiVersion: 42,
849
+ appName: testAppName,
850
+ serverVersion: {
851
+ major: 2,
852
+ minor: 40,
853
+ patch: 0,
854
+ full: 'n/a',
855
+ },
856
+ }}
857
+ >
858
+ <OfflineProvider offlineInterface={customMockOfflineInterface}>
859
+ {children}
860
+ </OfflineProvider>
861
+ </ConfigProvider>
862
+ )
863
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
864
+ wrapper: customWrapper,
865
+ })
866
+
867
+ // Expect previous value to be read correctly
868
+ expect(result.current.lastConnected).toEqual(testPreviousDate)
869
+
870
+ // Go to connected then disconnected again to generate a new date
871
+ await act(async () => {
872
+ jest.runOnlyPendingTimers()
873
+ })
874
+ expect(result.current.isConnected).toBe(true)
875
+ expect(result.current.lastConnected).toBe(null)
876
+ expect(localStorage.getItem(lastConnectedKey)).toBe(null)
877
+
878
+ mockPing.mockImplementationOnce(failedPing)
879
+ await act(async () => {
880
+ jest.runOnlyPendingTimers()
881
+ })
882
+ expect(result.current.isConnected).toBe(false)
883
+ // Note the new date:
884
+ expect(result.current.lastConnected).toEqual(testCurrentDate)
885
+
886
+ // Verify localStorage
887
+ expect(localStorage.getItem(lastConnectedKey)).toBe(
888
+ testCurrentDate.toUTCString()
889
+ )
890
+ })
891
+ })
892
+
893
+ describe("when the /api/ping endpoint isn't supported", () => {
894
+ const customWrapper: React.FC<{ children?: React.ReactNode }> = ({
895
+ children,
896
+ }) => (
897
+ <ConfigProvider
898
+ config={{
899
+ baseUrl: '..',
900
+ apiVersion: 42,
901
+ // an unsupported version:
902
+ serverVersion: { major: 2, minor: 39, patch: 0, full: 'n/a' },
903
+ }}
904
+ >
905
+ <OfflineProvider offlineInterface={mockOfflineInterface}>
906
+ {children}
907
+ </OfflineProvider>
908
+ </ConfigProvider>
909
+ )
910
+
911
+ test("pings aren't sent", async () => {
912
+ const setTimeoutSpy = jest.spyOn(window, 'setTimeout')
913
+ renderHook(() => useDhis2ConnectionStatus(), {
914
+ wrapper: customWrapper,
915
+ })
916
+
917
+ await act(async () => {
918
+ jest.runAllTimers()
919
+ })
920
+
921
+ expect(mockPing).not.toHaveBeenCalled()
922
+ expect(setTimeoutSpy).not.toHaveBeenCalled()
923
+ })
924
+
925
+ test('service worker updates still work', async () => {
926
+ const { result } = renderHook(() => useDhis2ConnectionStatus(), {
927
+ wrapper: wrapper,
928
+ })
929
+ // get onUpdate function passed to mockOfflineInterface
930
+ const { onUpdate } =
931
+ mockOfflineInterface.subscribeToDhis2ConnectionStatus.mock
932
+ .calls[0][0]
933
+
934
+ expect(result.current.isConnected).toBe(true)
935
+
936
+ // Trigger connection status change from offline interface
937
+ await act(async () => {
938
+ onUpdate({ isConnected: false })
939
+ })
940
+ expect(result.current.isConnected).toBe(false)
941
+
942
+ await act(async () => {
943
+ onUpdate({ isConnected: true })
944
+ })
945
+ expect(result.current.isConnected).toBe(true)
946
+ })
947
+ })