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