/** * Tests for NetworkManager */ import { NetworkManager } from '../NetworkManager'; import { ErrorHandler } from '../ErrorHandler'; import { ConversionIQConfig } from '../../types'; // Mock ErrorHandler jest.mock('../ErrorHandler'); describe('NetworkManager', () => { let networkManager: NetworkManager; let mockErrorHandler: jest.Mocked; let config: ConversionIQConfig; let mockXHR: any; let xhrBehavior: { status?: number; statusText?: string; responseText?: string; shouldError?: boolean; shouldAbort?: boolean; } | null = null; // Helper to configure the XHR response for the next request const setupXHRResponse = (behavior: { status?: number; statusText?: string; responseText?: string; shouldError?: boolean; shouldAbort?: boolean; }) => { xhrBehavior = behavior; }; beforeEach(() => { // Create mock config config = { apiKey: 'test-api-key', websiteId: 'test-website', endpoint: 'https://api.example.com', debug: false, timeout: 5000, retry: { maxAttempts: 3, baseDelay: 1000, maxDelay: 30000 } }; // Create mock error handler mockErrorHandler = new ErrorHandler(config) as jest.Mocked; mockErrorHandler.handle = jest.fn(); // Reset XHR behavior xhrBehavior = null; // Mock XMLHttpRequest constructor - follows best practice of synchronous callback triggering const MockXMLHttpRequest = jest.fn(function(this: any) { const instance = this; instance.open = jest.fn(); instance.setRequestHeader = jest.fn(); instance.abort = jest.fn(); instance.getAllResponseHeaders = jest.fn(() => ''); instance.readyState = 0; instance.status = 0; instance.statusText = ''; instance.responseText = ''; instance.onreadystatechange = null; instance.onerror = null; instance.onabort = null; instance.ontimeout = null; // Send method triggers callbacks synchronously (best practice for testing) instance.send = jest.fn(() => { if (xhrBehavior?.shouldError) { // Trigger error callback if (instance.onerror) { instance.onerror(new Error('Network error')); } return; } if (xhrBehavior?.shouldAbort) { // Trigger abort callback if (instance.onabort) { instance.onabort(); } return; } // Set response values instance.readyState = 4; // DONE instance.status = xhrBehavior?.status ?? 200; instance.statusText = xhrBehavior?.statusText ?? 'OK'; instance.responseText = xhrBehavior?.responseText ?? '{}'; // Trigger onreadystatechange synchronously if (instance.onreadystatechange) { instance.onreadystatechange(); } }); mockXHR = instance; return instance; }); // Add XMLHttpRequest constants MockXMLHttpRequest.DONE = 4; MockXMLHttpRequest.UNSENT = 0; MockXMLHttpRequest.OPENED = 1; MockXMLHttpRequest.HEADERS_RECEIVED = 2; MockXMLHttpRequest.LOADING = 3; (global as any).XMLHttpRequest = MockXMLHttpRequest; networkManager = new NetworkManager(config, mockErrorHandler); }); afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); }); describe('sendEvent', () => { it('should send a single event successfully', async () => { const event = { type: 'page_view', url: '/test' }; setupXHRResponse({ status: 200, statusText: 'OK', responseText: '{"success":true}' }); await networkManager.sendEvent(event); expect(mockXHR.open).toHaveBeenCalledWith( 'POST', 'https://api.example.com/api/v1/events', true ); expect(mockXHR.setRequestHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); // Note: Authorization header removed - events endpoint is anonymous expect(mockXHR.setRequestHeader).not.toHaveBeenCalledWith('Authorization', expect.anything()); }); it('should throw error on failed request', async () => { const event = { type: 'page_view' }; setupXHRResponse({ status: 500, statusText: 'Internal Server Error' }); await expect(networkManager.sendEvent(event)).rejects.toThrow(); }); }); describe('sendBatch', () => { it('should send batch of events successfully', async () => { const events = [ { type: 'page_view', url: '/test1' }, { type: 'page_view', url: '/test2' } ]; setupXHRResponse({ status: 202, statusText: 'Accepted', responseText: '{"success":true}' }); await networkManager.sendBatch(events); expect(mockXHR.open).toHaveBeenCalledWith( 'POST', 'https://api.example.com/api/v1/events/batch', true ); }); }); describe('ping', () => { it('should return true when API is reachable', async () => { setupXHRResponse({ status: 200, statusText: 'OK', responseText: '{"status":"ok"}' }); const result = await networkManager.ping(); expect(result).toBe(true); expect(mockXHR.open).toHaveBeenCalledWith( 'GET', 'https://api.example.com/api/v1/ping', true ); }); it('should return false when API is unreachable', async () => { setupXHRResponse({ status: 500 }); const result = await networkManager.ping(); expect(result).toBe(false); }); }); describe('request with retry logic', () => { it('should retry on transient failures', async () => { let attemptCount = 0; // For retry tests, we need to customize behavior per XHR instance // Override the beforeEach mock temporarily const MockXMLHttpRequest = jest.fn(function(this: any) { const instance = this; instance.open = jest.fn(); instance.setRequestHeader = jest.fn(); instance.abort = jest.fn(); instance.getAllResponseHeaders = jest.fn(() => ''); instance.readyState = 0; instance.status = 0; instance.statusText = ''; instance.responseText = ''; instance.onreadystatechange = null; instance.onerror = null; instance.onabort = null; instance.ontimeout = null; instance.send = jest.fn(() => { attemptCount++; instance.readyState = 4; // DONE if (attemptCount < 3) { // Fail first two attempts instance.status = 503; instance.statusText = 'Service Unavailable'; instance.responseText = '{"error":"Service Unavailable"}'; } else { // Succeed on third attempt instance.status = 200; instance.statusText = 'OK'; instance.responseText = '{"success":true}'; } if (instance.onreadystatechange) { instance.onreadystatechange(); } }); mockXHR = instance; return instance; }); // Add XMLHttpRequest constants MockXMLHttpRequest.DONE = 4; (global as any).XMLHttpRequest = MockXMLHttpRequest; // Recreate NetworkManager with the new XHR mock const testManager = new NetworkManager(config, mockErrorHandler); const request = { url: 'https://api.example.com/test', method: 'GET' as const, retry: { maxAttempts: 3, baseDelay: 1, // Very small delay for fast test execution maxDelay: 10 } }; await testManager.request(request); expect(attemptCount).toBe(3); }); it('should not retry on 400 errors', async () => { let attemptCount = 0; const MockXMLHttpRequest = jest.fn(function(this: any) { const instance = this; instance.open = jest.fn(); instance.setRequestHeader = jest.fn(); instance.abort = jest.fn(); instance.getAllResponseHeaders = jest.fn(() => ''); instance.readyState = 0; instance.status = 0; instance.statusText = ''; instance.responseText = ''; instance.onreadystatechange = null; instance.onerror = null; instance.onabort = null; instance.ontimeout = null; instance.send = jest.fn(() => { attemptCount++; instance.readyState = 4; // DONE instance.status = 400; instance.statusText = 'Bad Request'; instance.responseText = '{"error":"Bad Request"}'; if (instance.onreadystatechange) { instance.onreadystatechange(); } }); mockXHR = instance; return instance; }); // Add XMLHttpRequest constants MockXMLHttpRequest.DONE = 4; (global as any).XMLHttpRequest = MockXMLHttpRequest; // Recreate NetworkManager with the new XHR mock const testManager = new NetworkManager(config, mockErrorHandler); const request = { url: 'https://api.example.com/test', method: 'POST' as const, retry: { maxAttempts: 3, baseDelay: 1, // Very small delay for fast test execution maxDelay: 10 } }; await expect(testManager.request(request)).rejects.toThrow(); // Should only attempt once, not retry expect(attemptCount).toBe(1); }); it('should not retry on 401 errors', async () => { let attemptCount = 0; const MockXMLHttpRequest = jest.fn(function(this: any) { const instance = this; instance.open = jest.fn(); instance.setRequestHeader = jest.fn(); instance.abort = jest.fn(); instance.getAllResponseHeaders = jest.fn(() => ''); instance.readyState = 0; instance.status = 0; instance.statusText = ''; instance.responseText = ''; instance.onreadystatechange = null; instance.onerror = null; instance.onabort = null; instance.ontimeout = null; instance.send = jest.fn(() => { attemptCount++; instance.readyState = 4; // DONE instance.status = 401; instance.statusText = 'Unauthorized'; instance.responseText = '{"error":"Unauthorized"}'; if (instance.onreadystatechange) { instance.onreadystatechange(); } }); mockXHR = instance; return instance; }); // Add XMLHttpRequest constants MockXMLHttpRequest.DONE = 4; (global as any).XMLHttpRequest = MockXMLHttpRequest; // Recreate NetworkManager with the new XHR mock const testManager = new NetworkManager(config, mockErrorHandler); const request = { url: 'https://api.example.com/test', method: 'POST' as const }; await expect(testManager.request(request)).rejects.toThrow(); expect(attemptCount).toBe(1); }); it('should handle network errors', async () => { setupXHRResponse({ shouldError: true }); const request = { url: 'https://api.example.com/test', method: 'GET' as const, retry: { maxAttempts: 1, baseDelay: 0, maxDelay: 0 } }; await expect(networkManager.request(request)).rejects.toThrow('Network error occurred'); }); it('should handle request timeout', async () => { jest.useFakeTimers(); // Create a custom XHR that never responds (to trigger timeout) const MockXMLHttpRequest = jest.fn(function(this: any) { const instance = this; instance.open = jest.fn(); instance.setRequestHeader = jest.fn(); instance.abort = jest.fn(); instance.getAllResponseHeaders = jest.fn(() => ''); instance.readyState = 0; instance.status = 0; instance.statusText = ''; instance.responseText = ''; instance.onreadystatechange = null; instance.onerror = null; instance.onabort = null; instance.ontimeout = null; // Send method does NOT trigger response - simulating a hanging request instance.send = jest.fn(); mockXHR = instance; return instance; }); (global as any).XMLHttpRequest = MockXMLHttpRequest; const testManager = new NetworkManager(config, mockErrorHandler); const requestPromise = testManager.request({ url: 'https://api.example.com/test', method: 'GET' as const, timeout: 1000, retry: { maxAttempts: 1, baseDelay: 0, maxDelay: 0 } }); // Fast-forward time to trigger timeout - don't await separately jest.advanceTimersByTime(1000); await expect(requestPromise).rejects.toThrow('Request timeout after 1000ms'); jest.useRealTimers(); }); }); describe('canSendData', () => { it('should return false when offline', () => { Object.defineProperty(navigator, 'onLine', { writable: true, value: false }); expect(networkManager.canSendData()).toBe(false); }); it('should return true when online with good connection', () => { Object.defineProperty(navigator, 'onLine', { writable: true, value: true }); expect(networkManager.canSendData()).toBe(true); }); it('should return false on slow-2g connection', () => { Object.defineProperty(navigator, 'onLine', { writable: true, value: true }); (navigator as any).connection = { effectiveType: 'slow-2g' }; expect(networkManager.canSendData()).toBe(false); }); it('should return false when saveData is enabled', () => { Object.defineProperty(navigator, 'onLine', { writable: true, value: true }); (navigator as any).connection = { effectiveType: '4g', saveData: true }; expect(networkManager.canSendData()).toBe(false); }); }); describe('sendBeacon', () => { it('should send beacon when available', () => { const mockSendBeacon = jest.fn(() => true); Object.defineProperty(navigator, 'sendBeacon', { writable: true, value: mockSendBeacon }); const event = { type: 'page_view', url: '/test' }; const result = networkManager.sendBeacon(event); expect(result).toBe(true); expect(mockSendBeacon).toHaveBeenCalledWith( 'https://api.example.com/api/v1/events', expect.any(Blob) ); }); it('should return false when sendBeacon is not available', () => { Object.defineProperty(navigator, 'sendBeacon', { writable: true, value: undefined }); const event = { type: 'page_view' }; const result = networkManager.sendBeacon(event); expect(result).toBe(false); }); it('should handle sendBeacon errors', () => { const mockSendBeacon = jest.fn(() => { throw new Error('Beacon failed'); }); Object.defineProperty(navigator, 'sendBeacon', { writable: true, value: mockSendBeacon }); const event = { type: 'page_view' }; const result = networkManager.sendBeacon(event); expect(result).toBe(false); }); }); describe('debug mode', () => { it('should log debug messages when debug is enabled', async () => { const consoleLog = jest.spyOn(console, 'log').mockImplementation(); config.debug = true; networkManager = new NetworkManager(config, mockErrorHandler); setupXHRResponse({ status: 200, statusText: 'OK', responseText: '{"success":true}' }); await networkManager.sendEvent({ type: 'test' }); expect(consoleLog).toHaveBeenCalledWith( 'ConversionIQ: Request successful', expect.any(Object) ); consoleLog.mockRestore(); }); it('should log warnings on failed requests when debug is enabled', async () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(); config.debug = true; setupXHRResponse({ status: 500 }); // Create NetworkManager after setting up XHR response const testManager = new NetworkManager(config, mockErrorHandler); await expect(testManager.sendEvent({ type: 'test' })).rejects.toThrow(); expect(consoleWarn).toHaveBeenCalled(); consoleWarn.mockRestore(); }); }); describe('request headers', () => { it('should set custom headers', async () => { setupXHRResponse({ status: 200, responseText: '{}' }); await networkManager.request({ url: 'https://api.example.com/test', method: 'POST', headers: { 'X-Custom-Header': 'test-value' } }); expect(mockXHR.setRequestHeader).toHaveBeenCalledWith('X-Custom-Header', 'test-value'); }); it('should include SDK version header', async () => { setupXHRResponse({ status: 200, responseText: '{}' }); await networkManager.sendEvent({ type: 'test' }); expect(mockXHR.setRequestHeader).toHaveBeenCalledWith('X-ConversionIQ-SDK', '2.0.12'); }); }); describe('response parsing', () => { it('should parse JSON response', async () => { setupXHRResponse({ status: 200, responseText: '{"data":"test"}' }); const response = await networkManager.request({ url: 'https://api.example.com/test', method: 'GET' }); expect(response.data).toEqual({ data: 'test' }); }); it('should handle non-JSON response', async () => { setupXHRResponse({ status: 200, responseText: 'plain text response' }); const response = await networkManager.request({ url: 'https://api.example.com/test', method: 'GET' }); expect(response.data).toBe('plain text response'); }); it('should handle empty response', async () => { setupXHRResponse({ status: 204, responseText: '' }); const response = await networkManager.request({ url: 'https://api.example.com/test', method: 'DELETE' }); expect(response.data).toBeNull(); }); it('should parse response headers', async () => { // Set up custom mock for this test to include response headers const MockXMLHttpRequest = jest.fn(function(this: any) { const instance = this; instance.open = jest.fn(); instance.setRequestHeader = jest.fn(); instance.abort = jest.fn(); instance.getAllResponseHeaders = jest.fn(() => 'content-type: application/json\r\nx-custom-header: test-value\r\n' ); instance.readyState = 0; instance.status = 0; instance.statusText = ''; instance.responseText = ''; instance.onreadystatechange = null; instance.onerror = null; instance.onabort = null; instance.ontimeout = null; instance.send = jest.fn(() => { instance.readyState = 4; // DONE instance.status = 200; instance.statusText = 'OK'; instance.responseText = '{}'; if (instance.onreadystatechange) { instance.onreadystatechange(); } }); mockXHR = instance; return instance; }); // Add XMLHttpRequest constants MockXMLHttpRequest.DONE = 4; (global as any).XMLHttpRequest = MockXMLHttpRequest; // Recreate NetworkManager with the new XHR mock const testManager = new NetworkManager(config, mockErrorHandler); const response = await testManager.request({ url: 'https://api.example.com/test', method: 'GET' }); expect(response.headers).toEqual({ 'content-type': 'application/json', 'x-custom-header': 'test-value' }); }); }); });