@reservamos/browser-analytics 1.1.8-alpha.0 → 1.1.8-alpha.2
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 +21 -3
- package/README.md +33 -7
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +15 -2
- package/dist/browser-analytics.esm.js +590 -109
- 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/identify/identify.robustness.test.ts +112 -13
- package/src/events/identify/identify.ts +84 -34
- package/src/events/passengersCreated/passengersCreatedSchema.test.ts +142 -0
- package/src/events/passengersCreated/passengersCreatedSchema.ts +7 -1
- package/src/events/purchaseConfirmation/purchaseConfirmation.integration.test.ts +49 -0
- package/src/events/purchaseConfirmation/purchaseConfirmation.test.ts +17 -0
- package/src/events/purchaseConfirmation/purchaseConfirmationSchema.ts +2 -0
- package/src/events/sharedSchemas/tripSchema.ts +3 -8
- package/src/index.ts +1 -0
- package/src/init.botDetection.test.ts +187 -0
- package/src/init.test.ts +1 -0
- package/src/init.ts +15 -2
- package/src/services/botDetectionRelay.test.ts +3 -2
- package/src/services/deferredBotDetection.ts +60 -0
- package/src/services/fingerprint.test.ts +190 -0
- package/src/services/fingerprint.ts +141 -13
- package/src/services/fingerprint.wait.integration.test.ts +129 -0
- package/src/services/mixpanel.test.ts +119 -73
- package/src/services/mixpanel.ts +82 -23
- 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 +142 -0
- package/src/track.test.ts +130 -5
- package/src/track.ts +110 -25
- package/src/util/pendingWork.test.ts +164 -0
- package/src/util/pendingWork.ts +148 -0
- package/src/util/snapshotEventData.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reservamos/browser-analytics",
|
|
3
|
-
"version": "1.1.8-alpha.
|
|
3
|
+
"version": "1.1.8-alpha.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/reservamos/reservamos-browser-analytics.git"
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@eslint/js": "^9.9.1",
|
|
76
76
|
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
|
77
|
-
"@reservamos/js-api-client": "6.28.
|
|
77
|
+
"@reservamos/js-api-client": "6.28.1",
|
|
78
78
|
"@types/eslint__js": "^8.42.3",
|
|
79
79
|
"@types/jsdom": "^21.1.6",
|
|
80
80
|
"@types/mixpanel-browser": "^2.50.0",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
2
3
|
import identify from './identify';
|
|
3
4
|
|
|
4
5
|
const mocks = vi.hoisted(() => ({
|
|
@@ -8,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
8
9
|
ready: vi.fn(),
|
|
9
10
|
identify: vi.fn(),
|
|
10
11
|
attach: vi.fn(),
|
|
12
|
+
people: vi.fn(),
|
|
11
13
|
fingerprint: vi.fn(),
|
|
12
14
|
validate: vi.fn(),
|
|
13
15
|
error: vi.fn(),
|
|
@@ -26,6 +28,7 @@ vi.mock('@/services/mixpanel', () => ({
|
|
|
26
28
|
isReady: mocks.ready,
|
|
27
29
|
identify: mocks.identify,
|
|
28
30
|
attachProperty: mocks.attach,
|
|
31
|
+
setPeopleProperties: mocks.people,
|
|
29
32
|
},
|
|
30
33
|
}));
|
|
31
34
|
vi.mock('@/services/fingerprint', () => ({
|
|
@@ -60,30 +63,123 @@ describe('identify bounded local attempt', () => {
|
|
|
60
63
|
vi.unstubAllGlobals();
|
|
61
64
|
});
|
|
62
65
|
|
|
63
|
-
it('
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
it('submits and resolves before a two-second redirect despite a 2.5-second profile', async () => {
|
|
67
|
+
mocks.profile.mockImplementation(
|
|
68
|
+
() =>
|
|
69
|
+
new Promise((resolve) =>
|
|
70
|
+
setTimeout(() => resolve({ id: 'profile' }), 2500),
|
|
71
|
+
),
|
|
69
72
|
);
|
|
73
|
+
const properties = { email: 'test@example.com', firstName: 'Test' };
|
|
70
74
|
let finished = false;
|
|
71
|
-
|
|
75
|
+
void identify('user', properties).then(() => {
|
|
72
76
|
finished = true;
|
|
73
77
|
});
|
|
74
78
|
await vi.advanceTimersByTimeAsync(1);
|
|
75
|
-
expect(finished).toBe(
|
|
76
|
-
expect(mocks.identify).not.toHaveBeenCalled();
|
|
77
|
-
finish({ id: 'profile' });
|
|
78
|
-
await pending;
|
|
79
|
+
expect(finished).toBe(true);
|
|
79
80
|
expect(mocks.identify).toHaveBeenCalledWith(
|
|
80
81
|
'user',
|
|
81
|
-
expect.objectContaining({
|
|
82
|
+
expect.objectContaining({ $email: 'test@example.com' }),
|
|
82
83
|
expect.any(Function),
|
|
83
84
|
);
|
|
84
|
-
expect(
|
|
85
|
+
expect(mocks.people).not.toHaveBeenCalled();
|
|
86
|
+
await vi.advanceTimersByTimeAsync(1999);
|
|
87
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
88
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
89
|
+
expect(mocks.people).toHaveBeenCalledWith(
|
|
90
|
+
{ reservamos_one_id: 'profile' },
|
|
91
|
+
expect.any(Function),
|
|
92
|
+
);
|
|
93
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(properties).toEqual({
|
|
95
|
+
email: 'test@example.com',
|
|
96
|
+
firstName: 'Test',
|
|
97
|
+
});
|
|
85
98
|
});
|
|
86
99
|
|
|
100
|
+
it.each(['new-user', 'reinitialize'] as const)(
|
|
101
|
+
'suppresses late enrichment after %s',
|
|
102
|
+
async (transition) => {
|
|
103
|
+
let finish!: (value: { id: string }) => void;
|
|
104
|
+
let finishFingerprint!: (value: string) => void;
|
|
105
|
+
mocks.fingerprint.mockReturnValueOnce(
|
|
106
|
+
new Promise((resolve) => {
|
|
107
|
+
finishFingerprint = resolve;
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
mocks.profile.mockReturnValueOnce(
|
|
111
|
+
new Promise((resolve) => {
|
|
112
|
+
finish = resolve;
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
await identify('old-user');
|
|
116
|
+
if (transition === 'new-user') await identify('new-user');
|
|
117
|
+
else {
|
|
118
|
+
trackingLifecycle.beginInitialization();
|
|
119
|
+
trackingLifecycle.beginInitialization();
|
|
120
|
+
}
|
|
121
|
+
finish({ id: 'old-profile' });
|
|
122
|
+
finishFingerprint('old-fingerprint');
|
|
123
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
124
|
+
expect(mocks.people).not.toHaveBeenCalled();
|
|
125
|
+
expect(mocks.attach).not.toHaveBeenCalled();
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
it('attaches current fingerprint once', async () => {
|
|
130
|
+
mocks.fingerprint.mockResolvedValue('current-fingerprint');
|
|
131
|
+
await identify('user');
|
|
132
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
133
|
+
expect(mocks.attach).toHaveBeenCalledWith(
|
|
134
|
+
'Known Fingerprints',
|
|
135
|
+
'current-fingerprint',
|
|
136
|
+
);
|
|
137
|
+
expect(mocks.attach).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
it('retains the created profile ID in background datalake payload without mutating input', async () => {
|
|
140
|
+
mocks.profile.mockResolvedValue({ id: 'created-profile' });
|
|
141
|
+
mocks.coordinates.mockReturnValue({ lat: 1, long: 2 });
|
|
142
|
+
const properties = {
|
|
143
|
+
email: 'test@example.com',
|
|
144
|
+
firstName: 'Test',
|
|
145
|
+
lastName: 'User',
|
|
146
|
+
};
|
|
147
|
+
await identify('user', properties);
|
|
148
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
149
|
+
const request = fetchMock.mock.calls[0][1] as RequestInit;
|
|
150
|
+
expect(JSON.parse(request.body as string).profile_params).toEqual(
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
reservamos_one_id: 'created-profile',
|
|
153
|
+
distinct_id: 'user',
|
|
154
|
+
first_name: 'Test',
|
|
155
|
+
last_name: 'User',
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
expect(properties).toEqual({
|
|
159
|
+
email: 'test@example.com',
|
|
160
|
+
firstName: 'Test',
|
|
161
|
+
lastName: 'User',
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
it('does not identify an old user after readiness belongs to a replacement init', async () => {
|
|
165
|
+
trackingLifecycle.beginInitialization();
|
|
166
|
+
mocks.ready.mockReturnValue(false);
|
|
167
|
+
const pending = identify('old-user');
|
|
168
|
+
trackingLifecycle.beginInitialization();
|
|
169
|
+
mocks.ready.mockReturnValue(true);
|
|
170
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
171
|
+
await pending;
|
|
172
|
+
expect(mocks.identify).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
it('does not start optional requests after the attempt is superseded before their microtask', async () => {
|
|
175
|
+
const pending = identify('old-user');
|
|
176
|
+
await Promise.resolve();
|
|
177
|
+
trackingLifecycle.beginInitialization();
|
|
178
|
+
await pending;
|
|
179
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
180
|
+
expect(mocks.profile).not.toHaveBeenCalled();
|
|
181
|
+
expect(mocks.fingerprint).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
87
183
|
it('runs readiness once and removes its listener', async () => {
|
|
88
184
|
mocks.ready.mockReturnValue(false);
|
|
89
185
|
const remove = vi.spyOn(window, 'removeEventListener');
|
|
@@ -94,6 +190,7 @@ describe('identify bounded local attempt', () => {
|
|
|
94
190
|
await pending;
|
|
95
191
|
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
96
192
|
expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
|
|
193
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
97
194
|
expect(vi.getTimerCount()).toBe(0);
|
|
98
195
|
});
|
|
99
196
|
|
|
@@ -141,6 +238,7 @@ describe('identify bounded local attempt', () => {
|
|
|
141
238
|
it('continues after profile rejection with controlled diagnostics', async () => {
|
|
142
239
|
mocks.profile.mockRejectedValue(new Error('private@example.com'));
|
|
143
240
|
await identify('private-id');
|
|
241
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
144
242
|
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
145
243
|
expect(mocks.error).toHaveBeenCalledWith(
|
|
146
244
|
'Identify',
|
|
@@ -171,6 +269,7 @@ describe('identify bounded local attempt', () => {
|
|
|
171
269
|
mocks.coordinates.mockReturnValue({ lat: 1, long: 2 });
|
|
172
270
|
fetchMock.mockReturnValue(new Promise(() => undefined));
|
|
173
271
|
await identify('user');
|
|
272
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
174
273
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
175
274
|
'https://example.test/api/v1/datalake/identify',
|
|
176
275
|
expect.objectContaining({
|
|
@@ -4,6 +4,7 @@ import configService from '@/services/config';
|
|
|
4
4
|
import { getFingerprintForIdentify } from '@/services/fingerprint';
|
|
5
5
|
import geolocationService from '@/services/geolocation';
|
|
6
6
|
import mixpanelService from '@/services/mixpanel';
|
|
7
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
7
8
|
import validatorService from '@/services/validator';
|
|
8
9
|
import {
|
|
9
10
|
createIdentifyReporter,
|
|
@@ -15,6 +16,7 @@ import { DefaultProperties } from './identifySchema';
|
|
|
15
16
|
|
|
16
17
|
const KNOWN_PROPERTIES = ['firstName', 'lastName', 'email', 'phone'];
|
|
17
18
|
const FINGERPRINT_PROPERTY = 'Known Fingerprints';
|
|
19
|
+
let latestAttempt = 0;
|
|
18
20
|
|
|
19
21
|
export interface UserProperties
|
|
20
22
|
extends DefaultProperties,
|
|
@@ -112,21 +114,87 @@ const identifyWithLocation = async (
|
|
|
112
114
|
}
|
|
113
115
|
};
|
|
114
116
|
|
|
117
|
+
async function enrichProfileAndLocation(
|
|
118
|
+
userId: string,
|
|
119
|
+
properties: UserProperties,
|
|
120
|
+
isActive: () => boolean,
|
|
121
|
+
report: IdentifyReporter,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const profile = await waitForIdentifyStage(
|
|
124
|
+
'profile',
|
|
125
|
+
3000,
|
|
126
|
+
(context) =>
|
|
127
|
+
isActive()
|
|
128
|
+
? createAnonymousProfileForIdentify({ ...properties }, context.report)
|
|
129
|
+
: Promise.resolve(undefined),
|
|
130
|
+
report,
|
|
131
|
+
);
|
|
132
|
+
if (!isActive()) return;
|
|
133
|
+
const enriched = {
|
|
134
|
+
...properties,
|
|
135
|
+
...(profile && { reservamos_one_id: profile.id }),
|
|
136
|
+
};
|
|
137
|
+
if (profile) {
|
|
138
|
+
try {
|
|
139
|
+
mixpanelService.setPeopleProperties(
|
|
140
|
+
{ reservamos_one_id: profile.id },
|
|
141
|
+
report,
|
|
142
|
+
);
|
|
143
|
+
} catch {
|
|
144
|
+
report('profile', 'failed');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
void identifyWithLocation(userId, enriched, report);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function enrichFingerprint(
|
|
151
|
+
isActive: () => boolean,
|
|
152
|
+
report: IdentifyReporter,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const fingerprint = await waitForIdentifyStage(
|
|
155
|
+
'fingerprint',
|
|
156
|
+
2000,
|
|
157
|
+
(context) =>
|
|
158
|
+
!isActive()
|
|
159
|
+
? Promise.resolve('')
|
|
160
|
+
: getFingerprintForIdentify({
|
|
161
|
+
isActive: () => context.isActive() && isActive(),
|
|
162
|
+
report: context.report,
|
|
163
|
+
}),
|
|
164
|
+
report,
|
|
165
|
+
);
|
|
166
|
+
if (!fingerprint || !isActive()) return;
|
|
167
|
+
try {
|
|
168
|
+
mixpanelService.attachProperty(FINGERPRINT_PROPERTY, fingerprint);
|
|
169
|
+
} catch {
|
|
170
|
+
report('fingerprint', 'failed');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
115
174
|
/**
|
|
116
|
-
*
|
|
175
|
+
* Applies identity and initial properties before optional enrichment.
|
|
117
176
|
* @param {string} userId - The unique identifier for the user.
|
|
118
|
-
* @param {UserProperties} properties - Optional user properties.
|
|
119
|
-
* @returns {Promise<void>} Resolves after
|
|
177
|
+
* @param {UserProperties} properties - Optional user properties; never mutated.
|
|
178
|
+
* @returns {Promise<void>} Resolves after the local identity submission, not enrichment or remote delivery.
|
|
120
179
|
*/
|
|
121
180
|
async function identify(
|
|
122
181
|
userId: string,
|
|
123
182
|
properties: UserProperties = {},
|
|
124
183
|
): Promise<void> {
|
|
184
|
+
const attempt = ++latestAttempt;
|
|
185
|
+
const input = { ...properties };
|
|
125
186
|
const report = createIdentifyReporter();
|
|
126
|
-
|
|
187
|
+
const initialLifecycle = trackingLifecycle.capture();
|
|
188
|
+
if (
|
|
189
|
+
!(await waitForIdentifyReady(report)) ||
|
|
190
|
+
attempt !== latestAttempt ||
|
|
191
|
+
!initialLifecycle()
|
|
192
|
+
)
|
|
193
|
+
return;
|
|
194
|
+
let submitted = false;
|
|
127
195
|
try {
|
|
128
196
|
try {
|
|
129
|
-
validatorService.parseIdentifyProps(
|
|
197
|
+
validatorService.parseIdentifyProps(input);
|
|
130
198
|
if (!userId) {
|
|
131
199
|
report('validation', 'invalid_input');
|
|
132
200
|
return;
|
|
@@ -135,40 +203,22 @@ async function identify(
|
|
|
135
203
|
report('validation', 'invalid_input');
|
|
136
204
|
return;
|
|
137
205
|
}
|
|
138
|
-
const anonymousProfile = await waitForIdentifyStage(
|
|
139
|
-
'profile',
|
|
140
|
-
3000,
|
|
141
|
-
(context) =>
|
|
142
|
-
createAnonymousProfileForIdentify({ ...properties }, context.report),
|
|
143
|
-
report,
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
if (anonymousProfile) {
|
|
147
|
-
properties.reservamos_one_id = anonymousProfile.id;
|
|
148
|
-
}
|
|
149
|
-
const mappedProps = mapProperties(properties);
|
|
150
206
|
try {
|
|
151
|
-
mixpanelService.identify(userId,
|
|
207
|
+
mixpanelService.identify(userId, mapProperties(input), report);
|
|
152
208
|
} catch {
|
|
153
209
|
report('mixpanel', 'failed');
|
|
210
|
+
return;
|
|
154
211
|
}
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
mixpanelService.attachProperty(FINGERPRINT_PROPERTY, fingerprint);
|
|
164
|
-
} catch {
|
|
165
|
-
report('fingerprint', 'failed');
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
} catch {
|
|
169
|
-
report('mixpanel', 'failed');
|
|
212
|
+
const currentLifecycle = trackingLifecycle.capture();
|
|
213
|
+
const isActive = () => attempt === latestAttempt && currentLifecycle();
|
|
214
|
+
const scopedReport: IdentifyReporter = (...args) => {
|
|
215
|
+
if (isActive()) report(...args);
|
|
216
|
+
};
|
|
217
|
+
submitted = true;
|
|
218
|
+
void enrichProfileAndLocation(userId, input, isActive, scopedReport);
|
|
219
|
+
void enrichFingerprint(isActive, scopedReport);
|
|
170
220
|
} finally {
|
|
171
|
-
void identifyWithLocation(userId,
|
|
221
|
+
if (!submitted) void identifyWithLocation(userId, { ...input }, report);
|
|
172
222
|
}
|
|
173
223
|
}
|
|
174
224
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import paymentAttemptSchema from '@/events/paymentAttempt/paymentAttemptSchema';
|
|
3
|
+
import validatorService from '@/services/validator';
|
|
4
|
+
import passengersCreatedSchema from './passengersCreatedSchema';
|
|
5
|
+
|
|
6
|
+
const trip = {
|
|
7
|
+
'Departure Arrival': '2026-10-20T14:30:00',
|
|
8
|
+
'Departure Destination': 'Destination City',
|
|
9
|
+
'Departure Destination Terminal': 'Destination Terminal',
|
|
10
|
+
'Departure Line': 'Synthetic Line',
|
|
11
|
+
'Departure Origin': 'Origin City',
|
|
12
|
+
'Departure Origin Terminal': 'Origin Terminal',
|
|
13
|
+
'Departure Price': 450.5,
|
|
14
|
+
'Departure Route': 'Origin City - Destination City',
|
|
15
|
+
'Departure Stops': 1,
|
|
16
|
+
'Departure Time': '2026-10-20T08:00:00',
|
|
17
|
+
'Departure Transport Type': 'bus',
|
|
18
|
+
'Departure Transporter': 'Synthetic Transporter',
|
|
19
|
+
'Recommended Trip': false,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const passenger = {
|
|
23
|
+
'Passenger Birthdate': '1990-01-01',
|
|
24
|
+
'Passenger Seat': '12',
|
|
25
|
+
'Passenger Name': 'Synthetic Passenger',
|
|
26
|
+
'Passenger Document Type': 'passport',
|
|
27
|
+
'Passenger Document Id': 'X0000000',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const basePayload = {
|
|
31
|
+
'Trips': [trip],
|
|
32
|
+
'Passenger Count': 1,
|
|
33
|
+
'Trip Count': 1,
|
|
34
|
+
'Total': 450.5,
|
|
35
|
+
'product': 'web',
|
|
36
|
+
'Passengers': [passenger],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const paymentAttemptPayload = { ...basePayload, 'Payment Type': 'card' };
|
|
40
|
+
|
|
41
|
+
const without = (source: object, key: string) =>
|
|
42
|
+
Object.fromEntries(Object.entries(source).filter(([name]) => name !== key));
|
|
43
|
+
|
|
44
|
+
describe('passengersCreatedSchema', () => {
|
|
45
|
+
it('accepts a valid payload without Coupon', () => {
|
|
46
|
+
expect(passengersCreatedSchema.parse(basePayload)).toEqual(basePayload);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('accepts a valid payload with Coupon', () => {
|
|
50
|
+
const payload = { ...basePayload, Coupon: 'PROMO10' };
|
|
51
|
+
|
|
52
|
+
expect(passengersCreatedSchema.parse(payload)).toEqual(payload);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each([10, true, null, {}])('rejects Coupon given as %j', (coupon) => {
|
|
56
|
+
expect(
|
|
57
|
+
passengersCreatedSchema.safeParse({ ...basePayload, Coupon: coupon })
|
|
58
|
+
.success,
|
|
59
|
+
).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects an empty Coupon', () => {
|
|
63
|
+
expect(
|
|
64
|
+
passengersCreatedSchema.safeParse({ ...basePayload, Coupon: '' }).success,
|
|
65
|
+
).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps rejecting keys outside the schema', () => {
|
|
69
|
+
const result = passengersCreatedSchema.safeParse({
|
|
70
|
+
...basePayload,
|
|
71
|
+
Insurance: true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(result.success).toBe(false);
|
|
75
|
+
expect(result.error?.issues[0]?.code).toBe('unrecognized_keys');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('accepts the payload shape previously rejected for its Coupon key', () => {
|
|
79
|
+
expect(() =>
|
|
80
|
+
validatorService.parseEventProps('Passengers Created', {
|
|
81
|
+
...basePayload,
|
|
82
|
+
Coupon: 'PROMO10',
|
|
83
|
+
}),
|
|
84
|
+
).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects a trip without Departure Arrival', () => {
|
|
88
|
+
expect(
|
|
89
|
+
passengersCreatedSchema.safeParse({
|
|
90
|
+
...basePayload,
|
|
91
|
+
Trips: [without(trip, 'Departure Arrival')],
|
|
92
|
+
}).success,
|
|
93
|
+
).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('checkout events with an empty passenger list', () => {
|
|
98
|
+
it.each([
|
|
99
|
+
['Passengers Created', passengersCreatedSchema, basePayload],
|
|
100
|
+
['Payment Attempt', paymentAttemptSchema, paymentAttemptPayload],
|
|
101
|
+
] as const)(
|
|
102
|
+
'%s rejects an empty Passengers list',
|
|
103
|
+
(_name, schema, payload) => {
|
|
104
|
+
const result = schema.safeParse({ ...payload, Passengers: [] });
|
|
105
|
+
|
|
106
|
+
expect(result.success).toBe(false);
|
|
107
|
+
expect(result.error?.issues.map((issue) => issue.message)).toContain(
|
|
108
|
+
'Passengers must have at least 1 items',
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
it.each([
|
|
114
|
+
['Passengers Created', passengersCreatedSchema, basePayload],
|
|
115
|
+
['Payment Attempt', paymentAttemptSchema, paymentAttemptPayload],
|
|
116
|
+
] as const)(
|
|
117
|
+
'%s accepts an omitted Passengers list',
|
|
118
|
+
(_name, schema, payload) => {
|
|
119
|
+
expect(schema.safeParse(without(payload, 'Passengers')).success).toBe(
|
|
120
|
+
true,
|
|
121
|
+
);
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('Coupon parity with Payment Attempt', () => {
|
|
127
|
+
it.each(['PROMO10', '', 10])(
|
|
128
|
+
'gives the same verdict for Coupon %j',
|
|
129
|
+
(coupon) => {
|
|
130
|
+
const passengersCreated = passengersCreatedSchema.safeParse({
|
|
131
|
+
...basePayload,
|
|
132
|
+
Coupon: coupon,
|
|
133
|
+
});
|
|
134
|
+
const paymentAttempt = paymentAttemptSchema.safeParse({
|
|
135
|
+
...paymentAttemptPayload,
|
|
136
|
+
Coupon: coupon,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(passengersCreated.success).toBe(paymentAttempt.success);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
arrayField,
|
|
4
|
+
intField,
|
|
5
|
+
numberField,
|
|
6
|
+
stringField,
|
|
7
|
+
} from '@/util/primitiveFields';
|
|
3
8
|
import productValidation from '@/util/productValidation';
|
|
4
9
|
import passengerSchema from '../sharedSchemas/passengerSchema';
|
|
5
10
|
import tripSchema from '../sharedSchemas/tripSchema';
|
|
@@ -12,6 +17,7 @@ const passengersCreatedSchema = z
|
|
|
12
17
|
'Total': numberField('Total'),
|
|
13
18
|
'product': productValidation,
|
|
14
19
|
'Passengers': arrayField(passengerSchema, 'Passengers', 1).optional(),
|
|
20
|
+
'Coupon': stringField('Coupon').optional(),
|
|
15
21
|
})
|
|
16
22
|
.strict();
|
|
17
23
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import analytics from '@/index';
|
|
3
|
+
import mixpanelService from '@/services/mixpanel';
|
|
4
|
+
|
|
5
|
+
vi.mock('@/services/mixpanel', () => ({
|
|
6
|
+
default: {
|
|
7
|
+
isReady: vi.fn().mockReturnValue(true),
|
|
8
|
+
track: vi.fn(),
|
|
9
|
+
getMixpanelDistinctId: vi.fn().mockReturnValue('identified-user'),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
localStorage.clear();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('exposes the asynchronous wait through the public analytics facade', async () => {
|
|
19
|
+
localStorage.setItem(
|
|
20
|
+
'default_fingerprint_cache',
|
|
21
|
+
JSON.stringify({
|
|
22
|
+
fingerprint: 'cached-visitor',
|
|
23
|
+
expiresAt: Date.now() + 60000,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
await expect(analytics.identifiers.waitForFingerprintId()).resolves.toBe(
|
|
27
|
+
'cached-visitor',
|
|
28
|
+
);
|
|
29
|
+
expect(analytics.identifiers.getFingerprintId()).toBe('cached-visitor');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('validates and submits confirmation product through the real tracking pipeline', async () => {
|
|
33
|
+
analytics.track.purchaseConfirmation({
|
|
34
|
+
'Operation Id': 'ORDER-1',
|
|
35
|
+
'Payment Status': 'confirmed',
|
|
36
|
+
'product': 'web',
|
|
37
|
+
});
|
|
38
|
+
await vi.waitFor(() =>
|
|
39
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
40
|
+
'Purchase Confirmation Viewed',
|
|
41
|
+
expect.objectContaining({
|
|
42
|
+
'Operation Id': 'ORDER-1',
|
|
43
|
+
'Payment Status': 'confirmed',
|
|
44
|
+
'product': 'web',
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
@@ -28,6 +28,22 @@ describe('purchaseConfirmationSchema', () => {
|
|
|
28
28
|
expect(purchaseConfirmationSchema.parse(payload)).toEqual(payload);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
it.each(['confirmed', 'pending_pix'] as const)(
|
|
32
|
+
'preserves optional product for %s',
|
|
33
|
+
(status) => {
|
|
34
|
+
const payload = {
|
|
35
|
+
'Operation Id': 'MOB-123',
|
|
36
|
+
'Payment Status': status,
|
|
37
|
+
'product': 'web',
|
|
38
|
+
};
|
|
39
|
+
expect(purchaseConfirmationSchema.parse(payload)).toEqual(payload);
|
|
40
|
+
expect(
|
|
41
|
+
purchaseConfirmationSchema.safeParse({ ...payload, product: 'invalid' })
|
|
42
|
+
.success,
|
|
43
|
+
).toBe(false);
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
31
47
|
it('rejects a payload missing Operation Id', () => {
|
|
32
48
|
expect(() =>
|
|
33
49
|
purchaseConfirmationSchema.parse({ 'Payment Status': 'confirmed' }),
|
|
@@ -81,6 +97,7 @@ describe('trackPurchaseConfirmation', () => {
|
|
|
81
97
|
const eventData = {
|
|
82
98
|
'Operation Id': 'MOB-123',
|
|
83
99
|
'Payment Status': 'confirmed' as const,
|
|
100
|
+
'product': 'web' as const,
|
|
84
101
|
};
|
|
85
102
|
const meta = { source: 'checkout' };
|
|
86
103
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import productValidation from '@/util/productValidation';
|
|
2
3
|
|
|
3
4
|
const purchaseConfirmationSchema = z
|
|
4
5
|
.object({
|
|
@@ -6,6 +7,7 @@ const purchaseConfirmationSchema = z
|
|
|
6
7
|
'Payment Status': z.enum(['confirmed', 'pending_pix']),
|
|
7
8
|
'Payment Type': z.string().optional(),
|
|
8
9
|
'Total': z.number().optional(),
|
|
10
|
+
'product': productValidation.optional(),
|
|
9
11
|
})
|
|
10
12
|
.strict();
|
|
11
13
|
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
-
dateField,
|
|
4
|
-
intField,
|
|
5
|
-
numberField,
|
|
6
|
-
stringField,
|
|
7
|
-
} from '@/util/primitiveFields';
|
|
2
|
+
import { dateField, numberField, stringField } from '@/util/primitiveFields';
|
|
8
3
|
|
|
9
4
|
const tripSchema = z.object({
|
|
10
5
|
'Departure Arrival': dateField('Departure Arrival'),
|
|
@@ -17,7 +12,7 @@ const tripSchema = z.object({
|
|
|
17
12
|
'Departure Origin Terminal': stringField('Departure Origin Terminal'),
|
|
18
13
|
'Departure Price': numberField('Departure Price'),
|
|
19
14
|
'Departure Route': stringField('Departure Route'),
|
|
20
|
-
'Departure Stops':
|
|
15
|
+
'Departure Stops': z.number().int(),
|
|
21
16
|
'Departure Time': dateField('Departure Time'),
|
|
22
17
|
'Departure Transport Type': stringField('Departure Transport Type'),
|
|
23
18
|
'Departure Transporter': stringField('Departure Transporter'),
|
|
@@ -31,7 +26,7 @@ const tripSchema = z.object({
|
|
|
31
26
|
'Return Origin Terminal': stringField('Return Origin Terminal').optional(),
|
|
32
27
|
'Return Price': numberField('Return Price').optional(),
|
|
33
28
|
'Return Route': stringField('Return Route').optional(),
|
|
34
|
-
'Return Stops':
|
|
29
|
+
'Return Stops': z.number().int().optional(),
|
|
35
30
|
'Return Time': dateField('Return Time').optional(),
|
|
36
31
|
'Return Transport Type': stringField('Return Transport Type').optional(),
|
|
37
32
|
'Return Transporter': stringField('Return Transporter').optional(),
|
package/src/index.ts
CHANGED