@teardown/react-native 2.0.1 → 2.0.4
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 +46 -18
- package/src/clients/api/api.client.ts +2 -2
- package/src/clients/device/{expo-adapter.ts → adapters/basic.adapter.ts} +1 -1
- package/src/clients/device/{device.adpater-interface.ts → adapters/device.adpater-interface.ts} +1 -16
- package/src/clients/device/adapters/expo.adapter.ts +90 -0
- package/src/clients/device/device.client.test.ts +1 -1
- package/src/clients/device/device.client.ts +5 -1
- package/src/clients/device/index.ts +1 -1
- package/src/clients/force-update/force-update.client.test.ts +238 -6
- package/src/clients/force-update/force-update.client.ts +71 -11
- package/src/clients/identity/identity.client.test.ts +888 -223
- package/src/clients/identity/identity.client.ts +59 -20
- package/src/clients/storage/adapters/async-storage.adapter.ts +81 -0
- package/src/clients/storage/{mmkv-adapter.ts → adapters/mmkv.adapter.ts} +7 -10
- package/src/clients/storage/adapters/storage.adpater-interface.ts +30 -0
- package/src/clients/storage/index.ts +2 -1
- package/src/clients/storage/storage.client.ts +9 -20
- package/src/clients/utils/utils.client.ts +1 -57
- package/src/exports/adapters/async-storage.ts +1 -0
- package/src/exports/adapters/expo.ts +1 -0
- package/src/exports/adapters/mmkv.ts +1 -0
- package/src/hooks/use-force-update.ts +12 -3
- package/src/hooks/use-session.ts +7 -4
- package/src/teardown.core.ts +16 -6
- package/src/exports/expo.ts +0 -1
- package/src/exports/mmkv.ts +0 -1
|
@@ -1,25 +1,33 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
1
|
+
import { describe, expect, mock, test, beforeEach } from "bun:test";
|
|
2
2
|
|
|
3
3
|
// Mock react-native before any imports that use it
|
|
4
4
|
mock.module("react-native", () => ({
|
|
5
5
|
AppState: {
|
|
6
|
-
addEventListener: () => ({ remove: () => {} }),
|
|
6
|
+
addEventListener: () => ({ remove: () => { } }),
|
|
7
7
|
},
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
10
|
// Import after mock
|
|
11
|
-
const { IdentityClient } = await import("./identity.client");
|
|
11
|
+
const { IdentityClient, IDENTIFY_STORAGE_KEY } = await import("./identity.client");
|
|
12
12
|
const { IdentifyVersionStatusEnum } = await import("../force-update");
|
|
13
13
|
type IdentifyState = import("./identity.client").IdentifyState;
|
|
14
|
+
type Persona = import("./identity.client").Persona;
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Mock Factories
|
|
18
|
+
// ============================================================================
|
|
14
19
|
|
|
15
20
|
function createMockLoggingClient() {
|
|
21
|
+
const logs: { level: string; message: string; args: unknown[] }[] = [];
|
|
16
22
|
return {
|
|
17
23
|
createLogger: () => ({
|
|
18
|
-
info: () => {},
|
|
19
|
-
warn: () => {},
|
|
20
|
-
error: () => {},
|
|
21
|
-
debug: () => {},
|
|
24
|
+
info: (message: string, ...args: unknown[]) => logs.push({ level: "info", message, args }),
|
|
25
|
+
warn: (message: string, ...args: unknown[]) => logs.push({ level: "warn", message, args }),
|
|
26
|
+
error: (message: string, ...args: unknown[]) => logs.push({ level: "error", message, args }),
|
|
27
|
+
debug: (message: string, ...args: unknown[]) => logs.push({ level: "debug", message, args }),
|
|
22
28
|
}),
|
|
29
|
+
getLogs: () => logs,
|
|
30
|
+
clearLogs: () => logs.length = 0,
|
|
23
31
|
};
|
|
24
32
|
}
|
|
25
33
|
|
|
@@ -32,132 +40,245 @@ function createMockStorageClient() {
|
|
|
32
40
|
removeItem: (key: string) => storage.delete(key),
|
|
33
41
|
}),
|
|
34
42
|
getStorage: () => storage,
|
|
43
|
+
clear: () => storage.clear(),
|
|
35
44
|
};
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
function createMockUtilsClient() {
|
|
48
|
+
let uuidCounter = 0;
|
|
39
49
|
return {
|
|
40
|
-
generateRandomUUID: async () =>
|
|
50
|
+
generateRandomUUID: async () => `mock-uuid-${++uuidCounter}`,
|
|
51
|
+
resetCounter: () => uuidCounter = 0,
|
|
41
52
|
};
|
|
42
53
|
}
|
|
43
54
|
|
|
44
|
-
function createMockDeviceClient(
|
|
55
|
+
function createMockDeviceClient(overrides: Partial<{
|
|
56
|
+
deviceId: string;
|
|
57
|
+
timestamp: string;
|
|
58
|
+
application: { name: string; version: string; build: string; bundle_id: string };
|
|
59
|
+
hardware: { brand: string; model: string; device_type: string };
|
|
60
|
+
os: { name: string; version: string };
|
|
61
|
+
notifications: { push_token: string | null; platform: string | null };
|
|
62
|
+
update: null;
|
|
63
|
+
}> = {}) {
|
|
64
|
+
const defaultDeviceInfo = {
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
application: { name: "TestApp", version: "1.0.0", build: "100", bundle_id: "com.test.app" },
|
|
67
|
+
hardware: { brand: "Apple", model: "iPhone 15", device_type: "PHONE" },
|
|
68
|
+
os: { name: "iOS", version: "17.0" },
|
|
69
|
+
notifications: { push_token: null, platform: null },
|
|
70
|
+
update: null,
|
|
71
|
+
};
|
|
72
|
+
|
|
45
73
|
return {
|
|
46
|
-
getDeviceId: async () => "mock-device-id",
|
|
74
|
+
getDeviceId: async () => overrides.deviceId ?? "mock-device-id",
|
|
47
75
|
getDeviceInfo: async () => ({
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
os: { name: "iOS", version: "17.0" },
|
|
51
|
-
notifications: { push_token: null, platform: null },
|
|
52
|
-
update: null,
|
|
76
|
+
...defaultDeviceInfo,
|
|
77
|
+
...overrides,
|
|
53
78
|
}),
|
|
54
79
|
};
|
|
55
80
|
}
|
|
56
81
|
|
|
82
|
+
type ApiCallRecord = {
|
|
83
|
+
endpoint: string;
|
|
84
|
+
config: {
|
|
85
|
+
method: string;
|
|
86
|
+
headers: Record<string, string>;
|
|
87
|
+
body: unknown;
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
57
91
|
function createMockApiClient(options: {
|
|
58
92
|
success?: boolean;
|
|
59
93
|
versionStatus?: IdentifyVersionStatusEnum;
|
|
60
94
|
errorStatus?: number;
|
|
61
95
|
errorMessage?: string;
|
|
96
|
+
sessionId?: string;
|
|
97
|
+
deviceId?: string;
|
|
98
|
+
personaId?: string;
|
|
99
|
+
token?: string;
|
|
100
|
+
throwError?: Error;
|
|
62
101
|
} = {}) {
|
|
63
|
-
const {
|
|
102
|
+
const {
|
|
103
|
+
success = true,
|
|
104
|
+
versionStatus = IdentifyVersionStatusEnum.UP_TO_DATE,
|
|
105
|
+
errorStatus,
|
|
106
|
+
errorMessage,
|
|
107
|
+
sessionId = "session-123",
|
|
108
|
+
deviceId = "device-123",
|
|
109
|
+
personaId = "persona-123",
|
|
110
|
+
token = "token-123",
|
|
111
|
+
throwError,
|
|
112
|
+
} = options;
|
|
113
|
+
|
|
114
|
+
const calls: ApiCallRecord[] = [];
|
|
64
115
|
|
|
65
116
|
return {
|
|
66
117
|
apiKey: "test-api-key",
|
|
67
118
|
orgId: "test-org-id",
|
|
68
119
|
projectId: "test-project-id",
|
|
69
|
-
client: async () => {
|
|
120
|
+
client: async (endpoint: string, config: ApiCallRecord["config"]) => {
|
|
121
|
+
calls.push({ endpoint, config });
|
|
122
|
+
|
|
123
|
+
if (throwError) {
|
|
124
|
+
throw throwError;
|
|
125
|
+
}
|
|
126
|
+
|
|
70
127
|
if (!success) {
|
|
71
128
|
return {
|
|
72
129
|
error: {
|
|
73
130
|
status: errorStatus ?? 500,
|
|
74
|
-
value: {
|
|
131
|
+
value: {
|
|
132
|
+
message: errorMessage ?? "API Error",
|
|
133
|
+
error: { message: errorMessage ?? "API Error" },
|
|
134
|
+
},
|
|
75
135
|
},
|
|
76
136
|
data: null,
|
|
77
137
|
};
|
|
78
138
|
}
|
|
139
|
+
|
|
79
140
|
return {
|
|
80
141
|
error: null,
|
|
81
142
|
data: {
|
|
82
143
|
data: {
|
|
83
|
-
session_id:
|
|
84
|
-
device_id:
|
|
85
|
-
persona_id:
|
|
86
|
-
token:
|
|
144
|
+
session_id: sessionId,
|
|
145
|
+
device_id: deviceId,
|
|
146
|
+
persona_id: personaId,
|
|
147
|
+
token: token,
|
|
87
148
|
version_info: { status: versionStatus },
|
|
88
149
|
},
|
|
89
150
|
},
|
|
90
151
|
};
|
|
91
152
|
},
|
|
153
|
+
getCalls: () => calls,
|
|
154
|
+
getLastCall: () => calls[calls.length - 1],
|
|
155
|
+
clearCalls: () => calls.length = 0,
|
|
92
156
|
};
|
|
93
157
|
}
|
|
94
158
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
159
|
+
// Helper to create a standard client instance
|
|
160
|
+
function createTestClient(overrides: {
|
|
161
|
+
logging?: ReturnType<typeof createMockLoggingClient>;
|
|
162
|
+
storage?: ReturnType<typeof createMockStorageClient>;
|
|
163
|
+
utils?: ReturnType<typeof createMockUtilsClient>;
|
|
164
|
+
api?: ReturnType<typeof createMockApiClient>;
|
|
165
|
+
device?: ReturnType<typeof createMockDeviceClient>;
|
|
166
|
+
} = {}) {
|
|
167
|
+
const mockLogging = overrides.logging ?? createMockLoggingClient();
|
|
168
|
+
const mockStorage = overrides.storage ?? createMockStorageClient();
|
|
169
|
+
const mockUtils = overrides.utils ?? createMockUtilsClient();
|
|
170
|
+
const mockApi = overrides.api ?? createMockApiClient();
|
|
171
|
+
const mockDevice = overrides.device ?? createMockDeviceClient();
|
|
172
|
+
|
|
173
|
+
const client = new IdentityClient(
|
|
174
|
+
mockLogging as never,
|
|
175
|
+
mockUtils as never,
|
|
176
|
+
mockStorage as never,
|
|
177
|
+
mockApi as never,
|
|
178
|
+
mockDevice as never
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
client,
|
|
183
|
+
mockLogging,
|
|
184
|
+
mockStorage,
|
|
185
|
+
mockUtils,
|
|
186
|
+
mockApi,
|
|
187
|
+
mockDevice,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Helper to manually load state from storage without calling identify()
|
|
192
|
+
function loadStateFromStorage(client: InstanceType<typeof IdentityClient>) {
|
|
193
|
+
const clientAny = client as unknown as {
|
|
194
|
+
identifyState: IdentifyState;
|
|
195
|
+
initialized: boolean;
|
|
196
|
+
getIdentifyStateFromStorage: () => IdentifyState;
|
|
197
|
+
};
|
|
198
|
+
clientAny.identifyState = clientAny.getIdentifyStateFromStorage();
|
|
199
|
+
clientAny.initialized = true;
|
|
200
|
+
}
|
|
103
201
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
mockStorage as never,
|
|
108
|
-
mockApi as never,
|
|
109
|
-
mockDevice as never
|
|
110
|
-
);
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// Tests
|
|
204
|
+
// ============================================================================
|
|
111
205
|
|
|
206
|
+
describe("IdentityClient", () => {
|
|
207
|
+
describe("constructor and initial state", () => {
|
|
208
|
+
test("starts with unidentified state when no stored state", () => {
|
|
209
|
+
const { client } = createTestClient();
|
|
112
210
|
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
113
211
|
});
|
|
114
212
|
|
|
115
|
-
test("restores state from storage", () => {
|
|
116
|
-
const mockLogging = createMockLoggingClient();
|
|
213
|
+
test("restores identified state from storage after initialize", () => {
|
|
117
214
|
const mockStorage = createMockStorageClient();
|
|
118
|
-
const mockUtils = createMockUtilsClient();
|
|
119
|
-
const mockApi = createMockApiClient();
|
|
120
|
-
const mockDevice = createMockDeviceClient();
|
|
121
|
-
|
|
122
|
-
// Pre-populate storage with identified state
|
|
123
215
|
const storedState = {
|
|
124
216
|
type: "identified",
|
|
125
217
|
session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
|
|
126
218
|
version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
|
|
127
219
|
};
|
|
128
|
-
mockStorage.getStorage().set(
|
|
220
|
+
mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, JSON.stringify(storedState));
|
|
129
221
|
|
|
130
|
-
const client =
|
|
131
|
-
|
|
132
|
-
mockUtils as never,
|
|
133
|
-
mockStorage as never,
|
|
134
|
-
mockApi as never,
|
|
135
|
-
mockDevice as never
|
|
136
|
-
);
|
|
222
|
+
const { client } = createTestClient({ storage: mockStorage });
|
|
223
|
+
loadStateFromStorage(client);
|
|
137
224
|
|
|
138
225
|
const state = client.getIdentifyState();
|
|
139
226
|
expect(state.type).toBe("identified");
|
|
140
227
|
if (state.type === "identified") {
|
|
141
228
|
expect(state.session.session_id).toBe("s1");
|
|
229
|
+
expect(state.session.device_id).toBe("d1");
|
|
230
|
+
expect(state.session.persona_id).toBe("p1");
|
|
231
|
+
expect(state.session.token).toBe("t1");
|
|
142
232
|
}
|
|
143
233
|
});
|
|
234
|
+
|
|
235
|
+
test("resets stale identifying state from storage to unidentified", () => {
|
|
236
|
+
const mockStorage = createMockStorageClient();
|
|
237
|
+
const storedState = { type: "identifying" };
|
|
238
|
+
mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, JSON.stringify(storedState));
|
|
239
|
+
|
|
240
|
+
const { client } = createTestClient({ storage: mockStorage });
|
|
241
|
+
loadStateFromStorage(client);
|
|
242
|
+
|
|
243
|
+
// "identifying" is a transient state - should reset to unidentified
|
|
244
|
+
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("throws error on invalid stored state (corrupt JSON)", () => {
|
|
248
|
+
const mockStorage = createMockStorageClient();
|
|
249
|
+
mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, "not-valid-json{{{");
|
|
250
|
+
|
|
251
|
+
const { client } = createTestClient({ storage: mockStorage });
|
|
252
|
+
expect(() => loadStateFromStorage(client)).toThrow();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("throws error on invalid stored state (schema mismatch)", () => {
|
|
256
|
+
const mockStorage = createMockStorageClient();
|
|
257
|
+
mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, JSON.stringify({ type: "invalid_type" }));
|
|
258
|
+
|
|
259
|
+
const { client } = createTestClient({ storage: mockStorage });
|
|
260
|
+
expect(() => loadStateFromStorage(client)).toThrow();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("creates logger with correct name", () => {
|
|
264
|
+
const { client } = createTestClient();
|
|
265
|
+
expect(client.logger).toBeDefined();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("exposes utils client", () => {
|
|
269
|
+
const { client } = createTestClient();
|
|
270
|
+
expect(client.utils).toBeDefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("exposes storage client", () => {
|
|
274
|
+
const { client } = createTestClient();
|
|
275
|
+
expect(client.storage).toBeDefined();
|
|
276
|
+
});
|
|
144
277
|
});
|
|
145
278
|
|
|
146
279
|
describe("identify", () => {
|
|
147
280
|
test("transitions to identifying then identified on success", async () => {
|
|
148
|
-
const
|
|
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
|
-
);
|
|
281
|
+
const { client } = createTestClient();
|
|
161
282
|
|
|
162
283
|
const stateChanges: IdentifyState[] = [];
|
|
163
284
|
client.onIdentifyStateChange((state) => stateChanges.push(state));
|
|
@@ -170,116 +291,330 @@ describe("IdentityClient", () => {
|
|
|
170
291
|
expect(stateChanges[1].type).toBe("identified");
|
|
171
292
|
});
|
|
172
293
|
|
|
173
|
-
test("returns user data on successful identify", async () => {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
mockUtils as never,
|
|
183
|
-
mockStorage as never,
|
|
184
|
-
mockApi as never,
|
|
185
|
-
mockDevice as never
|
|
186
|
-
);
|
|
294
|
+
test("returns complete user data on successful identify", async () => {
|
|
295
|
+
const mockApi = createMockApiClient({
|
|
296
|
+
versionStatus: IdentifyVersionStatusEnum.UPDATE_AVAILABLE,
|
|
297
|
+
sessionId: "custom-session",
|
|
298
|
+
deviceId: "custom-device",
|
|
299
|
+
personaId: "custom-persona",
|
|
300
|
+
token: "custom-token",
|
|
301
|
+
});
|
|
302
|
+
const { client } = createTestClient({ api: mockApi });
|
|
187
303
|
|
|
188
304
|
const result = await client.identify();
|
|
189
305
|
|
|
190
306
|
expect(result.success).toBe(true);
|
|
191
307
|
if (result.success) {
|
|
192
|
-
expect(result.data.session_id).toBe("session
|
|
193
|
-
expect(result.data.device_id).toBe("device
|
|
308
|
+
expect(result.data.session_id).toBe("custom-session");
|
|
309
|
+
expect(result.data.device_id).toBe("custom-device");
|
|
310
|
+
expect(result.data.persona_id).toBe("custom-persona");
|
|
311
|
+
expect(result.data.token).toBe("custom-token");
|
|
194
312
|
expect(result.data.version_info.status).toBe(IdentifyVersionStatusEnum.UPDATE_AVAILABLE);
|
|
313
|
+
expect(result.data.version_info.update).toBeNull();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("handles UPDATE_REQUIRED version status", async () => {
|
|
318
|
+
const mockApi = createMockApiClient({
|
|
319
|
+
versionStatus: IdentifyVersionStatusEnum.UPDATE_REQUIRED,
|
|
320
|
+
});
|
|
321
|
+
const { client } = createTestClient({ api: mockApi });
|
|
322
|
+
|
|
323
|
+
const result = await client.identify();
|
|
324
|
+
|
|
325
|
+
expect(result.success).toBe(true);
|
|
326
|
+
if (result.success) {
|
|
327
|
+
expect(result.data.version_info.status).toBe(IdentifyVersionStatusEnum.UPDATE_REQUIRED);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("handles UP_TO_DATE version status", async () => {
|
|
332
|
+
const mockApi = createMockApiClient({
|
|
333
|
+
versionStatus: IdentifyVersionStatusEnum.UP_TO_DATE,
|
|
334
|
+
});
|
|
335
|
+
const { client } = createTestClient({ api: mockApi });
|
|
336
|
+
|
|
337
|
+
const result = await client.identify();
|
|
338
|
+
|
|
339
|
+
expect(result.success).toBe(true);
|
|
340
|
+
if (result.success) {
|
|
341
|
+
expect(result.data.version_info.status).toBe(IdentifyVersionStatusEnum.UP_TO_DATE);
|
|
195
342
|
}
|
|
196
343
|
});
|
|
197
344
|
|
|
198
345
|
test("reverts to previous state on API error", async () => {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
);
|
|
346
|
+
const mockApi = createMockApiClient({
|
|
347
|
+
success: false,
|
|
348
|
+
errorStatus: 500,
|
|
349
|
+
errorMessage: "Server error",
|
|
350
|
+
});
|
|
351
|
+
const { client } = createTestClient({ api: mockApi });
|
|
212
352
|
|
|
213
353
|
const result = await client.identify();
|
|
214
354
|
|
|
215
355
|
expect(result.success).toBe(false);
|
|
216
|
-
// Should revert to unidentified (the previous state)
|
|
217
356
|
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
218
357
|
});
|
|
219
358
|
|
|
220
|
-
test("
|
|
221
|
-
const mockLogging = createMockLoggingClient();
|
|
359
|
+
test("reverts to identified state on API error when previously identified", async () => {
|
|
222
360
|
const mockStorage = createMockStorageClient();
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
361
|
+
const storedState = {
|
|
362
|
+
type: "identified",
|
|
363
|
+
session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
|
|
364
|
+
version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
|
|
365
|
+
};
|
|
366
|
+
mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, JSON.stringify(storedState));
|
|
367
|
+
|
|
368
|
+
const mockApi = createMockApiClient({
|
|
369
|
+
success: false,
|
|
370
|
+
errorStatus: 500,
|
|
371
|
+
errorMessage: "Server error",
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const { client } = createTestClient({ storage: mockStorage, api: mockApi });
|
|
375
|
+
loadStateFromStorage(client);
|
|
376
|
+
|
|
377
|
+
// Should start identified
|
|
378
|
+
expect(client.getIdentifyState().type).toBe("identified");
|
|
379
|
+
|
|
380
|
+
const result = await client.identify();
|
|
381
|
+
|
|
382
|
+
// Should fail and revert to identified
|
|
383
|
+
expect(result.success).toBe(false);
|
|
384
|
+
expect(client.getIdentifyState().type).toBe("identified");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("handles 422 validation error with message", async () => {
|
|
388
|
+
const mockApi = createMockApiClient({
|
|
389
|
+
success: false,
|
|
390
|
+
errorStatus: 422,
|
|
391
|
+
errorMessage: "Validation failed: invalid device ID",
|
|
392
|
+
});
|
|
393
|
+
const { client } = createTestClient({ api: mockApi });
|
|
394
|
+
|
|
395
|
+
const result = await client.identify();
|
|
396
|
+
|
|
397
|
+
expect(result.success).toBe(false);
|
|
398
|
+
if (!result.success) {
|
|
399
|
+
expect(result.error).toBe("Validation failed: invalid device ID");
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("handles 422 error without message gracefully", async () => {
|
|
404
|
+
const mockApi = createMockApiClient({ success: false, errorStatus: 422 });
|
|
405
|
+
// Override to return null message
|
|
406
|
+
// @ts-expect-error - message is not yet implemented
|
|
407
|
+
mockApi.client = async () => ({
|
|
408
|
+
error: {
|
|
409
|
+
status: 422,
|
|
410
|
+
value: { message: null, error: { message: null } },
|
|
411
|
+
},
|
|
412
|
+
data: null,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const { client } = createTestClient({ api: mockApi });
|
|
416
|
+
const result = await client.identify();
|
|
417
|
+
|
|
418
|
+
expect(result.success).toBe(false);
|
|
419
|
+
if (!result.success) {
|
|
420
|
+
expect(result.error).toBe("Unknown error");
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("handles non-422 error extracting nested error message", async () => {
|
|
425
|
+
const mockApi = createMockApiClient({
|
|
426
|
+
success: false,
|
|
427
|
+
errorStatus: 403,
|
|
428
|
+
errorMessage: "Forbidden: invalid API key",
|
|
429
|
+
});
|
|
430
|
+
const { client } = createTestClient({ api: mockApi });
|
|
431
|
+
|
|
432
|
+
const result = await client.identify();
|
|
433
|
+
|
|
434
|
+
expect(result.success).toBe(false);
|
|
435
|
+
if (!result.success) {
|
|
436
|
+
expect(result.error).toBe("Forbidden: invalid API key");
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("handles thrown exceptions via tryCatch", async () => {
|
|
441
|
+
const mockApi = createMockApiClient({
|
|
442
|
+
throwError: new Error("Network connection failed"),
|
|
443
|
+
});
|
|
444
|
+
const { client } = createTestClient({ api: mockApi });
|
|
445
|
+
|
|
446
|
+
const result = await client.identify();
|
|
447
|
+
|
|
448
|
+
expect(result.success).toBe(false);
|
|
449
|
+
if (!result.success) {
|
|
450
|
+
expect(result.error).toBe("Network connection failed");
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("handles non-Error thrown exceptions", async () => {
|
|
455
|
+
const mockApi = createMockApiClient();
|
|
456
|
+
mockApi.client = async () => {
|
|
457
|
+
throw "string error"; // Non-Error throw
|
|
458
|
+
};
|
|
234
459
|
|
|
460
|
+
const { client } = createTestClient({ api: mockApi });
|
|
235
461
|
const result = await client.identify();
|
|
236
462
|
|
|
237
463
|
expect(result.success).toBe(false);
|
|
238
464
|
if (!result.success) {
|
|
239
|
-
expect(result.error).toBe("
|
|
465
|
+
expect(result.error).toBe("Unknown error");
|
|
240
466
|
}
|
|
241
467
|
});
|
|
242
468
|
|
|
243
469
|
test("persists identified state to storage", async () => {
|
|
244
|
-
const
|
|
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
|
-
);
|
|
470
|
+
const { client, mockStorage } = createTestClient();
|
|
257
471
|
|
|
258
472
|
await client.identify();
|
|
259
473
|
|
|
260
|
-
const stored = mockStorage.getStorage().get(
|
|
474
|
+
const stored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
|
|
261
475
|
expect(stored).toBeDefined();
|
|
262
476
|
const parsed = JSON.parse(stored!);
|
|
263
477
|
expect(parsed.type).toBe("identified");
|
|
264
478
|
expect(parsed.session.session_id).toBe("session-123");
|
|
479
|
+
expect(parsed.version_info.status).toBe(IdentifyVersionStatusEnum.UP_TO_DATE);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("passes persona data to API", async () => {
|
|
483
|
+
const { client, mockApi } = createTestClient();
|
|
484
|
+
|
|
485
|
+
const persona: Persona = {
|
|
486
|
+
name: "John Doe",
|
|
487
|
+
user_id: "user-456",
|
|
488
|
+
email: "john@example.com",
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
await client.identify(persona);
|
|
492
|
+
|
|
493
|
+
const lastCall = mockApi.getLastCall();
|
|
494
|
+
expect((lastCall.config.body as { persona?: Persona }).persona).toEqual(persona);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("passes undefined persona when not provided", async () => {
|
|
498
|
+
const { client, mockApi } = createTestClient();
|
|
499
|
+
|
|
500
|
+
await client.identify();
|
|
501
|
+
|
|
502
|
+
const lastCall = mockApi.getLastCall();
|
|
503
|
+
expect((lastCall.config.body as { persona?: Persona }).persona).toBeUndefined();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("passes partial persona data", async () => {
|
|
507
|
+
const { client, mockApi } = createTestClient();
|
|
508
|
+
|
|
509
|
+
const persona: Persona = { email: "only-email@test.com" };
|
|
510
|
+
|
|
511
|
+
await client.identify(persona);
|
|
512
|
+
|
|
513
|
+
const lastCall = mockApi.getLastCall();
|
|
514
|
+
expect((lastCall.config.body as { persona?: Persona }).persona).toEqual({ email: "only-email@test.com" });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("calls correct API endpoint", async () => {
|
|
518
|
+
const { client, mockApi } = createTestClient();
|
|
519
|
+
|
|
520
|
+
await client.identify();
|
|
521
|
+
|
|
522
|
+
const lastCall = mockApi.getLastCall();
|
|
523
|
+
expect(lastCall.endpoint).toBe("/v1/identify");
|
|
524
|
+
expect(lastCall.config.method).toBe("POST");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("sends correct headers to API", async () => {
|
|
528
|
+
const { client, mockApi } = createTestClient();
|
|
529
|
+
|
|
530
|
+
await client.identify();
|
|
531
|
+
|
|
532
|
+
const lastCall = mockApi.getLastCall();
|
|
533
|
+
expect(lastCall.config.headers["td-api-key"]).toBe("test-api-key");
|
|
534
|
+
expect(lastCall.config.headers["td-org-id"]).toBe("test-org-id");
|
|
535
|
+
expect(lastCall.config.headers["td-project-id"]).toBe("test-project-id");
|
|
536
|
+
expect(lastCall.config.headers["td-environment-slug"]).toBe("production");
|
|
537
|
+
expect(lastCall.config.headers["td-device-id"]).toBe("mock-device-id");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("sends device info in request body", async () => {
|
|
541
|
+
const mockDevice = createMockDeviceClient({
|
|
542
|
+
deviceId: "custom-device-id",
|
|
543
|
+
timestamp: "2024-01-15T10:30:00.000Z",
|
|
544
|
+
application: { name: "MyApp", version: "2.0.0", build: "200", bundle_id: "com.my.app" },
|
|
545
|
+
os: { name: "Android", version: "14" },
|
|
546
|
+
hardware: { brand: "Samsung", model: "Galaxy S24", device_type: "PHONE" },
|
|
547
|
+
});
|
|
548
|
+
const { client, mockApi } = createTestClient({ device: mockDevice });
|
|
549
|
+
|
|
550
|
+
await client.identify();
|
|
551
|
+
|
|
552
|
+
const lastCall = mockApi.getLastCall();
|
|
553
|
+
const device = (lastCall.config.body as { device?: { timestamp: string; application: { name: string; version: string; build: string; bundle_id: string }; os: { name: string; version: string }; hardware: { brand: string; model: string; device_type: string }; update: null } }).device;
|
|
554
|
+
|
|
555
|
+
expect(device?.timestamp).toBe("2024-01-15T10:30:00.000Z");
|
|
556
|
+
expect(device?.application.name).toBe("MyApp");
|
|
557
|
+
expect(device?.application.version).toBe("2.0.0");
|
|
558
|
+
expect(device?.os.name).toBe("Android");
|
|
559
|
+
expect(device?.hardware.brand).toBe("Samsung");
|
|
560
|
+
expect(device?.update).toBeNull();
|
|
265
561
|
});
|
|
266
562
|
});
|
|
267
563
|
|
|
268
|
-
describe("
|
|
269
|
-
test("
|
|
270
|
-
const mockLogging = createMockLoggingClient();
|
|
564
|
+
describe("setIdentifyState deduplication", () => {
|
|
565
|
+
test("does not emit when transitioning to same state type", async () => {
|
|
271
566
|
const mockStorage = createMockStorageClient();
|
|
272
|
-
const
|
|
567
|
+
const storedState = {
|
|
568
|
+
type: "identified",
|
|
569
|
+
session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
|
|
570
|
+
version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
|
|
571
|
+
};
|
|
572
|
+
mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, JSON.stringify(storedState));
|
|
573
|
+
|
|
273
574
|
const mockApi = createMockApiClient();
|
|
274
|
-
const
|
|
575
|
+
const { client } = createTestClient({ storage: mockStorage, api: mockApi });
|
|
576
|
+
loadStateFromStorage(client);
|
|
577
|
+
|
|
578
|
+
// Start identified
|
|
579
|
+
expect(client.getIdentifyState().type).toBe("identified");
|
|
580
|
+
|
|
581
|
+
const stateChanges: IdentifyState[] = [];
|
|
582
|
+
client.onIdentifyStateChange((state) => stateChanges.push(state));
|
|
583
|
+
|
|
584
|
+
// Identify again (will go identifying -> identified)
|
|
585
|
+
await client.identify();
|
|
586
|
+
|
|
587
|
+
// Should emit identifying and identified
|
|
588
|
+
expect(stateChanges).toHaveLength(2);
|
|
589
|
+
expect(stateChanges[0].type).toBe("identifying");
|
|
590
|
+
expect(stateChanges[1].type).toBe("identified");
|
|
591
|
+
});
|
|
275
592
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
);
|
|
593
|
+
test("logs debug message when state type unchanged", async () => {
|
|
594
|
+
const mockLogging = createMockLoggingClient();
|
|
595
|
+
const { client } = createTestClient({ logging: mockLogging });
|
|
596
|
+
|
|
597
|
+
// First identify succeeds
|
|
598
|
+
await client.identify();
|
|
599
|
+
expect(client.getIdentifyState().type).toBe("identified");
|
|
600
|
+
|
|
601
|
+
mockLogging.clearLogs();
|
|
602
|
+
|
|
603
|
+
// Second identify
|
|
604
|
+
await client.identify();
|
|
605
|
+
|
|
606
|
+
// Should have debug log about state already being 'identifying' (first setIdentifyState call)
|
|
607
|
+
// Then transitions to identified
|
|
608
|
+
const debugLogs = mockLogging.getLogs().filter(l => l.level === "debug");
|
|
609
|
+
// The "identifying" to "identifying" won't happen since we start from "identified"
|
|
610
|
+
// But we can check that state changes are logged properly
|
|
611
|
+
expect(mockLogging.getLogs().some(l => l.level === "info")).toBe(true);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe("onIdentifyStateChange", () => {
|
|
616
|
+
test("emits state changes to listeners", async () => {
|
|
617
|
+
const { client } = createTestClient();
|
|
283
618
|
|
|
284
619
|
const states: IdentifyState[] = [];
|
|
285
620
|
client.onIdentifyStateChange((state) => states.push(state));
|
|
@@ -289,20 +624,24 @@ describe("IdentityClient", () => {
|
|
|
289
624
|
expect(states.length).toBeGreaterThan(0);
|
|
290
625
|
});
|
|
291
626
|
|
|
292
|
-
test("
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
627
|
+
test("supports multiple listeners", async () => {
|
|
628
|
+
const { client } = createTestClient();
|
|
629
|
+
|
|
630
|
+
const states1: IdentifyState[] = [];
|
|
631
|
+
const states2: IdentifyState[] = [];
|
|
632
|
+
|
|
633
|
+
client.onIdentifyStateChange((state) => states1.push(state));
|
|
634
|
+
client.onIdentifyStateChange((state) => states2.push(state));
|
|
635
|
+
|
|
636
|
+
await client.identify();
|
|
298
637
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
);
|
|
638
|
+
expect(states1.length).toBeGreaterThan(0);
|
|
639
|
+
expect(states2.length).toBeGreaterThan(0);
|
|
640
|
+
expect(states1).toEqual(states2);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("returns unsubscribe function", async () => {
|
|
644
|
+
const { client } = createTestClient();
|
|
306
645
|
|
|
307
646
|
const states: IdentifyState[] = [];
|
|
308
647
|
const unsubscribe = client.onIdentifyStateChange((state) => states.push(state));
|
|
@@ -311,26 +650,49 @@ describe("IdentityClient", () => {
|
|
|
311
650
|
|
|
312
651
|
await client.identify();
|
|
313
652
|
|
|
314
|
-
// Should not receive any state changes after unsubscribing
|
|
315
653
|
expect(states).toHaveLength(0);
|
|
316
654
|
});
|
|
655
|
+
|
|
656
|
+
test("only unsubscribes the specific listener", async () => {
|
|
657
|
+
const { client } = createTestClient();
|
|
658
|
+
|
|
659
|
+
const states1: IdentifyState[] = [];
|
|
660
|
+
const states2: IdentifyState[] = [];
|
|
661
|
+
|
|
662
|
+
const unsubscribe1 = client.onIdentifyStateChange((state) => states1.push(state));
|
|
663
|
+
client.onIdentifyStateChange((state) => states2.push(state));
|
|
664
|
+
|
|
665
|
+
unsubscribe1();
|
|
666
|
+
|
|
667
|
+
await client.identify();
|
|
668
|
+
|
|
669
|
+
expect(states1).toHaveLength(0);
|
|
670
|
+
expect(states2.length).toBeGreaterThan(0);
|
|
671
|
+
});
|
|
317
672
|
});
|
|
318
673
|
|
|
319
674
|
describe("refresh", () => {
|
|
320
675
|
test("returns error when not identified", async () => {
|
|
321
|
-
const
|
|
676
|
+
const { client } = createTestClient();
|
|
677
|
+
|
|
678
|
+
const result = await client.refresh();
|
|
679
|
+
|
|
680
|
+
expect(result.success).toBe(false);
|
|
681
|
+
if (!result.success) {
|
|
682
|
+
expect(result.error).toBe("Not identified");
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("returns error when unidentified (stale identifying state resets)", async () => {
|
|
322
687
|
const mockStorage = createMockStorageClient();
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const mockDevice = createMockDeviceClient();
|
|
688
|
+
// "identifying" in storage gets reset to "unidentified" on load
|
|
689
|
+
mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, JSON.stringify({ type: "identifying" }));
|
|
326
690
|
|
|
327
|
-
const client =
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
mockDevice as never
|
|
333
|
-
);
|
|
691
|
+
const { client } = createTestClient({ storage: mockStorage });
|
|
692
|
+
loadStateFromStorage(client);
|
|
693
|
+
|
|
694
|
+
// State should have been reset to unidentified
|
|
695
|
+
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
334
696
|
|
|
335
697
|
const result = await client.refresh();
|
|
336
698
|
|
|
@@ -341,76 +703,78 @@ describe("IdentityClient", () => {
|
|
|
341
703
|
});
|
|
342
704
|
|
|
343
705
|
test("re-identifies when already identified", async () => {
|
|
344
|
-
const
|
|
345
|
-
const mockStorage = createMockStorageClient();
|
|
346
|
-
const mockUtils = createMockUtilsClient();
|
|
347
|
-
const mockApi = createMockApiClient();
|
|
348
|
-
const mockDevice = createMockDeviceClient();
|
|
706
|
+
const { client, mockApi } = createTestClient();
|
|
349
707
|
|
|
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
708
|
await client.identify();
|
|
360
709
|
expect(client.getIdentifyState().type).toBe("identified");
|
|
361
710
|
|
|
362
|
-
|
|
711
|
+
mockApi.clearCalls();
|
|
712
|
+
|
|
363
713
|
const result = await client.refresh();
|
|
714
|
+
|
|
364
715
|
expect(result.success).toBe(true);
|
|
716
|
+
expect(mockApi.getCalls()).toHaveLength(1);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("returns updated data on refresh", async () => {
|
|
720
|
+
const mockApi = createMockApiClient({
|
|
721
|
+
sessionId: "original-session",
|
|
722
|
+
});
|
|
723
|
+
const { client } = createTestClient({ api: mockApi });
|
|
724
|
+
|
|
725
|
+
await client.identify();
|
|
726
|
+
|
|
727
|
+
// Update mock to return new session
|
|
728
|
+
mockApi.client = async (endpoint: string, config: ApiCallRecord["config"]) => ({
|
|
729
|
+
error: null,
|
|
730
|
+
data: {
|
|
731
|
+
data: {
|
|
732
|
+
session_id: "refreshed-session",
|
|
733
|
+
device_id: "device-123",
|
|
734
|
+
persona_id: "persona-123",
|
|
735
|
+
token: "token-123",
|
|
736
|
+
version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE },
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const result = await client.refresh();
|
|
742
|
+
|
|
743
|
+
expect(result.success).toBe(true);
|
|
744
|
+
if (result.success) {
|
|
745
|
+
expect(result.data.session_id).toBe("refreshed-session");
|
|
746
|
+
}
|
|
365
747
|
});
|
|
366
748
|
});
|
|
367
749
|
|
|
368
750
|
describe("reset", () => {
|
|
369
751
|
test("resets state to unidentified", async () => {
|
|
370
|
-
const
|
|
371
|
-
const mockStorage = createMockStorageClient();
|
|
372
|
-
const mockUtils = createMockUtilsClient();
|
|
373
|
-
const mockApi = createMockApiClient();
|
|
374
|
-
const mockDevice = createMockDeviceClient();
|
|
752
|
+
const { client } = createTestClient();
|
|
375
753
|
|
|
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
754
|
await client.identify();
|
|
386
755
|
expect(client.getIdentifyState().type).toBe("identified");
|
|
387
756
|
|
|
388
|
-
// Then reset
|
|
389
757
|
client.reset();
|
|
390
758
|
|
|
391
759
|
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
392
|
-
|
|
393
|
-
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("removes state from storage then saves unidentified", async () => {
|
|
763
|
+
const { client, mockStorage } = createTestClient();
|
|
764
|
+
|
|
765
|
+
await client.identify();
|
|
766
|
+
expect(mockStorage.getStorage().has(IDENTIFY_STORAGE_KEY)).toBe(true);
|
|
767
|
+
|
|
768
|
+
client.reset();
|
|
769
|
+
|
|
770
|
+
// After reset, storage should contain unidentified state
|
|
771
|
+
const stored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
|
|
394
772
|
expect(stored).toBeDefined();
|
|
395
|
-
|
|
396
|
-
expect(JSON.parse(stored).type).toBe("unidentified");
|
|
397
|
-
}
|
|
773
|
+
expect(JSON.parse(stored!).type).toBe("unidentified");
|
|
398
774
|
});
|
|
399
775
|
|
|
400
776
|
test("emits unidentified state on reset", async () => {
|
|
401
|
-
const
|
|
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
|
-
);
|
|
777
|
+
const { client } = createTestClient();
|
|
414
778
|
|
|
415
779
|
await client.identify();
|
|
416
780
|
|
|
@@ -422,23 +786,152 @@ describe("IdentityClient", () => {
|
|
|
422
786
|
expect(states).toHaveLength(1);
|
|
423
787
|
expect(states[0].type).toBe("unidentified");
|
|
424
788
|
});
|
|
789
|
+
|
|
790
|
+
test("can identify again after reset", async () => {
|
|
791
|
+
const { client } = createTestClient();
|
|
792
|
+
|
|
793
|
+
await client.identify();
|
|
794
|
+
client.reset();
|
|
795
|
+
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
796
|
+
|
|
797
|
+
const result = await client.identify();
|
|
798
|
+
|
|
799
|
+
expect(result.success).toBe(true);
|
|
800
|
+
expect(client.getIdentifyState().type).toBe("identified");
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("does not emit when already unidentified", () => {
|
|
804
|
+
const { client } = createTestClient();
|
|
805
|
+
|
|
806
|
+
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
807
|
+
|
|
808
|
+
const states: IdentifyState[] = [];
|
|
809
|
+
client.onIdentifyStateChange((state) => states.push(state));
|
|
810
|
+
|
|
811
|
+
client.reset();
|
|
812
|
+
|
|
813
|
+
// setIdentifyState checks type equality and won't emit
|
|
814
|
+
expect(states).toHaveLength(0);
|
|
815
|
+
});
|
|
425
816
|
});
|
|
426
817
|
|
|
427
|
-
describe("
|
|
428
|
-
test("
|
|
429
|
-
const
|
|
818
|
+
describe("initialize", () => {
|
|
819
|
+
test("calls identify on initialize", async () => {
|
|
820
|
+
const { client, mockApi } = createTestClient();
|
|
821
|
+
|
|
822
|
+
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
823
|
+
|
|
824
|
+
await client.initialize();
|
|
825
|
+
|
|
826
|
+
expect(client.getIdentifyState().type).toBe("identified");
|
|
827
|
+
expect(mockApi.getCalls()).toHaveLength(1);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test("returns void on success", async () => {
|
|
831
|
+
const { client } = createTestClient();
|
|
832
|
+
|
|
833
|
+
const result = await client.initialize();
|
|
834
|
+
|
|
835
|
+
expect(result).toBeUndefined();
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test("propagates identify failure silently", async () => {
|
|
839
|
+
const mockApi = createMockApiClient({ success: false });
|
|
840
|
+
const { client } = createTestClient({ api: mockApi });
|
|
841
|
+
|
|
842
|
+
// initialize doesn't return the result, so we check state
|
|
843
|
+
await client.initialize();
|
|
844
|
+
|
|
845
|
+
expect(client.getIdentifyState().type).toBe("unidentified");
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
describe("getSessionState", () => {
|
|
850
|
+
test("returns null when unidentified", () => {
|
|
851
|
+
const { client } = createTestClient();
|
|
852
|
+
|
|
853
|
+
expect(client.getSessionState()).toBeNull();
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("returns null when identifying state restored (resets to unidentified)", () => {
|
|
430
857
|
const mockStorage = createMockStorageClient();
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
858
|
+
mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, JSON.stringify({ type: "identifying" }));
|
|
859
|
+
|
|
860
|
+
const { client } = createTestClient({ storage: mockStorage });
|
|
861
|
+
loadStateFromStorage(client);
|
|
862
|
+
|
|
863
|
+
// "identifying" is transient, resets to "unidentified"
|
|
864
|
+
expect(client.getSessionState()).toBeNull();
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
test("returns session when identified", async () => {
|
|
868
|
+
const { client } = createTestClient();
|
|
869
|
+
|
|
870
|
+
await client.identify();
|
|
434
871
|
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
872
|
+
const session = client.getSessionState();
|
|
873
|
+
expect(session).not.toBeNull();
|
|
874
|
+
expect(session?.session_id).toBe("session-123");
|
|
875
|
+
expect(session?.device_id).toBe("device-123");
|
|
876
|
+
expect(session?.persona_id).toBe("persona-123");
|
|
877
|
+
expect(session?.token).toBe("token-123");
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("returns updated session after re-identify", async () => {
|
|
881
|
+
const mockApi = createMockApiClient({ sessionId: "first-session" });
|
|
882
|
+
const { client } = createTestClient({ api: mockApi });
|
|
883
|
+
|
|
884
|
+
await client.identify();
|
|
885
|
+
expect(client.getSessionState()?.session_id).toBe("first-session");
|
|
886
|
+
|
|
887
|
+
// Change what API returns
|
|
888
|
+
mockApi.client = async () => ({
|
|
889
|
+
error: null,
|
|
890
|
+
data: {
|
|
891
|
+
data: {
|
|
892
|
+
session_id: "second-session",
|
|
893
|
+
device_id: "device-123",
|
|
894
|
+
persona_id: "persona-123",
|
|
895
|
+
token: "token-123",
|
|
896
|
+
version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE },
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
await client.identify();
|
|
902
|
+
expect(client.getSessionState()?.session_id).toBe("second-session");
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
describe("getIdentifyState", () => {
|
|
907
|
+
test("returns current state object", () => {
|
|
908
|
+
const { client } = createTestClient();
|
|
909
|
+
|
|
910
|
+
const state = client.getIdentifyState();
|
|
911
|
+
|
|
912
|
+
expect(state).toBeDefined();
|
|
913
|
+
expect(state.type).toBe("unidentified");
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("returns identified state with all fields", async () => {
|
|
917
|
+
const { client } = createTestClient();
|
|
918
|
+
|
|
919
|
+
await client.identify();
|
|
920
|
+
|
|
921
|
+
const state = client.getIdentifyState();
|
|
922
|
+
expect(state.type).toBe("identified");
|
|
923
|
+
if (state.type === "identified") {
|
|
924
|
+
expect(state.session).toBeDefined();
|
|
925
|
+
expect(state.version_info).toBeDefined();
|
|
926
|
+
expect(state.version_info.status).toBe(IdentifyVersionStatusEnum.UP_TO_DATE);
|
|
927
|
+
expect(state.version_info.update).toBeNull();
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
describe("shutdown", () => {
|
|
933
|
+
test("removes all listeners", async () => {
|
|
934
|
+
const { client } = createTestClient();
|
|
442
935
|
|
|
443
936
|
const states: IdentifyState[] = [];
|
|
444
937
|
client.onIdentifyStateChange((state) => states.push(state));
|
|
@@ -447,8 +940,180 @@ describe("IdentityClient", () => {
|
|
|
447
940
|
|
|
448
941
|
await client.identify();
|
|
449
942
|
|
|
450
|
-
// Should not receive any state changes after shutdown
|
|
451
943
|
expect(states).toHaveLength(0);
|
|
452
944
|
});
|
|
945
|
+
|
|
946
|
+
test("removes multiple listeners", async () => {
|
|
947
|
+
const { client } = createTestClient();
|
|
948
|
+
|
|
949
|
+
const states1: IdentifyState[] = [];
|
|
950
|
+
const states2: IdentifyState[] = [];
|
|
951
|
+
|
|
952
|
+
client.onIdentifyStateChange((state) => states1.push(state));
|
|
953
|
+
client.onIdentifyStateChange((state) => states2.push(state));
|
|
954
|
+
|
|
955
|
+
client.shutdown();
|
|
956
|
+
|
|
957
|
+
await client.identify();
|
|
958
|
+
|
|
959
|
+
expect(states1).toHaveLength(0);
|
|
960
|
+
expect(states2).toHaveLength(0);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("client still functions after shutdown", async () => {
|
|
964
|
+
const { client } = createTestClient();
|
|
965
|
+
|
|
966
|
+
client.shutdown();
|
|
967
|
+
|
|
968
|
+
const result = await client.identify();
|
|
969
|
+
|
|
970
|
+
expect(result.success).toBe(true);
|
|
971
|
+
expect(client.getIdentifyState().type).toBe("identified");
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test("can add new listeners after shutdown", async () => {
|
|
975
|
+
const { client } = createTestClient();
|
|
976
|
+
|
|
977
|
+
client.shutdown();
|
|
978
|
+
|
|
979
|
+
const states: IdentifyState[] = [];
|
|
980
|
+
client.onIdentifyStateChange((state) => states.push(state));
|
|
981
|
+
|
|
982
|
+
await client.identify();
|
|
983
|
+
|
|
984
|
+
expect(states.length).toBeGreaterThan(0);
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
describe("storage persistence", () => {
|
|
989
|
+
test("persists identifying state during identify", async () => {
|
|
990
|
+
const mockStorage = createMockStorageClient();
|
|
991
|
+
const mockApi = createMockApiClient();
|
|
992
|
+
|
|
993
|
+
// Make API slow so we can check intermediate state
|
|
994
|
+
let resolveApi: (value: unknown) => void;
|
|
995
|
+
const apiPromise = new Promise((resolve) => { resolveApi = resolve; });
|
|
996
|
+
|
|
997
|
+
mockApi.client = async () => {
|
|
998
|
+
await apiPromise;
|
|
999
|
+
return {
|
|
1000
|
+
error: null,
|
|
1001
|
+
data: {
|
|
1002
|
+
data: {
|
|
1003
|
+
session_id: "session-123",
|
|
1004
|
+
device_id: "device-123",
|
|
1005
|
+
persona_id: "persona-123",
|
|
1006
|
+
token: "token-123",
|
|
1007
|
+
version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE },
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
const { client } = createTestClient({ storage: mockStorage, api: mockApi });
|
|
1014
|
+
|
|
1015
|
+
const identifyPromise = client.identify();
|
|
1016
|
+
|
|
1017
|
+
// Check storage while API call is pending
|
|
1018
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
1019
|
+
const intermediateStored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
|
|
1020
|
+
expect(JSON.parse(intermediateStored!).type).toBe("identifying");
|
|
1021
|
+
|
|
1022
|
+
// Complete API call
|
|
1023
|
+
resolveApi!(null);
|
|
1024
|
+
await identifyPromise;
|
|
1025
|
+
|
|
1026
|
+
const finalStored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
|
|
1027
|
+
expect(JSON.parse(finalStored!).type).toBe("identified");
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test("storage survives client recreation", async () => {
|
|
1031
|
+
const mockStorage = createMockStorageClient();
|
|
1032
|
+
|
|
1033
|
+
// Create first client and identify
|
|
1034
|
+
const { client: client1 } = createTestClient({ storage: mockStorage });
|
|
1035
|
+
await client1.identify();
|
|
1036
|
+
|
|
1037
|
+
// Create second client with same storage
|
|
1038
|
+
const { client: client2 } = createTestClient({ storage: mockStorage });
|
|
1039
|
+
loadStateFromStorage(client2);
|
|
1040
|
+
|
|
1041
|
+
// Should restore identified state
|
|
1042
|
+
expect(client2.getIdentifyState().type).toBe("identified");
|
|
1043
|
+
expect(client2.getSessionState()?.session_id).toBe("session-123");
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
describe("concurrent operations", () => {
|
|
1048
|
+
test("handles rapid successive identify calls", async () => {
|
|
1049
|
+
const { client } = createTestClient();
|
|
1050
|
+
|
|
1051
|
+
const results = await Promise.all([
|
|
1052
|
+
client.identify(),
|
|
1053
|
+
client.identify(),
|
|
1054
|
+
client.identify(),
|
|
1055
|
+
]);
|
|
1056
|
+
|
|
1057
|
+
// All should succeed
|
|
1058
|
+
results.forEach(result => {
|
|
1059
|
+
expect(result.success).toBe(true);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
expect(client.getIdentifyState().type).toBe("identified");
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
test("handles identify during reset", async () => {
|
|
1066
|
+
const { client } = createTestClient();
|
|
1067
|
+
|
|
1068
|
+
await client.identify();
|
|
1069
|
+
|
|
1070
|
+
// Start identify and immediately reset
|
|
1071
|
+
const identifyPromise = client.identify();
|
|
1072
|
+
client.reset();
|
|
1073
|
+
|
|
1074
|
+
await identifyPromise;
|
|
1075
|
+
|
|
1076
|
+
// Final state depends on timing, but should be valid
|
|
1077
|
+
const state = client.getIdentifyState();
|
|
1078
|
+
expect(["unidentified", "identified"]).toContain(state.type);
|
|
1079
|
+
});
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
describe("edge cases", () => {
|
|
1083
|
+
test("handles empty persona object", async () => {
|
|
1084
|
+
const { client, mockApi } = createTestClient();
|
|
1085
|
+
|
|
1086
|
+
await client.identify({});
|
|
1087
|
+
|
|
1088
|
+
const lastCall = mockApi.getLastCall();
|
|
1089
|
+
expect(lastCall.config.body.persona).toEqual({});
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
test("handles very long session tokens", async () => {
|
|
1093
|
+
const longToken = "t".repeat(10000);
|
|
1094
|
+
const mockApi = createMockApiClient({ token: longToken });
|
|
1095
|
+
const { client } = createTestClient({ api: mockApi });
|
|
1096
|
+
|
|
1097
|
+
const result = await client.identify();
|
|
1098
|
+
|
|
1099
|
+
expect(result.success).toBe(true);
|
|
1100
|
+
if (result.success) {
|
|
1101
|
+
expect(result.data.token).toBe(longToken);
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test("handles special characters in session data", async () => {
|
|
1106
|
+
const mockApi = createMockApiClient({
|
|
1107
|
+
sessionId: "session-with-émojis-🚀-and-üñíçödé",
|
|
1108
|
+
});
|
|
1109
|
+
const { client, mockStorage } = createTestClient({ api: mockApi });
|
|
1110
|
+
|
|
1111
|
+
await client.identify();
|
|
1112
|
+
|
|
1113
|
+
// Verify it can be stored and retrieved
|
|
1114
|
+
const stored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
|
|
1115
|
+
const parsed = JSON.parse(stored!);
|
|
1116
|
+
expect(parsed.session.session_id).toBe("session-with-émojis-🚀-and-üñíçödé");
|
|
1117
|
+
});
|
|
453
1118
|
});
|
|
454
|
-
});
|
|
1119
|
+
});
|