@nextsparkjs/plugin-amplitude 0.1.0-beta.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.
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Test suite for Amplitude hooks
3
+ */
4
+
5
+ import { renderHook, act } from '@testing-library/react';
6
+ import { useAmplitude } from '../hooks/useAmplitude';
7
+ import { useExperiment } from '../hooks/useExperiment';
8
+ import { useSessionReplay } from '../hooks/useSessionReplay';
9
+ import { AmplitudeProvider } from '../providers/AmplitudeProvider';
10
+ import React from 'react';
11
+
12
+ // Mock context
13
+ const mockAmplitudeContext = {
14
+ amplitude: {
15
+ track: jest.fn().mockResolvedValue(undefined),
16
+ identify: jest.fn().mockResolvedValue(undefined),
17
+ setUserProperties: jest.fn().mockResolvedValue(undefined),
18
+ reset: jest.fn(),
19
+ isInitialized: jest.fn().mockReturnValue(true),
20
+ },
21
+ isInitialized: true,
22
+ config: {
23
+ apiKey: 'test-key',
24
+ serverZone: 'US' as const,
25
+ enableSessionReplay: true,
26
+ enableABTesting: true,
27
+ sampleRate: 1,
28
+ enableConsentManagement: true,
29
+ batchSize: 30,
30
+ flushInterval: 10000,
31
+ debugMode: false,
32
+ piiMaskingEnabled: true,
33
+ rateLimitEventsPerMinute: 1000,
34
+ errorRetryAttempts: 3,
35
+ errorRetryDelayMs: 1000,
36
+ },
37
+ consent: {
38
+ analytics: true,
39
+ sessionReplay: true,
40
+ experiments: true,
41
+ performance: true,
42
+ },
43
+ updateConsent: jest.fn(),
44
+ error: null,
45
+ };
46
+
47
+ // Mock the context hook
48
+ jest.mock('../providers/AmplitudeProvider', () => ({
49
+ useAmplitudeContext: () => mockAmplitudeContext,
50
+ AmplitudeProvider: ({ children }: { children: React.ReactNode }) => children,
51
+ }));
52
+
53
+ // Mock performance tracking
54
+ jest.mock('../lib/performance', () => ({
55
+ trackPerformanceMetric: jest.fn(),
56
+ getPerformanceStats: jest.fn(() => ({
57
+ amplitudeCore: {
58
+ initTime: 100,
59
+ trackingLatency: [50, 60, 70],
60
+ errorRate: 0.01,
61
+ eventQueueSize: 5,
62
+ memoryUsage: 1024 * 1024,
63
+ },
64
+ webVitals: {
65
+ cls: 0.1,
66
+ fid: 50,
67
+ fcp: 1000,
68
+ lcp: 2000,
69
+ ttfb: 200,
70
+ inp: 100,
71
+ },
72
+ browserMetrics: {
73
+ memoryUsage: 1024 * 1024,
74
+ connectionType: '4g',
75
+ devicePixelRatio: 2,
76
+ screenResolution: '1920x1080',
77
+ viewportSize: '1200x800',
78
+ },
79
+ })),
80
+ getPerformanceMetrics: jest.fn(() => []),
81
+ }));
82
+
83
+ // Mock security audit logger
84
+ jest.mock('../lib/security', () => ({
85
+ auditLogger: {
86
+ log: jest.fn(),
87
+ },
88
+ }));
89
+
90
+ describe('useAmplitude hook', () => {
91
+ beforeEach(() => {
92
+ jest.clearAllMocks();
93
+ });
94
+
95
+ it('should provide amplitude tracking functions', () => {
96
+ const { result } = renderHook(() => useAmplitude());
97
+
98
+ expect(result.current).toHaveProperty('track');
99
+ expect(result.current).toHaveProperty('identify');
100
+ expect(result.current).toHaveProperty('setUserProperties');
101
+ expect(result.current).toHaveProperty('reset');
102
+ expect(result.current).toHaveProperty('isInitialized');
103
+ expect(result.current).toHaveProperty('context');
104
+ });
105
+
106
+ it('should track events successfully', async () => {
107
+ const { result } = renderHook(() => useAmplitude());
108
+
109
+ await act(async () => {
110
+ await result.current.track('Test Event' as any, { test: 'value' });
111
+ });
112
+
113
+ expect(mockAmplitudeContext.amplitude.track).toHaveBeenCalledWith(
114
+ 'Test Event',
115
+ { test: 'value' }
116
+ );
117
+ });
118
+
119
+ it('should identify users successfully', async () => {
120
+ const { result } = renderHook(() => useAmplitude());
121
+
122
+ await act(async () => {
123
+ await result.current.identify('user123' as any, { name: 'Test User' });
124
+ });
125
+
126
+ expect(mockAmplitudeContext.amplitude.identify).toHaveBeenCalledWith(
127
+ 'user123',
128
+ { name: 'Test User' }
129
+ );
130
+ });
131
+
132
+ it('should set user properties successfully', async () => {
133
+ const { result } = renderHook(() => useAmplitude());
134
+
135
+ await act(async () => {
136
+ await result.current.setUserProperties({ plan: 'premium' });
137
+ });
138
+
139
+ expect(mockAmplitudeContext.amplitude.setUserProperties).toHaveBeenCalledWith(
140
+ { plan: 'premium' }
141
+ );
142
+ });
143
+
144
+ it('should reset amplitude successfully', () => {
145
+ const { result } = renderHook(() => useAmplitude());
146
+
147
+ act(() => {
148
+ result.current.reset();
149
+ });
150
+
151
+ expect(mockAmplitudeContext.amplitude.reset).toHaveBeenCalled();
152
+ });
153
+
154
+ it('should handle errors gracefully', async () => {
155
+ const error = new Error('Tracking failed');
156
+ mockAmplitudeContext.amplitude.track.mockRejectedValueOnce(error);
157
+
158
+ const { result } = renderHook(() => useAmplitude());
159
+
160
+ await act(async () => {
161
+ await expect(result.current.track('Test Event' as any))
162
+ .rejects.toThrow('Tracking failed');
163
+ });
164
+
165
+ expect(result.current.lastError).toEqual(error);
166
+ });
167
+
168
+ it('should respect consent settings', async () => {
169
+ // Temporarily modify the mock context's consent
170
+ const originalConsent = { ...mockAmplitudeContext.consent };
171
+ mockAmplitudeContext.consent = {
172
+ analytics: false,
173
+ sessionReplay: false,
174
+ experiments: false,
175
+ performance: false,
176
+ };
177
+
178
+ const { result } = renderHook(() => useAmplitude());
179
+
180
+ await act(async () => {
181
+ await result.current.track('Test Event' as any);
182
+ });
183
+
184
+ // Should not call amplitude.track when consent is not granted
185
+ expect(mockAmplitudeContext.amplitude.track).not.toHaveBeenCalled();
186
+
187
+ // Restore original consent
188
+ mockAmplitudeContext.consent = originalConsent;
189
+ });
190
+ });
191
+
192
+ describe('useExperiment hook', () => {
193
+ beforeEach(() => {
194
+ jest.clearAllMocks();
195
+
196
+ // Mock localStorage
197
+ Object.defineProperty(window, 'localStorage', {
198
+ value: {
199
+ getItem: jest.fn(),
200
+ setItem: jest.fn(),
201
+ removeItem: jest.fn(),
202
+ clear: jest.fn(),
203
+ },
204
+ writable: true,
205
+ });
206
+ });
207
+
208
+ it('should provide experiment functions', () => {
209
+ const { result } = renderHook(() => useExperiment());
210
+
211
+ expect(result.current).toHaveProperty('getVariant');
212
+ expect(result.current).toHaveProperty('trackExposure');
213
+ expect(result.current).toHaveProperty('trackConversion');
214
+ expect(result.current).toHaveProperty('isInExperiment');
215
+ expect(result.current).toHaveProperty('registerExperiment');
216
+ expect(result.current).toHaveProperty('canRunExperiments');
217
+ });
218
+
219
+ it('should register experiments', () => {
220
+ const { result } = renderHook(() => useExperiment());
221
+
222
+ const experimentConfig = {
223
+ id: 'test-experiment',
224
+ name: 'Test Experiment',
225
+ description: 'A test experiment',
226
+ status: 'running' as const,
227
+ variants: [
228
+ { id: 'control', name: 'Control', description: 'Control variant', allocation: 50, isControl: true, config: {} },
229
+ { id: 'treatment', name: 'Treatment', description: 'Treatment variant', allocation: 50, isControl: false, config: {} },
230
+ ],
231
+ targeting: {},
232
+ metrics: [],
233
+ startDate: new Date(),
234
+ sampleSize: 1000,
235
+ confidenceLevel: 0.95,
236
+ minimumDetectableEffect: 0.05,
237
+ allocations: { control: 50, treatment: 50 },
238
+ stickiness: 'user' as const,
239
+ };
240
+
241
+ act(() => {
242
+ result.current.registerExperiment(experimentConfig);
243
+ });
244
+
245
+ expect(result.current.experiments.has('test-experiment')).toBe(true);
246
+ });
247
+
248
+ it('should track experiment exposure', async () => {
249
+ const { result } = renderHook(() => useExperiment());
250
+
251
+ // First register the experiment so it can be found
252
+ const experimentConfig = {
253
+ id: 'test-experiment',
254
+ name: 'Test Experiment',
255
+ description: 'A test experiment',
256
+ status: 'running' as const,
257
+ variants: [
258
+ { id: 'control', name: 'Control', description: 'Control variant', allocation: 50, isControl: true, config: {} },
259
+ { id: 'treatment', name: 'Treatment', description: 'Treatment variant', allocation: 50, isControl: false, config: {} },
260
+ ],
261
+ targeting: {},
262
+ metrics: [],
263
+ startDate: new Date(),
264
+ sampleSize: 1000,
265
+ confidenceLevel: 0.95,
266
+ minimumDetectableEffect: 0.05,
267
+ allocations: { control: 50, treatment: 50 },
268
+ stickiness: 'user' as const,
269
+ };
270
+
271
+ act(() => {
272
+ result.current.registerExperiment(experimentConfig);
273
+ });
274
+
275
+ await act(async () => {
276
+ await result.current.trackExposure('test-experiment', 'control');
277
+ });
278
+
279
+ expect(mockAmplitudeContext.amplitude.track).toHaveBeenCalledWith(
280
+ 'Experiment Exposed',
281
+ expect.objectContaining({
282
+ experiment_id: 'test-experiment',
283
+ variant_id: 'control',
284
+ })
285
+ );
286
+ });
287
+
288
+ it('should track experiment conversion', async () => {
289
+ const { result } = renderHook(() => useExperiment());
290
+
291
+ // Register the experiment first
292
+ const experimentConfig = {
293
+ id: 'test-experiment',
294
+ name: 'Test Experiment',
295
+ description: 'A test experiment',
296
+ status: 'running' as const,
297
+ variants: [
298
+ { id: 'control', name: 'Control', description: 'Control variant', allocation: 50, isControl: true, config: {} },
299
+ { id: 'treatment', name: 'Treatment', description: 'Treatment variant', allocation: 50, isControl: false, config: {} },
300
+ ],
301
+ targeting: {},
302
+ metrics: [],
303
+ startDate: new Date(),
304
+ sampleSize: 1000,
305
+ confidenceLevel: 0.95,
306
+ minimumDetectableEffect: 0.05,
307
+ allocations: { control: 50, treatment: 50 },
308
+ stickiness: 'user' as const,
309
+ };
310
+
311
+ act(() => {
312
+ result.current.registerExperiment(experimentConfig);
313
+ });
314
+
315
+ // Track exposure first
316
+ await act(async () => {
317
+ await result.current.trackExposure('test-experiment', 'control');
318
+ });
319
+
320
+ // Then track conversion
321
+ await act(async () => {
322
+ await result.current.trackConversion('test-experiment', 'purchase', 99.99);
323
+ });
324
+
325
+ expect(mockAmplitudeContext.amplitude.track).toHaveBeenCalledWith(
326
+ 'Experiment Converted',
327
+ expect.objectContaining({
328
+ experiment_id: 'test-experiment',
329
+ metric_id: 'purchase',
330
+ conversion_value: 99.99,
331
+ })
332
+ );
333
+ });
334
+ });
335
+
336
+ describe('useSessionReplay hook', () => {
337
+ beforeEach(() => {
338
+ jest.clearAllMocks();
339
+
340
+ // Mock DOM methods
341
+ Object.defineProperty(window, 'MutationObserver', {
342
+ value: jest.fn().mockImplementation(() => ({
343
+ observe: jest.fn(),
344
+ disconnect: jest.fn(),
345
+ })),
346
+ writable: true,
347
+ });
348
+
349
+ // Mock localStorage
350
+ Object.defineProperty(window, 'localStorage', {
351
+ value: {
352
+ getItem: jest.fn(),
353
+ setItem: jest.fn(),
354
+ removeItem: jest.fn(),
355
+ clear: jest.fn(),
356
+ },
357
+ writable: true,
358
+ });
359
+
360
+ // Mock location
361
+ Object.defineProperty(window, 'location', {
362
+ value: {
363
+ href: 'https://example.com/test',
364
+ pathname: '/test',
365
+ },
366
+ writable: true,
367
+ });
368
+
369
+ // Mock document
370
+ Object.defineProperty(document, 'title', {
371
+ value: 'Test Page',
372
+ writable: true,
373
+ });
374
+
375
+ Object.defineProperty(document, 'referrer', {
376
+ value: 'https://example.com',
377
+ writable: true,
378
+ });
379
+ });
380
+
381
+ it('should provide session replay functions', () => {
382
+ const { result } = renderHook(() => useSessionReplay());
383
+
384
+ expect(result.current).toHaveProperty('startRecording');
385
+ expect(result.current).toHaveProperty('stopRecording');
386
+ expect(result.current).toHaveProperty('pauseRecording');
387
+ expect(result.current).toHaveProperty('resumeRecording');
388
+ expect(result.current).toHaveProperty('isRecording');
389
+ expect(result.current).toHaveProperty('canRecord');
390
+ });
391
+
392
+ it('should start recording when consent is granted', async () => {
393
+ // Mock Math.random to always pass sampling check (default 10% sample rate)
394
+ const originalRandom = Math.random;
395
+ Math.random = jest.fn(() => 0.05); // Less than 0.1 (10% sample rate)
396
+
397
+ const { result } = renderHook(() => useSessionReplay());
398
+
399
+ await act(async () => {
400
+ const started = await result.current.startRecording();
401
+ expect(started).toBe(true);
402
+ });
403
+
404
+ expect(result.current.isRecording).toBe(true);
405
+
406
+ // Restore Math.random
407
+ Math.random = originalRandom;
408
+ });
409
+
410
+ it('should not start recording without consent', async () => {
411
+ // Temporarily modify the mock context's consent
412
+ const originalConsent = { ...mockAmplitudeContext.consent };
413
+ mockAmplitudeContext.consent = {
414
+ analytics: true,
415
+ sessionReplay: false, // No session replay consent
416
+ experiments: true,
417
+ performance: true,
418
+ };
419
+
420
+ const { result } = renderHook(() => useSessionReplay());
421
+
422
+ await act(async () => {
423
+ const started = await result.current.startRecording();
424
+ expect(started).toBe(false);
425
+ });
426
+
427
+ expect(result.current.isRecording).toBe(false);
428
+
429
+ // Restore original consent
430
+ mockAmplitudeContext.consent = originalConsent;
431
+ });
432
+
433
+ it('should stop recording successfully', async () => {
434
+ const { result } = renderHook(() => useSessionReplay());
435
+
436
+ // Start recording first
437
+ await act(async () => {
438
+ await result.current.startRecording();
439
+ });
440
+
441
+ // Then stop it
442
+ await act(async () => {
443
+ await result.current.stopRecording();
444
+ });
445
+
446
+ expect(result.current.isRecording).toBe(false);
447
+ });
448
+
449
+ it('should pause and resume recording', async () => {
450
+ // Mock Math.random to always pass sampling check (default 10% sample rate)
451
+ const originalRandom = Math.random;
452
+ Math.random = jest.fn(() => 0.05); // Less than 0.1 (10% sample rate)
453
+
454
+ const { result } = renderHook(() => useSessionReplay());
455
+
456
+ // Start recording first
457
+ await act(async () => {
458
+ await result.current.startRecording();
459
+ });
460
+
461
+ // Pause recording
462
+ act(() => {
463
+ result.current.pauseRecording();
464
+ });
465
+
466
+ expect(result.current.recordingState.isPaused).toBe(true);
467
+
468
+ // Resume recording
469
+ act(() => {
470
+ result.current.resumeRecording();
471
+ });
472
+
473
+ expect(result.current.recordingState.isPaused).toBe(false);
474
+
475
+ // Restore Math.random
476
+ Math.random = originalRandom;
477
+ });
478
+ });