diff --git a/src/inbox/components/IterableInboxMessageDisplay.test.tsx b/src/inbox/components/IterableInboxMessageDisplay.test.tsx new file mode 100644 index 000000000..73c7a054b --- /dev/null +++ b/src/inbox/components/IterableInboxMessageDisplay.test.tsx @@ -0,0 +1,1071 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; + +import { IterableEdgeInsets } from '../../core'; +import { + IterableInAppMessage, + IterableInAppTrigger, + IterableInboxMetadata, +} from '../../inApp/classes'; +import { IterableHtmlInAppContent } from '../../inApp/classes/IterableHtmlInAppContent'; +import { IterableInAppTriggerType } from '../../inApp/enums'; +import type { IterableInboxRowViewModel } from '../types'; +import { + IterableInboxMessageDisplay, + iterableMessageDisplayTestIds, +} from './IterableInboxMessageDisplay'; + +// Suppress act() warnings for this test suite since they're expected from the component's useEffect +const originalError = console.error; +beforeAll(() => { + console.error = jest.fn(); +}); + +afterAll(() => { + console.error = originalError; +}); + +// Mock the Iterable class +jest.mock('../../core/classes/Iterable', () => ({ + Iterable: { + trackInAppClick: jest.fn(), + trackInAppClose: jest.fn(), + savedConfig: { + customActionHandler: jest.fn(), + urlHandler: jest.fn(), + }, + }, +})); + +// Mock Linking +jest.mock('react-native', () => ({ + ...jest.requireActual('react-native'), + Linking: { + openURL: jest.fn(), + }, +})); + +// Mock WebView +jest.mock('react-native-webview', () => { + const { View, Text } = require('react-native'); + + const MockWebView = ({ + onMessage, + injectedJavaScript, + source, + ...props + }: { + onMessage?: (event: { nativeEvent: { data: string } }) => void; + injectedJavaScript?: string; + source?: { html: string }; + [key: string]: unknown; + }) => ( + + {source?.html} + {injectedJavaScript} + { + if (onMessage) { + onMessage({ + nativeEvent: { + data: 'https://example.com', + }, + }); + } + }} + > + Trigger Message + + { + if (onMessage) { + onMessage({ + nativeEvent: { + data: 'iterable://delete', + }, + }); + } + }} + > + Trigger Delete + + { + if (onMessage) { + onMessage({ + nativeEvent: { + data: 'iterable://dismiss', + }, + }); + } + }} + > + Trigger Dismiss + + { + if (onMessage) { + onMessage({ + nativeEvent: { + data: 'action://customAction', + }, + }); + } + }} + > + Trigger Custom Action + + { + if (onMessage) { + onMessage({ + nativeEvent: { + data: 'myapp://deep-link', + }, + }); + } + }} + > + Trigger Deep Link + + + ); + + MockWebView.displayName = 'MockWebView'; + + return { + WebView: MockWebView, + }; +}); + +describe('IterableInboxMessageDisplay', () => { + const mockMessage = new IterableInAppMessage( + 'test-message-id', + 123, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date('2023-01-01T00:00:00Z'), + undefined, + true, + new IterableInboxMetadata( + 'Test Message Title', + 'Test Subtitle', + 'test-image.png' + ), + undefined, + false, + 0 + ); + + const mockRowViewModel: IterableInboxRowViewModel = { + inAppMessage: mockMessage, + title: 'Test Message Title', + subtitle: 'Test Subtitle', + imageUrl: 'test-image.png', + read: false, + createdAt: new Date('2023-01-01T00:00:00Z'), + }; + + const mockHtmlContent = new IterableHtmlInAppContent( + new IterableEdgeInsets(10, 10, 10, 10), + '

Test HTML Content

Test Link' + ); + + const defaultProps = { + rowViewModel: mockRowViewModel, + inAppContentPromise: Promise.resolve(mockHtmlContent), + returnToInbox: jest.fn(), + deleteRow: jest.fn(), + contentWidth: 300, + isPortrait: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Basic Rendering', () => { + it('should render without crashing with valid props', () => { + expect(() => + render() + ).not.toThrow(); + }); + + it('should render the message title', () => { + const { getByText } = render( + + ); + expect(getByText('Test Message Title')).toBeTruthy(); + }); + + it('should render the return button with "Inbox" text', () => { + const { getByText } = render( + + ); + expect(getByText('Inbox')).toBeTruthy(); + }); + + it('should render the return button', () => { + const { getByTestId } = render( + + ); + expect( + getByTestId(iterableMessageDisplayTestIds.returnButton) + ).toBeTruthy(); + }); + + it('should handle missing message title gracefully', () => { + const messageWithoutTitle = new IterableInAppMessage( + 'test-message-id', + 123, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date('2023-01-01T00:00:00Z'), + undefined, + true, + undefined, // No inbox metadata + undefined, + false, + 0 + ); + + const rowViewModelWithoutTitle: IterableInboxRowViewModel = { + ...mockRowViewModel, + inAppMessage: messageWithoutTitle, + }; + + const propsWithoutTitle = { + ...defaultProps, + rowViewModel: rowViewModelWithoutTitle, + }; + + expect(() => + render() + ).not.toThrow(); + }); + }); + + describe('Async Content Loading', () => { + it('should show content after inAppContentPromise resolves', async () => { + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + expect(getByTestId('webview-source')).toHaveTextContent( + '

Test HTML Content

Test Link' + ); + }); + + it('should handle rejected inAppContentPromise', async () => { + // Since the component doesn't handle promise rejections, we'll test that it doesn't crash + // when the promise never resolves (which simulates a network failure) + const neverResolvingPromise = new Promise( + () => { + // Never resolve or reject - simulates a hanging network request + } + ); + + const propsWithNeverResolvingPromise = { + ...defaultProps, + inAppContentPromise: neverResolvingPromise, + }; + + const { queryByTestId } = render( + + ); + + // Component should render without crashing + // The component always renders the header, so we can check that the WebView is not shown + // since the promise never resolves and inAppContent remains undefined + expect(queryByTestId(iterableMessageDisplayTestIds.webview)).toBeFalsy(); + }); + + it('should handle component unmounting before promise resolves', async () => { + const slowPromise = new Promise((resolve) => { + setTimeout(() => resolve(mockHtmlContent), 1000); + }); + + const propsWithSlowPromise = { + ...defaultProps, + inAppContentPromise: slowPromise, + }; + + const { unmount } = render( + + ); + + // Unmount before promise resolves + unmount(); + + // Should not crash + await waitFor(() => { + expect(true).toBe(true); // Just ensure we don't crash + }); + }); + }); + + describe('Return Button Interaction', () => { + it('should call returnToInbox when return button is pressed', () => { + const mockReturnToInbox = jest.fn(); + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByText } = render( + + ); + const returnButton = getByText('Inbox'); + + fireEvent.press(returnButton); + + expect(mockReturnToInbox).toHaveBeenCalledTimes(1); + expect(mockReturnToInbox).toHaveBeenCalledWith(); + }); + + it('should track in-app close with back source when return button is pressed', () => { + const { Iterable } = require('../../core/classes/Iterable'); + const { getByText } = render( + + ); + const returnButton = getByText('Inbox'); + + fireEvent.press(returnButton); + + expect(Iterable.trackInAppClose).toHaveBeenCalledWith( + mockRowViewModel.inAppMessage, + 1, // IterableInAppLocation.inbox + 0 // IterableInAppCloseSource.back + ); + }); + }); + + describe('WebView Message Handling', () => { + it('should handle external HTTP links', async () => { + const mockReturnToInbox = jest.fn(); + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const messageTrigger = getByTestId('webview-message-trigger'); + fireEvent.press(messageTrigger); + + expect(mockReturnToInbox).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should handle delete action', async () => { + const mockReturnToInbox = jest.fn(); + const mockDeleteRow = jest.fn(); + const propsWithMocks = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + deleteRow: mockDeleteRow, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const deleteTrigger = getByTestId('webview-delete-trigger'); + fireEvent.press(deleteTrigger); + + expect(mockReturnToInbox).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should handle dismiss action', async () => { + const mockReturnToInbox = jest.fn(); + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const dismissTrigger = getByTestId('webview-dismiss-trigger'); + fireEvent.press(dismissTrigger); + + expect(mockReturnToInbox).toHaveBeenCalledWith(); + }); + + it('should handle custom action', async () => { + const mockReturnToInbox = jest.fn(); + const { Iterable } = require('../../core/classes/Iterable'); + const mockCustomActionHandler = jest.fn(); + Iterable.savedConfig.customActionHandler = mockCustomActionHandler; + + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const customActionTrigger = getByTestId('webview-custom-action-trigger'); + fireEvent.press(customActionTrigger); + + expect(mockReturnToInbox).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should handle deep link', async () => { + const mockReturnToInbox = jest.fn(); + const { Iterable } = require('../../core/classes/Iterable'); + const mockUrlHandler = jest.fn(); + Iterable.savedConfig.urlHandler = mockUrlHandler; + + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const deepLinkTrigger = getByTestId('webview-deep-link-trigger'); + fireEvent.press(deepLinkTrigger); + + expect(mockReturnToInbox).toHaveBeenCalledWith(expect.any(Function)); + }); + + // Additional comprehensive tests for the specific lines highlighted + it('should execute deleteRow callback when delete action is triggered', async () => { + const mockReturnToInbox = jest.fn(); + const mockDeleteRow = jest.fn(); + const propsWithMocks = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + deleteRow: mockDeleteRow, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const deleteTrigger = getByTestId('webview-delete-trigger'); + fireEvent.press(deleteTrigger); + + // Verify returnToInbox is called with a callback + expect(mockReturnToInbox).toHaveBeenCalledWith(expect.any(Function)); + + // Execute the callback to verify deleteRow is called with correct messageId + const callback = mockReturnToInbox.mock.calls[0][0]; + callback(); + expect(mockDeleteRow).toHaveBeenCalledWith('test-message-id'); + }); + + it('should call returnToInbox without callback for dismiss action', async () => { + const mockReturnToInbox = jest.fn(); + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const dismissTrigger = getByTestId('webview-dismiss-trigger'); + fireEvent.press(dismissTrigger); + + // Verify returnToInbox is called without any arguments (no callback) + expect(mockReturnToInbox).toHaveBeenCalledWith(); + }); + + it('should call Linking.openURL for HTTP URLs', async () => { + const mockReturnToInbox = jest.fn(); + const { Linking } = require('react-native'); + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const messageTrigger = getByTestId('webview-message-trigger'); + fireEvent.press(messageTrigger); + + // Verify returnToInbox is called with a callback + expect(mockReturnToInbox).toHaveBeenCalledWith(expect.any(Function)); + + // Execute the callback to verify Linking.openURL is called + const callback = mockReturnToInbox.mock.calls[0][0]; + callback(); + expect(Linking.openURL).toHaveBeenCalledWith('https://example.com'); + }); + + it('should call customActionHandler with correct action and context', async () => { + const mockReturnToInbox = jest.fn(); + const { Iterable } = require('../../core/classes/Iterable'); + const mockCustomActionHandler = jest.fn(); + Iterable.savedConfig.customActionHandler = mockCustomActionHandler; + + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const customActionTrigger = getByTestId('webview-custom-action-trigger'); + fireEvent.press(customActionTrigger); + + // Verify returnToInbox is called with a callback + expect(mockReturnToInbox).toHaveBeenCalledWith(expect.any(Function)); + + // Execute the callback to verify customActionHandler is called + const callback = mockReturnToInbox.mock.calls[0][0]; + callback(); + expect(mockCustomActionHandler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'customAction', + data: 'action://customAction', + userInput: '', + }), + expect.objectContaining({ + action: expect.objectContaining({ + type: 'customAction', + data: 'action://customAction', + userInput: '', + }), + source: 2, // IterableActionSource.inApp + }) + ); + }); + + it('should call urlHandler with correct URL and context for deep links', async () => { + const mockReturnToInbox = jest.fn(); + const { Iterable } = require('../../core/classes/Iterable'); + const mockUrlHandler = jest.fn(); + Iterable.savedConfig.urlHandler = mockUrlHandler; + + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const deepLinkTrigger = getByTestId('webview-deep-link-trigger'); + fireEvent.press(deepLinkTrigger); + + // Verify returnToInbox is called with a callback + expect(mockReturnToInbox).toHaveBeenCalledWith(expect.any(Function)); + + // Execute the callback to verify urlHandler is called + const callback = mockReturnToInbox.mock.calls[0][0]; + callback(); + expect(mockUrlHandler).toHaveBeenCalledWith( + 'myapp://deep-link', + expect.objectContaining({ + action: expect.objectContaining({ + type: 'openUrl', + data: 'myapp://deep-link', + userInput: '', + }), + source: 2, // IterableActionSource.inApp + }) + ); + }); + + it('should handle missing customActionHandler gracefully', async () => { + const mockReturnToInbox = jest.fn(); + const { Iterable } = require('../../core/classes/Iterable'); + // Set customActionHandler to undefined + Iterable.savedConfig.customActionHandler = undefined; + + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const customActionTrigger = getByTestId('webview-custom-action-trigger'); + + // Should not throw an error even when customActionHandler is undefined + expect(() => fireEvent.press(customActionTrigger)).not.toThrow(); + + // Verify returnToInbox is still called + expect(mockReturnToInbox).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should handle missing urlHandler gracefully', async () => { + const mockReturnToInbox = jest.fn(); + const { Iterable } = require('../../core/classes/Iterable'); + // Set urlHandler to undefined + Iterable.savedConfig.urlHandler = undefined; + + const propsWithMockReturn = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const deepLinkTrigger = getByTestId('webview-deep-link-trigger'); + + // Should not throw an error even when urlHandler is undefined + expect(() => fireEvent.press(deepLinkTrigger)).not.toThrow(); + + // Verify returnToInbox is still called + expect(mockReturnToInbox).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + describe('Tracking and Analytics', () => { + it('should track in-app click when link is clicked', async () => { + const { Iterable } = require('../../core/classes/Iterable'); + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const messageTrigger = getByTestId('webview-message-trigger'); + fireEvent.press(messageTrigger); + + expect(Iterable.trackInAppClick).toHaveBeenCalledWith( + mockRowViewModel.inAppMessage, + 1, // IterableInAppLocation.inbox + 'https://example.com' + ); + }); + + it('should track in-app close with link source when link is clicked', async () => { + const { Iterable } = require('../../core/classes/Iterable'); + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + const messageTrigger = getByTestId('webview-message-trigger'); + fireEvent.press(messageTrigger); + + expect(Iterable.trackInAppClose).toHaveBeenCalledWith( + mockRowViewModel.inAppMessage, + 1, // IterableInAppLocation.inbox + 1, // IterableInAppCloseSource.link + 'https://example.com' + ); + }); + }); + + describe('Props Variations', () => { + it('should handle different content widths', () => { + const propsWithDifferentWidth = { ...defaultProps, contentWidth: 600 }; + expect(() => + render() + ).not.toThrow(); + }); + + it('should handle portrait mode', () => { + const portraitProps = { ...defaultProps, isPortrait: true }; + expect(() => + render() + ).not.toThrow(); + }); + + it('should handle landscape mode', () => { + const landscapeProps = { ...defaultProps, isPortrait: false }; + expect(() => + render() + ).not.toThrow(); + }); + + it('should handle zero content width', () => { + const zeroWidthProps = { ...defaultProps, contentWidth: 0 }; + expect(() => + render() + ).not.toThrow(); + }); + + it('should handle negative content width', () => { + const negativeWidthProps = { ...defaultProps, contentWidth: -100 }; + expect(() => + render() + ).not.toThrow(); + }); + + it('should handle very large content width', () => { + const largeWidthProps = { ...defaultProps, contentWidth: 2000 }; + expect(() => + render() + ).not.toThrow(); + }); + }); + + describe('Function Props', () => { + it('should handle returnToInbox function', () => { + const mockReturnToInbox = jest.fn(); + const propsWithReturnToInbox = { + ...defaultProps, + returnToInbox: mockReturnToInbox, + }; + + expect(() => + render() + ).not.toThrow(); + }); + + it('should handle deleteRow function', () => { + const mockDeleteRow = jest.fn(); + const propsWithDeleteRow = { ...defaultProps, deleteRow: mockDeleteRow }; + + expect(() => + render() + ).not.toThrow(); + }); + + it('should handle undefined function props gracefully', () => { + const propsWithUndefinedFunctions = { + ...defaultProps, + returnToInbox: undefined as unknown as (callback?: () => void) => void, + deleteRow: undefined as unknown as (id: string) => void, + }; + + expect(() => + render() + ).not.toThrow(); + }); + }); + + describe('WebView Configuration', () => { + it('should configure WebView with correct props', async () => { + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + // Check that injected JavaScript is present + const jsContent = getByTestId('webview-js').props.children; + expect(jsContent).toContain( + "const links = document.querySelectorAll('a')" + ); + expect(jsContent).toContain('links.forEach(link => {'); + expect(jsContent).toContain('window.ReactNativeWebView.postMessage'); + }); + + it('should set correct originWhiteList', async () => { + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + // The WebView should be rendered with the correct configuration + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty HTML content', async () => { + const emptyHtmlContent = new IterableHtmlInAppContent( + new IterableEdgeInsets(0, 0, 0, 0), + '' + ); + + const propsWithEmptyContent = { + ...defaultProps, + inAppContentPromise: Promise.resolve(emptyHtmlContent), + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + expect(getByTestId('webview-source')).toHaveTextContent(''); + }); + + it('should handle HTML content with special characters', async () => { + const specialHtmlContent = new IterableHtmlInAppContent( + new IterableEdgeInsets(10, 10, 10, 10), + '

测试标题 🚀

测试链接' + ); + + const propsWithSpecialContent = { + ...defaultProps, + inAppContentPromise: Promise.resolve(specialHtmlContent), + }; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + expect(getByTestId('webview-source')).toHaveTextContent( + '

测试标题 🚀

测试链接' + ); + }); + + it('should handle very long message titles', () => { + const longTitle = 'A'.repeat(1000); + const messageWithLongTitle = new IterableInAppMessage( + 'test-message-id', + 123, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date('2023-01-01T00:00:00Z'), + undefined, + true, + new IterableInboxMetadata(longTitle, 'Test Subtitle', 'test-image.png'), + undefined, + false, + 0 + ); + + const rowViewModelWithLongTitle: IterableInboxRowViewModel = { + ...mockRowViewModel, + inAppMessage: messageWithLongTitle, + title: longTitle, + }; + + const propsWithLongTitle = { + ...defaultProps, + rowViewModel: rowViewModelWithLongTitle, + }; + + expect(() => + render() + ).not.toThrow(); + }); + + it('should handle message with no inbox metadata', () => { + const messageWithoutMetadata = new IterableInAppMessage( + 'test-message-id', + 123, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date('2023-01-01T00:00:00Z'), + undefined, + true, + undefined, // No inbox metadata + undefined, + false, + 0 + ); + + const rowViewModelWithoutMetadata: IterableInboxRowViewModel = { + ...mockRowViewModel, + inAppMessage: messageWithoutMetadata, + }; + + const propsWithoutMetadata = { + ...defaultProps, + rowViewModel: rowViewModelWithoutMetadata, + }; + + expect(() => + render() + ).not.toThrow(); + }); + }); + + describe('Performance Considerations', () => { + it('should handle rapid prop changes', async () => { + const { rerender, getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + // Change props rapidly + const newProps1 = { ...defaultProps, contentWidth: 400 }; + const newProps2 = { ...defaultProps, isPortrait: false }; + const newProps3 = { + ...defaultProps, + contentWidth: 500, + isPortrait: true, + }; + + expect(() => { + rerender(); + rerender(); + rerender(); + }).not.toThrow(); + }); + + it('should handle multiple message displays efficiently', () => { + const messages = Array.from({ length: 10 }, (_, i) => { + const message = new IterableInAppMessage( + `message-${i}`, + i, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + undefined, + true, + new IterableInboxMetadata( + `Title ${i}`, + `Subtitle ${i}`, + `image${i}.png` + ), + undefined, + false, + i + ); + + return { + inAppMessage: message, + title: `Title ${i}`, + subtitle: `Subtitle ${i}`, + imageUrl: `image${i}.png`, + read: false, + createdAt: new Date(), + } as IterableInboxRowViewModel; + }); + + messages.forEach((rowViewModel) => { + const props = { + ...defaultProps, + rowViewModel, + }; + + expect(() => + render() + ).not.toThrow(); + }); + }); + }); + + describe('Integration with Iterable SDK', () => { + it('should work with Iterable.savedConfig.customActionHandler', async () => { + const { Iterable } = require('../../core/classes/Iterable'); + const mockCustomActionHandler = jest.fn(); + Iterable.savedConfig.customActionHandler = mockCustomActionHandler; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + // The component should render without errors when customActionHandler is set + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + it('should work with Iterable.savedConfig.urlHandler', async () => { + const { Iterable } = require('../../core/classes/Iterable'); + const mockUrlHandler = jest.fn(); + Iterable.savedConfig.urlHandler = mockUrlHandler; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + // The component should render without errors when urlHandler is set + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + it('should handle missing Iterable.savedConfig handlers', async () => { + const { Iterable } = require('../../core/classes/Iterable'); + Iterable.savedConfig.customActionHandler = undefined; + Iterable.savedConfig.urlHandler = undefined; + + const { getByTestId } = render( + + ); + + await waitFor(() => { + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + + // The component should render without errors when handlers are undefined + expect(getByTestId(iterableMessageDisplayTestIds.webview)).toBeTruthy(); + }); + }); +}); diff --git a/src/inbox/components/IterableInboxMessageDisplay.tsx b/src/inbox/components/IterableInboxMessageDisplay.tsx index d42306a04..b52eb47c3 100644 --- a/src/inbox/components/IterableInboxMessageDisplay.tsx +++ b/src/inbox/components/IterableInboxMessageDisplay.tsx @@ -26,6 +26,13 @@ import { ITERABLE_INBOX_COLORS } from '../constants'; import { type IterableInboxRowViewModel } from '../types'; import { HeaderBackButton } from './HeaderBackButton'; +export const iterableMessageDisplayTestIds = { + container: 'iterable-message-display-container', + returnButton: 'iterable-message-display-return-button', + messageTitle: 'iterable-message-display-message-title', + webview: 'iterable-message-display-webview', +}; + /** * Props for the IterableInboxMessageDisplay component. */ @@ -85,7 +92,7 @@ export const IterableInboxMessageDisplay = ({ header: { flexDirection: 'row', - height: Platform.OS === 'ios' ? 44 : 56, + height: Platform.OS === 'ios' ? 44 : 56, justifyContent: 'center', width: '100%', }, @@ -203,10 +210,14 @@ export const IterableInboxMessageDisplay = ({ } return ( - + { returnToInbox(); @@ -224,6 +235,7 @@ export const IterableInboxMessageDisplay = ({ numberOfLines={1} ellipsizeMode="tail" style={styles.messageTitleText} + testID={iterableMessageDisplayTestIds.messageTitle} > {messageTitle} @@ -233,6 +245,7 @@ export const IterableInboxMessageDisplay = ({ {inAppContent && (