@reservamos/browser-analytics 1.1.7-alpha.0 → 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/CHANGELOG.md +5 -0
- package/README.md +35 -0
- package/dist/browser-analytics.cjs +3 -3
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +55 -55
- package/dist/browser-analytics.esm.js +1737 -1477
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +3 -3
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +1 -1
- package/src/events/identify/identify.robustness.test.ts +220 -0
- package/src/events/identify/identify.test.ts +6 -4
- package/src/events/identify/identify.ts +84 -42
- package/src/index.ts +2 -0
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.identify.test.ts +56 -0
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.ts +39 -12
- package/src/services/botDetectionRelay.test.ts +127 -0
- package/src/services/botDetectionRelay.ts +64 -0
- package/src/services/fingerprint.test.ts +250 -35
- package/src/services/fingerprint.ts +89 -12
- package/src/services/mixpanel.test.ts +160 -0
- package/src/services/mixpanel.ts +32 -6
- package/src/track.test.ts +156 -0
- package/src/track.ts +24 -5
- package/src/types/botDetection.ts +16 -0
- package/src/util/identifyAttempt.ts +139 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import configService from '@/services/config';
|
|
3
|
+
import botDetectionRelayService from './botDetectionRelay';
|
|
4
|
+
|
|
5
|
+
vi.mock('@/services/config', () => ({
|
|
6
|
+
default: {
|
|
7
|
+
getCoreAPIConfig: vi.fn(),
|
|
8
|
+
},
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const payload = {
|
|
12
|
+
requestId: 'request-123',
|
|
13
|
+
visitorId: 'visitor-123',
|
|
14
|
+
distinctId: 'distinct-123',
|
|
15
|
+
eventName: 'Payment Attempt',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const coreApiConfig = {
|
|
19
|
+
baseUrl: 'https://data-lake.reservamossaas.com/api',
|
|
20
|
+
defaultHeaders: {
|
|
21
|
+
'Authorization': 'Bearer test-token',
|
|
22
|
+
'Origin': 'https://example.com',
|
|
23
|
+
'x-app-name': 'reservamos-web',
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('botDetectionRelayService', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.unstubAllGlobals();
|
|
30
|
+
vi.mocked(configService.getCoreAPIConfig).mockReturnValue(coreApiConfig);
|
|
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('posts the payload to the Core API fingerprint path with keepalive', async () => {
|
|
46
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
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
|
+
},
|
|
59
|
+
body: JSON.stringify(payload),
|
|
60
|
+
keepalive: true,
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('omits x-app-name when it is not in the core API config', async () => {
|
|
66
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
67
|
+
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
|
+
|
|
76
|
+
await botDetectionRelayService.relay(payload);
|
|
77
|
+
|
|
78
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
79
|
+
'https://datalake-api-dev.reservamossaas.com/api/v1/fingerprint/bot-detection',
|
|
80
|
+
expect.objectContaining({
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('contains rejected requests and reports the failure', async () => {
|
|
89
|
+
const error = new Error('Network unavailable');
|
|
90
|
+
const consoleError = vi
|
|
91
|
+
.spyOn(console, 'error')
|
|
92
|
+
.mockImplementation(() => undefined);
|
|
93
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error));
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
botDetectionRelayService.relay(payload),
|
|
97
|
+
).resolves.toBeUndefined();
|
|
98
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
99
|
+
'Error relaying Fingerprint identification:',
|
|
100
|
+
error,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
consoleError.mockRestore();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('contains non-success responses and reports their status', async () => {
|
|
107
|
+
const consoleError = vi
|
|
108
|
+
.spyOn(console, 'error')
|
|
109
|
+
.mockImplementation(() => undefined);
|
|
110
|
+
vi.stubGlobal(
|
|
111
|
+
'fetch',
|
|
112
|
+
vi.fn().mockResolvedValue({ ok: false, status: 503 }),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
botDetectionRelayService.relay(payload),
|
|
117
|
+
).resolves.toBeUndefined();
|
|
118
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
119
|
+
'Error relaying Fingerprint identification:',
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
message: 'Bot Detection relay failed with status 503',
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
consoleError.mockRestore();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { BotDetectionRelayPayload } from '@/types/botDetection';
|
|
2
|
+
import configService from '@/services/config';
|
|
3
|
+
|
|
4
|
+
const BOT_DETECTION_RELAY_PATH = '/v1/fingerprint/bot-detection';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Events that require a fresh Fingerprint identification and Core API relay.
|
|
8
|
+
* Owned by the SDK so consumers do not choose which events participate.
|
|
9
|
+
*/
|
|
10
|
+
const BOT_DETECTION_EVENTS = [
|
|
11
|
+
'Search',
|
|
12
|
+
'Payment Attempt',
|
|
13
|
+
'Purchase Attempt',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Checks whether an event is eligible for Bot Detection.
|
|
18
|
+
* @param {string} eventName - Mixpanel event name.
|
|
19
|
+
* @returns {boolean} Whether the event should obtain and relay a fresh identification.
|
|
20
|
+
*/
|
|
21
|
+
function isEnabledFor(eventName: string): boolean {
|
|
22
|
+
return (BOT_DETECTION_EVENTS as readonly string[]).includes(eventName);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Relays Fingerprint identification context to Core API.
|
|
27
|
+
* Failures are contained so they cannot affect an event already submitted to Mixpanel.
|
|
28
|
+
* @param {BotDetectionRelayPayload} payload - Request correlation data.
|
|
29
|
+
* @returns {Promise<void>} Resolves after the relay attempt completes.
|
|
30
|
+
*/
|
|
31
|
+
async function relay(payload: BotDetectionRelayPayload): Promise<void> {
|
|
32
|
+
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,
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Bot Detection relay failed with status ${response.status}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error('Error relaying Fingerprint identification:', error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const botDetectionRelayService = {
|
|
60
|
+
isEnabledFor,
|
|
61
|
+
relay,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
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
|
|
5
|
-
|
|
6
|
-
|
|
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:
|
|
15
|
-
init: initMock,
|
|
16
|
-
getVisitorData: getVisitorDataMock,
|
|
17
|
-
})),
|
|
25
|
+
FpjsClient: clientMock,
|
|
18
26
|
}));
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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('
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
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
|
|
272
|
+
'cached-visitor',
|
|
62
273
|
);
|
|
63
|
-
expect(getVisitorDataMock).
|
|
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({
|
|
342
|
+
getVisitorDataMock.mockResolvedValue({
|
|
343
|
+
...visitorData,
|
|
344
|
+
visitorId: 'new-visitor',
|
|
345
|
+
});
|
|
131
346
|
const consoleError = vi
|
|
132
347
|
.spyOn(console, 'error')
|
|
133
348
|
.mockImplementation(() => undefined);
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import type { FingerprintIdentification } from '@/types/botDetection';
|
|
2
|
+
import type { IdentifyStageContext } from '@/util/identifyAttempt';
|
|
1
3
|
import {
|
|
2
4
|
FingerprintJSPro,
|
|
3
5
|
FpjsClient,
|
|
4
6
|
} from '@fingerprintjs/fingerprintjs-pro-spa';
|
|
7
|
+
import mixpanelService from '@/services/mixpanel';
|
|
5
8
|
|
|
6
9
|
declare global {
|
|
7
10
|
interface Window {
|
|
@@ -18,6 +21,12 @@ const DEFAULT_CACHE_NAME = 'default_fingerprint_cache';
|
|
|
18
21
|
const DEFAULT_CACHE_TIME_IN_DAYS = 7;
|
|
19
22
|
const PROXY_QUERY_PARAM =
|
|
20
23
|
'apiKey=<apiKey>&version=<version>&loaderVersion=<loaderVersion>';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* True only after `fpClient.init()` succeeds. `window.fpClient` exists as soon as
|
|
27
|
+
* the client is constructed, but that instance still uses a stub agent that throws
|
|
28
|
+
* "FPJSAgent hasn't loaded yet" until init completes.
|
|
29
|
+
*/
|
|
21
30
|
let fingerprintAgentReady = false;
|
|
22
31
|
|
|
23
32
|
/**
|
|
@@ -38,6 +47,10 @@ const getExpirationDate = (cacheTimeInDays: number): number => {
|
|
|
38
47
|
* @returns {string | null} - The cached fingerprint or null if it is expired or not found.
|
|
39
48
|
*/
|
|
40
49
|
const getCachedFingerprint = (): string | null => {
|
|
50
|
+
return readCachedFingerprint();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function readCachedFingerprint(context?: IdentifyStageContext): string | null {
|
|
41
54
|
const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig;
|
|
42
55
|
|
|
43
56
|
try {
|
|
@@ -48,9 +61,10 @@ const getCachedFingerprint = (): string | null => {
|
|
|
48
61
|
localStorage.removeItem(cacheName);
|
|
49
62
|
return null;
|
|
50
63
|
} catch {
|
|
64
|
+
context?.report('cache', 'unavailable');
|
|
51
65
|
return null;
|
|
52
66
|
}
|
|
53
|
-
}
|
|
67
|
+
}
|
|
54
68
|
|
|
55
69
|
/**
|
|
56
70
|
* Sets the cached fingerprint in local storage.
|
|
@@ -130,11 +144,26 @@ async function initFingerprint(
|
|
|
130
144
|
|
|
131
145
|
/**
|
|
132
146
|
* Retrieves the fingerprint, first checking the cache and then querying the identification service if not found.
|
|
147
|
+
* Returns an empty string when the Fingerprint agent has not loaded, so callers like identify() can continue.
|
|
133
148
|
* @param {boolean} cacheOnly - Optional flag to retrieve the fingerprint only from the cache.
|
|
134
|
-
* @returns {Promise<string>} A promise that resolves with the fingerprint, or an empty string when
|
|
149
|
+
* @returns {Promise<string>} A promise that resolves with the fingerprint, or an empty string when unavailable.
|
|
135
150
|
* @throws {Error} Throws an error if retrieval fails after the agent has loaded.
|
|
136
151
|
*/
|
|
137
152
|
async function getFingerprint(cacheOnly = false): Promise<string> {
|
|
153
|
+
return readFingerprint(cacheOnly);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Internal identify path with late-effect suppression and sanitized diagnostics. */
|
|
157
|
+
export function getFingerprintForIdentify(
|
|
158
|
+
context: IdentifyStageContext,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
return readFingerprint(false, context);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function readFingerprint(
|
|
164
|
+
cacheOnly: boolean,
|
|
165
|
+
context?: IdentifyStageContext,
|
|
166
|
+
): Promise<string> {
|
|
138
167
|
if (!window.fingerprintConfig) {
|
|
139
168
|
if (cacheOnly) {
|
|
140
169
|
return '';
|
|
@@ -143,7 +172,7 @@ async function getFingerprint(cacheOnly = false): Promise<string> {
|
|
|
143
172
|
throw new Error('Fingerprint configuration is not initialized.');
|
|
144
173
|
}
|
|
145
174
|
|
|
146
|
-
const cachedFingerprint =
|
|
175
|
+
const cachedFingerprint = readCachedFingerprint(context);
|
|
147
176
|
if (cachedFingerprint) {
|
|
148
177
|
return cachedFingerprint;
|
|
149
178
|
}
|
|
@@ -152,22 +181,69 @@ async function getFingerprint(cacheOnly = false): Promise<string> {
|
|
|
152
181
|
return '';
|
|
153
182
|
}
|
|
154
183
|
|
|
184
|
+
const { visitorId } = await requestIdentification(false, context);
|
|
185
|
+
return visitorId;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Requests detailed visitor identification data from Fingerprint.
|
|
190
|
+
* @param {boolean} ignoreCache - Whether the Fingerprint SPA cache must be bypassed.
|
|
191
|
+
* @returns {Promise<FingerprintIdentification>} Request-scoped identification data.
|
|
192
|
+
* @throws {Error} If the identification service is not ready or retrieval fails.
|
|
193
|
+
*/
|
|
194
|
+
async function identifyVisitor(
|
|
195
|
+
ignoreCache = false,
|
|
196
|
+
): Promise<FingerprintIdentification> {
|
|
197
|
+
return requestIdentification(ignoreCache);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function requestIdentification(
|
|
201
|
+
ignoreCache: boolean,
|
|
202
|
+
context?: IdentifyStageContext,
|
|
203
|
+
): Promise<FingerprintIdentification> {
|
|
204
|
+
if (!isFingerprintReady()) {
|
|
205
|
+
throw new Error('Identification service is not initialized.');
|
|
206
|
+
}
|
|
207
|
+
|
|
155
208
|
try {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
209
|
+
const result = await window.fpClient.getVisitorData({}, ignoreCache);
|
|
210
|
+
if (!context || context.isActive()) {
|
|
211
|
+
try {
|
|
212
|
+
setCachedFingerprint(result.visitorId);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (!context) throw error;
|
|
215
|
+
context.report('cache', 'unavailable');
|
|
216
|
+
}
|
|
217
|
+
if (typeof result.visitorFound === 'boolean') {
|
|
218
|
+
try {
|
|
219
|
+
mixpanelService.setPeopleProperties({
|
|
220
|
+
'Visitor Found': result.visitorFound,
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (context) context.report('fingerprint', 'failed');
|
|
224
|
+
else console.error('Error updating Mixpanel visitor profile:', error);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
visitorId: result.visitorId,
|
|
231
|
+
requestId: result.requestId,
|
|
232
|
+
...(typeof result.visitorFound === 'boolean'
|
|
233
|
+
? { visitorFound: result.visitorFound }
|
|
234
|
+
: {}),
|
|
235
|
+
confidenceScore: result.confidence.score,
|
|
236
|
+
cacheHit: result.cacheHit,
|
|
237
|
+
};
|
|
160
238
|
} catch (error) {
|
|
161
|
-
console.error('Error retrieving fingerprint:', error);
|
|
239
|
+
if (!context) console.error('Error retrieving fingerprint:', error);
|
|
162
240
|
throw error;
|
|
163
241
|
}
|
|
164
242
|
}
|
|
165
243
|
|
|
166
244
|
/**
|
|
167
|
-
* Checks if the
|
|
168
|
-
*
|
|
169
|
-
* This function verifies that the Fingerprint agent loaded successfully.
|
|
170
|
-
* @returns {boolean} Returns true only after the agent has loaded.
|
|
245
|
+
* Checks if the Fingerprint agent finished loading and can identify visitors.
|
|
246
|
+
* @returns {boolean} True only after `initFingerprint()` has successfully loaded the agent.
|
|
171
247
|
*/
|
|
172
248
|
function isFingerprintReady(): boolean {
|
|
173
249
|
return window.fpClient !== undefined && fingerprintAgentReady;
|
|
@@ -176,6 +252,7 @@ function isFingerprintReady(): boolean {
|
|
|
176
252
|
const fingerprintService = {
|
|
177
253
|
initFingerprint,
|
|
178
254
|
getFingerprint,
|
|
255
|
+
identifyVisitor,
|
|
179
256
|
isFingerprintReady,
|
|
180
257
|
getCachedFingerprint,
|
|
181
258
|
};
|