Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

### Updates

- Added unit tests for `IterableInboxDataModel` (SDK-617).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here as in #937

Suggested change
- Added unit tests for `IterableInboxDataModel` (SDK-617).

- Added optional placement IDs to `Iterable.embeddedManager.syncMessages()` so apps can sync embedded messages for specific placements only (SDK-751). When omitted, null, or empty, behavior matches the previous sync-all call.
- **Android:** Scoped sync (`syncMessages([id, …])`) matches the pinned iterableapi behavior and may remove cached messages for placements not included in the sync response (SDK-798). iOS preserves unrequested placements. Prefer `syncMessages()` without placement IDs on Android until the native fix ships and the Android SDK pin is bumped.
- Added `IterableInAppMessage.jsonOnly` so `inAppHandler` and in-app message fetches can distinguish JSON-only messages from HTML in-apps (SDK-750).
Expand Down
272 changes: 272 additions & 0 deletions src/inbox/classes/IterableInboxDataModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { MockRNIterableAPI } from '../../__mocks__/MockRNIterableAPI';
import { IterableApi } from '../../core/classes/IterableApi';
import { IterableHtmlInAppContent } from '../../inApp/classes/IterableHtmlInAppContent';
import { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage';
import { IterableInAppTrigger } from '../../inApp/classes/IterableInAppTrigger';
import { IterableInAppTriggerType } from '../../inApp/enums/IterableInAppTriggerType';
import { IterableInAppDeleteSource } from '../../inApp/enums/IterableInAppDeleteSource';
import { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation';
import { IterableInboxMetadata } from '../../inApp/classes/IterableInboxMetadata';
import { type IterableInboxImpressionRowInfo } from '../types/IterableInboxImpressionRowInfo';

import { IterableInboxDataModel } from './IterableInboxDataModel';

jest.mock('../../api', () => ({
__esModule: true,
default: MockRNIterableAPI,
}));

describe('IterableInboxDataModel', () => {
let dateLocaleSpy: jest.SpyInstance;
let timeLocaleSpy: jest.SpyInstance;

const makeMessage = (
messageId: string,
createdAt: Date,
overrides?: Partial<{
saveToInbox: boolean;
read: boolean;
title: string;
}>
): IterableInAppMessage =>
new IterableInAppMessage(
messageId,
1,
new IterableInAppTrigger(IterableInAppTriggerType.immediate),
createdAt,
undefined,
overrides?.saveToInbox ?? true,
new IterableInboxMetadata(
overrides?.title ?? `title-${messageId}`,
'sub',
'https://example.com/icon.png'
),
undefined,
overrides?.read ?? false,
0
);

beforeEach(() => {
jest.clearAllMocks();
dateLocaleSpy = jest
.spyOn(Date.prototype, 'toLocaleDateString')
.mockReturnValue('1/15/2024');
timeLocaleSpy = jest
.spyOn(Date.prototype, 'toLocaleTimeString')
.mockReturnValue('3:00:00 PM');
});

afterEach(() => {
dateLocaleSpy.mockRestore();
timeLocaleSpy.mockRestore();
});
Comment on lines +59 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the Date spies are restored. Every IterableApi spy (getInboxMessages, setReadForMessage, removeMessage, the HTML fetch, startSession, updateVisibleRows, endSession) keeps its mocked implementation into later tests. clearAllMocks resets call history only, and jest.config.js has no restoreMocks.

Suggested change
afterEach(() => {
dateLocaleSpy.mockRestore();
timeLocaleSpy.mockRestore();
});
afterEach(() => {
jest.restoreAllMocks();
});

Then drop dateLocaleSpy and timeLocaleSpy (lines 20–21, and the assignments at 51 and 54). Otherwise eslint flags them as unused.


describe('refresh', () => {
it('should sort by most recent first when no comparator is set', async () => {
// GIVEN two messages with different createdAt values, returned older-first
const older = makeMessage('older', new Date('2024-01-01T10:00:00.000Z'));
const newer = makeMessage('newer', new Date('2024-02-01T10:00:00.000Z'));

const getInboxSpy = jest
.spyOn(IterableApi, 'getInboxMessages')
.mockResolvedValue([older, newer]);

const model = new IterableInboxDataModel();

// WHEN refresh runs without set() (default sortByMostRecent)
const rows = await model.refresh();

// THEN newer messages appear first regardless of API order
expect(getInboxSpy).toHaveBeenCalledTimes(1);
expect(rows.map((r) => r.inAppMessage.messageId)).toEqual([
'newer',
'older',
]);
expect(rows[0]?.inAppMessage).toBe(newer);
});

it('should apply a filter with default most-recent sort when only a filter is set', async () => {
// GIVEN three messages and a filter; no custom comparator (default sort)
const oldest = makeMessage('a', new Date('2024-01-01T10:00:00.000Z'));
const mid = makeMessage('b', new Date('2024-02-01T10:00:00.000Z'));
const newest = makeMessage('c', new Date('2024-03-01T10:00:00.000Z'));
const hidden = makeMessage('hidden', new Date('2024-04-01T10:00:00.000Z'));

jest.spyOn(IterableApi, 'getInboxMessages').mockResolvedValue([
oldest,
mid,
newest,
hidden,
]);

const model = new IterableInboxDataModel();
model.set((m) => m.messageId !== 'hidden');

const rows = await model.refresh();

expect(rows.map((r) => r.inAppMessage.messageId)).toEqual([
'c',
'b',
'a',
]);
});

it('should map inbox messages through filter and a custom comparator', async () => {
// GIVEN messages returned in arbitrary order and a filter that drops one id
const older = makeMessage('older', new Date('2024-01-01T10:00:00.000Z'));
const newer = makeMessage('newer', new Date('2024-02-01T10:00:00.000Z'));
const hidden = makeMessage('hidden', new Date('2024-03-01T10:00:00.000Z'));

const getInboxSpy = jest
.spyOn(IterableApi, 'getInboxMessages')
.mockResolvedValue([older, newer, hidden]);

const model = new IterableInboxDataModel();
model.set(
(m) => m.messageId !== 'hidden',
(a, b) => a.messageId.localeCompare(b.messageId)
);

// WHEN refresh runs
const rows = await model.refresh();

// THEN filter and comparator override default sort (lexicographic: newer, older)
expect(getInboxSpy).toHaveBeenCalledTimes(1);
expect(rows.map((r) => r.inAppMessage.messageId)).toEqual([
'newer',
'older',
]);
expect(rows[0]?.title).toBe('title-newer');
expect(rows[0]?.inAppMessage).toBe(newer);
Comment on lines +116 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With these fixtures, messageId.localeCompare gives ['newer', 'older'], which is the same order as the default most-recent sort. So the test passes even if the comparator is ignored. It also checks only title and identity, so a broken subtitle, imageUrl, createdAt or read in the row mapping goes unnoticed. Reversing the comparator and asserting the whole row covers both:

Suggested change
const older = makeMessage('older', new Date('2024-01-01T10:00:00.000Z'));
const newer = makeMessage('newer', new Date('2024-02-01T10:00:00.000Z'));
const hidden = makeMessage('hidden', new Date('2024-03-01T10:00:00.000Z'));
const getInboxSpy = jest
.spyOn(IterableApi, 'getInboxMessages')
.mockResolvedValue([older, newer, hidden]);
const model = new IterableInboxDataModel();
model.set(
(m) => m.messageId !== 'hidden',
(a, b) => a.messageId.localeCompare(b.messageId)
);
// WHEN refresh runs
const rows = await model.refresh();
// THEN filter and comparator override default sort (lexicographic: newer, older)
expect(getInboxSpy).toHaveBeenCalledTimes(1);
expect(rows.map((r) => r.inAppMessage.messageId)).toEqual([
'newer',
'older',
]);
expect(rows[0]?.title).toBe('title-newer');
expect(rows[0]?.inAppMessage).toBe(newer);
const older = makeMessage('older', new Date('2024-01-01T10:00:00.000Z'), {
read: true,
});
const newer = makeMessage('newer', new Date('2024-02-01T10:00:00.000Z'));
const hidden = makeMessage('hidden', new Date('2024-03-01T10:00:00.000Z'));
const getInboxSpy = jest
.spyOn(IterableApi, 'getInboxMessages')
.mockResolvedValue([older, newer, hidden]);
const model = new IterableInboxDataModel();
model.set(
(m) => m.messageId !== 'hidden',
// Reverse id order: the opposite of the default most-recent-first sort
(a, b) => b.messageId.localeCompare(a.messageId)
);
// WHEN refresh runs
const rows = await model.refresh();
// THEN the filter drops 'hidden' and the comparator replaces the default sort
expect(getInboxSpy).toHaveBeenCalledTimes(1);
expect(rows.map((r) => r.inAppMessage.messageId)).toEqual([
'older',
'newer',
]);
expect(rows[0]).toEqual({
title: 'title-older',
subtitle: 'sub',
imageUrl: 'https://example.com/icon.png',
createdAt: older.createdAt,
read: true,
inAppMessage: older,
});
expect(rows[0]?.inAppMessage).toBe(older);

});

it('should resolve to an empty array when getInboxMessages rejects', async () => {
// GIVEN the inbox fetch fails
jest
.spyOn(IterableApi, 'getInboxMessages')
.mockRejectedValue(new Error('network error'));

const model = new IterableInboxDataModel();

// WHEN refresh runs
const rows = await model.refresh();

// THEN the model returns an empty list
expect(rows).toEqual([]);
});
});

describe('getFormattedDate', () => {
it('should use the default locale date and time string when no date mapper is set', () => {
// GIVEN a message with a creation time
const model = new IterableInboxDataModel();
const message = makeMessage('m1', new Date('2024-06-15T12:00:00.000Z'));

// WHEN the date is formatted
const formatted = model.getFormattedDate(message);

// THEN the default mapper combines locale date and time
expect(formatted).toBe('1/15/2024 at 3:00:00 PM');
});

it('should use the custom date mapper when set', () => {
// GIVEN a custom date mapper
const model = new IterableInboxDataModel();
model.set(undefined, undefined, () => 'custom-label');
const message = makeMessage('m1', new Date('2024-06-15T12:00:00.000Z'));

// WHEN the date is formatted
const formatted = model.getFormattedDate(message);

// THEN the custom mapper output is returned
expect(formatted).toBe('custom-label');
Comment on lines +160 to +182

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: both formatter doubles return a constant whatever they receive. So formatting the wrong date, or passing the wrong message to the custom mapper, would still pass.
Suggest making the custom mapper echo its input, for example (m) => 'custom-${m.messageId}' and expect 'custom-m1'. For the default mapper, have the Date spies return something derived from this (for example this.toISOString()).

});
});

describe('IterableApi delegation', () => {
it('should call IterableApi.setReadForMessage when marking a message read', () => {
const spy = jest.spyOn(IterableApi, 'setReadForMessage').mockImplementation();
const model = new IterableInboxDataModel();

model.setMessageAsRead('msg-1');

expect(spy).toHaveBeenCalledWith('msg-1', true);
});

it('should call IterableApi.removeMessage when deleting an inbox item', () => {
const spy = jest.spyOn(IterableApi, 'removeMessage').mockImplementation();
const model = new IterableInboxDataModel();
const source = IterableInAppDeleteSource.inboxSwipe;

model.deleteItemById('msg-2', source);

expect(spy).toHaveBeenCalledWith(
'msg-2',
IterableInAppLocation.inbox,
source
);
});

it('should call IterableApi.getHtmlInAppContentForMessage and wrap the result', async () => {
const raw = {
edgeInsets: { top: 1, left: 2, bottom: 3, right: 4 },
html: '<p>hello</p>',
};
const spy = jest
.spyOn(IterableApi, 'getHtmlInAppContentForMessage')
.mockResolvedValue(raw as never);

const model = new IterableInboxDataModel();
const content = await model.getHtmlContentForMessageId('html-msg');

expect(spy).toHaveBeenCalledWith('html-msg');
expect(content).toBeInstanceOf(IterableHtmlInAppContent);
expect(content.html).toBe('<p>hello</p>');
});

it('should call IterableApi.startSession with visible rows', () => {
const spy = jest.spyOn(IterableApi, 'startSession').mockImplementation();
const model = new IterableInboxDataModel();
const rows: IterableInboxImpressionRowInfo[] = [
{ messageId: 'a', silentInbox: false },
];

model.startSession(rows);

expect(spy).toHaveBeenCalledWith(rows);
});

it('should call IterableApi.updateVisibleRows before IterableApi.endSession', async () => {
const updateSpy = jest
.spyOn(IterableApi, 'updateVisibleRows')
.mockImplementation();
const endSpy = jest.spyOn(IterableApi, 'endSession').mockImplementation();
const model = new IterableInboxDataModel();
const rows: IterableInboxImpressionRowInfo[] = [
{ messageId: 'b', silentInbox: true },
];

await model.endSession(rows);

expect(updateSpy).toHaveBeenCalledWith(rows);
expect(endSpy).toHaveBeenCalled();
expect(updateSpy.mock.invocationCallOrder[0]).toBeLessThan(
endSpy.mock.invocationCallOrder[0]!
);
});

it('should call IterableApi.updateVisibleRows when updateVisibleRows is invoked', () => {
const spy = jest
.spyOn(IterableApi, 'updateVisibleRows')
.mockImplementation();
const model = new IterableInboxDataModel();
const rows: IterableInboxImpressionRowInfo[] = [
{ messageId: 'c', silentInbox: false },
];

model.updateVisibleRows(rows);

expect(spy).toHaveBeenCalledWith(rows);
});
});
});
Loading