@reservamos/browser-analytics 1.1.7 → 1.1.8-alpha.1
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.
- package/CHANGELOG.md +13 -0
- package/README.md +16 -10
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.esm.js +500 -81
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +2 -2
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +2 -2
- package/src/events/sharedSchemas/tripSchema.ts +3 -8
- package/src/init.botDetection.test.ts +187 -0
- package/src/init.test.ts +288 -0
- package/src/init.ts +47 -12
- package/src/services/deferredBotDetection.ts +60 -0
- package/src/services/fingerprint.test.ts +36 -0
- package/src/services/fingerprint.ts +38 -20
- package/src/services/mixpanel.test.ts +103 -0
- package/src/services/mixpanel.ts +70 -21
- package/src/services/trackingLifecycle.ts +54 -0
- package/src/services/validator/validator.test.ts +79 -0
- package/src/track.readiness.test.ts +131 -0
- package/src/track.recovery.test.ts +339 -0
- package/src/track.storage.test.ts +127 -0
- package/src/track.test.ts +435 -66
- package/src/track.ts +174 -25
- package/src/util/pendingWork.test.ts +164 -0
- package/src/util/pendingWork.ts +148 -0
- package/src/util/snapshotEventData.ts +15 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Mock } from 'vitest';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import mixpanelService from '@/services/mixpanel';
|
|
4
|
+
import { trackEvent } from './track';
|
|
5
|
+
|
|
6
|
+
const transport = vi.hoisted(() => ({
|
|
7
|
+
loaded: undefined as (() => void) | undefined,
|
|
8
|
+
register: vi.fn(),
|
|
9
|
+
track: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// Keep the actual Mixpanel service and its readiness notification. Only the
|
|
13
|
+
// browser client and unrelated enrichment are replaced at their boundaries.
|
|
14
|
+
vi.mock('mixpanel-browser', () => ({
|
|
15
|
+
default: {
|
|
16
|
+
init: vi.fn((_token: string, config: { loaded: () => void }) => {
|
|
17
|
+
transport.loaded = config.loaded;
|
|
18
|
+
}),
|
|
19
|
+
register: transport.register,
|
|
20
|
+
track: transport.track,
|
|
21
|
+
get_distinct_id: vi.fn().mockReturnValue('distinct-123'),
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('@/services/botDetectionRelay', () => ({
|
|
25
|
+
default: { isEnabledFor: vi.fn().mockReturnValue(false) },
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('@/services/fingerprint', () => ({
|
|
28
|
+
default: {
|
|
29
|
+
getFingerprint: vi.fn().mockResolvedValue(''),
|
|
30
|
+
getCachedFingerprint: vi.fn().mockReturnValue(null),
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
vi.mock('@/services/geolocation', () => ({
|
|
34
|
+
default: {
|
|
35
|
+
getCoordinates: vi.fn().mockReturnValue(null),
|
|
36
|
+
getGeolocationSource: vi.fn().mockReturnValue(null),
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
vi.mock('@/services/validator', () => ({
|
|
40
|
+
default: { parseEventProps: vi.fn() },
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
describe('tracking with the actual Mixpanel readiness service', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.useFakeTimers();
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
Reflect.deleteProperty(window, 'mixpanel');
|
|
48
|
+
transport.loaded = undefined;
|
|
49
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.restoreAllMocks();
|
|
54
|
+
vi.clearAllTimers();
|
|
55
|
+
vi.useRealTimers();
|
|
56
|
+
Reflect.deleteProperty(window, 'mixpanel');
|
|
57
|
+
localStorage.clear();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function observeUnsubscriptions() {
|
|
61
|
+
const subscribe = mixpanelService.onReady;
|
|
62
|
+
const unsubscribers: Mock<[], void>[] = [];
|
|
63
|
+
vi.spyOn(mixpanelService, 'onReady').mockImplementation((callback) => {
|
|
64
|
+
const unsubscribe = vi.fn(subscribe(callback));
|
|
65
|
+
unsubscribers.push(unsubscribe);
|
|
66
|
+
return unsubscribe;
|
|
67
|
+
});
|
|
68
|
+
return unsubscribers;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
it('releases early Search on loaded before later View Results without Tracker Ready', async () => {
|
|
72
|
+
const unsubscribers = observeUnsubscriptions();
|
|
73
|
+
const remove = vi.spyOn(window, 'removeEventListener');
|
|
74
|
+
const dispatch = vi.spyOn(window, 'dispatchEvent');
|
|
75
|
+
const earlySearch = trackEvent('Search', { source: 'early' });
|
|
76
|
+
const initialization = mixpanelService.init('test-token');
|
|
77
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
78
|
+
expect(transport.track).not.toHaveBeenCalled();
|
|
79
|
+
|
|
80
|
+
transport.loaded?.();
|
|
81
|
+
await initialization;
|
|
82
|
+
await earlySearch;
|
|
83
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
84
|
+
expect(
|
|
85
|
+
vi.mocked(transport.register).mock.invocationCallOrder[0],
|
|
86
|
+
).toBeLessThan(vi.mocked(transport.track).mock.invocationCallOrder[0]);
|
|
87
|
+
expect(transport.track).toHaveBeenCalledWith(
|
|
88
|
+
'Search',
|
|
89
|
+
expect.objectContaining({ source: 'early' }),
|
|
90
|
+
);
|
|
91
|
+
expect(unsubscribers[0]).toHaveBeenCalledOnce();
|
|
92
|
+
expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
|
|
93
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
94
|
+
|
|
95
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
96
|
+
await trackEvent('View Results', { source: 'later' });
|
|
97
|
+
expect(vi.mocked(transport.track).mock.calls.map(([name]) => name)).toEqual(
|
|
98
|
+
['Search', 'View Results'],
|
|
99
|
+
);
|
|
100
|
+
expect(
|
|
101
|
+
dispatch.mock.calls.some(([event]) => event.type === 'Tracker Ready'),
|
|
102
|
+
).toBe(false);
|
|
103
|
+
transport.loaded?.();
|
|
104
|
+
expect(transport.track).toHaveBeenCalledTimes(2);
|
|
105
|
+
expect(unsubscribers[0]).toHaveBeenCalledOnce();
|
|
106
|
+
expect(console.error).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('unsubscribes on expiry and does not replay on late Mixpanel loaded', async () => {
|
|
110
|
+
const unsubscribers = observeUnsubscriptions();
|
|
111
|
+
const initialization = mixpanelService.init('test-token');
|
|
112
|
+
const pending = trackEvent('Search', { secret: 'private-payload' });
|
|
113
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
114
|
+
await pending;
|
|
115
|
+
expect(unsubscribers[0]).toHaveBeenCalledOnce();
|
|
116
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
117
|
+
expect(console.error).toHaveBeenCalledWith('Tracking recovery diagnostic', {
|
|
118
|
+
eventName: 'Search',
|
|
119
|
+
stage: 'readiness',
|
|
120
|
+
reason: 'timeout',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
transport.loaded?.();
|
|
124
|
+
await initialization;
|
|
125
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
126
|
+
await Promise.resolve();
|
|
127
|
+
expect(transport.track).not.toHaveBeenCalled();
|
|
128
|
+
expect(unsubscribers[0]).toHaveBeenCalledOnce();
|
|
129
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -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,127 @@
|
|
|
1
|
+
import coreApi from '@reservamos/js-api-client/core';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import fingerprintService from '@/services/fingerprint';
|
|
4
|
+
import mixpanelService from '@/services/mixpanel';
|
|
5
|
+
import { trackEvent } from './track';
|
|
6
|
+
|
|
7
|
+
const { getVisitorData, init, client } = vi.hoisted(() => ({
|
|
8
|
+
getVisitorData: vi.fn(),
|
|
9
|
+
init: vi.fn(),
|
|
10
|
+
client: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Keep the SDK Fingerprint cache, tracking, validation, and relay real.
|
|
14
|
+
vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
|
|
15
|
+
FingerprintJSPro: {
|
|
16
|
+
defaultEndpoint: 'https://fpjscdn.net',
|
|
17
|
+
defaultScriptUrlPattern: 'https://fpjscdn.net/agent',
|
|
18
|
+
},
|
|
19
|
+
FpjsClient: client,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const search = {
|
|
23
|
+
'Departure': '2026-11-01',
|
|
24
|
+
'Departure Delta': 1,
|
|
25
|
+
'Destination': 'Puebla',
|
|
26
|
+
'Destination Terminal': 'CAPU',
|
|
27
|
+
'Origin': 'Mexico City',
|
|
28
|
+
'Origin Terminal': 'TAPO',
|
|
29
|
+
'Passengers': 1,
|
|
30
|
+
'Route': 'Mexico City-Puebla',
|
|
31
|
+
'product': 'web',
|
|
32
|
+
};
|
|
33
|
+
const checkout = {
|
|
34
|
+
'Trips': [
|
|
35
|
+
{
|
|
36
|
+
'Departure Arrival': '2026-11-01T12:00:00',
|
|
37
|
+
'Departure Destination': 'Puebla',
|
|
38
|
+
'Departure Destination Terminal': 'CAPU',
|
|
39
|
+
'Departure Line': 'Bus line',
|
|
40
|
+
'Departure Origin': 'Mexico City',
|
|
41
|
+
'Departure Origin Terminal': 'TAPO',
|
|
42
|
+
'Departure Price': 100,
|
|
43
|
+
'Departure Route': 'Mexico City-Puebla',
|
|
44
|
+
'Departure Stops': 1,
|
|
45
|
+
'Departure Time': '2026-11-01T10:00:00',
|
|
46
|
+
'Departure Transport Type': 'bus',
|
|
47
|
+
'Departure Transporter': 'Carrier',
|
|
48
|
+
'Recommended Trip': false,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
'Passenger Count': 1,
|
|
52
|
+
'Trip Count': 1,
|
|
53
|
+
'Total': 100,
|
|
54
|
+
'product': 'web',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe('tracking with blocked SDK Fingerprint cache writes', () => {
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
localStorage.clear();
|
|
60
|
+
client.mockImplementation(() => ({ init, getVisitorData }));
|
|
61
|
+
init.mockResolvedValue(undefined);
|
|
62
|
+
getVisitorData.mockResolvedValue({
|
|
63
|
+
visitorId: 'visitor-123',
|
|
64
|
+
requestId: 'request-123',
|
|
65
|
+
visitorFound: true,
|
|
66
|
+
confidence: { score: 0.99 },
|
|
67
|
+
cacheHit: false,
|
|
68
|
+
});
|
|
69
|
+
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
|
+
);
|
|
78
|
+
vi.spyOn(coreApi.fingerprint, 'sendObservation').mockResolvedValue(true);
|
|
79
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
80
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
vi.restoreAllMocks();
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it.each([
|
|
90
|
+
['Search', search, true],
|
|
91
|
+
['View Results', { ...search, 'Bus count': 10 }, false],
|
|
92
|
+
['Purchase Attempt', checkout, true],
|
|
93
|
+
['Payment Attempt', { ...checkout, 'Payment Type': 'credit_card' }, true],
|
|
94
|
+
] as const)(
|
|
95
|
+
'preserves valid %s and its eligible observation',
|
|
96
|
+
async (eventName, properties, eligible) => {
|
|
97
|
+
await trackEvent(eventName, properties);
|
|
98
|
+
|
|
99
|
+
expect(mixpanelService.track).toHaveBeenCalledOnce();
|
|
100
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
101
|
+
eventName,
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
'User Fingerprint': expect.any(String),
|
|
104
|
+
'product': 'web',
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
const verifyIdentificationEffects = () => {
|
|
108
|
+
expect(mixpanelService.setPeopleProperties).toHaveBeenCalledWith({
|
|
109
|
+
'Visitor Found': true,
|
|
110
|
+
});
|
|
111
|
+
expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledTimes(
|
|
112
|
+
eligible ? 1 : 0,
|
|
113
|
+
);
|
|
114
|
+
if (eligible) {
|
|
115
|
+
expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledWith(
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
requestId: 'request-123',
|
|
118
|
+
visitorId: 'visitor-123',
|
|
119
|
+
distinctId: 'live-id',
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
await vi.waitFor(verifyIdentificationEffects);
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
});
|