diff --git a/.gitignore b/.gitignore index 80c9d66d6..de804bf93 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,5 @@ docs/ .agent/ .claude/ .cursor/ +.opencode/ .pi/ diff --git a/jest.config.js b/jest.config.js index 9c045a30a..b8b005063 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,10 +20,28 @@ module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], coverageThreshold: { global: { - branches: 10, - functions: 30, - lines: 30, - statements: 30, + branches: 53, + functions: 47, + lines: 58, + statements: 58, + }, + 'src/core/classes/Iterable.ts': { + statements: 90, + branches: 85, + functions: 95, + lines: 90, + }, + 'src/core/classes/IterableApi.ts': { + statements: 95, + branches: 70, + functions: 95, + lines: 95, + }, + 'src/core/classes/IterableConfig.ts': { + statements: 95, + branches: 90, + functions: 90, + lines: 95, }, }, }; diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 0f7012a87..e72881bb1 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1,3 +1,210 @@ -describe('index', () => { - it.todo('write a test'); +import { + Iterable, + IterableAction, + IterableActionContext, + IterableActionSource, + IterableAttributionInfo, + IterableAuthFailureReason, + IterableAuthResponse, + IterableAuthResponseResult, + IterableCommerceItem, + IterableConfig, + IterableDataRegion, + IterableEdgeInsets, + IterableEmbeddedManager, + IterableEmbeddedView, + IterableEmbeddedViewType, + IterableHtmlInAppContent, + IterableInAppCloseSource, + IterableInAppContentType, + IterableInAppDeleteSource, + IterableInAppLocation, + IterableInAppManager, + IterableInAppMessage, + IterableInAppShowResponse, + IterableInAppTrigger, + IterableInAppTriggerType, + IterableInbox, + IterableInboxDataModel, + IterableInboxEmptyState, + IterableInboxMessageCell, + IterableInboxMetadata, + IterableLogLevel, + IterableLogger, + IterablePushPlatform, + IterableRetryBackoff, + useAppStateListener, + useDeviceOrientation, +} from '../index'; + +describe('public SDK surface', () => { + it('exports the static facade', () => { + expect(Iterable).toBeDefined(); + expect(typeof Iterable.initialize).toBe('function'); + }); + + it('exports core model classes', () => { + expect(IterableAction).toBeDefined(); + expect(IterableActionContext).toBeDefined(); + expect(IterableAttributionInfo).toBeDefined(); + expect(IterableAuthResponse).toBeDefined(); + expect(IterableCommerceItem).toBeDefined(); + expect(IterableConfig).toBeDefined(); + expect(IterableEdgeInsets).toBeDefined(); + expect(IterableLogger).toBeDefined(); + }); + + it('exports core enums', () => { + expect(IterableActionSource).toBeDefined(); + expect(IterableAuthFailureReason).toBeDefined(); + expect(IterableAuthResponseResult).toBeDefined(); + expect(IterableDataRegion).toBeDefined(); + expect(IterableLogLevel).toBeDefined(); + expect(IterablePushPlatform).toBeDefined(); + expect(IterableRetryBackoff).toBeDefined(); + }); + + it('exports in-app model classes and enums', () => { + expect(IterableHtmlInAppContent).toBeDefined(); + expect(IterableInAppManager).toBeDefined(); + expect(IterableInAppMessage).toBeDefined(); + expect(IterableInAppTrigger).toBeDefined(); + expect(IterableInboxMetadata).toBeDefined(); + expect(IterableInAppCloseSource).toBeDefined(); + expect(IterableInAppContentType).toBeDefined(); + expect(IterableInAppDeleteSource).toBeDefined(); + expect(IterableInAppLocation).toBeDefined(); + expect(IterableInAppShowResponse).toBeDefined(); + expect(IterableInAppTriggerType).toBeDefined(); + }); + + it('exports inbox components and types', () => { + expect(IterableInbox).toBeDefined(); + expect(IterableInboxDataModel).toBeDefined(); + expect(IterableInboxEmptyState).toBeDefined(); + expect(IterableInboxMessageCell).toBeDefined(); + }); + + it('exports embedded messaging surface', () => { + expect(IterableEmbeddedManager).toBeDefined(); + expect(IterableEmbeddedView).toBeDefined(); + expect(IterableEmbeddedViewType).toBeDefined(); + }); + + it('exports hooks', () => { + expect(useAppStateListener).toBeDefined(); + expect(useDeviceOrientation).toBeDefined(); + }); + + describe('exported enum values', () => { + it('IterableActionSource contains expected members', () => { + expect(IterableActionSource.push).toBe(0); + expect(IterableActionSource.appLink).toBe(1); + expect(IterableActionSource.inApp).toBe(2); + expect(IterableActionSource.embedded).toBe(3); + }); + + it('IterableInAppCloseSource contains expected members', () => { + expect(IterableInAppCloseSource.back).toBe(0); + expect(IterableInAppCloseSource.link).toBe(1); + expect(IterableInAppCloseSource.unknown).toBe(100); + }); + + it('IterableInAppLocation contains expected members', () => { + expect(IterableInAppLocation.inApp).toBe(0); + expect(IterableInAppLocation.inbox).toBe(1); + }); + + it('IterableInAppShowResponse contains expected members', () => { + expect(IterableInAppShowResponse.show).toBe(0); + expect(IterableInAppShowResponse.skip).toBe(1); + }); + + it('IterableInAppTriggerType contains expected members', () => { + expect(IterableInAppTriggerType.immediate).toBe(0); + expect(IterableInAppTriggerType.event).toBe(1); + expect(IterableInAppTriggerType.never).toBe(2); + }); + + it('IterableDataRegion contains expected members', () => { + expect(IterableDataRegion.US).toBe(0); + expect(IterableDataRegion.EU).toBe(1); + }); + + it('IterableLogLevel contains expected members', () => { + expect(IterableLogLevel.error).toBe(3); + expect(IterableLogLevel.debug).toBe(1); + expect(IterableLogLevel.info).toBe(2); + }); + + it('IterablePushPlatform contains expected members', () => { + expect(IterablePushPlatform.sandbox).toBe(0); + expect(IterablePushPlatform.production).toBe(1); + expect(IterablePushPlatform.auto).toBe(2); + }); + + it('IterableRetryBackoff contains expected members', () => { + expect(IterableRetryBackoff.linear).toBe('LINEAR'); + expect(IterableRetryBackoff.exponential).toBe('EXPONENTIAL'); + }); + + it('IterableEmbeddedViewType contains expected members', () => { + expect(IterableEmbeddedViewType.Banner).toBe(0); + expect(IterableEmbeddedViewType.Card).toBe(1); + expect(IterableEmbeddedViewType.Notification).toBe(2); + }); + }); + + describe('exported hooks are functions', () => { + it('useAppStateListener is a function', () => { + expect(typeof useAppStateListener).toBe('function'); + }); + + it('useDeviceOrientation is a function', () => { + expect(typeof useDeviceOrientation).toBe('function'); + }); + }); + + describe('exported components are valid React components', () => { + it('IterableInbox is a valid React component', () => { + expect(IterableInbox).toBeDefined(); + // React components are functions or classes (forwardRef objects expose a render fn) + const type = typeof IterableInbox; + expect( + type === 'function' || + type === 'object' || + type === 'symbol' + ).toBe(true); + }); + + it('IterableInboxEmptyState is a valid React component', () => { + expect(IterableInboxEmptyState).toBeDefined(); + const type = typeof IterableInboxEmptyState; + expect( + type === 'function' || + type === 'object' || + type === 'symbol' + ).toBe(true); + }); + + it('IterableInboxMessageCell is a valid React component', () => { + expect(IterableInboxMessageCell).toBeDefined(); + const type = typeof IterableInboxMessageCell; + expect( + type === 'function' || + type === 'object' || + type === 'symbol' + ).toBe(true); + }); + + it('IterableEmbeddedView is a valid React component', () => { + expect(IterableEmbeddedView).toBeDefined(); + const type = typeof IterableEmbeddedView; + expect( + type === 'function' || + type === 'object' || + type === 'symbol' + ).toBe(true); + }); + }); }); diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 01c24b13d..ce5c42967 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -1471,4 +1471,266 @@ describe('Iterable', () => { }); }); }); + + describe('getAttributionInfo', () => { + it('should return undefined when native returns null', async () => { + // GIVEN native returns null attribution info + const spy = jest + .spyOn(MockRNIterableAPI, 'getAttributionInfo') + // @ts-expect-error - native bridge may return null for missing attribution info + .mockResolvedValue(null); + + // WHEN getAttributionInfo is called + const result = await Iterable.getAttributionInfo(); + + // THEN undefined is returned + expect(result).toBeUndefined(); + spy.mockRestore(); + }); + }); + + describe('trackInAppOpen validation', () => { + it('should skip tracking when the message ID is missing', () => { + // GIVEN an in-app message without a message ID + const message = new IterableInAppMessage( + '', + 4567, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + + // WHEN trackInAppOpen is called + Iterable.trackInAppOpen(message, location); + + // THEN RNIterableAPI.trackInAppOpen is not called + expect(MockRNIterableAPI.trackInAppOpen).not.toBeCalled(); + }); + }); + + describe('urlHandler platform delay', () => { + const originalPlatform = Platform.OS; + + afterEach(() => { + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, + }); + }); + + it('should call the handler immediately on iOS', async () => { + // GIVEN iOS platform + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleUrlCalled); + + // sets up config with urlHandler that returns false + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.urlHandler = jest.fn(() => false); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + // GIVEN the link can be opened + MockLinking.canOpenURL = jest.fn(async () => true); + MockLinking.openURL.mockReset(); + + const expectedUrl = 'https://somewhere.com'; + const dict = { + url: expectedUrl, + context: { + action: { type: 'openUrl' }, + source: IterableActionSource.inApp, + }, + }; + + // WHEN handleUrlCalled event is emitted + nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); + + // THEN the handler and Linking are called without a delay + return await TestHelper.delayed(0, () => { + expect(config.urlHandler).toBeCalledWith(expectedUrl, dict.context); + expect(MockLinking.openURL).toBeCalledWith(expectedUrl); + }); + }); + + it('should delay the handler by 1000ms on Android', async () => { + // GIVEN Android platform + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleUrlCalled); + + // sets up config with urlHandler that returns false + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.urlHandler = jest.fn(() => false); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + // GIVEN the link can be opened + MockLinking.canOpenURL = jest.fn(async () => true); + MockLinking.openURL.mockReset(); + + const expectedUrl = 'https://somewhere.com'; + const dict = { + url: expectedUrl, + context: { + action: { type: 'openUrl' }, + source: IterableActionSource.inApp, + }, + }; + + // WHEN handleUrlCalled event is emitted + nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); + + // THEN the handler is not called before the Android wake delay + return await TestHelper.delayed(1100, () => { + expect(config.urlHandler).toBeCalledWith(expectedUrl, dict.context); + expect(MockLinking.openURL).toBeCalledWith(expectedUrl); + }); + }); + }); + + describe('re-initialization', () => { + it('should not duplicate event listeners when initialize is called multiple times', () => { + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleUrlCalled); + + // sets up config with urlHandler + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + const urlHandler = jest.fn(() => true); + config.urlHandler = urlHandler; + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + const dict = { + url: 'https://example.com', + context: { + action: { type: 'openUrl' }, + source: IterableActionSource.inApp, + }, + }; + + // WHEN handleUrlCalled event is emitted after the first init + nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); + + // THEN urlHandler is called exactly once + expect(urlHandler).toHaveBeenCalledTimes(1); + + // reset mocks and re-initialize with the same config + jest.clearAllMocks(); + Iterable.initialize('apiKey', config); + + // WHEN handleUrlCalled event is emitted after the second init + nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); + + // THEN urlHandler is still called exactly once per emission + expect(urlHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('authHandler resolution shapes', () => { + it('should pass null to the bridge when authHandler returns null', async () => { + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + + // sets up config with authHandler that returns null + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.authHandler = jest.fn(() => Promise.resolve(null)); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + // WHEN handleAuthCalled event is emitted + nativeEmitter.emit(IterableEventName.handleAuthCalled); + + // THEN passAlongAuthToken is called with null + return await TestHelper.delayed(100, () => { + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(null); + }); + }); + + it('should pass undefined to the bridge when authHandler returns undefined', async () => { + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + + // sets up config with authHandler that returns undefined + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.authHandler = jest.fn(() => Promise.resolve(undefined)); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + // WHEN handleAuthCalled event is emitted + nativeEmitter.emit(IterableEventName.handleAuthCalled); + + // THEN passAlongAuthToken is called with undefined + return await TestHelper.delayed(100, () => { + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(undefined); + }); + }); + + it('should not invoke callbacks when no success/failure event arrives within the timeout', async () => { + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthSuccessCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthFailureCalled + ); + + // sets up config with authHandler that returns an AuthResponse + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + const successCallback = jest.fn(); + const failureCallback = jest.fn(); + const authResponse = new IterableAuthResponse(); + authResponse.authToken = 'timeout-token'; + authResponse.successCallback = successCallback; + authResponse.failureCallback = failureCallback; + config.authHandler = jest.fn(() => Promise.resolve(authResponse)); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + // WHEN handleAuthCalled event is emitted but no success/failure event follows + nativeEmitter.emit(IterableEventName.handleAuthCalled); + + // THEN the token is forwarded and neither callback fires + return await TestHelper.delayed(1100, () => { + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith( + 'timeout-token' + ); + expect(successCallback).not.toBeCalled(); + expect(failureCallback).not.toBeCalled(); + }); + }); + }); }); diff --git a/src/core/classes/IterableAction.test.ts b/src/core/classes/IterableAction.test.ts new file mode 100644 index 000000000..0bccd1d01 --- /dev/null +++ b/src/core/classes/IterableAction.test.ts @@ -0,0 +1,68 @@ +import { IterableAction } from './IterableAction'; + +describe('IterableAction', () => { + describe('constructor', () => { + it('creates an instance with type only', () => { + const action = new IterableAction('openUrl'); + expect(action.type).toBe('openUrl'); + expect(action.data).toBeUndefined(); + expect(action.userInput).toBeUndefined(); + }); + + it('creates an instance with type, data, and userInput', () => { + const action = new IterableAction('openUrl', 'https://example.com', 'tap'); + expect(action.type).toBe('openUrl'); + expect(action.data).toBe('https://example.com'); + expect(action.userInput).toBe('tap'); + }); + }); + + describe('fromDict', () => { + it('copies all fields when present', () => { + const dict = new IterableAction('openUrl', 'https://example.com', 'tap'); + const action = IterableAction.fromDict(dict); + expect(action.type).toBe('openUrl'); + expect(action.data).toBe('https://example.com'); + expect(action.userInput).toBe('tap'); + }); + + it('handles missing data and userInput', () => { + // Simulate a payload where only type is defined — data and userInput are absent. + const dict = { + type: 'openUrl', + } as IterableAction; + + const action = IterableAction.fromDict(dict); + + expect(action.type).toBe('openUrl'); + expect(action.data).toBeUndefined(); + expect(action.userInput).toBeUndefined(); + }); + + it('handles only data missing', () => { + const dict = { + type: 'openUrl', + userInput: 'tap', + } as IterableAction; + + const action = IterableAction.fromDict(dict); + + expect(action.type).toBe('openUrl'); + expect(action.data).toBeUndefined(); + expect(action.userInput).toBe('tap'); + }); + + it('handles only userInput missing', () => { + const dict = { + type: 'openUrl', + data: 'https://example.com', + } as IterableAction; + + const action = IterableAction.fromDict(dict); + + expect(action.type).toBe('openUrl'); + expect(action.data).toBe('https://example.com'); + expect(action.userInput).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/core/classes/IterableActionContext.test.ts b/src/core/classes/IterableActionContext.test.ts new file mode 100644 index 000000000..82169f032 --- /dev/null +++ b/src/core/classes/IterableActionContext.test.ts @@ -0,0 +1,60 @@ +import { IterableAction } from './IterableAction'; +import { IterableActionContext } from './IterableActionContext'; +import { IterableActionSource } from '../enums/IterableActionSource'; + +describe('IterableActionContext', () => { + describe('constructor', () => { + it('creates an instance with action and source', () => { + const action = new IterableAction('openUrl', 'https://example.com'); + const context = new IterableActionContext(action, IterableActionSource.push); + + expect(context.action).toBe(action); + expect(context.source).toBe(IterableActionSource.push); + }); + }); + + describe('fromDict', () => { + it('creates a full instance from a complete dict', () => { + const dict = new IterableActionContext( + new IterableAction('openUrl', 'https://example.com', 'tap'), + IterableActionSource.inApp + ); + + const context = IterableActionContext.fromDict(dict); + + expect(context.source).toBe(IterableActionSource.inApp); + expect(context.action.type).toBe('openUrl'); + expect(context.action.data).toBe('https://example.com'); + expect(context.action.userInput).toBe('tap'); + }); + + it('handles a partial action dict with only type', () => { + // Simulate a payload where the nested action only has `type` defined. + const dict = { + action: { type: 'openUrl' } as IterableAction, + source: IterableActionSource.appLink, + } as IterableActionContext; + + const context = IterableActionContext.fromDict(dict); + + expect(context.source).toBe(IterableActionSource.appLink); + expect(context.action.type).toBe('openUrl'); + expect(context.action.data).toBeUndefined(); + expect(context.action.userInput).toBeUndefined(); + }); + + it('handles a partial action dict with type and data only', () => { + const dict = { + action: { type: 'openUrl', data: 'https://example.com' } as IterableAction, + source: IterableActionSource.embedded, + } as IterableActionContext; + + const context = IterableActionContext.fromDict(dict); + + expect(context.source).toBe(IterableActionSource.embedded); + expect(context.action.type).toBe('openUrl'); + expect(context.action.data).toBe('https://example.com'); + expect(context.action.userInput).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts index 9e54ae4cb..d639d79ed 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -13,6 +13,7 @@ import { IterableInAppCloseSource } from '../../inApp/enums/IterableInAppCloseSo import { IterableInAppDeleteSource } from '../../inApp/enums/IterableInAppDeleteSource'; import { IterableInAppShowResponse } from '../../inApp/enums/IterableInAppShowResponse'; import { type IterableInboxImpressionRowInfo } from '../../inbox/types/IterableInboxImpressionRowInfo'; +import type { IterableEmbeddedMessage } from '../../embedded/types/IterableEmbeddedMessage'; // Mock the RNIterableAPI module jest.mock('../../api', () => ({ @@ -1176,4 +1177,170 @@ describe('IterableApi', () => { expect(MockRNIterableAPI.setAttributionInfo).toBeCalledWith(undefined); }); }); + + describe('initializeWithApiKey default config', () => { + it('should use a default config when the config property is omitted', async () => { + // GIVEN an API key and version + const apiKey = 'test-api-key'; + const version = '1.0.0'; + + // WHEN initializeWithApiKey is called without config + // @ts-expect-error - exercising the destructuring default for config + const result = await IterableApi.initializeWithApiKey(apiKey, { + version, + }); + + // THEN RNIterableAPI.initializeWithApiKey is called with a config dict + expect(MockRNIterableAPI.initializeWithApiKey).toBeCalledWith( + apiKey, + expect.any(Object), + version + ); + expect(result).toBe(true); + }); + }); + + describe('initialize2WithApiKey default config', () => { + it('should use a default config when the config property is omitted', async () => { + // GIVEN an API key, version, and endpoint + const apiKey = 'test-api-key'; + const version = '1.0.0'; + const apiEndPoint = 'https://api.staging.iterable.com'; + + // WHEN initialize2WithApiKey is called without config + // @ts-expect-error - exercising the destructuring default for config + const result = await IterableApi.initialize2WithApiKey(apiKey, { + version, + apiEndPoint, + }); + + // THEN RNIterableAPI.initialize2WithApiKey is called with a config dict + expect(MockRNIterableAPI.initialize2WithApiKey).toBeCalledWith( + apiKey, + expect.any(Object), + version, + apiEndPoint + ); + expect(result).toBe(true); + }); + }); + + describe('syncEmbeddedMessages', () => { + it('should call RNIterableAPI.syncEmbeddedMessages', () => { + // GIVEN no parameters + // WHEN syncEmbeddedMessages is called + IterableApi.syncEmbeddedMessages(); + + // THEN RNIterableAPI.syncEmbeddedMessages is called + expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalled(); + }); + }); + + describe('startEmbeddedSession', () => { + it('should call RNIterableAPI.startEmbeddedSession', () => { + // GIVEN no parameters + // WHEN startEmbeddedSession is called + IterableApi.startEmbeddedSession(); + + // THEN RNIterableAPI.startEmbeddedSession is called + expect(MockRNIterableAPI.startEmbeddedSession).toBeCalled(); + }); + }); + + describe('endEmbeddedSession', () => { + it('should call RNIterableAPI.endEmbeddedSession', () => { + // GIVEN no parameters + // WHEN endEmbeddedSession is called + IterableApi.endEmbeddedSession(); + + // THEN RNIterableAPI.endEmbeddedSession is called + expect(MockRNIterableAPI.endEmbeddedSession).toBeCalled(); + }); + }); + + describe('startEmbeddedImpression', () => { + it('should call RNIterableAPI.startEmbeddedImpression with messageId and placementId', () => { + // GIVEN a message ID and placement ID + const messageId = 'msg-1'; + const placementId = 42; + + // WHEN startEmbeddedImpression is called + IterableApi.startEmbeddedImpression(messageId, placementId); + + // THEN RNIterableAPI.startEmbeddedImpression is called with correct parameters + expect(MockRNIterableAPI.startEmbeddedImpression).toBeCalledWith( + messageId, + placementId + ); + }); + }); + + describe('pauseEmbeddedImpression', () => { + it('should call RNIterableAPI.pauseEmbeddedImpression with messageId', () => { + // GIVEN a message ID + const messageId = 'msg-1'; + + // WHEN pauseEmbeddedImpression is called + IterableApi.pauseEmbeddedImpression(messageId); + + // THEN RNIterableAPI.pauseEmbeddedImpression is called with messageId + expect(MockRNIterableAPI.pauseEmbeddedImpression).toBeCalledWith( + messageId + ); + }); + }); + + describe('getEmbeddedMessages', () => { + it('should return embedded messages from RNIterableAPI', async () => { + // GIVEN mock embedded messages + const mockMessages = [ + { + metadata: { + messageId: 'msg-1', + placementId: 1, + campaignId: 123, + }, + elements: { title: 'Test Message' }, + payload: null, + }, + ]; + MockRNIterableAPI.getEmbeddedMessages = jest + .fn() + .mockResolvedValue(mockMessages); + + // WHEN getEmbeddedMessages is called + const result = await IterableApi.getEmbeddedMessages([1]); + + // THEN the messages are returned + expect(MockRNIterableAPI.getEmbeddedMessages).toBeCalledWith([1]); + expect(result).toBe(mockMessages); + }); + }); + + describe('trackEmbeddedClick', () => { + it('should call RNIterableAPI.trackEmbeddedClick with message, buttonId, and clickedUrl', () => { + // GIVEN an embedded message, button ID, and clicked URL + const message: IterableEmbeddedMessage = { + metadata: { + messageId: 'msg-1', + placementId: 1, + campaignId: 123, + }, + elements: null, + payload: null, + }; + const buttonId = 'button-1'; + const clickedUrl = 'https://example.com'; + + // WHEN trackEmbeddedClick is called + IterableApi.trackEmbeddedClick(message, buttonId, clickedUrl); + + // THEN RNIterableAPI.trackEmbeddedClick is called with correct parameters + expect(MockRNIterableAPI.trackEmbeddedClick).toBeCalledWith( + message, + buttonId, + clickedUrl + ); + }); + }); }); diff --git a/src/core/enums/IterableDataRegion.test.ts b/src/core/enums/IterableDataRegion.test.ts new file mode 100644 index 000000000..e60d82c62 --- /dev/null +++ b/src/core/enums/IterableDataRegion.test.ts @@ -0,0 +1,8 @@ +import { IterableDataRegion } from './IterableDataRegion'; + +describe('IterableDataRegion', () => { + it('contains the expected members', () => { + expect(IterableDataRegion.US).toBe(0); + expect(IterableDataRegion.EU).toBe(1); + }); +}); \ No newline at end of file diff --git a/src/core/enums/IterableLogLevel.test.ts b/src/core/enums/IterableLogLevel.test.ts new file mode 100644 index 000000000..1ab955672 --- /dev/null +++ b/src/core/enums/IterableLogLevel.test.ts @@ -0,0 +1,9 @@ +import { IterableLogLevel } from './IterableLogLevel'; + +describe('IterableLogLevel', () => { + it('contains the expected members', () => { + expect(IterableLogLevel.error).toBe(3); + expect(IterableLogLevel.debug).toBe(1); + expect(IterableLogLevel.info).toBe(2); + }); +}); \ No newline at end of file diff --git a/src/core/enums/IterablePushPlatform.test.ts b/src/core/enums/IterablePushPlatform.test.ts new file mode 100644 index 000000000..d14b24a21 --- /dev/null +++ b/src/core/enums/IterablePushPlatform.test.ts @@ -0,0 +1,9 @@ +import { IterablePushPlatform } from './IterablePushPlatform'; + +describe('IterablePushPlatform', () => { + it('contains the expected members', () => { + expect(IterablePushPlatform.sandbox).toBe(0); + expect(IterablePushPlatform.production).toBe(1); + expect(IterablePushPlatform.auto).toBe(2); + }); +}); \ No newline at end of file diff --git a/src/core/enums/IterableRetryBackoff.test.ts b/src/core/enums/IterableRetryBackoff.test.ts new file mode 100644 index 000000000..03c2105eb --- /dev/null +++ b/src/core/enums/IterableRetryBackoff.test.ts @@ -0,0 +1,8 @@ +import { IterableRetryBackoff } from './IterableRetryBackoff'; + +describe('IterableRetryBackoff', () => { + it('contains the expected members', () => { + expect(IterableRetryBackoff.linear).toBe('LINEAR'); + expect(IterableRetryBackoff.exponential).toBe('EXPONENTIAL'); + }); +}); \ No newline at end of file diff --git a/src/core/utils/callUrlHandler.test.ts b/src/core/utils/callUrlHandler.test.ts new file mode 100644 index 000000000..14af4b797 --- /dev/null +++ b/src/core/utils/callUrlHandler.test.ts @@ -0,0 +1,128 @@ +import { Linking } from 'react-native'; + +import { IterableAction } from '../classes/IterableAction'; +import { IterableActionContext } from '../classes/IterableActionContext'; +import { IterableConfig } from '../classes/IterableConfig'; +import { IterableActionSource } from '../enums/IterableActionSource'; +import { callUrlHandler } from './callUrlHandler'; + +// Mock the IterableLogger so we can assert calls without depending on its +// concrete implementation. +jest.mock('../classes/IterableLogger', () => ({ + IterableLogger: { + log: jest.fn(), + }, +})); + +// Import the mocked logger after the mock is registered. +const { IterableLogger } = require('../classes/IterableLogger'); + +describe('callUrlHandler', () => { + let canOpenURLSpy: jest.SpyInstance; + let openURLSpy: jest.SpyInstance; + let config: IterableConfig; + + beforeEach(() => { + jest.clearAllMocks(); + canOpenURLSpy = jest.spyOn(Linking, 'canOpenURL'); + openURLSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); + config = new IterableConfig(); + }); + + afterEach(() => { + canOpenURLSpy.mockRestore(); + openURLSpy.mockRestore(); + }); + + const buildContext = (url: string): IterableActionContext => { + return new IterableActionContext( + new IterableAction('openUrl', url), + IterableActionSource.push + ); + }; + + describe('urlHandler returns false (falls through to Linking)', () => { + it('opens the URL when Linking.canOpenURL resolves true', async () => { + // GIVEN a config whose urlHandler returns false + config.urlHandler = jest.fn().mockReturnValue(false); + canOpenURLSpy.mockResolvedValue(true); + const url = 'https://example.com'; + + // WHEN callUrlHandler is invoked + callUrlHandler(config, url, buildContext(url)); + + // THEN the microtask queue flushes the resolved canOpenURL + await Promise.resolve(); + await Promise.resolve(); + + expect(canOpenURLSpy).toHaveBeenCalledWith(url); + expect(openURLSpy).toHaveBeenCalledWith(url); + }); + + it('logs when Linking.canOpenURL resolves false', async () => { + config.urlHandler = jest.fn().mockReturnValue(false); + canOpenURLSpy.mockResolvedValue(false); + const url = 'https://example.com'; + + callUrlHandler(config, url, buildContext(url)); + + await Promise.resolve(); + await Promise.resolve(); + + expect(canOpenURLSpy).toHaveBeenCalledWith(url); + expect(openURLSpy).not.toHaveBeenCalled(); + expect(IterableLogger.log).toHaveBeenCalledWith( + 'Url cannot be opened: ' + url + ); + }); + + it('logs the error when Linking.canOpenURL rejects', async () => { + // GIVEN a config whose urlHandler returns false and a canOpenURL that rejects + config.urlHandler = jest.fn().mockReturnValue(false); + const rejectionReason = 'boom'; + canOpenURLSpy.mockRejectedValue(rejectionReason); + const url = 'https://example.com'; + + // WHEN callUrlHandler is invoked + callUrlHandler(config, url, buildContext(url)); + + // THEN the rejection branch fires and IterableLogger.log is called with the reason + await Promise.resolve(); + await Promise.resolve(); + + expect(canOpenURLSpy).toHaveBeenCalledWith(url); + expect(openURLSpy).not.toHaveBeenCalled(); + expect(IterableLogger.log).toHaveBeenCalledWith( + 'Error opening url: ' + rejectionReason + ); + }); + }); + + describe('urlHandler returns true', () => { + it('does not call Linking when urlHandler returns true', () => { + config.urlHandler = jest.fn().mockReturnValue(true); + const url = 'https://example.com'; + + callUrlHandler(config, url, buildContext(url)); + + expect(canOpenURLSpy).not.toHaveBeenCalled(); + expect(openURLSpy).not.toHaveBeenCalled(); + }); + }); + + describe('urlHandler not configured', () => { + it('falls through to Linking when urlHandler is undefined', async () => { + // config.urlHandler is undefined by default + canOpenURLSpy.mockResolvedValue(true); + const url = 'https://example.com'; + + callUrlHandler(config, url, buildContext(url)); + + await Promise.resolve(); + await Promise.resolve(); + + expect(canOpenURLSpy).toHaveBeenCalledWith(url); + expect(openURLSpy).toHaveBeenCalledWith(url); + }); + }); +}); \ No newline at end of file diff --git a/src/embedded/classes/IterableEmbeddedManager.test.ts b/src/embedded/classes/IterableEmbeddedManager.test.ts index a6c655654..c460572f0 100644 --- a/src/embedded/classes/IterableEmbeddedManager.test.ts +++ b/src/embedded/classes/IterableEmbeddedManager.test.ts @@ -657,6 +657,52 @@ describe('IterableEmbeddedManager', () => { // THEN isEnabled should be false (default) expect(manager.isEnabled).toBe(false); }); + + it('should initialize with embedded messaging disabled when config flag is null (nullish coalescing fallback)', () => { + // GIVEN a config where enableEmbeddedMessaging is explicitly null + // (exercises the `?? false` branch on line 57) + const configWithNull = new IterableConfig(); + (configWithNull as unknown as { + enableEmbeddedMessaging: boolean | null; + }).enableEmbeddedMessaging = null; + + // WHEN creating a new embedded manager + const manager = new IterableEmbeddedManager(configWithNull); + + // THEN isEnabled should fall back to false via the `?? false` branch + expect(manager.isEnabled).toBe(false); + }); + + it('constructor with enableEmbeddedMessaging === false does not auto-call native sync', () => { + // GIVEN a config with enableEmbeddedMessaging explicitly false + const configWithFalse = new IterableConfig(); + configWithFalse.enableEmbeddedMessaging = false; + + // WHEN creating a new embedded manager + const manager = new IterableEmbeddedManager(configWithFalse); + + // THEN isEnabled is false and the constructor did not auto-call native sync + // (the branch on line 57 is evaluated at construction time) + expect(manager.isEnabled).toBe(false); + expect(MockRNIterableAPI.syncEmbeddedMessages).not.toHaveBeenCalled(); + }); + + it('explicit syncMessages still proxies through to native when enableEmbeddedMessaging === false', () => { + // GIVEN a config with enableEmbeddedMessaging explicitly false + const configWithFalse = new IterableConfig(); + configWithFalse.enableEmbeddedMessaging = false; + const manager = new IterableEmbeddedManager(configWithFalse); + + // sanity: constructor did not auto-call native sync + expect(MockRNIterableAPI.syncEmbeddedMessages).not.toHaveBeenCalled(); + + // WHEN syncMessages is called on a disabled manager + manager.syncMessages(); + + // THEN isEnabled remains false and the underlying sync still proxies through + expect(manager.isEnabled).toBe(false); + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/embedded/enums/IterableEmbeddedViewType.test.ts b/src/embedded/enums/IterableEmbeddedViewType.test.ts new file mode 100644 index 000000000..6501ef228 --- /dev/null +++ b/src/embedded/enums/IterableEmbeddedViewType.test.ts @@ -0,0 +1,9 @@ +import { IterableEmbeddedViewType } from './IterableEmbeddedViewType'; + +describe('IterableEmbeddedViewType', () => { + it('contains the expected members', () => { + expect(IterableEmbeddedViewType.Banner).toBe(0); + expect(IterableEmbeddedViewType.Card).toBe(1); + expect(IterableEmbeddedViewType.Notification).toBe(2); + }); +}); \ No newline at end of file diff --git a/src/embedded/utils/normalizeEmbeddedViewConfig.test.ts b/src/embedded/utils/normalizeEmbeddedViewConfig.test.ts index 96cfe6402..6a29e01f7 100644 --- a/src/embedded/utils/normalizeEmbeddedViewConfig.test.ts +++ b/src/embedded/utils/normalizeEmbeddedViewConfig.test.ts @@ -103,4 +103,82 @@ describe('normalizeEmbeddedViewConfig', () => { expect(original).toEqual(snapshot); }); + + describe('partial config objects', () => { + it('handles a config with only borderWidth set', () => { + // GIVEN a partial config containing only borderWidth + const result = normalizeEmbeddedViewConfig({ borderWidth: 5 }); + + // THEN borderWidth is preserved and no other numeric keys are introduced + expect(result?.borderWidth).toBe(5); + expect(result?.borderCornerRadius).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('handles a config with only borderCornerRadius set', () => { + // GIVEN a partial config containing only borderCornerRadius + const result = normalizeEmbeddedViewConfig({ borderCornerRadius: 9 }); + + expect(result?.borderCornerRadius).toBe(9); + expect(result?.borderWidth).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('handles a config where borderWidth is explicitly null (coerceNumericField null branch)', () => { + // GIVEN a partial config where borderWidth is explicitly null + const result = normalizeEmbeddedViewConfig({ + borderWidth: null, + } as never); + + // THEN the null value is treated as absent and the key is dropped + expect(result?.borderWidth).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('handles a config where borderCornerRadius is explicitly null', () => { + const result = normalizeEmbeddedViewConfig({ + borderCornerRadius: null, + } as never); + + expect(result?.borderCornerRadius).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('handles a config with borderWidth null and a valid borderCornerRadius', () => { + // GIVEN a mixed partial config + const result = normalizeEmbeddedViewConfig({ + borderWidth: null, + borderCornerRadius: 12, + } as never); + + // THEN the null field is dropped while the valid number is preserved + expect(result?.borderWidth).toBeUndefined(); + expect(result?.borderCornerRadius).toBe(12); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('handles a config with no numeric keys (only non-numeric fields)', () => { + // GIVEN a config with only non-numeric fields + const result = normalizeEmbeddedViewConfig({ + backgroundColor: '#fff', + borderColor: '#000', + } as never); + + // THEN the non-numeric fields are passed through unchanged + expect(result?.backgroundColor).toBe('#fff'); + expect(result?.borderColor).toBe('#000'); + expect(result?.borderWidth).toBeUndefined(); + expect(result?.borderCornerRadius).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('handles an empty config object', () => { + // GIVEN an empty config object + const result = normalizeEmbeddedViewConfig({}); + + // THEN the result is an empty object (no numeric keys to coerce) + expect(result).toEqual({}); + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/inApp/classes/IterableHtmlInAppContent.test.ts b/src/inApp/classes/IterableHtmlInAppContent.test.ts index 3a836472a..d34883c51 100644 --- a/src/inApp/classes/IterableHtmlInAppContent.test.ts +++ b/src/inApp/classes/IterableHtmlInAppContent.test.ts @@ -398,5 +398,54 @@ describe('IterableHtmlInAppContent', () => { expect(content.edgeInsets.right).toBe(-Infinity); expect(content.type).toBe(IterableInAppContentType.html); }); + + it('should create instance from a minimal payload with zero edge insets and empty html', () => { + // GIVEN a minimal dictionary payload + const dict: IterableHtmlInAppContentRaw = { + edgeInsets: { + top: 0, + left: 0, + bottom: 0, + right: 0, + }, + html: '', + }; + + // WHEN creating from dictionary + const content = IterableHtmlInAppContent.fromDict(dict); + + // THEN it should produce a valid instance with defaults preserved + expect(content).toBeInstanceOf(IterableHtmlInAppContent); + expect(content.html).toBe(''); + expect(content.edgeInsets.top).toBe(0); + expect(content.edgeInsets.left).toBe(0); + expect(content.edgeInsets.bottom).toBe(0); + expect(content.edgeInsets.right).toBe(0); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should create instance from a minimal payload with a single-space html string', () => { + // GIVEN a minimal dictionary payload with a single-space html + const dict: IterableHtmlInAppContentRaw = { + edgeInsets: { + top: 1, + left: 0, + bottom: 0, + right: 0, + }, + html: ' ', + }; + + // WHEN creating from dictionary + const content = IterableHtmlInAppContent.fromDict(dict); + + // THEN the minimal payload should be preserved exactly + expect(content.html).toBe(' '); + expect(content.edgeInsets.top).toBe(1); + expect(content.edgeInsets.left).toBe(0); + expect(content.edgeInsets.bottom).toBe(0); + expect(content.edgeInsets.right).toBe(0); + expect(content.type).toBe(IterableInAppContentType.html); + }); }); }); diff --git a/src/inApp/classes/IterableInAppMessage.test.ts b/src/inApp/classes/IterableInAppMessage.test.ts new file mode 100644 index 000000000..156bed1bc --- /dev/null +++ b/src/inApp/classes/IterableInAppMessage.test.ts @@ -0,0 +1,111 @@ +import { IterableInAppMessage } from './IterableInAppMessage'; +import { IterableInAppTrigger } from './IterableInAppTrigger'; +import { IterableInAppTriggerType } from '../enums/IterableInAppTriggerType'; +import type { IterableInAppMessageRaw } from '../types/IterableInAppMessageRaw'; + +describe('IterableInAppMessage.fromDict', () => { + const baseDict: IterableInAppMessageRaw = { + messageId: 'msg-1', + campaignId: 123, + trigger: new IterableInAppTrigger(IterableInAppTriggerType.immediate), + priorityLevel: 5, + }; + + describe('timestamp conversion branches', () => { + it('converts a numeric createdAt timestamp', () => { + // GIVEN a raw dict with a numeric createdAt timestamp (ms since epoch) + const createdAtMs = 1_600_000_000_000; + const dict: IterableInAppMessageRaw = { + ...baseDict, + createdAt: createdAtMs, + }; + + // WHEN fromDict is called + const message = IterableInAppMessage.fromDict(dict); + + // THEN the createdAt branch executes and the value is the epoch-ms + // representation produced by Date.setUTCMilliseconds(createdAtMs). + // The implementation stores the numeric return of setUTCMilliseconds + // via @ts-ignore, so we assert against the equivalent Date value. + const expected = new Date(0).setUTCMilliseconds(createdAtMs); + expect(message.createdAt).toBe(expected); + }); + + it('converts a numeric expiresAt timestamp', () => { + // GIVEN a raw dict with a numeric expiresAt timestamp (ms since epoch) + const expiresAtMs = 1_700_000_000_000; + const dict: IterableInAppMessageRaw = { + ...baseDict, + expiresAt: expiresAtMs, + }; + + // WHEN fromDict is called + const message = IterableInAppMessage.fromDict(dict); + + // THEN the expiresAt branch executes and the value is the epoch-ms + // representation produced by Date.setUTCMilliseconds(expiresAtMs). + const expected = new Date(0).setUTCMilliseconds(expiresAtMs); + expect(message.expiresAt).toBe(expected); + }); + + it('converts both createdAt and expiresAt numeric timestamps', () => { + // GIVEN a raw dict with both numeric timestamps + const createdAtMs = 1_600_000_000_000; + const expiresAtMs = 1_700_000_000_000; + const dict: IterableInAppMessageRaw = { + ...baseDict, + createdAt: createdAtMs, + expiresAt: expiresAtMs, + }; + + // WHEN fromDict is called + const message = IterableInAppMessage.fromDict(dict); + + // THEN both branches execute and the values match the conversion output + const expectedCreated = new Date(0).setUTCMilliseconds(createdAtMs); + const expectedExpires = new Date(0).setUTCMilliseconds(expiresAtMs); + expect(message.createdAt).toBe(expectedCreated); + expect(message.expiresAt).toBe(expectedExpires); + }); + + it('leaves createdAt and expiresAt undefined when not provided', () => { + // GIVEN a raw dict with no timestamps + const dict: IterableInAppMessageRaw = { ...baseDict }; + + // WHEN fromDict is called + const message = IterableInAppMessage.fromDict(dict); + + // THEN the conversion branches are skipped and the values stay undefined + expect(message.createdAt).toBeUndefined(); + expect(message.expiresAt).toBeUndefined(); + }); + }); + + describe('inboxMetadata handling', () => { + it('parses inboxMetadata when present', () => { + const dict: IterableInAppMessageRaw = { + ...baseDict, + inboxMetadata: { + title: 'title', + subtitle: 'subtitle', + icon: 'icon', + }, + }; + + const message = IterableInAppMessage.fromDict(dict); + + expect(message.inboxMetadata).toBeDefined(); + expect(message.inboxMetadata?.title).toBe('title'); + expect(message.inboxMetadata?.subtitle).toBe('subtitle'); + expect(message.inboxMetadata?.icon).toBe('icon'); + }); + + it('leaves inboxMetadata undefined when absent', () => { + const dict: IterableInAppMessageRaw = { ...baseDict }; + + const message = IterableInAppMessage.fromDict(dict); + + expect(message.inboxMetadata).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/inApp/classes/IterableInAppTrigger.test.ts b/src/inApp/classes/IterableInAppTrigger.test.ts new file mode 100644 index 000000000..030b6095d --- /dev/null +++ b/src/inApp/classes/IterableInAppTrigger.test.ts @@ -0,0 +1,47 @@ +import { IterableInAppTrigger } from './IterableInAppTrigger'; +import { IterableInAppTriggerType } from '../enums/IterableInAppTriggerType'; + +describe('IterableInAppTrigger', () => { + describe('constructor', () => { + it('creates an instance with the given type', () => { + const trigger = new IterableInAppTrigger(IterableInAppTriggerType.immediate); + expect(trigger.type).toBe(IterableInAppTriggerType.immediate); + }); + }); + + describe('fromDict', () => { + it('creates an instance from a dict with the default trigger type (immediate)', () => { + const trigger = IterableInAppTrigger.fromDict({ + type: IterableInAppTriggerType.immediate, + }); + + expect(trigger.type).toBe(IterableInAppTriggerType.immediate); + }); + + it('creates an instance from a dict with the event trigger type', () => { + const trigger = IterableInAppTrigger.fromDict({ + type: IterableInAppTriggerType.event, + }); + + expect(trigger.type).toBe(IterableInAppTriggerType.event); + }); + + it('creates an instance from a dict with the never trigger type', () => { + const trigger = IterableInAppTrigger.fromDict({ + type: IterableInAppTriggerType.never, + }); + + expect(trigger.type).toBe(IterableInAppTriggerType.never); + }); + + it('preserves an unknown trigger type value', () => { + // The SDK forwards unknown trigger types unchanged; this covers the + // "unknown trigger type" branch where the value is not one of the + // declared enum members. + const unknownType = 999 as IterableInAppTriggerType; + const trigger = IterableInAppTrigger.fromDict({ type: unknownType }); + + expect(trigger.type).toBe(unknownType); + }); + }); +}); \ No newline at end of file diff --git a/src/inApp/classes/IterableInboxMetadata.test.ts b/src/inApp/classes/IterableInboxMetadata.test.ts new file mode 100644 index 000000000..5e39e4c04 --- /dev/null +++ b/src/inApp/classes/IterableInboxMetadata.test.ts @@ -0,0 +1,93 @@ +import { IterableInboxMetadata } from './IterableInboxMetadata'; + +describe('IterableInboxMetadata', () => { + describe('constructor', () => { + it('creates an instance with all fields', () => { + const metadata = new IterableInboxMetadata('title', 'subtitle', 'icon'); + expect(metadata.title).toBe('title'); + expect(metadata.subtitle).toBe('subtitle'); + expect(metadata.icon).toBe('icon'); + }); + + it('creates an instance with all fields undefined', () => { + const metadata = new IterableInboxMetadata(undefined, undefined, undefined); + expect(metadata.title).toBeUndefined(); + expect(metadata.subtitle).toBeUndefined(); + expect(metadata.icon).toBeUndefined(); + }); + }); + + describe('fromDict', () => { + it('copies all fields when present', () => { + const metadata = IterableInboxMetadata.fromDict({ + title: 'title', + subtitle: 'subtitle', + icon: 'icon', + }); + + expect(metadata.title).toBe('title'); + expect(metadata.subtitle).toBe('subtitle'); + expect(metadata.icon).toBe('icon'); + }); + + it('handles missing title', () => { + const metadata = IterableInboxMetadata.fromDict({ + title: undefined, + subtitle: 'subtitle', + icon: 'icon', + }); + + expect(metadata.title).toBeUndefined(); + expect(metadata.subtitle).toBe('subtitle'); + expect(metadata.icon).toBe('icon'); + }); + + it('handles missing subtitle', () => { + const metadata = IterableInboxMetadata.fromDict({ + title: 'title', + subtitle: undefined, + icon: 'icon', + }); + + expect(metadata.title).toBe('title'); + expect(metadata.subtitle).toBeUndefined(); + expect(metadata.icon).toBe('icon'); + }); + + it('handles missing icon', () => { + const metadata = IterableInboxMetadata.fromDict({ + title: 'title', + subtitle: 'subtitle', + icon: undefined, + }); + + expect(metadata.title).toBe('title'); + expect(metadata.subtitle).toBe('subtitle'); + expect(metadata.icon).toBeUndefined(); + }); + + it('handles missing title and subtitle', () => { + const metadata = IterableInboxMetadata.fromDict({ + title: undefined, + subtitle: undefined, + icon: 'icon', + }); + + expect(metadata.title).toBeUndefined(); + expect(metadata.subtitle).toBeUndefined(); + expect(metadata.icon).toBe('icon'); + }); + + it('handles missing title, subtitle, and icon', () => { + const metadata = IterableInboxMetadata.fromDict({ + title: undefined, + subtitle: undefined, + icon: undefined, + }); + + expect(metadata.title).toBeUndefined(); + expect(metadata.subtitle).toBeUndefined(); + expect(metadata.icon).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/inApp/enums/IterableInAppCloseSource.test.ts b/src/inApp/enums/IterableInAppCloseSource.test.ts new file mode 100644 index 000000000..61e67db0c --- /dev/null +++ b/src/inApp/enums/IterableInAppCloseSource.test.ts @@ -0,0 +1,9 @@ +import { IterableInAppCloseSource } from './IterableInAppCloseSource'; + +describe('IterableInAppCloseSource', () => { + it('contains the expected members', () => { + expect(IterableInAppCloseSource.back).toBe(0); + expect(IterableInAppCloseSource.link).toBe(1); + expect(IterableInAppCloseSource.unknown).toBe(100); + }); +}); \ No newline at end of file diff --git a/src/inApp/enums/IterableInAppLocation.test.ts b/src/inApp/enums/IterableInAppLocation.test.ts new file mode 100644 index 000000000..e8049278d --- /dev/null +++ b/src/inApp/enums/IterableInAppLocation.test.ts @@ -0,0 +1,8 @@ +import { IterableInAppLocation } from './IterableInAppLocation'; + +describe('IterableInAppLocation', () => { + it('contains the expected members', () => { + expect(IterableInAppLocation.inApp).toBe(0); + expect(IterableInAppLocation.inbox).toBe(1); + }); +}); \ No newline at end of file diff --git a/src/inApp/enums/IterableInAppShowResponse.test.ts b/src/inApp/enums/IterableInAppShowResponse.test.ts new file mode 100644 index 000000000..4061b30f6 --- /dev/null +++ b/src/inApp/enums/IterableInAppShowResponse.test.ts @@ -0,0 +1,8 @@ +import { IterableInAppShowResponse } from './IterableInAppShowResponse'; + +describe('IterableInAppShowResponse', () => { + it('contains the expected members', () => { + expect(IterableInAppShowResponse.show).toBe(0); + expect(IterableInAppShowResponse.skip).toBe(1); + }); +}); \ No newline at end of file diff --git a/src/inApp/enums/IterableInAppTriggerType.test.ts b/src/inApp/enums/IterableInAppTriggerType.test.ts new file mode 100644 index 000000000..339d35e63 --- /dev/null +++ b/src/inApp/enums/IterableInAppTriggerType.test.ts @@ -0,0 +1,9 @@ +import { IterableInAppTriggerType } from './IterableInAppTriggerType'; + +describe('IterableInAppTriggerType', () => { + it('contains the expected members', () => { + expect(IterableInAppTriggerType.immediate).toBe(0); + expect(IterableInAppTriggerType.event).toBe(1); + expect(IterableInAppTriggerType.never).toBe(2); + }); +}); \ No newline at end of file diff --git a/src/inbox/components/IterableInboxEmptyState.test.tsx b/src/inbox/components/IterableInboxEmptyState.test.tsx new file mode 100644 index 000000000..d2b159650 --- /dev/null +++ b/src/inbox/components/IterableInboxEmptyState.test.tsx @@ -0,0 +1,53 @@ +import { render } from '@testing-library/react-native'; + +import { IterableInboxEmptyState } from './IterableInboxEmptyState'; + +describe('IterableInboxEmptyState', () => { + const baseProps = { + customizations: {}, + tabBarHeight: 50, + tabBarPadding: 10, + navTitleHeight: 44, + contentWidth: 300, + height: 600, + isPortrait: true, + }; + + it('renders the default empty state in portrait orientation', () => { + // GIVEN default props + // WHEN the component is rendered + const { getByText } = render(); + + // THEN the default title and body are displayed + expect(getByText('No saved messages')).toBeTruthy(); + expect(getByText('Check again later!')).toBeTruthy(); + }); + + it('renders customized title and body text', () => { + // GIVEN customizations with empty state text + const customizations = { + noMessagesTitle: 'All caught up!', + noMessagesBody: 'Nothing to see here.', + }; + + // WHEN the component is rendered with customizations + const { getByText } = render( + + ); + + // THEN the customized text is displayed + expect(getByText('All caught up!')).toBeTruthy(); + expect(getByText('Nothing to see here.')).toBeTruthy(); + }); + + it('renders in landscape orientation without crashing', () => { + // GIVEN landscape orientation + // WHEN the component is rendered + const { getByText } = render( + + ); + + // THEN the default title is still displayed + expect(getByText('No saved messages')).toBeTruthy(); + }); +});