@dhis2/app-service-offline 3.17.0-beta.4 → 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
package/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ # DHIS2 Platform
2
+ node_modules
3
+ .d2
4
+ src/locales
5
+ build
@@ -0,0 +1 @@
1
+ "use strict";
@@ -0,0 +1 @@
1
+ export {};
package/d2.config.js ADDED
@@ -0,0 +1,9 @@
1
+ const config = {
2
+ type: 'lib',
3
+
4
+ entryPoints: {
5
+ lib: './src/index.ts',
6
+ },
7
+ }
8
+
9
+ module.exports = config
package/jest.config.js ADDED
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ collectCoverageFrom: [
3
+ 'src/**/*.(js|jsx|ts|tsx)',
4
+ '!src/index.ts',
5
+ '!src/**/types/*',
6
+ '!src/**/types.ts',
7
+ ],
8
+
9
+ // Setup react-testing-library
10
+ setupFilesAfterEnv: ['<rootDir>/src/setupRTL.ts'],
11
+ // Fix for Jest 27
12
+ // https://github.com/facebook/jest/issues/11404#issuecomment-1003328922
13
+ testRunner: 'jest-jasmine2',
14
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dhis2/app-service-offline",
3
3
  "description": "A runtime service for online/offline detection and offline caching",
4
- "version": "3.17.0-beta.4",
4
+ "version": "3.17.1",
5
5
  "main": "./build/cjs/index.js",
6
6
  "module": "./build/es/index.js",
7
7
  "types": "./build/types/index.d.ts",
@@ -21,7 +21,7 @@
21
21
  "access": "public"
22
22
  },
23
23
  "files": [
24
- "build/**"
24
+ "**"
25
25
  ],
26
26
  "scripts": {
27
27
  "build:types": "tsc --emitDeclarationOnly --outDir ./build/types",
@@ -34,7 +34,7 @@
34
34
  "coverage": "yarn test --coverage"
35
35
  },
36
36
  "peerDependencies": {
37
- "@dhis2/app-service-config": "3.17.0-beta.4",
37
+ "@dhis2/app-service-config": "3.17.1",
38
38
  "react": "^16.8.6 || ^18",
39
39
  "react-dom": "^16.8.6 || ^18"
40
40
  },
@@ -0,0 +1,341 @@
1
+ import { AlertsProvider } from '@dhis2/app-service-alerts'
2
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import React from 'react'
4
+ import {
5
+ useCacheableSection,
6
+ CacheableSection,
7
+ CacheableSectionStartRecording,
8
+ } from '../lib/cacheable-section'
9
+ import { OfflineProvider } from '../lib/offline-provider'
10
+ import { RenderCounter, resetRenderCounts } from '../utils/render-counter'
11
+ import {
12
+ errorRecordingMock,
13
+ failedMessageRecordingMock,
14
+ mockOfflineInterface,
15
+ } from '../utils/test-mocks'
16
+
17
+ const renderCounts = {}
18
+
19
+ const identity = (arg: any) => arg
20
+
21
+ const TestControls = ({
22
+ id,
23
+ makeRecordingHandler = identity,
24
+ }: {
25
+ id: string
26
+ makeRecordingHandler?: (cb?: any) => () => Promise<any>
27
+ }) => {
28
+ const { startRecording, remove, isCached, lastUpdated, recordingState } =
29
+ useCacheableSection(id)
30
+
31
+ return (
32
+ <>
33
+ <RenderCounter id={`controls-rc-${id}`} countsObj={renderCounts} />
34
+ <button
35
+ data-testid={`start-recording-${id}`}
36
+ onClick={makeRecordingHandler(startRecording)}
37
+ />
38
+ <button
39
+ data-testid={`remove-${id}`}
40
+ onClick={() => {
41
+ remove()
42
+ }}
43
+ />
44
+ <div data-testid={`is-cached-${id}`}>{isCached ? 'yes' : 'no'}</div>
45
+ <div data-testid={`last-updated-${id}`}>
46
+ {lastUpdated?.toISOString() ?? 'never'}
47
+ </div>
48
+ <div data-testid={`recording-state-${id}`}>{recordingState}</div>
49
+ </>
50
+ )
51
+ }
52
+
53
+ const TestSection = ({
54
+ id,
55
+ children,
56
+ }: {
57
+ id: string
58
+ children?: React.ReactNode
59
+ }) => (
60
+ <CacheableSection
61
+ id={id}
62
+ loadingMask={<div data-testid={`loading-mask-${id}`} />}
63
+ >
64
+ <RenderCounter id={`section-rc-${id}`} countsObj={renderCounts} />
65
+ {children}
66
+ </CacheableSection>
67
+ )
68
+
69
+ const TestSingleSection = (props?: any) => {
70
+ // Props are spread so they can be overwritten
71
+ return (
72
+ <AlertsProvider>
73
+ <OfflineProvider offlineInterface={mockOfflineInterface} {...props}>
74
+ <TestControls id={'1'} {...props} />
75
+ <TestSection id={'1'} {...props} />
76
+ </OfflineProvider>
77
+ </AlertsProvider>
78
+ )
79
+ }
80
+
81
+ // Suppress 'act' warning for these tests
82
+ const originalError = console.error
83
+ beforeEach(() => {
84
+ // This is done before each because the 'recording error' test uses its own
85
+ // spy on console.error
86
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
87
+ const pattern =
88
+ /Warning: An update to .* inside a test was not wrapped in act/
89
+ if (typeof args[0] === 'string' && pattern.test(args[0])) {
90
+ return
91
+ }
92
+ return originalError.call(console, ...args)
93
+ })
94
+ })
95
+
96
+ afterEach(() => {
97
+ jest.clearAllMocks()
98
+ // This syntax appeases typescript:
99
+ ;(console.error as jest.Mock).mockRestore()
100
+ resetRenderCounts(renderCounts)
101
+ })
102
+
103
+ describe('Coordination between useCacheableSection and CacheableSection', () => {
104
+ it('renders in the default state initially', async () => {
105
+ render(<TestSingleSection />)
106
+
107
+ const { getByTestId } = screen
108
+ expect(getByTestId(/recording-state/)).toHaveTextContent('default')
109
+ expect(getByTestId(/is-cached/)).toHaveTextContent('no')
110
+ expect(getByTestId(/last-updated/)).toHaveTextContent('never')
111
+ expect(getByTestId(/section-rc/)).toBeInTheDocument()
112
+ expect(getByTestId(/controls-rc/)).toBeInTheDocument()
113
+ })
114
+
115
+ it('handles a successful recording', async (done) => {
116
+ const { getByTestId, queryByTestId } = screen
117
+
118
+ const onStarted = () => {
119
+ expect(getByTestId(/recording-state/)).toHaveTextContent(
120
+ 'recording'
121
+ )
122
+ expect(getByTestId(/loading-mask/)).toBeInTheDocument()
123
+ expect(getByTestId(/section-rc/)).toBeInTheDocument()
124
+ }
125
+ const onCompleted = () => {
126
+ expect(getByTestId(/recording-state/)).toHaveTextContent('default')
127
+ expect(queryByTestId(/loading-mask/)).not.toBeInTheDocument()
128
+ done()
129
+ }
130
+ const recordingOptions = { onStarted, onCompleted }
131
+ const makeRecordingHandler = (
132
+ startRecording: CacheableSectionStartRecording
133
+ ) => {
134
+ return () => startRecording(recordingOptions)
135
+ }
136
+ render(
137
+ <TestSingleSection makeRecordingHandler={makeRecordingHandler} />
138
+ )
139
+
140
+ await act(async () => {
141
+ fireEvent.click(getByTestId(/start-recording/))
142
+ })
143
+
144
+ // At this stage, should be pending
145
+ expect(getByTestId(/recording-state/)).toHaveTextContent('pending')
146
+ expect(queryByTestId(/section-rc/)).not.toBeInTheDocument()
147
+ expect.assertions(7)
148
+ })
149
+
150
+ it('handles a recording that encounters an error', async (done) => {
151
+ // Suppress the expected error from console (in addition to 'act' warning)
152
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
153
+ const actPattern =
154
+ /Warning: An update to .* inside a test was not wrapped in act/
155
+ const errPattern = /Error during recording/
156
+ const matchesPattern =
157
+ actPattern.test(args[0]) || errPattern.test(args[0])
158
+ if (typeof args[0] === 'string' && matchesPattern) {
159
+ return
160
+ }
161
+ return originalError.call(console, ...args)
162
+ })
163
+ const { getByTestId, queryByTestId } = screen
164
+
165
+ const testOfflineInterface = {
166
+ ...mockOfflineInterface,
167
+ startRecording: errorRecordingMock,
168
+ }
169
+
170
+ const onError = () => {
171
+ expect(getByTestId(/recording-state/)).toHaveTextContent('error')
172
+ expect(queryByTestId(/loading-mask/)).not.toBeInTheDocument()
173
+ expect(getByTestId(/section-rc/)).toBeInTheDocument()
174
+ done()
175
+ }
176
+ const makeRecordingHandler = (
177
+ startRecording: CacheableSectionStartRecording
178
+ ) => {
179
+ return () => startRecording({ onError })
180
+ }
181
+ render(
182
+ <TestSingleSection
183
+ offlineInterface={testOfflineInterface}
184
+ makeRecordingHandler={makeRecordingHandler}
185
+ />
186
+ )
187
+
188
+ await act(async () => {
189
+ fireEvent.click(getByTestId(/start-recording/))
190
+ })
191
+
192
+ expect.assertions(3)
193
+ })
194
+
195
+ // ! After bumping testing-library versions, something about this test
196
+ // ! causes the following ones to mysteriously fail 😤
197
+ it.skip('handles an error starting the recording', async (done) => {
198
+ const { getByTestId } = screen
199
+ const testOfflineInterface = {
200
+ ...mockOfflineInterface,
201
+ startRecording: failedMessageRecordingMock,
202
+ }
203
+
204
+ const onStarted = jest.fn()
205
+
206
+ const testErrCondition = (err: Error) => {
207
+ expect(err.message).toBe('Failed message') // from the mock
208
+ expect(onStarted).not.toHaveBeenCalled()
209
+ expect(getByTestId(/recording-state/)).toHaveTextContent('default')
210
+ done()
211
+ }
212
+
213
+ const makeRecordingHandler = (
214
+ startRecording: CacheableSectionStartRecording
215
+ ) => {
216
+ return () => startRecording({ onStarted }).catch(testErrCondition)
217
+ }
218
+
219
+ render(
220
+ <TestSingleSection
221
+ offlineInterface={testOfflineInterface}
222
+ makeRecordingHandler={makeRecordingHandler}
223
+ />
224
+ )
225
+
226
+ await act(async () => {
227
+ fireEvent.click(getByTestId(/start-recording/))
228
+ })
229
+ })
230
+ })
231
+
232
+ const TwoTestSections = (props?: any) => (
233
+ // Props are spread so they can be overwritten (but only on one section)
234
+ <AlertsProvider>
235
+ <OfflineProvider offlineInterface={mockOfflineInterface} {...props}>
236
+ <TestControls id={'1'} {...props} />
237
+ <TestSection id={'1'} {...props} />
238
+ <TestControls id={'2'} />
239
+ <TestSection id={'2'} />
240
+ </OfflineProvider>
241
+ </AlertsProvider>
242
+ )
243
+
244
+ // test that other sections don't rerender when one section does
245
+ describe('Performant state management', () => {
246
+ it('establishes a pre-recording render count', () => {
247
+ render(<TwoTestSections />)
248
+
249
+ const { getByTestId } = screen
250
+ // Two renders for controls: undefined and 'default' states
251
+ expect(getByTestId('controls-rc-1')).toHaveTextContent('2')
252
+ expect(getByTestId('controls-rc-2')).toHaveTextContent('2')
253
+ // Just one render for sections
254
+ expect(getByTestId('section-rc-1')).toHaveTextContent('1')
255
+ expect(getByTestId('section-rc-2')).toHaveTextContent('1')
256
+ })
257
+
258
+ it('isolates rerenders from other consumers', async (done) => {
259
+ const { getByTestId } = screen
260
+ // Make assertions
261
+ const onCompleted = () => {
262
+ // Before refactor: controls components have 6 renders EACH, and
263
+ // sections 1 and 2 have 2 and 1 renders, respectively
264
+ // After refactor, render counts for section that recorded:
265
+ expect(getByTestId('controls-rc-1')).toHaveTextContent('5')
266
+ expect(getByTestId('section-rc-1')).toHaveTextContent('2')
267
+ // Section that did not record (should be same as pre-recording):
268
+ expect(getByTestId('controls-rc-2')).toHaveTextContent('2')
269
+ expect(getByTestId('section-rc-2')).toHaveTextContent('1')
270
+ done()
271
+ }
272
+
273
+ const makeRecordingHandler =
274
+ (startRecording: CacheableSectionStartRecording) => () =>
275
+ startRecording({ onCompleted })
276
+ render(<TwoTestSections makeRecordingHandler={makeRecordingHandler} />)
277
+
278
+ await act(async () => {
279
+ fireEvent.click(getByTestId('start-recording-1'))
280
+ })
281
+
282
+ expect.assertions(4)
283
+ })
284
+ })
285
+
286
+ describe('useCacheableSection can be used inside a child of CacheableSection', () => {
287
+ const ChildTest = (props?: any) => {
288
+ // Props are spread so they can be overwritten
289
+ return (
290
+ <AlertsProvider>
291
+ <OfflineProvider
292
+ offlineInterface={mockOfflineInterface}
293
+ {...props}
294
+ >
295
+ <TestSection id={'1'} {...props}>
296
+ <TestControls id={'1'} {...props} />
297
+ </TestSection>
298
+ </OfflineProvider>
299
+ </AlertsProvider>
300
+ )
301
+ }
302
+
303
+ it('handles a successful recording', async (done) => {
304
+ const { getByTestId, queryByTestId } = screen
305
+
306
+ const onStarted = async () => {
307
+ await waitFor(() => {
308
+ expect(getByTestId(/recording-state/)).toHaveTextContent(
309
+ 'recording'
310
+ )
311
+ expect(getByTestId(/loading-mask/)).toBeInTheDocument()
312
+ expect(getByTestId(/section-rc/)).toBeInTheDocument()
313
+ })
314
+ }
315
+ const onCompleted = () => {
316
+ expect(getByTestId(/recording-state/)).toHaveTextContent('default')
317
+ expect(queryByTestId(/loading-mask/)).not.toBeInTheDocument()
318
+ done()
319
+ }
320
+ const recordingOptions = { onStarted, onCompleted }
321
+ const makeRecordingHandler = (
322
+ startRecording: CacheableSectionStartRecording
323
+ ) => {
324
+ return () => startRecording(recordingOptions)
325
+ }
326
+
327
+ render(<ChildTest makeRecordingHandler={makeRecordingHandler} />)
328
+
329
+ await act(async () => {
330
+ await fireEvent.click(getByTestId(/start-recording/))
331
+ })
332
+
333
+ await waitFor(() => {
334
+ // At this stage, should be pending
335
+ // - In this test case, 'controls' should not be rendered
336
+ expect(queryByTestId(/recording-state/)).not.toBeInTheDocument()
337
+ expect(queryByTestId(/section-rc/)).not.toBeInTheDocument()
338
+ expect.assertions(7)
339
+ })
340
+ })
341
+ })
@@ -0,0 +1 @@
1
+ declare module 'fake-indexeddb/lib/FDBFactory'
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { OfflineProvider } from './lib/offline-provider'
2
+ export { CacheableSection, useCacheableSection } from './lib/cacheable-section'
3
+ export { useCachedSections } from './lib/cacheable-section-state'
4
+ // Use "useOnlineStatus" name for backwards compatibility
5
+ export { useNetworkStatus as useOnlineStatus } from './lib/network-status'
6
+ export {
7
+ useOnlineStatusMessage,
8
+ useSetOnlineStatusMessage,
9
+ useOnlineStatusMessageValue,
10
+ } from './lib/online-status-message'
11
+ export { clearSensitiveCaches } from './lib/clear-sensitive-caches'
12
+ export { useDhis2ConnectionStatus } from './lib/dhis2-connection-status'
@@ -0,0 +1,45 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import React, { FC, PropsWithChildren } from 'react'
3
+ import { mockOfflineInterface } from '../../utils/test-mocks'
4
+ import { useCachedSection, useRecordingState } from '../cacheable-section-state'
5
+ import { OfflineProvider } from '../offline-provider'
6
+
7
+ const wrapper: FC<PropsWithChildren> = ({ children }) => (
8
+ <OfflineProvider offlineInterface={mockOfflineInterface}>
9
+ {children}
10
+ </OfflineProvider>
11
+ )
12
+
13
+ test('useRecordingState has stable references', () => {
14
+ const { result, rerender } = renderHook(() => useRecordingState('one'), {
15
+ wrapper,
16
+ })
17
+
18
+ const origRecordingState = result.current.recordingState
19
+ const origSetRecordingState = result.current.setRecordingState
20
+ const origRemoveRecordingState = result.current.removeRecordingState
21
+
22
+ rerender()
23
+
24
+ expect(result.current.recordingState).toBe(origRecordingState)
25
+ expect(result.current.setRecordingState).toBe(origSetRecordingState)
26
+ expect(result.current.removeRecordingState).toBe(origRemoveRecordingState)
27
+ })
28
+
29
+ test('useCachedSection has stable references', () => {
30
+ const { result, rerender } = renderHook(() => useCachedSection('one'), {
31
+ wrapper,
32
+ })
33
+
34
+ const origIsCached = result.current.isCached
35
+ const origLastUpdated = result.current.lastUpdated
36
+ const origRemove = result.current.remove
37
+ const origSyncCachedSections = result.current.syncCachedSections
38
+
39
+ rerender()
40
+
41
+ expect(result.current.isCached).toBe(origIsCached)
42
+ expect(result.current.lastUpdated).toBe(origLastUpdated)
43
+ expect(result.current.remove).toBe(origRemove)
44
+ expect(result.current.syncCachedSections).toBe(origSyncCachedSections)
45
+ })
@@ -0,0 +1,182 @@
1
+ import FDBFactory from 'fake-indexeddb/lib/FDBFactory'
2
+ import { openDB } from 'idb'
3
+ import 'fake-indexeddb/auto'
4
+ import {
5
+ clearSensitiveCaches,
6
+ SECTIONS_DB,
7
+ SECTIONS_STORE,
8
+ } from '../clear-sensitive-caches'
9
+
10
+ // Mocks for CacheStorage API
11
+
12
+ // Returns true if an existing cache is deleted
13
+ const makeCachesDeleteMock = (keys: string[]) => {
14
+ return jest
15
+ .fn()
16
+ .mockImplementation((key) => Promise.resolve(keys.includes(key)))
17
+ }
18
+
19
+ const keysMockDefault = jest.fn().mockImplementation(async () => [])
20
+ const deleteMockDefault = makeCachesDeleteMock([])
21
+ const cachesDefault = {
22
+ keys: keysMockDefault,
23
+ delete: deleteMockDefault,
24
+ // the following to satisfy types:
25
+ has: () => Promise.resolve(true),
26
+ open: () => Promise.resolve(new Cache()),
27
+ match: () => Promise.resolve(new Response()),
28
+ }
29
+ window.caches = cachesDefault
30
+
31
+ afterEach(() => {
32
+ window.caches = cachesDefault
33
+ jest.clearAllMocks()
34
+ })
35
+
36
+ // silence debug logs for these tests
37
+ const originalDebug = console.debug
38
+ beforeAll(() => {
39
+ jest.spyOn(console, 'debug').mockImplementation((...args) => {
40
+ const pattern = /Clearing sensitive caches/
41
+ if (typeof args[0] === 'string' && pattern.test(args[0])) {
42
+ return
43
+ }
44
+ return originalDebug.call(console, ...args)
45
+ })
46
+ })
47
+ afterAll(() => {
48
+ ;(console.debug as jest.Mock).mockRestore()
49
+ })
50
+
51
+ it('does not fail if there are no caches or no sections-db', () => {
52
+ return expect(clearSensitiveCaches()).resolves.toBe(false)
53
+ })
54
+
55
+ it('returns false if caches.keys throws', async () => {
56
+ const spy = jest.fn(() => {
57
+ throw new Error('Security Error')
58
+ })
59
+ window.caches = {
60
+ ...cachesDefault,
61
+ keys: spy,
62
+ }
63
+
64
+ const result = await clearSensitiveCaches()
65
+
66
+ expect(spy).toHaveBeenCalled()
67
+ expect(result).toBe(false)
68
+ })
69
+
70
+ it('clears potentially sensitive caches', async () => {
71
+ const testKeys = ['cache1', 'cache2', 'app-shell', 'other-assets']
72
+ const keysMock = jest
73
+ .fn()
74
+ .mockImplementation(() => Promise.resolve(testKeys))
75
+ const deleteMock = makeCachesDeleteMock(testKeys)
76
+ window.caches = { ...cachesDefault, keys: keysMock, delete: deleteMock }
77
+
78
+ const cachesDeleted = await clearSensitiveCaches()
79
+ expect(cachesDeleted).toBe(true)
80
+
81
+ expect(deleteMock).toHaveBeenCalledTimes(4)
82
+ expect(deleteMock.mock.calls[0][0]).toBe('cache1')
83
+ expect(deleteMock.mock.calls[1][0]).toBe('cache2')
84
+ expect(deleteMock.mock.calls[2][0]).toBe('app-shell')
85
+ expect(deleteMock.mock.calls[3][0]).toBe('other-assets')
86
+ })
87
+
88
+ it('preserves keepable caches', async () => {
89
+ const keysMock = jest
90
+ .fn()
91
+ .mockImplementation(async () => [
92
+ 'cache1',
93
+ 'cache2',
94
+ 'app-shell',
95
+ 'other-assets',
96
+ 'workbox-precache-v2-https://hey.howareya.now/',
97
+ ])
98
+ window.caches = { ...cachesDefault, keys: keysMock }
99
+
100
+ await clearSensitiveCaches()
101
+
102
+ expect(deleteMockDefault).toHaveBeenCalledTimes(4)
103
+ expect(deleteMockDefault.mock.calls[0][0]).toBe('cache1')
104
+ expect(deleteMockDefault.mock.calls[1][0]).toBe('cache2')
105
+ expect(deleteMockDefault.mock.calls[2][0]).toBe('app-shell')
106
+ expect(deleteMockDefault.mock.calls[3][0]).toBe('other-assets')
107
+ expect(deleteMockDefault).not.toHaveBeenCalledWith(
108
+ 'workbox-precache-v2-https://hey.howareya.now/'
109
+ )
110
+ })
111
+
112
+ describe('clears sections-db', () => {
113
+ // Test DB
114
+ function openTestDB(dbName: string) {
115
+ // simplified version of app platform openDB logic
116
+ return openDB(dbName, 1, {
117
+ upgrade(db) {
118
+ db.createObjectStore(SECTIONS_STORE, { keyPath: 'sectionId' })
119
+ },
120
+ })
121
+ }
122
+
123
+ afterEach(() => {
124
+ // reset indexedDB state
125
+ window.indexedDB = new FDBFactory()
126
+ })
127
+
128
+ it('clears sections-db if it exists', async () => {
129
+ // Open and populate test DB
130
+ const db = await openTestDB(SECTIONS_DB)
131
+ await db.put(SECTIONS_STORE, {
132
+ sectionId: 'id-1',
133
+ lastUpdated: new Date(),
134
+ requests: 3,
135
+ })
136
+ await db.put(SECTIONS_STORE, {
137
+ sectionId: 'id-2',
138
+ lastUpdated: new Date(),
139
+ requests: 3,
140
+ })
141
+
142
+ await clearSensitiveCaches()
143
+
144
+ // Sections-db should be cleared
145
+ const allSections = await db.getAll(SECTIONS_STORE)
146
+ expect(allSections).toHaveLength(0)
147
+ })
148
+
149
+ it("doesn't clear sections-db if it doesn't exist and doesn't open a new one", async () => {
150
+ const openMock = jest.fn()
151
+ window.indexedDB.open = openMock
152
+
153
+ expect(await indexedDB.databases()).not.toContain(SECTIONS_DB)
154
+
155
+ await clearSensitiveCaches()
156
+
157
+ expect(openMock).not.toHaveBeenCalled()
158
+ return expect(await indexedDB.databases()).not.toContain(SECTIONS_DB)
159
+ })
160
+
161
+ it("doesn't handle IDB if 'databases' property is not on window.indexedDB", async () => {
162
+ // Open DB -- 'indexedDB.open' _would_ get called in this test
163
+ // if 'databases' property exists
164
+ await openTestDB(SECTIONS_DB)
165
+ const openMock = jest.fn()
166
+ window.indexedDB.open = openMock
167
+
168
+ // Remove 'databases' from indexedDB prototype for this test
169
+ // (simulates Firefox environment)
170
+ const idbProto = Object.getPrototypeOf(window.indexedDB)
171
+ const databases = idbProto.databases
172
+ delete idbProto.databases
173
+
174
+ expect('databases' in window.indexedDB).toBe(false)
175
+ await expect(clearSensitiveCaches()).resolves.toBeDefined()
176
+ expect(openMock).not.toHaveBeenCalled()
177
+
178
+ // Restore indexedDB prototype for later tests
179
+ idbProto.databases = databases
180
+ expect('databases' in window.indexedDB).toBe(true)
181
+ })
182
+ })