From 01a6d091ba2f9d51bfd74da75af57f5aeef5747e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Fri, 6 Nov 2026 09:14:06 +0000 Subject: [PATCH 1/2] test(SDK-617): add unit tests for IterableInboxDataModel Cover refresh mapping, fetch failure, date formatting, and IterableApi delegation so inbox data model behaviour stays regression-safe. --- CHANGELOG.md | 1 + .../classes/IterableInboxDataModel.test.ts | 223 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 src/inbox/classes/IterableInboxDataModel.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a82ae61a..74dc8963c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Updates +- 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). diff --git a/src/inbox/classes/IterableInboxDataModel.test.ts b/src/inbox/classes/IterableInboxDataModel.test.ts new file mode 100644 index 000000000..bc6b6bc0a --- /dev/null +++ b/src/inbox/classes/IterableInboxDataModel.test.ts @@ -0,0 +1,223 @@ +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(); + }); + + describe('refresh', () => { + it('should map inbox messages through filter, comparator, and default sort (most recent first)', 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 are applied and rows map inbox fields + 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); + }); + + 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'); + }); + }); + + 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: '

hello

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

hello

'); + }); + + 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); + }); + }); +}); From 29697801dbc560c918800b0fcc25558ffb44aa7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Fri, 6 Nov 2026 09:23:19 +0000 Subject: [PATCH 2/2] test(SDK-617): exercise default inbox sort in refresh tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add coverage for sortByMostRecent when no comparator is set and split the custom-comparator case from misleading titles (review F1–F2). --- .../classes/IterableInboxDataModel.test.ts | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/src/inbox/classes/IterableInboxDataModel.test.ts b/src/inbox/classes/IterableInboxDataModel.test.ts index bc6b6bc0a..cf67c8d04 100644 --- a/src/inbox/classes/IterableInboxDataModel.test.ts +++ b/src/inbox/classes/IterableInboxDataModel.test.ts @@ -62,7 +62,56 @@ describe('IterableInboxDataModel', () => { }); describe('refresh', () => { - it('should map inbox messages through filter, comparator, and default sort (most recent first)', async () => { + 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')); @@ -81,7 +130,7 @@ describe('IterableInboxDataModel', () => { // WHEN refresh runs const rows = await model.refresh(); - // THEN filter and comparator are applied and rows map inbox fields + // THEN filter and comparator override default sort (lexicographic: newer, older) expect(getInboxSpy).toHaveBeenCalledTimes(1); expect(rows.map((r) => r.inAppMessage.messageId)).toEqual([ 'newer',