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();
+ });
+});