@reservamos/browser-analytics 1.1.6 → 1.1.7-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/package.json CHANGED
@@ -1,23 +1,19 @@
1
1
  {
2
2
  "name": "@reservamos/browser-analytics",
3
- "version": "1.1.6",
3
+ "version": "1.1.7-alpha.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/reservamos/reservamos-browser-analytics.git"
7
7
  },
8
- "main": "./dist/reservamos-browser-analytics.cjs",
9
- "module": "./dist/reservamos-browser-analytics.esm.js",
8
+ "main": "./dist/browser-analytics.cjs",
9
+ "module": "./dist/browser-analytics.esm.js",
10
10
  "exports": {
11
11
  ".": {
12
12
  "require": "./dist/browser-analytics.cjs",
13
13
  "import": "./dist/browser-analytics.esm.js",
14
14
  "types": "./dist/browser-analytics.d.ts"
15
15
  },
16
- "./dist/": {
17
- "import": "./dist/",
18
- "require": "./dist/",
19
- "types": "./dist/"
20
- }
16
+ "./dist/*": "./dist/*"
21
17
  },
22
18
  "bugs": {
23
19
  "url": "https://github.com/reservamos/reservamos-browser-analytics/issues"
@@ -32,7 +28,7 @@
32
28
  "package.json"
33
29
  ],
34
30
  "homepage": "https://github.com/reservamos/reservamos-browser-analytics#readme",
35
- "jsdelivr": "./dist/reservamos-browser-analytics.iife.js",
31
+ "jsdelivr": "./dist/browser-analytics.iife.js",
36
32
  "keywords": [
37
33
  "analytics",
38
34
  "browser"
@@ -49,21 +45,27 @@
49
45
  "build": "tsc && vite build",
50
46
  "postbuild": "dts-bundle-generator --config ./dts-bundle-generator.config.ts",
51
47
  "test": "vitest",
48
+ "test:run": "vitest --run",
52
49
  "test:coverage": "vitest --coverage",
53
50
  "format": "prettier . --write",
51
+ "format:check": "node scripts/check-format.mjs",
52
+ "lint": "eslint src/ scripts/",
53
+ "typecheck": "tsc --noEmit",
54
+ "validate": "./validate.sh",
55
+ "validate:full": "./validate.sh --full",
54
56
  "upgrade": "upgradeps",
55
57
  "release": "npm run build && np"
56
58
  },
57
59
  "type": "module",
58
- "types": "./dist/reservamos-browser-analytics.d.ts",
60
+ "types": "./dist/browser-analytics.d.ts",
59
61
  "typesVersions": {
60
62
  "*": {
61
63
  "*": [
62
- "./dist/reservamos-browser-analytics.d.ts"
64
+ "./dist/browser-analytics.d.ts"
63
65
  ]
64
66
  }
65
67
  },
66
- "typings": "./dist/reservamos-browser-analytics.d.ts",
68
+ "typings": "./dist/browser-analytics.d.ts",
67
69
  "dependencies": {
68
70
  "@fingerprintjs/fingerprintjs-pro-spa": "^1.3.3",
69
71
  "mixpanel-browser": "^2.55.1",
@@ -0,0 +1,220 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import identify from './identify';
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ config: vi.fn(),
6
+ coordinates: vi.fn(),
7
+ profile: vi.fn(),
8
+ ready: vi.fn(),
9
+ identify: vi.fn(),
10
+ attach: vi.fn(),
11
+ fingerprint: vi.fn(),
12
+ validate: vi.fn(),
13
+ error: vi.fn(),
14
+ }));
15
+ vi.mock('@/services/config', () => ({
16
+ default: { getCoreAPIConfig: mocks.config },
17
+ }));
18
+ vi.mock('@/services/geolocation', () => ({
19
+ default: { getCoordinates: mocks.coordinates },
20
+ }));
21
+ vi.mock('@/profiles/createAnonymousProfile/createAnonymousProfile', () => ({
22
+ createAnonymousProfileForIdentify: mocks.profile,
23
+ }));
24
+ vi.mock('@/services/mixpanel', () => ({
25
+ default: {
26
+ isReady: mocks.ready,
27
+ identify: mocks.identify,
28
+ attachProperty: mocks.attach,
29
+ },
30
+ }));
31
+ vi.mock('@/services/fingerprint', () => ({
32
+ getFingerprintForIdentify: mocks.fingerprint,
33
+ }));
34
+ vi.mock('@/services/validator', () => ({
35
+ default: { parseIdentifyProps: mocks.validate },
36
+ }));
37
+ vi.mock('@/track', () => ({ trackEventError: mocks.error }));
38
+
39
+ describe('identify bounded local attempt', () => {
40
+ const fetchMock = vi.fn();
41
+ beforeEach(() => {
42
+ vi.useFakeTimers();
43
+ Object.values(mocks).forEach((mock) => mock.mockReset());
44
+ fetchMock.mockReset().mockResolvedValue({ ok: true, status: 200 });
45
+ vi.stubGlobal('fetch', fetchMock);
46
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
47
+ mocks.ready.mockReturnValue(true);
48
+ mocks.profile.mockResolvedValue(undefined);
49
+ mocks.fingerprint.mockResolvedValue('');
50
+ mocks.coordinates.mockReturnValue(null);
51
+ mocks.config.mockReturnValue({
52
+ baseUrl: 'https://example.test/api',
53
+ defaultHeaders: { 'x-app-name': 'reservamos-web' },
54
+ });
55
+ });
56
+ afterEach(() => {
57
+ vi.clearAllTimers();
58
+ vi.useRealTimers();
59
+ vi.restoreAllMocks();
60
+ vi.unstubAllGlobals();
61
+ });
62
+
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
+ }),
69
+ );
70
+ let finished = false;
71
+ const pending = identify('user', { email: 'test@example.com' }).then(() => {
72
+ finished = true;
73
+ });
74
+ await vi.advanceTimersByTimeAsync(1);
75
+ expect(finished).toBe(false);
76
+ expect(mocks.identify).not.toHaveBeenCalled();
77
+ finish({ id: 'profile' });
78
+ await pending;
79
+ expect(mocks.identify).toHaveBeenCalledWith(
80
+ 'user',
81
+ expect.objectContaining({ reservamos_one_id: 'profile' }),
82
+ expect.any(Function),
83
+ );
84
+ expect(vi.getTimerCount()).toBe(0);
85
+ });
86
+
87
+ it('runs readiness once and removes its listener', async () => {
88
+ mocks.ready.mockReturnValue(false);
89
+ const remove = vi.spyOn(window, 'removeEventListener');
90
+ const pending = identify('user');
91
+ mocks.ready.mockReturnValue(true);
92
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
93
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
94
+ await pending;
95
+ expect(mocks.identify).toHaveBeenCalledTimes(1);
96
+ expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
97
+ expect(vi.getTimerCount()).toBe(0);
98
+ });
99
+
100
+ it('expires readiness without a diagnostic listener or late execution', async () => {
101
+ mocks.ready.mockReturnValue(false);
102
+ const add = vi.spyOn(window, 'addEventListener');
103
+ const pending = identify('private-id');
104
+ await vi.advanceTimersByTimeAsync(5000);
105
+ await pending;
106
+ mocks.ready.mockReturnValue(true);
107
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
108
+ expect(mocks.identify).not.toHaveBeenCalled();
109
+ expect(mocks.error).not.toHaveBeenCalled();
110
+ expect(
111
+ add.mock.calls.filter(([name]) => name === 'Tracker Ready'),
112
+ ).toHaveLength(1);
113
+ expect(console.error).toHaveBeenCalledWith('Identify diagnostic', {
114
+ stage: 'readiness',
115
+ reason: 'timeout',
116
+ });
117
+ expect(vi.getTimerCount()).toBe(0);
118
+ });
119
+
120
+ it('continues after profile timeout and ignores its late result', async () => {
121
+ let finish: (value: { id: string }) => void = () => undefined;
122
+ mocks.profile.mockReturnValue(
123
+ new Promise((resolve) => {
124
+ finish = resolve;
125
+ }),
126
+ );
127
+ const properties = { email: 'test@example.com' };
128
+ const pending = identify('user', properties);
129
+ await vi.advanceTimersByTimeAsync(3000);
130
+ await pending;
131
+ finish({ id: 'late-profile' });
132
+ await vi.advanceTimersByTimeAsync(0);
133
+ expect(mocks.identify).toHaveBeenCalledTimes(1);
134
+ expect(properties).not.toHaveProperty('reservamos_one_id');
135
+ expect(mocks.error).toHaveBeenCalledWith('Identify', expect.any(Error), {
136
+ stage: 'profile',
137
+ reason: 'timeout',
138
+ });
139
+ });
140
+
141
+ it('continues after profile rejection with controlled diagnostics', async () => {
142
+ mocks.profile.mockRejectedValue(new Error('private@example.com'));
143
+ await identify('private-id');
144
+ expect(mocks.identify).toHaveBeenCalledTimes(1);
145
+ expect(mocks.error).toHaveBeenCalledWith(
146
+ 'Identify',
147
+ expect.objectContaining({ message: 'Identify profile: failed' }),
148
+ { stage: 'profile', reason: 'failed' },
149
+ );
150
+ expect(console.error).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('expires fingerprint and consumes its late rejection', async () => {
154
+ let reject: (reason: Error) => void = () => undefined;
155
+ mocks.fingerprint.mockReturnValue(
156
+ new Promise((_resolve, fail) => {
157
+ reject = fail;
158
+ }),
159
+ );
160
+ const pending = identify('user');
161
+ await vi.advanceTimersByTimeAsync(2000);
162
+ await pending;
163
+ reject(new Error('private-id'));
164
+ await vi.advanceTimersByTimeAsync(0);
165
+ expect(mocks.attach).not.toHaveBeenCalled();
166
+ expect(mocks.error).toHaveBeenCalledTimes(1);
167
+ expect(vi.getTimerCount()).toBe(0);
168
+ });
169
+
170
+ it('starts geolocation without awaiting fetch and aborts at its deadline', async () => {
171
+ mocks.coordinates.mockReturnValue({ lat: 1, long: 2 });
172
+ fetchMock.mockReturnValue(new Promise(() => undefined));
173
+ await identify('user');
174
+ expect(fetchMock).toHaveBeenCalledWith(
175
+ 'https://example.test/api/v1/datalake/identify',
176
+ expect.objectContaining({
177
+ headers: expect.objectContaining({ 'x-app-name': 'reservamos-web' }),
178
+ }),
179
+ );
180
+ const signal = (fetchMock.mock.calls[0][1] as RequestInit).signal;
181
+ await vi.advanceTimersByTimeAsync(5000);
182
+ expect(signal?.aborted).toBe(true);
183
+ expect(mocks.error).toHaveBeenCalledWith('Identify', expect.any(Error), {
184
+ stage: 'datalake',
185
+ reason: 'timeout',
186
+ });
187
+ expect(vi.getTimerCount()).toBe(0);
188
+ });
189
+
190
+ it.each(['http', 'rejection'])(
191
+ 'observes datalake %s without personal data',
192
+ async (outcome) => {
193
+ mocks.coordinates.mockReturnValue({ lat: 1, long: 2 });
194
+ if (outcome === 'http')
195
+ fetchMock.mockResolvedValue({ ok: false, status: 500 });
196
+ else fetchMock.mockRejectedValue(new Error('private@example.com'));
197
+ await identify('private-id', { email: 'private@example.com' });
198
+ await vi.advanceTimersByTimeAsync(0);
199
+ expect(mocks.error).toHaveBeenCalledWith(
200
+ 'Identify',
201
+ expect.any(Error),
202
+ outcome === 'http'
203
+ ? { stage: 'datalake', reason: 'http_error', httpStatus: 500 }
204
+ : { stage: 'datalake', reason: 'failed' },
205
+ );
206
+ expect(JSON.stringify(mocks.error.mock.calls)).not.toContain('private');
207
+ expect(vi.getTimerCount()).toBe(0);
208
+ },
209
+ );
210
+
211
+ it('retains the geolocation finally attempt on validation failure', async () => {
212
+ mocks.validate.mockImplementation(() => {
213
+ throw new Error('private@example.com');
214
+ });
215
+ mocks.coordinates.mockReturnValue({ lat: 1, long: 2 });
216
+ await identify('user');
217
+ expect(mocks.identify).not.toHaveBeenCalled();
218
+ expect(fetchMock).toHaveBeenCalledTimes(1);
219
+ });
220
+ });
@@ -17,8 +17,8 @@ vi.mock('@/services/geolocation', () => ({
17
17
  },
18
18
  }));
19
19
 
20
- vi.mock('@/profiles/createAnonymousProfile', () => ({
21
- default: createAnonymousProfileMock,
20
+ vi.mock('@/profiles/createAnonymousProfile/createAnonymousProfile', () => ({
21
+ createAnonymousProfileForIdentify: createAnonymousProfileMock,
22
22
  }));
23
23
 
24
24
  vi.mock('@/services/mixpanel', () => ({
@@ -31,6 +31,7 @@ vi.mock('@/services/mixpanel', () => ({
31
31
  }));
32
32
 
33
33
  vi.mock('@/services/fingerprint', () => ({
34
+ getFingerprintForIdentify: vi.fn().mockResolvedValue(null),
34
35
  default: {
35
36
  getFingerprint: vi.fn().mockResolvedValue(null),
36
37
  },
@@ -63,8 +64,8 @@ describe('identify datalake request', () => {
63
64
  getCoreAPIConfigMock.mockReturnValue({
64
65
  baseUrl: 'https://data-lake.reservamossaas.com/api',
65
66
  defaultHeaders: {
66
- Authorization: 'Bearer test-token',
67
- Origin: 'https://example.com',
67
+ 'Authorization': 'Bearer test-token',
68
+ 'Origin': 'https://example.com',
68
69
  'x-app-name': 'reservamos-web',
69
70
  },
70
71
  });
@@ -74,6 +75,7 @@ describe('identify datalake request', () => {
74
75
 
75
76
  afterEach(() => {
76
77
  vi.restoreAllMocks();
78
+ vi.unstubAllGlobals();
77
79
  });
78
80
 
79
81
  it('sends x-app-name header on datalake identify request', async () => {
@@ -1,10 +1,16 @@
1
- import createAnonymousProfile from '@/profiles/createAnonymousProfile';
1
+ import type { IdentifyReporter } from '@/util/identifyAttempt';
2
+ import { createAnonymousProfileForIdentify } from '@/profiles/createAnonymousProfile/createAnonymousProfile';
2
3
  import configService from '@/services/config';
4
+ import { getFingerprintForIdentify } from '@/services/fingerprint';
3
5
  import geolocationService from '@/services/geolocation';
6
+ import mixpanelService from '@/services/mixpanel';
4
7
  import validatorService from '@/services/validator';
5
- import { trackEventError, tryTrackEvent } from '@/track';
6
- import fingerprintService from '../../services/fingerprint';
7
- import mixpanelService from '../../services/mixpanel';
8
+ import {
9
+ createIdentifyReporter,
10
+ observeIdentifyResult,
11
+ waitForIdentifyReady,
12
+ waitForIdentifyStage,
13
+ } from '@/util/identifyAttempt';
8
14
  import { DefaultProperties } from './identifySchema';
9
15
 
10
16
  const KNOWN_PROPERTIES = ['firstName', 'lastName', 'email', 'phone'];
@@ -51,7 +57,9 @@ function mapProperties(properties: UserProperties): Record<string, unknown> {
51
57
  const identifyWithLocation = async (
52
58
  userId: string,
53
59
  properties: UserProperties,
60
+ report: IdentifyReporter,
54
61
  ) => {
62
+ let observer: ReturnType<typeof observeIdentifyResult> | undefined;
55
63
  try {
56
64
  const coordinates = geolocationService.getCoordinates();
57
65
 
@@ -69,25 +77,38 @@ const identifyWithLocation = async (
69
77
  delete properties.lastName;
70
78
 
71
79
  const coreApiConfig = configService.getCoreAPIConfig();
72
- fetch(`${coreApiConfig.baseUrl}/v1/datalake/identify`, {
73
- method: 'POST',
74
- headers: {
75
- 'Content-Type': 'application/json',
76
- 'Authorization': coreApiConfig.defaultHeaders?.Authorization || '',
77
- 'Origin': coreApiConfig.defaultHeaders?.Origin || '',
78
- ...(coreApiConfig.defaultHeaders?.['x-app-name']
79
- ? { 'x-app-name': coreApiConfig.defaultHeaders['x-app-name'] }
80
- : {}),
81
- },
82
- body: JSON.stringify({
83
- profile_params: {
84
- ...properties,
85
- distinct_id: userId,
80
+ const controller = new AbortController();
81
+ observer = observeIdentifyResult('datalake', report, () =>
82
+ controller.abort(),
83
+ );
84
+ const response = await fetch(
85
+ `${coreApiConfig.baseUrl}/v1/datalake/identify`,
86
+ {
87
+ method: 'POST',
88
+ signal: controller.signal,
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ 'Authorization': coreApiConfig.defaultHeaders?.Authorization || '',
92
+ 'Origin': coreApiConfig.defaultHeaders?.Origin || '',
93
+ ...(coreApiConfig.defaultHeaders?.['x-app-name']
94
+ ? { 'x-app-name': coreApiConfig.defaultHeaders['x-app-name'] }
95
+ : {}),
86
96
  },
87
- }),
88
- });
89
- } catch (error) {
90
- console.error('Error identifying user-service', error);
97
+ body: JSON.stringify({
98
+ profile_params: {
99
+ ...properties,
100
+ distinct_id: userId,
101
+ },
102
+ }),
103
+ },
104
+ );
105
+ observer.complete(
106
+ response.ok ? undefined : 'http_error',
107
+ response.ok ? undefined : response.status,
108
+ );
109
+ } catch {
110
+ if (observer) observer.complete('failed');
111
+ else report('datalake', 'failed');
91
112
  }
92
113
  };
93
114
 
@@ -95,39 +116,60 @@ const identifyWithLocation = async (
95
116
  * Identifies a user with given properties and fingerprint.
96
117
  * @param {string} userId - The unique identifier for the user.
97
118
  * @param {UserProperties} properties - Optional user properties.
98
- * @throws {Error} If analytics service is not initialized or userId is missing.
99
- * @returns {Promise<void>}
119
+ * @returns {Promise<void>} Resolves after a bounded local attempt, not confirmed remote delivery.
100
120
  */
101
121
  async function identify(
102
122
  userId: string,
103
123
  properties: UserProperties = {},
104
124
  ): Promise<void> {
105
- tryTrackEvent(async () => {
125
+ const report = createIdentifyReporter();
126
+ if (!(await waitForIdentifyReady(report))) return;
127
+ try {
106
128
  try {
107
129
  validatorService.parseIdentifyProps(properties);
108
-
109
130
  if (!userId) {
110
- console.error('User ID is required for identification.');
111
- throw new Error('User ID is required for identification.');
131
+ report('validation', 'invalid_input');
132
+ return;
112
133
  }
113
- const anonymousProfile = await createAnonymousProfile(properties);
134
+ } catch {
135
+ report('validation', 'invalid_input');
136
+ return;
137
+ }
138
+ const anonymousProfile = await waitForIdentifyStage(
139
+ 'profile',
140
+ 3000,
141
+ (context) =>
142
+ createAnonymousProfileForIdentify({ ...properties }, context.report),
143
+ report,
144
+ );
114
145
 
115
- if (anonymousProfile) {
116
- properties.reservamos_one_id = anonymousProfile.id;
117
- }
118
- const mappedProps = mapProperties(properties);
119
- mixpanelService.identify(userId, mappedProps);
120
- const fingerprint = await fingerprintService.getFingerprint();
121
- if (fingerprint) {
146
+ if (anonymousProfile) {
147
+ properties.reservamos_one_id = anonymousProfile.id;
148
+ }
149
+ const mappedProps = mapProperties(properties);
150
+ try {
151
+ mixpanelService.identify(userId, mappedProps, report);
152
+ } catch {
153
+ report('mixpanel', 'failed');
154
+ }
155
+ const fingerprint = await waitForIdentifyStage(
156
+ 'fingerprint',
157
+ 2000,
158
+ getFingerprintForIdentify,
159
+ report,
160
+ );
161
+ if (fingerprint) {
162
+ try {
122
163
  mixpanelService.attachProperty(FINGERPRINT_PROPERTY, fingerprint);
164
+ } catch {
165
+ report('fingerprint', 'failed');
123
166
  }
124
- } catch (error) {
125
- console.error('Error identifying user', error);
126
- trackEventError('Identify', error, { userId, properties });
127
- } finally {
128
- identifyWithLocation(userId, properties);
129
167
  }
130
- });
168
+ } catch {
169
+ report('mixpanel', 'failed');
170
+ } finally {
171
+ void identifyWithLocation(userId, properties, report);
172
+ }
131
173
  }
132
174
 
133
175
  export default identify;
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import type { PurchaseConfirmationProps } from '@/events/purchaseConfirmation';
11
11
  import type { SearchProps } from '@/events/search';
12
12
  import type { SeatChangeProps } from '@/events/seatChange';
13
13
  import type { ViewResultsProps } from '@/events/viewResults';
14
+ import type { InitConfig } from '@/init';
14
15
  import type { CreateAnonymousProfileProps } from '@/profiles/createAnonymousProfile';
15
16
  import type { CreateFrequentPassengersSchemaProps } from '@/recommendations/createFrequentPassengers';
16
17
  import type { CreateRecommendedSeatsSchemaProps } from '@/recommendations/createRecommendedSeats';
@@ -103,6 +104,7 @@ export type {
103
104
  DeleteFrequentPassengerProps,
104
105
  GeolocationSelectedProps,
105
106
  PurchaseConfirmationProps,
107
+ InitConfig,
106
108
  };
107
109
 
108
110
  export default analytics;
@@ -0,0 +1,56 @@
1
+ import coreApi from '@reservamos/js-api-client/core';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import fingerprintService from '@/services/fingerprint';
4
+ import mixpanelService from '@/services/mixpanel';
5
+ import createAnonymousProfile, {
6
+ createAnonymousProfileForIdentify,
7
+ } from './createAnonymousProfile';
8
+
9
+ describe('anonymous profile internal identify path', () => {
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ it('propagates API failure without a raw log, while the public wrapper still returns undefined', async () => {
15
+ vi.spyOn(fingerprintService, 'getCachedFingerprint').mockReturnValue(null);
16
+ vi.spyOn(mixpanelService, 'getMixpanelDistinctId').mockReturnValue(null);
17
+ const error = new Error('private@example.com');
18
+ vi.spyOn(coreApi.profiles, 'createAnonymousProfile').mockRejectedValue(
19
+ error,
20
+ );
21
+ const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
22
+ await expect(
23
+ createAnonymousProfileForIdentify({ email: 'test@example.com' }, vi.fn()),
24
+ ).rejects.toBe(error);
25
+ expect(log).not.toHaveBeenCalled();
26
+ await expect(
27
+ createAnonymousProfile({ email: 'test@example.com' }),
28
+ ).resolves.toBeUndefined();
29
+ });
30
+
31
+ it('omits unreadable optional identifiers and still requests a profile', async () => {
32
+ const error = new Error('private-id');
33
+ vi.spyOn(fingerprintService, 'getCachedFingerprint').mockImplementation(
34
+ () => {
35
+ throw error;
36
+ },
37
+ );
38
+ vi.spyOn(mixpanelService, 'getMixpanelDistinctId').mockImplementation(
39
+ () => {
40
+ throw error;
41
+ },
42
+ );
43
+ const create = vi
44
+ .spyOn(coreApi.profiles, 'createAnonymousProfile')
45
+ .mockResolvedValue({ id: 'profile-123' });
46
+ const report = vi.fn();
47
+ await createAnonymousProfileForIdentify(
48
+ { email: 'test@example.com' },
49
+ report,
50
+ );
51
+ expect(create).toHaveBeenCalledWith(
52
+ expect.objectContaining({ identifiers: [] }),
53
+ );
54
+ expect(report).toHaveBeenCalledWith('cache', 'unavailable');
55
+ });
56
+ });
@@ -4,6 +4,7 @@ import type {
4
4
  CreateAnonymousProfilePayload,
5
5
  IdentifierKey,
6
6
  } from '@reservamos/js-api-client/core';
7
+ import type { IdentifyReporter } from '@/util/identifyAttempt';
7
8
  import type { CreateAnonymousProfileProps } from './createAnonymousProfileSchema';
8
9
  import coreApi from '@reservamos/js-api-client/core';
9
10
  import fingerprintService from '@/services/fingerprint';
@@ -67,22 +68,48 @@ async function createAnonymousProfile(
67
68
  payload: CreateAnonymousProfileProps,
68
69
  ): Promise<AnonymousProfile | undefined> {
69
70
  try {
70
- validatorService.validateProps(payload, CreateAnonymousProfileSchema);
71
-
72
- const identifiers: AnonymousIdentifier[] = [];
73
- const userFingerprintId = fingerprintService.getCachedFingerprint();
74
- const distinctId = mixpanelService.getMixpanelDistinctId();
75
-
76
- if (userFingerprintId)
77
- identifiers.push({ key: 'fingerprint', value: userFingerprintId });
78
- if (distinctId) identifiers.push({ key: 'distinct_id', value: distinctId });
79
-
80
- const dataPayload = getAnonymousProfilePayload(payload, identifiers);
81
- return await coreApi.profiles.createAnonymousProfile(dataPayload);
71
+ return await requestAnonymousProfile(payload);
82
72
  } catch (error) {
83
73
  console.error('Could not create anonymous profile:', error);
84
74
  return undefined;
85
75
  }
86
76
  }
87
77
 
78
+ /** Internal identify path: failures propagate to its bounded, sanitized observer. */
79
+ export function createAnonymousProfileForIdentify(
80
+ payload: CreateAnonymousProfileProps,
81
+ report: IdentifyReporter,
82
+ ): Promise<AnonymousProfile | undefined> {
83
+ return requestAnonymousProfile(payload, report);
84
+ }
85
+
86
+ async function requestAnonymousProfile(
87
+ payload: CreateAnonymousProfileProps,
88
+ report?: IdentifyReporter,
89
+ ): Promise<AnonymousProfile | undefined> {
90
+ validatorService.validateProps(payload, CreateAnonymousProfileSchema);
91
+
92
+ const identifiers: AnonymousIdentifier[] = [];
93
+ const readIdentifier = (read: () => string | null) => {
94
+ if (!report) return read();
95
+ try {
96
+ return read();
97
+ } catch {
98
+ report('cache', 'unavailable');
99
+ return null;
100
+ }
101
+ };
102
+ const userFingerprintId = readIdentifier(
103
+ fingerprintService.getCachedFingerprint,
104
+ );
105
+ const distinctId = readIdentifier(mixpanelService.getMixpanelDistinctId);
106
+
107
+ if (userFingerprintId)
108
+ identifiers.push({ key: 'fingerprint', value: userFingerprintId });
109
+ if (distinctId) identifiers.push({ key: 'distinct_id', value: distinctId });
110
+
111
+ const dataPayload = getAnonymousProfilePayload(payload, identifiers);
112
+ return await coreApi.profiles.createAnonymousProfile(dataPayload);
113
+ }
114
+
88
115
  export default createAnonymousProfile;