@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.
@@ -65,6 +65,24 @@ describe('fingerprintService', () => {
65
65
  vi.unstubAllGlobals();
66
66
  });
67
67
 
68
+ it.each([null, 'cached-visitor'])(
69
+ 'reads the default cache before fingerprint configuration exists: %s',
70
+ (visitorId) => {
71
+ Reflect.deleteProperty(window, 'fingerprintConfig');
72
+ if (visitorId) {
73
+ localStorage.setItem(
74
+ 'default_fingerprint_cache',
75
+ JSON.stringify({
76
+ fingerprint: visitorId,
77
+ expiresAt: Date.now() + 60_000,
78
+ }),
79
+ );
80
+ }
81
+ expect(fingerprintService.getCachedFingerprint()).toBe(visitorId);
82
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
83
+ },
84
+ );
85
+
68
86
  it('omits unavailable Visitor Found metadata without blocking identification', async () => {
69
87
  getVisitorDataMock.mockResolvedValue({
70
88
  ...visitorData,
@@ -77,6 +95,24 @@ describe('fingerprintService', () => {
77
95
  expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
78
96
  });
79
97
 
98
+ it('does not announce readiness when an invalidated agent finishes loading late', async () => {
99
+ let finish!: () => void;
100
+ initMock.mockReturnValue(
101
+ new Promise<void>((resolve) => {
102
+ finish = resolve;
103
+ }),
104
+ );
105
+ const notify = vi.fn();
106
+ const stop = fingerprintService.onReady(notify);
107
+ const pending = fingerprintService.initFingerprint('old-key');
108
+ fingerprintService.invalidate();
109
+ finish();
110
+ await pending;
111
+ expect(fingerprintService.isFingerprintReady()).toBe(false);
112
+ expect(notify).not.toHaveBeenCalled();
113
+ stop();
114
+ });
115
+
80
116
  it('suppresses identify-specific cache and profile effects after its scope closes', async () => {
81
117
  let finish: (value: typeof visitorData) => void = () => undefined;
82
118
  getVisitorDataMock.mockReturnValue(
@@ -5,6 +5,7 @@ import {
5
5
  FpjsClient,
6
6
  } from '@fingerprintjs/fingerprintjs-pro-spa';
7
7
  import mixpanelService from '@/services/mixpanel';
8
+ import trackingLifecycle from '@/services/trackingLifecycle';
8
9
 
9
10
  declare global {
10
11
  interface Window {
@@ -28,6 +29,22 @@ const PROXY_QUERY_PARAM =
28
29
  * "FPJSAgent hasn't loaded yet" until init completes.
29
30
  */
30
31
  let fingerprintAgentReady = false;
32
+ let initialization = 0;
33
+ const readinessSubscribers = new Set<() => void>();
34
+
35
+ /** Notifies deferred observations only when the actual provider agent has loaded. */
36
+ function onReady(notify: () => void): () => void {
37
+ readinessSubscribers.add(notify);
38
+ return () => {
39
+ readinessSubscribers.delete(notify);
40
+ };
41
+ }
42
+
43
+ /** Prevents a previous configuration or late agent load from serving a new init. */
44
+ function invalidate(): void {
45
+ initialization++;
46
+ fingerprintAgentReady = false;
47
+ }
31
48
 
32
49
  /**
33
50
  * Calculates the expiration date based on the cache time.
@@ -51,9 +68,8 @@ const getCachedFingerprint = (): string | null => {
51
68
  };
52
69
 
53
70
  function readCachedFingerprint(context?: IdentifyStageContext): string | null {
54
- const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig;
55
-
56
71
  try {
72
+ const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig ?? {};
57
73
  const cachedData = JSON.parse(localStorage.getItem(cacheName) || 'null');
58
74
  if (cachedData && cachedData.expiresAt > Date.now()) {
59
75
  return cachedData.fingerprint;
@@ -117,7 +133,8 @@ async function initFingerprint(
117
133
  customScriptEndpointArray.unshift(buildProxyUrl(proxyUrl));
118
134
  }
119
135
 
120
- fingerprintAgentReady = false;
136
+ invalidate();
137
+ const attempt = initialization;
121
138
  window.fingerprintConfig = {};
122
139
  const client = new FpjsClient({
123
140
  loadOptions: {
@@ -130,11 +147,12 @@ async function initFingerprint(
130
147
 
131
148
  try {
132
149
  await client.init();
133
- if (window.fpClient === client) {
150
+ if (window.fpClient === client && attempt === initialization) {
134
151
  fingerprintAgentReady = true;
152
+ readinessSubscribers.forEach((notify) => notify());
135
153
  }
136
154
  } catch (error) {
137
- if (window.fpClient === client) {
155
+ if (window.fpClient === client && attempt === initialization) {
138
156
  fingerprintAgentReady = false;
139
157
  }
140
158
  console.error('Error initializing identification service:', error);
@@ -149,8 +167,11 @@ async function initFingerprint(
149
167
  * @returns {Promise<string>} A promise that resolves with the fingerprint, or an empty string when unavailable.
150
168
  * @throws {Error} Throws an error if retrieval fails after the agent has loaded.
151
169
  */
152
- async function getFingerprint(cacheOnly = false): Promise<string> {
153
- return readFingerprint(cacheOnly);
170
+ async function getFingerprint(
171
+ cacheOnly = false,
172
+ context?: IdentifyStageContext,
173
+ ): Promise<string> {
174
+ return readFingerprint(cacheOnly, context);
154
175
  }
155
176
 
156
177
  /** Internal identify path with late-effect suppression and sanitized diagnostics. */
@@ -164,20 +185,12 @@ async function readFingerprint(
164
185
  cacheOnly: boolean,
165
186
  context?: IdentifyStageContext,
166
187
  ): Promise<string> {
167
- if (!window.fingerprintConfig) {
168
- if (cacheOnly) {
169
- return '';
170
- }
171
-
172
- throw new Error('Fingerprint configuration is not initialized.');
173
- }
174
-
175
188
  const cachedFingerprint = readCachedFingerprint(context);
176
189
  if (cachedFingerprint) {
177
190
  return cachedFingerprint;
178
191
  }
179
192
 
180
- if (cacheOnly || !isFingerprintReady()) {
193
+ if (cacheOnly || !window.fingerprintConfig || !isFingerprintReady()) {
181
194
  return '';
182
195
  }
183
196
 
@@ -193,8 +206,9 @@ async function readFingerprint(
193
206
  */
194
207
  async function identifyVisitor(
195
208
  ignoreCache = false,
209
+ context?: IdentifyStageContext,
196
210
  ): Promise<FingerprintIdentification> {
197
- return requestIdentification(ignoreCache);
211
+ return requestIdentification(ignoreCache, context);
198
212
  }
199
213
 
200
214
  async function requestIdentification(
@@ -210,9 +224,8 @@ async function requestIdentification(
210
224
  if (!context || context.isActive()) {
211
225
  try {
212
226
  setCachedFingerprint(result.visitorId);
213
- } catch (error) {
214
- if (!context) throw error;
215
- context.report('cache', 'unavailable');
227
+ } catch {
228
+ context?.report('cache', 'unavailable');
216
229
  }
217
230
  if (typeof result.visitorFound === 'boolean') {
218
231
  try {
@@ -254,7 +267,12 @@ const fingerprintService = {
254
267
  getFingerprint,
255
268
  identifyVisitor,
256
269
  isFingerprintReady,
270
+ onReady,
271
+ invalidate,
257
272
  getCachedFingerprint,
258
273
  };
259
274
 
275
+ // One module-lifetime listener invalidates the old agent before Mixpanel signals readiness.
276
+ trackingLifecycle.onInitialization(invalidate);
277
+
260
278
  export default fingerprintService;
@@ -9,6 +9,7 @@ import {
9
9
  it,
10
10
  vi,
11
11
  } from 'vitest';
12
+ import { trackEvent } from '@/track';
12
13
  import { version } from '../../package.json';
13
14
  import mixpanelService from './mixpanel';
14
15
 
@@ -33,6 +34,7 @@ describe('Mixpanel SDK version metadata', () => {
33
34
 
34
35
  afterEach(() => {
35
36
  vi.restoreAllMocks();
37
+ vi.unstubAllGlobals();
36
38
  vi.clearAllTimers();
37
39
  vi.useRealTimers();
38
40
  });
@@ -86,6 +88,21 @@ describe('Mixpanel SDK version metadata', () => {
86
88
  });
87
89
  });
88
90
 
91
+ it('completes a repeated init with the installed singleton provider without waiting for another loaded callback', async () => {
92
+ const complete = vi.fn();
93
+ void mixpanelService.init('test-sdk-version-token').then(complete);
94
+ await Promise.resolve();
95
+ expect(complete).toHaveBeenCalledOnce();
96
+ expect(mixpanelService.isReady()).toBe(true);
97
+ });
98
+
99
+ it('rejects a different project before disturbing the existing provider session', () => {
100
+ expect(() => mixpanelService.init('different-project')).toThrow(
101
+ 'new page session',
102
+ );
103
+ expect(mixpanelService.isReady()).toBe(true);
104
+ });
105
+
89
106
  it('still sets people properties when the SDK distinct ID cache is blocked', () => {
90
107
  vi.spyOn(transport, '_track_or_batch').mockImplementation(
91
108
  ({ data }) => data,
@@ -100,6 +117,92 @@ describe('Mixpanel SDK version metadata', () => {
100
117
  expect(people).toHaveBeenCalledWith({});
101
118
  });
102
119
 
120
+ it.each(['getItem', 'setItem'] as const)(
121
+ 'keeps the live distinct ID when SDK storage %s fails',
122
+ (method) => {
123
+ vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('live-id');
124
+ vi.spyOn(Storage.prototype, method).mockImplementation(() => {
125
+ throw new DOMException('Blocked', 'SecurityError');
126
+ });
127
+ expect(mixpanelService.getMixpanelDistinctId()).toBe('live-id');
128
+ },
129
+ );
130
+
131
+ it.each(['getItem', 'setItem'] as const)(
132
+ 'submits the original event when SDK storage %s is blocked',
133
+ 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();
139
+ vi.spyOn(Storage.prototype, method).mockImplementation(() => {
140
+ throw new DOMException('Blocked', 'SecurityError');
141
+ });
142
+ 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',
148
+ }),
149
+ });
150
+ },
151
+ );
152
+
153
+ it('returns null when no live distinct ID is available and the cache is blocked', () => {
154
+ vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
155
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
156
+ throw new DOMException('Blocked', 'SecurityError');
157
+ });
158
+ expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
159
+ });
160
+
161
+ it('uses the live distinct ID first and null fallback when localStorage is null', () => {
162
+ vi.stubGlobal('localStorage', null);
163
+ const liveId = vi
164
+ .spyOn(mixpanel, 'get_distinct_id')
165
+ .mockReturnValue('live-id');
166
+ expect(mixpanelService.getMixpanelDistinctId()).toBe('live-id');
167
+ liveId.mockReturnValue('');
168
+ expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
169
+ });
170
+
171
+ it('preserves identify diagnostics and people updates when localStorage is null', () => {
172
+ vi.useFakeTimers();
173
+ vi.stubGlobal('localStorage', null);
174
+ vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
175
+ const people = vi
176
+ .spyOn(mixpanel.people, 'set')
177
+ .mockImplementation(() => undefined);
178
+ const report = vi.fn();
179
+ mixpanelService.identify('null-storage-user', { plan: 'standard' }, report);
180
+ expect(report).toHaveBeenCalledWith('cache', 'unavailable');
181
+ expect(people).toHaveBeenCalledWith(
182
+ { plan: 'standard' },
183
+ expect.any(Function),
184
+ );
185
+ });
186
+
187
+ it('uses the cached distinct ID when Mixpanel has no live ID', () => {
188
+ localStorage.setItem('mp_distinct_id', 'cached-id');
189
+ vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
190
+ expect(mixpanelService.getMixpanelDistinctId()).toBe('cached-id');
191
+ });
192
+
193
+ it('preserves identify cache diagnostics and people updates on a write failure', () => {
194
+ vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
195
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
196
+ throw new DOMException('Blocked', 'SecurityError');
197
+ });
198
+ vi.spyOn(mixpanel.people, 'set').mockImplementation(() => undefined);
199
+ vi.useFakeTimers();
200
+ const report = vi.fn();
201
+ mixpanelService.identify('user-id', {}, report);
202
+ expect(report).toHaveBeenCalledWith('cache', 'unavailable');
203
+ expect(mixpanel.people.set).toHaveBeenCalled();
204
+ });
205
+
103
206
  it.each([
104
207
  1,
105
208
  0,
@@ -1,5 +1,6 @@
1
1
  import type { IdentifyReporter } from '@/util/identifyAttempt';
2
2
  import mixpanel from 'mixpanel-browser';
3
+ import trackingLifecycle from '@/services/trackingLifecycle';
3
4
  import { observeIdentifyResult } from '@/util/identifyAttempt';
4
5
  import { version } from '../../package.json';
5
6
 
@@ -11,6 +12,19 @@ declare global {
11
12
 
12
13
  const MIXPANEL_DISTINCT_ID_CACHE_KEY = 'mp_distinct_id';
13
14
 
15
+ const readinessSubscribers = new Set<() => void>();
16
+ let initializing = false;
17
+ let initialization = 0;
18
+ let initializedConfig: { token: string; proxyUrl?: string } | undefined;
19
+
20
+ /** Subscribes to internal Mixpanel readiness; callers own unsubscription. */
21
+ function onReady(callback: () => void): () => void {
22
+ readinessSubscribers.add(callback);
23
+ return () => {
24
+ readinessSubscribers.delete(callback);
25
+ };
26
+ }
27
+
14
28
  /**
15
29
  * SDK metadata sent on every Mixpanel event, including native identification events.
16
30
  */
@@ -31,12 +45,30 @@ function init(
31
45
  debug = false,
32
46
  proxyUrl?: string,
33
47
  ): Promise<void> {
48
+ validateConfiguration(mixpanelToken, proxyUrl);
49
+ const attempt = ++initialization;
50
+ initializing = true;
51
+ trackingLifecycle.beginInitialization();
52
+ // The installed singleton does not call loaded again after its first init.
53
+ if (initializedConfig && window.mixpanel === mixpanel) {
54
+ mixpanel.set_config({ debug });
55
+ initializing = false;
56
+ readinessSubscribers.forEach((notify) => notify());
57
+ return Promise.resolve();
58
+ }
34
59
  return new Promise<void>((resolve) => {
35
60
  mixpanel.init(mixpanelToken, {
36
61
  debug,
37
62
  loaded: () => {
63
+ if (attempt !== initialization) {
64
+ resolve();
65
+ return;
66
+ }
38
67
  window.mixpanel = mixpanel;
39
68
  mixpanel.register({ ...SDK_SUPER_PROPERTIES });
69
+ initializedConfig = { token: mixpanelToken, proxyUrl };
70
+ initializing = false;
71
+ readinessSubscribers.forEach((notify) => notify());
40
72
  resolve();
41
73
  },
42
74
  ...(proxyUrl && { api_host: proxyUrl }),
@@ -44,6 +76,20 @@ function init(
44
76
  });
45
77
  }
46
78
 
79
+ /** Rejects unsupported singleton reconfiguration before changing the active session. */
80
+ function validateConfiguration(token: string, proxyUrl?: string): void {
81
+ if (
82
+ initializedConfig &&
83
+ window.mixpanel === mixpanel &&
84
+ (initializedConfig.token !== token ||
85
+ initializedConfig.proxyUrl !== proxyUrl)
86
+ ) {
87
+ throw new Error(
88
+ 'Changing the Mixpanel project or proxy requires a new page session.',
89
+ );
90
+ }
91
+ }
92
+
47
93
  /**
48
94
  * Identifies a user in Mixpanel and sets their properties.
49
95
  * @param {string} userId - The unique identifier for the user.
@@ -56,12 +102,9 @@ function identify(
56
102
  properties: Record<string, unknown>,
57
103
  report?: IdentifyReporter,
58
104
  ): void {
105
+ trackingLifecycle.identify(userId);
59
106
  mixpanel.identify(userId);
60
- try {
61
- updateCachedDistinctId(userId);
62
- } catch {
63
- report?.('cache', 'unavailable');
64
- }
107
+ if (!updateCachedDistinctId(userId)) report?.('cache', 'unavailable');
65
108
  if (!report) {
66
109
  mixpanel.people.set(properties);
67
110
  return;
@@ -112,7 +155,7 @@ function setPeopleProperties(properties: Record<string, unknown>): void {
112
155
  * @returns {boolean} Returns true if the Mixpanel tracker is ready, otherwise false.
113
156
  */
114
157
  function isReady(): boolean {
115
- return window.mixpanel !== undefined;
158
+ return !initializing && window.mixpanel !== undefined;
116
159
  }
117
160
  /**
118
161
  * Tracks an event in Mixpanel with the given name and properties.
@@ -130,37 +173,43 @@ function track(eventName: string, properties: Record<string, unknown>): void {
130
173
  }
131
174
 
132
175
  /**
133
- * Retrieves the distinct ID from Mixpanel.
134
- * This function checks if Mixpanel is initialized and returns the current user's
135
- * distinct ID. If Mixpanel is not initialized, it returns null.
136
- * @returns {string | null} The distinct ID if available, or null if Mixpanel is not initialized.
176
+ * Retrieves the live Mixpanel distinct ID first, caching it on a best-effort basis.
177
+ * Falls back to the SDK cache only when no live ID is available.
178
+ * @returns {string | null} The live or cached distinct ID, or null when neither is available.
137
179
  */
138
180
  export const getMixpanelDistinctId = (): string | null => {
139
- const cachedId = localStorage.getItem(MIXPANEL_DISTINCT_ID_CACHE_KEY);
140
-
141
- if (mixpanel && mixpanel.get_distinct_id) {
142
- const distinctId = mixpanel.get_distinct_id();
143
-
181
+ const distinctId = mixpanel?.get_distinct_id?.();
182
+ if (distinctId) {
144
183
  updateCachedDistinctId(distinctId);
145
-
146
184
  return distinctId;
147
- } else if (cachedId) {
148
- return cachedId;
149
185
  }
150
- return null;
186
+
187
+ try {
188
+ return localStorage.getItem(MIXPANEL_DISTINCT_ID_CACHE_KEY);
189
+ } catch {
190
+ return null;
191
+ }
151
192
  };
152
193
  /**
153
194
  * Updates the cached Mixpanel distinct ID.
154
195
  * Should be called when identifying a user to ensure cache stays in sync.
155
196
  * @param {string} distinctId - The distinct ID to cache
197
+ * @returns {boolean} Whether the cache write succeeded.
156
198
  */
157
- function updateCachedDistinctId(distinctId: string): void {
158
- localStorage.setItem(MIXPANEL_DISTINCT_ID_CACHE_KEY, distinctId);
199
+ function updateCachedDistinctId(distinctId: string): boolean {
200
+ try {
201
+ localStorage.setItem(MIXPANEL_DISTINCT_ID_CACHE_KEY, distinctId);
202
+ return true;
203
+ } catch {
204
+ return false;
205
+ }
159
206
  }
160
207
 
161
208
  const mixpanelService = {
162
209
  init,
210
+ validateConfiguration,
163
211
  isReady,
212
+ onReady,
164
213
  track,
165
214
  identify,
166
215
  attachProperty,
@@ -0,0 +1,54 @@
1
+ let generation = 0;
2
+ let initialized = false;
3
+ let userId: string | undefined;
4
+ const subscribers = new Set<() => void>();
5
+ const initializationSubscribers = new Set<() => void>();
6
+
7
+ function invalidate(): void {
8
+ generation++;
9
+ subscribers.forEach((notify) => notify());
10
+ }
11
+
12
+ /** Pre-init events may join the first initialization; later configurations replace them. */
13
+ function beginInitialization(): void {
14
+ if (initialized) invalidate();
15
+ initialized = true;
16
+ userId = undefined;
17
+ initializationSubscribers.forEach((notify) => notify());
18
+ }
19
+
20
+ /** Invalidates retained work before the provider changes the active user. */
21
+ function identify(nextUserId: string): void {
22
+ if (userId !== nextUserId) {
23
+ userId = nextUserId;
24
+ invalidate();
25
+ }
26
+ }
27
+
28
+ /** Captures a predicate shared by waiting work and late provider effects. */
29
+ function capture(): () => boolean {
30
+ const captured = generation;
31
+ return () => captured === generation;
32
+ }
33
+
34
+ function onChange(notify: () => void): () => void {
35
+ subscribers.add(notify);
36
+ return () => {
37
+ subscribers.delete(notify);
38
+ };
39
+ }
40
+
41
+ function onInitialization(notify: () => void): () => void {
42
+ initializationSubscribers.add(notify);
43
+ return () => {
44
+ initializationSubscribers.delete(notify);
45
+ };
46
+ }
47
+
48
+ export default {
49
+ beginInitialization,
50
+ identify,
51
+ capture,
52
+ onChange,
53
+ onInitialization,
54
+ };
@@ -36,6 +36,85 @@ describe('InitConfigSchema', () => {
36
36
  });
37
37
 
38
38
  describe('parseEventProps', () => {
39
+ const checkout = {
40
+ 'Trips': [
41
+ {
42
+ 'Departure Arrival': '2026-11-01T12:00:00',
43
+ 'Departure Destination': 'Puebla',
44
+ 'Departure Destination Terminal': 'CAPU',
45
+ 'Departure Line': 'Bus line',
46
+ 'Departure Origin': 'Mexico City',
47
+ 'Departure Origin Terminal': 'TAPO',
48
+ 'Departure Price': 100,
49
+ 'Departure Route': 'Mexico City-Puebla',
50
+ 'Departure Stops': 0,
51
+ 'Departure Time': '2026-11-01T10:00:00',
52
+ 'Departure Transport Type': 'bus',
53
+ 'Departure Transporter': 'Carrier',
54
+ 'Recommended Trip': false,
55
+ },
56
+ ],
57
+ 'Passenger Count': 1,
58
+ 'Trip Count': 1,
59
+ 'Total': 100,
60
+ 'product': 'web',
61
+ };
62
+
63
+ describe.each(['Purchase Attempt', 'Payment Attempt'])(
64
+ '%s stops',
65
+ (eventName) => {
66
+ const properties = {
67
+ ...checkout,
68
+ ...(eventName === 'Payment Attempt' && {
69
+ 'Payment Type': 'credit_card',
70
+ }),
71
+ };
72
+
73
+ it.each([0, 1, 2])('accepts %i departure and return stops', (stops) => {
74
+ expect(() =>
75
+ validatorService.parseEventProps(eventName, {
76
+ ...properties,
77
+ Trips: [
78
+ {
79
+ ...checkout.Trips[0],
80
+ 'Departure Stops': stops,
81
+ 'Return Stops': stops,
82
+ },
83
+ ],
84
+ }),
85
+ ).not.toThrow();
86
+ });
87
+
88
+ it.each(['Departure Stops', 'Return Stops'])(
89
+ 'rejects invalid %s without coercion',
90
+ (field) => {
91
+ for (const stops of [0.5, '0', null, NaN]) {
92
+ expect(() =>
93
+ validatorService.parseEventProps(eventName, {
94
+ ...properties,
95
+ Trips: [{ ...checkout.Trips[0], [field]: stops }],
96
+ }),
97
+ ).toThrow();
98
+ }
99
+ },
100
+ );
101
+
102
+ it('requires departure stops and keeps return stops optional', () => {
103
+ const { 'Departure Stops': omitted, ...trip } = checkout.Trips[0];
104
+ expect(omitted).toBe(0);
105
+ expect(() =>
106
+ validatorService.parseEventProps(eventName, {
107
+ ...properties,
108
+ Trips: [trip],
109
+ }),
110
+ ).toThrow();
111
+ expect(() =>
112
+ validatorService.parseEventProps(eventName, properties),
113
+ ).not.toThrow();
114
+ });
115
+ },
116
+ );
117
+
39
118
  /**
40
119
  * This test supposes that unknownEvent is not a known event.
41
120
  * And that the customEventSchema does not support objects as values for a field.