@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/CHANGELOG.md +13 -0
- package/README.md +58 -10
- 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 +2257 -1991
- 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 +14 -12
- 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 +362 -0
- package/src/services/fingerprint.ts +99 -18
- 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;
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fingerprintService, { getFingerprintForIdentify } from './fingerprint';
|
|
3
|
+
|
|
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
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
|
|
21
|
+
FingerprintJSPro: {
|
|
22
|
+
defaultEndpoint: 'https://fpjscdn.net',
|
|
23
|
+
defaultScriptUrlPattern: 'https://fpjscdn.net/agent',
|
|
24
|
+
},
|
|
25
|
+
FpjsClient: clientMock,
|
|
26
|
+
}));
|
|
27
|
+
|
|
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
|
+
});
|
|
55
|
+
getVisitorDataMock.mockReset();
|
|
56
|
+
initMock.mockReset();
|
|
57
|
+
setPeoplePropertiesMock.mockReset();
|
|
58
|
+
getVisitorDataMock.mockResolvedValue(visitorData);
|
|
59
|
+
initMock.mockResolvedValue(undefined);
|
|
60
|
+
await fingerprintService.initFingerprint('test-api-key');
|
|
61
|
+
});
|
|
62
|
+
|
|
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);
|
|
116
|
+
const consoleError = vi
|
|
117
|
+
.spyOn(console, 'error')
|
|
118
|
+
.mockImplementation(() => undefined);
|
|
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',
|
|
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);
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
fingerprintService.initFingerprint('test-api-key'),
|
|
239
|
+
).rejects.toThrow('Agent script blocked');
|
|
240
|
+
expect(fingerprintService.isFingerprintReady()).toBe(false);
|
|
241
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
242
|
+
'Error initializing identification service:',
|
|
243
|
+
initError,
|
|
244
|
+
);
|
|
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
|
+
|
|
252
|
+
consoleError.mockRestore();
|
|
253
|
+
});
|
|
254
|
+
|
|
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'));
|
|
267
|
+
|
|
268
|
+
await expect(
|
|
269
|
+
fingerprintService.initFingerprint('test-api-key'),
|
|
270
|
+
).rejects.toThrow('Agent script blocked');
|
|
271
|
+
await expect(fingerprintService.getFingerprint()).resolves.toBe(
|
|
272
|
+
'cached-visitor',
|
|
273
|
+
);
|
|
274
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
275
|
+
|
|
276
|
+
consoleError.mockRestore();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('waits for the agent to load before identifying', async () => {
|
|
280
|
+
let finishInit: () => void = () => undefined;
|
|
281
|
+
initMock.mockImplementation(
|
|
282
|
+
() =>
|
|
283
|
+
new Promise<void>((resolve) => {
|
|
284
|
+
finishInit = resolve;
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const init = fingerprintService.initFingerprint('test-api-key');
|
|
289
|
+
|
|
290
|
+
expect(fingerprintService.isFingerprintReady()).toBe(false);
|
|
291
|
+
await expect(fingerprintService.getFingerprint()).resolves.toBe('');
|
|
292
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
293
|
+
|
|
294
|
+
finishInit();
|
|
295
|
+
await init;
|
|
296
|
+
|
|
297
|
+
expect(fingerprintService.isFingerprintReady()).toBe(true);
|
|
298
|
+
await expect(fingerprintService.getFingerprint()).resolves.toBe(
|
|
299
|
+
'visitor-123',
|
|
300
|
+
);
|
|
301
|
+
expect(getVisitorDataMock).toHaveBeenCalledOnce();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('does not mark a newer failed client ready when an older init finishes', async () => {
|
|
305
|
+
let finishFirstInit: () => void = () => undefined;
|
|
306
|
+
initMock
|
|
307
|
+
.mockImplementationOnce(
|
|
308
|
+
() =>
|
|
309
|
+
new Promise<void>((resolve) => {
|
|
310
|
+
finishFirstInit = resolve;
|
|
311
|
+
}),
|
|
312
|
+
)
|
|
313
|
+
.mockRejectedValueOnce(new Error('New agent script blocked'));
|
|
314
|
+
const consoleError = vi
|
|
315
|
+
.spyOn(console, 'error')
|
|
316
|
+
.mockImplementation(() => undefined);
|
|
317
|
+
|
|
318
|
+
const firstInit = fingerprintService.initFingerprint('first-key');
|
|
319
|
+
await expect(
|
|
320
|
+
fingerprintService.initFingerprint('second-key'),
|
|
321
|
+
).rejects.toThrow('New agent script blocked');
|
|
322
|
+
finishFirstInit();
|
|
323
|
+
await firstInit;
|
|
324
|
+
|
|
325
|
+
expect(fingerprintService.isFingerprintReady()).toBe(false);
|
|
326
|
+
await expect(fingerprintService.getFingerprint()).resolves.toBe('');
|
|
327
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
328
|
+
|
|
329
|
+
consoleError.mockRestore();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('keeps a newer loaded client ready when an older init fails', async () => {
|
|
333
|
+
let failFirstInit: (reason?: unknown) => void = () => undefined;
|
|
334
|
+
initMock
|
|
335
|
+
.mockImplementationOnce(
|
|
336
|
+
() =>
|
|
337
|
+
new Promise<void>((_resolve, reject) => {
|
|
338
|
+
failFirstInit = reject;
|
|
339
|
+
}),
|
|
340
|
+
)
|
|
341
|
+
.mockResolvedValueOnce(undefined);
|
|
342
|
+
getVisitorDataMock.mockResolvedValue({
|
|
343
|
+
...visitorData,
|
|
344
|
+
visitorId: 'new-visitor',
|
|
345
|
+
});
|
|
346
|
+
const consoleError = vi
|
|
347
|
+
.spyOn(console, 'error')
|
|
348
|
+
.mockImplementation(() => undefined);
|
|
349
|
+
|
|
350
|
+
const firstInit = fingerprintService.initFingerprint('first-key');
|
|
351
|
+
await fingerprintService.initFingerprint('second-key');
|
|
352
|
+
failFirstInit(new Error('Old agent script blocked'));
|
|
353
|
+
await expect(firstInit).rejects.toThrow('Old agent script blocked');
|
|
354
|
+
|
|
355
|
+
expect(fingerprintService.isFingerprintReady()).toBe(true);
|
|
356
|
+
await expect(fingerprintService.getFingerprint()).resolves.toBe(
|
|
357
|
+
'new-visitor',
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
consoleError.mockRestore();
|
|
361
|
+
});
|
|
362
|
+
});
|