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