@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.
Files changed (30) hide show
  1. package/CHANGELOG.md +19 -2
  2. package/README.md +32 -4
  3. package/dist/browser-analytics.cjs +2 -2
  4. package/dist/browser-analytics.cjs.map +1 -1
  5. package/dist/browser-analytics.d.ts +23 -10
  6. package/dist/browser-analytics.esm.js +466 -154
  7. package/dist/browser-analytics.esm.js.map +1 -1
  8. package/dist/browser-analytics.iife.js +2 -2
  9. package/dist/browser-analytics.iife.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/events/identify/identify.recovery.test.ts +367 -0
  12. package/src/events/identify/identify.robustness.test.ts +112 -13
  13. package/src/events/identify/identify.ts +72 -35
  14. package/src/events/passengersCreated/passengersCreatedSchema.test.ts +142 -0
  15. package/src/events/passengersCreated/passengersCreatedSchema.ts +7 -1
  16. package/src/events/purchaseConfirmation/purchaseConfirmation.integration.test.ts +49 -0
  17. package/src/events/purchaseConfirmation/purchaseConfirmation.test.ts +17 -0
  18. package/src/events/purchaseConfirmation/purchaseConfirmationSchema.ts +2 -0
  19. package/src/index.ts +1 -0
  20. package/src/init.ts +10 -1
  21. package/src/profiles/createAnonymousProfile/createAnonymousProfile.ts +18 -2
  22. package/src/services/botDetectionRelay.test.ts +3 -2
  23. package/src/services/fingerprint.test.ts +172 -0
  24. package/src/services/fingerprint.ts +106 -5
  25. package/src/services/fingerprint.wait.integration.test.ts +129 -0
  26. package/src/services/mixpanel.batch.test.ts +108 -0
  27. package/src/services/mixpanel.test.ts +77 -73
  28. package/src/services/mixpanel.ts +45 -18
  29. package/src/services/profileEnrichment.ts +258 -0
  30. 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.1",
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.0",
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('awaits the local profile and includes its ID before identifying', async () => {
64
- let finish: (value: { id: string }) => void = () => undefined;
65
- mocks.profile.mockReturnValue(
66
- new Promise((resolve) => {
67
- finish = resolve;
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
- const pending = identify('user', { email: 'test@example.com' }).then(() => {
75
+ void identify('user', properties).then(() => {
72
76
  finished = true;
73
77
  });
74
78
  await vi.advanceTimersByTimeAsync(1);
75
- expect(finished).toBe(false);
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({ reservamos_one_id: 'profile' }),
82
+ expect.objectContaining({ $email: 'test@example.com' }),
82
83
  expect.any(Function),
83
84
  );
84
- expect(vi.getTimerCount()).toBe(0);
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({