diff --git a/__mocks__/react-native.ts b/__mocks__/react-native.ts index 617e9411..3c6670b6 100644 --- a/__mocks__/react-native.ts +++ b/__mocks__/react-native.ts @@ -1,5 +1,28 @@ import { vi } from 'vitest'; +export const createEmitterSubscriptionMock = ( + eventName: string, + callback: (payload: unknown) => void, +) => ({ + remove: vi.fn(), + emitter: { + addListener: vi.fn(), + removeAllListeners: vi.fn(), + listenerCount: vi.fn(() => 1), + emit: vi.fn(), + }, + listener: () => callback, + context: undefined, + eventType: eventName, + key: 0, + subscriber: { + addSubscription: vi.fn(), + removeSubscription: vi.fn(), + removeAllSubscriptions: vi.fn(), + getSubscriptionsForType: vi.fn(), + }, +}); + const mockRNOneSignal = { initialize: vi.fn(), login: vi.fn(), @@ -61,6 +84,7 @@ const mockRNOneSignal = { addOutcome: vi.fn(), addUniqueOutcome: vi.fn(), addOutcomeWithValue: vi.fn(), + displayNotification: vi.fn(), }; const mockPlatform = { @@ -78,10 +102,8 @@ export { mockPlatform, mockRNOneSignal }; export class NativeEventEmitter { constructor(_nativeModule: typeof mockRNOneSignal) {} - addListener(_eventName: string, _callback: (payload: unknown) => void) { - return { - remove: vi.fn(), - }; + addListener(eventName: string, callback: (payload: unknown) => void) { + return createEmitterSubscriptionMock(eventName, callback); } removeListener(_eventName: string, _callback: (payload: unknown) => void) { diff --git a/src/OSNotification.test.ts b/src/OSNotification.test.ts new file mode 100644 index 00000000..0dee397e --- /dev/null +++ b/src/OSNotification.test.ts @@ -0,0 +1,242 @@ +import { NativeModules, Platform } from 'react-native'; +import OSNotification, { type BaseNotificationData } from './OSNotification'; + +const mockRNOneSignal = NativeModules.OneSignal; +const mockPlatform = Platform; + +describe('OSNotification', () => { + const baseNotificationData: BaseNotificationData = { + body: 'Test notification body', + sound: 'default', + title: 'Test Title', + launchURL: 'https://example.com', + rawPayload: { key: 'value' }, + actionButtons: [{ id: 'btn1', text: 'Button 1' }], + additionalData: { custom: 'data' }, + notificationId: 'test-notification-id', + }; + + beforeEach(() => { + mockPlatform.OS = 'ios'; + mockRNOneSignal.displayNotification.mockClear(); + }); + + describe('constructor', () => { + test('should initialize common properties', () => { + const notification = new OSNotification(baseNotificationData); + + expect(notification.body).toBe('Test notification body'); + expect(notification.sound).toBe('default'); + expect(notification.title).toBe('Test Title'); + expect(notification.launchURL).toBe('https://example.com'); + expect(notification.rawPayload).toEqual({ key: 'value' }); + expect(notification.actionButtons).toEqual([ + { id: 'btn1', text: 'Button 1' }, + ]); + expect(notification.additionalData).toEqual({ custom: 'data' }); + expect(notification.notificationId).toBe('test-notification-id'); + }); + + test('should initialize with optional common properties as undefined', () => { + const notificationData = { + body: 'Test body', + rawPayload: '', + notificationId: 'id-123', + }; + const notification = new OSNotification(notificationData); + + expect(notification.body).toBe('Test body'); + expect(notification.sound).toBeUndefined(); + expect(notification.title).toBeUndefined(); + expect(notification.launchURL).toBeUndefined(); + expect(notification.actionButtons).toBeUndefined(); + expect(notification.additionalData).toBeUndefined(); + }); + + describe('Android-specific properties', () => { + beforeEach(() => { + mockPlatform.OS = 'android'; + }); + + test('should initialize Android-specific properties on Android platform', () => { + const androidData = { + ...baseNotificationData, + groupKey: 'group-1', + groupMessage: 'group message', + ledColor: 'FFFF0000', + priority: 2, + smallIcon: 'icon_small', + largeIcon: 'icon_large', + bigPicture: 'image_url', + collapseId: 'collapse-1', + fromProjectNumber: '123456789', + smallIconAccentColor: 'FFFF0000', + lockScreenVisibility: '1', + androidNotificationId: 456, + }; + const notification = new OSNotification(androidData); + + expect(notification.groupKey).toBe('group-1'); + expect(notification.groupMessage).toBe('group message'); + expect(notification.ledColor).toBe('FFFF0000'); + expect(notification.priority).toBe(2); + expect(notification.smallIcon).toBe('icon_small'); + expect(notification.largeIcon).toBe('icon_large'); + expect(notification.bigPicture).toBe('image_url'); + expect(notification.collapseId).toBe('collapse-1'); + expect(notification.fromProjectNumber).toBe('123456789'); + expect(notification.smallIconAccentColor).toBe('FFFF0000'); + expect(notification.lockScreenVisibility).toBe('1'); + expect(notification.androidNotificationId).toBe(456); + }); + + test('should not set iOS-specific properties on Android', () => { + const notification = new OSNotification(baseNotificationData); + + expect(notification.badge).toBeUndefined(); + expect(notification.badgeIncrement).toBeUndefined(); + expect(notification.category).toBeUndefined(); + expect(notification.threadId).toBeUndefined(); + expect(notification.subtitle).toBeUndefined(); + expect(notification.templateId).toBeUndefined(); + expect(notification.templateName).toBeUndefined(); + expect(notification.attachments).toBeUndefined(); + expect(notification.mutableContent).toBeUndefined(); + expect(notification.contentAvailable).toBeUndefined(); + expect(notification.relevanceScore).toBeUndefined(); + expect(notification.interruptionLevel).toBeUndefined(); + }); + }); + + describe('iOS-specific properties', () => { + beforeEach(() => { + mockPlatform.OS = 'ios'; + }); + + test('should initialize iOS-specific properties on iOS platform', () => { + const iosData = { + ...baseNotificationData, + badge: '1', + badgeIncrement: '5', + category: 'NOTIFICATION_CATEGORY', + threadId: 'thread-123', + subtitle: 'Test Subtitle', + templateId: 'template-1', + templateName: 'template-name', + attachments: { key: 'attachment-value' }, + mutableContent: true, + contentAvailable: '1', + relevanceScore: 0.9, + interruptionLevel: 'timeSensitive', + }; + const notification = new OSNotification(iosData); + + expect(notification.badge).toBe('1'); + expect(notification.badgeIncrement).toBe('5'); + expect(notification.category).toBe('NOTIFICATION_CATEGORY'); + expect(notification.threadId).toBe('thread-123'); + expect(notification.subtitle).toBe('Test Subtitle'); + expect(notification.templateId).toBe('template-1'); + expect(notification.templateName).toBe('template-name'); + expect(notification.attachments).toEqual({ key: 'attachment-value' }); + expect(notification.mutableContent).toBe(true); + expect(notification.contentAvailable).toBe('1'); + expect(notification.relevanceScore).toBe(0.9); + expect(notification.interruptionLevel).toBe('timeSensitive'); + }); + + test('should not set Android-specific properties on iOS', () => { + const notification = new OSNotification(baseNotificationData); + + expect(notification.groupKey).toBeUndefined(); + expect(notification.groupMessage).toBeUndefined(); + expect(notification.ledColor).toBeUndefined(); + expect(notification.priority).toBeUndefined(); + expect(notification.smallIcon).toBeUndefined(); + expect(notification.largeIcon).toBeUndefined(); + expect(notification.bigPicture).toBeUndefined(); + expect(notification.collapseId).toBeUndefined(); + expect(notification.fromProjectNumber).toBeUndefined(); + expect(notification.smallIconAccentColor).toBeUndefined(); + expect(notification.lockScreenVisibility).toBeUndefined(); + expect(notification.androidNotificationId).toBeUndefined(); + }); + }); + }); + + describe('display', () => { + test('should call native displayNotification with notificationId', () => { + const notification = new OSNotification(baseNotificationData); + notification.display(); + + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith( + 'test-notification-id', + ); + }); + + test('should display notification with different notificationId', () => { + const notificationID = 'custom-id-789'; + const notification = new OSNotification({ + ...baseNotificationData, + notificationId: notificationID, + }); + notification.display(); + + expect(mockRNOneSignal.displayNotification).toHaveBeenCalledWith( + notificationID, + ); + }); + + test('should return undefined', () => { + const notification = new OSNotification(baseNotificationData); + const result = notification.display(); + + expect(result).toBeUndefined(); + }); + }); + + describe('rawPayload types', () => { + test('should accept object as rawPayload', () => { + const notificationData = { + ...baseNotificationData, + rawPayload: { key: 'value', nested: { key: 'value' } }, + }; + const notification = new OSNotification(notificationData); + + expect(notification.rawPayload).toEqual({ + key: 'value', + nested: { key: 'value' }, + }); + }); + + test('should accept string as rawPayload', () => { + const notificationData = { + ...baseNotificationData, + rawPayload: '{"key":"value"}', + }; + const notification = new OSNotification(notificationData); + + expect(notification.rawPayload).toBe('{"key":"value"}'); + }); + + test('should accept empty object as rawPayload', () => { + const notificationData = { + ...baseNotificationData, + rawPayload: {}, + }; + const notification = new OSNotification(notificationData); + + expect(notification.rawPayload).toEqual({}); + }); + + test('should accept empty string as rawPayload', () => { + const notificationData = { + ...baseNotificationData, + rawPayload: '', + }; + const notification = new OSNotification(notificationData); + + expect(notification.rawPayload).toBe(''); + }); + }); +}); diff --git a/src/OSNotification.ts b/src/OSNotification.ts index ffc42747..7e7cf5c9 100644 --- a/src/OSNotification.ts +++ b/src/OSNotification.ts @@ -1,6 +1,49 @@ import { NativeModules, Platform } from 'react-native'; const RNOneSignal = NativeModules.OneSignal; +export interface BaseNotificationData { + body: string; + sound?: string; + title?: string; + launchURL?: string; + rawPayload: object | string; // platform bridges return different types + actionButtons?: object[]; + additionalData?: object; + notificationId: string; +} + +interface AndroidNotificationData extends BaseNotificationData { + groupKey?: string; + groupMessage?: string; + ledColor?: string; + priority?: number; + smallIcon?: string; + largeIcon?: string; + bigPicture?: string; + collapseId?: string; + fromProjectNumber?: string; + smallIconAccentColor?: string; + lockScreenVisibility?: string; + androidNotificationId?: number; +} + +interface iOSNotificationData extends BaseNotificationData { + badge?: string; + badgeIncrement?: string; + category?: string; + threadId?: string; + subtitle?: string; + templateId?: string; + templateName?: string; + attachments?: object; + mutableContent?: boolean; + contentAvailable?: string; + relevanceScore?: number; + interruptionLevel?: string; +} + +export type OSNotificationData = AndroidNotificationData | iOSNotificationData; + export default class OSNotification { body: string; sound?: string; @@ -37,7 +80,7 @@ export default class OSNotification { relevanceScore?: number; interruptionLevel?: string; - constructor(receivedEvent: OSNotification) { + constructor(receivedEvent: OSNotificationData) { this.body = receivedEvent.body; this.sound = receivedEvent.sound; this.title = receivedEvent.title; @@ -47,7 +90,8 @@ export default class OSNotification { this.additionalData = receivedEvent.additionalData; this.notificationId = receivedEvent.notificationId; - if (Platform.OS === 'android') { + /* v8 ignore else -- @preserve */ + if (isAndroidNotificationData(receivedEvent)) { this.groupKey = receivedEvent.groupKey; this.ledColor = receivedEvent.ledColor; this.priority = receivedEvent.priority; @@ -60,9 +104,7 @@ export default class OSNotification { this.smallIconAccentColor = receivedEvent.smallIconAccentColor; this.lockScreenVisibility = receivedEvent.lockScreenVisibility; this.androidNotificationId = receivedEvent.androidNotificationId; - } - - if (Platform.OS === 'ios') { + } else if (isiOSNotificationData(receivedEvent)) { this.badge = receivedEvent.badge; this.category = receivedEvent.category; this.threadId = receivedEvent.threadId; @@ -83,3 +125,15 @@ export default class OSNotification { return; } } + +const isAndroidNotificationData = ( + _data: AndroidNotificationData | iOSNotificationData, +): _data is AndroidNotificationData => { + return Platform.OS === 'android'; +}; + +const isiOSNotificationData = ( + _data: AndroidNotificationData | iOSNotificationData, +): _data is iOSNotificationData => { + return Platform.OS === 'ios'; +}; diff --git a/src/events/EventManager.test.ts b/src/events/EventManager.test.ts new file mode 100644 index 00000000..c307bc2b --- /dev/null +++ b/src/events/EventManager.test.ts @@ -0,0 +1,489 @@ +import { NativeEventEmitter } from 'react-native'; +import { createEmitterSubscriptionMock } from '../../__mocks__/react-native'; +import { + IN_APP_MESSAGE_CLICKED, + IN_APP_MESSAGE_DID_DISMISS, + IN_APP_MESSAGE_DID_DISPLAY, + IN_APP_MESSAGE_WILL_DISMISS, + IN_APP_MESSAGE_WILL_DISPLAY, + NOTIFICATION_CLICKED, + NOTIFICATION_WILL_DISPLAY, + PERMISSION_CHANGED, + SUBSCRIPTION_CHANGED, + USER_STATE_CHANGED, +} from '../constants/events'; +import OSNotification from '../OSNotification'; +import type { NotificationClickEvent } from '../types/notificationEvents'; +import type { PushSubscriptionChangedState } from '../types/subscription'; +import type { UserChangedState } from '../types/user'; +import EventManager, { type RawEventListenerMap } from './EventManager'; +import NotificationWillDisplayEvent from './NotificationWillDisplayEvent'; + +describe('EventManager', () => { + let eventManager: EventManager; + let mockNativeModule: any; + let eventCallbacks: Map void>; + + const getCallback = ( + eventName: K, + ): RawEventListenerMap[K] | undefined => { + return eventCallbacks.get(eventName) as RawEventListenerMap[K] | undefined; + }; + + beforeEach(() => { + eventCallbacks = new Map(); + + mockNativeModule = { + addListener: vi.fn(), + removeListener: vi.fn(), + }; + + // Spy on NativeEventEmitter.prototype.addListener to capture callbacks + vi.spyOn(NativeEventEmitter.prototype, 'addListener').mockImplementation( + (eventName: string, callback: (payload: any) => void) => { + eventCallbacks.set(eventName, callback); + return createEmitterSubscriptionMock(eventName, callback) as never; + }, + ); + + eventManager = new EventManager(mockNativeModule); + }); + + describe('constructor', () => { + test('should initialize with all required properties and listeners', () => { + expect(eventManager).toBeDefined(); + expect(eventManager['RNOneSignal']).toBe(mockNativeModule); + expect(eventManager['oneSignalEventEmitter']).toBeDefined(); + expect(eventManager['eventListenerArrayMap']).toBeInstanceOf(Map); + + const listeners = eventManager['listeners']; + const expectedEvents = [ + PERMISSION_CHANGED, + SUBSCRIPTION_CHANGED, + USER_STATE_CHANGED, + NOTIFICATION_WILL_DISPLAY, + NOTIFICATION_CLICKED, + IN_APP_MESSAGE_CLICKED, + IN_APP_MESSAGE_WILL_DISPLAY, + IN_APP_MESSAGE_WILL_DISMISS, + IN_APP_MESSAGE_DID_DISMISS, + IN_APP_MESSAGE_DID_DISPLAY, + ]; + + expectedEvents.forEach((eventName) => { + expect(listeners[eventName]).toBeDefined(); + }); + + // Verify that eventCallbacks were populated during setup + expect(eventCallbacks.size).toBe(10); + }); + }); + + describe('setupListeners', () => { + test('should register all event listeners with NativeEventEmitter', () => { + const eventList = [ + PERMISSION_CHANGED, + SUBSCRIPTION_CHANGED, + USER_STATE_CHANGED, + NOTIFICATION_WILL_DISPLAY, + NOTIFICATION_CLICKED, + IN_APP_MESSAGE_CLICKED, + IN_APP_MESSAGE_WILL_DISPLAY, + IN_APP_MESSAGE_WILL_DISMISS, + IN_APP_MESSAGE_DID_DISMISS, + IN_APP_MESSAGE_DID_DISPLAY, + ]; + + eventList.forEach((eventName) => { + expect(eventCallbacks.has(eventName)).toBe(true); + }); + }); + + test('should not setup listeners if RNOneSignal is null', () => { + new EventManager(null as any); + expect(mockNativeModule.addListener).not.toHaveBeenCalled(); + }); + }); + + describe('addEventListener', () => { + test('should add a handler to a new event', () => { + const handler = vi.fn(); + eventManager.addEventListener(PERMISSION_CHANGED, handler); + + const handlerArray = + eventManager['eventListenerArrayMap'].get(PERMISSION_CHANGED); + expect(handlerArray).toContain(handler); + }); + + test('should add multiple handlers to the same event', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + eventManager.addEventListener(PERMISSION_CHANGED, handler1); + eventManager.addEventListener(PERMISSION_CHANGED, handler2); + + const handlerArray = + eventManager['eventListenerArrayMap'].get(PERMISSION_CHANGED); + expect(handlerArray).toContain(handler1); + expect(handlerArray).toContain(handler2); + expect(handlerArray?.length).toBe(2); + }); + + test('should add handlers to different events independently', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + eventManager.addEventListener(PERMISSION_CHANGED, handler1); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler2); + + const handlerArray1 = + eventManager['eventListenerArrayMap'].get(PERMISSION_CHANGED); + const handlerArray2 = + eventManager['eventListenerArrayMap'].get(SUBSCRIPTION_CHANGED); + + expect(handlerArray1).toContain(handler1); + expect(handlerArray2).toContain(handler2); + }); + }); + + describe('removeEventListener', () => { + test('should remove a handler from an event', () => { + const handler = vi.fn(); + eventManager.addEventListener(PERMISSION_CHANGED, handler); + eventManager.removeEventListener(PERMISSION_CHANGED, handler); + + const handlerArray = + eventManager['eventListenerArrayMap'].get(PERMISSION_CHANGED); + expect(handlerArray).toBeUndefined(); + }); + + test('should remove specific handler when multiple exist', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const handler3 = vi.fn(); + + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler1); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler2); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler3); + eventManager.removeEventListener(SUBSCRIPTION_CHANGED, handler2); + + const handlerArray = + eventManager['eventListenerArrayMap'].get(SUBSCRIPTION_CHANGED); + expect(handlerArray).toContain(handler1); + expect(handlerArray).not.toContain(handler2); + expect(handlerArray).toContain(handler3); + expect(handlerArray?.length).toBe(2); + }); + + test('should do nothing if event does not exist', () => { + const handler = vi.fn(); + expect(() => { + eventManager.removeEventListener('non-existent-event' as any, handler); + }).not.toThrow(); + }); + + test('should do nothing if handler is not in the list', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, handler1); + expect(() => { + eventManager.removeEventListener(NOTIFICATION_WILL_DISPLAY, handler2); + }).not.toThrow(); + + const handlerArray = eventManager['eventListenerArrayMap'].get( + NOTIFICATION_WILL_DISPLAY, + ); + expect(handlerArray).toContain(handler1); + expect(handlerArray?.length).toBe(1); + }); + }); + + describe('generateEventListener', () => { + test('should return an EmitterSubscription', () => { + const listener = + eventManager['generateEventListener'](PERMISSION_CHANGED); + expect(listener).toBeDefined(); + expect(listener.remove).toBeDefined(); + }); + + test('should handle NOTIFICATION_WILL_DISPLAY events', () => { + const handler = vi.fn(); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, handler); + + const callback = getCallback(NOTIFICATION_WILL_DISPLAY); + callback!(rawWillDisplayPayload); + + expect(handler).toHaveBeenCalled(); + const receivedEvent = handler.mock.calls[0][0]; + expect(receivedEvent).toBeInstanceOf(NotificationWillDisplayEvent); + }); + + test('should handle PERMISSION_CHANGED events with boolean payload', () => { + const handler = vi.fn(); + eventManager.addEventListener(PERMISSION_CHANGED, handler); + + const payload = getRawPermissionChangedPayload(true); + const callback = getCallback(PERMISSION_CHANGED); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(true); + }); + + test('should handle generic events with object payload', () => { + const handler = vi.fn(); + + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler); + + const callback = getCallback(SUBSCRIPTION_CHANGED); + callback!(pushChangedPayload); + + expect(handler).toHaveBeenCalledWith(pushChangedPayload); + }); + + test('should call all handlers for an event', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const handler3 = vi.fn(); + + eventManager.addEventListener(USER_STATE_CHANGED, handler1); + eventManager.addEventListener(USER_STATE_CHANGED, handler2); + eventManager.addEventListener(USER_STATE_CHANGED, handler3); + + const callback = getCallback(USER_STATE_CHANGED); + const payload = { + current: { onesignalId: '123', externalId: '456' }, + } satisfies UserChangedState; + callback!(payload); + + expect(handler1).toHaveBeenCalledWith(payload); + expect(handler2).toHaveBeenCalledWith(payload); + expect(handler3).toHaveBeenCalledWith(payload); + }); + + test('should do nothing if no handlers are registered', () => { + const callback = getCallback(IN_APP_MESSAGE_WILL_DISPLAY); + + expect(() => { + callback!({ message: { messageId: 'msg-123' } }); + }).not.toThrow(); + }); + + test('should handle IN_APP_MESSAGE_CLICKED events', () => { + const handler = vi.fn(); + const payload = { + message: { messageId: 'msg-123' }, + result: { closingMessage: false, actionId: 'action-1' }, + }; + + eventManager.addEventListener(IN_APP_MESSAGE_CLICKED, handler); + + const callback = getCallback(IN_APP_MESSAGE_CLICKED); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should handle IN_APP_MESSAGE_WILL_DISPLAY events', () => { + const handler = vi.fn(); + const payload = { message: { messageId: 'msg-123' } }; + + eventManager.addEventListener(IN_APP_MESSAGE_WILL_DISPLAY, handler); + + const callback = getCallback(IN_APP_MESSAGE_WILL_DISPLAY); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should handle IN_APP_MESSAGE_DID_DISPLAY events', () => { + const handler = vi.fn(); + const payload = { message: { messageId: 'msg-123' } }; + + eventManager.addEventListener(IN_APP_MESSAGE_DID_DISPLAY, handler); + + const callback = getCallback(IN_APP_MESSAGE_DID_DISPLAY); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should handle IN_APP_MESSAGE_WILL_DISMISS events', () => { + const handler = vi.fn(); + const payload = { message: { messageId: 'msg-123' } }; + + eventManager.addEventListener(IN_APP_MESSAGE_WILL_DISMISS, handler); + + const callback = getCallback(IN_APP_MESSAGE_WILL_DISMISS); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should handle IN_APP_MESSAGE_DID_DISMISS events', () => { + const handler = vi.fn(); + const payload = { message: { messageId: 'msg-123' } }; + + eventManager.addEventListener(IN_APP_MESSAGE_DID_DISMISS, handler); + + const callback = getCallback(IN_APP_MESSAGE_DID_DISMISS); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + + test('should handle NOTIFICATION_CLICKED events', () => { + const handler = vi.fn(); + const payload = { + result: { actionId: 'action-1' }, + notification: new OSNotification({ + notificationId: 'test-id', + body: 'test-body', + rawPayload: {}, + }), + } satisfies NotificationClickEvent; + + eventManager.addEventListener(NOTIFICATION_CLICKED, handler); + + const callback = getCallback(NOTIFICATION_CLICKED); + callback!(payload); + + expect(handler).toHaveBeenCalledWith(payload); + }); + }); + + describe('integration scenarios', () => { + test('should handle add and remove listener lifecycle', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + // Add handlers + eventManager.addEventListener(PERMISSION_CHANGED, handler1); + eventManager.addEventListener(PERMISSION_CHANGED, handler2); + + // Trigger event + const callback = getCallback(PERMISSION_CHANGED); + const payload = getRawPermissionChangedPayload(true); + callback!(payload); + + expect(handler1).toHaveBeenCalledWith(true); + expect(handler2).toHaveBeenCalledWith(true); + + // Remove one handler + eventManager.removeEventListener(PERMISSION_CHANGED, handler1); + + // Reset mocks + handler1.mockClear(); + handler2.mockClear(); + + // Trigger event again + callback!(getRawPermissionChangedPayload(false)); + + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).toHaveBeenCalledWith(false); + }); + + test('should handle complex event type scenarios', () => { + const permissionHandler = vi.fn(); + const subscriptionHandler = vi.fn(); + const notificationWillDisplayHandler = vi.fn(); + + eventManager.addEventListener(PERMISSION_CHANGED, permissionHandler); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, subscriptionHandler); + eventManager.addEventListener( + NOTIFICATION_WILL_DISPLAY, + notificationWillDisplayHandler, + ); + + // Get callbacks + const permissionCallback = getCallback(PERMISSION_CHANGED); + const subscriptionCallback = getCallback(SUBSCRIPTION_CHANGED); + const notificationCallback = getCallback(NOTIFICATION_WILL_DISPLAY); + + // Trigger different event types + const permissionPayload = getRawPermissionChangedPayload(true); + permissionCallback!(permissionPayload); + subscriptionCallback!(pushChangedPayload); + notificationCallback!(rawWillDisplayPayload); + + expect(permissionHandler).toHaveBeenCalledWith(true); + expect(subscriptionHandler).toHaveBeenCalledWith(pushChangedPayload); + expect(notificationWillDisplayHandler).toHaveBeenCalledWith( + new NotificationWillDisplayEvent(rawWillDisplayPayload), + ); + }); + + test('should maintain separate handler arrays for different events', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const handler3 = vi.fn(); + + eventManager.addEventListener(PERMISSION_CHANGED, handler1); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler2); + eventManager.addEventListener(USER_STATE_CHANGED, handler3); + + const permissionArray = + eventManager['eventListenerArrayMap'].get(PERMISSION_CHANGED); + const subscriptionArray = + eventManager['eventListenerArrayMap'].get(SUBSCRIPTION_CHANGED); + const userStateArray = + eventManager['eventListenerArrayMap'].get(USER_STATE_CHANGED); + + expect(permissionArray).toEqual([handler1]); + expect(subscriptionArray).toEqual([handler2]); + expect(userStateArray).toEqual([handler3]); + expect(permissionArray).not.toEqual(subscriptionArray); + }); + + test('should handle multiple sequential add and remove operations', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const handler3 = vi.fn(); + + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler1); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler2); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, handler3); + + const callback = getCallback(SUBSCRIPTION_CHANGED); + + callback!(pushChangedPayload); + expect(handler1).toHaveBeenCalledWith(pushChangedPayload); + expect(handler2).toHaveBeenCalledWith(pushChangedPayload); + expect(handler3).toHaveBeenCalledWith(pushChangedPayload); + + eventManager.removeEventListener(SUBSCRIPTION_CHANGED, handler2); + handler1.mockClear(); + handler2.mockClear(); + handler3.mockClear(); + + callback!(pushChangedPayload); + expect(handler1).toHaveBeenCalledWith(pushChangedPayload); + expect(handler2).not.toHaveBeenCalled(); + expect(handler3).toHaveBeenCalledWith(pushChangedPayload); + }); + }); +}); + +// helper payloads +const getRawPermissionChangedPayload = (permission: boolean) => ({ + permission: permission, +}); + +const rawWillDisplayPayload = new OSNotification({ + notificationId: 'test-id', + body: 'test-body', + rawPayload: {}, +}); + +const pushChangedPayload = { + previous: { + id: 'previous-id', + token: 'previous-token', + optedIn: false, + }, + current: { + id: 'current-id', + token: 'current-token', + optedIn: true, + }, +} satisfies PushSubscriptionChangedState; diff --git a/src/events/EventManager.ts b/src/events/EventManager.ts index 1a94e579..5d5b2edd 100644 --- a/src/events/EventManager.ts +++ b/src/events/EventManager.ts @@ -16,8 +16,43 @@ import { USER_STATE_CHANGED, } from '../constants/events'; import OSNotification from '../OSNotification'; +import type { + InAppMessageClickEvent, + InAppMessageDidDismissEvent, + InAppMessageDidDisplayEvent, + InAppMessageWillDismissEvent, + InAppMessageWillDisplayEvent, +} from '../types/inAppMessage'; +import type { NotificationClickEvent } from '../types/notificationEvents'; +import type { PushSubscriptionChangedState } from '../types/subscription'; +import type { UserChangedState } from '../types/user'; import NotificationWillDisplayEvent from './NotificationWillDisplayEvent'; +export interface EventListenerMap { + [PERMISSION_CHANGED]: (event: boolean) => void; + [SUBSCRIPTION_CHANGED]: (event: PushSubscriptionChangedState) => void; + [USER_STATE_CHANGED]: (event: UserChangedState) => void; + [NOTIFICATION_WILL_DISPLAY]: (event: NotificationWillDisplayEvent) => void; + [NOTIFICATION_CLICKED]: (event: NotificationClickEvent) => void; + [IN_APP_MESSAGE_CLICKED]: (event: InAppMessageClickEvent) => void; + [IN_APP_MESSAGE_WILL_DISPLAY]: (event: InAppMessageWillDisplayEvent) => void; + [IN_APP_MESSAGE_WILL_DISMISS]: (event: InAppMessageWillDismissEvent) => void; + [IN_APP_MESSAGE_DID_DISMISS]: (event: InAppMessageDidDismissEvent) => void; + [IN_APP_MESSAGE_DID_DISPLAY]: (event: InAppMessageDidDisplayEvent) => void; +} + +// Internal event listeners that connect to the native modules then get +// transformed (via generateEventListener) into the EventListenerMap types +type RawNotificationOverrides = { + [PERMISSION_CHANGED]: (payload: { permission: boolean }) => void; + [NOTIFICATION_WILL_DISPLAY]: (payload: OSNotification) => void; +}; +export type RawEventListenerMap = Omit< + EventListenerMap, + keyof RawNotificationOverrides +> & + RawNotificationOverrides; + const eventList = [ PERMISSION_CHANGED, SUBSCRIPTION_CHANGED, @@ -29,7 +64,7 @@ const eventList = [ IN_APP_MESSAGE_WILL_DISMISS, IN_APP_MESSAGE_DID_DISMISS, IN_APP_MESSAGE_DID_DISPLAY, -]; +] as const; export default class EventManager { private RNOneSignal: NativeModule; @@ -61,7 +96,10 @@ export default class EventManager { * @param {function} handler * @returns void */ - addEventListener(eventName: string, handler: (event: T) => void) { + addEventListener( + eventName: K, + handler: EventListenerMap[K], + ) { let handlerArray = this.eventListenerArrayMap.get(eventName); if (handlerArray && handlerArray.length > 0) { handlerArray.push(handler); @@ -76,7 +114,10 @@ export default class EventManager { * @param {function} handler * @returns void */ - removeEventListener(eventName: string, handler: any) { + removeEventListener( + eventName: K, + handler: EventListenerMap[K], + ) { const handlerArray = this.eventListenerArrayMap.get(eventName); if (!handlerArray) { return; @@ -91,8 +132,10 @@ export default class EventManager { } // returns an event listener with the js to native mapping - generateEventListener(eventName: string): EmitterSubscription { - const addListenerCallback = (payload: object) => { + generateEventListener( + eventName: K, + ): EmitterSubscription { + const addListenerCallback: RawEventListenerMap[K] = (payload: unknown) => { let handlerArray = this.eventListenerArrayMap.get(eventName); if (handlerArray) { if (eventName === NOTIFICATION_WILL_DISPLAY) { @@ -108,7 +151,7 @@ export default class EventManager { }); } else { handlerArray.forEach((handler) => { - handler(payload); + handler(payload as EventListenerMap[K]); }); } } diff --git a/src/index.test.ts b/src/index.test.ts index a5d83227..6b40bd7d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -12,7 +12,7 @@ import { SUBSCRIPTION_CHANGED, USER_STATE_CHANGED, } from './constants/events'; -import EventManager from './events/EventManager'; +import EventManager, { type EventListenerMap } from './events/EventManager'; import * as helpers from './helpers'; import { LogLevel, OneSignal, OSNotificationPermission } from './index'; @@ -38,6 +38,14 @@ const removeEventManagerListenerSpy = vi.spyOn( 'removeEventListener', ); +const filterEventListener = ( + eventName: K, +): EventListenerMap[K] => { + return addEventManagerListenerSpy.mock.calls.filter( + (call) => call[0] === eventName, + )[0][1] as EventListenerMap[K]; +}; + describe('OneSignal', () => { beforeEach(() => { mockPlatform.OS = 'ios'; @@ -64,6 +72,37 @@ describe('OneSignal', () => { test('should initialize OneSignal with appId', () => { OneSignal.initialize(APP_ID); expect(mockRNOneSignal.initialize).toHaveBeenCalledWith(APP_ID); + + // test permission change listener + const changeFn = filterEventListener(PERMISSION_CHANGED); + changeFn(true); + const permission = OneSignal.Notifications.hasPermission(); + expect(permission).toBe(true); + + // test push subscription change listener + const pushData = { + previous: { + id: '', + token: '', + optedIn: false, + }, + current: { + id: 'subscription-id', + token: 'push-token', + optedIn: true, + }, + }; + const subscriptionChangeFn = filterEventListener(SUBSCRIPTION_CHANGED); + subscriptionChangeFn(pushData); + const pushSubscription = + OneSignal.User.pushSubscription.getPushSubscriptionId(); + expect(pushSubscription).toBe('subscription-id'); + + // reset push subscription + subscriptionChangeFn({ + ...pushData, + current: { id: '', token: '', optedIn: false }, + }); }); test('should not initialize if native module is not loaded', () => { diff --git a/src/index.ts b/src/index.ts index a6fb7ce5..2fc85415 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,16 +20,14 @@ import type { InAppMessageClickEvent, InAppMessageDidDismissEvent, InAppMessageDidDisplayEvent, - InAppMessageEventName, - InAppMessageEventTypeMap, + InAppMessageListeners, InAppMessageWillDismissEvent, InAppMessageWillDisplayEvent, } from './types/inAppMessage'; import type { LiveActivitySetupOptions } from './types/liveActivities'; import type { NotificationClickEvent, - NotificationEventName, - NotificationEventTypeMap, + NotificationListeners, } from './types/notificationEvents'; import type { PushSubscriptionChangedState, @@ -62,9 +60,12 @@ let pushSub: PushSubscriptionState = { }; async function _addPermissionObserver() { - OneSignal.Notifications.addEventListener('permissionChange', (granted) => { - notificationPermission = granted; - }); + OneSignal.Notifications.addEventListener( + 'permissionChange', + (granted: boolean) => { + notificationPermission = granted; + }, + ); notificationPermission = await RNOneSignal.hasNotificationPermission(); } @@ -165,14 +166,10 @@ export namespace OneSignal { export function enter( activityId: string, token: string, - handler?: Function, + handler: Function = () => {}, ) { if (!isNativeModuleLoaded(RNOneSignal)) return; - if (!handler) { - handler = () => {}; - } - // Only Available on iOS if (Platform.OS === 'ios') { RNOneSignal.enterLiveActivity(activityId, token, handler); @@ -186,13 +183,9 @@ export namespace OneSignal { * * @param activityId: The activity identifier the live activity on this device will no longer receive updates for. **/ - export function exit(activityId: string, handler?: Function) { + export function exit(activityId: string, handler: Function = () => {}) { if (!isNativeModuleLoaded(RNOneSignal)) return; - if (!handler) { - handler = () => {}; - } - if (Platform.OS === 'ios') { RNOneSignal.exitLiveActivity(activityId, handler); } @@ -293,10 +286,7 @@ export namespace OneSignal { isValidCallback(listener); RNOneSignal.addPushSubscriptionObserver(); - eventManager.addEventListener( - SUBSCRIPTION_CHANGED, - listener, - ); + eventManager.addEventListener(SUBSCRIPTION_CHANGED, listener); } /** Clears current subscription observers. */ @@ -415,10 +405,7 @@ export namespace OneSignal { isValidCallback(listener); RNOneSignal.addUserStateObserver(); - eventManager.addEventListener( - USER_STATE_CHANGED, - listener, - ); + eventManager.addEventListener(USER_STATE_CHANGED, listener); } /** Clears current user state observers. */ @@ -690,49 +677,38 @@ export namespace OneSignal { /** * Add listeners for notification click and/or lifecycle events. */ - export function addEventListener( - event: K, - listener: (event: NotificationEventTypeMap[K]) => void, + export function addEventListener( + ...[event, listener]: NotificationListeners ): void { if (!isNativeModuleLoaded(RNOneSignal)) return; isValidCallback(listener); + /* v8 ignore else -- @preserve */ if (event === 'click') { RNOneSignal.addNotificationClickListener(); - eventManager.addEventListener( - NOTIFICATION_CLICKED, - listener as (event: NotificationClickEvent) => void, - ); + eventManager.addEventListener(NOTIFICATION_CLICKED, listener); } else if (event === 'foregroundWillDisplay') { RNOneSignal.addNotificationForegroundLifecycleListener(); - eventManager.addEventListener( - NOTIFICATION_WILL_DISPLAY, - listener as (event: NotificationWillDisplayEvent) => void, - ); + eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, listener); } else if (event === 'permissionChange') { isValidCallback(listener); RNOneSignal.addPermissionObserver(); - eventManager.addEventListener( - PERMISSION_CHANGED, - listener as (event: boolean) => void, - ); + eventManager.addEventListener(PERMISSION_CHANGED, listener); } } /** * Remove listeners for notification click and/or lifecycle events. */ - export function removeEventListener( - event: K, - listener: (event: NotificationEventTypeMap[K]) => void, + export function removeEventListener( + ...[event, listener]: NotificationListeners ): void { + /* v8 ignore else -- @preserve */ if (event === 'click') { eventManager.removeEventListener(NOTIFICATION_CLICKED, listener); } else if (event === 'foregroundWillDisplay') { eventManager.removeEventListener(NOTIFICATION_WILL_DISPLAY, listener); } else if (event === 'permissionChange') { eventManager.removeEventListener(PERMISSION_CHANGED, listener); - } else { - return; } } @@ -783,86 +759,50 @@ export namespace OneSignal { /** * Add listeners for In-App Message click and/or lifecycle events. */ - export function addEventListener( - event: K, - listener: (event: InAppMessageEventTypeMap[K]) => void, + export function addEventListener( + ...[event, listener]: InAppMessageListeners ): void { - if (!isNativeModuleLoaded(RNOneSignal)) { - return; - } + if (!isNativeModuleLoaded(RNOneSignal)) return; + isValidCallback(listener); + /* v8 ignore else -- @preserve */ if (event === 'click') { - isValidCallback(listener); RNOneSignal.addInAppMessageClickListener(); - eventManager.addEventListener( - IN_APP_MESSAGE_CLICKED, - listener as (event: InAppMessageClickEvent) => void, - ); - } else { - if (event === 'willDisplay') { - isValidCallback(listener); - eventManager.addEventListener( - IN_APP_MESSAGE_WILL_DISPLAY, - listener as (event: InAppMessageWillDisplayEvent) => void, - ); - } else if (event === 'didDisplay') { - isValidCallback(listener); - eventManager.addEventListener( - IN_APP_MESSAGE_DID_DISPLAY, - listener as (event: InAppMessageDidDisplayEvent) => void, - ); - } else if (event === 'willDismiss') { - isValidCallback(listener); - eventManager.addEventListener( - IN_APP_MESSAGE_WILL_DISMISS, - listener as (event: InAppMessageWillDismissEvent) => void, - ); - } else if (event === 'didDismiss') { - isValidCallback(listener); - eventManager.addEventListener( - IN_APP_MESSAGE_DID_DISMISS, - listener as (event: InAppMessageDidDismissEvent) => void, - ); - } else { - return; - } + eventManager.addEventListener(IN_APP_MESSAGE_CLICKED, listener); + } else if (event === 'willDisplay') { + RNOneSignal.addInAppMessagesLifecycleListener(); + eventManager.addEventListener(IN_APP_MESSAGE_WILL_DISPLAY, listener); + } else if (event === 'didDisplay') { + RNOneSignal.addInAppMessagesLifecycleListener(); + eventManager.addEventListener(IN_APP_MESSAGE_DID_DISPLAY, listener); + } else if (event === 'willDismiss') { + RNOneSignal.addInAppMessagesLifecycleListener(); + eventManager.addEventListener(IN_APP_MESSAGE_WILL_DISMISS, listener); + } else if (event === 'didDismiss') { RNOneSignal.addInAppMessagesLifecycleListener(); + eventManager.addEventListener(IN_APP_MESSAGE_DID_DISMISS, listener); } } /** * Remove listeners for In-App Message click and/or lifecycle events. */ - export function removeEventListener( - event: K, - listener: (obj: InAppMessageEventTypeMap[K]) => void, + export function removeEventListener( + ...[event, listener]: InAppMessageListeners ): void { + if (!isNativeModuleLoaded(RNOneSignal)) return; + isValidCallback(listener); + if (event === 'click') { eventManager.removeEventListener(IN_APP_MESSAGE_CLICKED, listener); - } else { - if (event === 'willDisplay') { - eventManager.removeEventListener( - IN_APP_MESSAGE_WILL_DISPLAY, - listener, - ); - } else if (event === 'didDisplay') { - eventManager.removeEventListener( - IN_APP_MESSAGE_DID_DISPLAY, - listener, - ); - } else if (event === 'willDismiss') { - eventManager.removeEventListener( - IN_APP_MESSAGE_WILL_DISMISS, - listener, - ); - } else if (event === 'didDismiss') { - eventManager.removeEventListener( - IN_APP_MESSAGE_DID_DISMISS, - listener, - ); - } else { - return; - } + } else if (event === 'willDisplay') { + eventManager.removeEventListener(IN_APP_MESSAGE_WILL_DISPLAY, listener); + } else if (event === 'didDisplay') { + eventManager.removeEventListener(IN_APP_MESSAGE_DID_DISPLAY, listener); + } else if (event === 'willDismiss') { + eventManager.removeEventListener(IN_APP_MESSAGE_WILL_DISMISS, listener); + } else if (event === 'didDismiss') { + eventManager.removeEventListener(IN_APP_MESSAGE_DID_DISMISS, listener); } } diff --git a/src/types/inAppMessage.ts b/src/types/inAppMessage.ts index 36b5007c..9b34ec85 100644 --- a/src/types/inAppMessage.ts +++ b/src/types/inAppMessage.ts @@ -1,3 +1,5 @@ +import type { EventListenerMap } from '../events/EventManager'; + export type InAppMessageEventName = | 'click' | 'willDisplay' @@ -5,13 +7,12 @@ export type InAppMessageEventName = | 'willDismiss' | 'didDismiss'; -export type InAppMessageEventTypeMap = { - click: InAppMessageClickEvent; - willDisplay: InAppMessageWillDisplayEvent; - didDisplay: InAppMessageDidDisplayEvent; - willDismiss: InAppMessageWillDismissEvent; - didDismiss: InAppMessageDidDismissEvent; -}; +export type InAppMessageListeners = + | ['click', EventListenerMap['OneSignal-inAppMessageClicked']] + | ['willDisplay', EventListenerMap['OneSignal-inAppMessageWillDisplay']] + | ['didDisplay', EventListenerMap['OneSignal-inAppMessageDidDisplay']] + | ['willDismiss', EventListenerMap['OneSignal-inAppMessageWillDismiss']] + | ['didDismiss', EventListenerMap['OneSignal-inAppMessageDidDismiss']]; export interface InAppMessage { messageId: string; diff --git a/src/types/notificationEvents.ts b/src/types/notificationEvents.ts index 281e8b11..c08b8903 100644 --- a/src/types/notificationEvents.ts +++ b/src/types/notificationEvents.ts @@ -1,16 +1,18 @@ import OSNotification from '../OSNotification'; -import NotificationWillDisplayEvent from '../events/NotificationWillDisplayEvent'; +import type { EventListenerMap } from '../events/EventManager'; export type NotificationEventName = | 'click' | 'foregroundWillDisplay' | 'permissionChange'; -export type NotificationEventTypeMap = { - click: NotificationClickEvent; - foregroundWillDisplay: NotificationWillDisplayEvent; - permissionChange: boolean; -}; +export type NotificationListeners = + | ['click', EventListenerMap['OneSignal-notificationClicked']] + | [ + 'foregroundWillDisplay', + EventListenerMap['OneSignal-notificationWillDisplayInForeground'], + ] + | ['permissionChange', EventListenerMap['OneSignal-permissionChanged']]; export interface NotificationClickEvent { result: NotificationClickResult;