import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AnalyticsClient } from './analytics-client'; describe('AnalyticsClient', () => { let fetchMock: ReturnType; beforeEach(() => { fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true }), }); global.fetch = fetchMock; localStorage.clear(); vi.useFakeTimers(); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it('should create a client with default config', () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', }); expect(client).toBeDefined(); client.destroy(); }); it('should track view events', async () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', batchSize: 1, }); client.trackView({ contentId: 'post-123', contentType: 'post', }); await Promise.resolve(); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:4000/analytics/track/view', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: expect.stringContaining('post-123'), }), ); client.destroy(); }); it('should track engagement events', async () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', batchSize: 1, }); client.trackEngagement({ userId: 'user-123', metricType: 'like', targetId: 'post-456', targetType: 'content', }); await Promise.resolve(); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:4000/analytics/track/engagement', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: expect.stringContaining('user-123'), credentials: 'include', }), ); client.destroy(); }); it('should batch multiple events', async () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', batchSize: 1, }); client.trackView({ contentId: '1', contentType: 'post' }); client.trackView({ contentId: '2', contentType: 'post' }); client.trackView({ contentId: '3', contentType: 'post' }); await Promise.resolve(); expect(fetchMock).toHaveBeenCalledTimes(3); client.destroy(); }); it('should generate and store session ID', () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', }); const sessionId = localStorage.getItem('analytics_session_id'); expect(sessionId).toBeTruthy(); expect(typeof sessionId).toBe('string'); client.destroy(); }); it('should flush on destroy', async () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', batchSize: 10, }); client.trackView({ contentId: '1', contentType: 'post' }); client.destroy(); await Promise.resolve(); expect(fetchMock).toHaveBeenCalled(); }); describe('trackInteraction', () => { it('should track click interaction events', async () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', batchSize: 1, }); client.trackInteraction({ type: 'click', data: { elementId: 'signup-btn', elementText: 'Sign Up', elementType: 'button', pageUrl: 'http://localhost:3000/', eventName: 'signup_cta', }, }); // Wait for flush interval vi.advanceTimersByTime(5000); await Promise.resolve(); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:4000/analytics/track/interaction', expect.objectContaining({ method: 'POST', body: expect.stringContaining('signup-btn'), }), ); client.destroy(); }); it('should track scroll interaction events', async () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', batchSize: 1, }); client.trackInteraction({ type: 'scroll', data: { pageUrl: 'http://localhost:3000/', depth: 50, timeToReachMs: 2500, isBeyondFold: true, }, }); vi.advanceTimersByTime(5000); await Promise.resolve(); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:4000/analytics/track/interaction', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"depth":50'), }), ); client.destroy(); }); it('should track funnel step events', async () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', batchSize: 1, }); client.trackInteraction({ type: 'funnel_step', data: { funnelId: 'signup', stepId: 'email', stepNumber: 1, action: 'enter', }, }); vi.advanceTimersByTime(5000); await Promise.resolve(); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:4000/analytics/track/interaction', expect.objectContaining({ method: 'POST', body: expect.stringContaining('funnel_step'), }), ); client.destroy(); }); it('should batch multiple interaction events', async () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', batchSize: 3, }); // Queue 3 events client.trackInteraction({ type: 'click', data: { elementType: 'button', pageUrl: '/' }, }); client.trackInteraction({ type: 'scroll', data: { pageUrl: '/', depth: 25, timeToReachMs: 1000, isBeyondFold: false }, }); client.trackInteraction({ type: 'scroll', data: { pageUrl: '/', depth: 50, timeToReachMs: 2000, isBeyondFold: true }, }); // Should auto-flush at batchSize await Promise.resolve(); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:4000/analytics/track/interaction', expect.objectContaining({ method: 'POST', }), ); client.destroy(); }); it('should include sessionId in interaction events', async () => { const client = new AnalyticsClient({ apiBaseUrl: 'http://localhost:4000', appName: 'test-app', batchSize: 1, }); client.trackInteraction({ type: 'click', data: { elementType: 'button', pageUrl: '/' }, }); vi.advanceTimersByTime(5000); await Promise.resolve(); const body = fetchMock.mock.calls.find( (call) => call[0].includes('/track/interaction'), )?.[1]?.body; expect(body).toContain('sessionId'); client.destroy(); }); }); });