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

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.
@@ -0,0 +1,154 @@
1
+ import coreApi from '@reservamos/js-api-client/core';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import botDetectionRelayService from './botDetectionRelay';
4
+
5
+ const payload = {
6
+ requestId: 'request-123',
7
+ visitorId: 'visitor-123',
8
+ distinctId: 'distinct-123',
9
+ eventName: 'Payment Attempt',
10
+ };
11
+
12
+ const coreApiConfig = {
13
+ baseUrl: 'https://data-lake.reservamossaas.com/api',
14
+ defaultHeaders: {
15
+ 'Authorization': 'Bearer test-token',
16
+ 'Origin': 'https://example.com',
17
+ 'x-app-name': 'reservamos-web',
18
+ },
19
+ };
20
+
21
+ describe('botDetectionRelayService', () => {
22
+ beforeEach(() => {
23
+ vi.unstubAllGlobals();
24
+ coreApi.setConfig(coreApiConfig);
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.useRealTimers();
29
+ vi.unstubAllGlobals();
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ it('enables only the SDK-owned Bot Detection events', () => {
34
+ expect(botDetectionRelayService.isEnabledFor('Search')).toBe(true);
35
+ expect(botDetectionRelayService.isEnabledFor('Payment Attempt')).toBe(true);
36
+ expect(botDetectionRelayService.isEnabledFor('Purchase Attempt')).toBe(
37
+ true,
38
+ );
39
+ expect(botDetectionRelayService.isEnabledFor('View Results')).toBe(false);
40
+ expect(
41
+ botDetectionRelayService.isEnabledFor('Purchase Confirmation Viewed'),
42
+ ).toBe(false);
43
+ });
44
+
45
+ it('sends an observation through the configured client', async () => {
46
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 202 });
47
+ vi.stubGlobal('fetch', fetchMock);
48
+
49
+ await botDetectionRelayService.relay(payload);
50
+
51
+ expect(fetchMock).toHaveBeenCalledWith(
52
+ 'https://data-lake.reservamossaas.com/api/v1/fingerprint/bot-detection',
53
+ {
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/json',
57
+ 'x-app-name': 'reservamos-web',
58
+ 'Authorization': 'Bearer test-token',
59
+ },
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),
67
+ },
68
+ );
69
+ });
70
+
71
+ it('omits distinctId when unavailable', async () => {
72
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 202 });
73
+ vi.stubGlobal('fetch', fetchMock);
74
+
75
+ await botDetectionRelayService.relay({
76
+ requestId: payload.requestId,
77
+ visitorId: payload.visitorId,
78
+ eventName: 'Search',
79
+ });
80
+
81
+ expect(fetchMock).toHaveBeenCalledWith(
82
+ expect.any(String),
83
+ expect.objectContaining({
84
+ body: JSON.stringify({
85
+ requestId: payload.requestId,
86
+ visitorId: payload.visitorId,
87
+ eventName: 'Fingerprint Requested',
88
+ }),
89
+ }),
90
+ );
91
+ });
92
+
93
+ it('contains rejected requests and reports the failure', async () => {
94
+ const error = new Error('Network unavailable');
95
+ const consoleError = vi
96
+ .spyOn(console, 'error')
97
+ .mockImplementation(() => undefined);
98
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error));
99
+
100
+ await expect(
101
+ botDetectionRelayService.relay(payload),
102
+ ).resolves.toBeUndefined();
103
+ expect(consoleError).toHaveBeenCalledWith(
104
+ 'Error relaying Fingerprint identification:',
105
+ { httpStatus: null, reason: 'network_or_cors' },
106
+ );
107
+
108
+ consoleError.mockRestore();
109
+ });
110
+
111
+ it('contains non-success responses and reports their status', async () => {
112
+ const consoleError = vi
113
+ .spyOn(console, 'error')
114
+ .mockImplementation(() => undefined);
115
+ vi.stubGlobal(
116
+ 'fetch',
117
+ vi.fn().mockResolvedValue({ ok: false, status: 503 }),
118
+ );
119
+
120
+ await expect(
121
+ botDetectionRelayService.relay(payload),
122
+ ).resolves.toBeUndefined();
123
+ expect(consoleError).toHaveBeenCalledWith(
124
+ 'Error relaying Fingerprint identification:',
125
+ { httpStatus: 503, reason: 'service_unavailable' },
126
+ );
127
+
128
+ consoleError.mockRestore();
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
+ });
154
+ });
@@ -0,0 +1,54 @@
1
+ import type { BotDetectionRelayPayload } from '@/types/botDetection';
2
+ import coreApi from '@reservamos/js-api-client/core';
3
+
4
+ /**
5
+ * Events that require a fresh Fingerprint identification and Core API relay.
6
+ * Owned by the SDK so consumers do not choose which events participate.
7
+ */
8
+ const BOT_DETECTION_EVENTS = [
9
+ 'Search',
10
+ 'Payment Attempt',
11
+ 'Purchase Attempt',
12
+ ] as const;
13
+
14
+ /**
15
+ * Checks whether an event is eligible for Bot Detection.
16
+ * @param {string} eventName - Mixpanel event name.
17
+ * @returns {boolean} Whether the event should obtain and relay a fresh identification.
18
+ */
19
+ function isEnabledFor(eventName: string): boolean {
20
+ return (BOT_DETECTION_EVENTS as readonly string[]).includes(eventName);
21
+ }
22
+
23
+ /**
24
+ * Relays Fingerprint identification context to Core API.
25
+ * Failures are contained so they cannot affect an event already submitted to Mixpanel.
26
+ * @param {BotDetectionRelayPayload} payload - Request correlation data.
27
+ * @returns {Promise<void>} Resolves after the relay attempt completes.
28
+ */
29
+ async function relay(payload: BotDetectionRelayPayload): Promise<void> {
30
+ try {
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
+ }
42
+ },
43
+ });
44
+ } catch (error) {
45
+ console.error('Error relaying Fingerprint identification:', error);
46
+ }
47
+ }
48
+
49
+ const botDetectionRelayService = {
50
+ isEnabledFor,
51
+ relay,
52
+ };
53
+
54
+ export default botDetectionRelayService;
@@ -1,66 +1,279 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import fingerprintService from './fingerprint';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import fingerprintService, { getFingerprintForIdentify } from './fingerprint';
3
3
 
4
- const { initMock, getVisitorDataMock } = vi.hoisted(() => ({
5
- initMock: vi.fn(),
6
- getVisitorDataMock: vi.fn(),
7
- }));
4
+ const visitorData = {
5
+ visitorId: 'visitor-123',
6
+ requestId: 'request-123',
7
+ visitorFound: true,
8
+ confidence: { score: 0.99 },
9
+ cacheHit: false,
10
+ };
11
+
12
+ const { getVisitorDataMock, initMock, setPeoplePropertiesMock, clientMock } =
13
+ vi.hoisted(() => ({
14
+ clientMock: vi.fn(),
15
+ getVisitorDataMock: vi.fn(),
16
+ initMock: vi.fn(),
17
+ setPeoplePropertiesMock: vi.fn(),
18
+ }));
8
19
 
9
20
  vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
10
21
  FingerprintJSPro: {
11
22
  defaultEndpoint: 'https://fpjscdn.net',
12
23
  defaultScriptUrlPattern: 'https://fpjscdn.net/agent',
13
24
  },
14
- FpjsClient: vi.fn().mockImplementation(() => ({
15
- init: initMock,
16
- getVisitorData: getVisitorDataMock,
17
- })),
25
+ FpjsClient: clientMock,
18
26
  }));
19
27
 
20
- describe('fingerprintService readiness', () => {
21
- beforeEach(() => {
22
- localStorage.clear();
23
- initMock.mockReset();
28
+ vi.mock('@/services/mixpanel', () => ({
29
+ default: { setPeopleProperties: setPeoplePropertiesMock },
30
+ }));
31
+
32
+ describe('fingerprintService', () => {
33
+ let storage: Record<string, string>;
34
+
35
+ beforeEach(async () => {
36
+ clientMock.mockImplementation(() => ({
37
+ init: initMock,
38
+ getVisitorData: getVisitorDataMock,
39
+ }));
40
+ storage = {};
41
+ vi.stubGlobal('localStorage', {
42
+ getItem: (key: string) => storage[key] ?? null,
43
+ setItem: (key: string, value: string) => {
44
+ storage[key] = value;
45
+ },
46
+ removeItem: (key: string) => {
47
+ storage = Object.fromEntries(
48
+ Object.entries(storage).filter(([entryKey]) => entryKey !== key),
49
+ );
50
+ },
51
+ clear: () => {
52
+ storage = {};
53
+ },
54
+ });
24
55
  getVisitorDataMock.mockReset();
56
+ initMock.mockReset();
57
+ setPeoplePropertiesMock.mockReset();
58
+ getVisitorDataMock.mockResolvedValue(visitorData);
59
+ initMock.mockResolvedValue(undefined);
60
+ await fingerprintService.initFingerprint('test-api-key');
25
61
  });
26
62
 
27
- it('does not identify with a client whose agent failed to load', async () => {
28
- const initError = new Error('Agent script blocked');
63
+ afterEach(() => {
64
+ vi.restoreAllMocks();
65
+ vi.unstubAllGlobals();
66
+ });
67
+
68
+ it('omits unavailable Visitor Found metadata without blocking identification', async () => {
69
+ getVisitorDataMock.mockResolvedValue({
70
+ ...visitorData,
71
+ visitorFound: undefined,
72
+ });
73
+ const result = await fingerprintService.identifyVisitor();
74
+ expect(result.visitorId).toBe('visitor-123');
75
+ expect(result.requestId).toBe('request-123');
76
+ expect(result).not.toHaveProperty('visitorFound');
77
+ expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('suppresses identify-specific cache and profile effects after its scope closes', async () => {
81
+ let finish: (value: typeof visitorData) => void = () => undefined;
82
+ getVisitorDataMock.mockReturnValue(
83
+ new Promise((resolve) => {
84
+ finish = resolve;
85
+ }),
86
+ );
87
+ let active = true;
88
+ const pending = getFingerprintForIdentify({
89
+ isActive: () => active,
90
+ report: vi.fn(),
91
+ });
92
+ active = false;
93
+ finish(visitorData);
94
+ await pending;
95
+ expect(fingerprintService.getCachedFingerprint()).toBeNull();
96
+ expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it('continues the identify path when fingerprint cache writes fail', async () => {
100
+ vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
101
+ throw new Error('private-id');
102
+ });
103
+ const report = vi.fn();
104
+ await expect(
105
+ getFingerprintForIdentify({ isActive: () => true, report }),
106
+ ).resolves.toBe('visitor-123');
107
+ expect(report).toHaveBeenCalledWith('cache', 'unavailable');
108
+ expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
109
+ 'Visitor Found': true,
110
+ });
111
+ });
112
+
113
+ it('sanitizes errors only on the identify-specific fingerprint path', async () => {
114
+ const error = new Error('private-id');
115
+ getVisitorDataMock.mockRejectedValue(error);
29
116
  const consoleError = vi
30
117
  .spyOn(console, 'error')
31
118
  .mockImplementation(() => undefined);
32
- initMock.mockRejectedValue(initError);
33
- getVisitorDataMock.mockRejectedValue(
34
- new Error(
35
- "FPJSAgent hasn't loaded yet. Make sure to call the init() method first.",
36
- ),
119
+ await expect(
120
+ getFingerprintForIdentify({ isActive: () => true, report: vi.fn() }),
121
+ ).rejects.toBe(error);
122
+ expect(consoleError).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('returns request-scoped identification data and bypasses the SPA cache', async () => {
126
+ await expect(fingerprintService.identifyVisitor(true)).resolves.toEqual({
127
+ visitorId: 'visitor-123',
128
+ requestId: 'request-123',
129
+ visitorFound: true,
130
+ confidenceScore: 0.99,
131
+ cacheHit: false,
132
+ });
133
+
134
+ expect(getVisitorDataMock).toHaveBeenCalledWith({}, true);
135
+ expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
136
+ 'Visitor Found': true,
137
+ });
138
+ });
139
+
140
+ it('writes a false visitor match to the Mixpanel profile', async () => {
141
+ getVisitorDataMock.mockResolvedValue({
142
+ ...visitorData,
143
+ visitorFound: false,
144
+ });
145
+
146
+ await expect(
147
+ fingerprintService.identifyVisitor(true),
148
+ ).resolves.toMatchObject({ visitorFound: false });
149
+ expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
150
+ 'Visitor Found': false,
151
+ });
152
+ expect(setPeoplePropertiesMock).toHaveBeenCalledTimes(1);
153
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
154
+ });
155
+
156
+ it('keeps identification data when the Mixpanel profile update fails', async () => {
157
+ const error = new Error('Mixpanel unavailable');
158
+ const consoleError = vi
159
+ .spyOn(console, 'error')
160
+ .mockImplementation(() => undefined);
161
+ setPeoplePropertiesMock.mockImplementation(() => {
162
+ throw error;
163
+ });
164
+
165
+ await expect(fingerprintService.identifyVisitor(true)).resolves.toEqual({
166
+ visitorId: 'visitor-123',
167
+ requestId: 'request-123',
168
+ visitorFound: true,
169
+ confidenceScore: 0.99,
170
+ cacheHit: false,
171
+ });
172
+ expect(fingerprintService.getCachedFingerprint()).toBe('visitor-123');
173
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
174
+ expect(consoleError).toHaveBeenCalledWith(
175
+ 'Error updating Mixpanel visitor profile:',
176
+ error,
177
+ );
178
+
179
+ consoleError.mockRestore();
180
+ });
181
+
182
+ it('preserves the visitor-only SDK cache contract', async () => {
183
+ await fingerprintService.identifyVisitor(true);
184
+
185
+ expect(fingerprintService.getCachedFingerprint()).toBe('visitor-123');
186
+ await expect(fingerprintService.getFingerprint(true)).resolves.toBe(
187
+ 'visitor-123',
37
188
  );
189
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
190
+ expect(setPeoplePropertiesMock).toHaveBeenCalledTimes(1);
191
+ });
192
+
193
+ it('does not reuse a legacy cached visitor ID as a request-scoped result', async () => {
194
+ localStorage.setItem(
195
+ 'default_fingerprint_cache',
196
+ JSON.stringify({
197
+ fingerprint: 'legacy-visitor',
198
+ expiresAt: Date.now() + 60_000,
199
+ }),
200
+ );
201
+
202
+ await expect(
203
+ fingerprintService.identifyVisitor(true),
204
+ ).resolves.toMatchObject({
205
+ visitorId: 'visitor-123',
206
+ requestId: 'request-123',
207
+ });
208
+ expect(getVisitorDataMock).toHaveBeenCalledWith({}, true);
209
+ });
210
+
211
+ it('reports and rethrows identification errors', async () => {
212
+ const error = new Error('Fingerprint unavailable');
213
+ const consoleError = vi
214
+ .spyOn(console, 'error')
215
+ .mockImplementation(() => undefined);
216
+ getVisitorDataMock.mockRejectedValue(error);
217
+
218
+ await expect(fingerprintService.identifyVisitor(true)).rejects.toThrow(
219
+ 'Fingerprint unavailable',
220
+ );
221
+ expect(consoleError).toHaveBeenCalledWith(
222
+ 'Error retrieving fingerprint:',
223
+ error,
224
+ );
225
+ expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
226
+
227
+ consoleError.mockRestore();
228
+ });
229
+
230
+ it('does not treat a constructed client as ready until the agent loads', async () => {
231
+ const consoleError = vi
232
+ .spyOn(console, 'error')
233
+ .mockImplementation(() => undefined);
234
+ const initError = new Error('Agent script blocked');
235
+ initMock.mockRejectedValue(initError);
38
236
 
39
237
  await expect(
40
- fingerprintService.initFingerprint('test-key'),
238
+ fingerprintService.initFingerprint('test-api-key'),
41
239
  ).rejects.toThrow('Agent script blocked');
42
240
  expect(fingerprintService.isFingerprintReady()).toBe(false);
43
- await expect(fingerprintService.getFingerprint()).resolves.toBe('');
44
- expect(getVisitorDataMock).not.toHaveBeenCalled();
45
241
  expect(consoleError).toHaveBeenCalledWith(
46
242
  'Error initializing identification service:',
47
243
  initError,
48
244
  );
49
245
 
246
+ await expect(fingerprintService.getFingerprint()).resolves.toBe('');
247
+ await expect(fingerprintService.identifyVisitor(true)).rejects.toThrow(
248
+ 'Identification service is not initialized.',
249
+ );
250
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
251
+
50
252
  consoleError.mockRestore();
51
253
  });
52
254
 
53
- it('identifies after the agent loads successfully', async () => {
54
- initMock.mockResolvedValue(undefined);
55
- getVisitorDataMock.mockResolvedValue({ visitorId: 'visitor-123' });
56
-
57
- await fingerprintService.initFingerprint('test-key');
255
+ it('returns a cached visitor ID when the agent failed to load', async () => {
256
+ localStorage.setItem(
257
+ 'default_fingerprint_cache',
258
+ JSON.stringify({
259
+ fingerprint: 'cached-visitor',
260
+ expiresAt: Date.now() + 60_000,
261
+ }),
262
+ );
263
+ const consoleError = vi
264
+ .spyOn(console, 'error')
265
+ .mockImplementation(() => undefined);
266
+ initMock.mockRejectedValue(new Error('Agent script blocked'));
58
267
 
59
- expect(fingerprintService.isFingerprintReady()).toBe(true);
268
+ await expect(
269
+ fingerprintService.initFingerprint('test-api-key'),
270
+ ).rejects.toThrow('Agent script blocked');
60
271
  await expect(fingerprintService.getFingerprint()).resolves.toBe(
61
- 'visitor-123',
272
+ 'cached-visitor',
62
273
  );
63
- expect(getVisitorDataMock).toHaveBeenCalledOnce();
274
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
275
+
276
+ consoleError.mockRestore();
64
277
  });
65
278
 
66
279
  it('waits for the agent to load before identifying', async () => {
@@ -71,9 +284,8 @@ describe('fingerprintService readiness', () => {
71
284
  finishInit = resolve;
72
285
  }),
73
286
  );
74
- getVisitorDataMock.mockResolvedValue({ visitorId: 'visitor-123' });
75
287
 
76
- const init = fingerprintService.initFingerprint('test-key');
288
+ const init = fingerprintService.initFingerprint('test-api-key');
77
289
 
78
290
  expect(fingerprintService.isFingerprintReady()).toBe(false);
79
291
  await expect(fingerprintService.getFingerprint()).resolves.toBe('');
@@ -127,7 +339,10 @@ describe('fingerprintService readiness', () => {
127
339
  }),
128
340
  )
129
341
  .mockResolvedValueOnce(undefined);
130
- getVisitorDataMock.mockResolvedValue({ visitorId: 'new-visitor' });
342
+ getVisitorDataMock.mockResolvedValue({
343
+ ...visitorData,
344
+ visitorId: 'new-visitor',
345
+ });
131
346
  const consoleError = vi
132
347
  .spyOn(console, 'error')
133
348
  .mockImplementation(() => undefined);