diff --git a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java index 3e151160..ec28e64a 100644 --- a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java +++ b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java @@ -598,6 +598,10 @@ public void addOutcomeWithValue(String name, double value) { @Override public void login(String externalUserId) { + if (externalUserId == null || externalUserId.isEmpty()) { + Logging.error("login called with a null or empty externalUserId", null); + return; + } OneSignal.login(externalUserId); } diff --git a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm index 4273ac11..180c35eb 100644 --- a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm +++ b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm @@ -138,6 +138,11 @@ + (void)sendEventWithName:(NSString *)name withBody:(NSDictionary *)body { } RCT_EXPORT_METHOD(login : (NSString *)externalId) { + if (externalId == nil || [externalId length] == 0) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR + message:@"login called with a nil or empty externalId"]; + return; + } [OneSignal login:externalId]; } diff --git a/src/helpers.test.ts b/src/helpers.test.ts index 8d904ebf..60c39f96 100644 --- a/src/helpers.test.ts +++ b/src/helpers.test.ts @@ -4,7 +4,12 @@ import { beforeEach, describe, expect, test, vi, type MockInstance } from 'vite- import { IOS_NULL_SENTINEL } from './constants/internal'; import { encodeNullsForIOS, + hasMissingEntries, + hasMissingItems, + isBoolean, + isInteger, isNativeModuleLoaded, + isMissing, isObjectSerializable, isValidCallback, } from './helpers'; @@ -55,7 +60,7 @@ describe('helpers', () => { expect(result).toBe(false); expect(errorSpy).toHaveBeenCalledTimes(1); expect(errorSpy).toHaveBeenCalledWith( - 'Could not load RNOneSignal native module. Make sure native dependencies are properly linked.', + '[OneSignal] Could not load RNOneSignal native module. Make sure native dependencies are properly linked.', ); }, ); @@ -66,6 +71,93 @@ describe('helpers', () => { }); }); + describe('isBoolean', () => { + test.each([true, false])('should accept %s', (value) => { + expect(isBoolean(value, 'api: flag')).toBe(true); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, 'true', 0, {}])('should reject %s', (value) => { + expect(isBoolean(value, 'api: flag')).toBe(false); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] api: flag must be a boolean'); + }); + }); + + describe('isInteger', () => { + test.each([0, 5, -3])('should accept %s', (value) => { + expect(isInteger(value, 'api: id')).toBe(true); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, '1', 1.5, NaN, Infinity])('should reject %s', (value) => { + expect(isInteger(value, 'api: id')).toBe(false); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] api: id must be an integer'); + }); + }); + + describe('isMissing', () => { + test.each([ + { description: 'a non-empty string', value: 'id', expected: false }, + { description: 'a whitespace string', value: ' ', expected: false }, + { description: 'an empty string', value: '', expected: true }, + { description: 'null', value: null, expected: true }, + { description: 'undefined', value: undefined, expected: true }, + { description: 'a number', value: 1, expected: true }, + { description: 'a boolean', value: true, expected: true }, + ])( + 'should return $expected for $description', + ({ value, expected }: { description: string; value: unknown; expected: boolean }) => { + expect(isMissing(value, 'login: externalId')).toBe(expected); + }, + ); + }); + + describe('hasMissingEntries', () => { + test.each([ + { description: 'null', values: null, allowEmptyValue: false, expected: true }, + { description: 'an array', values: ['x'], allowEmptyValue: false, expected: true }, + { description: 'an empty key', values: { '': 'id' }, allowEmptyValue: false, expected: true }, + { description: 'an empty value', values: { a: '' }, allowEmptyValue: false, expected: true }, + { description: 'a numeric value', values: { a: 5 }, allowEmptyValue: false, expected: true }, + { + description: 'an allowed empty value', + values: { a: '' }, + allowEmptyValue: true, + expected: false, + }, + { + description: 'an allowed numeric value', + values: { a: 5 }, + allowEmptyValue: true, + expected: false, + }, + { description: 'a null value', values: { a: null }, allowEmptyValue: true, expected: true }, + { + description: 'valid entries', + values: { a: 'id' }, + allowEmptyValue: false, + expected: false, + }, + ])('should return $expected for $description', ({ values, allowEmptyValue, expected }) => { + expect( + hasMissingEntries(values as Record | null, 'addAliases', allowEmptyValue), + ).toBe(expected); + }); + }); + + describe('hasMissingItems', () => { + test.each([ + { description: 'null', values: null, expected: true }, + { description: 'a string', values: 'key', expected: true }, + { description: 'an empty item', values: ['a', ''], expected: true }, + { description: 'a non-string item', values: ['a', 1], expected: true }, + { description: 'valid items', values: ['a', 'b'], expected: false }, + { description: 'an empty array', values: [], expected: false }, + ])('should return $expected for $description', ({ values, expected }) => { + expect(hasMissingItems(values, 'removeTags', 'key')).toBe(expected); + }); + }); + describe('isObjectSerializable', () => { test.each([ { description: 'an empty object', value: {} }, diff --git a/src/helpers.ts b/src/helpers.ts index ca6e0f41..d51f360e 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -9,7 +9,7 @@ export function isValidCallback(handler: Function) { export function isNativeModuleLoaded(module: object | null | undefined): boolean { if (module == null) { console.error( - 'Could not load RNOneSignal native module. Make sure native dependencies are properly linked.', + '[OneSignal] Could not load RNOneSignal native module. Make sure native dependencies are properly linked.', ); return false; @@ -18,6 +18,57 @@ export function isNativeModuleLoaded(module: object | null | undefined): boolean return true; } +export function isMissing(value: unknown, api: string): boolean { + if (typeof value === 'string' && value.length > 0) return false; + console.error(`[OneSignal] ${api} is required`); + return true; +} + +export function isBoolean(value: unknown, api: string): value is boolean { + if (typeof value === 'boolean') return true; + console.error(`[OneSignal] ${api} must be a boolean`); + return false; +} + +export function isInteger(value: unknown, api: string): value is number { + if (Number.isInteger(value)) return true; + console.error(`[OneSignal] ${api} must be an integer`); + return false; +} + +export function isObject(value: unknown, api: string): value is Record { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) return true; + console.error(`[OneSignal] ${api} must be an object`); + return false; +} + +export function isFunction(value: unknown, api: string): value is Function { + if (typeof value === 'function') return true; + console.error(`[OneSignal] ${api} must be a function`); + return false; +} + +export function hasMissingEntries( + values: Record | null | undefined, + api: string, + allowEmptyValue = false, +): boolean { + if (!isObject(values, `${api}: argument`)) return true; + return Object.entries(values).some(([key, item]) => { + if (isMissing(key, `${api}: key`)) return true; + if (!allowEmptyValue) return isMissing(item, `${api}: value`); + return item == null && isMissing(item, `${api}: value`); + }); +} + +export function hasMissingItems(values: unknown, api: string, item: string): boolean { + if (!Array.isArray(values)) { + console.error(`[OneSignal] ${api}: ${item}s must be an array of strings`); + return true; + } + return values.some((value) => isMissing(value, `${api}: ${item}`)); +} + /** * Returns true if the value is a JSON-serializable object. */ diff --git a/src/index.test.ts b/src/index.test.ts index 99bc865f..d4354e79 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -18,6 +18,7 @@ import { IOS_NULL_SENTINEL } from './constants/internal'; import EventManager, { type EventListenerMap } from './events/EventManager'; import * as helpers from './helpers'; import { LogLevel, OneSignal, OSNotificationPermission } from './index'; +import type { LiveActivitySetupOptions } from './types/liveActivities'; const mockPlatform = Platform; @@ -112,6 +113,18 @@ describe('OneSignal', () => { expect(mockRNOneSignal.initialize).not.toHaveBeenCalled(); }); + test('should not initialize if appId is null', () => { + OneSignal.initialize(null as unknown as string); + expect(mockRNOneSignal.initialize).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] initialize: appId is required'); + }); + + test('should not initialize if appId is empty', () => { + OneSignal.initialize(''); + expect(mockRNOneSignal.initialize).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] initialize: appId is required'); + }); + test('should keep a permission event that arrives before the startup read resolves', async () => { let resolveStartupRead: ((granted: boolean) => void) | undefined; vi.mocked(mockRNOneSignal.hasNotificationPermission).mockReturnValueOnce( @@ -163,7 +176,7 @@ describe('OneSignal', () => { await flushPromises(); expect(console.warn).toHaveBeenCalledWith( - 'OneSignal: failed to read initial state', + '[OneSignal] failed to read initial state', expect.any(Error), ); }); @@ -180,6 +193,18 @@ describe('OneSignal', () => { OneSignal.login('external-123'); expect(mockRNOneSignal.login).not.toHaveBeenCalled(); }); + + test('should not login if externalId is null', () => { + OneSignal.login(null as unknown as string); + expect(mockRNOneSignal.login).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] login: externalId is required'); + }); + + test('should not login if externalId is empty', () => { + OneSignal.login(''); + expect(mockRNOneSignal.login).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] login: externalId is required'); + }); }); describe('logout', () => { @@ -206,6 +231,14 @@ describe('OneSignal', () => { OneSignal.setConsentRequired(true); expect(mockRNOneSignal.setPrivacyConsentRequired).not.toHaveBeenCalled(); }); + + test.each([undefined, null, 'true', 1])('should not set consent required with %s', (value) => { + OneSignal.setConsentRequired(value as unknown as boolean); + expect(mockRNOneSignal.setPrivacyConsentRequired).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] setConsentRequired: required must be a boolean', + ); + }); }); describe('setConsentGiven', () => { @@ -219,6 +252,14 @@ describe('OneSignal', () => { OneSignal.setConsentGiven(true); expect(mockRNOneSignal.setPrivacyConsentGiven).not.toHaveBeenCalled(); }); + + test.each([undefined, null, 'false', 0])('should not set consent given with %s', (value) => { + OneSignal.setConsentGiven(value as unknown as boolean); + expect(mockRNOneSignal.setPrivacyConsentGiven).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] setConsentGiven: granted must be a boolean', + ); + }); }); describe('Debug', () => { @@ -233,6 +274,25 @@ describe('OneSignal', () => { OneSignal.Debug.setLogLevel(LogLevel.Info); expect(mockRNOneSignal.setLogLevel).not.toHaveBeenCalled(); }); + + test.each([-1, 7])('should not set log level for out-of-range level %s', (level) => { + OneSignal.Debug.setLogLevel(level as LogLevel); + expect(mockRNOneSignal.setLogLevel).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] setLogLevel: level must be a LogLevel value', + ); + }); + + test.each([2.5, '2', null, undefined, NaN])( + 'should not set log level for non-integer level %s', + (level) => { + OneSignal.Debug.setLogLevel(level as unknown as LogLevel); + expect(mockRNOneSignal.setLogLevel).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] setLogLevel: level must be an integer', + ); + }, + ); }); describe('setAlertLevel', () => { @@ -246,6 +306,14 @@ describe('OneSignal', () => { OneSignal.Debug.setAlertLevel(LogLevel.Warn); expect(mockRNOneSignal.setAlertLevel).not.toHaveBeenCalled(); }); + + test('should not set alert level for an invalid level', () => { + OneSignal.Debug.setAlertLevel(99 as LogLevel); + expect(mockRNOneSignal.setAlertLevel).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] setAlertLevel: level must be a LogLevel value', + ); + }); }); }); @@ -281,6 +349,26 @@ describe('OneSignal', () => { OneSignal.LiveActivities.enter('activity-id', 'token'); expect(mockRNOneSignal.enterLiveActivity).not.toHaveBeenCalled(); }); + + test.each([ + ['', 'token', '[OneSignal] enter: activityId is required'], + [123, 'token', '[OneSignal] enter: activityId is required'], + ['activity-id', null, '[OneSignal] enter: token is required'], + ])('should not enter with activityId %s and token %s', (activityId, token, message) => { + OneSignal.LiveActivities.enter(activityId as string, token as string); + expect(mockRNOneSignal.enterLiveActivity).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith(message); + }); + + test.each([null, 1, {}])('should not enter with handler %s', (handler) => { + OneSignal.LiveActivities.enter( + 'activity-id', + 'token', + handler as unknown as (result: object) => void, + ); + expect(mockRNOneSignal.enterLiveActivity).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] enter: handler must be a function'); + }); }); describe('exit', () => { @@ -310,6 +398,21 @@ describe('OneSignal', () => { expect(mockRNOneSignal.exitLiveActivity).not.toHaveBeenCalled(); mockPlatform.OS = 'ios'; }); + + test.each([null, '', 1])('should not exit with activityId %s', (activityId) => { + OneSignal.LiveActivities.exit(activityId as string); + expect(mockRNOneSignal.exitLiveActivity).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] exit: activityId is required'); + }); + + test.each([null, 'cb'])('should not exit with handler %s', (handler) => { + OneSignal.LiveActivities.exit( + 'activity-id', + handler as unknown as (result: object) => void, + ); + expect(mockRNOneSignal.exitLiveActivity).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] exit: handler must be a function'); + }); }); describe('setPushToStartToken', () => { @@ -330,6 +433,15 @@ describe('OneSignal', () => { expect(mockRNOneSignal.setPushToStartToken).not.toHaveBeenCalled(); mockPlatform.OS = 'ios'; }); + + test.each([ + [undefined, 'token', '[OneSignal] setPushToStartToken: activityType is required'], + ['activity-type', 7, '[OneSignal] setPushToStartToken: token is required'], + ])('should not set token with %s and %s', (activityType, token, message) => { + OneSignal.LiveActivities.setPushToStartToken(activityType as string, token as string); + expect(mockRNOneSignal.setPushToStartToken).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith(message); + }); }); describe('removePushToStartToken', () => { @@ -350,6 +462,14 @@ describe('OneSignal', () => { expect(mockRNOneSignal.removePushToStartToken).not.toHaveBeenCalled(); mockPlatform.OS = 'ios'; }); + + test.each([null, '', {}])('should not remove token with activityType %s', (activityType) => { + OneSignal.LiveActivities.removePushToStartToken(activityType as string); + expect(mockRNOneSignal.removePushToStartToken).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] removePushToStartToken: activityType is required', + ); + }); }); describe('setupDefault', () => { @@ -376,6 +496,34 @@ describe('OneSignal', () => { expect(mockRNOneSignal.setupDefaultLiveActivity).not.toHaveBeenCalled(); mockPlatform.OS = 'ios'; }); + + test('should allow options with only one flag', () => { + OneSignal.LiveActivities.setupDefault({ + enablePushToStart: false, + } as LiveActivitySetupOptions); + expect(mockRNOneSignal.setupDefaultLiveActivity).toHaveBeenCalledWith({ + enablePushToStart: false, + }); + }); + + test.each([ + ['a string', 'abc', '[OneSignal] setupDefault: options must be an object'], + ['an array', [], '[OneSignal] setupDefault: options must be an object'], + [ + 'a null flag', + { enablePushToStart: null }, + '[OneSignal] setupDefault: enablePushToStart must be a boolean', + ], + [ + 'an object flag', + { enablePushToUpdate: {} }, + '[OneSignal] setupDefault: enablePushToUpdate must be a boolean', + ], + ])('should not setup with %s', (_description, options, message) => { + OneSignal.LiveActivities.setupDefault(options as unknown as LiveActivitySetupOptions); + expect(mockRNOneSignal.setupDefaultLiveActivity).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith(message); + }); }); describe('startDefault', () => { @@ -402,6 +550,21 @@ describe('OneSignal', () => { expect(mockRNOneSignal.startDefaultLiveActivity).not.toHaveBeenCalled(); mockPlatform.OS = 'ios'; }); + + test.each([ + [null, {}, {}, '[OneSignal] startDefault: activityId is required'], + ['activity-id', null, {}, '[OneSignal] startDefault: attributes must be an object'], + ['activity-id', {}, 'content', '[OneSignal] startDefault: content must be an object'], + ['activity-id', {}, [], '[OneSignal] startDefault: content must be an object'], + ])('should not start with %s, %s, %s', (activityId, attributes, content, message) => { + OneSignal.LiveActivities.startDefault( + activityId as string, + attributes as object, + content as object, + ); + expect(mockRNOneSignal.startDefaultLiveActivity).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith(message); + }); }); }); @@ -455,7 +618,7 @@ describe('OneSignal', () => { const result = OneSignal.User.pushSubscription.getPushSubscriptionId(); expect(result).toBe(''); expect(console.warn).toHaveBeenCalledWith( - 'OneSignal: This method has been deprecated. Use getIdAsync instead for getting push subscription id.', + '[OneSignal] This method has been deprecated. Use getIdAsync instead for getting push subscription id.', ); // with a push id @@ -496,7 +659,7 @@ describe('OneSignal', () => { const result = OneSignal.User.pushSubscription.getPushSubscriptionToken(); expect(result).toBe(''); expect(console.warn).toHaveBeenCalledWith( - 'OneSignal: This method has been deprecated. Use getTokenAsync instead for getting push subscription token.', + '[OneSignal] This method has been deprecated. Use getTokenAsync instead for getting push subscription token.', ); // with a push token @@ -537,7 +700,7 @@ describe('OneSignal', () => { const result = OneSignal.User.pushSubscription.getOptedIn(); expect(result).toBe(false); expect(console.warn).toHaveBeenCalledWith( - 'OneSignal: This method has been deprecated. Use getOptedInAsync instead for getting push subscription opted in status.', + '[OneSignal] This method has been deprecated. Use getOptedInAsync instead for getting push subscription opted in status.', ); // with a opted in status @@ -676,6 +839,16 @@ describe('OneSignal', () => { expect(mockRNOneSignal.setLanguage).toHaveBeenCalledWith('en'); }); + test('forwards an empty language so native can reset', () => { + OneSignal.User.setLanguage(''); + expect(mockRNOneSignal.setLanguage).toHaveBeenCalledWith(''); + }); + + test('does not set a null language', () => { + OneSignal.User.setLanguage(null as unknown as string); + expect(mockRNOneSignal.setLanguage).not.toHaveBeenCalled(); + }); + test('should not set language if native module is not loaded', () => { isNativeLoadedSpy.mockReturnValue(false); OneSignal.User.setLanguage('en'); @@ -708,6 +881,17 @@ describe('OneSignal', () => { OneSignal.User.addAliases({}); expect(mockRNOneSignal.addAliases).not.toHaveBeenCalled(); }); + + test.each([ + { description: 'an empty label', aliases: { '': 'id' } }, + { description: 'an empty id', aliases: { label: '' } }, + { description: 'a null id', aliases: { label: null } }, + { description: 'a non-object', aliases: ['x'] }, + ])('should not add aliases with $description', ({ aliases }) => { + OneSignal.User.addAliases(aliases as unknown as Record); + expect(mockRNOneSignal.addAliases).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); }); describe('removeAlias', () => { @@ -735,6 +919,20 @@ describe('OneSignal', () => { OneSignal.User.removeAliases(['label']); expect(mockRNOneSignal.removeAliases).not.toHaveBeenCalled(); }); + + test('should not remove aliases with an empty label', () => { + OneSignal.User.removeAliases(['label', '']); + expect(mockRNOneSignal.removeAliases).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] removeAliases: label is required'); + }); + + test('should not remove aliases if labels is not an array', () => { + OneSignal.User.removeAliases(null as unknown as string[]); + expect(mockRNOneSignal.removeAliases).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] removeAliases: labels must be an array of strings', + ); + }); }); describe('addEmail', () => { @@ -748,6 +946,18 @@ describe('OneSignal', () => { OneSignal.User.addEmail(EMAIL); expect(mockRNOneSignal.addEmail).not.toHaveBeenCalled(); }); + + test('should not add email if email is null', () => { + OneSignal.User.addEmail(null as unknown as string); + expect(mockRNOneSignal.addEmail).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] addEmail: email is required'); + }); + + test('should not add email if email is empty', () => { + OneSignal.User.addEmail(''); + expect(mockRNOneSignal.addEmail).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] addEmail: email is required'); + }); }); describe('removeEmail', () => { @@ -807,6 +1017,14 @@ describe('OneSignal', () => { expect(mockRNOneSignal.addTag).not.toHaveBeenCalled(); }); + test.each([ + [5, '5'], + [false, 'false'], + ])('should convert tag value %s to a string', (value, expected) => { + OneSignal.User.addTag('key', value as unknown as string); + expect(mockRNOneSignal.addTag).toHaveBeenCalledWith('key', expected); + }); + test('should not add tag if native module is not loaded', () => { isNativeLoadedSpy.mockReturnValue(false); OneSignal.User.addTag('key', 'value'); @@ -826,6 +1044,27 @@ describe('OneSignal', () => { OneSignal.User.addTags({ key: 'value' }); expect(mockRNOneSignal.addTags).not.toHaveBeenCalled(); }); + + test('should allow an empty tag value', () => { + OneSignal.User.addTags({ level: '' }); + expect(mockRNOneSignal.addTags).toHaveBeenCalledWith({ level: '' }); + }); + + test('should convert non-string tag values to strings', () => { + OneSignal.User.addTags({ age: 5, vip: true } as unknown as Record); + expect(mockRNOneSignal.addTags).toHaveBeenCalledWith({ age: '5', vip: 'true' }); + }); + + test.each([ + { description: 'an empty key', tags: { '': 'value' } }, + { description: 'a null value', tags: { key: null } }, + { description: 'a non-object', tags: 'abc' }, + { description: 'null', tags: null }, + ])('should not add tags with $description', ({ tags }) => { + OneSignal.User.addTags(tags as unknown as Record); + expect(mockRNOneSignal.addTags).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); }); describe('removeTag', () => { @@ -853,6 +1092,20 @@ describe('OneSignal', () => { OneSignal.User.removeTags(['key']); expect(mockRNOneSignal.removeTags).not.toHaveBeenCalled(); }); + + test('should not remove tags with an empty key', () => { + OneSignal.User.removeTags(['']); + expect(mockRNOneSignal.removeTags).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] removeTags: key is required'); + }); + + test('should not remove tags if keys is not an array', () => { + OneSignal.User.removeTags('key' as unknown as string[]); + expect(mockRNOneSignal.removeTags).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] removeTags: keys must be an array of strings', + ); + }); }); describe('getTags', () => { @@ -880,6 +1133,47 @@ describe('OneSignal', () => { expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith('purchase', properties); }); + describe.each(['android', 'ios'] as const)('JSON round-trip on %s', (os) => { + const sent = (value: Record) => + os === 'ios' ? helpers.encodeNullsForIOS(value) : value; + + beforeEach(() => { + mockPlatform.OS = os; + }); + + test.each([ + { + description: 'NaN and Infinity as null', + properties: { abc: NaN, def: Infinity, nested: { items: [1, -Infinity] } }, + expected: { abc: null, def: null, nested: { items: [1, null] } }, + }, + { + description: 'a Date as an ISO string', + properties: { at: new Date('2026-01-02T03:04:05.000Z') }, + expected: { at: '2026-01-02T03:04:05.000Z' }, + }, + { + description: 'undefined values and functions dropped', + properties: { kept: 'yes', missing: undefined, fn: () => {} }, + expected: { kept: 'yes' }, + }, + { + description: 'JSON values unchanged', + properties: { s: 'x', n: 1.5, b: false, nil: null, list: ['a', { deep: 2 }] }, + expected: { s: 'x', n: 1.5, b: false, nil: null, list: ['a', { deep: 2 }] }, + }, + ])('should send $description', ({ properties, expected }) => { + OneSignal.User.trackEvent('purchase', properties); + expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith('purchase', sent(expected)); + }); + + test('should not mutate the caller properties', () => { + const properties = { abc: NaN }; + OneSignal.User.trackEvent('purchase', properties); + expect(properties.abc).toBeNaN(); + }); + }); + test('should track event with just name using default empty properties', () => { OneSignal.User.trackEvent('page_view'); expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith('page_view', {}); @@ -891,17 +1185,27 @@ describe('OneSignal', () => { expect(mockRNOneSignal.trackEvent).not.toHaveBeenCalled(); }); + test.each(['', null, undefined])('should not track event with name %s', (name) => { + OneSignal.User.trackEvent(name as unknown as string); + expect(mockRNOneSignal.trackEvent).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] trackEvent: name is required'); + }); + test('should not track event if properties are not serializable', () => { const circular: Record = {}; circular.self = circular; OneSignal.User.trackEvent('event', circular); - expect(errorSpy).toHaveBeenCalledWith('Properties must be a JSON-serializable object'); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] trackEvent: properties must be a JSON-serializable object', + ); expect(mockRNOneSignal.trackEvent).not.toHaveBeenCalled(); }); test('should not track event if properties is not an object', () => { OneSignal.User.trackEvent('event', 'invalid' as unknown as Record); - expect(errorSpy).toHaveBeenCalledWith('Properties must be a JSON-serializable object'); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] trackEvent: properties must be a JSON-serializable object', + ); expect(mockRNOneSignal.trackEvent).not.toHaveBeenCalled(); }); @@ -1012,6 +1316,28 @@ describe('OneSignal', () => { 'OneSignal native module not loaded', ); }); + + test('should default fallbackToSettings to false', async () => { + vi.mocked(mockRNOneSignal.requestNotificationPermission).mockResolvedValue(true); + await OneSignal.Notifications.requestPermission(); + expect(mockRNOneSignal.requestNotificationPermission).toHaveBeenCalledWith(false); + }); + + test('should treat null fallbackToSettings as false', async () => { + vi.mocked(mockRNOneSignal.requestNotificationPermission).mockResolvedValue(true); + await OneSignal.Notifications.requestPermission(null); + expect(mockRNOneSignal.requestNotificationPermission).toHaveBeenCalledWith(false); + }); + + test.each(['true', 1])('should reject fallbackToSettings %s', async (value) => { + await expect( + OneSignal.Notifications.requestPermission(value as unknown as boolean), + ).rejects.toThrow('fallbackToSettings must be a boolean'); + expect(mockRNOneSignal.requestNotificationPermission).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] requestPermission: fallbackToSettings must be a boolean', + ); + }); }); describe('canRequestPermission', () => { @@ -1057,7 +1383,7 @@ describe('OneSignal', () => { OneSignal.Notifications.registerForProvisionalAuthorization(handler); expect(console.warn).toHaveBeenCalledWith( - 'registerForProvisionalAuthorization: this function is not supported on Android', + '[OneSignal] registerForProvisionalAuthorization: this function is not supported on Android', ); expect(mockRNOneSignal.registerForProvisionalAuthorization).not.toHaveBeenCalled(); }); @@ -1174,6 +1500,14 @@ describe('OneSignal', () => { expect(mockRNOneSignal.removeNotification).toHaveBeenCalledWith(NOTIFICATION_ID); }); + test.each(['123', null, 1.5, NaN])('should not remove for invalid id %s', (id) => { + OneSignal.Notifications.removeNotification(id as unknown as number); + expect(mockRNOneSignal.removeNotification).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] removeNotification: id must be an integer', + ); + }); + test('should not remove if native module is not loaded', () => { isNativeLoadedSpy.mockReturnValue(false); OneSignal.Notifications.removeNotification(NOTIFICATION_ID); @@ -1207,12 +1541,21 @@ describe('OneSignal', () => { expect(mockRNOneSignal.removeGroupedNotifications).not.toHaveBeenCalled(); }); + test.each([null, undefined, ''])('should not remove with group id %s', (id) => { + OneSignal.Notifications.removeGroupedNotifications(id as string); + + expect(mockRNOneSignal.removeGroupedNotifications).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] removeGroupedNotifications: id is required', + ); + }); + test('should log message on iOS', () => { mockPlatform.OS = 'ios'; OneSignal.Notifications.removeGroupedNotifications(GROUP_ID); expect(console.warn).toHaveBeenCalledWith( - 'removeGroupedNotifications: this function is not supported on iOS', + '[OneSignal] removeGroupedNotifications: this function is not supported on iOS', ); expect(mockRNOneSignal.removeGroupedNotifications).not.toHaveBeenCalled(); }); @@ -1298,6 +1641,11 @@ describe('OneSignal', () => { OneSignal.InAppMessages.removeEventListener(eventName as any, listener); expect(removeEventManagerListenerSpy).toHaveBeenCalledWith(eventConstant, listener); }); + + test('should not validate the listener', () => { + OneSignal.InAppMessages.removeEventListener('click', null as unknown as () => void); + expect(isValidCallbackSpy).not.toHaveBeenCalled(); + }); }); describe('addTrigger', () => { @@ -1306,18 +1654,26 @@ describe('OneSignal', () => { expect(mockRNOneSignal.addTrigger).toHaveBeenCalledWith('key', 'value'); }); - test('should log error but still call native method if key is missing', () => { + test('should not add trigger if key is missing', () => { OneSignal.InAppMessages.addTrigger('', 'value'); expect(errorSpy).toHaveBeenCalled(); - expect(mockRNOneSignal.addTrigger).toHaveBeenCalledWith('', 'value'); + expect(mockRNOneSignal.addTrigger).not.toHaveBeenCalled(); }); - test('should log error but still call native method if value is null', () => { - OneSignal.InAppMessages.addTrigger('key', null as unknown as string); - expect(errorSpy).toHaveBeenCalled(); - expect(mockRNOneSignal.addTrigger).toHaveBeenCalledWith('key', null); + test('should allow an empty value', () => { + OneSignal.InAppMessages.addTrigger('key', ''); + expect(mockRNOneSignal.addTrigger).toHaveBeenCalledWith('key', ''); }); + test.each([null, undefined, false, 5, {}])( + 'should not add trigger for value %s', + (value) => { + OneSignal.InAppMessages.addTrigger('key', value as unknown as string); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] addTrigger: value must be a string'); + expect(mockRNOneSignal.addTrigger).not.toHaveBeenCalled(); + }, + ); + test('should not add trigger if native module is not loaded', () => { isNativeLoadedSpy.mockReturnValue(false); OneSignal.InAppMessages.addTrigger('key', 'value'); @@ -1337,6 +1693,21 @@ describe('OneSignal', () => { OneSignal.InAppMessages.addTriggers({ key: 'value' }); expect(mockRNOneSignal.addTriggers).not.toHaveBeenCalled(); }); + + test('should allow an empty trigger value', () => { + OneSignal.InAppMessages.addTriggers({ key: '' }); + expect(mockRNOneSignal.addTriggers).toHaveBeenCalledWith({ key: '' }); + }); + + test.each([ + { description: 'an empty key', triggers: { '': 'value' } }, + { description: 'a null value', triggers: { key: null } }, + { description: 'a non-object', triggers: ['a'] }, + ])('should not add triggers with $description', ({ triggers }) => { + OneSignal.InAppMessages.addTriggers(triggers as unknown as Record); + expect(mockRNOneSignal.addTriggers).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); }); describe('removeTrigger', () => { @@ -1364,6 +1735,20 @@ describe('OneSignal', () => { OneSignal.InAppMessages.removeTriggers(['key']); expect(mockRNOneSignal.removeTriggers).not.toHaveBeenCalled(); }); + + test('should not remove triggers with an empty key', () => { + OneSignal.InAppMessages.removeTriggers(['']); + expect(mockRNOneSignal.removeTriggers).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] removeTriggers: key is required'); + }); + + test('should not remove triggers if keys is not an array', () => { + OneSignal.InAppMessages.removeTriggers('key' as unknown as string[]); + expect(mockRNOneSignal.removeTriggers).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] removeTriggers: keys must be an array of strings', + ); + }); }); describe('clearTriggers', () => { @@ -1390,6 +1775,12 @@ describe('OneSignal', () => { OneSignal.InAppMessages.setPaused(true); expect(mockRNOneSignal.paused).not.toHaveBeenCalled(); }); + + test.each([undefined, null, 'true', 1, {}])('should not set paused with %s', (value) => { + OneSignal.InAppMessages.setPaused(value as unknown as boolean); + expect(mockRNOneSignal.paused).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] setPaused: pause must be a boolean'); + }); }); describe('getPaused', () => { @@ -1434,6 +1825,12 @@ describe('OneSignal', () => { OneSignal.Location.setShared(true); expect(mockRNOneSignal.setLocationShared).not.toHaveBeenCalled(); }); + + test.each([undefined, null, 'false', 0])('should not set shared with %s', (value) => { + OneSignal.Location.setShared(value as unknown as boolean); + expect(mockRNOneSignal.setLocationShared).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] setShared: shared must be a boolean'); + }); }); describe('isShared', () => { @@ -1467,6 +1864,12 @@ describe('OneSignal', () => { OneSignal.Session.addOutcome(OUTCOME_NAME); expect(mockRNOneSignal.addOutcome).not.toHaveBeenCalled(); }); + + test.each(['', null])('should not add outcome with name %s', (name) => { + OneSignal.Session.addOutcome(name as unknown as string); + expect(mockRNOneSignal.addOutcome).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] addOutcome: name is required'); + }); }); describe('addUniqueOutcome', () => { @@ -1480,6 +1883,12 @@ describe('OneSignal', () => { OneSignal.Session.addUniqueOutcome(OUTCOME_NAME); expect(mockRNOneSignal.addUniqueOutcome).not.toHaveBeenCalled(); }); + + test('should not add unique outcome with an empty name', () => { + OneSignal.Session.addUniqueOutcome(''); + expect(mockRNOneSignal.addUniqueOutcome).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[OneSignal] addUniqueOutcome: name is required'); + }); }); describe('addOutcomeWithValue', () => { @@ -1498,6 +1907,30 @@ describe('OneSignal', () => { OneSignal.Session.addOutcomeWithValue(OUTCOME_NAME, 100); expect(mockRNOneSignal.addOutcomeWithValue).not.toHaveBeenCalled(); }); + + test.each([-5, 0, 0.5])('should allow value %s', (value) => { + OneSignal.Session.addOutcomeWithValue(OUTCOME_NAME, value); + expect(mockRNOneSignal.addOutcomeWithValue).toHaveBeenCalledWith(OUTCOME_NAME, value); + }); + + test.each([NaN, Infinity, 'abc', '', ' ', null, undefined])( + 'should not add outcome with value %s', + (value) => { + OneSignal.Session.addOutcomeWithValue(OUTCOME_NAME, value as unknown as number); + expect(mockRNOneSignal.addOutcomeWithValue).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] addOutcomeWithValue: value must be a finite number', + ); + }, + ); + + test('should not add outcome with an empty name', () => { + OneSignal.Session.addOutcomeWithValue('', 1); + expect(mockRNOneSignal.addOutcomeWithValue).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + '[OneSignal] addOutcomeWithValue: name is required', + ); + }); }); }); }); diff --git a/src/index.ts b/src/index.ts index 212299d1..c057a2ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,14 @@ import EventManager from './events/EventManager'; import NotificationWillDisplayEvent from './events/NotificationWillDisplayEvent'; import { encodeNullsForIOS, + hasMissingEntries, + hasMissingItems, + isBoolean, + isInteger, isNativeModuleLoaded, + isFunction, + isMissing, + isObject, isObjectSerializable, isValidCallback, } from './helpers'; @@ -57,6 +64,29 @@ export enum LogLevel { Verbose, } +// Native maps the level straight onto its own enum without a range check. +function isValidLogLevel(level: unknown, api: string): boolean { + if (!isInteger(level, `${api}: level`)) return false; + if (level in LogLevel) return true; + console.error(`[OneSignal] ${api}: level must be a LogLevel value`); + return false; +} + +// iOS reads each flag with `boolValue`, which throws on objects, arrays, and NSNull. +function isValidSetupOptions(options: unknown): boolean { + if (!isObject(options, 'setupDefault: options')) return false; + return (['enablePushToStart', 'enablePushToUpdate'] as const).every((flag) => { + if (options[flag] === undefined || typeof options[flag] === 'boolean') return true; + console.error(`[OneSignal] setupDefault: ${flag} must be a boolean`); + return false; + }); +} + +// iOS bridges tags into a Swift [String: String], which crashes on non-string values. +function stringifyValues(values: Record): Record { + return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, String(value)])); +} + let notificationPermission = false; let permissionObserverAdded = false; let subscriptionObserverAdded = false; @@ -107,11 +137,12 @@ export namespace OneSignal { /** Initializes the OneSignal SDK. This should be called during startup of the application. */ export function initialize(appId: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(appId, 'initialize: appId')) return; RNOneSignal.initialize(appId); void Promise.all([_addPermissionObserver(), _addPushSubscriptionObserver()]).catch((error) => { - console.warn('OneSignal: failed to read initial state', error); + console.warn('[OneSignal] failed to read initial state', error); }); } @@ -121,6 +152,7 @@ export namespace OneSignal { */ export function login(externalId: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(externalId, 'login: externalId')) return; RNOneSignal.login(externalId); } @@ -138,6 +170,7 @@ export namespace OneSignal { /** For GDPR users, your application should call this method before setting the App ID. */ export function setConsentRequired(required: boolean) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isBoolean(required, 'setConsentRequired: required')) return; RNOneSignal.setPrivacyConsentRequired(required); } @@ -149,6 +182,7 @@ export namespace OneSignal { */ export function setConsentGiven(granted: boolean) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isBoolean(granted, 'setConsentGiven: granted')) return; RNOneSignal.setPrivacyConsentGiven(granted); } @@ -160,6 +194,7 @@ export namespace OneSignal { */ export function setLogLevel(nsLogLevel: LogLevel) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isValidLogLevel(nsLogLevel, 'setLogLevel')) return; RNOneSignal.setLogLevel(nsLogLevel); } @@ -170,6 +205,7 @@ export namespace OneSignal { */ export function setAlertLevel(visualLogLevel: LogLevel) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isValidLogLevel(visualLogLevel, 'setAlertLevel')) return; RNOneSignal.setAlertLevel(visualLogLevel); } @@ -192,6 +228,13 @@ export namespace OneSignal { if (!isNativeModuleLoaded(RNOneSignal)) return; if (Platform.OS === 'ios') { + if ( + isMissing(activityId, 'enter: activityId') || + isMissing(token, 'enter: token') || + !isFunction(handler, 'enter: handler') + ) { + return; + } RNOneSignal.enterLiveActivity(activityId, token, handler); } } @@ -208,6 +251,9 @@ export namespace OneSignal { if (!isNativeModuleLoaded(RNOneSignal)) return; if (Platform.OS === 'ios') { + if (isMissing(activityId, 'exit: activityId') || !isFunction(handler, 'exit: handler')) { + return; + } RNOneSignal.exitLiveActivity(activityId, handler); } } @@ -227,6 +273,12 @@ export namespace OneSignal { if (!isNativeModuleLoaded(RNOneSignal)) return; if (Platform.OS === 'ios') { + if ( + isMissing(activityType, 'setPushToStartToken: activityType') || + isMissing(token, 'setPushToStartToken: token') + ) { + return; + } RNOneSignal.setPushToStartToken(activityType, token); } } @@ -245,6 +297,7 @@ export namespace OneSignal { if (!isNativeModuleLoaded(RNOneSignal)) return; if (Platform.OS === 'ios') { + if (isMissing(activityType, 'removePushToStartToken: activityType')) return; RNOneSignal.removePushToStartToken(activityType); } } @@ -267,6 +320,7 @@ export namespace OneSignal { if (!isNativeModuleLoaded(RNOneSignal)) return; if (Platform.OS === 'ios') { + if (options != null && !isValidSetupOptions(options)) return; RNOneSignal.setupDefaultLiveActivity(options ?? null); } } @@ -287,6 +341,13 @@ export namespace OneSignal { if (!isNativeModuleLoaded(RNOneSignal)) return; if (Platform.OS === 'ios') { + if ( + isMissing(activityId, 'startDefault: activityId') || + !isObject(attributes, 'startDefault: attributes') || + !isObject(content, 'startDefault: content') + ) { + return; + } RNOneSignal.startDefaultLiveActivity(activityId, attributes, content); } } @@ -324,7 +385,7 @@ export namespace OneSignal { return ''; } console.warn( - 'OneSignal: This method has been deprecated. Use getIdAsync instead for getting push subscription id.', + '[OneSignal] This method has been deprecated. Use getIdAsync instead for getting push subscription id.', ); return pushSub.id ? pushSub.id : ''; @@ -346,7 +407,7 @@ export namespace OneSignal { return ''; } console.warn( - 'OneSignal: This method has been deprecated. Use getTokenAsync instead for getting push subscription token.', + '[OneSignal] This method has been deprecated. Use getTokenAsync instead for getting push subscription token.', ); return pushSub.token ? pushSub.token : ''; @@ -369,7 +430,7 @@ export namespace OneSignal { return false; } console.warn( - 'OneSignal: This method has been deprecated. Use getOptedInAsync instead for getting push subscription opted in status.', + '[OneSignal] This method has been deprecated. Use getOptedInAsync instead for getting push subscription opted in status.', ); return pushSub.optedIn ?? false; @@ -445,9 +506,13 @@ export namespace OneSignal { return RNOneSignal.getExternalId(); } - /** Explicitly set a 2-character language code for the user. */ + /** Explicitly set a 2-character language code for the user. Empty string resets to the device language. */ export function setLanguage(language: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (typeof language !== 'string') { + console.error('[OneSignal] setLanguage: language is required'); + return; + } RNOneSignal.setLanguage(language); } @@ -455,6 +520,7 @@ export namespace OneSignal { /** Set an alias for the current user. If this alias label already exists on this user, it will be overwritten with the new alias id. */ export function addAlias(label: string, id: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(label, 'addAlias: label') || isMissing(id, 'addAlias: id')) return; RNOneSignal.addAlias(label, id); } @@ -462,6 +528,7 @@ export namespace OneSignal { /** Set aliases for the current user. If any alias already exists, it will be overwritten to the new values. */ export function addAliases(aliases: Record) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (hasMissingEntries(aliases, 'addAliases')) return; RNOneSignal.addAliases(aliases); } @@ -469,6 +536,7 @@ export namespace OneSignal { /** Remove an alias from the current user. */ export function removeAlias(label: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(label, 'removeAlias: label')) return; RNOneSignal.removeAlias(label); } @@ -476,6 +544,7 @@ export namespace OneSignal { /** Remove aliases from the current user. */ export function removeAliases(labels: string[]) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (hasMissingItems(labels, 'removeAliases', 'label')) return; RNOneSignal.removeAliases(labels); } @@ -483,6 +552,7 @@ export namespace OneSignal { /** Add a new email subscription to the current user. */ export function addEmail(email: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(email, 'addEmail: email')) return; RNOneSignal.addEmail(email); } @@ -493,6 +563,7 @@ export namespace OneSignal { */ export function removeEmail(email: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(email, 'removeEmail: email')) return; RNOneSignal.removeEmail(email); } @@ -500,6 +571,7 @@ export namespace OneSignal { /** Add a new SMS subscription to the current user. */ export function addSms(smsNumber: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(smsNumber, 'addSms: smsNumber')) return; RNOneSignal.addSms(smsNumber); } @@ -510,6 +582,7 @@ export namespace OneSignal { */ export function removeSms(smsNumber: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(smsNumber, 'removeSms: smsNumber')) return; RNOneSignal.removeSms(smsNumber); } @@ -521,12 +594,13 @@ export namespace OneSignal { export function addTag(key: string, value: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; - if (!key || value === undefined || value === null) { - console.error('OneSignal: addTag: must include a key and a value'); + if (isMissing(key, 'addTag: key')) return; + if (value == null) { + console.error('[OneSignal] addTag: value is required'); return; } - RNOneSignal.addTag(key, value); + RNOneSignal.addTag(key, String(value)); } /** @@ -536,13 +610,15 @@ export namespace OneSignal { */ export function addTags(tags: Record) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (hasMissingEntries(tags, 'addTags', true)) return; - RNOneSignal.addTags(tags); + RNOneSignal.addTags(stringifyValues(tags)); } /** Remove the data tag with the provided key from the current user. */ export function removeTag(key: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(key, 'removeTag: key')) return; RNOneSignal.removeTag(key); } @@ -550,6 +626,7 @@ export namespace OneSignal { /** Remove multiple tags with the provided keys from the current user. */ export function removeTags(keys: string[]) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (hasMissingItems(keys, 'removeTags', 'key')) return; RNOneSignal.removeTags(keys); } @@ -564,19 +641,31 @@ export namespace OneSignal { return tags as { [key: string]: string }; } - /** Track custom events for the current user. */ + /** + * Track custom events for the current user. + * + * @param name - The event name. + * @param properties - A JSON-serializable object, sent as its `JSON.stringify` result: + * `NaN` and `Infinity` become `null`, `Date` values become ISO strings, and `undefined` + * values and functions are omitted. + */ export function trackEvent(name: string, properties: Record = {}) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(name, 'trackEvent: name')) return; if (!isObjectSerializable(properties)) { - console.error('Properties must be a JSON-serializable object'); + console.error('[OneSignal] trackEvent: properties must be a JSON-serializable object'); return; } + // Native JSON serializers reject NaN and Infinity (Android throws, iOS drops the event), + // so send the JSON round-trip, where they become null. + const json = JSON.parse(JSON.stringify(properties)) as Record; + // The iOS TurboModule bridge drops dictionary entries whose value is // `null`. Encode nulls as a sentinel string so the native side can // restore them as `NSNull`. See SDK-4386. - const payload = Platform.OS === 'ios' ? encodeNullsForIOS(properties) : properties; + const payload = Platform.OS === 'ios' ? encodeNullsForIOS(json) : json; RNOneSignal.trackEvent(name, payload); } @@ -588,7 +677,7 @@ export namespace OneSignal { */ export function hasPermission(): boolean { console.warn( - 'OneSignal: This method has been deprecated. Use getPermissionAsync instead for getting notification permission status.', + '[OneSignal] This method has been deprecated. Use getPermissionAsync instead for getting notification permission status.', ); return notificationPermission; @@ -607,12 +696,16 @@ export namespace OneSignal { * notification permission. Use the fallbackToSettings parameter to prompt to open the settings app if a user has already * declined push permissions. */ - export function requestPermission(fallbackToSettings: boolean): Promise { + export function requestPermission(fallbackToSettings?: boolean | null): Promise { if (!isNativeModuleLoaded(RNOneSignal)) { return Promise.reject(new Error('OneSignal native module not loaded')); } + const fallback = fallbackToSettings ?? false; + if (!isBoolean(fallback, 'requestPermission: fallbackToSettings')) { + return Promise.reject(new Error('fallbackToSettings must be a boolean')); + } - return RNOneSignal.requestNotificationPermission(fallbackToSettings); + return RNOneSignal.requestNotificationPermission(fallback); } /** @@ -640,7 +733,7 @@ export namespace OneSignal { RNOneSignal.registerForProvisionalAuthorization(handler); } else { console.warn( - 'registerForProvisionalAuthorization: this function is not supported on Android', + '[OneSignal] registerForProvisionalAuthorization: this function is not supported on Android', ); } } @@ -675,7 +768,6 @@ export namespace OneSignal { RNOneSignal.addNotificationForegroundLifecycleListener(); eventManager.addEventListener(NOTIFICATION_WILL_DISPLAY, listener); } else if (event === 'permissionChange') { - isValidCallback(listener); RNOneSignal.addPermissionObserver(); eventManager.addEventListener(PERMISSION_CHANGED, listener); } @@ -711,9 +803,10 @@ export namespace OneSignal { if (!isNativeModuleLoaded(RNOneSignal)) return; if (Platform.OS === 'android') { + if (!isInteger(id, 'removeNotification: id')) return; RNOneSignal.removeNotification(id); } else { - console.warn('removeNotification: this function is not supported on iOS'); + console.warn('[OneSignal] removeNotification: this function is not supported on iOS'); } } @@ -726,9 +819,12 @@ export namespace OneSignal { if (!isNativeModuleLoaded(RNOneSignal)) return; if (Platform.OS === 'android') { + if (isMissing(id, 'removeGroupedNotifications: id')) return; RNOneSignal.removeGroupedNotifications(id); } else { - console.warn('removeGroupedNotifications: this function is not supported on iOS'); + console.warn( + '[OneSignal] removeGroupedNotifications: this function is not supported on iOS', + ); } } } @@ -765,7 +861,6 @@ export namespace OneSignal { */ export function removeEventListener(...[event, listener]: InAppMessageListeners): void { if (!isNativeModuleLoaded(RNOneSignal)) return; - isValidCallback(listener); if (event === 'click') { eventManager.removeEventListener(IN_APP_MESSAGE_CLICKED, listener); @@ -787,9 +882,10 @@ export namespace OneSignal { export function addTrigger(key: string, value: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; - // value can be assigned to `false` so we cannot just check `!value` - if (!key || value == null) { - console.error('OneSignal: addTrigger: must include a key and a value'); + if (isMissing(key, 'addTrigger: key')) return; + if (typeof value !== 'string') { + console.error('[OneSignal] addTrigger: value must be a string'); + return; } RNOneSignal.addTrigger(key, value); @@ -801,6 +897,7 @@ export namespace OneSignal { */ export function addTriggers(triggers: Record) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (hasMissingEntries(triggers, 'addTriggers', true)) return; RNOneSignal.addTriggers(triggers); } @@ -808,6 +905,7 @@ export namespace OneSignal { /** Remove the trigger with the provided key from the current user. */ export function removeTrigger(key: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(key, 'removeTrigger: key')) return; RNOneSignal.removeTrigger(key); } @@ -815,6 +913,7 @@ export namespace OneSignal { /** Remove multiple triggers from the current user. */ export function removeTriggers(keys: string[]) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (hasMissingItems(keys, 'removeTriggers', 'key')) return; RNOneSignal.removeTriggers(keys); } @@ -833,6 +932,7 @@ export namespace OneSignal { */ export function setPaused(pause: boolean) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isBoolean(pause, 'setPaused: pause')) return; RNOneSignal.paused(pause); } @@ -858,6 +958,7 @@ export namespace OneSignal { /** Disable or enable location collection (defaults to enabled if your app has location permission). */ export function setShared(shared: boolean) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (!isBoolean(shared, 'setShared: shared')) return; RNOneSignal.setLocationShared(shared); } @@ -879,6 +980,7 @@ export namespace OneSignal { /** Increases the "Count" of this Outcome by 1 and will be counted each time sent. */ export function addOutcome(name: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(name, 'addOutcome: name')) return; RNOneSignal.addOutcome(name); } @@ -886,6 +988,7 @@ export namespace OneSignal { /** Increases "Count" by 1 only once. This can only be attributed to a single notification. */ export function addUniqueOutcome(name: string) { if (!isNativeModuleLoaded(RNOneSignal)) return; + if (isMissing(name, 'addUniqueOutcome: name')) return; RNOneSignal.addUniqueOutcome(name); } @@ -897,7 +1000,15 @@ export namespace OneSignal { export function addOutcomeWithValue(name: string, value: string | number) { if (!isNativeModuleLoaded(RNOneSignal)) return; - RNOneSignal.addOutcomeWithValue(name, Number(value)); + if (isMissing(name, 'addOutcomeWithValue: name')) return; + + const numericValue = typeof value === 'string' && value.trim() !== '' ? Number(value) : value; + if (typeof numericValue !== 'number' || !Number.isFinite(numericValue)) { + console.error('[OneSignal] addOutcomeWithValue: value must be a finite number'); + return; + } + + RNOneSignal.addOutcomeWithValue(name, numericValue); } } }