Skip to content

Commit f835f06

Browse files
authored
Merge pull request Expensify#705 from callstack-internal/feature/idb-provider-unit-tests
Implement unit tests for IDB provider
2 parents 9408ac8 + 14115ad commit f835f06

17 files changed

Lines changed: 606 additions & 295 deletions

File tree

jest-test-environment.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import JSDOMEnvironment from 'jest-environment-jsdom';
2+
3+
// We need this custom JSDOM environment implementation in order
4+
// to support `structuredClone` in Jest, that is used by `fake-indexeddb` library.
5+
// Reference: https://github.com/jsdom/jsdom/issues/3363#issuecomment-1467894943
6+
export default class FixJSDOMEnvironment extends JSDOMEnvironment {
7+
constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
8+
super(...args);
9+
this.global.structuredClone = structuredClone;
10+
}
11+
}

jest.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ module.exports = {
1010
__DEV__: true,
1111
WebSocket: {},
1212
},
13-
testEnvironment: 'jsdom',
13+
testEnvironment: './jest-test-environment.ts',
14+
setupFiles: ['fake-indexeddb/auto'],
1415
setupFilesAfterEnv: ['./jestSetup.js'],
1516
testTimeout: 60000,
1617
transformIgnorePatterns: ['node_modules/(?!((@)?react-native|@ngneat/falso|uuid)/)'],

jestSetup.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
jest.mock('./lib/storage');
22
jest.mock('./lib/storage/platforms/index.native', () => require('./lib/storage/__mocks__'));
33
jest.mock('./lib/storage/platforms/index', () => require('./lib/storage/__mocks__'));
4-
jest.mock('./lib/storage/providers/IDBKeyValProvider', () => require('./lib/storage/__mocks__'));
54

65
jest.mock('react-native-device-info', () => ({getFreeDiskStorage: () => {}}));
76
jest.mock('react-native-nitro-sqlite', () => ({

lib/storage/InstanceSync/index.web.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const InstanceSync = {
3232
/**
3333
* @param {Function} onStorageKeyChanged Storage synchronization mechanism keeping all opened tabs in sync
3434
*/
35-
init: (onStorageKeyChanged: OnStorageKeyChanged, store: StorageProvider) => {
35+
init: (onStorageKeyChanged: OnStorageKeyChanged, store: StorageProvider<unknown>) => {
3636
storage = store;
3737

3838
// This listener will only be triggered by events coming from other tabs

lib/storage/__mocks__/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import MemoryOnlyProvider, {mockStore, mockSet, setMockStore} from '../providers/MemoryOnlyProvider';
1+
import MemoryOnlyProvider, {mockStore, setMockStore} from '../providers/MemoryOnlyProvider';
22

33
const init = jest.fn(MemoryOnlyProvider.init);
44

@@ -18,7 +18,7 @@ const StorageMock = {
1818
getAllKeys: jest.fn(MemoryOnlyProvider.getAllKeys),
1919
getDatabaseSize: jest.fn(MemoryOnlyProvider.getDatabaseSize),
2020
keepInstancesSync: jest.fn(),
21-
mockSet,
21+
2222
getMockStore: jest.fn(() => mockStore),
2323
setMockStore: jest.fn((data) => setMockStore(data)),
2424
};

lib/storage/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ import type StorageProvider from './providers/types';
77
import * as GlobalSettings from '../GlobalSettings';
88
import decorateWithMetrics from '../metrics';
99

10-
let provider = PlatformStorage;
10+
let provider = PlatformStorage as StorageProvider<unknown>;
1111
let shouldKeepInstancesSync = false;
1212
let finishInitalization: (value?: unknown) => void;
1313
const initPromise = new Promise((resolve) => {
1414
finishInitalization = resolve;
1515
});
1616

1717
type Storage = {
18-
getStorageProvider: () => StorageProvider;
19-
} & Omit<StorageProvider, 'name'>;
18+
getStorageProvider: () => StorageProvider<unknown>;
19+
} & Omit<StorageProvider<unknown>, 'name' | 'store'>;
2020

2121
/**
2222
* Degrade performance by removing the storage provider and only using cache

lib/storage/providers/IDBKeyValProvider/createStore.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {promisifyRequest} from 'idb-keyval';
1+
import * as IDB from 'idb-keyval';
22
import type {UseStore} from 'idb-keyval';
33
import {logInfo} from '../../../Logger';
44

@@ -12,7 +12,7 @@ function createStore(dbName: string, storeName: string): UseStore {
1212
if (dbp) return dbp;
1313
const request = indexedDB.open(dbName);
1414
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
15-
dbp = promisifyRequest(request);
15+
dbp = IDB.promisifyRequest(request);
1616

1717
dbp.then(
1818
(db) => {
@@ -49,7 +49,7 @@ function createStore(dbName: string, storeName: string): UseStore {
4949
updatedDatabase.createObjectStore(storeName);
5050
};
5151

52-
dbp = promisifyRequest(request);
52+
dbp = IDB.promisifyRequest(request);
5353
return dbp;
5454
};
5555

lib/storage/providers/IDBKeyValProvider/index.ts

Lines changed: 96 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import type {UseStore} from 'idb-keyval';
2-
import {set, keys, getMany, setMany, get, clear, del, delMany, promisifyRequest} from 'idb-keyval';
2+
import * as IDB from 'idb-keyval';
33
import utils from '../../../utils';
44
import type StorageProvider from '../types';
55
import type {OnyxKey, OnyxValue} from '../../../types';
66
import createStore from './createStore';
77

8-
// We don't want to initialize the store while the JS bundle loads as idb-keyval will try to use global.indexedDB
9-
// which might not be available in certain environments that load the bundle (e.g. electron main process).
10-
let idbKeyValStore: UseStore;
118
const DB_NAME = 'OnyxDB';
129
const STORE_NAME = 'keyvaluepairs';
1310

14-
const provider: StorageProvider = {
11+
const provider: StorageProvider<UseStore | undefined> = {
12+
// We don't want to initialize the store while the JS bundle loads as idb-keyval will try to use global.indexedDB
13+
// which might not be available in certain environments that load the bundle (e.g. electron main process).
14+
store: undefined,
1515
/**
1616
* The name of the provider that can be printed to the logs
1717
*/
@@ -25,71 +25,120 @@ const provider: StorageProvider = {
2525
if (newIdbKeyValStore == null) {
2626
throw Error('IDBKeyVal store could not be created');
2727
}
28-
29-
idbKeyValStore = newIdbKeyValStore;
28+
provider.store = newIdbKeyValStore;
3029
},
3130

32-
setItem: (key, value) => {
31+
setItem(key, value) {
32+
if (!provider.store) {
33+
throw new Error('Store not initialized!');
34+
}
35+
3336
if (value === null) {
34-
provider.removeItem(key);
37+
return provider.removeItem(key);
38+
}
39+
40+
return IDB.set(key, value, provider.store);
41+
},
42+
multiGet(keysParam) {
43+
if (!provider.store) {
44+
throw new Error('Store not initialized!');
3545
}
3646

37-
return set(key, value, idbKeyValStore);
47+
return IDB.getMany(keysParam, provider.store).then((values) => values.map((value, index) => [keysParam[index], value]));
3848
},
39-
multiGet: (keysParam) => getMany(keysParam, idbKeyValStore).then((values) => values.map((value, index) => [keysParam[index], value])),
40-
multiMerge: (pairs) =>
41-
idbKeyValStore('readwrite', (store) => {
49+
multiMerge(pairs) {
50+
if (!provider.store) {
51+
throw new Error('Store not initialized!');
52+
}
53+
54+
return provider.store('readwrite', (store) => {
4255
// Note: we are using the manual store transaction here, to fit the read and update
4356
// of the items in one transaction to achieve best performance.
44-
const getValues = Promise.all(pairs.map(([key]) => promisifyRequest<OnyxValue<OnyxKey>>(store.get(key))));
57+
const getValues = Promise.all(pairs.map(([key]) => IDB.promisifyRequest<OnyxValue<OnyxKey>>(store.get(key))));
4558

4659
return getValues.then((values) => {
47-
const pairsWithoutNull = pairs.filter(([key, value]) => {
60+
for (const [index, [key, value]] of pairs.entries()) {
4861
if (value === null) {
49-
provider.removeItem(key);
50-
return false;
51-
}
52-
53-
return true;
54-
});
62+
store.delete(key);
63+
} else {
64+
const newValue = utils.fastMerge(values[index] as Record<string, unknown>, value as Record<string, unknown>, {
65+
shouldRemoveNestedNulls: true,
66+
objectRemovalMode: 'replace',
67+
}).result;
5568

56-
const upsertMany = pairsWithoutNull.map(([key, value], index) => {
57-
const prev = values[index];
58-
const newValue = utils.fastMerge(prev as Record<string, unknown>, value as Record<string, unknown>, {
59-
shouldRemoveNestedNulls: true,
60-
objectRemovalMode: 'replace',
61-
}).result;
69+
store.put(newValue, key);
70+
}
71+
}
6272

63-
return promisifyRequest(store.put(newValue, key));
64-
});
65-
return Promise.all(upsertMany);
73+
return IDB.promisifyRequest(store.transaction);
6674
});
67-
}).then(() => undefined),
75+
});
76+
},
6877
mergeItem(key, change) {
6978
// Since Onyx already merged the existing value with the changes, we can just set the value directly.
7079
return provider.multiMerge([[key, change]]);
7180
},
72-
multiSet: (pairs) => {
73-
const pairsWithoutNull = pairs.filter(([key, value]) => {
74-
if (value === null) {
75-
provider.removeItem(key);
76-
return false;
81+
multiSet(pairs) {
82+
if (!provider.store) {
83+
throw new Error('Store not initialized!');
84+
}
85+
86+
return provider.store('readwrite', (store) => {
87+
for (const [key, value] of pairs) {
88+
if (value === null) {
89+
store.delete(key);
90+
} else {
91+
store.put(value, key);
92+
}
7793
}
7894

79-
return true;
80-
}) as Array<[IDBValidKey, unknown]>;
95+
return IDB.promisifyRequest(store.transaction);
96+
});
97+
},
98+
clear() {
99+
if (!provider.store) {
100+
throw new Error('Store not initialized!');
101+
}
102+
103+
return IDB.clear(provider.store);
104+
},
105+
getAllKeys() {
106+
if (!provider.store) {
107+
throw new Error('Store not initialized!');
108+
}
109+
110+
return IDB.keys(provider.store);
111+
},
112+
getItem(key) {
113+
if (!provider.store) {
114+
throw new Error('Store not initialized!');
115+
}
81116

82-
return setMany(pairsWithoutNull, idbKeyValStore);
117+
return (
118+
IDB.get(key, provider.store)
119+
// idb-keyval returns undefined for missing items, but this needs to return null so that idb-keyval does the same thing as SQLiteStorage.
120+
.then((val) => (val === undefined ? null : val))
121+
);
122+
},
123+
removeItem(key) {
124+
if (!provider.store) {
125+
throw new Error('Store not initialized!');
126+
}
127+
128+
return IDB.del(key, provider.store);
129+
},
130+
removeItems(keysParam) {
131+
if (!provider.store) {
132+
throw new Error('Store not initialized!');
133+
}
134+
135+
return IDB.delMany(keysParam, provider.store);
83136
},
84-
clear: () => clear(idbKeyValStore),
85-
getAllKeys: () => keys(idbKeyValStore),
86-
getItem: (key) =>
87-
get(key, idbKeyValStore)
88-
// idb-keyval returns undefined for missing items, but this needs to return null so that idb-keyval does the same thing as SQLiteStorage.
89-
.then((val) => (val === undefined ? null : val)),
90-
removeItem: (key) => del(key, idbKeyValStore),
91-
removeItems: (keysParam) => delMany(keysParam, idbKeyValStore),
92137
getDatabaseSize() {
138+
if (!provider.store) {
139+
throw new Error('Store is not initialized!');
140+
}
141+
93142
if (!window.navigator || !window.navigator.storage) {
94143
throw new Error('StorageManager browser API unavailable');
95144
}

0 commit comments

Comments
 (0)