@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
@@ -1,9 +1,10 @@
1
1
  import type { IdentifyReporter } from '@/util/identifyAttempt';
2
- import { createAnonymousProfileForIdentify } from '@/profiles/createAnonymousProfile/createAnonymousProfile';
3
2
  import configService from '@/services/config';
4
3
  import { getFingerprintForIdentify } from '@/services/fingerprint';
5
4
  import geolocationService from '@/services/geolocation';
6
5
  import mixpanelService from '@/services/mixpanel';
6
+ import profileEnrichment from '@/services/profileEnrichment';
7
+ import trackingLifecycle from '@/services/trackingLifecycle';
7
8
  import validatorService from '@/services/validator';
8
9
  import {
9
10
  createIdentifyReporter,
@@ -15,6 +16,7 @@ import { DefaultProperties } from './identifySchema';
15
16
 
16
17
  const KNOWN_PROPERTIES = ['firstName', 'lastName', 'email', 'phone'];
17
18
  const FINGERPRINT_PROPERTY = 'Known Fingerprints';
19
+ let latestAttempt = 0;
18
20
 
19
21
  export interface UserProperties
20
22
  extends DefaultProperties,
@@ -112,21 +114,74 @@ const identifyWithLocation = async (
112
114
  }
113
115
  };
114
116
 
117
+ async function enrichProfileAndLocation(
118
+ userId: string,
119
+ properties: UserProperties,
120
+ isActive: () => boolean,
121
+ report: IdentifyReporter,
122
+ ): Promise<void> {
123
+ const profile = await profileEnrichment.enrich(
124
+ userId,
125
+ properties,
126
+ isActive,
127
+ report,
128
+ );
129
+ if (!isActive()) return;
130
+ const enriched = {
131
+ ...properties,
132
+ ...(profile && { reservamos_one_id: profile.id }),
133
+ };
134
+ void identifyWithLocation(userId, enriched, report);
135
+ }
136
+
137
+ async function enrichFingerprint(
138
+ isActive: () => boolean,
139
+ report: IdentifyReporter,
140
+ ): Promise<void> {
141
+ const fingerprint = await waitForIdentifyStage(
142
+ 'fingerprint',
143
+ 2000,
144
+ (context) =>
145
+ !isActive()
146
+ ? Promise.resolve('')
147
+ : getFingerprintForIdentify({
148
+ isActive: () => context.isActive() && isActive(),
149
+ report: context.report,
150
+ }),
151
+ report,
152
+ );
153
+ if (!fingerprint || !isActive()) return;
154
+ try {
155
+ mixpanelService.attachProperty(FINGERPRINT_PROPERTY, fingerprint);
156
+ } catch {
157
+ report('fingerprint', 'failed');
158
+ }
159
+ }
160
+
115
161
  /**
116
- * Identifies a user with given properties and fingerprint.
162
+ * Applies identity and initial properties before optional enrichment.
117
163
  * @param {string} userId - The unique identifier for the user.
118
- * @param {UserProperties} properties - Optional user properties.
119
- * @returns {Promise<void>} Resolves after a bounded local attempt, not confirmed remote delivery.
164
+ * @param {UserProperties} properties - Optional user properties; never mutated.
165
+ * @returns {Promise<void>} Resolves after the local identity submission, not enrichment or remote delivery.
120
166
  */
121
167
  async function identify(
122
168
  userId: string,
123
169
  properties: UserProperties = {},
124
170
  ): Promise<void> {
171
+ const attempt = ++latestAttempt;
172
+ const input = { ...properties };
125
173
  const report = createIdentifyReporter();
126
- if (!(await waitForIdentifyReady(report))) return;
174
+ const initialLifecycle = trackingLifecycle.capture();
175
+ if (
176
+ !(await waitForIdentifyReady(report)) ||
177
+ attempt !== latestAttempt ||
178
+ !initialLifecycle()
179
+ )
180
+ return;
181
+ let submitted = false;
127
182
  try {
128
183
  try {
129
- validatorService.parseIdentifyProps(properties);
184
+ validatorService.parseIdentifyProps(input);
130
185
  if (!userId) {
131
186
  report('validation', 'invalid_input');
132
187
  return;
@@ -135,40 +190,22 @@ async function identify(
135
190
  report('validation', 'invalid_input');
136
191
  return;
137
192
  }
138
- const anonymousProfile = await waitForIdentifyStage(
139
- 'profile',
140
- 3000,
141
- (context) =>
142
- createAnonymousProfileForIdentify({ ...properties }, context.report),
143
- report,
144
- );
145
-
146
- if (anonymousProfile) {
147
- properties.reservamos_one_id = anonymousProfile.id;
148
- }
149
- const mappedProps = mapProperties(properties);
150
193
  try {
151
- mixpanelService.identify(userId, mappedProps, report);
194
+ mixpanelService.identify(userId, mapProperties(input), report);
152
195
  } catch {
153
196
  report('mixpanel', 'failed');
197
+ return;
154
198
  }
155
- const fingerprint = await waitForIdentifyStage(
156
- 'fingerprint',
157
- 2000,
158
- getFingerprintForIdentify,
159
- report,
160
- );
161
- if (fingerprint) {
162
- try {
163
- mixpanelService.attachProperty(FINGERPRINT_PROPERTY, fingerprint);
164
- } catch {
165
- report('fingerprint', 'failed');
166
- }
167
- }
168
- } catch {
169
- report('mixpanel', 'failed');
199
+ const currentLifecycle = trackingLifecycle.capture();
200
+ const isActive = () => attempt === latestAttempt && currentLifecycle();
201
+ const scopedReport: IdentifyReporter = (...args) => {
202
+ if (isActive()) report(...args);
203
+ };
204
+ submitted = true;
205
+ void enrichProfileAndLocation(userId, input, isActive, scopedReport);
206
+ void enrichFingerprint(isActive, scopedReport);
170
207
  } finally {
171
- void identifyWithLocation(userId, properties, report);
208
+ if (!submitted) void identifyWithLocation(userId, { ...input }, report);
172
209
  }
173
210
  }
174
211
 
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import paymentAttemptSchema from '@/events/paymentAttempt/paymentAttemptSchema';
3
+ import validatorService from '@/services/validator';
4
+ import passengersCreatedSchema from './passengersCreatedSchema';
5
+
6
+ const trip = {
7
+ 'Departure Arrival': '2026-10-20T14:30:00',
8
+ 'Departure Destination': 'Destination City',
9
+ 'Departure Destination Terminal': 'Destination Terminal',
10
+ 'Departure Line': 'Synthetic Line',
11
+ 'Departure Origin': 'Origin City',
12
+ 'Departure Origin Terminal': 'Origin Terminal',
13
+ 'Departure Price': 450.5,
14
+ 'Departure Route': 'Origin City - Destination City',
15
+ 'Departure Stops': 1,
16
+ 'Departure Time': '2026-10-20T08:00:00',
17
+ 'Departure Transport Type': 'bus',
18
+ 'Departure Transporter': 'Synthetic Transporter',
19
+ 'Recommended Trip': false,
20
+ };
21
+
22
+ const passenger = {
23
+ 'Passenger Birthdate': '1990-01-01',
24
+ 'Passenger Seat': '12',
25
+ 'Passenger Name': 'Synthetic Passenger',
26
+ 'Passenger Document Type': 'passport',
27
+ 'Passenger Document Id': 'X0000000',
28
+ };
29
+
30
+ const basePayload = {
31
+ 'Trips': [trip],
32
+ 'Passenger Count': 1,
33
+ 'Trip Count': 1,
34
+ 'Total': 450.5,
35
+ 'product': 'web',
36
+ 'Passengers': [passenger],
37
+ };
38
+
39
+ const paymentAttemptPayload = { ...basePayload, 'Payment Type': 'card' };
40
+
41
+ const without = (source: object, key: string) =>
42
+ Object.fromEntries(Object.entries(source).filter(([name]) => name !== key));
43
+
44
+ describe('passengersCreatedSchema', () => {
45
+ it('accepts a valid payload without Coupon', () => {
46
+ expect(passengersCreatedSchema.parse(basePayload)).toEqual(basePayload);
47
+ });
48
+
49
+ it('accepts a valid payload with Coupon', () => {
50
+ const payload = { ...basePayload, Coupon: 'PROMO10' };
51
+
52
+ expect(passengersCreatedSchema.parse(payload)).toEqual(payload);
53
+ });
54
+
55
+ it.each([10, true, null, {}])('rejects Coupon given as %j', (coupon) => {
56
+ expect(
57
+ passengersCreatedSchema.safeParse({ ...basePayload, Coupon: coupon })
58
+ .success,
59
+ ).toBe(false);
60
+ });
61
+
62
+ it('rejects an empty Coupon', () => {
63
+ expect(
64
+ passengersCreatedSchema.safeParse({ ...basePayload, Coupon: '' }).success,
65
+ ).toBe(false);
66
+ });
67
+
68
+ it('keeps rejecting keys outside the schema', () => {
69
+ const result = passengersCreatedSchema.safeParse({
70
+ ...basePayload,
71
+ Insurance: true,
72
+ });
73
+
74
+ expect(result.success).toBe(false);
75
+ expect(result.error?.issues[0]?.code).toBe('unrecognized_keys');
76
+ });
77
+
78
+ it('accepts the payload shape previously rejected for its Coupon key', () => {
79
+ expect(() =>
80
+ validatorService.parseEventProps('Passengers Created', {
81
+ ...basePayload,
82
+ Coupon: 'PROMO10',
83
+ }),
84
+ ).not.toThrow();
85
+ });
86
+
87
+ it('rejects a trip without Departure Arrival', () => {
88
+ expect(
89
+ passengersCreatedSchema.safeParse({
90
+ ...basePayload,
91
+ Trips: [without(trip, 'Departure Arrival')],
92
+ }).success,
93
+ ).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe('checkout events with an empty passenger list', () => {
98
+ it.each([
99
+ ['Passengers Created', passengersCreatedSchema, basePayload],
100
+ ['Payment Attempt', paymentAttemptSchema, paymentAttemptPayload],
101
+ ] as const)(
102
+ '%s rejects an empty Passengers list',
103
+ (_name, schema, payload) => {
104
+ const result = schema.safeParse({ ...payload, Passengers: [] });
105
+
106
+ expect(result.success).toBe(false);
107
+ expect(result.error?.issues.map((issue) => issue.message)).toContain(
108
+ 'Passengers must have at least 1 items',
109
+ );
110
+ },
111
+ );
112
+
113
+ it.each([
114
+ ['Passengers Created', passengersCreatedSchema, basePayload],
115
+ ['Payment Attempt', paymentAttemptSchema, paymentAttemptPayload],
116
+ ] as const)(
117
+ '%s accepts an omitted Passengers list',
118
+ (_name, schema, payload) => {
119
+ expect(schema.safeParse(without(payload, 'Passengers')).success).toBe(
120
+ true,
121
+ );
122
+ },
123
+ );
124
+ });
125
+
126
+ describe('Coupon parity with Payment Attempt', () => {
127
+ it.each(['PROMO10', '', 10])(
128
+ 'gives the same verdict for Coupon %j',
129
+ (coupon) => {
130
+ const passengersCreated = passengersCreatedSchema.safeParse({
131
+ ...basePayload,
132
+ Coupon: coupon,
133
+ });
134
+ const paymentAttempt = paymentAttemptSchema.safeParse({
135
+ ...paymentAttemptPayload,
136
+ Coupon: coupon,
137
+ });
138
+
139
+ expect(passengersCreated.success).toBe(paymentAttempt.success);
140
+ },
141
+ );
142
+ });
@@ -1,5 +1,10 @@
1
1
  import { z } from 'zod';
2
- import { arrayField, intField, numberField } from '@/util/primitiveFields';
2
+ import {
3
+ arrayField,
4
+ intField,
5
+ numberField,
6
+ stringField,
7
+ } from '@/util/primitiveFields';
3
8
  import productValidation from '@/util/productValidation';
4
9
  import passengerSchema from '../sharedSchemas/passengerSchema';
5
10
  import tripSchema from '../sharedSchemas/tripSchema';
@@ -12,6 +17,7 @@ const passengersCreatedSchema = z
12
17
  'Total': numberField('Total'),
13
18
  'product': productValidation,
14
19
  'Passengers': arrayField(passengerSchema, 'Passengers', 1).optional(),
20
+ 'Coupon': stringField('Coupon').optional(),
15
21
  })
16
22
  .strict();
17
23
 
@@ -0,0 +1,49 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+ import analytics from '@/index';
3
+ import mixpanelService from '@/services/mixpanel';
4
+
5
+ vi.mock('@/services/mixpanel', () => ({
6
+ default: {
7
+ isReady: vi.fn().mockReturnValue(true),
8
+ track: vi.fn(),
9
+ getMixpanelDistinctId: vi.fn().mockReturnValue('identified-user'),
10
+ },
11
+ }));
12
+
13
+ afterEach(() => {
14
+ vi.clearAllMocks();
15
+ localStorage.clear();
16
+ });
17
+
18
+ it('exposes the asynchronous wait through the public analytics facade', async () => {
19
+ localStorage.setItem(
20
+ 'default_fingerprint_cache',
21
+ JSON.stringify({
22
+ fingerprint: 'cached-visitor',
23
+ expiresAt: Date.now() + 60000,
24
+ }),
25
+ );
26
+ await expect(analytics.identifiers.waitForFingerprintId()).resolves.toBe(
27
+ 'cached-visitor',
28
+ );
29
+ expect(analytics.identifiers.getFingerprintId()).toBe('cached-visitor');
30
+ });
31
+
32
+ it('validates and submits confirmation product through the real tracking pipeline', async () => {
33
+ analytics.track.purchaseConfirmation({
34
+ 'Operation Id': 'ORDER-1',
35
+ 'Payment Status': 'confirmed',
36
+ 'product': 'web',
37
+ });
38
+ await vi.waitFor(() =>
39
+ expect(mixpanelService.track).toHaveBeenCalledWith(
40
+ 'Purchase Confirmation Viewed',
41
+ expect.objectContaining({
42
+ 'Operation Id': 'ORDER-1',
43
+ 'Payment Status': 'confirmed',
44
+ 'product': 'web',
45
+ }),
46
+ ),
47
+ );
48
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
49
+ });
@@ -28,6 +28,22 @@ describe('purchaseConfirmationSchema', () => {
28
28
  expect(purchaseConfirmationSchema.parse(payload)).toEqual(payload);
29
29
  });
30
30
 
31
+ it.each(['confirmed', 'pending_pix'] as const)(
32
+ 'preserves optional product for %s',
33
+ (status) => {
34
+ const payload = {
35
+ 'Operation Id': 'MOB-123',
36
+ 'Payment Status': status,
37
+ 'product': 'web',
38
+ };
39
+ expect(purchaseConfirmationSchema.parse(payload)).toEqual(payload);
40
+ expect(
41
+ purchaseConfirmationSchema.safeParse({ ...payload, product: 'invalid' })
42
+ .success,
43
+ ).toBe(false);
44
+ },
45
+ );
46
+
31
47
  it('rejects a payload missing Operation Id', () => {
32
48
  expect(() =>
33
49
  purchaseConfirmationSchema.parse({ 'Payment Status': 'confirmed' }),
@@ -81,6 +97,7 @@ describe('trackPurchaseConfirmation', () => {
81
97
  const eventData = {
82
98
  'Operation Id': 'MOB-123',
83
99
  'Payment Status': 'confirmed' as const,
100
+ 'product': 'web' as const,
84
101
  };
85
102
  const meta = { source: 'checkout' };
86
103
 
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import productValidation from '@/util/productValidation';
2
3
 
3
4
  const purchaseConfirmationSchema = z
4
5
  .object({
@@ -6,6 +7,7 @@ const purchaseConfirmationSchema = z
6
7
  'Payment Status': z.enum(['confirmed', 'pending_pix']),
7
8
  'Payment Type': z.string().optional(),
8
9
  'Total': z.number().optional(),
10
+ 'product': productValidation.optional(),
9
11
  })
10
12
  .strict();
11
13
 
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ const analytics = {
51
51
  identify,
52
52
  identifiers: {
53
53
  getFingerprintId: fingerprintService.getCachedFingerprint,
54
+ waitForFingerprintId: fingerprintService.waitForFingerprintId,
54
55
  getDistinctId: mixpanelService.getMixpanelDistinctId,
55
56
  },
56
57
  profiles: {
package/src/init.ts CHANGED
@@ -4,6 +4,7 @@ import configService from '@/services/config';
4
4
  import fingerprintService from '@/services/fingerprint';
5
5
  import geolocationService from '@/services/geolocation';
6
6
  import mixpanelService from '@/services/mixpanel';
7
+ import profileEnrichment from '@/services/profileEnrichment';
7
8
  import validator, { InitConfigSchema } from '@/services/validator';
8
9
 
9
10
  /**
@@ -51,12 +52,20 @@ async function init(config: InitConfig) {
51
52
  if (appName) {
52
53
  configService.setAppName(appName);
53
54
  }
54
- coreApi.setConfig(configService.getCoreAPIConfig());
55
+ const coreConfig = configService.getCoreAPIConfig();
56
+ coreApi.setConfig(coreConfig);
55
57
 
56
58
  // Dispatch the 'Tracker Ready' event
57
59
  onLoaded();
58
60
  if (!isCurrent()) return;
59
61
 
62
+ profileEnrichment.initialize({
63
+ project: mixpanelToken,
64
+ proxy: mixpanelProxyUrl,
65
+ apiBaseUrl: coreConfig.baseUrl,
66
+ appName: coreConfig.defaultHeaders?.['x-app-name'],
67
+ });
68
+
60
69
  await Promise.all([
61
70
  initializeOptionalStage('fingerprint', () =>
62
71
  isCurrent()
@@ -87,6 +87,16 @@ async function requestAnonymousProfile(
87
87
  payload: CreateAnonymousProfileProps,
88
88
  report?: IdentifyReporter,
89
89
  ): Promise<AnonymousProfile | undefined> {
90
+ return requestPreparedAnonymousProfile(
91
+ prepareAnonymousProfileForIdentify(payload, report),
92
+ );
93
+ }
94
+
95
+ /** Captures exactly the normalized Core request, including its original identifiers. */
96
+ export function prepareAnonymousProfileForIdentify(
97
+ payload: CreateAnonymousProfileProps,
98
+ report?: IdentifyReporter,
99
+ ): CreateAnonymousProfilePayload {
90
100
  validatorService.validateProps(payload, CreateAnonymousProfileSchema);
91
101
 
92
102
  const identifiers: AnonymousIdentifier[] = [];
@@ -108,8 +118,14 @@ async function requestAnonymousProfile(
108
118
  identifiers.push({ key: 'fingerprint', value: userFingerprintId });
109
119
  if (distinctId) identifiers.push({ key: 'distinct_id', value: distinctId });
110
120
 
111
- const dataPayload = getAnonymousProfilePayload(payload, identifiers);
112
- return await coreApi.profiles.createAnonymousProfile(dataPayload);
121
+ return getAnonymousProfilePayload(payload, identifiers);
122
+ }
123
+
124
+ /** Sends a captured request without rebuilding identifiers from destination state. */
125
+ export function requestPreparedAnonymousProfile(
126
+ payload: CreateAnonymousProfilePayload,
127
+ ): Promise<AnonymousProfile> {
128
+ return coreApi.profiles.createAnonymousProfile(payload);
113
129
  }
114
130
 
115
131
  export default createAnonymousProfile;
@@ -50,7 +50,7 @@ describe('botDetectionRelayService', () => {
50
50
 
51
51
  expect(fetchMock).toHaveBeenCalledWith(
52
52
  'https://data-lake.reservamossaas.com/api/v1/fingerprint/bot-detection',
53
- {
53
+ expect.objectContaining({
54
54
  method: 'POST',
55
55
  headers: {
56
56
  'Content-Type': 'application/json',
@@ -64,7 +64,8 @@ describe('botDetectionRelayService', () => {
64
64
  eventName: 'Fingerprint Requested',
65
65
  }),
66
66
  signal: expect.any(AbortSignal),
67
- },
67
+ keepalive: true,
68
+ }),
68
69
  );
69
70
  });
70
71
 
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import trackingLifecycle from '@/services/trackingLifecycle';
2
3
  import fingerprintService, { getFingerprintForIdentify } from './fingerprint';
3
4
 
4
5
  const visitorData = {
@@ -396,3 +397,174 @@ describe('fingerprintService', () => {
396
397
  consoleError.mockRestore();
397
398
  });
398
399
  });
400
+
401
+ describe('bounded fingerprint waiting', () => {
402
+ beforeEach(async () => {
403
+ vi.useFakeTimers();
404
+ localStorage.clear();
405
+ initMock.mockResolvedValue(undefined);
406
+ getVisitorDataMock.mockReset().mockResolvedValue(visitorData);
407
+ clientMock.mockImplementation(() => ({
408
+ init: initMock,
409
+ getVisitorData: getVisitorDataMock,
410
+ }));
411
+ await fingerprintService.initFingerprint('wait-key');
412
+ });
413
+ afterEach(() => {
414
+ vi.clearAllTimers();
415
+ vi.useRealTimers();
416
+ vi.restoreAllMocks();
417
+ localStorage.clear();
418
+ });
419
+
420
+ it('returns cache without a provider request', async () => {
421
+ localStorage.setItem(
422
+ 'default_fingerprint_cache',
423
+ JSON.stringify({ fingerprint: 'cached', expiresAt: Date.now() + 1000 }),
424
+ );
425
+ await expect(fingerprintService.waitForFingerprintId()).resolves.toBe(
426
+ 'cached',
427
+ );
428
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
429
+ });
430
+ it('joins a fresh request already in flight and serves concurrent waiters', async () => {
431
+ let finish!: (value: typeof visitorData) => void;
432
+ getVisitorDataMock.mockReturnValue(
433
+ new Promise((resolve) => {
434
+ finish = resolve;
435
+ }),
436
+ );
437
+ const existing = fingerprintService.identifyVisitor(true);
438
+ const first = fingerprintService.waitForFingerprintId();
439
+ const second = fingerprintService.waitForFingerprintId();
440
+ finish(visitorData);
441
+ await expect(Promise.all([first, second])).resolves.toEqual([
442
+ 'visitor-123',
443
+ 'visitor-123',
444
+ ]);
445
+ await existing;
446
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
447
+ await vi.advanceTimersByTimeAsync(0);
448
+ expect(vi.getTimerCount()).toBe(0);
449
+ });
450
+ it('waits for an agent still loading, then identifies only once', async () => {
451
+ let finish!: () => void;
452
+ initMock.mockReturnValue(
453
+ new Promise<void>((resolve) => {
454
+ finish = resolve;
455
+ }),
456
+ );
457
+ const initialization = fingerprintService.initFingerprint('late-key');
458
+ const first = fingerprintService.waitForFingerprintId();
459
+ const second = fingerprintService.waitForFingerprintId();
460
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
461
+ finish();
462
+ await initialization;
463
+ await expect(Promise.all([first, second])).resolves.toEqual([
464
+ 'visitor-123',
465
+ 'visitor-123',
466
+ ]);
467
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
468
+ expect(getVisitorDataMock).toHaveBeenCalledWith({}, false);
469
+ await vi.advanceTimersByTimeAsync(0);
470
+ expect(vi.getTimerCount()).toBe(0);
471
+ });
472
+ it('resolves null when the pending agent fails to load', async () => {
473
+ let fail!: (error: Error) => void;
474
+ initMock.mockReturnValue(
475
+ new Promise((_resolve, reject) => {
476
+ fail = reject;
477
+ }),
478
+ );
479
+ const initialization = fingerprintService.initFingerprint('blocked-key');
480
+ const pending = fingerprintService.waitForFingerprintId();
481
+ const rejected = expect(initialization).rejects.toThrow('blocked');
482
+ fail(new Error('blocked'));
483
+ await rejected;
484
+ await expect(pending).resolves.toBeNull();
485
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
486
+ });
487
+ it.each([-1, Infinity, NaN])(
488
+ 'resolves null for invalid budget %s',
489
+ async (budget) => {
490
+ await expect(
491
+ fingerprintService.waitForFingerprintId(budget),
492
+ ).resolves.toBeNull();
493
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
494
+ },
495
+ );
496
+ it('keeps a late successful result after the caller times out without another lookup', async () => {
497
+ let finish!: (value: typeof visitorData) => void;
498
+ getVisitorDataMock.mockReturnValue(
499
+ new Promise((resolve) => {
500
+ finish = resolve;
501
+ }),
502
+ );
503
+ setPeoplePropertiesMock.mockClear();
504
+ const pending = fingerprintService.waitForFingerprintId(2000);
505
+ await vi.advanceTimersByTimeAsync(2000);
506
+ await expect(pending).resolves.toBeNull();
507
+ await vi.advanceTimersByTimeAsync(500);
508
+ finish(visitorData);
509
+ await vi.advanceTimersByTimeAsync(0);
510
+ expect(fingerprintService.getCachedFingerprint()).toBe('visitor-123');
511
+ expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
512
+ 'Visitor Found': true,
513
+ });
514
+ await expect(fingerprintService.waitForFingerprintId()).resolves.toBe(
515
+ 'visitor-123',
516
+ );
517
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
518
+ expect(vi.getTimerCount()).toBe(0);
519
+ });
520
+ it('suppresses a timed-out request after its user lifecycle changes', async () => {
521
+ let finish!: (value: typeof visitorData) => void;
522
+ getVisitorDataMock.mockReturnValue(
523
+ new Promise((resolve) => {
524
+ finish = resolve;
525
+ }),
526
+ );
527
+ setPeoplePropertiesMock.mockClear();
528
+ const pending = fingerprintService.waitForFingerprintId(2000);
529
+ await vi.advanceTimersByTimeAsync(2000);
530
+ await expect(pending).resolves.toBeNull();
531
+ trackingLifecycle.identify('replacement-user-after-timeout');
532
+ finish(visitorData);
533
+ await vi.advanceTimersByTimeAsync(0);
534
+ expect(fingerprintService.getCachedFingerprint()).toBeNull();
535
+ expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
536
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
537
+ expect(vi.getTimerCount()).toBe(0);
538
+ });
539
+ it('resolves null at its timeout and consumes late rejection', async () => {
540
+ let fail!: (error: Error) => void;
541
+ getVisitorDataMock.mockReturnValue(
542
+ new Promise((_resolve, reject) => {
543
+ fail = reject;
544
+ }),
545
+ );
546
+ const pending = fingerprintService.waitForFingerprintId(100);
547
+ await vi.advanceTimersByTimeAsync(100);
548
+ await expect(pending).resolves.toBeNull();
549
+ fail(new Error('offline'));
550
+ await vi.advanceTimersByTimeAsync(0);
551
+ await vi.advanceTimersByTimeAsync(0);
552
+ expect(vi.getTimerCount()).toBe(0);
553
+ });
554
+ it('cancels when initialization changes and ignores an old result', async () => {
555
+ let finish!: (value: typeof visitorData) => void;
556
+ getVisitorDataMock.mockReturnValue(
557
+ new Promise((resolve) => {
558
+ finish = resolve;
559
+ }),
560
+ );
561
+ const pending = fingerprintService.waitForFingerprintId();
562
+ await fingerprintService.initFingerprint('replacement');
563
+ await expect(pending).resolves.toBeNull();
564
+ finish(visitorData);
565
+ await vi.advanceTimersByTimeAsync(0);
566
+ expect(fingerprintService.getCachedFingerprint()).toBeNull();
567
+ await vi.advanceTimersByTimeAsync(0);
568
+ expect(vi.getTimerCount()).toBe(0);
569
+ });
570
+ });