@reservamos/browser-analytics 1.1.7-alpha.1 → 1.1.8-alpha.0

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.7-alpha.1",
3
+ "version": "1.1.8-alpha.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/reservamos/reservamos-browser-analytics.git"
@@ -54,7 +54,7 @@
54
54
  "validate": "./validate.sh",
55
55
  "validate:full": "./validate.sh --full",
56
56
  "upgrade": "upgradeps",
57
- "release": "npm run build && np"
57
+ "release": "gh workflow run publish.yml --ref main"
58
58
  },
59
59
  "type": "module",
60
60
  "types": "./dist/browser-analytics.d.ts",
@@ -74,6 +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
78
  "@types/eslint__js": "^8.42.3",
78
79
  "@types/jsdom": "^21.1.6",
79
80
  "@types/mixpanel-browser": "^2.50.0",
@@ -93,7 +94,6 @@
93
94
  "typescript-eslint": "^8.4.0",
94
95
  "upgradeps": "^2.0.6",
95
96
  "vite": "^5.0.12",
96
- "vitest": "^1.2.1",
97
- "@reservamos/js-api-client": "6.0.1"
97
+ "vitest": "^1.2.1"
98
98
  }
99
99
  }
@@ -0,0 +1,287 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import createAnonymousProfile from '@/profiles/createAnonymousProfile/createAnonymousProfile';
3
+ import { trackEvent } from '@/track';
4
+ import init from './init';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ mixpanel: vi.fn(),
8
+ fingerprint: vi.fn(),
9
+ geolocation: vi.fn(),
10
+ validate: vi.fn(),
11
+ environment: vi.fn(),
12
+ appName: vi.fn(),
13
+ config: vi.fn(),
14
+ core: vi.fn(),
15
+ distinctId: vi.fn(),
16
+ profile: vi.fn(),
17
+ track: vi.fn(),
18
+ ready: vi.fn(),
19
+ }));
20
+ vi.mock('@/services/mixpanel', () => ({
21
+ default: {
22
+ init: mocks.mixpanel,
23
+ getMixpanelDistinctId: mocks.distinctId,
24
+ isReady: mocks.ready,
25
+ track: mocks.track,
26
+ },
27
+ }));
28
+ vi.mock('@/services/fingerprint', async (importOriginal) => {
29
+ const actual =
30
+ await importOriginal<typeof import('@/services/fingerprint')>();
31
+ return {
32
+ ...actual,
33
+ default: { ...actual.default, initFingerprint: mocks.fingerprint },
34
+ };
35
+ });
36
+ vi.mock('@/services/geolocation', () => ({
37
+ default: {
38
+ init: mocks.geolocation,
39
+ getCoordinates: vi.fn().mockReturnValue(null),
40
+ getGeolocationSource: vi.fn().mockReturnValue(null),
41
+ },
42
+ }));
43
+ vi.mock('@/services/validator', async (importOriginal) => {
44
+ const actual = await importOriginal<typeof import('@/services/validator')>();
45
+ return {
46
+ ...actual,
47
+ default: { ...actual.default, parseInitProps: mocks.validate },
48
+ };
49
+ });
50
+ vi.mock('@/services/config', () => ({
51
+ default: {
52
+ setEnvironment: mocks.environment,
53
+ setAppName: mocks.appName,
54
+ getCoreAPIConfig: mocks.config,
55
+ },
56
+ }));
57
+ vi.mock('@reservamos/js-api-client/core', () => ({
58
+ default: {
59
+ setConfig: mocks.core,
60
+ profiles: { createAnonymousProfile: mocks.profile },
61
+ },
62
+ }));
63
+
64
+ const config = {
65
+ mixpanelToken: 'test-token',
66
+ identificationKey: 'test-key',
67
+ appName: 'test-app',
68
+ isSandbox: true,
69
+ };
70
+
71
+ describe('init optional enrichment', () => {
72
+ beforeEach(() => {
73
+ vi.useFakeTimers();
74
+ Object.values(mocks).forEach((mock) => mock.mockReset());
75
+ localStorage.clear();
76
+ Reflect.deleteProperty(window, 'fingerprintConfig');
77
+ mocks.distinctId.mockReturnValue('live-id');
78
+ mocks.ready.mockReturnValue(true);
79
+ mocks.profile.mockResolvedValue({ id: 'profile-id' });
80
+ mocks.mixpanel.mockResolvedValue(undefined);
81
+ mocks.fingerprint.mockResolvedValue(undefined);
82
+ mocks.geolocation.mockResolvedValue(null);
83
+ mocks.config.mockReturnValue({ baseUrl: 'https://example.test' });
84
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
85
+ });
86
+ afterEach(() => {
87
+ vi.clearAllTimers();
88
+ vi.useRealTimers();
89
+ vi.restoreAllMocks();
90
+ });
91
+
92
+ it('configures Core API and emits readiness while both optional stages are pending', async () => {
93
+ mocks.fingerprint.mockReturnValue(new Promise(() => undefined));
94
+ mocks.geolocation.mockReturnValue(new Promise(() => undefined));
95
+ const dispatch = vi
96
+ .spyOn(window, 'dispatchEvent')
97
+ .mockImplementation((event) => {
98
+ if (event.type === 'Tracker Ready') {
99
+ expect(mocks.environment).toHaveBeenCalledWith('sandbox');
100
+ expect(mocks.appName).toHaveBeenCalledWith('test-app');
101
+ expect(mocks.core).toHaveBeenCalledWith({
102
+ baseUrl: 'https://example.test',
103
+ });
104
+ }
105
+ return true;
106
+ });
107
+ const completed = vi.fn();
108
+ const pending = init(config).then(completed);
109
+ await vi.advanceTimersByTimeAsync(0);
110
+ expect(dispatch).toHaveBeenCalledWith(
111
+ expect.objectContaining({ type: 'Tracker Ready' }),
112
+ );
113
+ expect(mocks.fingerprint).toHaveBeenCalledOnce();
114
+ expect(mocks.geolocation).toHaveBeenCalledOnce();
115
+ expect(completed).not.toHaveBeenCalled();
116
+ await vi.advanceTimersByTimeAsync(3000);
117
+ await pending;
118
+ expect(completed).toHaveBeenCalledOnce();
119
+ expect(console.error).toHaveBeenCalledWith(
120
+ 'SDK initialization diagnostic',
121
+ { stage: 'fingerprint', reason: 'timeout' },
122
+ );
123
+ expect(console.error).toHaveBeenCalledWith(
124
+ 'SDK initialization diagnostic',
125
+ { stage: 'geolocation', reason: 'timeout' },
126
+ );
127
+ expect(vi.getTimerCount()).toBe(0);
128
+ });
129
+
130
+ it.each([null, 'cached-visitor'])(
131
+ 'supports profile calls from readiness before fingerprint init with cache %s',
132
+ async (cachedId) => {
133
+ if (cachedId) {
134
+ localStorage.setItem(
135
+ 'default_fingerprint_cache',
136
+ JSON.stringify({
137
+ fingerprint: cachedId,
138
+ expiresAt: Date.now() + 60_000,
139
+ }),
140
+ );
141
+ }
142
+ let outcome: Promise<unknown> | undefined;
143
+ window.addEventListener(
144
+ 'Tracker Ready',
145
+ () => {
146
+ outcome = createAnonymousProfile({ email: 'test@example.com' }).then(
147
+ (response) => ({ response }),
148
+ (error: unknown) => ({ error }),
149
+ );
150
+ },
151
+ { once: true },
152
+ );
153
+ await init(config);
154
+ await expect(outcome).resolves.toEqual({
155
+ response: { id: 'profile-id' },
156
+ });
157
+ expect(mocks.profile).toHaveBeenCalledWith({
158
+ identifier_key: 'email',
159
+ identifier_value: 'test@example.com',
160
+ details: {},
161
+ identifiers: [
162
+ ...(cachedId ? [{ key: 'fingerprint', value: cachedId }] : []),
163
+ { key: 'distinct_id', value: 'live-id' },
164
+ ],
165
+ });
166
+ },
167
+ );
168
+
169
+ describe.each(['Search', 'View Results'])(
170
+ '%s from early readiness',
171
+ (eventName) => {
172
+ it.each([null, 'cached-visitor'])(
173
+ 'submits before Fingerprint config with cache %s',
174
+ async (visitorId) => {
175
+ if (visitorId)
176
+ localStorage.setItem(
177
+ 'default_fingerprint_cache',
178
+ JSON.stringify({
179
+ fingerprint: visitorId,
180
+ expiresAt: Date.now() + 60_000,
181
+ }),
182
+ );
183
+ const properties = {
184
+ 'Departure': '2026-10-14',
185
+ 'Departure Delta': 0,
186
+ 'Origin': 'A',
187
+ 'Origin Terminal': 'A',
188
+ 'Destination': 'B',
189
+ 'Destination Terminal': 'B',
190
+ 'Route': 'A-B',
191
+ 'Passengers': 1,
192
+ 'Bus count': 1,
193
+ 'product': 'web',
194
+ };
195
+ let tracked: Promise<void> | undefined;
196
+ window.addEventListener(
197
+ 'Tracker Ready',
198
+ () => {
199
+ tracked = trackEvent(eventName, properties);
200
+ },
201
+ { once: true },
202
+ );
203
+ await init(config);
204
+ await tracked;
205
+ expect(mocks.track).toHaveBeenCalledTimes(1);
206
+ expect(mocks.track).toHaveBeenCalledWith(
207
+ eventName,
208
+ expect.objectContaining({
209
+ 'User Fingerprint': visitorId ?? '',
210
+ 'Route': 'A-B',
211
+ }),
212
+ );
213
+ },
214
+ );
215
+ },
216
+ );
217
+
218
+ it('waits for required Mixpanel initialization before configuring or signaling readiness', async () => {
219
+ let finishMixpanel!: () => void;
220
+ mocks.mixpanel.mockReturnValue(
221
+ new Promise<void>((resolve) => {
222
+ finishMixpanel = resolve;
223
+ }),
224
+ );
225
+ const dispatch = vi.spyOn(window, 'dispatchEvent');
226
+ const pending = init(config);
227
+ await vi.advanceTimersByTimeAsync(0);
228
+ expect(mocks.core).not.toHaveBeenCalled();
229
+ expect(dispatch).not.toHaveBeenCalled();
230
+ finishMixpanel();
231
+ await pending;
232
+ expect(dispatch).toHaveBeenCalledOnce();
233
+ expect(vi.getTimerCount()).toBe(0);
234
+ });
235
+
236
+ it.each(['fingerprint', 'geolocation'] as const)(
237
+ 'continues after %s rejects without leaking raw errors',
238
+ async (stage) => {
239
+ mocks[stage].mockRejectedValue(new Error('private-stage-error'));
240
+ const dispatch = vi.spyOn(window, 'dispatchEvent');
241
+ await expect(init(config)).resolves.toBeUndefined();
242
+ expect(dispatch).toHaveBeenCalledOnce();
243
+ expect(mocks.fingerprint).toHaveBeenCalledOnce();
244
+ expect(mocks.geolocation).toHaveBeenCalledOnce();
245
+ expect(console.error).toHaveBeenCalledWith(
246
+ 'SDK initialization diagnostic',
247
+ { stage, reason: 'failed' },
248
+ );
249
+ expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
250
+ 'private-stage-error',
251
+ );
252
+ expect(vi.getTimerCount()).toBe(0);
253
+ },
254
+ );
255
+
256
+ it('continues to geolocation if fingerprint initialization throws synchronously', async () => {
257
+ mocks.fingerprint.mockImplementation(() => {
258
+ throw new Error('private-stage-error');
259
+ });
260
+ await expect(init(config)).resolves.toBeUndefined();
261
+ expect(mocks.geolocation).toHaveBeenCalledOnce();
262
+ expect(console.error).toHaveBeenCalledWith(
263
+ 'SDK initialization diagnostic',
264
+ { stage: 'fingerprint', reason: 'failed' },
265
+ );
266
+ expect(vi.getTimerCount()).toBe(0);
267
+ });
268
+
269
+ it('consumes late rejection after the optional deadline without another diagnostic', async () => {
270
+ let rejectFingerprint!: (reason: Error) => void;
271
+ mocks.fingerprint.mockReturnValue(
272
+ new Promise<void>((_resolve, reject) => {
273
+ rejectFingerprint = reject;
274
+ }),
275
+ );
276
+ const dispatch = vi.spyOn(window, 'dispatchEvent');
277
+ const pending = init(config);
278
+ await vi.advanceTimersByTimeAsync(3000);
279
+ await pending;
280
+ expect(console.error).toHaveBeenCalledTimes(1);
281
+ rejectFingerprint(new Error('private-late-error'));
282
+ await vi.advanceTimersByTimeAsync(0);
283
+ expect(console.error).toHaveBeenCalledTimes(1);
284
+ expect(dispatch).toHaveBeenCalledOnce();
285
+ expect(vi.getTimerCount()).toBe(0);
286
+ });
287
+ });
package/src/init.ts CHANGED
@@ -15,7 +15,7 @@ export type InitConfig = z.infer<typeof InitConfigSchema>;
15
15
  * Dispatches a 'Tracker Ready' event to the window object.
16
16
  *
17
17
  * This function creates and dispatches a custom event named 'Tracker Ready'
18
- * to signal that the tracking library has been successfully initialized and is ready to use.
18
+ * after Mixpanel and Core API configuration are ready; optional enrichment may still be pending.
19
19
  */
20
20
  function onLoaded() {
21
21
  window.dispatchEvent(new CustomEvent('Tracker Ready'));
@@ -24,6 +24,7 @@ function onLoaded() {
24
24
  /**
25
25
  * Initializes the tracking library with the provided configuration.
26
26
  * @param {InitConfig} config - The configuration object for initialization.
27
+ * Optional enrichment runs concurrently, with a three-second waiting limit per stage.
27
28
  * @throws {Error} Throws an error if the configuration is invalid.
28
29
  */
29
30
  async function init(config: InitConfig) {
@@ -41,17 +42,6 @@ async function init(config: InitConfig) {
41
42
 
42
43
  await mixpanelService.init(mixpanelToken, debug, mixpanelProxyUrl);
43
44
 
44
- try {
45
- await fingerprintService.initFingerprint(
46
- identificationKey,
47
- identifyProxyUrl,
48
- );
49
- } catch (error) {
50
- console.error('Error initializing identification service:', error);
51
- }
52
-
53
- await geolocationService.init();
54
-
55
45
  configService.setEnvironment(isSandbox ? 'sandbox' : 'prod');
56
46
  if (appName) {
57
47
  configService.setAppName(appName);
@@ -60,6 +50,38 @@ async function init(config: InitConfig) {
60
50
 
61
51
  // Dispatch the 'Tracker Ready' event
62
52
  onLoaded();
53
+
54
+ await Promise.all([
55
+ initializeOptionalStage('fingerprint', () =>
56
+ fingerprintService.initFingerprint(identificationKey, identifyProxyUrl),
57
+ ),
58
+ initializeOptionalStage('geolocation', () => geolocationService.init()),
59
+ ]);
60
+ }
61
+
62
+ /** Bounds the wait for optional work without cancelling future enrichment. */
63
+ function initializeOptionalStage(
64
+ stage: 'fingerprint' | 'geolocation',
65
+ initialize: () => Promise<unknown>,
66
+ ): Promise<void> {
67
+ return new Promise((resolve) => {
68
+ let active = true;
69
+ const finish = (reason?: 'timeout' | 'failed') => {
70
+ if (!active) return;
71
+ active = false;
72
+ clearTimeout(timer);
73
+ if (reason)
74
+ console.error('SDK initialization diagnostic', { stage, reason });
75
+ resolve();
76
+ };
77
+ const timer = setTimeout(() => finish('timeout'), 3000);
78
+ void Promise.resolve()
79
+ .then(initialize)
80
+ .then(
81
+ () => finish(),
82
+ () => finish('failed'),
83
+ );
84
+ });
63
85
  }
64
86
 
65
87
  /**
@@ -1,13 +1,7 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import configService from '@/services/config';
1
+ import coreApi from '@reservamos/js-api-client/core';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import botDetectionRelayService from './botDetectionRelay';
4
4
 
5
- vi.mock('@/services/config', () => ({
6
- default: {
7
- getCoreAPIConfig: vi.fn(),
8
- },
9
- }));
10
-
11
5
  const payload = {
12
6
  requestId: 'request-123',
13
7
  visitorId: 'visitor-123',
@@ -27,7 +21,13 @@ const coreApiConfig = {
27
21
  describe('botDetectionRelayService', () => {
28
22
  beforeEach(() => {
29
23
  vi.unstubAllGlobals();
30
- vi.mocked(configService.getCoreAPIConfig).mockReturnValue(coreApiConfig);
24
+ coreApi.setConfig(coreApiConfig);
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.useRealTimers();
29
+ vi.unstubAllGlobals();
30
+ vi.restoreAllMocks();
31
31
  });
32
32
 
33
33
  it('enables only the SDK-owned Bot Detection events', () => {
@@ -42,8 +42,8 @@ describe('botDetectionRelayService', () => {
42
42
  ).toBe(false);
43
43
  });
44
44
 
45
- it('posts the payload to the Core API fingerprint path with keepalive', async () => {
46
- const fetchMock = vi.fn().mockResolvedValue({ ok: true });
45
+ it('sends an observation through the configured client', async () => {
46
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 202 });
47
47
  vi.stubGlobal('fetch', fetchMock);
48
48
 
49
49
  await botDetectionRelayService.relay(payload);
@@ -55,32 +55,37 @@ describe('botDetectionRelayService', () => {
55
55
  headers: {
56
56
  'Content-Type': 'application/json',
57
57
  'x-app-name': 'reservamos-web',
58
+ 'Authorization': 'Bearer test-token',
58
59
  },
59
- body: JSON.stringify(payload),
60
- keepalive: true,
60
+ body: JSON.stringify({
61
+ requestId: payload.requestId,
62
+ visitorId: payload.visitorId,
63
+ distinctId: payload.distinctId,
64
+ eventName: 'Fingerprint Requested',
65
+ }),
66
+ signal: expect.any(AbortSignal),
61
67
  },
62
68
  );
63
69
  });
64
70
 
65
- it('omits x-app-name when it is not in the core API config', async () => {
66
- const fetchMock = vi.fn().mockResolvedValue({ ok: true });
71
+ it('omits distinctId when unavailable', async () => {
72
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 202 });
67
73
  vi.stubGlobal('fetch', fetchMock);
68
- vi.mocked(configService.getCoreAPIConfig).mockReturnValue({
69
- baseUrl: 'https://datalake-api-dev.reservamossaas.com/api',
70
- defaultHeaders: {
71
- Authorization: 'Bearer test-token',
72
- Origin: 'https://example.com',
73
- },
74
- });
75
74
 
76
- await botDetectionRelayService.relay(payload);
75
+ await botDetectionRelayService.relay({
76
+ requestId: payload.requestId,
77
+ visitorId: payload.visitorId,
78
+ eventName: 'Search',
79
+ });
77
80
 
78
81
  expect(fetchMock).toHaveBeenCalledWith(
79
- 'https://datalake-api-dev.reservamossaas.com/api/v1/fingerprint/bot-detection',
82
+ expect.any(String),
80
83
  expect.objectContaining({
81
- headers: {
82
- 'Content-Type': 'application/json',
83
- },
84
+ body: JSON.stringify({
85
+ requestId: payload.requestId,
86
+ visitorId: payload.visitorId,
87
+ eventName: 'Fingerprint Requested',
88
+ }),
84
89
  }),
85
90
  );
86
91
  });
@@ -97,7 +102,7 @@ describe('botDetectionRelayService', () => {
97
102
  ).resolves.toBeUndefined();
98
103
  expect(consoleError).toHaveBeenCalledWith(
99
104
  'Error relaying Fingerprint identification:',
100
- error,
105
+ { httpStatus: null, reason: 'network_or_cors' },
101
106
  );
102
107
 
103
108
  consoleError.mockRestore();
@@ -117,11 +122,33 @@ describe('botDetectionRelayService', () => {
117
122
  ).resolves.toBeUndefined();
118
123
  expect(consoleError).toHaveBeenCalledWith(
119
124
  'Error relaying Fingerprint identification:',
120
- expect.objectContaining({
121
- message: 'Bot Detection relay failed with status 503',
122
- }),
125
+ { httpStatus: 503, reason: 'service_unavailable' },
123
126
  );
124
127
 
125
128
  consoleError.mockRestore();
126
129
  });
130
+
131
+ it('contains client timeouts and reports a sanitized diagnostic', async () => {
132
+ vi.useFakeTimers();
133
+ const consoleError = vi
134
+ .spyOn(console, 'error')
135
+ .mockImplementation(() => undefined);
136
+ const fetchMock = vi.fn(
137
+ (_url: string, { signal }: { signal: AbortSignal }) =>
138
+ new Promise<Response>((_resolve, reject) => {
139
+ signal.addEventListener('abort', () => reject(new Error('Aborted')));
140
+ }),
141
+ );
142
+ vi.stubGlobal('fetch', fetchMock);
143
+
144
+ const attempt = botDetectionRelayService.relay(payload);
145
+ await vi.advanceTimersByTimeAsync(5000);
146
+
147
+ await expect(attempt).resolves.toBeUndefined();
148
+ expect(fetchMock).toHaveBeenCalledTimes(1);
149
+ expect(consoleError).toHaveBeenCalledWith(
150
+ 'Error relaying Fingerprint identification:',
151
+ { httpStatus: null, reason: 'timeout' },
152
+ );
153
+ });
127
154
  });
@@ -1,7 +1,5 @@
1
1
  import type { BotDetectionRelayPayload } from '@/types/botDetection';
2
- import configService from '@/services/config';
3
-
4
- const BOT_DETECTION_RELAY_PATH = '/v1/fingerprint/bot-detection';
2
+ import coreApi from '@reservamos/js-api-client/core';
5
3
 
6
4
  /**
7
5
  * Events that require a fresh Fingerprint identification and Core API relay.
@@ -30,27 +28,19 @@ function isEnabledFor(eventName: string): boolean {
30
28
  */
31
29
  async function relay(payload: BotDetectionRelayPayload): Promise<void> {
32
30
  try {
33
- const coreApiConfig = configService.getCoreAPIConfig();
34
- const response = await fetch(
35
- `${coreApiConfig.baseUrl}${BOT_DETECTION_RELAY_PATH}`,
36
- {
37
- method: 'POST',
38
- headers: {
39
- 'Content-Type': 'application/json',
40
- ...(coreApiConfig.defaultHeaders?.['x-app-name']
41
- ? { 'x-app-name': coreApiConfig.defaultHeaders['x-app-name'] }
42
- : {}),
43
- },
44
- body: JSON.stringify(payload),
45
- keepalive: true,
31
+ await coreApi.fingerprint.sendObservation({
32
+ requestId: payload.requestId,
33
+ visitorId: payload.visitorId,
34
+ distinctId: payload.distinctId,
35
+ onDiagnostic(diagnostic) {
36
+ if (diagnostic.reason !== 'http_accepted') {
37
+ console.error(
38
+ 'Error relaying Fingerprint identification:',
39
+ diagnostic,
40
+ );
41
+ }
46
42
  },
47
- );
48
-
49
- if (!response.ok) {
50
- throw new Error(
51
- `Bot Detection relay failed with status ${response.status}`,
52
- );
53
- }
43
+ });
54
44
  } catch (error) {
55
45
  console.error('Error relaying Fingerprint identification:', error);
56
46
  }
@@ -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,
@@ -51,9 +51,8 @@ const getCachedFingerprint = (): string | null => {
51
51
  };
52
52
 
53
53
  function readCachedFingerprint(context?: IdentifyStageContext): string | null {
54
- const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig;
55
-
56
54
  try {
55
+ const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig ?? {};
57
56
  const cachedData = JSON.parse(localStorage.getItem(cacheName) || 'null');
58
57
  if (cachedData && cachedData.expiresAt > Date.now()) {
59
58
  return cachedData.fingerprint;
@@ -164,20 +163,12 @@ async function readFingerprint(
164
163
  cacheOnly: boolean,
165
164
  context?: IdentifyStageContext,
166
165
  ): 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
166
  const cachedFingerprint = readCachedFingerprint(context);
176
167
  if (cachedFingerprint) {
177
168
  return cachedFingerprint;
178
169
  }
179
170
 
180
- if (cacheOnly || !isFingerprintReady()) {
171
+ if (cacheOnly || !window.fingerprintConfig || !isFingerprintReady()) {
181
172
  return '';
182
173
  }
183
174