@reservamos/browser-analytics 1.1.8-alpha.0 → 1.2.0-alpha.0

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 (39) hide show
  1. package/CHANGELOG.md +21 -3
  2. package/README.md +33 -7
  3. package/dist/browser-analytics.cjs +2 -2
  4. package/dist/browser-analytics.cjs.map +1 -1
  5. package/dist/browser-analytics.d.ts +15 -2
  6. package/dist/browser-analytics.esm.js +590 -109
  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.robustness.test.ts +112 -13
  12. package/src/events/identify/identify.ts +84 -34
  13. package/src/events/passengersCreated/passengersCreatedSchema.test.ts +142 -0
  14. package/src/events/passengersCreated/passengersCreatedSchema.ts +7 -1
  15. package/src/events/purchaseConfirmation/purchaseConfirmation.integration.test.ts +49 -0
  16. package/src/events/purchaseConfirmation/purchaseConfirmation.test.ts +17 -0
  17. package/src/events/purchaseConfirmation/purchaseConfirmationSchema.ts +2 -0
  18. package/src/events/sharedSchemas/tripSchema.ts +3 -8
  19. package/src/index.ts +1 -0
  20. package/src/init.botDetection.test.ts +187 -0
  21. package/src/init.test.ts +1 -0
  22. package/src/init.ts +15 -2
  23. package/src/services/botDetectionRelay.test.ts +3 -2
  24. package/src/services/deferredBotDetection.ts +60 -0
  25. package/src/services/fingerprint.test.ts +190 -0
  26. package/src/services/fingerprint.ts +141 -13
  27. package/src/services/fingerprint.wait.integration.test.ts +129 -0
  28. package/src/services/mixpanel.test.ts +119 -73
  29. package/src/services/mixpanel.ts +82 -23
  30. package/src/services/trackingLifecycle.ts +54 -0
  31. package/src/services/validator/validator.test.ts +79 -0
  32. package/src/track.readiness.test.ts +131 -0
  33. package/src/track.recovery.test.ts +339 -0
  34. package/src/track.storage.test.ts +142 -0
  35. package/src/track.test.ts +130 -5
  36. package/src/track.ts +110 -25
  37. package/src/util/pendingWork.test.ts +164 -0
  38. package/src/util/pendingWork.ts +148 -0
  39. package/src/util/snapshotEventData.ts +15 -0
@@ -0,0 +1,339 @@
1
+ import coreApi from '@reservamos/js-api-client/core';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const provider = vi.hoisted(() => ({
5
+ initMixpanel: vi.fn(),
6
+ track: vi.fn(),
7
+ identify: vi.fn(),
8
+ distinctId: vi.fn(),
9
+ register: vi.fn(),
10
+ setPeople: vi.fn(),
11
+ setConfig: vi.fn(),
12
+ initFingerprint: vi.fn(),
13
+ getVisitorData: vi.fn(),
14
+ client: vi.fn(),
15
+ }));
16
+ vi.mock('mixpanel-browser', () => ({
17
+ default: {
18
+ init: provider.initMixpanel,
19
+ track: provider.track,
20
+ identify: provider.identify,
21
+ get_distinct_id: provider.distinctId,
22
+ register: provider.register,
23
+ set_config: provider.setConfig,
24
+ people: { set: provider.setPeople },
25
+ },
26
+ }));
27
+ vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
28
+ FingerprintJSPro: {
29
+ defaultEndpoint: 'endpoint',
30
+ defaultScriptUrlPattern: 'agent',
31
+ },
32
+ FpjsClient: provider.client,
33
+ }));
34
+ vi.mock('@/services/geolocation', () => ({
35
+ default: {
36
+ init: async () => undefined,
37
+ getCoordinates: () => null,
38
+ getGeolocationSource: () => null,
39
+ },
40
+ }));
41
+
42
+ const search = {
43
+ 'Departure': '2026-11-01',
44
+ 'Departure Delta': 1,
45
+ 'Destination': 'Puebla',
46
+ 'Destination Terminal': 'CAPU',
47
+ 'Origin': 'Mexico City',
48
+ 'Origin Terminal': 'TAPO',
49
+ 'Passengers': 1,
50
+ 'Route': 'Mexico City-Puebla',
51
+ 'product': 'web' as const,
52
+ };
53
+ const visitor = {
54
+ visitorId: 'visitor',
55
+ requestId: 'request',
56
+ visitorFound: true,
57
+ confidence: { score: 0.99 },
58
+ cacheHit: false,
59
+ };
60
+
61
+ describe('tracking recovery through real SDK services and validation', () => {
62
+ let tracking: typeof import('./track');
63
+ let mixpanel: typeof import('@/services/mixpanel').default;
64
+ let fingerprint: typeof import('@/services/fingerprint').default;
65
+ let lifecycle: typeof import('@/services/trackingLifecycle').default;
66
+ let loaded: () => void;
67
+ beforeEach(async () => {
68
+ vi.resetModules();
69
+ vi.useFakeTimers();
70
+ vi.resetAllMocks();
71
+ localStorage.clear();
72
+ Reflect.deleteProperty(window, 'mixpanel');
73
+ Reflect.deleteProperty(window, 'fingerprintConfig');
74
+ Reflect.deleteProperty(window, 'fpClient');
75
+ provider.distinctId.mockReturnValue('anonymous');
76
+ provider.initMixpanel.mockImplementation(
77
+ (_token, options: { loaded: () => void }) => {
78
+ loaded = options.loaded;
79
+ },
80
+ );
81
+ provider.initFingerprint.mockResolvedValue(undefined);
82
+ provider.getVisitorData.mockResolvedValue(visitor);
83
+ provider.client.mockImplementation(() => ({
84
+ init: provider.initFingerprint,
85
+ getVisitorData: provider.getVisitorData,
86
+ }));
87
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
88
+ vi.spyOn(coreApi.fingerprint, 'sendObservation').mockResolvedValue(true);
89
+ tracking = await import('./track');
90
+ mixpanel = (await import('@/services/mixpanel')).default;
91
+ fingerprint = (await import('@/services/fingerprint')).default;
92
+ lifecycle = (await import('@/services/trackingLifecycle')).default;
93
+ });
94
+ afterEach(async () => {
95
+ lifecycle.beginInitialization();
96
+ lifecycle.beginInitialization();
97
+ await vi.advanceTimersByTimeAsync(0);
98
+ vi.clearAllTimers();
99
+ vi.restoreAllMocks();
100
+ vi.useRealTimers();
101
+ });
102
+ async function readyMixpanel() {
103
+ const pending = mixpanel.init('test');
104
+ loaded();
105
+ await pending;
106
+ }
107
+
108
+ it('recovers at 5.2 seconds with the original snapshot and exactly one provider call', async () => {
109
+ const properties = { source: 'original', values: [1, 2] };
110
+ const pending = tracking.trackEvent('Recovery Test', properties);
111
+ properties.source = 'mutated';
112
+ properties.values.push(3);
113
+ await vi.advanceTimersByTimeAsync(5200);
114
+ await pending;
115
+ expect(provider.track).not.toHaveBeenCalled();
116
+ await readyMixpanel();
117
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
118
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
119
+ await vi.advanceTimersByTimeAsync(0);
120
+ expect(provider.track).toHaveBeenCalledOnce();
121
+ expect(provider.track).toHaveBeenCalledWith(
122
+ 'Recovery Test',
123
+ expect.objectContaining({ source: 'original', values: [1, 2] }),
124
+ );
125
+ await vi.advanceTimersByTimeAsync(60000);
126
+ expect(provider.track).toHaveBeenCalledOnce();
127
+ });
128
+
129
+ it('never replays an accepted event after its provider throws and later recovers', async () => {
130
+ await readyMixpanel();
131
+ const accepted: string[] = [];
132
+ provider.track.mockImplementationOnce((event: string) => {
133
+ accepted.push(event);
134
+ throw new Error('accepted');
135
+ });
136
+ await tracking.trackEvent('Recovery Test', { source: 'checkout' });
137
+ provider.track.mockImplementation(() => undefined);
138
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
139
+ await vi.advanceTimersByTimeAsync(60000);
140
+ expect(accepted).toEqual(['Recovery Test']);
141
+ expect(provider.track).toHaveBeenCalledOnce();
142
+ });
143
+
144
+ it('keeps two legitimate identical calls and does not collapse them', async () => {
145
+ const first = tracking.trackEvent('Recovery Test', { value: 1 });
146
+ const second = tracking.trackEvent('Recovery Test', { value: 1 });
147
+ await readyMixpanel();
148
+ await Promise.all([first, second]);
149
+ expect(provider.track).toHaveBeenCalledTimes(2);
150
+ });
151
+
152
+ it('preserves allowed undefined array values instead of revalidating them as null', async () => {
153
+ const pending = tracking.trackEvent('Recovery Test', {
154
+ values: [undefined],
155
+ source: 'checkout',
156
+ });
157
+ await readyMixpanel();
158
+ await pending;
159
+ expect(provider.track).toHaveBeenCalledOnce();
160
+ expect(provider.track).toHaveBeenCalledWith(
161
+ 'Recovery Test',
162
+ expect.objectContaining({ source: 'checkout' }),
163
+ );
164
+ });
165
+
166
+ it('drops expired entries and invalidates waiting work on reinitialization', async () => {
167
+ const expired = tracking.trackEvent('Expired', {});
168
+ await vi.advanceTimersByTimeAsync(60000);
169
+ await expired;
170
+ void mixpanel.init('first');
171
+ const stale = tracking.trackEvent('Stale', {});
172
+ const currentInit = mixpanel.init('second');
173
+ loaded();
174
+ await currentInit;
175
+ await stale;
176
+ await vi.advanceTimersByTimeAsync(0);
177
+ expect(provider.track).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it('sends the original at tracker readiness and observes only after Fingerprint loads', async () => {
181
+ await readyMixpanel();
182
+ await tracking.trackEvent('Search', search);
183
+ expect(provider.track).toHaveBeenCalledOnce();
184
+ expect(provider.getVisitorData).not.toHaveBeenCalled();
185
+ await vi.advanceTimersByTimeAsync(5200);
186
+ await fingerprint.initFingerprint('test-key');
187
+ await vi.advanceTimersByTimeAsync(0);
188
+ expect(provider.getVisitorData).toHaveBeenCalledOnce();
189
+ expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledWith(
190
+ expect.objectContaining({
191
+ requestId: 'request',
192
+ distinctId: 'anonymous',
193
+ }),
194
+ );
195
+ expect(provider.track).toHaveBeenCalledOnce();
196
+ });
197
+
198
+ it('suppresses late cache, profile and relay effects after observation expiry', async () => {
199
+ await readyMixpanel();
200
+ await fingerprint.initFingerprint('test-key');
201
+ let finish!: (value: typeof visitor) => void;
202
+ provider.getVisitorData.mockReturnValue(
203
+ new Promise((resolve) => {
204
+ finish = resolve;
205
+ }),
206
+ );
207
+ await tracking.trackEvent('Search', search);
208
+ await vi.advanceTimersByTimeAsync(10000);
209
+ finish(visitor);
210
+ await vi.advanceTimersByTimeAsync(0);
211
+ expect(provider.setPeople).not.toHaveBeenCalled();
212
+ expect(fingerprint.getCachedFingerprint()).toBeNull();
213
+ expect(coreApi.fingerprint.sendObservation).not.toHaveBeenCalled();
214
+ expect(provider.track).toHaveBeenCalledOnce();
215
+ });
216
+
217
+ it('suppresses old observation effects when identify switches user', async () => {
218
+ await readyMixpanel();
219
+ await tracking.trackEvent('Search', search);
220
+ mixpanel.identify('new-user', {});
221
+ provider.setPeople.mockClear();
222
+ await fingerprint.initFingerprint('test-key');
223
+ await vi.advanceTimersByTimeAsync(0);
224
+ expect(provider.getVisitorData).not.toHaveBeenCalled();
225
+ expect(coreApi.fingerprint.sendObservation).not.toHaveBeenCalled();
226
+ expect(provider.track).toHaveBeenCalledOnce();
227
+ });
228
+
229
+ it('caps actual concurrent identification requests even after their local deadlines expire', async () => {
230
+ await readyMixpanel();
231
+ await fingerprint.initFingerprint('test-key');
232
+ provider.getVisitorData.mockReturnValue(new Promise(() => undefined));
233
+ for (let i = 0; i < 5; i++) await tracking.trackEvent('Search', search);
234
+ expect(provider.getVisitorData).toHaveBeenCalledTimes(3);
235
+ await vi.advanceTimersByTimeAsync(10000);
236
+ await tracking.trackEvent('Search', search);
237
+ expect(provider.getVisitorData).toHaveBeenCalledTimes(3);
238
+ expect(provider.track).toHaveBeenCalledTimes(6);
239
+ });
240
+
241
+ it('waits for the current init instead of using the old provider or a stale loaded callback', async () => {
242
+ void mixpanel.init('superseded');
243
+ const staleLoaded = loaded;
244
+ const currentInit = mixpanel.init('current');
245
+ const pending = tracking.trackEvent('Current', {});
246
+ staleLoaded();
247
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
248
+ await vi.advanceTimersByTimeAsync(0);
249
+ expect(provider.track).not.toHaveBeenCalled();
250
+ loaded();
251
+ await currentInit;
252
+ await pending;
253
+ expect(provider.track).toHaveBeenCalledOnce();
254
+ });
255
+
256
+ it('invalidates an event whose identity changes before its asynchronous cache read completes', async () => {
257
+ await readyMixpanel();
258
+ const pending = tracking.trackEvent('Old User', {});
259
+ mixpanel.identify('new-user', {});
260
+ await pending;
261
+ expect(provider.track).not.toHaveBeenCalled();
262
+ });
263
+
264
+ it('uses the public Search wrapper from Tracker Ready and observes after the real agent loads', async () => {
265
+ let finish!: () => void;
266
+ provider.initFingerprint.mockReturnValue(
267
+ new Promise<void>((resolve) => {
268
+ finish = resolve;
269
+ }),
270
+ );
271
+ const analytics = (await import('./index')).default;
272
+ const onReady = () => {
273
+ analytics.track.search(search);
274
+ };
275
+ window.addEventListener('Tracker Ready', onReady, { once: true });
276
+ const initialized = analytics.init({
277
+ mixpanelToken: 'test',
278
+ identificationKey: 'test-key',
279
+ });
280
+ loaded();
281
+ await vi.advanceTimersByTimeAsync(0);
282
+ expect(provider.track).toHaveBeenCalledOnce();
283
+ expect(provider.getVisitorData).not.toHaveBeenCalled();
284
+ await vi.advanceTimersByTimeAsync(5200);
285
+ await initialized;
286
+ finish();
287
+ await vi.advanceTimersByTimeAsync(0);
288
+ expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledOnce();
289
+ expect(provider.track).toHaveBeenCalledOnce();
290
+ });
291
+
292
+ it('prevents an older public init from replacing the newer Core API and agent configuration', async () => {
293
+ const configure = vi.spyOn(coreApi, 'setConfig');
294
+ const analytics = (await import('./index')).default;
295
+ const oldInit = analytics.init({
296
+ mixpanelToken: 'test',
297
+ identificationKey: 'old-agent',
298
+ appName: 'old-app',
299
+ });
300
+ const oldLoaded = loaded;
301
+ const currentInit = analytics.init({
302
+ mixpanelToken: 'test',
303
+ identificationKey: 'new-agent',
304
+ appName: 'new-app',
305
+ });
306
+ loaded();
307
+ await currentInit;
308
+ oldLoaded();
309
+ await oldInit;
310
+ expect(configure).toHaveBeenCalledOnce();
311
+ expect(configure.mock.calls[0][0]?.defaultHeaders?.['x-app-name']).toBe(
312
+ 'new-app',
313
+ );
314
+ expect(provider.client).toHaveBeenCalledOnce();
315
+ expect(provider.client).toHaveBeenCalledWith(
316
+ expect.objectContaining({
317
+ loadOptions: expect.objectContaining({ apiKey: 'new-agent' }),
318
+ }),
319
+ );
320
+ });
321
+
322
+ it('suppresses expired View Results refresh effects without replaying the event', async () => {
323
+ await readyMixpanel();
324
+ await fingerprint.initFingerprint('test-key');
325
+ let finish!: (value: typeof visitor) => void;
326
+ provider.getVisitorData.mockReturnValue(
327
+ new Promise((resolve) => {
328
+ finish = resolve;
329
+ }),
330
+ );
331
+ await tracking.trackEvent('View Results', { ...search, 'Bus count': 10 });
332
+ await vi.advanceTimersByTimeAsync(10000);
333
+ finish(visitor);
334
+ await vi.advanceTimersByTimeAsync(0);
335
+ expect(provider.setPeople).not.toHaveBeenCalled();
336
+ expect(fingerprint.getCachedFingerprint()).toBeNull();
337
+ expect(provider.track).toHaveBeenCalledOnce();
338
+ });
339
+ });
@@ -0,0 +1,142 @@
1
+ import coreApi from '@reservamos/js-api-client/core';
2
+ import mixpanel from 'mixpanel-browser';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import fingerprintService from '@/services/fingerprint';
5
+ import mixpanelService from '@/services/mixpanel';
6
+ import { trackEvent } from './track';
7
+
8
+ const { getVisitorData, init, client } = vi.hoisted(() => ({
9
+ getVisitorData: vi.fn(),
10
+ init: vi.fn(),
11
+ client: vi.fn(),
12
+ }));
13
+
14
+ const { provider } = vi.hoisted(() => ({
15
+ provider: {
16
+ init: vi.fn((_token: string, config: { loaded: () => void }) =>
17
+ config.loaded(),
18
+ ),
19
+ register: vi.fn(),
20
+ set_config: vi.fn(),
21
+ identify: vi.fn(),
22
+ track: vi.fn(),
23
+ get_distinct_id: vi.fn(() => 'live-id'),
24
+ people: { set: vi.fn(), union: vi.fn() },
25
+ },
26
+ }));
27
+ vi.mock('mixpanel-browser', () => ({ default: provider }));
28
+
29
+ // Keep the SDK Fingerprint cache, Mixpanel service, tracking, validation, and relay real.
30
+ vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
31
+ FingerprintJSPro: {
32
+ defaultEndpoint: 'https://fpjscdn.net',
33
+ defaultScriptUrlPattern: 'https://fpjscdn.net/agent',
34
+ },
35
+ FpjsClient: client,
36
+ }));
37
+
38
+ const search = {
39
+ 'Departure': '2026-11-01',
40
+ 'Departure Delta': 1,
41
+ 'Destination': 'Puebla',
42
+ 'Destination Terminal': 'CAPU',
43
+ 'Origin': 'Mexico City',
44
+ 'Origin Terminal': 'TAPO',
45
+ 'Passengers': 1,
46
+ 'Route': 'Mexico City-Puebla',
47
+ 'product': 'web',
48
+ };
49
+ const checkout = {
50
+ 'Trips': [
51
+ {
52
+ 'Departure Arrival': '2026-11-01T12:00:00',
53
+ 'Departure Destination': 'Puebla',
54
+ 'Departure Destination Terminal': 'CAPU',
55
+ 'Departure Line': 'Bus line',
56
+ 'Departure Origin': 'Mexico City',
57
+ 'Departure Origin Terminal': 'TAPO',
58
+ 'Departure Price': 100,
59
+ 'Departure Route': 'Mexico City-Puebla',
60
+ 'Departure Stops': 1,
61
+ 'Departure Time': '2026-11-01T10:00:00',
62
+ 'Departure Transport Type': 'bus',
63
+ 'Departure Transporter': 'Carrier',
64
+ 'Recommended Trip': false,
65
+ },
66
+ ],
67
+ 'Passenger Count': 1,
68
+ 'Trip Count': 1,
69
+ 'Total': 100,
70
+ 'product': 'web',
71
+ };
72
+
73
+ describe('tracking with blocked SDK Fingerprint cache writes', () => {
74
+ beforeEach(async () => {
75
+ localStorage.clear();
76
+ await mixpanelService.init('test-storage-token');
77
+ client.mockImplementation(() => ({ init, getVisitorData }));
78
+ init.mockResolvedValue(undefined);
79
+ getVisitorData.mockResolvedValue({
80
+ visitorId: 'visitor-123',
81
+ requestId: 'request-123',
82
+ visitorFound: true,
83
+ confidence: { score: 0.99 },
84
+ cacheHit: false,
85
+ });
86
+ await fingerprintService.initFingerprint('test-api-key');
87
+ vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('live-id');
88
+ vi.spyOn(mixpanel.people, 'set').mockImplementation(() => undefined);
89
+ vi.spyOn(coreApi.fingerprint, 'sendObservation').mockResolvedValue(true);
90
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
91
+ throw new DOMException('Blocked', 'SecurityError');
92
+ });
93
+ });
94
+
95
+ afterEach(() => {
96
+ vi.restoreAllMocks();
97
+ vi.clearAllMocks();
98
+ });
99
+
100
+ it.each([
101
+ ['Search', search, true],
102
+ ['View Results', { ...search, 'Bus count': 10 }, false],
103
+ ['Purchase Attempt', checkout, true],
104
+ ['Payment Attempt', { ...checkout, 'Payment Type': 'credit_card' }, true],
105
+ ] as const)(
106
+ 'preserves valid %s and its eligible observation',
107
+ async (eventName, properties, eligible) => {
108
+ await trackEvent(eventName, properties);
109
+
110
+ expect(mixpanel.track).toHaveBeenCalledOnce();
111
+ expect(mixpanel.track).toHaveBeenCalledWith(
112
+ eventName,
113
+ expect.objectContaining({
114
+ 'User Fingerprint': expect.any(String),
115
+ 'product': 'web',
116
+ }),
117
+ );
118
+ /**
119
+ * Checks effects that may finish after event submission during background refresh.
120
+ * @returns {void} Throws until the expected profile and relay effects are observed.
121
+ */
122
+ const verifyIdentificationEffects = () => {
123
+ expect(mixpanel.people.set).toHaveBeenCalledWith({
124
+ 'Visitor Found': true,
125
+ });
126
+ expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledTimes(
127
+ eligible ? 1 : 0,
128
+ );
129
+ if (eligible) {
130
+ expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledWith(
131
+ expect.objectContaining({
132
+ requestId: 'request-123',
133
+ visitorId: 'visitor-123',
134
+ distinctId: 'live-id',
135
+ }),
136
+ );
137
+ }
138
+ };
139
+ await vi.waitFor(verifyIdentificationEffects);
140
+ },
141
+ );
142
+ });
package/src/track.test.ts CHANGED
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import botDetectionRelayService from '@/services/botDetectionRelay';
3
3
  import fingerprintService from '@/services/fingerprint';
4
4
  import mixpanelService from '@/services/mixpanel';
5
+ import trackingLifecycle from '@/services/trackingLifecycle';
5
6
  import validator from '@/services/validator';
6
7
  import { trackEvent, trackEventError, tryTrackEvent } from './track';
7
8
 
@@ -18,6 +19,7 @@ vi.mock('@/services/fingerprint', () => ({
18
19
  getCachedFingerprint: vi.fn(),
19
20
  identifyVisitor: vi.fn(),
20
21
  isFingerprintReady: vi.fn(),
22
+ onReady: vi.fn().mockReturnValue(() => undefined),
21
23
  },
22
24
  }));
23
25
 
@@ -30,6 +32,7 @@ vi.mock('@/services/geolocation', () => ({
30
32
 
31
33
  vi.mock('@/services/mixpanel', () => ({
32
34
  default: {
35
+ onReady: vi.fn().mockReturnValue(() => undefined),
33
36
  isReady: vi.fn().mockReturnValue(true),
34
37
  getMixpanelDistinctId: vi.fn().mockReturnValue('distinct-123'),
35
38
  track: vi.fn(),
@@ -42,9 +45,12 @@ vi.mock('@/services/validator', () => ({
42
45
  },
43
46
  }));
44
47
 
48
+ let testIdentity = 0;
45
49
  describe('trackEvent Bot Detection relay', () => {
46
50
  beforeEach(() => {
51
+ trackingLifecycle.identify(`test-${testIdentity++}`);
47
52
  vi.resetAllMocks();
53
+ vi.mocked(fingerprintService.onReady).mockReturnValue(() => undefined);
48
54
  vi.mocked(mixpanelService.isReady).mockReturnValue(true);
49
55
  vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(
50
56
  'distinct-123',
@@ -97,7 +103,10 @@ describe('trackEvent Bot Detection relay', () => {
97
103
  expect(mixpanelService.track).toHaveBeenCalledTimes(1);
98
104
  await completion;
99
105
  expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
100
- expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
106
+ expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(
107
+ true,
108
+ expect.objectContaining({ isActive: expect.any(Function) }),
109
+ );
101
110
  await vi.waitFor(() =>
102
111
  expect(botDetectionRelayService.relay).toHaveBeenCalledOnce(),
103
112
  );
@@ -248,13 +257,64 @@ describe('trackEvent Bot Detection relay', () => {
248
257
  expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
249
258
  });
250
259
 
260
+ it('submits View Results while identification remains pending beyond 5.2 seconds', async () => {
261
+ vi.useFakeTimers();
262
+ try {
263
+ vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
264
+ vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(null);
265
+ vi.mocked(fingerprintService.getFingerprint).mockReturnValue(
266
+ new Promise(() => undefined),
267
+ );
268
+ const onComplete = vi.fn();
269
+ void trackEvent('View Results', { 'Results Count': 2 }).then(onComplete);
270
+ await vi.advanceTimersByTimeAsync(5200);
271
+ expect(mixpanelService.track).toHaveBeenCalledWith('View Results', {
272
+ 'User Fingerprint': '',
273
+ 'Results Count': 2,
274
+ });
275
+ expect(onComplete).toHaveBeenCalledOnce();
276
+ expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
277
+ } finally {
278
+ vi.useRealTimers();
279
+ }
280
+ });
281
+
282
+ it('keeps cached View Results identity and does not replay after late identification', async () => {
283
+ vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
284
+ let finish!: (visitor: string) => void;
285
+ vi.mocked(fingerprintService.getFingerprint).mockImplementation(() => {
286
+ expect(mixpanelService.track).toHaveBeenCalledOnce();
287
+ return new Promise((resolve) => {
288
+ finish = resolve;
289
+ });
290
+ });
291
+ await trackEvent('View Results', { 'Results Count': 2 }, { source: 'web' });
292
+ expect(mixpanelService.track).toHaveBeenCalledWith('View Results', {
293
+ 'User Fingerprint': 'cached-visitor',
294
+ 'Results Count': 2,
295
+ 'source': 'web',
296
+ });
297
+ expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(
298
+ false,
299
+ expect.objectContaining({ isActive: expect.any(Function) }),
300
+ );
301
+ finish('late-visitor');
302
+ await Promise.resolve();
303
+ expect(mixpanelService.track).toHaveBeenCalledOnce();
304
+ expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
305
+ });
306
+
251
307
  it('submits View Results with an empty fingerprint if identification rejects', async () => {
252
308
  vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
309
+ vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(null);
253
310
  vi.mocked(fingerprintService.getFingerprint).mockRejectedValue(
254
311
  new Error('Fingerprint failed'),
255
312
  );
256
313
  await trackEvent('View Results', { 'Results Count': 2 }, { source: 'web' });
257
- expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(false);
314
+ expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(
315
+ false,
316
+ expect.objectContaining({ isActive: expect.any(Function) }),
317
+ );
258
318
  expect(mixpanelService.track).toHaveBeenCalledTimes(1);
259
319
  expect(mixpanelService.track).toHaveBeenCalledWith('View Results', {
260
320
  'User Fingerprint': '',
@@ -277,6 +337,70 @@ describe('trackEvent Bot Detection relay', () => {
277
337
  });
278
338
  expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
279
339
  });
340
+
341
+ it('keeps the original event exactly once when identification omits requestId', async () => {
342
+ const identification = {
343
+ visitorId: 'fresh-visitor',
344
+ confidenceScore: 0.99,
345
+ cacheHit: false,
346
+ } as Awaited<ReturnType<typeof fingerprintService.identifyVisitor>>;
347
+ vi.mocked(fingerprintService.identifyVisitor).mockResolvedValue(
348
+ identification,
349
+ );
350
+ await trackEvent('Search', {});
351
+ await Promise.resolve();
352
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
353
+ expect(mixpanelService.track).toHaveBeenCalledWith('Search', {
354
+ 'User Fingerprint': 'cached-visitor',
355
+ });
356
+ await new Promise((resolve) => setTimeout(resolve, 0));
357
+ expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
358
+ });
359
+
360
+ it('omits an unavailable distinctId from the background relay', async () => {
361
+ vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(null);
362
+ await trackEvent('Search', {});
363
+ await Promise.resolve();
364
+ await vi.waitFor(() => {
365
+ expect(botDetectionRelayService.relay).toHaveBeenCalledTimes(1);
366
+ expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
367
+ requestId: 'fresh-request',
368
+ visitorId: 'fresh-visitor',
369
+ eventName: 'Search',
370
+ });
371
+ });
372
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
373
+ });
374
+
375
+ it.each(['fingerprint', 'relay'] as const)(
376
+ 'reports a controlled local diagnostic for a background %s failure',
377
+ async (stage) => {
378
+ const error = new Error('private visitor identifier');
379
+ if (stage === 'fingerprint') {
380
+ vi.mocked(fingerprintService.identifyVisitor).mockRejectedValue(error);
381
+ } else {
382
+ vi.mocked(botDetectionRelayService.relay).mockRejectedValue(error);
383
+ }
384
+ const diagnostic = vi
385
+ .spyOn(console, 'error')
386
+ .mockImplementation(() => undefined);
387
+ try {
388
+ await trackEvent('Search', {});
389
+ await Promise.resolve();
390
+ await vi.waitFor(() => {
391
+ expect(diagnostic).toHaveBeenCalledWith('Bot Detection diagnostic', {
392
+ eventName: 'Search',
393
+ stage,
394
+ reason: 'failed',
395
+ });
396
+ });
397
+ expect(diagnostic.mock.calls.flat()).not.toContain(error);
398
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
399
+ } finally {
400
+ diagnostic.mockRestore();
401
+ }
402
+ },
403
+ );
280
404
  });
281
405
 
282
406
  describe('bounded tracking readiness', () => {
@@ -284,6 +408,7 @@ describe('bounded tracking readiness', () => {
284
408
  vi.useFakeTimers();
285
409
  vi.resetAllMocks();
286
410
  vi.mocked(mixpanelService.isReady).mockReturnValue(false);
411
+ vi.mocked(mixpanelService.onReady).mockReturnValue(() => undefined);
287
412
  vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
288
413
  vi.mocked(fingerprintService.getFingerprint).mockResolvedValue('');
289
414
  vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(false);
@@ -336,15 +461,15 @@ describe('bounded tracking readiness', () => {
336
461
  expect(vi.getTimerCount()).toBe(0);
337
462
  });
338
463
 
339
- it('expires cleanly, reports a controlled diagnostic, and ignores late readiness', async () => {
464
+ it('expires after the retention deadline and ignores later readiness', async () => {
340
465
  const remove = vi.spyOn(window, 'removeEventListener');
341
466
  const pending = trackEvent('Payment Attempt', {
342
467
  secret: 'private-payload',
343
468
  });
344
469
  void pending.catch(() => undefined);
345
- await vi.advanceTimersByTimeAsync(5000);
470
+ await vi.advanceTimersByTimeAsync(60000);
346
471
  await expect(pending).resolves.toBeUndefined();
347
- expect(console.error).toHaveBeenCalledWith('Tracking diagnostic', {
472
+ expect(console.error).toHaveBeenCalledWith('Tracking recovery diagnostic', {
348
473
  eventName: 'Payment Attempt',
349
474
  stage: 'readiness',
350
475
  reason: 'timeout',