@teardown/react-native 1.2.39 → 2.0.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.
- package/README.md +89 -7
- package/docs/01-getting-started.mdx +147 -0
- package/docs/02-core-concepts.mdx +188 -0
- package/docs/03-identity.mdx +301 -0
- package/docs/04-force-updates.mdx +339 -0
- package/docs/05-device-info.mdx +324 -0
- package/docs/06-logging.mdx +345 -0
- package/docs/06-storage.mdx +349 -0
- package/docs/07-api-reference.mdx +472 -0
- package/docs/07-logging.mdx +345 -0
- package/docs/08-api-reference.mdx +472 -0
- package/docs/08-hooks-reference.mdx +476 -0
- package/docs/09-advanced.mdx +563 -0
- package/docs/09-hooks-reference.mdx +476 -0
- package/docs/10-advanced.mdx +563 -0
- package/package.json +65 -47
- package/src/clients/api/api.client.ts +80 -0
- package/src/clients/api/index.ts +1 -0
- package/src/clients/device/device.adpater-interface.ts +57 -0
- package/src/clients/device/device.client.test.ts +190 -0
- package/src/clients/device/device.client.ts +69 -0
- package/src/clients/device/expo-adapter.ts +90 -0
- package/src/clients/device/index.ts +4 -0
- package/src/clients/force-update/force-update.client.test.ts +295 -0
- package/src/clients/force-update/force-update.client.ts +224 -0
- package/src/clients/force-update/index.ts +1 -0
- package/src/clients/identity/identity.client.test.ts +454 -0
- package/src/clients/identity/identity.client.ts +255 -0
- package/src/clients/identity/index.ts +1 -0
- package/src/clients/logging/index.ts +1 -0
- package/src/clients/logging/logging.client.ts +92 -0
- package/src/clients/notifications/notifications.client.ts +10 -0
- package/src/clients/storage/index.ts +1 -0
- package/src/clients/storage/mmkv-adapter.ts +23 -0
- package/src/clients/storage/storage.client.ts +75 -0
- package/src/clients/utils/index.ts +1 -0
- package/src/clients/utils/utils.client.ts +75 -0
- package/src/components/ui/button.tsx +0 -0
- package/src/components/ui/input.tsx +0 -0
- package/src/contexts/index.ts +1 -0
- package/src/contexts/teardown.context.ts +17 -0
- package/src/exports/expo.ts +1 -0
- package/src/exports/index.ts +16 -0
- package/src/exports/mmkv.ts +1 -0
- package/src/hooks/use-force-update.ts +38 -0
- package/src/hooks/use-session.ts +26 -0
- package/src/providers/teardown.provider.tsx +28 -0
- package/src/teardown.core.ts +76 -0
- package/dist/components/index.d.ts +0 -1
- package/dist/components/index.js +0 -3
- package/dist/components/index.js.map +0 -1
- package/dist/components/teardown-logo.d.ts +0 -4
- package/dist/components/teardown-logo.js +0 -35
- package/dist/components/teardown-logo.js.map +0 -1
- package/dist/containers/index.d.ts +0 -1
- package/dist/containers/index.js +0 -18
- package/dist/containers/index.js.map +0 -1
- package/dist/containers/teardown.container.d.ts +0 -8
- package/dist/containers/teardown.container.js +0 -26
- package/dist/containers/teardown.container.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -22
- package/dist/index.js.map +0 -1
- package/dist/plugins/http.plugin.d.ts +0 -23
- package/dist/plugins/http.plugin.js +0 -145
- package/dist/plugins/http.plugin.js.map +0 -1
- package/dist/plugins/index.d.ts +0 -2
- package/dist/plugins/index.js +0 -20
- package/dist/plugins/index.js.map +0 -1
- package/dist/plugins/logging.plugin.d.ts +0 -9
- package/dist/plugins/logging.plugin.js +0 -36
- package/dist/plugins/logging.plugin.js.map +0 -1
- package/dist/plugins/websocket.plugin.d.ts +0 -1
- package/dist/plugins/websocket.plugin.js +0 -108
- package/dist/plugins/websocket.plugin.js.map +0 -1
- package/dist/services/index.d.ts +0 -1
- package/dist/services/index.js +0 -18
- package/dist/services/index.js.map +0 -1
- package/dist/services/teardown.service.d.ts +0 -10
- package/dist/services/teardown.service.js +0 -22
- package/dist/services/teardown.service.js.map +0 -1
- package/dist/teardown.client.d.ts +0 -41
- package/dist/teardown.client.js +0 -60
- package/dist/teardown.client.js.map +0 -1
- package/dist/utils/log.d.ts +0 -5
- package/dist/utils/log.js +0 -9
- package/dist/utils/log.js.map +0 -1
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Mock react-native before any imports that use it
|
|
4
|
+
mock.module("react-native", () => ({
|
|
5
|
+
AppState: {
|
|
6
|
+
addEventListener: () => ({ remove: () => {} }),
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// Import after mock
|
|
11
|
+
const { IdentityClient } = await import("./identity.client");
|
|
12
|
+
const { IdentifyVersionStatusEnum } = await import("../force-update");
|
|
13
|
+
type IdentifyState = import("./identity.client").IdentifyState;
|
|
14
|
+
|
|
15
|
+
function createMockLoggingClient() {
|
|
16
|
+
return {
|
|
17
|
+
createLogger: () => ({
|
|
18
|
+
info: () => {},
|
|
19
|
+
warn: () => {},
|
|
20
|
+
error: () => {},
|
|
21
|
+
debug: () => {},
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createMockStorageClient() {
|
|
27
|
+
const storage = new Map<string, string>();
|
|
28
|
+
return {
|
|
29
|
+
createStorage: () => ({
|
|
30
|
+
getItem: (key: string) => storage.get(key) ?? null,
|
|
31
|
+
setItem: (key: string, value: string) => storage.set(key, value),
|
|
32
|
+
removeItem: (key: string) => storage.delete(key),
|
|
33
|
+
}),
|
|
34
|
+
getStorage: () => storage,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createMockUtilsClient() {
|
|
39
|
+
return {
|
|
40
|
+
generateRandomUUID: async () => "mock-uuid",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createMockDeviceClient() {
|
|
45
|
+
return {
|
|
46
|
+
getDeviceId: async () => "mock-device-id",
|
|
47
|
+
getDeviceInfo: async () => ({
|
|
48
|
+
application: { name: "TestApp", version: "1.0.0", build: "100", bundle_id: "com.test" },
|
|
49
|
+
hardware: { brand: "Apple", model: "iPhone", device_type: "PHONE" },
|
|
50
|
+
os: { name: "iOS", version: "17.0" },
|
|
51
|
+
notifications: { push_token: null, platform: null },
|
|
52
|
+
update: null,
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createMockApiClient(options: {
|
|
58
|
+
success?: boolean;
|
|
59
|
+
versionStatus?: IdentifyVersionStatusEnum;
|
|
60
|
+
errorStatus?: number;
|
|
61
|
+
errorMessage?: string;
|
|
62
|
+
} = {}) {
|
|
63
|
+
const { success = true, versionStatus = IdentifyVersionStatusEnum.UP_TO_DATE, errorStatus, errorMessage } = options;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
apiKey: "test-api-key",
|
|
67
|
+
orgId: "test-org-id",
|
|
68
|
+
projectId: "test-project-id",
|
|
69
|
+
client: async () => {
|
|
70
|
+
if (!success) {
|
|
71
|
+
return {
|
|
72
|
+
error: {
|
|
73
|
+
status: errorStatus ?? 500,
|
|
74
|
+
value: { message: errorMessage ?? "API Error", error: { message: errorMessage ?? "API Error" } },
|
|
75
|
+
},
|
|
76
|
+
data: null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
error: null,
|
|
81
|
+
data: {
|
|
82
|
+
data: {
|
|
83
|
+
session_id: "session-123",
|
|
84
|
+
device_id: "device-123",
|
|
85
|
+
persona_id: "persona-123",
|
|
86
|
+
token: "token-123",
|
|
87
|
+
version_info: { status: versionStatus },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("IdentityClient", () => {
|
|
96
|
+
describe("initial state", () => {
|
|
97
|
+
test("starts with unidentified state when no stored state", () => {
|
|
98
|
+
const mockLogging = createMockLoggingClient();
|
|
99
|
+
const mockStorage = createMockStorageClient();
|
|
100
|
+
const mockUtils = createMockUtilsClient();
|
|
101
|
+
const mockApi = createMockApiClient();
|
|
102
|
+
const mockDevice = createMockDeviceClient();
|
|
103
|
+
|
|
104
|
+
const client = new IdentityClient(
|
|
105
|
+
mockLogging as never,
|
|
106
|
+
mockUtils as never,
|
|
107
|
+
mockStorage as never,
|
|
108
|
+
mockApi as never,
|
|
109
|
+
mockDevice as never
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("restores state from storage", () => {
|
|
116
|
+
const mockLogging = createMockLoggingClient();
|
|
117
|
+
const mockStorage = createMockStorageClient();
|
|
118
|
+
const mockUtils = createMockUtilsClient();
|
|
119
|
+
const mockApi = createMockApiClient();
|
|
120
|
+
const mockDevice = createMockDeviceClient();
|
|
121
|
+
|
|
122
|
+
// Pre-populate storage with identified state
|
|
123
|
+
const storedState = {
|
|
124
|
+
type: "identified",
|
|
125
|
+
session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
|
|
126
|
+
version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
|
|
127
|
+
};
|
|
128
|
+
mockStorage.getStorage().set("IDENTIFY_STATE", JSON.stringify(storedState));
|
|
129
|
+
|
|
130
|
+
const client = new IdentityClient(
|
|
131
|
+
mockLogging as never,
|
|
132
|
+
mockUtils as never,
|
|
133
|
+
mockStorage as never,
|
|
134
|
+
mockApi as never,
|
|
135
|
+
mockDevice as never
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const state = client.getIdentifyState();
|
|
139
|
+
expect(state.type).toBe("identified");
|
|
140
|
+
if (state.type === "identified") {
|
|
141
|
+
expect(state.session.session_id).toBe("s1");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("identify", () => {
|
|
147
|
+
test("transitions to identifying then identified on success", async () => {
|
|
148
|
+
const mockLogging = createMockLoggingClient();
|
|
149
|
+
const mockStorage = createMockStorageClient();
|
|
150
|
+
const mockUtils = createMockUtilsClient();
|
|
151
|
+
const mockApi = createMockApiClient({ success: true });
|
|
152
|
+
const mockDevice = createMockDeviceClient();
|
|
153
|
+
|
|
154
|
+
const client = new IdentityClient(
|
|
155
|
+
mockLogging as never,
|
|
156
|
+
mockUtils as never,
|
|
157
|
+
mockStorage as never,
|
|
158
|
+
mockApi as never,
|
|
159
|
+
mockDevice as never
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const stateChanges: IdentifyState[] = [];
|
|
163
|
+
client.onIdentifyStateChange((state) => stateChanges.push(state));
|
|
164
|
+
|
|
165
|
+
const result = await client.identify();
|
|
166
|
+
|
|
167
|
+
expect(result.success).toBe(true);
|
|
168
|
+
expect(stateChanges).toHaveLength(2);
|
|
169
|
+
expect(stateChanges[0].type).toBe("identifying");
|
|
170
|
+
expect(stateChanges[1].type).toBe("identified");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("returns user data on successful identify", async () => {
|
|
174
|
+
const mockLogging = createMockLoggingClient();
|
|
175
|
+
const mockStorage = createMockStorageClient();
|
|
176
|
+
const mockUtils = createMockUtilsClient();
|
|
177
|
+
const mockApi = createMockApiClient({ versionStatus: IdentifyVersionStatusEnum.UPDATE_AVAILABLE });
|
|
178
|
+
const mockDevice = createMockDeviceClient();
|
|
179
|
+
|
|
180
|
+
const client = new IdentityClient(
|
|
181
|
+
mockLogging as never,
|
|
182
|
+
mockUtils as never,
|
|
183
|
+
mockStorage as never,
|
|
184
|
+
mockApi as never,
|
|
185
|
+
mockDevice as never
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const result = await client.identify();
|
|
189
|
+
|
|
190
|
+
expect(result.success).toBe(true);
|
|
191
|
+
if (result.success) {
|
|
192
|
+
expect(result.data.session_id).toBe("session-123");
|
|
193
|
+
expect(result.data.device_id).toBe("device-123");
|
|
194
|
+
expect(result.data.version_info.status).toBe(IdentifyVersionStatusEnum.UPDATE_AVAILABLE);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("reverts to previous state on API error", async () => {
|
|
199
|
+
const mockLogging = createMockLoggingClient();
|
|
200
|
+
const mockStorage = createMockStorageClient();
|
|
201
|
+
const mockUtils = createMockUtilsClient();
|
|
202
|
+
const mockApi = createMockApiClient({ success: false, errorStatus: 500, errorMessage: "Server error" });
|
|
203
|
+
const mockDevice = createMockDeviceClient();
|
|
204
|
+
|
|
205
|
+
const client = new IdentityClient(
|
|
206
|
+
mockLogging as never,
|
|
207
|
+
mockUtils as never,
|
|
208
|
+
mockStorage as never,
|
|
209
|
+
mockApi as never,
|
|
210
|
+
mockDevice as never
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const result = await client.identify();
|
|
214
|
+
|
|
215
|
+
expect(result.success).toBe(false);
|
|
216
|
+
// Should revert to unidentified (the previous state)
|
|
217
|
+
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("handles 422 validation error", async () => {
|
|
221
|
+
const mockLogging = createMockLoggingClient();
|
|
222
|
+
const mockStorage = createMockStorageClient();
|
|
223
|
+
const mockUtils = createMockUtilsClient();
|
|
224
|
+
const mockApi = createMockApiClient({ success: false, errorStatus: 422, errorMessage: "Validation failed" });
|
|
225
|
+
const mockDevice = createMockDeviceClient();
|
|
226
|
+
|
|
227
|
+
const client = new IdentityClient(
|
|
228
|
+
mockLogging as never,
|
|
229
|
+
mockUtils as never,
|
|
230
|
+
mockStorage as never,
|
|
231
|
+
mockApi as never,
|
|
232
|
+
mockDevice as never
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const result = await client.identify();
|
|
236
|
+
|
|
237
|
+
expect(result.success).toBe(false);
|
|
238
|
+
if (!result.success) {
|
|
239
|
+
expect(result.error).toBe("Validation failed");
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("persists identified state to storage", async () => {
|
|
244
|
+
const mockLogging = createMockLoggingClient();
|
|
245
|
+
const mockStorage = createMockStorageClient();
|
|
246
|
+
const mockUtils = createMockUtilsClient();
|
|
247
|
+
const mockApi = createMockApiClient({ success: true });
|
|
248
|
+
const mockDevice = createMockDeviceClient();
|
|
249
|
+
|
|
250
|
+
const client = new IdentityClient(
|
|
251
|
+
mockLogging as never,
|
|
252
|
+
mockUtils as never,
|
|
253
|
+
mockStorage as never,
|
|
254
|
+
mockApi as never,
|
|
255
|
+
mockDevice as never
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
await client.identify();
|
|
259
|
+
|
|
260
|
+
const stored = mockStorage.getStorage().get("IDENTIFY_STATE");
|
|
261
|
+
expect(stored).toBeDefined();
|
|
262
|
+
const parsed = JSON.parse(stored!);
|
|
263
|
+
expect(parsed.type).toBe("identified");
|
|
264
|
+
expect(parsed.session.session_id).toBe("session-123");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("onIdentifyStateChange", () => {
|
|
269
|
+
test("emits state changes to listeners", async () => {
|
|
270
|
+
const mockLogging = createMockLoggingClient();
|
|
271
|
+
const mockStorage = createMockStorageClient();
|
|
272
|
+
const mockUtils = createMockUtilsClient();
|
|
273
|
+
const mockApi = createMockApiClient();
|
|
274
|
+
const mockDevice = createMockDeviceClient();
|
|
275
|
+
|
|
276
|
+
const client = new IdentityClient(
|
|
277
|
+
mockLogging as never,
|
|
278
|
+
mockUtils as never,
|
|
279
|
+
mockStorage as never,
|
|
280
|
+
mockApi as never,
|
|
281
|
+
mockDevice as never
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const states: IdentifyState[] = [];
|
|
285
|
+
client.onIdentifyStateChange((state) => states.push(state));
|
|
286
|
+
|
|
287
|
+
await client.identify();
|
|
288
|
+
|
|
289
|
+
expect(states.length).toBeGreaterThan(0);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("returns unsubscribe function", async () => {
|
|
293
|
+
const mockLogging = createMockLoggingClient();
|
|
294
|
+
const mockStorage = createMockStorageClient();
|
|
295
|
+
const mockUtils = createMockUtilsClient();
|
|
296
|
+
const mockApi = createMockApiClient();
|
|
297
|
+
const mockDevice = createMockDeviceClient();
|
|
298
|
+
|
|
299
|
+
const client = new IdentityClient(
|
|
300
|
+
mockLogging as never,
|
|
301
|
+
mockUtils as never,
|
|
302
|
+
mockStorage as never,
|
|
303
|
+
mockApi as never,
|
|
304
|
+
mockDevice as never
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const states: IdentifyState[] = [];
|
|
308
|
+
const unsubscribe = client.onIdentifyStateChange((state) => states.push(state));
|
|
309
|
+
|
|
310
|
+
unsubscribe();
|
|
311
|
+
|
|
312
|
+
await client.identify();
|
|
313
|
+
|
|
314
|
+
// Should not receive any state changes after unsubscribing
|
|
315
|
+
expect(states).toHaveLength(0);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("refresh", () => {
|
|
320
|
+
test("returns error when not identified", async () => {
|
|
321
|
+
const mockLogging = createMockLoggingClient();
|
|
322
|
+
const mockStorage = createMockStorageClient();
|
|
323
|
+
const mockUtils = createMockUtilsClient();
|
|
324
|
+
const mockApi = createMockApiClient();
|
|
325
|
+
const mockDevice = createMockDeviceClient();
|
|
326
|
+
|
|
327
|
+
const client = new IdentityClient(
|
|
328
|
+
mockLogging as never,
|
|
329
|
+
mockUtils as never,
|
|
330
|
+
mockStorage as never,
|
|
331
|
+
mockApi as never,
|
|
332
|
+
mockDevice as never
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const result = await client.refresh();
|
|
336
|
+
|
|
337
|
+
expect(result.success).toBe(false);
|
|
338
|
+
if (!result.success) {
|
|
339
|
+
expect(result.error).toBe("Not identified");
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("re-identifies when already identified", async () => {
|
|
344
|
+
const mockLogging = createMockLoggingClient();
|
|
345
|
+
const mockStorage = createMockStorageClient();
|
|
346
|
+
const mockUtils = createMockUtilsClient();
|
|
347
|
+
const mockApi = createMockApiClient();
|
|
348
|
+
const mockDevice = createMockDeviceClient();
|
|
349
|
+
|
|
350
|
+
const client = new IdentityClient(
|
|
351
|
+
mockLogging as never,
|
|
352
|
+
mockUtils as never,
|
|
353
|
+
mockStorage as never,
|
|
354
|
+
mockApi as never,
|
|
355
|
+
mockDevice as never
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// First identify
|
|
359
|
+
await client.identify();
|
|
360
|
+
expect(client.getIdentifyState().type).toBe("identified");
|
|
361
|
+
|
|
362
|
+
// Then refresh
|
|
363
|
+
const result = await client.refresh();
|
|
364
|
+
expect(result.success).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("reset", () => {
|
|
369
|
+
test("resets state to unidentified", async () => {
|
|
370
|
+
const mockLogging = createMockLoggingClient();
|
|
371
|
+
const mockStorage = createMockStorageClient();
|
|
372
|
+
const mockUtils = createMockUtilsClient();
|
|
373
|
+
const mockApi = createMockApiClient();
|
|
374
|
+
const mockDevice = createMockDeviceClient();
|
|
375
|
+
|
|
376
|
+
const client = new IdentityClient(
|
|
377
|
+
mockLogging as never,
|
|
378
|
+
mockUtils as never,
|
|
379
|
+
mockStorage as never,
|
|
380
|
+
mockApi as never,
|
|
381
|
+
mockDevice as never
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// First identify
|
|
385
|
+
await client.identify();
|
|
386
|
+
expect(client.getIdentifyState().type).toBe("identified");
|
|
387
|
+
|
|
388
|
+
// Then reset
|
|
389
|
+
client.reset();
|
|
390
|
+
|
|
391
|
+
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
392
|
+
// Storage contains unidentified state after reset (setIdentifyState saves it)
|
|
393
|
+
const stored = mockStorage.getStorage().get("IDENTIFY_STATE");
|
|
394
|
+
expect(stored).toBeDefined();
|
|
395
|
+
if (stored) {
|
|
396
|
+
expect(JSON.parse(stored).type).toBe("unidentified");
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("emits unidentified state on reset", async () => {
|
|
401
|
+
const mockLogging = createMockLoggingClient();
|
|
402
|
+
const mockStorage = createMockStorageClient();
|
|
403
|
+
const mockUtils = createMockUtilsClient();
|
|
404
|
+
const mockApi = createMockApiClient();
|
|
405
|
+
const mockDevice = createMockDeviceClient();
|
|
406
|
+
|
|
407
|
+
const client = new IdentityClient(
|
|
408
|
+
mockLogging as never,
|
|
409
|
+
mockUtils as never,
|
|
410
|
+
mockStorage as never,
|
|
411
|
+
mockApi as never,
|
|
412
|
+
mockDevice as never
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
await client.identify();
|
|
416
|
+
|
|
417
|
+
const states: IdentifyState[] = [];
|
|
418
|
+
client.onIdentifyStateChange((state) => states.push(state));
|
|
419
|
+
|
|
420
|
+
client.reset();
|
|
421
|
+
|
|
422
|
+
expect(states).toHaveLength(1);
|
|
423
|
+
expect(states[0].type).toBe("unidentified");
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe("shutdown", () => {
|
|
428
|
+
test("removes all listeners", async () => {
|
|
429
|
+
const mockLogging = createMockLoggingClient();
|
|
430
|
+
const mockStorage = createMockStorageClient();
|
|
431
|
+
const mockUtils = createMockUtilsClient();
|
|
432
|
+
const mockApi = createMockApiClient();
|
|
433
|
+
const mockDevice = createMockDeviceClient();
|
|
434
|
+
|
|
435
|
+
const client = new IdentityClient(
|
|
436
|
+
mockLogging as never,
|
|
437
|
+
mockUtils as never,
|
|
438
|
+
mockStorage as never,
|
|
439
|
+
mockApi as never,
|
|
440
|
+
mockDevice as never
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const states: IdentifyState[] = [];
|
|
444
|
+
client.onIdentifyStateChange((state) => states.push(state));
|
|
445
|
+
|
|
446
|
+
client.shutdown();
|
|
447
|
+
|
|
448
|
+
await client.identify();
|
|
449
|
+
|
|
450
|
+
// Should not receive any state changes after shutdown
|
|
451
|
+
expect(states).toHaveLength(0);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import type { AsyncResult } from "@teardown/types";
|
|
2
|
+
import { EventEmitter } from "eventemitter3";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { ApiClient } from "../api";
|
|
5
|
+
import type { DeviceClient } from "../device/device.client";
|
|
6
|
+
import { IdentifyVersionStatusEnum } from "../force-update";
|
|
7
|
+
import type { Logger, LoggingClient } from "../logging";
|
|
8
|
+
import type { StorageClient, SupportedStorage } from "../storage";
|
|
9
|
+
import type { UtilsClient } from "../utils";
|
|
10
|
+
|
|
11
|
+
export type Persona = {
|
|
12
|
+
name?: string | undefined;
|
|
13
|
+
user_id?: string | undefined;
|
|
14
|
+
email?: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type IdentityUser = {
|
|
18
|
+
session_id: string;
|
|
19
|
+
device_id: string;
|
|
20
|
+
persona_id: string;
|
|
21
|
+
token: string;
|
|
22
|
+
version_info: {
|
|
23
|
+
status: IdentifyVersionStatusEnum;
|
|
24
|
+
update: null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const UnidentifiedSessionStateSchema = z.object({
|
|
29
|
+
type: z.literal("unidentified"),
|
|
30
|
+
});
|
|
31
|
+
export const IdentifyingSessionStateSchema = z.object({
|
|
32
|
+
type: z.literal("identifying"),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
export const UpdateVersionStatusBodySchema = z.object({
|
|
37
|
+
version: z.string(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const SessionSchema = z.object({
|
|
41
|
+
session_id: z.string(),
|
|
42
|
+
device_id: z.string(),
|
|
43
|
+
persona_id: z.string(),
|
|
44
|
+
token: z.string(),
|
|
45
|
+
});
|
|
46
|
+
export type Session = z.infer<typeof SessionSchema>;
|
|
47
|
+
|
|
48
|
+
export const VersionStatusResponseSchema = z.object({
|
|
49
|
+
status: z.enum(["UPDATE_AVAILABLE", "UPDATE_REQUIRED", "UP_TO_DATE"]),
|
|
50
|
+
latest_version: z.string().optional(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const IdentifiedSessionStateSchema = z.object({
|
|
54
|
+
type: z.literal("identified"),
|
|
55
|
+
session: SessionSchema,
|
|
56
|
+
version_info: z.object({
|
|
57
|
+
status: z.enum(IdentifyVersionStatusEnum),
|
|
58
|
+
update: z.null(),
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const IdentifyStateSchema = z.discriminatedUnion("type", [UnidentifiedSessionStateSchema, IdentifyingSessionStateSchema, IdentifiedSessionStateSchema]);
|
|
63
|
+
export type IdentifyState = z.infer<typeof IdentifyStateSchema>;
|
|
64
|
+
|
|
65
|
+
export type UnidentifiedSessionState = z.infer<typeof UnidentifiedSessionStateSchema>;
|
|
66
|
+
export type IdentifyingSessionState = z.infer<typeof IdentifyingSessionStateSchema>;
|
|
67
|
+
export type IdentifiedSessionState = z.infer<typeof IdentifiedSessionStateSchema>;
|
|
68
|
+
|
|
69
|
+
export type IdentifyStateChangeEvents = {
|
|
70
|
+
IDENTIFY_STATE_CHANGED: (state: IdentifyState) => void;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
export const IDENTIFY_STORAGE_KEY = "IDENTIFY_STATE";
|
|
75
|
+
|
|
76
|
+
export class IdentityClient {
|
|
77
|
+
private emitter = new EventEmitter<IdentifyStateChangeEvents>();
|
|
78
|
+
private identifyState: IdentifyState;
|
|
79
|
+
|
|
80
|
+
public readonly logger: Logger;
|
|
81
|
+
public readonly utils: UtilsClient;
|
|
82
|
+
public readonly storage: SupportedStorage;
|
|
83
|
+
|
|
84
|
+
constructor(
|
|
85
|
+
logging: LoggingClient,
|
|
86
|
+
utils: UtilsClient,
|
|
87
|
+
storage: StorageClient,
|
|
88
|
+
private readonly api: ApiClient,
|
|
89
|
+
private readonly device: DeviceClient
|
|
90
|
+
) {
|
|
91
|
+
this.logger = logging.createLogger({
|
|
92
|
+
name: "IdentityClient",
|
|
93
|
+
});
|
|
94
|
+
this.storage = storage.createStorage("identity");
|
|
95
|
+
this.utils = utils;
|
|
96
|
+
this.identifyState = this.getIdentifyStateFromStorage();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async initialize(): Promise<void> {
|
|
100
|
+
await this.identify();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private getIdentifyStateFromStorage(): IdentifyState {
|
|
104
|
+
const stored = this.storage.getItem(IDENTIFY_STORAGE_KEY);
|
|
105
|
+
if (stored == null) {
|
|
106
|
+
// console.log("no stored session state");
|
|
107
|
+
return UnidentifiedSessionStateSchema.parse({ type: "unidentified" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// console.log("stored session state", stored);
|
|
111
|
+
return IdentifyStateSchema.parse(JSON.parse(stored));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private saveIdentifyStateToStorage(identifyState: IdentifyState): void {
|
|
115
|
+
this.storage.setItem(IDENTIFY_STORAGE_KEY, JSON.stringify(identifyState));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private setIdentifyState(newState: IdentifyState): void {
|
|
119
|
+
this.logger.info(`Identify state: ${this.identifyState.type} -> ${newState.type}`);
|
|
120
|
+
this.identifyState = newState;
|
|
121
|
+
this.saveIdentifyStateToStorage(newState);
|
|
122
|
+
this.emitter.emit("IDENTIFY_STATE_CHANGED", newState);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public onIdentifyStateChange(listener: (state: IdentifyState) => void) {
|
|
126
|
+
this.emitter.addListener("IDENTIFY_STATE_CHANGED", listener);
|
|
127
|
+
return () => {
|
|
128
|
+
this.emitter.removeListener("IDENTIFY_STATE_CHANGED", listener);
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
public getIdentifyState(): IdentifyState {
|
|
133
|
+
return this.identifyState;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public getSessionState(): Session | null {
|
|
137
|
+
if (this.identifyState.type !== "identified") {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return this.identifyState.session;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public shutdown() {
|
|
144
|
+
this.emitter.removeAllListeners("IDENTIFY_STATE_CHANGED");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
public reset() {
|
|
148
|
+
this.storage.removeItem(IDENTIFY_STORAGE_KEY);
|
|
149
|
+
this.setIdentifyState({ type: "unidentified" });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Re-identify the current persona to refresh session data.
|
|
154
|
+
* Only works if already identified.
|
|
155
|
+
*/
|
|
156
|
+
async refresh(): AsyncResult<IdentityUser> {
|
|
157
|
+
if (this.identifyState.type !== "identified") {
|
|
158
|
+
return { success: false, error: "Not identified" };
|
|
159
|
+
}
|
|
160
|
+
return this.identify();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Catches all errors and returns an AsyncResult
|
|
165
|
+
* @param fn - The function to try
|
|
166
|
+
* @returns An {@link AsyncResult}
|
|
167
|
+
*/
|
|
168
|
+
private async tryCatch<T>(fn: () => AsyncResult<T>): AsyncResult<T> {
|
|
169
|
+
try {
|
|
170
|
+
const result = await fn();
|
|
171
|
+
return result;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async identify(persona?: Persona): AsyncResult<IdentityUser> {
|
|
181
|
+
const previousState = this.identifyState;
|
|
182
|
+
this.setIdentifyState({ type: "identifying" });
|
|
183
|
+
|
|
184
|
+
return this.tryCatch(async () => {
|
|
185
|
+
const deviceId = await this.device.getDeviceId();
|
|
186
|
+
const deviceInfo = await this.device.getDeviceInfo();
|
|
187
|
+
console.log("this.api.apiKey", this.api.apiKey);
|
|
188
|
+
console.log("this.api.orgId", this.api.orgId);
|
|
189
|
+
console.log("this.api.projectId", this.api.projectId);
|
|
190
|
+
console.log("this.api.environmentSlug", this.api.environmentSlug);
|
|
191
|
+
console.log("this.api.deviceId", deviceId);
|
|
192
|
+
const response = await this.api.client("/v1/identify", {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: {
|
|
195
|
+
"td-api-key": this.api.apiKey,
|
|
196
|
+
"td-org-id": this.api.orgId,
|
|
197
|
+
"td-project-id": this.api.projectId,
|
|
198
|
+
"td-environment-slug": "production",
|
|
199
|
+
"td-device-id": deviceId,
|
|
200
|
+
},
|
|
201
|
+
body: {
|
|
202
|
+
persona,
|
|
203
|
+
device: {
|
|
204
|
+
...deviceInfo,
|
|
205
|
+
update: deviceInfo.update
|
|
206
|
+
? {
|
|
207
|
+
...deviceInfo.update,
|
|
208
|
+
created_at: deviceInfo.update.created_at,
|
|
209
|
+
}
|
|
210
|
+
: null,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (response.error != null) {
|
|
216
|
+
console.log("identify error", response.error.status, response.error.value);
|
|
217
|
+
this.setIdentifyState(previousState);
|
|
218
|
+
|
|
219
|
+
if (response.error.status === 422) {
|
|
220
|
+
this.logger.warn("422 Error identifying user", response.error.value);
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
error: response.error.value.message ?? "Unknown error",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const value = response.error.value;
|
|
228
|
+
return {
|
|
229
|
+
success: false,
|
|
230
|
+
error: value?.error?.message ?? "Unknown error",
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.setIdentifyState({
|
|
235
|
+
type: "identified",
|
|
236
|
+
session: response.data.data,
|
|
237
|
+
version_info: {
|
|
238
|
+
status: response.data.data.version_info.status,
|
|
239
|
+
update: null,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
success: true,
|
|
245
|
+
data: {
|
|
246
|
+
...response.data.data,
|
|
247
|
+
version_info: {
|
|
248
|
+
status: response.data.data.version_info.status,
|
|
249
|
+
update: null,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./identity.client";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./logging.client";
|