/** * Tests for MinimalConversionIQ SDK */ import { MinimalConversionIQ } from '../minimal'; // Mock fetch globally const mockFetch = jest.fn(); global.fetch = mockFetch as any; // Mock navigator.userAgent Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (Test Browser)', configurable: true }); describe('MinimalConversionIQ', () => { let sdk: MinimalConversionIQ; const mockConfig = { apiKey: 'test-api-key', websiteId: 'test-website-id', endpoint: 'https://test-endpoint.com', debug: false }; beforeEach(() => { jest.clearAllMocks(); mockFetch.mockResolvedValue({ ok: true, status: 202, statusText: 'Accepted' }); }); describe('Constructor', () => { it('should initialize with provided config', () => { sdk = new MinimalConversionIQ(mockConfig); expect(sdk).toBeDefined(); }); it('should use default endpoint if not provided', () => { const sdkWithDefaults = new MinimalConversionIQ({ apiKey: 'test-key', websiteId: 'test-id' }); expect(sdkWithDefaults).toBeDefined(); }); it('should generate a session ID on initialization', () => { sdk = new MinimalConversionIQ(mockConfig); // The session ID should be set internally expect(sdk).toBeDefined(); }); }); describe('track()', () => { beforeEach(() => { sdk = new MinimalConversionIQ(mockConfig); }); it('should throw error when websiteId is not configured', async () => { const sdkWithoutWebsiteId = new MinimalConversionIQ({ apiKey: 'test-key' }); await expect(sdkWithoutWebsiteId.track('test_event')).rejects.toThrow( 'ConversionIQ: websiteId is required but not configured' ); }); it('should call correct endpoint with /api/v1/events path', async () => { await sdk.track('custom_event', { foo: 'bar' }); expect(mockFetch).toHaveBeenCalledWith( 'https://test-endpoint.com/api/v1/events', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'Content-Type': 'application/json', 'Authorization': 'Bearer test-api-key' }) }) ); }); it('should send event with required fields', async () => { await sdk.track('custom_event', { custom_data: 'test' }); expect(mockFetch).toHaveBeenCalled(); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body).toMatchObject({ website_id: 'test-website-id', event_type: 'custom_event', session_id: expect.any(String), timestamp: expect.any(String), user_agent: expect.any(String), page_url: expect.any(String) }); }); it('should include metadata with viewport dimensions and SDK version', async () => { await sdk.track('custom_event', { foo: 'bar' }); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.metadata).toMatchObject({ foo: 'bar', viewport_width: expect.any(Number), viewport_height: expect.any(Number), sdk_version: '2.0.12-minimal' }); }); it('should include user_id when set via identify()', async () => { mockFetch.mockClear(); sdk.identify('user-123'); // identify() calls track('identify'), so clear that call mockFetch.mockClear(); await sdk.track('custom_event'); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.user_id).toBe('user-123'); }); it('should handle fetch errors gracefully', async () => { mockFetch.mockRejectedValue(new Error('Network error')); await expect(sdk.track('custom_event')).rejects.toThrow('Network error'); }); it('should handle HTTP error responses', async () => { mockFetch.mockResolvedValue({ ok: false, status: 400, statusText: 'Bad Request' }); await expect(sdk.track('custom_event')).rejects.toThrow('HTTP 400: Bad Request'); }); it('should log debug info when debug mode is enabled', async () => { const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); const debugSdk = new MinimalConversionIQ({ ...mockConfig, debug: true }); await debugSdk.track('custom_event'); expect(consoleSpy).toHaveBeenCalledWith( 'ConversionIQ: Event tracked', expect.any(Object) ); consoleSpy.mockRestore(); }); it('should log errors in debug mode', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); const debugSdk = new MinimalConversionIQ({ ...mockConfig, debug: true }); mockFetch.mockRejectedValue(new Error('Test error')); await expect(debugSdk.track('custom_event')).rejects.toThrow(); expect(consoleErrorSpy).toHaveBeenCalledWith( 'ConversionIQ: Failed to track event', expect.any(Error) ); consoleErrorSpy.mockRestore(); }); }); describe('identify()', () => { beforeEach(() => { sdk = new MinimalConversionIQ(mockConfig); }); it('should set user ID and track identify event', async () => { await sdk.identify('user-456'); expect(mockFetch).toHaveBeenCalled(); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.event_type).toBe('identify'); expect(body.user_id).toBe('user-456'); expect(body.metadata.user_id).toBe('user-456'); }); it('should persist user ID for subsequent events', async () => { mockFetch.mockClear(); sdk.identify('user-789'); mockFetch.mockClear(); await sdk.track('custom_event'); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.user_id).toBe('user-789'); }); }); describe('trackConversion()', () => { beforeEach(() => { sdk = new MinimalConversionIQ(mockConfig); }); it('should track conversion with value and currency', async () => { await sdk.trackConversion(99.99, 'USD', { product_id: 'prod-123' }); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.event_type).toBe('conversion'); expect(body.metadata).toMatchObject({ value: 99.99, currency: 'USD', product_id: 'prod-123' }); }); it('should default to USD if currency not provided', async () => { await sdk.trackConversion(50); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.metadata.currency).toBe('USD'); expect(body.metadata.value).toBe(50); }); }); describe('trackPageView()', () => { beforeEach(() => { sdk = new MinimalConversionIQ(mockConfig); }); it('should track page view with URL, title, and referrer', async () => { // Mock document properties Object.defineProperty(document, 'title', { value: 'Test Page', configurable: true }); Object.defineProperty(document, 'referrer', { value: 'https://referrer.com', configurable: true }); await sdk.trackPageView(); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.event_type).toBe('page_view'); expect(body.metadata).toMatchObject({ url: expect.any(String), title: 'Test Page', referrer: 'https://referrer.com' }); }); }); describe('trackClick()', () => { beforeEach(() => { sdk = new MinimalConversionIQ(mockConfig); }); it('should track click with element selector', async () => { await sdk.trackClick('#submit-button', { action: 'submit' }); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.event_type).toBe('click'); expect(body.metadata).toMatchObject({ element: '#submit-button', page_url: expect.any(String), action: 'submit' }); }); it('should track click without metadata', async () => { await sdk.trackClick('.nav-link'); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.event_type).toBe('click'); expect(body.metadata.element).toBe('.nav-link'); }); }); describe('Session ID generation', () => { it('should generate unique session IDs', () => { const sdk1 = new MinimalConversionIQ(mockConfig); const sdk2 = new MinimalConversionIQ(mockConfig); // Session IDs should be different for different instances expect(sdk1).not.toBe(sdk2); }); it('should generate session ID with correct format', async () => { sdk = new MinimalConversionIQ(mockConfig); await sdk.track('test_event'); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.session_id).toMatch(/^ciq_[a-z0-9]+$/); }); }); describe('Error handling', () => { beforeEach(() => { sdk = new MinimalConversionIQ(mockConfig); }); it('should handle network errors', async () => { mockFetch.mockRejectedValue(new Error('Network failure')); await expect(sdk.track('test_event')).rejects.toThrow('Network failure'); }); it('should handle server errors (500)', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' }); await expect(sdk.track('test_event')).rejects.toThrow('HTTP 500: Internal Server Error'); }); it('should handle validation errors (400)', async () => { mockFetch.mockResolvedValue({ ok: false, status: 400, statusText: 'Bad Request' }); await expect(sdk.track('test_event')).rejects.toThrow('HTTP 400: Bad Request'); }); it('should handle rate limiting (429)', async () => { mockFetch.mockResolvedValue({ ok: false, status: 429, statusText: 'Too Many Requests' }); await expect(sdk.track('test_event')).rejects.toThrow('HTTP 429: Too Many Requests'); }); }); describe('Integration with backend', () => { beforeEach(() => { sdk = new MinimalConversionIQ(mockConfig); }); it('should send payload matching EventRequest model', async () => { await sdk.track('page_view'); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); // Verify all required fields from EventRequest model expect(body).toHaveProperty('website_id'); expect(body).toHaveProperty('event_type'); expect(body).toHaveProperty('session_id'); expect(body).toHaveProperty('timestamp'); expect(body).toHaveProperty('user_agent'); expect(body).toHaveProperty('page_url'); expect(body).toHaveProperty('metadata'); // Verify timestamp is ISO 8601 format expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); }); it('should accept 202 Accepted response from backend', async () => { mockFetch.mockResolvedValue({ ok: true, status: 202, statusText: 'Accepted' }); await expect(sdk.track('test_event')).resolves.not.toThrow(); }); }); });