@reservamos/browser-analytics 1.1.8-alpha.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reservamos/browser-analytics",
3
- "version": "1.1.8-alpha.1",
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.0",
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('awaits the local profile and includes its ID before identifying', async () => {
64
- let finish: (value: { id: string }) => void = () => undefined;
65
- mocks.profile.mockReturnValue(
66
- new Promise((resolve) => {
67
- finish = resolve;
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
- const pending = identify('user', { email: 'test@example.com' }).then(() => {
75
+ void identify('user', properties).then(() => {
72
76
  finished = true;
73
77
  });
74
78
  await vi.advanceTimersByTimeAsync(1);
75
- expect(finished).toBe(false);
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({ reservamos_one_id: 'profile' }),
82
+ expect.objectContaining({ $email: 'test@example.com' }),
82
83
  expect.any(Function),
83
84
  );
84
- expect(vi.getTimerCount()).toBe(0);
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
- * Identifies a user with given properties and fingerprint.
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 a bounded local attempt, not confirmed remote delivery.
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
- if (!(await waitForIdentifyReady(report))) return;
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(properties);
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, mappedProps, report);
207
+ mixpanelService.identify(userId, mapProperties(input), report);
152
208
  } catch {
153
209
  report('mixpanel', 'failed');
210
+ return;
154
211
  }
155
- const fingerprint = await waitForIdentifyStage(
156
- 'fingerprint',
157
- 2000,
158
- getFingerprintForIdentify,
159
- report,
160
- );
161
- if (fingerprint) {
162
- try {
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, properties, report);
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 { arrayField, intField, numberField } from '@/util/primitiveFields';
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
 
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ const analytics = {
51
51
  identify,
52
52
  identifiers: {
53
53
  getFingerprintId: fingerprintService.getCachedFingerprint,
54
+ waitForFingerprintId: fingerprintService.waitForFingerprintId,
54
55
  getDistinctId: mixpanelService.getMixpanelDistinctId,
55
56
  },
56
57
  profiles: {
@@ -50,7 +50,7 @@ describe('botDetectionRelayService', () => {
50
50
 
51
51
  expect(fetchMock).toHaveBeenCalledWith(
52
52
  'https://data-lake.reservamossaas.com/api/v1/fingerprint/bot-detection',
53
- {
53
+ expect.objectContaining({
54
54
  method: 'POST',
55
55
  headers: {
56
56
  'Content-Type': 'application/json',
@@ -64,7 +64,8 @@ describe('botDetectionRelayService', () => {
64
64
  eventName: 'Fingerprint Requested',
65
65
  }),
66
66
  signal: expect.any(AbortSignal),
67
- },
67
+ keepalive: true,
68
+ }),
68
69
  );
69
70
  });
70
71