@reservamos/browser-analytics 1.1.8-alpha.1 → 1.1.8-alpha.3
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/CHANGELOG.md +19 -2
- package/README.md +32 -4
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +23 -10
- package/dist/browser-analytics.esm.js +466 -154
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +2 -2
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +2 -2
- package/src/events/identify/identify.recovery.test.ts +367 -0
- package/src/events/identify/identify.robustness.test.ts +112 -13
- package/src/events/identify/identify.ts +72 -35
- package/src/events/passengersCreated/passengersCreatedSchema.test.ts +142 -0
- package/src/events/passengersCreated/passengersCreatedSchema.ts +7 -1
- package/src/events/purchaseConfirmation/purchaseConfirmation.integration.test.ts +49 -0
- package/src/events/purchaseConfirmation/purchaseConfirmation.test.ts +17 -0
- package/src/events/purchaseConfirmation/purchaseConfirmationSchema.ts +2 -0
- package/src/index.ts +1 -0
- package/src/init.ts +10 -1
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.ts +18 -2
- package/src/services/botDetectionRelay.test.ts +3 -2
- package/src/services/fingerprint.test.ts +172 -0
- package/src/services/fingerprint.ts +106 -5
- package/src/services/fingerprint.wait.integration.test.ts +129 -0
- package/src/services/mixpanel.batch.test.ts +108 -0
- package/src/services/mixpanel.test.ts +77 -73
- package/src/services/mixpanel.ts +45 -18
- package/src/services/profileEnrichment.ts +258 -0
- package/src/track.storage.test.ts +27 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reservamos/browser-analytics",
|
|
3
|
-
"version": "1.1.8-alpha.
|
|
3
|
+
"version": "1.1.8-alpha.3",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/reservamos/reservamos-browser-analytics.git"
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@eslint/js": "^9.9.1",
|
|
76
76
|
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
|
77
|
-
"@reservamos/js-api-client": "6.28.
|
|
77
|
+
"@reservamos/js-api-client": "6.28.1",
|
|
78
78
|
"@types/eslint__js": "^8.42.3",
|
|
79
79
|
"@types/jsdom": "^21.1.6",
|
|
80
80
|
"@types/mixpanel-browser": "^2.50.0",
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import type { InitConfig } from '@/init';
|
|
2
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
profile: vi.fn(),
|
|
6
|
+
people: vi.fn(),
|
|
7
|
+
restore: vi.fn(),
|
|
8
|
+
identify: vi.fn(),
|
|
9
|
+
user: 'user-a',
|
|
10
|
+
apiBaseUrl: 'https://core.test',
|
|
11
|
+
appName: '',
|
|
12
|
+
fingerprint: null as string | null,
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('@reservamos/js-api-client/core', () => ({
|
|
15
|
+
default: {
|
|
16
|
+
setConfig: vi.fn(),
|
|
17
|
+
profiles: { createAnonymousProfile: mocks.profile },
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@/services/mixpanel', async () => {
|
|
21
|
+
const lifecycle = (await import('@/services/trackingLifecycle')).default;
|
|
22
|
+
return {
|
|
23
|
+
default: {
|
|
24
|
+
init: vi.fn(async () => lifecycle.beginInitialization()),
|
|
25
|
+
validateConfiguration: vi.fn(),
|
|
26
|
+
isReady: () => true,
|
|
27
|
+
getMixpanelDistinctId: () => mocks.user,
|
|
28
|
+
identify: mocks.identify.mockImplementation((user: string) => {
|
|
29
|
+
mocks.user = user;
|
|
30
|
+
lifecycle.identify(user);
|
|
31
|
+
}),
|
|
32
|
+
setPeopleProperties: mocks.people,
|
|
33
|
+
restorePeopleProperties: mocks.restore,
|
|
34
|
+
attachProperty: vi.fn(),
|
|
35
|
+
track: vi.fn(),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
vi.mock('@/services/fingerprint', () => ({
|
|
40
|
+
getFingerprintForIdentify: vi.fn().mockResolvedValue(''),
|
|
41
|
+
default: {
|
|
42
|
+
initFingerprint: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
getCachedFingerprint: () => mocks.fingerprint,
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
vi.mock('@/services/geolocation', () => ({
|
|
47
|
+
default: {
|
|
48
|
+
init: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
getCoordinates: () => null,
|
|
50
|
+
getGeolocationSource: () => null,
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
vi.mock('@/services/config', () => ({
|
|
54
|
+
default: {
|
|
55
|
+
setEnvironment: vi.fn(),
|
|
56
|
+
setAppName: vi.fn(),
|
|
57
|
+
getCoreAPIConfig: () => ({
|
|
58
|
+
baseUrl: mocks.apiBaseUrl,
|
|
59
|
+
defaultHeaders: { 'x-app-name': mocks.appName },
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const config = { mixpanelToken: 'recovery-project', identificationKey: 'test' };
|
|
65
|
+
let init: typeof import('@/init').default;
|
|
66
|
+
let identify: typeof import('./identify').default;
|
|
67
|
+
|
|
68
|
+
async function loadDestination(destinationConfig: InitConfig = config) {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
vi.clearAllTimers();
|
|
71
|
+
vi.setSystemTime(now);
|
|
72
|
+
vi.resetModules();
|
|
73
|
+
init = (await import('@/init')).default;
|
|
74
|
+
identify = (await import('./identify')).default;
|
|
75
|
+
await init(destinationConfig);
|
|
76
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
vi.resetModules();
|
|
82
|
+
sessionStorage.clear();
|
|
83
|
+
localStorage.clear();
|
|
84
|
+
mocks.user = 'user-a';
|
|
85
|
+
mocks.apiBaseUrl = 'https://core.test';
|
|
86
|
+
mocks.appName = '';
|
|
87
|
+
mocks.fingerprint = null;
|
|
88
|
+
mocks.profile.mockReset();
|
|
89
|
+
mocks.people
|
|
90
|
+
.mockReset()
|
|
91
|
+
.mockImplementation((_props, _report, accepted?: () => void) =>
|
|
92
|
+
accepted?.(),
|
|
93
|
+
);
|
|
94
|
+
mocks.restore
|
|
95
|
+
.mockReset()
|
|
96
|
+
.mockImplementation((_user, _props, _report, accepted?: () => void) =>
|
|
97
|
+
accepted?.(),
|
|
98
|
+
);
|
|
99
|
+
mocks.identify.mockReset();
|
|
100
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
101
|
+
init = (await import('@/init')).default;
|
|
102
|
+
identify = (await import('./identify')).default;
|
|
103
|
+
await init(config);
|
|
104
|
+
});
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
vi.clearAllTimers();
|
|
107
|
+
vi.useRealTimers();
|
|
108
|
+
vi.restoreAllMocks();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('recovers a lost profile response on destination init without consumer identify', async () => {
|
|
112
|
+
mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
|
|
113
|
+
await identify('user-a', {
|
|
114
|
+
email: 'a@example.com',
|
|
115
|
+
firstName: 'Original name',
|
|
116
|
+
customField: 'Not persisted',
|
|
117
|
+
});
|
|
118
|
+
expect(sessionStorage.length).toBe(1);
|
|
119
|
+
const stored = sessionStorage.getItem(sessionStorage.key(0) || '') || '';
|
|
120
|
+
expect(stored).toContain('a@example.com');
|
|
121
|
+
expect(stored).not.toContain('Not persisted');
|
|
122
|
+
|
|
123
|
+
mocks.profile.mockResolvedValueOnce({ id: 'profile-a' });
|
|
124
|
+
await loadDestination();
|
|
125
|
+
|
|
126
|
+
expect(mocks.profile).toHaveBeenCalledTimes(2);
|
|
127
|
+
expect(mocks.restore).toHaveBeenCalledWith(
|
|
128
|
+
'user-a',
|
|
129
|
+
{ reservamos_one_id: 'profile-a' },
|
|
130
|
+
expect.any(Function),
|
|
131
|
+
expect.any(Function),
|
|
132
|
+
);
|
|
133
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(sessionStorage.length).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('keeps a resolved numeric profile ID until people acceptance and drops the contact identifier', async () => {
|
|
138
|
+
mocks.people.mockImplementation(() => undefined);
|
|
139
|
+
mocks.profile.mockResolvedValue({ id: 42 });
|
|
140
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
141
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
142
|
+
const stored = sessionStorage.getItem(sessionStorage.key(0) || '') || '';
|
|
143
|
+
expect(JSON.parse(stored)).toEqual(
|
|
144
|
+
expect.objectContaining({ profileId: 42 }),
|
|
145
|
+
);
|
|
146
|
+
expect(stored).not.toContain('a@example.com');
|
|
147
|
+
await loadDestination();
|
|
148
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(mocks.restore).toHaveBeenCalledWith(
|
|
150
|
+
'user-a',
|
|
151
|
+
{ reservamos_one_id: 42 },
|
|
152
|
+
expect.any(Function),
|
|
153
|
+
expect.any(Function),
|
|
154
|
+
);
|
|
155
|
+
expect(sessionStorage.length).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('does not resume an intent for a different persisted user', async () => {
|
|
159
|
+
mocks.profile.mockReturnValue(new Promise(() => undefined));
|
|
160
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
161
|
+
mocks.user = 'user-b';
|
|
162
|
+
await loadDestination();
|
|
163
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
164
|
+
expect(mocks.restore).not.toHaveBeenCalled();
|
|
165
|
+
expect(sessionStorage.length).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it.each(['project', 'proxy', 'core', 'application'])(
|
|
169
|
+
'does not resume an intent with a different %s',
|
|
170
|
+
async (change) => {
|
|
171
|
+
mocks.profile.mockReturnValue(new Promise(() => undefined));
|
|
172
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
173
|
+
const destination: InitConfig = { ...config };
|
|
174
|
+
if (change === 'project') destination.mixpanelToken = 'other-project';
|
|
175
|
+
if (change === 'proxy')
|
|
176
|
+
destination.mixpanelProxyUrl = 'https://other-proxy.test';
|
|
177
|
+
if (change === 'core') mocks.apiBaseUrl = 'https://other-core.test';
|
|
178
|
+
if (change === 'application') mocks.appName = 'other-app';
|
|
179
|
+
await loadDestination(destination);
|
|
180
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
181
|
+
expect(mocks.restore).not.toHaveBeenCalled();
|
|
182
|
+
expect(sessionStorage.length).toBe(0);
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
it('recovers a phone-only profile and preserves its original user identifier', async () => {
|
|
187
|
+
mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
|
|
188
|
+
await identify('user-a', { phone: '+525500000000' });
|
|
189
|
+
mocks.profile.mockResolvedValueOnce({ id: 'phone-profile' });
|
|
190
|
+
await loadDestination();
|
|
191
|
+
expect(mocks.profile.mock.calls[1][0]).toEqual(
|
|
192
|
+
expect.objectContaining({
|
|
193
|
+
identifier_key: 'phone',
|
|
194
|
+
identifier_value: '+525500000000',
|
|
195
|
+
identifiers: [{ key: 'distinct_id', value: 'user-a' }],
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
expect(mocks.restore).toHaveBeenCalledWith(
|
|
199
|
+
'user-a',
|
|
200
|
+
{ reservamos_one_id: 'phone-profile' },
|
|
201
|
+
expect.any(Function),
|
|
202
|
+
expect.any(Function),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('bounds a stalled request without retrying until another initialization', async () => {
|
|
207
|
+
mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
|
|
208
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
209
|
+
await vi.advanceTimersByTimeAsync(3100);
|
|
210
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
211
|
+
expect(sessionStorage.length).toBe(1);
|
|
212
|
+
mocks.profile.mockResolvedValueOnce({ id: 'recovered-profile' });
|
|
213
|
+
await loadDestination();
|
|
214
|
+
expect(mocks.profile).toHaveBeenCalledTimes(2);
|
|
215
|
+
expect(sessionStorage.length).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it.each(['user-a', 'user-b'])(
|
|
219
|
+
'does not let a superseded response overwrite or clear a replacement intent for %s',
|
|
220
|
+
async (nextUser) => {
|
|
221
|
+
let finish!: (profile: { id: string }) => void;
|
|
222
|
+
mocks.profile.mockReturnValueOnce(
|
|
223
|
+
new Promise((resolve) => {
|
|
224
|
+
finish = resolve;
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
await identify('user-a', { email: 'old@example.com' });
|
|
228
|
+
mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
|
|
229
|
+
await identify(nextUser, { email: 'new@example.com' });
|
|
230
|
+
finish({ id: 'old-profile' });
|
|
231
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
232
|
+
expect(
|
|
233
|
+
mocks.people.mock.calls.some(
|
|
234
|
+
([properties]) => properties.reservamos_one_id === 'old-profile',
|
|
235
|
+
),
|
|
236
|
+
).toBe(false);
|
|
237
|
+
const entry = JSON.parse(
|
|
238
|
+
sessionStorage.getItem(sessionStorage.key(0) || '') || '{}',
|
|
239
|
+
);
|
|
240
|
+
expect(entry.userId).toBe(nextUser);
|
|
241
|
+
expect(entry.request.identifier_value).toBe('new@example.com');
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
it('rejects expired recovery work on initialization', async () => {
|
|
246
|
+
mocks.profile.mockReturnValue(new Promise(() => undefined));
|
|
247
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
248
|
+
vi.setSystemTime(Date.now() + 10 * 60 * 1000 + 1);
|
|
249
|
+
await loadDestination();
|
|
250
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
251
|
+
expect(sessionStorage.length).toBe(0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it.each(['{invalid', JSON.stringify({ version: 99 }), 'x'.repeat(4097)])(
|
|
255
|
+
'discards invalid persisted work without requesting a profile',
|
|
256
|
+
async (entry) => {
|
|
257
|
+
sessionStorage.setItem('reservamos_pending_profile_v1', entry);
|
|
258
|
+
await loadDestination();
|
|
259
|
+
expect(mocks.profile).not.toHaveBeenCalled();
|
|
260
|
+
expect(sessionStorage.length).toBe(0);
|
|
261
|
+
expect(mocks.restore).not.toHaveBeenCalled();
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
it('limits failed profile HTTP attempts across page initialization', async () => {
|
|
266
|
+
mocks.profile.mockRejectedValue(new Error('private backend error'));
|
|
267
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
268
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
269
|
+
await loadDestination();
|
|
270
|
+
await loadDestination();
|
|
271
|
+
await loadDestination();
|
|
272
|
+
expect(mocks.profile).toHaveBeenCalledTimes(3);
|
|
273
|
+
expect(sessionStorage.length).toBe(0);
|
|
274
|
+
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
|
|
275
|
+
'private backend error',
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('preserves normal enrichment when session storage is blocked', async () => {
|
|
280
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
|
|
281
|
+
this: Storage,
|
|
282
|
+
) {
|
|
283
|
+
if (this === sessionStorage)
|
|
284
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
285
|
+
});
|
|
286
|
+
mocks.profile.mockResolvedValue({ id: 'profile-a' });
|
|
287
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
288
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
289
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(mocks.people).toHaveBeenCalledWith(
|
|
291
|
+
{ reservamos_one_id: 'profile-a' },
|
|
292
|
+
expect.any(Function),
|
|
293
|
+
expect.any(Function),
|
|
294
|
+
);
|
|
295
|
+
expect(sessionStorage.length).toBe(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('replays the complete original Core request with unchanged details and identifiers', async () => {
|
|
299
|
+
mocks.fingerprint = 'original-fingerprint';
|
|
300
|
+
mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
|
|
301
|
+
await identify('user-a', {
|
|
302
|
+
email: 'new@example.com',
|
|
303
|
+
phone: '+525500000001',
|
|
304
|
+
cpf: '12345678900',
|
|
305
|
+
firstName: 'Original',
|
|
306
|
+
lastName: 'Name',
|
|
307
|
+
});
|
|
308
|
+
const original = mocks.profile.mock.calls[0][0];
|
|
309
|
+
expect(original.details).toEqual({ firstName: 'Original', lastName: 'Name' });
|
|
310
|
+
expect(original.identifiers).toContainEqual({
|
|
311
|
+
key: 'phone',
|
|
312
|
+
value: '+525500000001',
|
|
313
|
+
});
|
|
314
|
+
expect(original.identifiers).toContainEqual({
|
|
315
|
+
key: 'fingerprint',
|
|
316
|
+
value: 'original-fingerprint',
|
|
317
|
+
});
|
|
318
|
+
mocks.fingerprint = 'destination-fingerprint';
|
|
319
|
+
mocks.profile.mockImplementationOnce(async (request) => ({
|
|
320
|
+
id: request.identifiers.some(
|
|
321
|
+
(identifier: { key: string; value: string }) =>
|
|
322
|
+
identifier.key === 'phone' && identifier.value === '+525500000001',
|
|
323
|
+
)
|
|
324
|
+
? 'strong-phone-profile'
|
|
325
|
+
: 'weak-user-profile',
|
|
326
|
+
}));
|
|
327
|
+
await loadDestination();
|
|
328
|
+
expect(mocks.profile.mock.calls[1][0]).toEqual(original);
|
|
329
|
+
expect(mocks.restore).toHaveBeenCalledWith(
|
|
330
|
+
'user-a',
|
|
331
|
+
{ reservamos_one_id: 'strong-phone-profile' },
|
|
332
|
+
expect.any(Function),
|
|
333
|
+
expect.any(Function),
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('removes superseded recovery intent when replacing its storage record fails', async () => {
|
|
338
|
+
mocks.profile.mockReturnValue(new Promise(() => undefined));
|
|
339
|
+
await identify('user-a', { email: 'old@example.com' });
|
|
340
|
+
const setItem = Storage.prototype.setItem;
|
|
341
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
|
|
342
|
+
this: Storage,
|
|
343
|
+
key,
|
|
344
|
+
value,
|
|
345
|
+
) {
|
|
346
|
+
if (this === sessionStorage) throw new Error('write unavailable');
|
|
347
|
+
return setItem.call(this, key, value);
|
|
348
|
+
});
|
|
349
|
+
await identify('user-a', { email: 'new@example.com' });
|
|
350
|
+
expect(sessionStorage.length).toBe(0);
|
|
351
|
+
await loadDestination();
|
|
352
|
+
expect(mocks.profile).toHaveBeenCalledTimes(2);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('does not retry when the attempt count cannot be persisted', async () => {
|
|
356
|
+
mocks.profile.mockReturnValue(new Promise(() => undefined));
|
|
357
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
358
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
359
|
+
throw new Error('blocked');
|
|
360
|
+
});
|
|
361
|
+
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
|
362
|
+
throw new Error('blocked');
|
|
363
|
+
});
|
|
364
|
+
await loadDestination();
|
|
365
|
+
await loadDestination();
|
|
366
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
367
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
2
3
|
import identify from './identify';
|
|
3
4
|
|
|
4
5
|
const mocks = vi.hoisted(() => ({
|
|
@@ -8,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
8
9
|
ready: vi.fn(),
|
|
9
10
|
identify: vi.fn(),
|
|
10
11
|
attach: vi.fn(),
|
|
12
|
+
people: vi.fn(),
|
|
11
13
|
fingerprint: vi.fn(),
|
|
12
14
|
validate: vi.fn(),
|
|
13
15
|
error: vi.fn(),
|
|
@@ -26,6 +28,7 @@ vi.mock('@/services/mixpanel', () => ({
|
|
|
26
28
|
isReady: mocks.ready,
|
|
27
29
|
identify: mocks.identify,
|
|
28
30
|
attachProperty: mocks.attach,
|
|
31
|
+
setPeopleProperties: mocks.people,
|
|
29
32
|
},
|
|
30
33
|
}));
|
|
31
34
|
vi.mock('@/services/fingerprint', () => ({
|
|
@@ -60,30 +63,123 @@ describe('identify bounded local attempt', () => {
|
|
|
60
63
|
vi.unstubAllGlobals();
|
|
61
64
|
});
|
|
62
65
|
|
|
63
|
-
it('
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
it('submits and resolves before a two-second redirect despite a 2.5-second profile', async () => {
|
|
67
|
+
mocks.profile.mockImplementation(
|
|
68
|
+
() =>
|
|
69
|
+
new Promise((resolve) =>
|
|
70
|
+
setTimeout(() => resolve({ id: 'profile' }), 2500),
|
|
71
|
+
),
|
|
69
72
|
);
|
|
73
|
+
const properties = { email: 'test@example.com', firstName: 'Test' };
|
|
70
74
|
let finished = false;
|
|
71
|
-
|
|
75
|
+
void identify('user', properties).then(() => {
|
|
72
76
|
finished = true;
|
|
73
77
|
});
|
|
74
78
|
await vi.advanceTimersByTimeAsync(1);
|
|
75
|
-
expect(finished).toBe(
|
|
76
|
-
expect(mocks.identify).not.toHaveBeenCalled();
|
|
77
|
-
finish({ id: 'profile' });
|
|
78
|
-
await pending;
|
|
79
|
+
expect(finished).toBe(true);
|
|
79
80
|
expect(mocks.identify).toHaveBeenCalledWith(
|
|
80
81
|
'user',
|
|
81
|
-
expect.objectContaining({
|
|
82
|
+
expect.objectContaining({ $email: 'test@example.com' }),
|
|
82
83
|
expect.any(Function),
|
|
83
84
|
);
|
|
84
|
-
expect(
|
|
85
|
+
expect(mocks.people).not.toHaveBeenCalled();
|
|
86
|
+
await vi.advanceTimersByTimeAsync(1999);
|
|
87
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
88
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
89
|
+
expect(mocks.people).toHaveBeenCalledWith(
|
|
90
|
+
{ reservamos_one_id: 'profile' },
|
|
91
|
+
expect.any(Function),
|
|
92
|
+
);
|
|
93
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(properties).toEqual({
|
|
95
|
+
email: 'test@example.com',
|
|
96
|
+
firstName: 'Test',
|
|
97
|
+
});
|
|
85
98
|
});
|
|
86
99
|
|
|
100
|
+
it.each(['new-user', 'reinitialize'] as const)(
|
|
101
|
+
'suppresses late enrichment after %s',
|
|
102
|
+
async (transition) => {
|
|
103
|
+
let finish!: (value: { id: string }) => void;
|
|
104
|
+
let finishFingerprint!: (value: string) => void;
|
|
105
|
+
mocks.fingerprint.mockReturnValueOnce(
|
|
106
|
+
new Promise((resolve) => {
|
|
107
|
+
finishFingerprint = resolve;
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
mocks.profile.mockReturnValueOnce(
|
|
111
|
+
new Promise((resolve) => {
|
|
112
|
+
finish = resolve;
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
await identify('old-user');
|
|
116
|
+
if (transition === 'new-user') await identify('new-user');
|
|
117
|
+
else {
|
|
118
|
+
trackingLifecycle.beginInitialization();
|
|
119
|
+
trackingLifecycle.beginInitialization();
|
|
120
|
+
}
|
|
121
|
+
finish({ id: 'old-profile' });
|
|
122
|
+
finishFingerprint('old-fingerprint');
|
|
123
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
124
|
+
expect(mocks.people).not.toHaveBeenCalled();
|
|
125
|
+
expect(mocks.attach).not.toHaveBeenCalled();
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
it('attaches current fingerprint once', async () => {
|
|
130
|
+
mocks.fingerprint.mockResolvedValue('current-fingerprint');
|
|
131
|
+
await identify('user');
|
|
132
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
133
|
+
expect(mocks.attach).toHaveBeenCalledWith(
|
|
134
|
+
'Known Fingerprints',
|
|
135
|
+
'current-fingerprint',
|
|
136
|
+
);
|
|
137
|
+
expect(mocks.attach).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
it('retains the created profile ID in background datalake payload without mutating input', async () => {
|
|
140
|
+
mocks.profile.mockResolvedValue({ id: 'created-profile' });
|
|
141
|
+
mocks.coordinates.mockReturnValue({ lat: 1, long: 2 });
|
|
142
|
+
const properties = {
|
|
143
|
+
email: 'test@example.com',
|
|
144
|
+
firstName: 'Test',
|
|
145
|
+
lastName: 'User',
|
|
146
|
+
};
|
|
147
|
+
await identify('user', properties);
|
|
148
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
149
|
+
const request = fetchMock.mock.calls[0][1] as RequestInit;
|
|
150
|
+
expect(JSON.parse(request.body as string).profile_params).toEqual(
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
reservamos_one_id: 'created-profile',
|
|
153
|
+
distinct_id: 'user',
|
|
154
|
+
first_name: 'Test',
|
|
155
|
+
last_name: 'User',
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
expect(properties).toEqual({
|
|
159
|
+
email: 'test@example.com',
|
|
160
|
+
firstName: 'Test',
|
|
161
|
+
lastName: 'User',
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
it('does not identify an old user after readiness belongs to a replacement init', async () => {
|
|
165
|
+
trackingLifecycle.beginInitialization();
|
|
166
|
+
mocks.ready.mockReturnValue(false);
|
|
167
|
+
const pending = identify('old-user');
|
|
168
|
+
trackingLifecycle.beginInitialization();
|
|
169
|
+
mocks.ready.mockReturnValue(true);
|
|
170
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
171
|
+
await pending;
|
|
172
|
+
expect(mocks.identify).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
it('does not start optional requests after the attempt is superseded before their microtask', async () => {
|
|
175
|
+
const pending = identify('old-user');
|
|
176
|
+
await Promise.resolve();
|
|
177
|
+
trackingLifecycle.beginInitialization();
|
|
178
|
+
await pending;
|
|
179
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
180
|
+
expect(mocks.profile).not.toHaveBeenCalled();
|
|
181
|
+
expect(mocks.fingerprint).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
87
183
|
it('runs readiness once and removes its listener', async () => {
|
|
88
184
|
mocks.ready.mockReturnValue(false);
|
|
89
185
|
const remove = vi.spyOn(window, 'removeEventListener');
|
|
@@ -94,6 +190,7 @@ describe('identify bounded local attempt', () => {
|
|
|
94
190
|
await pending;
|
|
95
191
|
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
96
192
|
expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
|
|
193
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
97
194
|
expect(vi.getTimerCount()).toBe(0);
|
|
98
195
|
});
|
|
99
196
|
|
|
@@ -141,6 +238,7 @@ describe('identify bounded local attempt', () => {
|
|
|
141
238
|
it('continues after profile rejection with controlled diagnostics', async () => {
|
|
142
239
|
mocks.profile.mockRejectedValue(new Error('private@example.com'));
|
|
143
240
|
await identify('private-id');
|
|
241
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
144
242
|
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
145
243
|
expect(mocks.error).toHaveBeenCalledWith(
|
|
146
244
|
'Identify',
|
|
@@ -171,6 +269,7 @@ describe('identify bounded local attempt', () => {
|
|
|
171
269
|
mocks.coordinates.mockReturnValue({ lat: 1, long: 2 });
|
|
172
270
|
fetchMock.mockReturnValue(new Promise(() => undefined));
|
|
173
271
|
await identify('user');
|
|
272
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
174
273
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
175
274
|
'https://example.test/api/v1/datalake/identify',
|
|
176
275
|
expect.objectContaining({
|