@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
@@ -5,6 +5,7 @@ import {
5
5
  FpjsClient,
6
6
  } from '@fingerprintjs/fingerprintjs-pro-spa';
7
7
  import mixpanelService from '@/services/mixpanel';
8
+ import trackingLifecycle from '@/services/trackingLifecycle';
8
9
 
9
10
  declare global {
10
11
  interface Window {
@@ -28,6 +29,33 @@ const PROXY_QUERY_PARAM =
28
29
  * "FPJSAgent hasn't loaded yet" until init completes.
29
30
  */
30
31
  let fingerprintAgentReady = false;
32
+ let initialization = 0;
33
+ let startedInitialization = -1;
34
+ const readinessSubscribers = new Set<() => void>();
35
+ const waitingConsumers = new Set<() => void>();
36
+ let pendingIdentification:
37
+ | {
38
+ promise: Promise<FingerprintIdentification>;
39
+ contexts: Set<IdentifyStageContext | undefined>;
40
+ isCurrent: () => boolean;
41
+ }
42
+ | undefined;
43
+
44
+ /** Notifies deferred observations only when the actual provider agent has loaded. */
45
+ function onReady(notify: () => void): () => void {
46
+ readinessSubscribers.add(notify);
47
+ return () => {
48
+ readinessSubscribers.delete(notify);
49
+ };
50
+ }
51
+
52
+ /** Prevents a previous configuration or late agent load from serving a new init. */
53
+ function invalidate(): void {
54
+ initialization++;
55
+ fingerprintAgentReady = false;
56
+ pendingIdentification = undefined;
57
+ waitingConsumers.forEach((cancel) => cancel());
58
+ }
31
59
 
32
60
  /**
33
61
  * Calculates the expiration date based on the cache time.
@@ -116,7 +144,9 @@ async function initFingerprint(
116
144
  customScriptEndpointArray.unshift(buildProxyUrl(proxyUrl));
117
145
  }
118
146
 
119
- fingerprintAgentReady = false;
147
+ if (startedInitialization === initialization) invalidate();
148
+ startedInitialization = initialization;
149
+ const attempt = initialization;
120
150
  window.fingerprintConfig = {};
121
151
  const client = new FpjsClient({
122
152
  loadOptions: {
@@ -129,12 +159,14 @@ async function initFingerprint(
129
159
 
130
160
  try {
131
161
  await client.init();
132
- if (window.fpClient === client) {
162
+ if (window.fpClient === client && attempt === initialization) {
133
163
  fingerprintAgentReady = true;
164
+ readinessSubscribers.forEach((notify) => notify());
134
165
  }
135
166
  } catch (error) {
136
- if (window.fpClient === client) {
167
+ if (window.fpClient === client && attempt === initialization) {
137
168
  fingerprintAgentReady = false;
169
+ waitingConsumers.forEach((cancel) => cancel());
138
170
  }
139
171
  console.error('Error initializing identification service:', error);
140
172
  throw error;
@@ -148,8 +180,11 @@ async function initFingerprint(
148
180
  * @returns {Promise<string>} A promise that resolves with the fingerprint, or an empty string when unavailable.
149
181
  * @throws {Error} Throws an error if retrieval fails after the agent has loaded.
150
182
  */
151
- async function getFingerprint(cacheOnly = false): Promise<string> {
152
- return readFingerprint(cacheOnly);
183
+ async function getFingerprint(
184
+ cacheOnly = false,
185
+ context?: IdentifyStageContext,
186
+ ): Promise<string> {
187
+ return readFingerprint(cacheOnly, context);
153
188
  }
154
189
 
155
190
  /** Internal identify path with late-effect suppression and sanitized diagnostics. */
@@ -184,13 +219,58 @@ async function readFingerprint(
184
219
  */
185
220
  async function identifyVisitor(
186
221
  ignoreCache = false,
222
+ context?: IdentifyStageContext,
187
223
  ): Promise<FingerprintIdentification> {
188
- return requestIdentification(ignoreCache);
224
+ return requestIdentification(ignoreCache, context);
189
225
  }
190
226
 
191
227
  async function requestIdentification(
192
228
  ignoreCache: boolean,
193
229
  context?: IdentifyStageContext,
230
+ ): Promise<FingerprintIdentification> {
231
+ if (!ignoreCache && pendingIdentification?.isCurrent()) {
232
+ for (const participant of pendingIdentification.contexts) {
233
+ if (participant && !participant.isActive())
234
+ pendingIdentification.contexts.delete(participant);
235
+ }
236
+ pendingIdentification.contexts.add(context);
237
+ return pendingIdentification.promise;
238
+ }
239
+ const contexts = new Set([context]);
240
+ const attempt = initialization;
241
+ const currentLifecycle = trackingLifecycle.capture();
242
+ const isCurrent = () => attempt === initialization && currentLifecycle();
243
+ const sharedContext: IdentifyStageContext = {
244
+ isActive: () =>
245
+ isCurrent() &&
246
+ [...contexts].some(
247
+ (participant) => !participant || participant.isActive(),
248
+ ),
249
+ report: (...args) => {
250
+ for (const participant of contexts) {
251
+ if (participant?.isActive()) participant.report(...args);
252
+ }
253
+ },
254
+ };
255
+ const request = {
256
+ contexts,
257
+ isCurrent,
258
+ promise: performIdentification(ignoreCache, sharedContext, () =>
259
+ contexts.has(undefined),
260
+ ),
261
+ };
262
+ pendingIdentification = request;
263
+ const clear = () => {
264
+ if (pendingIdentification === request) pendingIdentification = undefined;
265
+ };
266
+ void request.promise.then(clear, clear);
267
+ return request.promise;
268
+ }
269
+
270
+ async function performIdentification(
271
+ ignoreCache: boolean,
272
+ context: IdentifyStageContext,
273
+ hasUnscopedConsumer: () => boolean,
194
274
  ): Promise<FingerprintIdentification> {
195
275
  if (!isFingerprintReady()) {
196
276
  throw new Error('Identification service is not initialized.');
@@ -198,12 +278,11 @@ async function requestIdentification(
198
278
 
199
279
  try {
200
280
  const result = await window.fpClient.getVisitorData({}, ignoreCache);
201
- if (!context || context.isActive()) {
281
+ if (context.isActive()) {
202
282
  try {
203
283
  setCachedFingerprint(result.visitorId);
204
- } catch (error) {
205
- if (!context) throw error;
206
- context.report('cache', 'unavailable');
284
+ } catch {
285
+ context?.report('cache', 'unavailable');
207
286
  }
208
287
  if (typeof result.visitorFound === 'boolean') {
209
288
  try {
@@ -211,8 +290,9 @@ async function requestIdentification(
211
290
  'Visitor Found': result.visitorFound,
212
291
  });
213
292
  } catch (error) {
214
- if (context) context.report('fingerprint', 'failed');
215
- else console.error('Error updating Mixpanel visitor profile:', error);
293
+ context.report('fingerprint', 'failed');
294
+ if (hasUnscopedConsumer())
295
+ console.error('Error updating Mixpanel visitor profile:', error);
216
296
  }
217
297
  }
218
298
  }
@@ -227,11 +307,53 @@ async function requestIdentification(
227
307
  cacheHit: result.cacheHit,
228
308
  };
229
309
  } catch (error) {
230
- if (!context) console.error('Error retrieving fingerprint:', error);
310
+ if (hasUnscopedConsumer())
311
+ console.error('Error retrieving fingerprint:', error);
231
312
  throw error;
232
313
  }
233
314
  }
234
315
 
316
+ /**
317
+ * Waits for cached or in-flight identity without forcing a fresh provider request.
318
+ * @param {number} timeoutMs - Maximum wait in milliseconds; defaults to 2000.
319
+ * @returns {Promise<string | null>} Visitor ID, or null when unavailable within the budget.
320
+ */
321
+ function waitForFingerprintId(timeoutMs = 2000): Promise<string | null> {
322
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 2147483647)
323
+ return Promise.resolve(null);
324
+ const cached = getCachedFingerprint();
325
+ if (cached) return Promise.resolve(cached);
326
+ return new Promise((resolve) => {
327
+ let active = true;
328
+ let started = false;
329
+ const finish = (visitorId: string | null) => {
330
+ if (!active) return;
331
+ active = false;
332
+ clearTimeout(timer);
333
+ stopReady();
334
+ stopLifecycle();
335
+ waitingConsumers.delete(cancel);
336
+ resolve(visitorId);
337
+ };
338
+ const cancel = () => finish(null);
339
+ const start = () => {
340
+ if (!active || started || !isFingerprintReady()) return;
341
+ started = true;
342
+ const identity = getFingerprint(false, {
343
+ // Caller timeout ends waiting; the shared request still checks its lifecycle.
344
+ isActive: () => true,
345
+ report: () => undefined,
346
+ });
347
+ void identity.then((visitorId) => finish(visitorId || null), cancel);
348
+ };
349
+ const timer = setTimeout(cancel, timeoutMs);
350
+ const stopReady = onReady(start);
351
+ const stopLifecycle = trackingLifecycle.onChange(cancel);
352
+ waitingConsumers.add(cancel);
353
+ start();
354
+ });
355
+ }
356
+
235
357
  /**
236
358
  * Checks if the Fingerprint agent finished loading and can identify visitors.
237
359
  * @returns {boolean} True only after `initFingerprint()` has successfully loaded the agent.
@@ -245,7 +367,13 @@ const fingerprintService = {
245
367
  getFingerprint,
246
368
  identifyVisitor,
247
369
  isFingerprintReady,
370
+ onReady,
371
+ invalidate,
248
372
  getCachedFingerprint,
373
+ waitForFingerprintId,
249
374
  };
250
375
 
376
+ // One module-lifetime listener invalidates the old agent before Mixpanel signals readiness.
377
+ trackingLifecycle.onInitialization(invalidate);
378
+
251
379
  export default fingerprintService;
@@ -0,0 +1,129 @@
1
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
2
+ import identify from '@/events/identify';
3
+ import init from '@/init';
4
+ import fingerprintService from '@/services/fingerprint';
5
+ import mixpanelService from '@/services/mixpanel';
6
+ import trackingLifecycle from '@/services/trackingLifecycle';
7
+ import { trackEvent } from '@/track';
8
+
9
+ const mocks = vi.hoisted(() => ({ load: vi.fn(), get: vi.fn() }));
10
+ const visitor = {
11
+ visitorId: 'shared-visitor',
12
+ requestId: 'request',
13
+ visitorFound: true,
14
+ confidence: { score: 1 },
15
+ cacheHit: false,
16
+ };
17
+ vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
18
+ FingerprintJSPro: {
19
+ defaultEndpoint: 'https://fp.test',
20
+ defaultScriptUrlPattern: 'https://fp.test/agent',
21
+ },
22
+ FpjsClient: vi.fn(() => ({ init: mocks.load, getVisitorData: mocks.get })),
23
+ }));
24
+ vi.mock('@reservamos/js-api-client/core', () => ({
25
+ default: {
26
+ setConfig: vi.fn(),
27
+ profiles: { createAnonymousProfile: vi.fn().mockResolvedValue(undefined) },
28
+ },
29
+ }));
30
+ vi.mock('@/services/config', () => ({
31
+ default: {
32
+ setEnvironment: vi.fn(),
33
+ setAppName: vi.fn(),
34
+ getCoreAPIConfig: vi
35
+ .fn()
36
+ .mockReturnValue({ baseUrl: 'https://core.test', defaultHeaders: {} }),
37
+ },
38
+ }));
39
+ vi.mock('@/services/geolocation', () => ({
40
+ default: {
41
+ init: vi.fn().mockResolvedValue(undefined),
42
+ getCoordinates: vi.fn().mockReturnValue(null),
43
+ getGeolocationSource: vi.fn().mockReturnValue(null),
44
+ },
45
+ }));
46
+ vi.mock('@/services/mixpanel', () => ({
47
+ default: {
48
+ validateConfiguration: vi.fn(),
49
+ init: vi.fn(async () => {
50
+ trackingLifecycle.beginInitialization();
51
+ }),
52
+ isReady: vi.fn().mockReturnValue(true),
53
+ identify: vi.fn((user: string) => trackingLifecycle.identify(user)),
54
+ setPeopleProperties: vi.fn(),
55
+ attachProperty: vi.fn(),
56
+ getMixpanelDistinctId: vi.fn().mockReturnValue('user'),
57
+ track: vi.fn(),
58
+ },
59
+ }));
60
+
61
+ beforeEach(() => {
62
+ vi.useFakeTimers();
63
+ vi.clearAllMocks();
64
+ localStorage.clear();
65
+ mocks.load.mockResolvedValue(undefined);
66
+ mocks.get.mockResolvedValue(visitor);
67
+ });
68
+ afterEach(() => {
69
+ vi.clearAllTimers();
70
+ vi.useRealTimers();
71
+ localStorage.clear();
72
+ });
73
+
74
+ it('preserves a waiter registered by Tracker Ready during that same initialization', async () => {
75
+ let loaded!: () => void;
76
+ mocks.load.mockReturnValue(
77
+ new Promise<void>((resolve) => {
78
+ loaded = resolve;
79
+ }),
80
+ );
81
+ let waiting: Promise<string | null> | undefined;
82
+ let settled = false;
83
+ window.addEventListener(
84
+ 'Tracker Ready',
85
+ () => {
86
+ waiting = fingerprintService.waitForFingerprintId(1000).then((value) => {
87
+ settled = true;
88
+ return value;
89
+ });
90
+ },
91
+ { once: true },
92
+ );
93
+ const initialization = init({
94
+ mixpanelToken: 'test',
95
+ identificationKey: 'test',
96
+ });
97
+ await vi.advanceTimersByTimeAsync(0);
98
+ expect(waiting).toBeDefined();
99
+ expect(settled).toBe(false);
100
+ loaded();
101
+ await initialization;
102
+ await expect(waiting).resolves.toBe('shared-visitor');
103
+ expect(mocks.get).toHaveBeenCalledTimes(1);
104
+ });
105
+
106
+ it('keeps cache and profile effects for a current Identify joining a superseded request', async () => {
107
+ await init({ mixpanelToken: 'test', identificationKey: 'test' });
108
+ let finish!: (value: typeof visitor) => void;
109
+ mocks.get.mockReturnValue(
110
+ new Promise((resolve) => {
111
+ finish = resolve;
112
+ }),
113
+ );
114
+ await identify('user', { email: 'test@example.com' });
115
+ await identify('user', { email: 'test@example.com' });
116
+ finish(visitor);
117
+ await vi.advanceTimersByTimeAsync(0);
118
+ expect(mocks.get).toHaveBeenCalledTimes(1);
119
+ expect(fingerprintService.getCachedFingerprint()).toBe('shared-visitor');
120
+ expect(mixpanelService.setPeopleProperties).toHaveBeenCalledWith({
121
+ 'Visitor Found': true,
122
+ });
123
+ expect(mixpanelService.attachProperty).toHaveBeenCalledTimes(1);
124
+ await trackEvent('Cache Check', { step: 'after identify' });
125
+ expect(mixpanelService.track).toHaveBeenCalledWith(
126
+ 'Cache Check',
127
+ expect.objectContaining({ 'User Fingerprint': 'shared-visitor' }),
128
+ );
129
+ });
@@ -1,5 +1,4 @@
1
1
  import mixpanel from 'mixpanel-browser';
2
- import { version as mixpanelVersion } from 'mixpanel-browser/package.json';
3
2
  import {
4
3
  afterAll,
5
4
  afterEach,
@@ -13,92 +12,117 @@ import { trackEvent } from '@/track';
13
12
  import { version } from '../../package.json';
14
13
  import mixpanelService from './mixpanel';
15
14
 
16
- interface TransportData {
17
- event?: string;
18
- properties?: Record<string, unknown>;
19
- }
15
+ const { provider } = vi.hoisted(() => ({
16
+ provider: {
17
+ init: vi.fn((_token: string, config: { loaded: () => void }) =>
18
+ config.loaded(),
19
+ ),
20
+ register: vi.fn(),
21
+ set_config: vi.fn(),
22
+ identify: vi.fn(),
23
+ track: vi.fn(),
24
+ get_distinct_id: vi.fn(() => 'live-id'),
25
+ people: { set: vi.fn(), union: vi.fn() },
26
+ },
27
+ }));
20
28
 
21
- // Intercept transport only: identify, track, register, and property merging stay real.
22
- const transport = mixpanel as unknown as {
23
- _track_or_batch(options: { data: TransportData }): TransportData;
24
- stop_batch_senders(): void;
25
- };
29
+ // Exercise SDK service methods while replacing the vendor before initialization.
30
+ vi.mock('mixpanel-browser', () => ({ default: provider }));
31
+
32
+ describe('distinct ID lookup before initialization', () => {
33
+ afterEach(() => {
34
+ vi.restoreAllMocks();
35
+ localStorage.clear();
36
+ });
37
+
38
+ it.each(['cached', 'empty', 'blocked'])(
39
+ 'uses the %s cache without calling the uninitialized provider',
40
+ (mode) => {
41
+ const previous = window.mixpanel;
42
+ Reflect.deleteProperty(window, 'mixpanel');
43
+ const lookup = vi
44
+ .spyOn(mixpanel, 'get_distinct_id')
45
+ .mockImplementation(() => {
46
+ throw new Error('Provider persistence is not initialized');
47
+ });
48
+ if (mode === 'cached')
49
+ localStorage.setItem('mp_distinct_id', 'cached-id');
50
+ if (mode === 'blocked')
51
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
52
+ throw new DOMException('Blocked', 'SecurityError');
53
+ });
54
+ try {
55
+ expect(mixpanelService.getMixpanelDistinctId()).toBe(
56
+ mode === 'cached' ? 'cached-id' : null,
57
+ );
58
+ expect(lookup).not.toHaveBeenCalled();
59
+ } finally {
60
+ if (previous) window.mixpanel = previous;
61
+ }
62
+ },
63
+ );
64
+ });
26
65
 
27
66
  describe('Mixpanel SDK version metadata', () => {
28
67
  beforeAll(async () => {
29
68
  localStorage.clear();
30
69
  await mixpanelService.init('test-sdk-version-token');
31
- transport.stop_batch_senders();
32
- mixpanel.set_config({ disable_persistence: true });
33
70
  });
34
71
 
35
72
  afterEach(() => {
36
73
  vi.restoreAllMocks();
74
+ vi.unstubAllGlobals();
37
75
  vi.clearAllTimers();
38
76
  vi.useRealTimers();
77
+ vi.clearAllMocks();
39
78
  });
40
79
 
41
80
  afterAll(() => {
42
- transport.stop_batch_senders();
43
81
  localStorage.clear();
44
82
  });
45
83
 
46
- it('registers SDK metadata and propagates it to the native $identify event', () => {
47
- expect(mixpanel.get_property('Event Source')).toBe('sdk');
48
- expect(mixpanel.get_property('Reservamos Version')).toBe(version);
49
-
50
- const send = vi
51
- .spyOn(transport, '_track_or_batch')
52
- .mockImplementation(({ data }) => data);
53
-
54
- mixpanelService.identify('sdk-version-user', {});
55
- const events = send.mock.calls
56
- .map(([options]) => options.data)
57
- .filter((data) => data.event);
58
-
59
- expect(events).toHaveLength(1);
60
- expect(events[0]).toEqual({
61
- event: '$identify',
62
- properties: expect.objectContaining({
63
- 'Event Source': 'sdk',
64
- 'Reservamos Version': version,
65
- '$lib_version': mixpanelVersion,
66
- }),
84
+ it('registers SDK metadata before identifying at the provider boundary', () => {
85
+ expect(mixpanel.register).toHaveBeenCalledWith({
86
+ 'Event Source': 'sdk',
87
+ 'Reservamos Version': version,
67
88
  });
68
-
69
- send.mockClear();
89
+ expect(mixpanel.register).not.toHaveBeenCalledWith(
90
+ expect.objectContaining({ $lib_version: expect.anything() }),
91
+ );
70
92
  mixpanelService.identify('sdk-version-user', {});
71
- expect(send.mock.calls.some(([options]) => options.data.event)).toBe(false);
93
+ expect(mixpanel.identify).toHaveBeenCalledWith('sdk-version-user');
94
+ expect(mixpanel.people.set).toHaveBeenCalledWith({});
72
95
  });
73
96
 
74
97
  it('keeps the SDK version authoritative for SDK-tracked events', () => {
75
- const send = vi
76
- .spyOn(transport, '_track_or_batch')
77
- .mockImplementation(({ data }) => data);
78
-
79
98
  mixpanelService.track('Test', { 'Reservamos Version': 'caller-version' });
80
-
81
- expect(send.mock.calls[0][0].data).toEqual({
82
- event: 'Test',
83
- properties: expect.objectContaining({
84
- 'Event Source': 'sdk',
85
- 'Reservamos Version': version,
86
- }),
99
+ expect(mixpanel.track).toHaveBeenCalledWith('Test', {
100
+ 'Reservamos Version': version,
87
101
  });
88
102
  });
89
103
 
90
- it('still sets people properties when the SDK distinct ID cache is blocked', () => {
91
- vi.spyOn(transport, '_track_or_batch').mockImplementation(
92
- ({ data }) => data,
104
+ it('completes a repeated init with the installed singleton provider without waiting for another loaded callback', async () => {
105
+ const complete = vi.fn();
106
+ void mixpanelService.init('test-sdk-version-token').then(complete);
107
+ await Promise.resolve();
108
+ expect(complete).toHaveBeenCalledOnce();
109
+ expect(mixpanelService.isReady()).toBe(true);
110
+ });
111
+
112
+ it('rejects a different project before disturbing the existing provider session', () => {
113
+ expect(() => mixpanelService.init('different-project')).toThrow(
114
+ 'new page session',
93
115
  );
116
+ expect(mixpanelService.isReady()).toBe(true);
117
+ });
118
+
119
+ it('still sets people properties when the SDK distinct ID cache is blocked', () => {
94
120
  vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
95
121
  throw new DOMException('Blocked', 'SecurityError');
96
122
  });
97
- const people = vi
98
- .spyOn(mixpanel.people, 'set')
99
- .mockImplementation(() => undefined);
100
123
  mixpanelService.identify('cache-blocked-user', {});
101
- expect(people).toHaveBeenCalledWith({});
124
+ expect(mixpanel.identify).toHaveBeenCalledWith('cache-blocked-user');
125
+ expect(mixpanel.people.set).toHaveBeenCalledWith({});
102
126
  });
103
127
 
104
128
  it.each(['getItem', 'setItem'] as const)(
@@ -115,31 +139,59 @@ describe('Mixpanel SDK version metadata', () => {
115
139
  it.each(['getItem', 'setItem'] as const)(
116
140
  'submits the original event when SDK storage %s is blocked',
117
141
  async (method) => {
118
- const send = vi
119
- .spyOn(transport, '_track_or_batch')
120
- .mockImplementation(({ data }) => data);
121
- mixpanel.identify('storage-test-id');
122
- send.mockClear();
142
+ vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('storage-test-id');
123
143
  vi.spyOn(Storage.prototype, method).mockImplementation(() => {
124
144
  throw new DOMException('Blocked', 'SecurityError');
125
145
  });
126
146
  await trackEvent('Storage Failure Test', { source: 'checkout' });
127
- expect(send.mock.calls.map(([options]) => options.data)).toContainEqual({
128
- event: 'Storage Failure Test',
129
- properties: expect.objectContaining({
130
- source: 'checkout',
131
- distinct_id: 'storage-test-id',
147
+ expect(mixpanel.track).toHaveBeenCalledWith(
148
+ 'Storage Failure Test',
149
+ expect.objectContaining({
150
+ 'source': 'checkout',
151
+ 'Reservamos Version': version,
132
152
  }),
133
- });
153
+ );
154
+ expect(mixpanelService.getMixpanelDistinctId()).toBe('storage-test-id');
134
155
  },
135
156
  );
136
157
 
137
- it('returns null when no live distinct ID is available and the cache is blocked', () => {
158
+ it('submits the original event when no live distinct ID is available and the cache is blocked', async () => {
138
159
  vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
139
160
  vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
140
161
  throw new DOMException('Blocked', 'SecurityError');
141
162
  });
142
163
  expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
164
+ await trackEvent('Storage Failure Test', { source: 'checkout' });
165
+ expect(mixpanel.track).toHaveBeenCalledWith(
166
+ 'Storage Failure Test',
167
+ expect.objectContaining({ source: 'checkout' }),
168
+ );
169
+ });
170
+
171
+ it('uses the live distinct ID first and null fallback when localStorage is null', () => {
172
+ vi.stubGlobal('localStorage', null);
173
+ const liveId = vi
174
+ .spyOn(mixpanel, 'get_distinct_id')
175
+ .mockReturnValue('live-id');
176
+ expect(mixpanelService.getMixpanelDistinctId()).toBe('live-id');
177
+ liveId.mockReturnValue('');
178
+ expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
179
+ });
180
+
181
+ it('preserves identify diagnostics and people updates when localStorage is null', () => {
182
+ vi.useFakeTimers();
183
+ vi.stubGlobal('localStorage', null);
184
+ vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
185
+ const people = vi
186
+ .spyOn(mixpanel.people, 'set')
187
+ .mockImplementation(() => undefined);
188
+ const report = vi.fn();
189
+ mixpanelService.identify('null-storage-user', { plan: 'standard' }, report);
190
+ expect(report).toHaveBeenCalledWith('cache', 'unavailable');
191
+ expect(people).toHaveBeenCalledWith(
192
+ { plan: 'standard' },
193
+ expect.any(Function),
194
+ );
143
195
  });
144
196
 
145
197
  it('uses the cached distinct ID when Mixpanel has no live ID', () => {
@@ -172,9 +224,6 @@ describe('Mixpanel SDK version metadata', () => {
172
224
  (response) => {
173
225
  vi.useFakeTimers();
174
226
  vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
175
- vi.spyOn(transport, '_track_or_batch').mockImplementation(
176
- ({ data }) => data,
177
- );
178
227
  let callback: ((response: unknown) => void) | undefined;
179
228
  vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
180
229
  callback = args[1] as (response: unknown) => void;
@@ -203,9 +252,6 @@ describe('Mixpanel SDK version metadata', () => {
203
252
  it('reports missing people callback as unobserved and ignores a late result', () => {
204
253
  vi.useFakeTimers();
205
254
  vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
206
- vi.spyOn(transport, '_track_or_batch').mockImplementation(
207
- ({ data }) => data,
208
- );
209
255
  let callback: ((response: unknown) => void) | undefined;
210
256
  vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
211
257
  callback = args[1] as (response: unknown) => void;