UNPKG

@tinytapanalytics/sdk

Version:

Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time

734 lines (580 loc) 20.8 kB
/** * Tests for MinimalTinyTapAnalytics SDK */ import { MinimalTinyTapAnalytics } from '../minimal'; import packageJson from '../../package.json'; // 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('MinimalTinyTapAnalytics', () => { let sdk: MinimalTinyTapAnalytics; 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 MinimalTinyTapAnalytics(mockConfig); expect(sdk).toBeDefined(); }); it('should use default endpoint if not provided', () => { const sdkWithDefaults = new MinimalTinyTapAnalytics({ apiKey: 'test-key', websiteId: 'test-id' }); expect(sdkWithDefaults).toBeDefined(); }); it('should generate a session ID on initialization', () => { sdk = new MinimalTinyTapAnalytics(mockConfig); // The session ID should be set internally expect(sdk).toBeDefined(); }); }); describe('track()', () => { beforeEach(() => { sdk = new MinimalTinyTapAnalytics(mockConfig); }); it('should throw error when websiteId is not configured', async () => { const sdkWithoutWebsiteId = new MinimalTinyTapAnalytics({ apiKey: 'test-key' }); await expect(sdkWithoutWebsiteId.track('test_event')).rejects.toThrow( 'TinyTapAnalytics: 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: `${packageJson.version}-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 MinimalTinyTapAnalytics({ ...mockConfig, debug: true }); await debugSdk.track('custom_event'); expect(consoleSpy).toHaveBeenCalledWith( 'TinyTapAnalytics: 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 MinimalTinyTapAnalytics({ ...mockConfig, debug: true }); mockFetch.mockRejectedValue(new Error('Test error')); await expect(debugSdk.track('custom_event')).rejects.toThrow(); expect(consoleErrorSpy).toHaveBeenCalledWith( 'TinyTapAnalytics: Failed to track event', expect.any(Error) ); consoleErrorSpy.mockRestore(); }); }); describe('identify()', () => { beforeEach(() => { sdk = new MinimalTinyTapAnalytics(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 MinimalTinyTapAnalytics(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 MinimalTinyTapAnalytics(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 MinimalTinyTapAnalytics(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 MinimalTinyTapAnalytics(mockConfig); const sdk2 = new MinimalTinyTapAnalytics(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 MinimalTinyTapAnalytics(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 MinimalTinyTapAnalytics(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'); }); it('should log error in debug mode when websiteId is missing', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); const debugSdkNoWebsiteId = new MinimalTinyTapAnalytics({ apiKey: 'test-key', debug: true }); await expect(debugSdkNoWebsiteId.track('test_event')).rejects.toThrow( 'TinyTapAnalytics: websiteId is required but not configured' ); expect(consoleErrorSpy).toHaveBeenCalledWith( 'TinyTapAnalytics: websiteId is required but not configured' ); consoleErrorSpy.mockRestore(); }); }); describe('Integration with backend', () => { beforeEach(() => { sdk = new MinimalTinyTapAnalytics(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(); }); }); describe('Auto-initialization', () => { let originalNodeEnv: string | undefined; let consoleLogSpy: jest.SpyInstance; let consoleErrorSpy: jest.SpyInstance; beforeEach(() => { // Save original NODE_ENV originalNodeEnv = process.env.NODE_ENV; // Clear window.TinyTapAnalytics delete (window as any).TinyTapAnalytics; delete (window as any).__tinytapanalytics_config; // Mock console methods consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); // Clear module cache to allow re-import jest.resetModules(); mockFetch.mockClear(); mockFetch.mockResolvedValue({ ok: true, status: 202, statusText: 'Accepted' }); }); afterEach(() => { // Restore NODE_ENV process.env.NODE_ENV = originalNodeEnv; consoleLogSpy.mockRestore(); consoleErrorSpy.mockRestore(); delete (window as any).TinyTapAnalytics; delete (window as any).__tinytapanalytics_config; }); it('should auto-initialize with script tag data attributes', () => { // Set NODE_ENV to non-test value to trigger auto-init process.env.NODE_ENV = 'production'; // Set debug mode in global config to trigger logging (window as any).__tinytapanalytics_config = { debug: true }; // Mock script tag with data attributes const mockScriptTag = { dataset: { apiKey: 'script-api-key', websiteId: 'script-website-id' } }; Object.defineProperty(document, 'currentScript', { value: mockScriptTag, configurable: true }); Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); // Re-import module to trigger auto-init jest.isolateModules(() => { require('../minimal'); }); // Verify SDK was attached to window expect((window as any).TinyTapAnalytics).toBeDefined(); // Verify initialization was logged (debug mode enables logging) expect(consoleLogSpy).toHaveBeenCalledWith( 'TinyTapAnalytics Minimal SDK initialized', expect.objectContaining({ websiteId: 'script-website-id', apiKey: '***', debug: true }) ); }); it('should auto-initialize with global config object', () => { process.env.NODE_ENV = 'production'; // Set global config (window as any).__tinytapanalytics_config = { apiKey: 'global-api-key', websiteId: 'global-website-id', debug: true }; Object.defineProperty(document, 'currentScript', { value: null, configurable: true }); Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); jest.isolateModules(() => { require('../minimal'); }); expect((window as any).TinyTapAnalytics).toBeDefined(); expect(consoleLogSpy).toHaveBeenCalledWith( 'TinyTapAnalytics Minimal SDK initialized', expect.objectContaining({ websiteId: 'global-website-id', debug: true }) ); }); it('should log warning when websiteId is not set', () => { process.env.NODE_ENV = 'production'; (window as any).__tinytapanalytics_config = { apiKey: 'test-key' // websiteId not provided }; Object.defineProperty(document, 'currentScript', { value: null, configurable: true }); Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); jest.isolateModules(() => { require('../minimal'); }); expect(consoleLogSpy).toHaveBeenCalledWith( 'TinyTapAnalytics Minimal SDK initialized', expect.objectContaining({ websiteId: 'NOT SET - Events will fail!' }) ); }); it('should auto-track page view after initialization when DOM is ready', async () => { process.env.NODE_ENV = 'production'; (window as any).__tinytapanalytics_config = { apiKey: 'test-key', websiteId: 'test-website-id' }; Object.defineProperty(document, 'currentScript', { value: null, configurable: true }); Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); mockFetch.mockClear(); jest.isolateModules(() => { require('../minimal'); }); // Wait for async trackPageView to complete await new Promise(resolve => setTimeout(resolve, 100)); // Verify page view was tracked expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('/api/v1/events'), expect.objectContaining({ method: 'POST' }) ); const callArgs = mockFetch.mock.calls[0]; const body = JSON.parse(callArgs[1].body); expect(body.event_type).toBe('page_view'); }); it('should handle page view tracking errors gracefully', async () => { process.env.NODE_ENV = 'production'; (window as any).__tinytapanalytics_config = { apiKey: 'test-key', websiteId: 'test-website-id' }; Object.defineProperty(document, 'currentScript', { value: null, configurable: true }); Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); // Make fetch fail mockFetch.mockRejectedValue(new Error('Network error')); jest.isolateModules(() => { require('../minimal'); }); // Wait for async trackPageView to fail await new Promise(resolve => setTimeout(resolve, 100)); // Verify error was logged but didn't crash expect(consoleErrorSpy).toHaveBeenCalledWith( 'TinyTapAnalytics: Failed to track initial page view', expect.any(Error) ); }); it('should wait for DOMContentLoaded if document is still loading', () => { process.env.NODE_ENV = 'production'; (window as any).__tinytapanalytics_config = { apiKey: 'test-key', websiteId: 'test-website-id' }; Object.defineProperty(document, 'currentScript', { value: null, configurable: true }); Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true }); const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); jest.isolateModules(() => { require('../minimal'); }); // Verify DOMContentLoaded listener was added expect(addEventListenerSpy).toHaveBeenCalledWith( 'DOMContentLoaded', expect.any(Function) ); addEventListenerSpy.mockRestore(); }); it('should use script tag apiKey as websiteId if websiteId not provided', () => { process.env.NODE_ENV = 'production'; // Enable debug mode to see the logging (window as any).__tinytapanalytics_config = { debug: true }; const mockScriptTag = { dataset: { apiKey: 'only-api-key' // websiteId not provided } }; Object.defineProperty(document, 'currentScript', { value: mockScriptTag, configurable: true }); Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); jest.isolateModules(() => { require('../minimal'); }); expect(consoleLogSpy).toHaveBeenCalledWith( 'TinyTapAnalytics Minimal SDK initialized', expect.objectContaining({ websiteId: 'only-api-key', // apiKey used as fallback debug: true }) ); }); it('should not auto-initialize without script tag or global config', () => { process.env.NODE_ENV = 'production'; Object.defineProperty(document, 'currentScript', { value: null, configurable: true }); // No global config set jest.isolateModules(() => { require('../minimal'); }); // SDK should not be attached to window expect((window as any).TinyTapAnalytics).toBeUndefined(); }); }); });