const mockAa = jest.fn(); jest.mock('search-insights', () => mockAa); (global as any).aa = mockAa; import { sanitizeEventName, sendAlgoliaConversionEvent, trackAlgoliaConversionWithDwellTime, getValidConversionData, initializeAlgoliaEvent } from '../conversionEvent'; const mockLocalStorage = { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn() }; Object.defineProperty(window, 'localStorage', { value: mockLocalStorage, writable: true }); Object.defineProperty(document, 'hidden', { value: false, writable: true }); Object.defineProperty(document, 'addEventListener', { value: jest.fn(), writable: true }); Object.defineProperty(window, 'addEventListener', { value: jest.fn(), writable: true }); // Mock window.location.pathname Object.defineProperty(window, 'location', { value: { pathname: '/current-article-path' }, writable: true }); describe('Algolia Conversion Event Helpers', () => { const validConversionData = { eventName: 'Test Event', queryID: 'test-query-456', objectID: 'test-object-123', indexName: 'test-index', applicationId: 'test-app-id', apiKey: 'test-api-key', timestamp: 1000000000000, articleUrl: '/current-article-path' }; beforeEach(() => { jest.clearAllMocks(); mockLocalStorage.getItem.mockClear(); mockLocalStorage.setItem.mockClear(); mockLocalStorage.removeItem.mockClear(); mockAa.mockClear(); (document.addEventListener as jest.Mock).mockClear(); (window.addEventListener as jest.Mock).mockClear(); }); describe('initializeAlgoliaEvent', () => { beforeEach(() => { mockLocalStorage.getItem.mockClear(); mockLocalStorage.setItem.mockClear(); mockAa.mockClear(); }); it('should initialize with appId, apiKey and userToken when hash is successfully generated', () => { mockLocalStorage.getItem.mockReturnValue(null); // Mock crypto.randomUUID const mockUUID = '12345678-1234-4abc-yxyz-123456789abc'; global.crypto = { randomUUID: jest.fn().mockReturnValue(mockUUID) } as any; initializeAlgoliaEvent('test-app-id', 'test-api-key'); expect(mockLocalStorage.getItem).toHaveBeenCalledWith('algoliaUserHash'); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( 'algoliaUserHash', mockUUID ); expect(mockAa).toHaveBeenCalledWith('init', { appId: 'test-app-id', apiKey: 'test-api-key', userToken: mockUUID }); }); it('should use existing hash from localStorage when available', () => { const existingHash = 'existing-uuid-hash'; mockLocalStorage.getItem.mockReturnValue(existingHash); initializeAlgoliaEvent('test-app-id', 'test-api-key'); expect(mockLocalStorage.getItem).toHaveBeenCalledWith('algoliaUserHash'); expect(mockLocalStorage.setItem).not.toHaveBeenCalled(); expect(mockAa).toHaveBeenCalledWith('init', { appId: 'test-app-id', apiKey: 'test-api-key', userToken: existingHash }); }); it('should generate UUID using fallback when crypto.randomUUID is not available', () => { mockLocalStorage.getItem.mockReturnValue(null); // Remove crypto.randomUUID global.crypto = {} as any; initializeAlgoliaEvent('test-app-id', 'test-api-key'); expect(mockLocalStorage.getItem).toHaveBeenCalledWith('algoliaUserHash'); expect(mockLocalStorage.setItem).toHaveBeenCalled(); const savedHash = mockLocalStorage.setItem.mock.calls[0][1]; // Verify it matches UUID v4 format expect(savedHash).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ ); expect(mockAa).toHaveBeenCalledWith('init', { appId: 'test-app-id', apiKey: 'test-api-key', userToken: savedHash }); }); it('should initialize without userToken when localStorage throws error', () => { mockLocalStorage.getItem.mockImplementation(() => { throw new Error('localStorage not available'); }); initializeAlgoliaEvent('test-app-id', 'test-api-key'); expect(mockAa).toHaveBeenCalledWith('init', { appId: 'test-app-id', apiKey: 'test-api-key' }); }); it('should initialize without userToken when localStorage.setItem throws error', () => { mockLocalStorage.getItem.mockReturnValue(null); mockLocalStorage.setItem.mockImplementation(() => { throw new Error('localStorage quota exceeded'); }); global.crypto = { randomUUID: jest.fn().mockReturnValue('test-uuid') } as any; initializeAlgoliaEvent('test-app-id', 'test-api-key'); expect(mockAa).toHaveBeenCalledWith('init', { appId: 'test-app-id', apiKey: 'test-api-key' }); }); it('should handle multiple calls consistently with same hash', () => { const existingHash = 'persistent-hash'; mockLocalStorage.getItem.mockReturnValue(existingHash); initializeAlgoliaEvent('app-1', 'key-1'); initializeAlgoliaEvent('app-2', 'key-2'); expect(mockLocalStorage.getItem).toHaveBeenCalledTimes(2); expect(mockAa).toHaveBeenNthCalledWith(1, 'init', { appId: 'app-1', apiKey: 'key-1', userToken: existingHash }); expect(mockAa).toHaveBeenNthCalledWith(2, 'init', { appId: 'app-2', apiKey: 'key-2', userToken: existingHash }); }); }); describe('sanitizeEventName', () => { it('should return the original string if it contains only visible ASCII characters and is under 64 characters', () => { const input = 'Valid Event Name 123!@#$%^&*()'; const result = sanitizeEventName(input); expect(result).toBe(input); }); it('should remove non-visible ASCII characters', () => { const input = 'Event\u0000Name\u001F\u007F\u0080'; const result = sanitizeEventName(input); expect(result).toBe('EventName'); }); it('should truncate strings longer than 64 characters and add ellipsis', () => { const input = 'A'.repeat(70); const result = sanitizeEventName(input); expect(result).toBe('A'.repeat(60) + '...'); expect(result.length).toBe(63); }); it('should handle exactly 64 characters without truncation', () => { const input = 'A'.repeat(64); const result = sanitizeEventName(input); expect(result).toBe(input); expect(result.length).toBe(64); }); it('should handle empty string', () => { const result = sanitizeEventName(''); expect(result).toBe(''); }); it('should handle string with only non-visible characters', () => { const input = '\u0000\u001F\u007F'; const result = sanitizeEventName(input); expect(result).toBe(''); }); it('should handle mixed visible and non-visible characters that exceed 64 chars after cleaning', () => { const input = 'Valid'.repeat(15) + '\u0000\u001F'; const result = sanitizeEventName(input); expect(result).toBe('Valid'.repeat(12) + '...'); }); }); describe('sendAlgoliaConversionEvent', () => { it('should call aa with correct parameters when all required fields are provided', () => { const params = { eventName: 'Test Event', objectID: 'test-object-123', queryID: 'test-query-456', indexName: 'test-index' }; sendAlgoliaConversionEvent(params); expect(mockAa).toHaveBeenCalledWith('convertedObjectIDsAfterSearch', { eventName: 'Test Event', index: 'test-index', queryID: 'test-query-456', objectIDs: ['test-object-123'], analyticsTags: ['web'] }); }); it('should sanitize event name before sending', () => { const params = { eventName: 'Test\u0000Event\u001F', objectID: 'test-object-123', queryID: 'test-query-456', indexName: 'test-index' }; sendAlgoliaConversionEvent(params); expect(mockAa).toHaveBeenCalledWith('convertedObjectIDsAfterSearch', { eventName: 'TestEvent', index: 'test-index', queryID: 'test-query-456', objectIDs: ['test-object-123'], analyticsTags: ['web'] }); }); it('should not call aa when indexName is missing', () => { const params = { eventName: 'Test Event', objectID: 'test-object-123', queryID: 'test-query-456', indexName: '' }; sendAlgoliaConversionEvent(params); expect(mockAa).not.toHaveBeenCalled(); }); it('should not call aa when queryID is missing', () => { const params = { eventName: 'Test Event', objectID: 'test-object-123', queryID: '', indexName: 'test-index' }; sendAlgoliaConversionEvent(params); expect(mockAa).not.toHaveBeenCalled(); }); it('should not call aa when objectID is missing', () => { const params = { eventName: 'Test Event', objectID: '', queryID: 'test-query-456', indexName: 'test-index' }; sendAlgoliaConversionEvent(params); expect(mockAa).not.toHaveBeenCalled(); }); }); describe('getValidConversionData', () => { it('should return null when no data is found in localStorage', () => { mockLocalStorage.getItem.mockReturnValue(null); const result = getValidConversionData(); expect(result).toBeNull(); expect(mockLocalStorage.getItem).toHaveBeenCalledWith( 'algoliaConversions' ); }); it('should return null when stored data is invalid JSON', () => { mockLocalStorage.getItem.mockReturnValue('invalid-json'); const result = getValidConversionData(); expect(result).toBeNull(); }); it('should return null when no matching articleUrl is found', () => { const stored = [ { ...validConversionData, articleUrl: '/not-this-url' }, { ...validConversionData, articleUrl: '/another-url' } ]; mockLocalStorage.getItem.mockReturnValue(JSON.stringify(stored)); const result = getValidConversionData(); expect(result).toBeNull(); }); it('should return the matching conversion data for current articleUrl', () => { const stored = [ { ...validConversionData, articleUrl: '/not-this-url' }, validConversionData, { ...validConversionData, articleUrl: '/another-url' } ]; mockLocalStorage.getItem.mockReturnValue(JSON.stringify(stored)); const result = getValidConversionData(); expect(result).toEqual(validConversionData); }); it('should return null if stored value is not an array', () => { mockLocalStorage.getItem.mockReturnValue( JSON.stringify(validConversionData) ); const result = getValidConversionData(); expect(result).toBeNull(); }); it('should return null if array contains invalid objects', () => { const stored = [ { foo: 'bar' }, { ...validConversionData, articleUrl: undefined } ]; mockLocalStorage.getItem.mockReturnValue(JSON.stringify(stored)); const result = getValidConversionData(); expect(result).toBeNull(); }); it('should return null when conversion data is missing articleUrl', () => { const fixedTime = 1000000000000; jest.spyOn(Date, 'now').mockReturnValue(fixedTime); const dataWithoutUrl = { eventName: 'Test Event', queryID: 'test-query-456', objectID: 'test-object-123', indexName: 'test-index', applicationId: 'test-app-id', apiKey: 'test-api-key', timestamp: 1000000000000 // Missing articleUrl }; mockLocalStorage.getItem.mockReturnValue(JSON.stringify(dataWithoutUrl)); const result = getValidConversionData(); expect(result).toBeNull(); jest.restoreAllMocks(); }); it('should return null and remove data when articleUrl does not match current URL', () => { const fixedTime = 1000000000000; jest.spyOn(Date, 'now').mockReturnValue(fixedTime); const dataWithDifferentUrl = { ...validConversionData, articleUrl: '/different-article-path' }; mockLocalStorage.getItem.mockReturnValue( JSON.stringify(dataWithDifferentUrl) ); const result = getValidConversionData(); expect(result).toBeNull(); jest.restoreAllMocks(); }); it('should return valid data when articleUrl matches current URL', () => { const fixedTime = 1000000000000; jest.spyOn(Date, 'now').mockReturnValue(fixedTime); mockLocalStorage.getItem.mockReturnValue( JSON.stringify(validConversionData) ); const result = getValidConversionData(); expect(result).toBeNull(); }); }); describe('trackAlgoliaConversionWithDwellTime', () => { beforeEach(() => { jest.useFakeTimers(); jest.spyOn(Date, 'now').mockReturnValue(1000000000000); mockAa.mockClear(); (document.addEventListener as jest.Mock).mockClear(); (window.addEventListener as jest.Mock).mockClear(); }); afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); }); it('should initialize search-insights with provided data using initializeAlgoliaEvent', () => { mockLocalStorage.getItem.mockReturnValue('existing-hash'); trackAlgoliaConversionWithDwellTime(validConversionData); expect(mockAa).toHaveBeenCalledWith('init', { appId: 'test-app-id', apiKey: 'test-api-key', userToken: 'existing-hash' }); }); it('should initialize search-insights with provided data', () => { mockLocalStorage.getItem.mockReturnValue(null); trackAlgoliaConversionWithDwellTime(validConversionData); expect(mockAa).toHaveBeenCalledWith( 'init', expect.objectContaining({ appId: 'test-app-id', apiKey: 'test-api-key' }) ); }); it('should return early if search-insights initialization fails', () => { mockAa.mockImplementationOnce((method: string) => { if (method === 'init') { throw new Error('Init failed'); } }); trackAlgoliaConversionWithDwellTime(validConversionData); expect(mockAa).toHaveBeenCalledWith('init', expect.anything()); expect(document.addEventListener).not.toHaveBeenCalled(); }); it('should set up dwell time tracking with valid data', () => { trackAlgoliaConversionWithDwellTime(validConversionData); expect(mockAa).toHaveBeenCalledWith('init', expect.anything()); expect(document.addEventListener).toHaveBeenCalledWith( 'visibilitychange', expect.any(Function) ); expect(window.addEventListener).toHaveBeenCalledWith( 'beforeunload', expect.any(Function) ); }); it('should track conversion after 8 seconds of dwell time', () => { let currentTime = 1000000000000; (Date.now as jest.Mock).mockImplementation(() => currentTime); trackAlgoliaConversionWithDwellTime(validConversionData); expect(mockAa).toHaveBeenCalledWith('init', { appId: 'test-app-id', apiKey: 'test-api-key' }); for (let i = 0; i < 8; i++) { currentTime += 1000; (Date.now as jest.Mock).mockReturnValue(currentTime); jest.advanceTimersByTime(1000); } expect(mockAa).toHaveBeenCalledWith('convertedObjectIDsAfterSearch', { eventName: 'Test Event', index: 'test-index', queryID: 'test-query-456', objectIDs: ['test-object-123'], analyticsTags: ['web'] }); }); it('should not track conversion before 8 seconds', () => { let currentTime = 1000000000000; (Date.now as jest.Mock).mockImplementation(() => currentTime); trackAlgoliaConversionWithDwellTime(validConversionData); for (let i = 0; i < 7; i++) { currentTime += 1000; (Date.now as jest.Mock).mockReturnValue(currentTime); jest.advanceTimersByTime(1000); } expect(mockAa).not.toHaveBeenCalledWith( 'convertedObjectIDsAfterSearch', expect.anything() ); }); it('should set up proper event listeners for tracking', () => { trackAlgoliaConversionWithDwellTime(validConversionData); expect(document.addEventListener).toHaveBeenCalledWith( 'visibilitychange', expect.any(Function) ); expect(window.addEventListener).toHaveBeenCalledWith( 'beforeunload', expect.any(Function) ); }); it('should pause and resume dwell time tracking on visibility changes', () => { let currentTime = 1000000000000; (Date.now as jest.Mock).mockImplementation(() => currentTime); trackAlgoliaConversionWithDwellTime(validConversionData); const visibilityChangeCallback = (document.addEventListener as jest.Mock).mock.calls.find( call => call[0] === 'visibilitychange' )[1]; // Simulate 4 seconds of visible time for (let i = 0; i < 4; i++) { currentTime += 1000; (Date.now as jest.Mock).mockReturnValue(currentTime); jest.advanceTimersByTime(1000); } // Hide the page Object.defineProperty(document, 'hidden', { value: true, writable: true }); visibilityChangeCallback(); // Advance time while hidden (shouldn't count toward dwell time) for (let i = 0; i < 6; i++) { currentTime += 1000; (Date.now as jest.Mock).mockReturnValue(currentTime); jest.advanceTimersByTime(1000); } // Show the page again Object.defineProperty(document, 'hidden', { value: false, writable: true }); visibilityChangeCallback(); // Advance 4 more seconds (total visible time: 8 seconds) for (let i = 0; i < 4; i++) { currentTime += 1000; (Date.now as jest.Mock).mockReturnValue(currentTime); jest.advanceTimersByTime(1000); } expect(mockAa).toHaveBeenCalledWith('convertedObjectIDsAfterSearch', { eventName: 'Test Event', index: 'test-index', queryID: 'test-query-456', objectIDs: ['test-object-123'], analyticsTags: ['web'] }); }); it('should clean up timer when conversion is tracked', () => { let currentTime = 1000000000000; (Date.now as jest.Mock).mockImplementation(() => currentTime); const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); trackAlgoliaConversionWithDwellTime(validConversionData); for (let i = 0; i < 8; i++) { currentTime += 1000; (Date.now as jest.Mock).mockReturnValue(currentTime); jest.advanceTimersByTime(1000); } expect(clearIntervalSpy).toHaveBeenCalled(); clearIntervalSpy.mockRestore(); }); it('should handle beforeunload without tracking if dwell time not reached', () => { let currentTime = 1000000000000; (Date.now as jest.Mock).mockImplementation(() => currentTime); trackAlgoliaConversionWithDwellTime(validConversionData); const beforeunloadCallback = (window.addEventListener as jest.Mock).mock.calls.find( call => call[0] === 'beforeunload' )[1]; // Only 5 seconds of dwell time for (let i = 0; i < 5; i++) { currentTime += 1000; (Date.now as jest.Mock).mockReturnValue(currentTime); jest.advanceTimersByTime(1000); } beforeunloadCallback(); expect(mockAa).not.toHaveBeenCalledWith( 'convertedObjectIDsAfterSearch', expect.anything() ); }); }); });