@reservamos/browser-analytics 1.1.8-alpha.1 → 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.
@@ -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,23 +12,61 @@ 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(() => {
@@ -37,54 +74,30 @@ describe('Mixpanel SDK version metadata', () => {
37
74
  vi.unstubAllGlobals();
38
75
  vi.clearAllTimers();
39
76
  vi.useRealTimers();
77
+ vi.clearAllMocks();
40
78
  });
41
79
 
42
80
  afterAll(() => {
43
- transport.stop_batch_senders();
44
81
  localStorage.clear();
45
82
  });
46
83
 
47
- it('registers SDK metadata and propagates it to the native $identify event', () => {
48
- expect(mixpanel.get_property('Event Source')).toBe('sdk');
49
- expect(mixpanel.get_property('Reservamos Version')).toBe(version);
50
-
51
- const send = vi
52
- .spyOn(transport, '_track_or_batch')
53
- .mockImplementation(({ data }) => data);
54
-
55
- mixpanelService.identify('sdk-version-user', {});
56
- const events = send.mock.calls
57
- .map(([options]) => options.data)
58
- .filter((data) => data.event);
59
-
60
- expect(events).toHaveLength(1);
61
- expect(events[0]).toEqual({
62
- event: '$identify',
63
- properties: expect.objectContaining({
64
- 'Event Source': 'sdk',
65
- 'Reservamos Version': version,
66
- '$lib_version': mixpanelVersion,
67
- }),
84
+ it('registers SDK metadata before identifying at the provider boundary', () => {
85
+ expect(mixpanel.register).toHaveBeenCalledWith({
86
+ 'Event Source': 'sdk',
87
+ 'Reservamos Version': version,
68
88
  });
69
-
70
- send.mockClear();
89
+ expect(mixpanel.register).not.toHaveBeenCalledWith(
90
+ expect.objectContaining({ $lib_version: expect.anything() }),
91
+ );
71
92
  mixpanelService.identify('sdk-version-user', {});
72
- 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({});
73
95
  });
74
96
 
75
97
  it('keeps the SDK version authoritative for SDK-tracked events', () => {
76
- const send = vi
77
- .spyOn(transport, '_track_or_batch')
78
- .mockImplementation(({ data }) => data);
79
-
80
98
  mixpanelService.track('Test', { 'Reservamos Version': 'caller-version' });
81
-
82
- expect(send.mock.calls[0][0].data).toEqual({
83
- event: 'Test',
84
- properties: expect.objectContaining({
85
- 'Event Source': 'sdk',
86
- 'Reservamos Version': version,
87
- }),
99
+ expect(mixpanel.track).toHaveBeenCalledWith('Test', {
100
+ 'Reservamos Version': version,
88
101
  });
89
102
  });
90
103
 
@@ -104,17 +117,12 @@ describe('Mixpanel SDK version metadata', () => {
104
117
  });
105
118
 
106
119
  it('still sets people properties when the SDK distinct ID cache is blocked', () => {
107
- vi.spyOn(transport, '_track_or_batch').mockImplementation(
108
- ({ data }) => data,
109
- );
110
120
  vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
111
121
  throw new DOMException('Blocked', 'SecurityError');
112
122
  });
113
- const people = vi
114
- .spyOn(mixpanel.people, 'set')
115
- .mockImplementation(() => undefined);
116
123
  mixpanelService.identify('cache-blocked-user', {});
117
- expect(people).toHaveBeenCalledWith({});
124
+ expect(mixpanel.identify).toHaveBeenCalledWith('cache-blocked-user');
125
+ expect(mixpanel.people.set).toHaveBeenCalledWith({});
118
126
  });
119
127
 
120
128
  it.each(['getItem', 'setItem'] as const)(
@@ -131,31 +139,33 @@ describe('Mixpanel SDK version metadata', () => {
131
139
  it.each(['getItem', 'setItem'] as const)(
132
140
  'submits the original event when SDK storage %s is blocked',
133
141
  async (method) => {
134
- const send = vi
135
- .spyOn(transport, '_track_or_batch')
136
- .mockImplementation(({ data }) => data);
137
- mixpanel.identify('storage-test-id');
138
- send.mockClear();
142
+ vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('storage-test-id');
139
143
  vi.spyOn(Storage.prototype, method).mockImplementation(() => {
140
144
  throw new DOMException('Blocked', 'SecurityError');
141
145
  });
142
146
  await trackEvent('Storage Failure Test', { source: 'checkout' });
143
- expect(send.mock.calls.map(([options]) => options.data)).toContainEqual({
144
- event: 'Storage Failure Test',
145
- properties: expect.objectContaining({
146
- source: 'checkout',
147
- distinct_id: 'storage-test-id',
147
+ expect(mixpanel.track).toHaveBeenCalledWith(
148
+ 'Storage Failure Test',
149
+ expect.objectContaining({
150
+ 'source': 'checkout',
151
+ 'Reservamos Version': version,
148
152
  }),
149
- });
153
+ );
154
+ expect(mixpanelService.getMixpanelDistinctId()).toBe('storage-test-id');
150
155
  },
151
156
  );
152
157
 
153
- 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 () => {
154
159
  vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
155
160
  vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
156
161
  throw new DOMException('Blocked', 'SecurityError');
157
162
  });
158
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
+ );
159
169
  });
160
170
 
161
171
  it('uses the live distinct ID first and null fallback when localStorage is null', () => {
@@ -214,9 +224,6 @@ describe('Mixpanel SDK version metadata', () => {
214
224
  (response) => {
215
225
  vi.useFakeTimers();
216
226
  vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
217
- vi.spyOn(transport, '_track_or_batch').mockImplementation(
218
- ({ data }) => data,
219
- );
220
227
  let callback: ((response: unknown) => void) | undefined;
221
228
  vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
222
229
  callback = args[1] as (response: unknown) => void;
@@ -245,9 +252,6 @@ describe('Mixpanel SDK version metadata', () => {
245
252
  it('reports missing people callback as unobserved and ignores a late result', () => {
246
253
  vi.useFakeTimers();
247
254
  vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
248
- vi.spyOn(transport, '_track_or_batch').mockImplementation(
249
- ({ data }) => data,
250
- );
251
255
  let callback: ((response: unknown) => void) | undefined;
252
256
  vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
253
257
  callback = args[1] as (response: unknown) => void;
@@ -105,21 +105,7 @@ function identify(
105
105
  trackingLifecycle.identify(userId);
106
106
  mixpanel.identify(userId);
107
107
  if (!updateCachedDistinctId(userId)) report?.('cache', 'unavailable');
108
- if (!report) {
109
- mixpanel.people.set(properties);
110
- return;
111
- }
112
- const observer = observeIdentifyResult('people', report);
113
- try {
114
- mixpanel.people.set(properties, (response) => {
115
- const status = typeof response === 'number' ? response : response?.status;
116
- observer.complete(
117
- status === 1 ? undefined : status === 0 ? 'failed' : 'unconfirmed',
118
- );
119
- });
120
- } catch {
121
- observer.complete('failed');
122
- }
108
+ writePeopleProperties(properties, report);
123
109
  }
124
110
 
125
111
  /**
@@ -142,12 +128,36 @@ function attachProperty(key: string, value: unknown): void {
142
128
  * Sets people properties for the current Mixpanel user without changing their distinct ID.
143
129
  * @param {Record<string, unknown>} properties - Profile properties to set on the current user.
144
130
  */
145
- function setPeopleProperties(properties: Record<string, unknown>): void {
131
+ function setPeopleProperties(
132
+ properties: Record<string, unknown>,
133
+ report?: IdentifyReporter,
134
+ ): void {
146
135
  if (!isReady()) {
147
136
  return;
148
137
  }
149
138
 
150
- mixpanel.people.set(properties);
139
+ writePeopleProperties(properties, report);
140
+ }
141
+
142
+ function writePeopleProperties(
143
+ properties: Record<string, unknown>,
144
+ report?: IdentifyReporter,
145
+ ): void {
146
+ if (!report) {
147
+ mixpanel.people.set(properties);
148
+ return;
149
+ }
150
+ const observer = observeIdentifyResult('people', report);
151
+ try {
152
+ mixpanel.people.set(properties, (response) => {
153
+ const status = typeof response === 'number' ? response : response?.status;
154
+ observer.complete(
155
+ status === 1 ? undefined : status === 0 ? 'failed' : 'unconfirmed',
156
+ );
157
+ });
158
+ } catch {
159
+ observer.complete('failed');
160
+ }
151
161
  }
152
162
 
153
163
  /**
@@ -178,7 +188,7 @@ function track(eventName: string, properties: Record<string, unknown>): void {
178
188
  * @returns {string | null} The live or cached distinct ID, or null when neither is available.
179
189
  */
180
190
  export const getMixpanelDistinctId = (): string | null => {
181
- const distinctId = mixpanel?.get_distinct_id?.();
191
+ const distinctId = isReady() ? mixpanel.get_distinct_id() : null;
182
192
  if (distinctId) {
183
193
  updateCachedDistinctId(distinctId);
184
194
  return distinctId;
@@ -1,4 +1,5 @@
1
1
  import coreApi from '@reservamos/js-api-client/core';
2
+ import mixpanel from 'mixpanel-browser';
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
  import fingerprintService from '@/services/fingerprint';
4
5
  import mixpanelService from '@/services/mixpanel';
@@ -10,7 +11,22 @@ const { getVisitorData, init, client } = vi.hoisted(() => ({
10
11
  client: vi.fn(),
11
12
  }));
12
13
 
13
- // Keep the SDK Fingerprint cache, tracking, validation, and relay real.
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.
14
30
  vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
15
31
  FingerprintJSPro: {
16
32
  defaultEndpoint: 'https://fpjscdn.net',
@@ -57,6 +73,7 @@ const checkout = {
57
73
  describe('tracking with blocked SDK Fingerprint cache writes', () => {
58
74
  beforeEach(async () => {
59
75
  localStorage.clear();
76
+ await mixpanelService.init('test-storage-token');
60
77
  client.mockImplementation(() => ({ init, getVisitorData }));
61
78
  init.mockResolvedValue(undefined);
62
79
  getVisitorData.mockResolvedValue({
@@ -67,14 +84,8 @@ describe('tracking with blocked SDK Fingerprint cache writes', () => {
67
84
  cacheHit: false,
68
85
  });
69
86
  await fingerprintService.initFingerprint('test-api-key');
70
- vi.spyOn(mixpanelService, 'isReady').mockReturnValue(true);
71
- vi.spyOn(mixpanelService, 'getMixpanelDistinctId').mockReturnValue(
72
- 'live-id',
73
- );
74
- vi.spyOn(mixpanelService, 'track').mockImplementation(() => undefined);
75
- vi.spyOn(mixpanelService, 'setPeopleProperties').mockImplementation(
76
- () => undefined,
77
- );
87
+ vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('live-id');
88
+ vi.spyOn(mixpanel.people, 'set').mockImplementation(() => undefined);
78
89
  vi.spyOn(coreApi.fingerprint, 'sendObservation').mockResolvedValue(true);
79
90
  vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
80
91
  throw new DOMException('Blocked', 'SecurityError');
@@ -96,16 +107,20 @@ describe('tracking with blocked SDK Fingerprint cache writes', () => {
96
107
  async (eventName, properties, eligible) => {
97
108
  await trackEvent(eventName, properties);
98
109
 
99
- expect(mixpanelService.track).toHaveBeenCalledOnce();
100
- expect(mixpanelService.track).toHaveBeenCalledWith(
110
+ expect(mixpanel.track).toHaveBeenCalledOnce();
111
+ expect(mixpanel.track).toHaveBeenCalledWith(
101
112
  eventName,
102
113
  expect.objectContaining({
103
114
  'User Fingerprint': expect.any(String),
104
115
  'product': 'web',
105
116
  }),
106
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
+ */
107
122
  const verifyIdentificationEffects = () => {
108
- expect(mixpanelService.setPeopleProperties).toHaveBeenCalledWith({
123
+ expect(mixpanel.people.set).toHaveBeenCalledWith({
109
124
  'Visitor Found': true,
110
125
  });
111
126
  expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledTimes(