@teardown/react-native 2.0.11 → 2.0.13
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.
- package/package.json +2 -2
- package/src/clients/api/api.client.ts +9 -8
- package/src/clients/identity/identity.client.test.ts +4 -4
- package/src/clients/identity/identity.client.ts +74 -70
- package/src/clients/storage/adapters/mmkv.adapter.ts +2 -4
- package/src/clients/storage/adapters/storage-adapters.test.ts +579 -0
- package/src/providers/teardown.provider.tsx +2 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teardown/react-native",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.13",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"react": "*",
|
|
76
76
|
"react-native": "*",
|
|
77
77
|
"react-native-device-info": "^15",
|
|
78
|
-
"react-native-mmkv": "^4
|
|
78
|
+
"react-native-mmkv": "^4",
|
|
79
79
|
"typescript": "*"
|
|
80
80
|
},
|
|
81
81
|
"peerDependenciesMeta": {
|
|
@@ -5,7 +5,7 @@ import type { StorageClient } from "../storage";
|
|
|
5
5
|
|
|
6
6
|
export type { Eden, IngestApi };
|
|
7
7
|
|
|
8
|
-
const TEARDOWN_INGEST_URL = "
|
|
8
|
+
const TEARDOWN_INGEST_URL = "https://ingest.teardown.dev";
|
|
9
9
|
const TEARDOWN_API_KEY_HEADER = "td-api-key";
|
|
10
10
|
const TEARDOWN_ORG_ID_HEADER = "td-org-id";
|
|
11
11
|
const TEARDOWN_PROJECT_ID_HEADER = "td-project-id";
|
|
@@ -26,8 +26,10 @@ export type ApiClientOptions = {
|
|
|
26
26
|
project_id: string;
|
|
27
27
|
/**
|
|
28
28
|
* The slug of the environment.
|
|
29
|
+
*
|
|
30
|
+
* @default "production"
|
|
29
31
|
*/
|
|
30
|
-
environment_slug
|
|
32
|
+
environment_slug?: string | null;
|
|
31
33
|
/**
|
|
32
34
|
* A function that will be called before each request.
|
|
33
35
|
* @param endpoint The endpoint being requested.
|
|
@@ -36,7 +38,6 @@ export type ApiClientOptions = {
|
|
|
36
38
|
*/
|
|
37
39
|
onRequest?: (endpoint: IngestApi.Endpoints, options: IngestApi.RequestOptions) => Promise<IngestApi.RequestOptions>;
|
|
38
40
|
|
|
39
|
-
|
|
40
41
|
/**
|
|
41
42
|
* The URL of the ingest API.
|
|
42
43
|
* @default https://ingest.teardown.dev
|
|
@@ -54,10 +55,10 @@ export class ApiClient {
|
|
|
54
55
|
) {
|
|
55
56
|
this.client = IngestApi.client(options.ingestUrl ?? TEARDOWN_INGEST_URL, {
|
|
56
57
|
headers: {
|
|
57
|
-
[TEARDOWN_API_KEY_HEADER]: `Bearer ${this.
|
|
58
|
-
[TEARDOWN_ORG_ID_HEADER]: this.
|
|
59
|
-
[TEARDOWN_PROJECT_ID_HEADER]: this.
|
|
60
|
-
[TEARDOWN_ENVIRONMENT_SLUG_HEADER]: this.
|
|
58
|
+
[TEARDOWN_API_KEY_HEADER]: `Bearer ${this.apiKey}`,
|
|
59
|
+
[TEARDOWN_ORG_ID_HEADER]: this.orgId,
|
|
60
|
+
[TEARDOWN_PROJECT_ID_HEADER]: this.projectId,
|
|
61
|
+
[TEARDOWN_ENVIRONMENT_SLUG_HEADER]: this.environmentSlug,
|
|
61
62
|
},
|
|
62
63
|
});
|
|
63
64
|
}
|
|
@@ -75,6 +76,6 @@ export class ApiClient {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
get environmentSlug(): string {
|
|
78
|
-
return this.options.environment_slug;
|
|
79
|
+
return this.options.environment_slug ?? "production";
|
|
79
80
|
}
|
|
80
81
|
}
|
|
@@ -491,7 +491,7 @@ describe("IdentityClient", () => {
|
|
|
491
491
|
await client.identify(persona);
|
|
492
492
|
|
|
493
493
|
const lastCall = mockApi.getLastCall();
|
|
494
|
-
expect((lastCall.config.body as {
|
|
494
|
+
expect((lastCall.config.body as { user?: Persona }).user).toEqual(persona);
|
|
495
495
|
});
|
|
496
496
|
|
|
497
497
|
test("passes undefined persona when not provided", async () => {
|
|
@@ -500,7 +500,7 @@ describe("IdentityClient", () => {
|
|
|
500
500
|
await client.identify();
|
|
501
501
|
|
|
502
502
|
const lastCall = mockApi.getLastCall();
|
|
503
|
-
expect((lastCall.config.body as {
|
|
503
|
+
expect((lastCall.config.body as { user?: Persona }).user).toBeUndefined();
|
|
504
504
|
});
|
|
505
505
|
|
|
506
506
|
test("passes partial persona data", async () => {
|
|
@@ -511,7 +511,7 @@ describe("IdentityClient", () => {
|
|
|
511
511
|
await client.identify(persona);
|
|
512
512
|
|
|
513
513
|
const lastCall = mockApi.getLastCall();
|
|
514
|
-
expect((lastCall.config.body as {
|
|
514
|
+
expect((lastCall.config.body as { user?: Persona }).user).toEqual({ email: "only-email@test.com" });
|
|
515
515
|
});
|
|
516
516
|
|
|
517
517
|
test("calls correct API endpoint", async () => {
|
|
@@ -1086,7 +1086,7 @@ describe("IdentityClient", () => {
|
|
|
1086
1086
|
await client.identify({});
|
|
1087
1087
|
|
|
1088
1088
|
const lastCall = mockApi.getLastCall();
|
|
1089
|
-
expect(lastCall.config.body.
|
|
1089
|
+
expect((lastCall.config.body as { user?: Persona }).user).toEqual({});
|
|
1090
1090
|
});
|
|
1091
1091
|
|
|
1092
1092
|
test("handles very long session tokens", async () => {
|
|
@@ -21,9 +21,9 @@ export type IdentityUser = {
|
|
|
21
21
|
token: string;
|
|
22
22
|
version_info: {
|
|
23
23
|
status: IdentifyVersionStatusEnum;
|
|
24
|
-
update: null
|
|
25
|
-
}
|
|
26
|
-
}
|
|
24
|
+
update: null;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
27
|
|
|
28
28
|
export const UnidentifiedSessionStateSchema = z.object({
|
|
29
29
|
type: z.literal("unidentified"),
|
|
@@ -32,7 +32,6 @@ export const IdentifyingSessionStateSchema = z.object({
|
|
|
32
32
|
type: z.literal("identifying"),
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
|
|
36
35
|
export const UpdateVersionStatusBodySchema = z.object({
|
|
37
36
|
version: z.string(),
|
|
38
37
|
});
|
|
@@ -59,7 +58,11 @@ export const IdentifiedSessionStateSchema = z.object({
|
|
|
59
58
|
}),
|
|
60
59
|
});
|
|
61
60
|
|
|
62
|
-
export const IdentifyStateSchema = z.discriminatedUnion("type", [
|
|
61
|
+
export const IdentifyStateSchema = z.discriminatedUnion("type", [
|
|
62
|
+
UnidentifiedSessionStateSchema,
|
|
63
|
+
IdentifyingSessionStateSchema,
|
|
64
|
+
IdentifiedSessionStateSchema,
|
|
65
|
+
]);
|
|
63
66
|
export type IdentifyState = z.infer<typeof IdentifyStateSchema>;
|
|
64
67
|
|
|
65
68
|
export type UnidentifiedSessionState = z.infer<typeof UnidentifiedSessionStateSchema>;
|
|
@@ -70,7 +73,6 @@ export type IdentifyStateChangeEvents = {
|
|
|
70
73
|
IDENTIFY_STATE_CHANGED: (state: IdentifyState) => void;
|
|
71
74
|
};
|
|
72
75
|
|
|
73
|
-
|
|
74
76
|
export const IDENTIFY_STORAGE_KEY = "IDENTIFY_STATE";
|
|
75
77
|
|
|
76
78
|
export class IdentityClient {
|
|
@@ -141,7 +143,6 @@ export class IdentityClient {
|
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
private setIdentifyState(newState: IdentifyState): void {
|
|
144
|
-
|
|
145
146
|
if (this.identifyState.type === newState.type) {
|
|
146
147
|
this.logger.debug(`Identify state already set: ${this.identifyState.type}`);
|
|
147
148
|
return;
|
|
@@ -216,78 +217,81 @@ export class IdentityClient {
|
|
|
216
217
|
const previousState = this.identifyState;
|
|
217
218
|
this.setIdentifyState({ type: "identifying" });
|
|
218
219
|
|
|
219
|
-
return this.tryCatch(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
body: {
|
|
234
|
-
user,
|
|
235
|
-
device: {
|
|
236
|
-
timestamp: deviceInfo.timestamp,
|
|
237
|
-
os: deviceInfo.os,
|
|
238
|
-
application: deviceInfo.application,
|
|
239
|
-
hardware: deviceInfo.hardware,
|
|
240
|
-
update: null,
|
|
220
|
+
return this.tryCatch(
|
|
221
|
+
async () => {
|
|
222
|
+
this.logger.debug("Getting device ID...");
|
|
223
|
+
const deviceId = await this.device.getDeviceId();
|
|
224
|
+
const deviceInfo = await this.device.getDeviceInfo();
|
|
225
|
+
this.logger.debug("Calling identify API...");
|
|
226
|
+
const response = await this.api.client("/v1/identify", {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: {
|
|
229
|
+
"td-api-key": this.api.apiKey,
|
|
230
|
+
"td-org-id": this.api.orgId,
|
|
231
|
+
"td-project-id": this.api.projectId,
|
|
232
|
+
"td-environment-slug": "production",
|
|
233
|
+
"td-device-id": deviceId,
|
|
241
234
|
},
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
235
|
+
body: {
|
|
236
|
+
user,
|
|
237
|
+
device: {
|
|
238
|
+
timestamp: deviceInfo.timestamp,
|
|
239
|
+
os: deviceInfo.os,
|
|
240
|
+
application: deviceInfo.application,
|
|
241
|
+
hardware: deviceInfo.hardware,
|
|
242
|
+
update: null,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
this.logger.debug(`Identify API response received`);
|
|
248
|
+
if (response.error != null) {
|
|
249
|
+
this.logger.warn("Identify API error", response.error.status, response.error.value);
|
|
250
|
+
this.setIdentifyState(previousState);
|
|
251
|
+
|
|
252
|
+
if (response.error.status === 422) {
|
|
253
|
+
this.logger.warn("422 Error identifying user", response.error.value);
|
|
254
|
+
return {
|
|
255
|
+
success: false,
|
|
256
|
+
error: response.error.value.message ?? "Unknown error",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const value = response.error.value;
|
|
252
261
|
return {
|
|
253
262
|
success: false,
|
|
254
|
-
error:
|
|
263
|
+
error: value?.error?.message ?? "Unknown error",
|
|
255
264
|
};
|
|
256
265
|
}
|
|
257
266
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
error: value?.error?.message ?? "Unknown error",
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
this.setIdentifyState({
|
|
266
|
-
type: "identified",
|
|
267
|
-
session: response.data.data,
|
|
268
|
-
version_info: {
|
|
269
|
-
status: response.data.data.version_info.status,
|
|
270
|
-
update: null,
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
return {
|
|
275
|
-
success: true,
|
|
276
|
-
data: {
|
|
277
|
-
...response.data.data,
|
|
267
|
+
this.setIdentifyState({
|
|
268
|
+
type: "identified",
|
|
269
|
+
session: response.data.data,
|
|
278
270
|
version_info: {
|
|
279
271
|
status: response.data.data.version_info.status,
|
|
280
272
|
update: null,
|
|
281
273
|
},
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
success: true,
|
|
278
|
+
data: {
|
|
279
|
+
...response.data.data,
|
|
280
|
+
version_info: {
|
|
281
|
+
status: response.data.data.version_info.status,
|
|
282
|
+
update: null,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
(error) => {
|
|
288
|
+
this.logger.error("Error identifying user", error);
|
|
289
|
+
this.setIdentifyState(previousState);
|
|
290
|
+
return {
|
|
291
|
+
success: false,
|
|
292
|
+
error: error.message,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
);
|
|
292
296
|
}
|
|
293
297
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import * as MMKV from "react-native-mmkv";
|
|
2
2
|
import { StorageAdapter, type SupportedStorage } from "./storage.adpater-interface";
|
|
3
3
|
|
|
4
|
-
|
|
5
4
|
export class MMKVStorageAdapter extends StorageAdapter {
|
|
6
5
|
createStorage(storageKey: string): SupportedStorage {
|
|
7
|
-
const storage = MMKV.createMMKV({ id: storageKey });
|
|
6
|
+
const storage = MMKV.createMMKV({ id: storageKey, encryptionKey: storageKey });
|
|
8
7
|
return {
|
|
9
8
|
preload: () => {
|
|
10
9
|
storage.getAllKeys();
|
|
@@ -16,5 +15,4 @@ export class MMKVStorageAdapter extends StorageAdapter {
|
|
|
16
15
|
keys: () => storage.getAllKeys(),
|
|
17
16
|
};
|
|
18
17
|
}
|
|
19
|
-
|
|
20
|
-
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { SupportedStorage } from "./storage.adpater-interface";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Mock MMKV storage instance
|
|
6
|
+
*/
|
|
7
|
+
function createMockMMKVInstance() {
|
|
8
|
+
const store = new Map<string, string>();
|
|
9
|
+
return {
|
|
10
|
+
getString: mock((key: string) => store.get(key)),
|
|
11
|
+
set: mock((key: string, value: string) => {
|
|
12
|
+
store.set(key, value);
|
|
13
|
+
}),
|
|
14
|
+
remove: mock((key: string) => {
|
|
15
|
+
store.delete(key);
|
|
16
|
+
}),
|
|
17
|
+
clearAll: mock(() => {
|
|
18
|
+
store.clear();
|
|
19
|
+
}),
|
|
20
|
+
getAllKeys: mock(() => Array.from(store.keys())),
|
|
21
|
+
_store: store, // expose for test assertions
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Mock AsyncStorage
|
|
27
|
+
*/
|
|
28
|
+
function createMockAsyncStorage() {
|
|
29
|
+
const store = new Map<string, string>();
|
|
30
|
+
return {
|
|
31
|
+
getAllKeys: mock(async () => Array.from(store.keys())),
|
|
32
|
+
multiGet: mock(async (keys: string[]) =>
|
|
33
|
+
keys.map((k) => [k, store.get(k) ?? null] as [string, string | null])
|
|
34
|
+
),
|
|
35
|
+
setItem: mock(async (key: string, value: string) => {
|
|
36
|
+
store.set(key, value);
|
|
37
|
+
}),
|
|
38
|
+
removeItem: mock(async (key: string) => {
|
|
39
|
+
store.delete(key);
|
|
40
|
+
}),
|
|
41
|
+
multiRemove: mock(async (keys: string[]) => {
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
store.delete(key);
|
|
44
|
+
}
|
|
45
|
+
}),
|
|
46
|
+
_store: store,
|
|
47
|
+
_reset: () => {
|
|
48
|
+
store.clear();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Create mock instances
|
|
54
|
+
const mockMMKVInstance = createMockMMKVInstance();
|
|
55
|
+
const mockAsyncStorage = createMockAsyncStorage();
|
|
56
|
+
|
|
57
|
+
// Mock the react-native-mmkv module
|
|
58
|
+
mock.module("react-native-mmkv", () => ({
|
|
59
|
+
createMMKV: mock(() => mockMMKVInstance),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// Mock the @react-native-async-storage/async-storage module
|
|
63
|
+
mock.module("@react-native-async-storage/async-storage", () => ({
|
|
64
|
+
default: mockAsyncStorage,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// Import adapters AFTER mocking
|
|
68
|
+
const { MMKVStorageAdapter } = await import("./mmkv.adapter");
|
|
69
|
+
const { AsyncStorageAdapter } = await import("./async-storage.adapter");
|
|
70
|
+
|
|
71
|
+
describe("MMKVStorageAdapter", () => {
|
|
72
|
+
let adapter: InstanceType<typeof MMKVStorageAdapter>;
|
|
73
|
+
let storage: SupportedStorage;
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
mockMMKVInstance._store.clear();
|
|
77
|
+
mockMMKVInstance.getString.mockClear();
|
|
78
|
+
mockMMKVInstance.set.mockClear();
|
|
79
|
+
mockMMKVInstance.remove.mockClear();
|
|
80
|
+
mockMMKVInstance.clearAll.mockClear();
|
|
81
|
+
mockMMKVInstance.getAllKeys.mockClear();
|
|
82
|
+
|
|
83
|
+
adapter = new MMKVStorageAdapter();
|
|
84
|
+
storage = adapter.createStorage("test-store");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("createStorage", () => {
|
|
88
|
+
test("creates storage with correct storageKey", () => {
|
|
89
|
+
const { createMMKV } = require("react-native-mmkv");
|
|
90
|
+
adapter.createStorage("my-app-store");
|
|
91
|
+
expect(createMMKV).toHaveBeenCalledWith({
|
|
92
|
+
id: "my-app-store",
|
|
93
|
+
encryptionKey: "my-app-store",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("preload", () => {
|
|
99
|
+
test("calls getAllKeys to warm up storage", () => {
|
|
100
|
+
storage.preload();
|
|
101
|
+
expect(mockMMKVInstance.getAllKeys).toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("is synchronous (returns void)", () => {
|
|
105
|
+
const result = storage.preload();
|
|
106
|
+
expect(result).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("getItem", () => {
|
|
111
|
+
test("returns null for non-existent key", () => {
|
|
112
|
+
expect(storage.getItem("missing")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns stored value", () => {
|
|
116
|
+
mockMMKVInstance._store.set("existing", "value");
|
|
117
|
+
expect(storage.getItem("existing")).toBe("value");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("converts undefined to null", () => {
|
|
121
|
+
// MMKV returns undefined for missing keys
|
|
122
|
+
expect(storage.getItem("undefined-key")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("handles empty string value", () => {
|
|
126
|
+
mockMMKVInstance._store.set("empty", "");
|
|
127
|
+
expect(storage.getItem("empty")).toBe("");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("handles JSON string value", () => {
|
|
131
|
+
const json = JSON.stringify({ nested: { data: [1, 2, 3] } });
|
|
132
|
+
mockMMKVInstance._store.set("json", json);
|
|
133
|
+
expect(storage.getItem("json")).toBe(json);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("handles unicode values", () => {
|
|
137
|
+
mockMMKVInstance._store.set("unicode", "日本語テスト🎉");
|
|
138
|
+
expect(storage.getItem("unicode")).toBe("日本語テスト🎉");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("setItem", () => {
|
|
143
|
+
test("stores value", () => {
|
|
144
|
+
storage.setItem("key", "value");
|
|
145
|
+
expect(mockMMKVInstance.set).toHaveBeenCalledWith("key", "value");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("overwrites existing value", () => {
|
|
149
|
+
storage.setItem("key", "original");
|
|
150
|
+
storage.setItem("key", "updated");
|
|
151
|
+
expect(mockMMKVInstance._store.get("key")).toBe("updated");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("handles empty string key", () => {
|
|
155
|
+
storage.setItem("", "empty-key-value");
|
|
156
|
+
expect(mockMMKVInstance.set).toHaveBeenCalledWith("", "empty-key-value");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("handles empty string value", () => {
|
|
160
|
+
storage.setItem("key", "");
|
|
161
|
+
expect(mockMMKVInstance.set).toHaveBeenCalledWith("key", "");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("handles large values", () => {
|
|
165
|
+
const largeValue = "x".repeat(10000);
|
|
166
|
+
storage.setItem("large", largeValue);
|
|
167
|
+
expect(mockMMKVInstance.set).toHaveBeenCalledWith("large", largeValue);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("removeItem", () => {
|
|
172
|
+
test("removes existing item", () => {
|
|
173
|
+
mockMMKVInstance._store.set("key", "value");
|
|
174
|
+
storage.removeItem("key");
|
|
175
|
+
expect(mockMMKVInstance.remove).toHaveBeenCalledWith("key");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("handles removing non-existent key", () => {
|
|
179
|
+
storage.removeItem("non-existent");
|
|
180
|
+
expect(mockMMKVInstance.remove).toHaveBeenCalledWith("non-existent");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("clear", () => {
|
|
185
|
+
test("clears all items", () => {
|
|
186
|
+
mockMMKVInstance._store.set("key1", "value1");
|
|
187
|
+
mockMMKVInstance._store.set("key2", "value2");
|
|
188
|
+
storage.clear();
|
|
189
|
+
expect(mockMMKVInstance.clearAll).toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("handles clearing empty storage", () => {
|
|
193
|
+
storage.clear();
|
|
194
|
+
expect(mockMMKVInstance.clearAll).toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("keys", () => {
|
|
199
|
+
test("returns empty array for empty storage", () => {
|
|
200
|
+
expect(storage.keys()).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("returns all keys", () => {
|
|
204
|
+
mockMMKVInstance._store.set("key1", "value1");
|
|
205
|
+
mockMMKVInstance._store.set("key2", "value2");
|
|
206
|
+
const keys = storage.keys();
|
|
207
|
+
expect(keys).toContain("key1");
|
|
208
|
+
expect(keys).toContain("key2");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("AsyncStorageAdapter", () => {
|
|
214
|
+
let adapter: InstanceType<typeof AsyncStorageAdapter>;
|
|
215
|
+
let storage: SupportedStorage;
|
|
216
|
+
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
mockAsyncStorage._reset();
|
|
219
|
+
mockAsyncStorage.getAllKeys.mockClear();
|
|
220
|
+
mockAsyncStorage.multiGet.mockClear();
|
|
221
|
+
mockAsyncStorage.setItem.mockClear();
|
|
222
|
+
mockAsyncStorage.removeItem.mockClear();
|
|
223
|
+
mockAsyncStorage.multiRemove.mockClear();
|
|
224
|
+
|
|
225
|
+
adapter = new AsyncStorageAdapter();
|
|
226
|
+
storage = adapter.createStorage("test-store");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("preload/hydration", () => {
|
|
230
|
+
test("hydrates cache from AsyncStorage", async () => {
|
|
231
|
+
mockAsyncStorage._store.set("test-store:key1", "value1");
|
|
232
|
+
mockAsyncStorage._store.set("test-store:key2", "value2");
|
|
233
|
+
mockAsyncStorage._store.set("other-store:key3", "value3");
|
|
234
|
+
|
|
235
|
+
await storage.preload();
|
|
236
|
+
|
|
237
|
+
expect(storage.getItem("key1")).toBe("value1");
|
|
238
|
+
expect(storage.getItem("key2")).toBe("value2");
|
|
239
|
+
expect(storage.getItem("key3")).toBeNull(); // different prefix
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("only hydrates once", async () => {
|
|
243
|
+
await storage.preload();
|
|
244
|
+
await storage.preload();
|
|
245
|
+
await storage.preload();
|
|
246
|
+
|
|
247
|
+
expect(mockAsyncStorage.getAllKeys).toHaveBeenCalledTimes(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("filters keys by storageKey prefix", async () => {
|
|
251
|
+
mockAsyncStorage._store.set("test-store:mykey", "myvalue");
|
|
252
|
+
mockAsyncStorage._store.set("another-store:otherkey", "othervalue");
|
|
253
|
+
|
|
254
|
+
await storage.preload();
|
|
255
|
+
|
|
256
|
+
expect(mockAsyncStorage.multiGet).toHaveBeenCalledWith(["test-store:mykey"]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("handles AsyncStorage error gracefully", async () => {
|
|
260
|
+
mockAsyncStorage.getAllKeys.mockImplementationOnce(async () => {
|
|
261
|
+
throw new Error("Storage error");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Should not throw
|
|
265
|
+
await storage.preload();
|
|
266
|
+
|
|
267
|
+
// Cache remains empty
|
|
268
|
+
expect(storage.keys()).toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("sets hydrated flag even on error", async () => {
|
|
272
|
+
mockAsyncStorage.getAllKeys.mockImplementationOnce(async () => {
|
|
273
|
+
throw new Error("Storage error");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await storage.preload();
|
|
277
|
+
// Second call should not hit AsyncStorage
|
|
278
|
+
await storage.preload();
|
|
279
|
+
|
|
280
|
+
expect(mockAsyncStorage.getAllKeys).toHaveBeenCalledTimes(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("handles null values from multiGet", async () => {
|
|
284
|
+
mockAsyncStorage._store.set("test-store:key1", "value1");
|
|
285
|
+
mockAsyncStorage.multiGet.mockImplementationOnce(async () => [
|
|
286
|
+
["test-store:key1", "value1"],
|
|
287
|
+
["test-store:key2", null], // null value
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
await storage.preload();
|
|
291
|
+
|
|
292
|
+
expect(storage.getItem("key1")).toBe("value1");
|
|
293
|
+
expect(storage.getItem("key2")).toBeNull();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("getItem", () => {
|
|
298
|
+
test("returns null before preload", () => {
|
|
299
|
+
mockAsyncStorage._store.set("test-store:key", "value");
|
|
300
|
+
expect(storage.getItem("key")).toBeNull();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("returns cached value after preload", async () => {
|
|
304
|
+
mockAsyncStorage._store.set("test-store:key", "value");
|
|
305
|
+
await storage.preload();
|
|
306
|
+
expect(storage.getItem("key")).toBe("value");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("returns value set before preload", () => {
|
|
310
|
+
storage.setItem("key", "value");
|
|
311
|
+
expect(storage.getItem("key")).toBe("value");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("returns null for non-existent key", async () => {
|
|
315
|
+
await storage.preload();
|
|
316
|
+
expect(storage.getItem("missing")).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("setItem", () => {
|
|
321
|
+
test("stores value in cache immediately", () => {
|
|
322
|
+
storage.setItem("key", "value");
|
|
323
|
+
expect(storage.getItem("key")).toBe("value");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("persists to AsyncStorage with prefixed key", async () => {
|
|
327
|
+
storage.setItem("key", "value");
|
|
328
|
+
// Wait for async operation
|
|
329
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
330
|
+
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith("test-store:key", "value");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("overwrites existing value", async () => {
|
|
334
|
+
storage.setItem("key", "original");
|
|
335
|
+
storage.setItem("key", "updated");
|
|
336
|
+
|
|
337
|
+
expect(storage.getItem("key")).toBe("updated");
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
339
|
+
expect(mockAsyncStorage.setItem).toHaveBeenLastCalledWith("test-store:key", "updated");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("handles AsyncStorage error gracefully", async () => {
|
|
343
|
+
mockAsyncStorage.setItem.mockImplementationOnce(async () => {
|
|
344
|
+
throw new Error("Write error");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Should not throw
|
|
348
|
+
storage.setItem("key", "value");
|
|
349
|
+
|
|
350
|
+
// Value should still be in cache
|
|
351
|
+
expect(storage.getItem("key")).toBe("value");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe("removeItem", () => {
|
|
356
|
+
test("removes from cache immediately", async () => {
|
|
357
|
+
await storage.preload();
|
|
358
|
+
storage.setItem("key", "value");
|
|
359
|
+
storage.removeItem("key");
|
|
360
|
+
expect(storage.getItem("key")).toBeNull();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("removes from AsyncStorage with prefixed key", async () => {
|
|
364
|
+
storage.setItem("key", "value");
|
|
365
|
+
storage.removeItem("key");
|
|
366
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
367
|
+
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith("test-store:key");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("handles removing non-existent key", async () => {
|
|
371
|
+
storage.removeItem("non-existent");
|
|
372
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
373
|
+
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith("test-store:non-existent");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("handles AsyncStorage error gracefully", async () => {
|
|
377
|
+
mockAsyncStorage.removeItem.mockImplementationOnce(async () => {
|
|
378
|
+
throw new Error("Remove error");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
storage.setItem("key", "value");
|
|
382
|
+
// Should not throw
|
|
383
|
+
storage.removeItem("key");
|
|
384
|
+
|
|
385
|
+
// Value should be removed from cache
|
|
386
|
+
expect(storage.getItem("key")).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("clear", () => {
|
|
391
|
+
test("clears cache immediately", async () => {
|
|
392
|
+
storage.setItem("key1", "value1");
|
|
393
|
+
storage.setItem("key2", "value2");
|
|
394
|
+
storage.clear();
|
|
395
|
+
|
|
396
|
+
expect(storage.keys()).toEqual([]);
|
|
397
|
+
expect(storage.getItem("key1")).toBeNull();
|
|
398
|
+
expect(storage.getItem("key2")).toBeNull();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("calls multiRemove with prefixed keys", async () => {
|
|
402
|
+
storage.setItem("key1", "value1");
|
|
403
|
+
storage.setItem("key2", "value2");
|
|
404
|
+
storage.clear();
|
|
405
|
+
|
|
406
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
407
|
+
expect(mockAsyncStorage.multiRemove).toHaveBeenCalledWith(
|
|
408
|
+
expect.arrayContaining(["test-store:key1", "test-store:key2"])
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("handles clearing empty storage", async () => {
|
|
413
|
+
storage.clear();
|
|
414
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
415
|
+
expect(mockAsyncStorage.multiRemove).toHaveBeenCalledWith([]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("handles AsyncStorage error gracefully", async () => {
|
|
419
|
+
mockAsyncStorage.multiRemove.mockImplementationOnce(async () => {
|
|
420
|
+
throw new Error("Clear error");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
storage.setItem("key", "value");
|
|
424
|
+
// Should not throw
|
|
425
|
+
storage.clear();
|
|
426
|
+
|
|
427
|
+
// Cache should still be cleared
|
|
428
|
+
expect(storage.keys()).toEqual([]);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("keys", () => {
|
|
433
|
+
test("returns empty array for empty cache", () => {
|
|
434
|
+
expect(storage.keys()).toEqual([]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("returns keys from cache", () => {
|
|
438
|
+
storage.setItem("key1", "value1");
|
|
439
|
+
storage.setItem("key2", "value2");
|
|
440
|
+
const keys = storage.keys();
|
|
441
|
+
expect(keys).toHaveLength(2);
|
|
442
|
+
expect(keys).toContain("key1");
|
|
443
|
+
expect(keys).toContain("key2");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("reflects removals", () => {
|
|
447
|
+
storage.setItem("key1", "value1");
|
|
448
|
+
storage.setItem("key2", "value2");
|
|
449
|
+
storage.removeItem("key1");
|
|
450
|
+
expect(storage.keys()).toEqual(["key2"]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("returns unprefixed keys", async () => {
|
|
454
|
+
mockAsyncStorage._store.set("test-store:prefixed-key", "value");
|
|
455
|
+
await storage.preload();
|
|
456
|
+
expect(storage.keys()).toContain("prefixed-key");
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe("key prefixing isolation", () => {
|
|
461
|
+
test("different storageKeys are isolated", async () => {
|
|
462
|
+
mockAsyncStorage._store.set("store-a:key", "value-a");
|
|
463
|
+
mockAsyncStorage._store.set("store-b:key", "value-b");
|
|
464
|
+
|
|
465
|
+
const storageA = adapter.createStorage("store-a");
|
|
466
|
+
const storageB = adapter.createStorage("store-b");
|
|
467
|
+
|
|
468
|
+
await storageA.preload();
|
|
469
|
+
await storageB.preload();
|
|
470
|
+
|
|
471
|
+
expect(storageA.getItem("key")).toBe("value-a");
|
|
472
|
+
expect(storageB.getItem("key")).toBe("value-b");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("clear only affects own storageKey", async () => {
|
|
476
|
+
const storageA = adapter.createStorage("store-a");
|
|
477
|
+
const storageB = adapter.createStorage("store-b");
|
|
478
|
+
|
|
479
|
+
storageA.setItem("key", "value-a");
|
|
480
|
+
storageB.setItem("key", "value-b");
|
|
481
|
+
|
|
482
|
+
storageA.clear();
|
|
483
|
+
|
|
484
|
+
expect(storageA.getItem("key")).toBeNull();
|
|
485
|
+
expect(storageB.getItem("key")).toBe("value-b");
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
describe("Edge Cases", () => {
|
|
491
|
+
describe("MMKVStorageAdapter edge cases", () => {
|
|
492
|
+
let storage: SupportedStorage;
|
|
493
|
+
|
|
494
|
+
beforeEach(() => {
|
|
495
|
+
mockMMKVInstance._store.clear();
|
|
496
|
+
mockMMKVInstance.set.mockClear();
|
|
497
|
+
const adapter = new MMKVStorageAdapter();
|
|
498
|
+
storage = adapter.createStorage("edge-test");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("handles special characters in keys", () => {
|
|
502
|
+
storage.setItem("key:with/special-chars", "value");
|
|
503
|
+
expect(mockMMKVInstance.set).toHaveBeenCalledWith("key:with/special-chars", "value");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("handles newlines in values", () => {
|
|
507
|
+
storage.setItem("multiline", "line1\nline2\r\nline3");
|
|
508
|
+
expect(mockMMKVInstance._store.get("multiline")).toBe("line1\nline2\r\nline3");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("handles rapid successive operations", () => {
|
|
512
|
+
for (let i = 0; i < 100; i++) {
|
|
513
|
+
storage.setItem(`key-${i}`, `value-${i}`);
|
|
514
|
+
}
|
|
515
|
+
expect(mockMMKVInstance.set).toHaveBeenCalledTimes(100);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
describe("AsyncStorageAdapter edge cases", () => {
|
|
520
|
+
let storage: SupportedStorage;
|
|
521
|
+
|
|
522
|
+
beforeEach(() => {
|
|
523
|
+
mockAsyncStorage._reset();
|
|
524
|
+
const adapter = new AsyncStorageAdapter();
|
|
525
|
+
storage = adapter.createStorage("edge-test");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("handles special characters in keys (properly prefixed)", async () => {
|
|
529
|
+
storage.setItem("key:with/special-chars", "value");
|
|
530
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
531
|
+
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
|
|
532
|
+
"edge-test:key:with/special-chars",
|
|
533
|
+
"value"
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("handles keys that look like prefixed keys", async () => {
|
|
538
|
+
// Key that contains colon similar to prefix format
|
|
539
|
+
storage.setItem("other:nested:key", "value");
|
|
540
|
+
expect(storage.getItem("other:nested:key")).toBe("value");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("setItem works before preload completes", () => {
|
|
544
|
+
// Start preload but don't await
|
|
545
|
+
storage.preload();
|
|
546
|
+
storage.setItem("key", "value");
|
|
547
|
+
|
|
548
|
+
// Should be immediately available in cache
|
|
549
|
+
expect(storage.getItem("key")).toBe("value");
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("handles concurrent operations", async () => {
|
|
553
|
+
for (let i = 0; i < 50; i++) {
|
|
554
|
+
storage.setItem(`key-${i}`, `value-${i}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
558
|
+
|
|
559
|
+
for (let i = 0; i < 50; i++) {
|
|
560
|
+
expect(storage.getItem(`key-${i}`)).toBe(`value-${i}`);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("cache survives failed preload", async () => {
|
|
565
|
+
// Set value before preload
|
|
566
|
+
storage.setItem("cached", "value");
|
|
567
|
+
|
|
568
|
+
// Force preload to fail
|
|
569
|
+
mockAsyncStorage.getAllKeys.mockImplementationOnce(async () => {
|
|
570
|
+
throw new Error("Network error");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await storage.preload();
|
|
574
|
+
|
|
575
|
+
// Cached value should still be there
|
|
576
|
+
expect(storage.getItem("cached")).toBe("value");
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { memo, useEffect, useMemo } from "react";
|
|
2
2
|
|
|
3
|
-
import type { TeardownCore } from "../teardown.core";
|
|
4
3
|
import { TeardownContext } from "../contexts/teardown.context";
|
|
4
|
+
import type { TeardownCore } from "../teardown.core";
|
|
5
5
|
|
|
6
6
|
export { useTeardown } from "../contexts/teardown.context";
|
|
7
7
|
|
|
@@ -20,9 +20,5 @@ export const TeardownProvider = memo((props: TeardownProviderProps) => {
|
|
|
20
20
|
};
|
|
21
21
|
}, [core]);
|
|
22
22
|
|
|
23
|
-
return
|
|
24
|
-
<TeardownContext.Provider value={context}>
|
|
25
|
-
{children}
|
|
26
|
-
</TeardownContext.Provider>
|
|
27
|
-
);
|
|
23
|
+
return <TeardownContext.Provider value={context}>{children}</TeardownContext.Provider>;
|
|
28
24
|
});
|