diff --git a/__test__/constants/constants.ts b/__test__/constants/constants.ts index f0b313779..9599106a4 100644 --- a/__test__/constants/constants.ts +++ b/__test__/constants/constants.ts @@ -1,3 +1,5 @@ +import { NotificationType } from 'src/shared/subscriptions/constants'; + export const APP_ID = '34fcbe85-278d-4fd2-a4ec-0f80e95072c5'; export const PUSH_TOKEN = 'https://fcm.googleapis.com/fcm/send/01010101010101'; @@ -13,3 +15,19 @@ export const SUB_ID_2 = '7777777777-8888888888-9999999999'; export const SUB_ID_3 = '1010101010-1111111111-2222222222'; export const DEVICE_OS = '56'; + +export const BASE_IDENTITY = { + properties: { + language: 'en', + timezone_id: 'America/Los_Angeles', + }, + refresh_device_metadata: true, +}; + +export const BASE_SUB = { + device_model: '', + device_os: '56', + enabled: true, + notification_types: NotificationType.Subscribed, + sdk: __VERSION__, +}; diff --git a/__test__/setupTests.ts b/__test__/setupTests.ts index cd264c748..856aca70e 100644 --- a/__test__/setupTests.ts +++ b/__test__/setupTests.ts @@ -1,5 +1,8 @@ +import OneSignalApi from 'src/shared/api/OneSignalApi'; +import { ConfigIntegrationKind } from 'src/shared/config/constants'; import { clearAll } from 'src/shared/database/client'; import { DEFAULT_USER_AGENT } from './constants'; +import TestContext from './support/environment/TestContext'; import { server } from './support/mocks/server'; beforeAll(() => @@ -9,15 +12,15 @@ beforeAll(() => ); beforeEach(async () => { - if (typeof OneSignal !== 'undefined') { - OneSignal.coreDirector?.operationRepo.clear(); - OneSignal.emitter?.removeAllListeners(); - } await clearAll(); }); afterEach(() => { server.resetHandlers(); + if (typeof OneSignal !== 'undefined') { + OneSignal.coreDirector?.operationRepo.clear(); + OneSignal.emitter?.removeAllListeners(); + } }); afterAll(() => server.close()); @@ -44,3 +47,12 @@ Object.defineProperty(navigator, 'userAgent', { value: DEFAULT_USER_AGENT, writable: true, }); + +export const mockJsonp = () => { + const serverConfig = TestContext.getFakeServerAppConfig( + ConfigIntegrationKind.Custom, + ); + vi.spyOn(OneSignalApi, 'jsonpLib').mockImplementation((_, fn) => { + fn(null, serverConfig); + }); +}; diff --git a/__test__/support/environment/TestEnvironment.ts b/__test__/support/environment/TestEnvironment.ts index e85b004bc..f98fbf21d 100644 --- a/__test__/support/environment/TestEnvironment.ts +++ b/__test__/support/environment/TestEnvironment.ts @@ -1,4 +1,5 @@ import { ONESIGNAL_ID } from '__test__/constants'; +import { mockJsonp } from '__test__/setupTests'; import type { AppUserConfig, ConfigIntegrationKindValue, @@ -6,39 +7,40 @@ import type { } from 'src/shared/config/types'; import type { RecursivePartial } from 'src/shared/context/types'; import { updateIdentityModel } from '../helpers/setup'; -import { - initOSGlobals, - stubDomEnvironment, - stubNotification, -} from './TestEnvironmentHelpers'; +import { initOSGlobals, stubNotification } from './TestEnvironmentHelpers'; export interface TestEnvironmentConfig { userConfig?: AppUserConfig; - initOptions?: any; initUserAndPushSubscription?: boolean; // default: false - initializes User & PushSubscription in UserNamespace (e.g. creates an anonymous user) - environment?: string; permission?: NotificationPermission; addPrompts?: boolean; url?: string; - userAgent?: string; overrideServerConfig?: RecursivePartial; integration?: ConfigIntegrationKindValue; - useMockedIdentity?: boolean; } +Object.defineProperty(document, 'readyState', { + value: 'complete', + writable: true, +}); export class TestEnvironment { - static async initialize(config: TestEnvironmentConfig = {}) { - // reset db & localStorage - // await clearAll(); - const oneSignal = await initOSGlobals(config); + static initialize(config: TestEnvironmentConfig = {}) { + mockJsonp(); + const oneSignal = initOSGlobals(config); OneSignal.coreDirector.operationRepo.queue = []; - // if (config.useMockedIdentity) { updateIdentityModel('onesignal_id', ONESIGNAL_ID); - // } - await stubDomEnvironment(config); - config.environment = 'dom'; + // Set URL if provided + if (config.url) { + Object.defineProperty(window, 'location', { + value: new URL(config.url), + writable: true, + }); + } + + window.isSecureContext = true; + stubNotification(config); return oneSignal; } diff --git a/__test__/support/environment/TestEnvironmentHelpers.ts b/__test__/support/environment/TestEnvironmentHelpers.ts index 26def7795..10686d108 100644 --- a/__test__/support/environment/TestEnvironmentHelpers.ts +++ b/__test__/support/environment/TestEnvironmentHelpers.ts @@ -1,35 +1,23 @@ -import { type DOMWindow, JSDOM, ResourceLoader } from 'jsdom'; import CoreModule from 'src/core/CoreModule'; import { SubscriptionModel } from 'src/core/models/SubscriptionModel'; import { ModelChangeTags } from 'src/core/types/models'; -import { db } from 'src/shared/database/client'; import { setPushToken } from 'src/shared/database/subscription'; -import { - NotificationType, - SubscriptionType, -} from 'src/shared/subscriptions/constants'; +import { SubscriptionType } from 'src/shared/subscriptions/constants'; import { CoreModuleDirector } from '../../../src/core/CoreModuleDirector'; import NotificationsNamespace from '../../../src/onesignal/NotificationsNamespace'; import OneSignal from '../../../src/onesignal/OneSignal'; import { ONESIGNAL_EVENTS } from '../../../src/onesignal/OneSignalEvents'; import UserNamespace from '../../../src/onesignal/UserNamespace'; import Context from '../../../src/page/models/Context'; -import { getSlidedownElement } from '../../../src/page/slidedown/SlidedownElement'; import Emitter from '../../../src/shared/libraries/Emitter'; -import { CUSTOM_LINK_CSS_CLASSES } from '../../../src/shared/slidedown/constants'; -import { - DEFAULT_USER_AGENT, - DEVICE_OS, - ONESIGNAL_ID, - SUB_ID_3, -} from '../../constants'; +import { BASE_SUB, ONESIGNAL_ID, SUB_ID_3 } from '../../constants'; import MockNotification from '../mocks/MockNotification'; import TestContext from './TestContext'; import { type TestEnvironmentConfig } from './TestEnvironment'; declare const global: any; -export async function initOSGlobals(config: TestEnvironmentConfig = {}) { +export function initOSGlobals(config: TestEnvironmentConfig = {}) { global.OneSignal = OneSignal; global.OneSignal.EVENTS = ONESIGNAL_EVENTS; global.OneSignal.config = TestContext.getFakeMergedConfig(config); @@ -53,60 +41,6 @@ export function stubNotification(config: TestEnvironmentConfig) { global.Notification.permission = config.permission ? config.permission : global.Notification.permission; - - // window is only defined in dom environment, not in SW - if (config.environment === 'dom') { - global.window.Notification = global.Notification; - } -} - -export async function stubDomEnvironment(config: TestEnvironmentConfig) { - if (!config) { - config = {}; - } - - let url = 'https://localhost:3001/webpush/sandbox?https=1'; - - if (config.url) { - url = config.url.toString(); - global.location = url; - } - - let html = ''; - - if (config.addPrompts) { - html = `\ - \ - \ - \ - ${getSlidedownElement({}).outerHTML}`; - } - - const resourceLoader = new ResourceLoader({ - userAgent: config.userAgent - ? config.userAgent.toString() - : DEFAULT_USER_AGENT.toString(), - }); - - // global document object must be defined for `getSlidedownElement` to work correctly. - // this line initializes the document object - const dom = new JSDOM(html, { - resources: resourceLoader, - url: url, - contentType: 'text/html', - runScripts: 'dangerously', - pretendToBeVisual: true, - }); - - const windowDef = dom.window; - (windowDef as any).location = url; - - const windowTop: DOMWindow = windowDef; - dom.reconfigure({ url, windowTop }); - global.window = windowDef; - global.window.isSecureContext = true; - global.document = windowDef.document; - return dom; } export const createPushSub = ({ @@ -120,14 +54,10 @@ export const createPushSub = ({ } = {}) => { const pushSubscription = new SubscriptionModel(); pushSubscription.initializeFromJson({ - device_model: '', - device_os: DEVICE_OS, - enabled: true, + ...BASE_SUB, id, - notification_types: NotificationType.Subscribed, onesignalId, token, - sdk: __VERSION__, type: SubscriptionType.ChromePush, }); return pushSubscription; @@ -160,15 +90,8 @@ export const setupSubModelStore = async ({ await setPushToken(pushModel.token); OneSignal.coreDirector.subscriptionModelStore.replaceAll( [pushModel], - ModelChangeTags.NO_PROPOGATE, + ModelChangeTags.NO_PROPAGATE, ); - await vi.waitUntil(async () => { - const subscription = (await db.getAll('subscriptions'))[0]; - return ( - subscription.id === pushModel.id && subscription.token === pushModel.token - ); - }); - return pushModel; }; diff --git a/__test__/support/helpers/requests.ts b/__test__/support/helpers/requests.ts index eea6fa06b..efb254842 100644 --- a/__test__/support/helpers/requests.ts +++ b/__test__/support/helpers/requests.ts @@ -1,38 +1,21 @@ import { http, HttpResponse } from 'msw'; import type { ISubscription, IUserProperties } from 'src/core/types/api'; -import { ConfigIntegrationKind } from 'src/shared/config/constants'; +import type { NotificationIcons } from 'src/shared/notifications/types'; import { APP_ID, ONESIGNAL_ID, SUB_ID } from '../../constants'; -import TestContext from '../environment/TestContext'; import { server } from '../mocks/server'; // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // configs -const serverConfig = TestContext.getFakeServerAppConfig( - ConfigIntegrationKind.Custom, -); - -export const mockServerConfig = () => { - return http.get('**/sync/*/web', ({ request }) => { - const url = new URL(request.url); - const callbackParam = url.searchParams.get('callback'); - return new HttpResponse( - `${callbackParam}(${JSON.stringify(serverConfig)})`, - { - headers: { - 'Content-Type': 'application/javascript', - }, - }, - ); - }); -}; export const mockPageStylesCss = () => { - return http.get( - 'https://onesignal.com/sdks/web/v16/OneSignalSDK.page.styles.css', - () => { - return new HttpResponse('/* CSS */', { - headers: { 'Content-Type': 'text/css' }, - }); - }, + server.use( + http.get( + 'https://onesignal.com/sdks/web/v16/OneSignalSDK.page.styles.css', + () => { + return new HttpResponse('/* CSS */', { + headers: { 'Content-Type': 'text/css' }, + }); + }, + ), ); }; @@ -397,3 +380,16 @@ export const setSendCustomEventResponse = () => status: 200, callback: sendCustomEventFn, }); + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// icons +export const getNotificationIcons = () => + server.use( + http.get('**/apps/:appId/icon', () => { + return HttpResponse.json({ + chrome: 'https://onesignal.com/icon.png', + firefox: 'https://onesignal.com/icon.png', + safari: 'https://onesignal.com/icon.png', + }); + }), + ); diff --git a/__test__/support/helpers/setup.ts b/__test__/support/helpers/setup.ts index 957fac54f..e34eef1da 100644 --- a/__test__/support/helpers/setup.ts +++ b/__test__/support/helpers/setup.ts @@ -3,16 +3,21 @@ import { IdentityModel } from 'src/core/models/IdentityModel'; import { PropertiesModel } from 'src/core/models/PropertiesModel'; import { SubscriptionModel } from 'src/core/models/SubscriptionModel'; import { ModelChangeTags } from 'src/core/types/models'; +import { ResourceLoadState } from 'src/page/services/DynamicResourceLoader'; import { db } from 'src/shared/database/client'; import type { IdentitySchema, PropertiesSchema, + SubscriptionSchema, } from 'src/shared/database/types'; export const setIsPushEnabled = async (isPushEnabled: boolean) => { await db.put('Options', { key: 'isPushEnabled', value: isPushEnabled }); }; +/** + * Waits for indexedDB identity table to be populated with the correct identity. + */ export const getIdentityItem = async ( condition: (identity: IdentitySchema) => boolean = () => true, ) => { @@ -24,6 +29,9 @@ export const getIdentityItem = async ( return identity; }; +/** + * Waits for indexedDB properties table to be populated with the correct properties. + */ export const getPropertiesItem = async ( condition: (properties: PropertiesSchema) => boolean = () => true, ) => { @@ -35,58 +43,65 @@ export const getPropertiesItem = async ( return properties; }; +/** + * Waits for indexedDB subscriptions table to be populated with the correct number of subscriptions. + */ +export const getDbSubscriptions = async (length: number) => { + let subscriptions: SubscriptionSchema[] = []; + await vi.waitUntil(async () => { + subscriptions = await db.getAll('subscriptions'); + return subscriptions.length === length; + }); + return subscriptions; +}; + +/** + * Update identity model but not trigger action to trigger api call. + */ export const setupIdentityModel = async ( - { - onesignalID, - }: { - onesignalID?: string; - } = { - onesignalID: ONESIGNAL_ID, - }, + onesignalID: string = ONESIGNAL_ID, ) => { const newIdentityModel = new IdentityModel(); - if (onesignalID) { - newIdentityModel.onesignalId = onesignalID; - } + newIdentityModel.onesignalId = onesignalID; OneSignal.coreDirector.identityModelStore.replace( newIdentityModel, - ModelChangeTags.NO_PROPOGATE, + ModelChangeTags.NO_PROPAGATE, ); - - // wait for db to be updated - await getIdentityItem((i) => i.onesignal_id === onesignalID); }; +/** + * Update properties model but not trigger action to trigger api call. + */ export const setupPropertiesModel = async ( - { - onesignalID, - }: { - onesignalID?: string; - } = { - onesignalID: ONESIGNAL_ID, - }, + onesignalID: string = ONESIGNAL_ID, ) => { const newPropertiesModel = new PropertiesModel(); - if (onesignalID) { - newPropertiesModel.onesignalId = onesignalID; - } + newPropertiesModel.onesignalId = onesignalID; OneSignal.coreDirector.propertiesModelStore.replace( newPropertiesModel, - ModelChangeTags.NO_PROPOGATE, + ModelChangeTags.NO_PROPAGATE, ); // wait for db to be updated await getPropertiesItem((p) => p.onesignalId === onesignalID); }; -export const updateIdentityModel = async ( +/** + * Update identity model but not trigger action to trigger api call. + */ +export const updateIdentityModel = async < + T extends keyof IdentitySchema & string, +>( property: T, value?: IdentitySchema[T], ) => { const identityModel = OneSignal.coreDirector.getIdentityModel(); - identityModel.setProperty(property, value, ModelChangeTags.NO_PROPOGATE); + identityModel.setProperty(property, value, ModelChangeTags.NO_PROPAGATE); }; +/** + * Update properties model but not trigger action to trigger api call. + */ export const updatePropertiesModel = async < T extends Exclude< keyof PropertiesSchema, @@ -97,9 +112,12 @@ export const updatePropertiesModel = async < value?: PropertiesSchema[T], ) => { const propertiesModel = OneSignal.coreDirector.getPropertiesModel(); - propertiesModel.setProperty(property, value, ModelChangeTags.NO_PROPOGATE); + propertiesModel.setProperty(property, value, ModelChangeTags.NO_PROPAGATE); }; +/** + * Update subscription model but not trigger action to trigger api call. + */ export const setupSubscriptionModel = async ( id: string | undefined, token: string | undefined, @@ -109,6 +127,16 @@ export const setupSubscriptionModel = async ( subscriptionModel.token = token || ''; OneSignal.coreDirector.subscriptionModelStore.replaceAll( [subscriptionModel], - ModelChangeTags.NO_PROPOGATE, + ModelChangeTags.NO_PROPAGATE, ); }; + +/** + * In case some action triggers a call to loadSdkStylesheet, we need to mock it. + */ +export const setupLoadStylesheet = async () => { + vi.spyOn( + OneSignal.context.dynamicResourceLoader, + 'loadSdkStylesheet', + ).mockResolvedValue(ResourceLoadState.Loaded); +}; diff --git a/__test__/unit/api/OneSignalWithIndex.ts b/__test__/unit/api/OneSignalWithIndex.ts deleted file mode 100644 index 33499d145..000000000 --- a/__test__/unit/api/OneSignalWithIndex.ts +++ /dev/null @@ -1,5 +0,0 @@ -import OneSignal from '../../../src/onesignal/OneSignal'; - -export interface OneSignalWithIndex extends OneSignal { - [key: string]: any; -} diff --git a/__test__/unit/api/apiJson.test.ts b/__test__/unit/api/apiJson.test.ts index d739558e8..c420d452b 100644 --- a/__test__/unit/api/apiJson.test.ts +++ b/__test__/unit/api/apiJson.test.ts @@ -1,35 +1,28 @@ import OneSignal from '../../../src/onesignal/OneSignal'; import { matchApiToSpec } from '../../support/helpers/api'; -import type { OneSignalWithIndex } from './OneSignalWithIndex'; describe('API matches spec file', () => { - let OneSignalWithIndex: OneSignalWithIndex; - - beforeAll(() => { - OneSignalWithIndex = OneSignal as OneSignalWithIndex; - }); - test('Check top-level OneSignal API', async () => { - await matchApiToSpec({ OneSignal: OneSignalWithIndex }, 'OneSignal'); + await matchApiToSpec({ OneSignal }, 'OneSignal'); }); test('Check Slidedown namespace', async () => { - await matchApiToSpec(OneSignalWithIndex, 'Slidedown'); + await matchApiToSpec(OneSignal, 'Slidedown'); }); test('Check Notifications namespace', async () => { - await matchApiToSpec(OneSignalWithIndex, 'Notifications'); + await matchApiToSpec(OneSignal, 'Notifications'); }); test('Check Session namespace', async () => { - await matchApiToSpec(OneSignalWithIndex, 'Session'); + await matchApiToSpec(OneSignal, 'Session'); }); test('Check User namespace', async () => { - await matchApiToSpec(OneSignalWithIndex, 'User'); + await matchApiToSpec(OneSignal, 'User'); }); test('Check PushSubscription namespace', async () => { - await matchApiToSpec(OneSignalWithIndex['User'], 'PushSubscription'); + await matchApiToSpec(OneSignal['User'], 'PushSubscription'); }); }); diff --git a/__test__/unit/core/osModel.test.ts b/__test__/unit/core/osModel.test.ts deleted file mode 100644 index 6b940a255..000000000 --- a/__test__/unit/core/osModel.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { DEVICE_OS } from '__test__/constants'; -import { SubscriptionModel } from 'src/core/models/SubscriptionModel'; -import { SubscriptionType } from 'src/shared/subscriptions/constants'; -import { generateNewSubscription } from '../../support/helpers/core'; - -describe('Model tests', () => { - test('Set function updates data', async () => { - const newSub = generateNewSubscription(); - expect(newSub.enabled).toBe(undefined); - newSub.setProperty('enabled', true); - expect(newSub.enabled).toBe(true); - }); - - test('Set function broadcasts update event', async () => { - const newSub = generateNewSubscription(); - newSub.subscribe({ - onChanged: () => { - expect(true).toBe(true); - }, - }); - newSub.setProperty('enabled', true); - }); - - test('Hydrate function updates data', async () => { - const newSub = generateNewSubscription(); - expect(newSub.type).toBe(SubscriptionType.Email); - newSub.setProperty('type', SubscriptionType.ChromePush); - expect(newSub.type).toBe(SubscriptionType.ChromePush); - }); - - test('Encode function returns encoded model', async () => { - const newSub = generateNewSubscription(); - expect(newSub.toJSON()).toEqual({ - type: SubscriptionType.Email, - id: '123', - token: 'myToken', - device_os: DEVICE_OS, - device_model: '', - sdk: __VERSION__, - }); - - const model = new SubscriptionModel(); - model.setProperty('type', SubscriptionType.Email); - model.setProperty('id', '123'); - model.setProperty('token', 'myToken'); - - expect(model.toJSON()).toEqual({ - type: SubscriptionType.Email, - id: '123', - token: 'myToken', - device_os: DEVICE_OS, - device_model: '', - sdk: __VERSION__, - }); - }); -}); diff --git a/__test__/unit/models/deliveryPlatformKind.test.ts b/__test__/unit/models/deliveryPlatformKind.test.ts deleted file mode 100644 index 773fd703f..000000000 --- a/__test__/unit/models/deliveryPlatformKind.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DeliveryPlatformKind } from '../../../src/shared/models/DeliveryPlatformKind'; - -describe('DeliveryPlatformKind', () => { - test('delivery platform constants should be correct', async () => { - expect(DeliveryPlatformKind.ChromeLike).toBe(5); - expect(DeliveryPlatformKind.SafariLegacy).toBe(7); - expect(DeliveryPlatformKind.Firefox).toBe(8); - }); -}); diff --git a/__test__/unit/notifications/permission.test.ts b/__test__/unit/notifications/permission.test.ts index 5bd23581f..f8e5bd4ad 100644 --- a/__test__/unit/notifications/permission.test.ts +++ b/__test__/unit/notifications/permission.test.ts @@ -17,8 +17,8 @@ function expectPermissionChangeEvent( } describe('Notifications namespace permission properties', () => { - beforeEach(async () => { - await TestEnvironment.initialize(); + beforeEach(() => { + TestEnvironment.initialize(); }); afterEach(() => { diff --git a/__test__/unit/push/registerForPush.test.ts b/__test__/unit/push/registerForPush.test.ts index 68b08129c..53b2b5be7 100644 --- a/__test__/unit/push/registerForPush.test.ts +++ b/__test__/unit/push/registerForPush.test.ts @@ -12,8 +12,8 @@ vi.mock('src/shared/libraries/Log'); const spy = vi.spyOn(InitHelper, 'registerForPushNotifications'); describe('Register for push', () => { - beforeEach(async () => { - await TestEnvironment.initialize({ + beforeEach(() => { + TestEnvironment.initialize({ addPrompts: true, }); }); diff --git a/__test__/unit/pushSubscription/nativePermissionChange.test.ts b/__test__/unit/pushSubscription/nativePermissionChange.test.ts index a6306d817..7a4bf23cd 100644 --- a/__test__/unit/pushSubscription/nativePermissionChange.test.ts +++ b/__test__/unit/pushSubscription/nativePermissionChange.test.ts @@ -25,8 +25,8 @@ const triggerNotificationSpy = vi.spyOn( vi.useFakeTimers(); describe('Notification Types are set correctly on subscription change', () => { - beforeEach(async () => { - await TestEnvironment.initialize(); + beforeEach(() => { + TestEnvironment.initialize(); OneSignal.emitter = new Emitter(); }); diff --git a/__test__/unit/user/user.test.ts b/__test__/unit/user/user.test.ts index 91fe86488..e0f33c064 100644 --- a/__test__/unit/user/user.test.ts +++ b/__test__/unit/user/user.test.ts @@ -5,8 +5,8 @@ import { TestEnvironment } from '../../support/environment/TestEnvironment'; vi.mock('../../../src/shared/libraries/Log'); describe('User tests', () => { - test('getTags called with unset tags should return empty tags', async () => { - await TestEnvironment.initialize(); + test('getTags called with unset tags should return empty tags', () => { + TestEnvironment.initialize(); const user = User.createOrGetInstance(); const tags = user.getTags(); @@ -14,8 +14,8 @@ describe('User tests', () => { expect(tags).toStrictEqual({}); }); - test('getTags called with empty tags in properties model should return empty tags', async () => { - await TestEnvironment.initialize(); + test('getTags called with empty tags in properties model should return empty tags', () => { + TestEnvironment.initialize(); const user = User.createOrGetInstance(); const tags = user.getTags(); @@ -23,8 +23,8 @@ describe('User tests', () => { expect(tags).toStrictEqual({}); }); - test('getTags called with tags in properties model should return tags', async () => { - await TestEnvironment.initialize(); + test('getTags called with tags in properties model should return tags', () => { + TestEnvironment.initialize(); const tagsSample = { key1: 'value1' }; const propModel = OneSignal.coreDirector.getPropertiesModel(); @@ -36,8 +36,8 @@ describe('User tests', () => { expect(tags).toBe(tagsSample); }); - test('getLanguage should return the correct user language', async () => { - await TestEnvironment.initialize(); + test('getLanguage should return the correct user language', () => { + TestEnvironment.initialize(); const languageSample = 'fr'; @@ -50,8 +50,8 @@ describe('User tests', () => { expect(language).toBe(languageSample); }); - test('setLanguage should call the properties model set method', async () => { - await TestEnvironment.initialize(); + test('setLanguage should call the properties model set method', () => { + TestEnvironment.initialize(); const languageSample = 'fr'; diff --git a/index.html b/index.html index 4521dd8fe..ecebc2621 100644 --- a/index.html +++ b/index.html @@ -49,6 +49,7 @@ // uncomment to test login // OneSignalDeferred.push(async function (OneSignal) { // await OneSignal.login('new-web-sdk'); + // OneSignal.User.addEmail('test@test.com'); // }); diff --git a/package-lock.json b/package-lock.json index 634594393..fb6da6d09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@types/intl-tel-input": "^18.1.4", "@types/jsdom": "^21.1.7", "@types/jsonp": "^0.2.3", - "@types/node": "^24.1.0", + "@types/node": "^24.3.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^5.36.1", "@typescript-eslint/parser": "^5.36.1", @@ -30,6 +30,7 @@ "eslint": "^8.23.0", "eslint-config-prettier": "9.0.0", "fake-indexeddb": "5.0.2", + "intl-tel-input": "^25.4.0", "jsdom": "^26.1.0", "msw": "^2.10.5", "prettier": "3.6.2", @@ -1460,13 +1461,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", - "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~7.10.0" } }, "node_modules/@types/qs": { @@ -3502,6 +3503,13 @@ "dev": true, "license": "ISC" }, + "node_modules/intl-tel-input": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/intl-tel-input/-/intl-tel-input-25.4.0.tgz", + "integrity": "sha512-8Z9KCQXXg4htav1sRQIe3x4u1jqpe63aj+WkiEEr4Qz4V/kPry/AllvSotIi0OJAyEFu8nYif2mIko3dAneIEA==", + "dev": true, + "license": "MIT" + }, "node_modules/irregular-plurals": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", @@ -5497,9 +5505,9 @@ } }, "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 9081c557f..71fe92ba7 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@types/intl-tel-input": "^18.1.4", "@types/jsdom": "^21.1.7", "@types/jsonp": "^0.2.3", - "@types/node": "^24.1.0", + "@types/node": "^24.3.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^5.36.1", "@typescript-eslint/parser": "^5.36.1", @@ -60,6 +60,7 @@ "eslint": "^8.23.0", "eslint-config-prettier": "9.0.0", "fake-indexeddb": "5.0.2", + "intl-tel-input": "^25.4.0", "jsdom": "^26.1.0", "msw": "^2.10.5", "prettier": "3.6.2", @@ -81,12 +82,12 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "54.812 kB", + "limit": "54.94 kB", "gzip": true }, { "path": "./build/releases/OneSignalSDK.sw.js", - "limit": "15.3 kB", + "limit": "15.293 kB", "gzip": true }, { diff --git a/src/core/executors/IdentityOperationExecutor.test.ts b/src/core/executors/IdentityOperationExecutor.test.ts index 1b2ed32dd..1a0bb7959 100644 --- a/src/core/executors/IdentityOperationExecutor.test.ts +++ b/src/core/executors/IdentityOperationExecutor.test.ts @@ -9,7 +9,6 @@ import { } from '__test__/support/helpers/requests'; import { updateIdentityModel } from '__test__/support/helpers/setup'; import { ExecutionResult } from 'src/core/types/operation'; -import type { IdentitySchema } from 'src/shared/database/types'; import type { MockInstance } from 'vitest'; import { OPERATION_NAME } from '../constants'; import { RebuildUserService } from '../modelRepo/RebuildUserService'; @@ -42,8 +41,8 @@ describe('IdentityOperationExecutor', () => { ); }; - beforeAll(async () => { - await TestEnvironment.initialize(); + beforeAll(() => { + TestEnvironment.initialize(); }); beforeEach(() => { @@ -109,7 +108,7 @@ describe('IdentityOperationExecutor', () => { test('can execute delete alias op', async () => { updateIdentityModel('onesignal_id', ONESIGNAL_ID); - updateIdentityModel(label as keyof IdentitySchema, value); + updateIdentityModel(label, value); const executor = getExecutor(); diff --git a/src/core/executors/LoginUserOperationExecutor.test.ts b/src/core/executors/LoginUserOperationExecutor.test.ts index 7e40bbf80..e5d8298d1 100644 --- a/src/core/executors/LoginUserOperationExecutor.test.ts +++ b/src/core/executors/LoginUserOperationExecutor.test.ts @@ -1,5 +1,6 @@ import { APP_ID, + BASE_IDENTITY, DEVICE_OS, EXTERNAL_ID, ONESIGNAL_ID, @@ -54,11 +55,11 @@ let rebuildUserService: RebuildUserService; vi.mock('src/shared/libraries/Log'); describe('LoginUserOperationExecutor', () => { - beforeAll(async () => { - await TestEnvironment.initialize(); + beforeAll(() => { + TestEnvironment.initialize(); }); - beforeEach(async () => { + beforeEach(() => { identityModelStore = OneSignal.coreDirector.identityModelStore; propertiesModelStore = OneSignal.coreDirector.propertiesModelStore; subscriptionModelStore = OneSignal.coreDirector.subscriptionModelStore; @@ -168,10 +169,10 @@ describe('LoginUserOperationExecutor', () => { await setPushToken(PUSH_TOKEN); const subscriptionModel = new SubscriptionModel(); - subscriptionModel.setProperty('id', SUB_ID, ModelChangeTags.NO_PROPOGATE); + subscriptionModel.setProperty('id', SUB_ID, ModelChangeTags.NO_PROPAGATE); subscriptionModelStore.add( subscriptionModel, - ModelChangeTags.NO_PROPOGATE, + ModelChangeTags.NO_PROPAGATE, ); // perform operations with old onesignal id @@ -346,11 +347,7 @@ describe('LoginUserOperationExecutor', () => { identity: { external_id: EXTERNAL_ID, }, - properties: { - language: 'en', - timezone_id: 'America/Los_Angeles', - }, - refresh_device_metadata: true, + ...BASE_IDENTITY, subscriptions: [ { device_model: '', diff --git a/src/core/executors/RefreshUserOperationExecutor.test.ts b/src/core/executors/RefreshUserOperationExecutor.test.ts index 3610eec37..1fd5f515b 100644 --- a/src/core/executors/RefreshUserOperationExecutor.test.ts +++ b/src/core/executors/RefreshUserOperationExecutor.test.ts @@ -12,7 +12,6 @@ import { SomeOperation } from '__test__/support/helpers/executors'; import { setGetUserError, setGetUserResponse, - setUpdateSubscriptionResponse, } from '__test__/support/helpers/requests'; import { updateIdentityModel } from '__test__/support/helpers/setup'; import { setPushToken } from 'src/shared/database/subscription'; @@ -43,11 +42,11 @@ let getRebuildOpsSpy: MockInstance; vi.mock('src/shared/libraries/Log'); describe('RefreshUserOperationExecutor', () => { - beforeAll(async () => { - await TestEnvironment.initialize(); + beforeAll(() => { + TestEnvironment.initialize(); }); - beforeEach(async () => { + beforeEach(() => { identityModelStore = OneSignal.coreDirector.identityModelStore; propertiesModelStore = OneSignal.coreDirector.propertiesModelStore; subscriptionModelStore = OneSignal.coreDirector.subscriptionModelStore; @@ -177,7 +176,6 @@ describe('RefreshUserOperationExecutor', () => { }); test('should preserve cached push subscription when updating models', async () => { - setUpdateSubscriptionResponse({ subscriptionId: SUB_ID_2 }); // Set up a push subscription in the store const pushSubModel = new SubscriptionModel(); pushSubModel.id = SUB_ID_2; @@ -185,7 +183,7 @@ describe('RefreshUserOperationExecutor', () => { pushSubModel.token = PUSH_TOKEN; pushSubModel.notification_types = NotificationType.Subscribed; - subscriptionModelStore.add(pushSubModel, ModelChangeTags.NO_PROPOGATE); + subscriptionModelStore.add(pushSubModel, ModelChangeTags.NO_PROPAGATE); await setPushToken(PUSH_TOKEN); const executor = getExecutor(); diff --git a/src/core/executors/SubscriptionOperationExecutor.test.ts b/src/core/executors/SubscriptionOperationExecutor.test.ts index 74fbf9b36..a46be2e12 100644 --- a/src/core/executors/SubscriptionOperationExecutor.test.ts +++ b/src/core/executors/SubscriptionOperationExecutor.test.ts @@ -50,12 +50,11 @@ const BACKEND_SUBSCRIPTION_ID = 'backend-subscription-id'; vi.mock('src/shared/libraries/Log'); describe('SubscriptionOperationExecutor', () => { - beforeAll(async () => { - await TestEnvironment.initialize(); + beforeAll(() => { + TestEnvironment.initialize(); }); - beforeEach(async () => { - setCreateSubscriptionResponse({}); + beforeEach(() => { subscriptionModelStore = OneSignal.coreDirector.subscriptionModelStore; newRecordsState = OneSignal.coreDirector.newRecordsState; newRecordsState.records.clear(); diff --git a/src/core/executors/UpdateUserOperationExecutor.test.ts b/src/core/executors/UpdateUserOperationExecutor.test.ts index 7e417d595..5b14fd610 100644 --- a/src/core/executors/UpdateUserOperationExecutor.test.ts +++ b/src/core/executors/UpdateUserOperationExecutor.test.ts @@ -27,8 +27,8 @@ let getRebuildOpsSpy: MockInstance; vi.mock('src/shared/libraries/Log'); describe('UpdateUserOperationExecutor', () => { - beforeAll(async () => { - await TestEnvironment.initialize(); + beforeAll(() => { + TestEnvironment.initialize(); }); beforeEach(() => { diff --git a/src/core/models/IdentityModel.ts b/src/core/models/IdentityModel.ts index 0288c6706..41bb30e04 100644 --- a/src/core/models/IdentityModel.ts +++ b/src/core/models/IdentityModel.ts @@ -1,18 +1,13 @@ +import type { IdentitySchema } from 'src/shared/database/types'; import { IdentityConstants } from '../constants'; import { Model } from './Model'; -type IIdentityModel = { - [IdentityConstants.ONESIGNAL_ID]: string; - [IdentityConstants.EXTERNAL_ID]?: string | undefined; - [key: string]: string | undefined; -}; - /** * The identity model as a MapModel: a simple key-value pair where the key represents * the alias label and the value represents the alias ID for that alias label. * This model provides simple access to more well-defined aliases. */ -export class IdentityModel extends Model { +export class IdentityModel extends Model { /** * The OneSignal ID for this identity. * WARNING: This *might* be a local ID depending on whether the user has been diff --git a/src/core/models/Model.test.ts b/src/core/models/Model.test.ts new file mode 100644 index 000000000..d3fb7fba7 --- /dev/null +++ b/src/core/models/Model.test.ts @@ -0,0 +1,54 @@ +import { DEVICE_OS } from '__test__/constants'; +import { generateNewSubscription } from '__test__/support/helpers/core'; +import { SubscriptionModel } from 'src/core/models/SubscriptionModel'; +import { SubscriptionType } from 'src/shared/subscriptions/constants'; + +test('Set function updates data', async () => { + const newSub = generateNewSubscription(); + expect(newSub.enabled).toBe(undefined); + newSub.setProperty('enabled', true); + expect(newSub.enabled).toBe(true); +}); + +test('Set function broadcasts update event', async () => { + const newSub = generateNewSubscription(); + newSub.subscribe({ + onChanged: () => { + expect(true).toBe(true); + }, + }); + newSub.setProperty('enabled', true); +}); + +test('Hydrate function updates data', async () => { + const newSub = generateNewSubscription(); + expect(newSub.type).toBe(SubscriptionType.Email); + newSub.setProperty('type', SubscriptionType.ChromePush); + expect(newSub.type).toBe(SubscriptionType.ChromePush); +}); + +test('Encode function returns encoded model', async () => { + const newSub = generateNewSubscription(); + expect(newSub.toJSON()).toEqual({ + type: SubscriptionType.Email, + id: '123', + token: 'myToken', + device_os: DEVICE_OS, + device_model: '', + sdk: __VERSION__, + }); + + const model = new SubscriptionModel(); + model.setProperty('type', SubscriptionType.Email); + model.setProperty('id', '123'); + model.setProperty('token', 'myToken'); + + expect(model.toJSON()).toEqual({ + type: SubscriptionType.Email, + id: '123', + token: 'myToken', + device_os: DEVICE_OS, + device_model: '', + sdk: __VERSION__, + }); +}); diff --git a/src/core/types/models.ts b/src/core/types/models.ts index e306020d3..39d14b35f 100644 --- a/src/core/types/models.ts +++ b/src/core/types/models.ts @@ -9,7 +9,7 @@ export const ModelChangeTags = { /** * A change was performed that should *not* be propogated to the backend. */ - NO_PROPOGATE: 'NO_PROPOGATE', + NO_PROPAGATE: 'NO_PROPAGATE', /** * A change was performed through the backend hydrating the model. diff --git a/src/entries/pageSdkInit.test.ts b/src/entries/pageSdkInit.test.ts index b9860e02b..5bca5a0c4 100644 --- a/src/entries/pageSdkInit.test.ts +++ b/src/entries/pageSdkInit.test.ts @@ -1,21 +1,17 @@ import { APP_ID } from '__test__/constants'; import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; -import { mockServerConfig } from '__test__/support/helpers/requests'; import { server } from '__test__/support/mocks/server'; import { http, HttpResponse } from 'msw'; import Log from 'src/shared/libraries/Log'; // need to wait for full OperationRepo rework describe('pageSdkInit', () => { - beforeEach(async () => { + beforeEach(() => { const cssURL = 'https://onesignal.com/sdks/web/v16/OneSignalSDK.page.styles.css'; - server.use( - mockServerConfig(), - http.get(cssURL, () => HttpResponse.text('')), - ); - await TestEnvironment.initialize(); + server.use(http.get(cssURL, () => HttpResponse.text(''))); + TestEnvironment.initialize(); }); afterEach(async () => { diff --git a/src/entries/pageSdkInit2.test.ts b/src/entries/pageSdkInit2.test.ts index e24f56b0c..c52fcc6d9 100644 --- a/src/entries/pageSdkInit2.test.ts +++ b/src/entries/pageSdkInit2.test.ts @@ -1,7 +1,7 @@ // separate test file to avoid side effects from pageSdkInit.test.ts import { APP_ID, - DEVICE_OS, + BASE_SUB, ONESIGNAL_ID, PUSH_TOKEN, SUB_ID, @@ -10,27 +10,27 @@ import { import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; import { setupSubModelStore } from '__test__/support/environment/TestEnvironmentHelpers'; import { - mockServerConfig, setCreateSubscriptionResponse, setCreateUserResponse, setGetUserResponse, } from '__test__/support/helpers/requests'; -import { updateIdentityModel } from '__test__/support/helpers/setup'; -import { server } from '__test__/support/mocks/server'; +import { + getDbSubscriptions, + updateIdentityModel, +} from '__test__/support/helpers/setup'; import { SubscriptionModel } from 'src/core/models/SubscriptionModel'; -import { db } from 'src/shared/database/client'; -import type { SubscriptionSchema } from 'src/shared/database/types'; import Log from 'src/shared/libraries/Log'; import { IDManager } from 'src/shared/managers/IDManager'; describe('pageSdkInit 2', () => { - beforeEach(async () => { - await TestEnvironment.initialize(); - updateIdentityModel('onesignal_id', undefined); - server.use(mockServerConfig()); + beforeEach(() => { + TestEnvironment.initialize(); }); test('can login and addEmail', async () => { + const onesignalId = IDManager.createLocalId(); + updateIdentityModel('onesignal_id', onesignalId); + const email = 'joe@example.com'; const subModel = await setupSubModelStore({ id: SUB_ID, @@ -96,21 +96,13 @@ describe('pageSdkInit 2', () => { }); // wait user subscriptions to be refresh/replaced - let subscriptions: SubscriptionSchema[] = []; - await vi.waitUntil(async () => { - subscriptions = await db.getAll('subscriptions'); - return subscriptions.length === 2; - }); + const subscriptions = await getDbSubscriptions(2); subscriptions.sort((a, b) => a.type.localeCompare(b.type)); // should the push subscription and the email be added to the subscriptions modelstore const shared = { - device_model: '', - device_os: DEVICE_OS, - enabled: true, + ...BASE_SUB, modelName: 'subscriptions', - notification_types: 1, - sdk: __VERSION__, }; expect(subscriptions).toEqual([ { diff --git a/src/global.d.ts b/src/global.d.ts index 571fe300f..73b142fbd 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,3 +1,4 @@ +import intlTelInput from 'intl-tel-input'; import { OneSignalDeferredLoadedCallback } from './page/models/OneSignalDeferredLoadedCallback'; /** @@ -34,21 +35,8 @@ declare global { safari?: { pushNotification?: SafariRemoteNotification; }; - intlTelInputUtils: { - numberFormat: { - E164: string; - }; - }; - intlTelInput: ( - element: Element, - options: { - autoPlaceholder: string; - separateDialCode: boolean; - }, - ) => { - isValidNumber: () => boolean; - getNumber: (format: string) => string; - }; + intlTelInputUtils: (typeof intlTelInput)['utils']; + intlTelInput: typeof intlTelInput; } interface WorkerGlobalScope { diff --git a/src/onesignal/NotificationsNamespace.test.ts b/src/onesignal/NotificationsNamespace.test.ts index 9e6e154f6..10b9770aa 100644 --- a/src/onesignal/NotificationsNamespace.test.ts +++ b/src/onesignal/NotificationsNamespace.test.ts @@ -7,8 +7,8 @@ import { import NotificationsNamespace from './NotificationsNamespace'; describe('NotificationsNamespace', () => { - beforeEach(async () => { - await TestEnvironment.initialize(); + beforeEach(() => { + TestEnvironment.initialize(); }); test('should set the default url', async () => { diff --git a/src/onesignal/OneSignal.test.ts b/src/onesignal/OneSignal.test.ts index 218ccc8b5..1cf94b606 100644 --- a/src/onesignal/OneSignal.test.ts +++ b/src/onesignal/OneSignal.test.ts @@ -1,5 +1,6 @@ import { - APP_ID, + BASE_IDENTITY, + BASE_SUB, DEVICE_OS, ONESIGNAL_ID, ONESIGNAL_ID_2, @@ -18,7 +19,6 @@ import { deleteSubscriptionFn, getUserFn, mockPageStylesCss, - mockServerConfig, sendCustomEventFn, setAddAliasError, setAddAliasResponse, @@ -29,19 +29,18 @@ import { setGetUserResponse, setSendCustomEventResponse, setTransferSubscriptionResponse, - setUpdateSubscriptionResponse, setUpdateUserResponse, transferSubscriptionFn, updateUserFn, } from '__test__/support/helpers/requests'; import { + getDbSubscriptions, getIdentityItem, setupIdentityModel, setupPropertiesModel, updateIdentityModel, } from '__test__/support/helpers/setup'; import { MockServiceWorker } from '__test__/support/mocks/MockServiceWorker'; -import { server } from '__test__/support/mocks/server'; import { OperationQueueItem } from 'src/core/operationRepo/OperationRepo'; import { type ICreateUserSubscription } from 'src/core/types/api'; import { ModelChangeTags } from 'src/core/types/models'; @@ -56,30 +55,29 @@ import { IDManager } from 'src/shared/managers/IDManager'; import { SubscriptionManagerPage } from 'src/shared/managers/subscription/page'; import { RawPushSubscription } from 'src/shared/models/RawPushSubscription'; +mockPageStylesCss(); + describe('OneSignal', () => { - beforeAll(async () => { - server.use(mockServerConfig(), mockPageStylesCss()); - await TestEnvironment.initialize(); - await OneSignal.init({ appId: APP_ID }); + beforeAll(() => { + TestEnvironment.initialize({ + initUserAndPushSubscription: true, + }); }); beforeEach(async () => { OneSignal.coreDirector.subscriptionModelStore.replaceAll( [], - ModelChangeTags.NO_PROPOGATE, + ModelChangeTags.NO_PROPAGATE, ); - // OneSignal.coreDirector.operationRepo.clear(); setConsentRequired(false); setupPropertiesModel(); - await setupIdentityModel(); + setupIdentityModel(); }); describe('User', () => { describe('aliases', () => { beforeEach(() => { setAddAliasResponse(); - addAliasFn.mockClear(); - deleteAliasFn.mockClear(); }); test('can add an alias to the current user', async () => { @@ -170,14 +168,10 @@ describe('OneSignal', () => { onesignalId: ONESIGNAL_ID, subscriptions: [ { + ...BASE_SUB, id: SUB_ID_2, token: 'test@test.com', type: 'Email', - device_os: DEVICE_OS, - device_model: '', - sdk: __VERSION__, - enabled: true, - notification_types: 1, }, ], }); @@ -332,8 +326,16 @@ describe('OneSignal', () => { OneSignal.User.addSms(sms); await getSmsSubscriptionDbItems(1); + await vi.waitUntil(() => createSubscriptionFn.mock.calls.length === 1); + await vi.waitUntil(async () => { + const sub = (await db.getAll('subscriptions'))[0]; + return sub.id === SUB_ID_3; + }); + OneSignal.User.removeSms(sms); + await vi.waitUntil(() => deleteSubscriptionFn.mock.calls.length === 1); + await getSmsSubscriptionDbItems(0); }); }); @@ -451,7 +453,7 @@ describe('OneSignal', () => { identity: { external_id: newExternalId, }, - ...baseIdentity, + ...BASE_IDENTITY, subscriptions: [ { id: SUB_ID, @@ -497,7 +499,7 @@ describe('OneSignal', () => { identity: { external_id: externalId, }, - ...baseIdentity, + ...BASE_IDENTITY, subscriptions: [ { id: SUB_ID, @@ -526,7 +528,6 @@ describe('OneSignal', () => { beforeEach(async () => { setAddAliasResponse(); setTransferSubscriptionResponse(); - setCreateSubscriptionResponse(); await setupSubModelStore({ id: SUB_ID, @@ -535,10 +536,6 @@ describe('OneSignal', () => { }); test('login before adding email and sms - it should create subscriptions with the external ID', async () => { - setGetUserResponse({ - externalId, - }); - await OneSignal.login(externalId); await vi.waitUntil(() => addAliasFn.mock.calls.length === 1); @@ -616,17 +613,17 @@ describe('OneSignal', () => { externalId, }); setCreateUserResponse({}); - setupIdentityModel({ - onesignalID: undefined, - }); + + const localId = IDManager.createLocalId(); + setupIdentityModel(localId); OneSignal.coreDirector.subscriptionModelStore.replaceAll( [], - ModelChangeTags.NO_PROPOGATE, + ModelChangeTags.NO_PROPAGATE, ); // wait for db to be updated - await getIdentityItem((i) => i.onesignal_id === undefined); + await getIdentityItem((i) => i.onesignal_id === localId); OneSignal.login(externalId); await vi.waitUntil(() => getUserFn.mock.calls.length === 1); @@ -649,13 +646,12 @@ describe('OneSignal', () => { identity: { external_id: externalId, }, - ...baseIdentity, + ...BASE_IDENTITY, subscriptions: [], }); }); test('login with a prior web push subscription - it should transfer the subscription', async () => { - setGetUserResponse(); setCreateUserResponse(); updateIdentityModel('onesignal_id', ''); @@ -676,7 +672,7 @@ describe('OneSignal', () => { identity: { external_id: externalId, }, - ...baseIdentity, + ...BASE_IDENTITY, subscriptions: [ { id: SUB_ID, @@ -689,7 +685,7 @@ describe('OneSignal', () => { setGetUserResponse(); OneSignal.coreDirector.subscriptionModelStore.replaceAll( [], - ModelChangeTags.NO_PROPOGATE, + ModelChangeTags.NO_PROPAGATE, ); setPushToken(''); subscribeFcmFromPageSpy.mockImplementation( @@ -708,7 +704,7 @@ describe('OneSignal', () => { }); // new/empty user - setupIdentityModel({ onesignalID: undefined }); + setupIdentityModel(IDManager.createLocalId()); // calling login before accept permissions OneSignal.login(externalId); @@ -722,13 +718,13 @@ describe('OneSignal', () => { // first call just sets the external id await vi.waitUntil(() => createUserFn.mock.calls.length === 1, { - interval: 1, + interval: 0, }); expect(createUserFn).toHaveBeenCalledWith({ identity: { external_id: externalId, }, - ...baseIdentity, + ...BASE_IDENTITY, subscriptions: [], }); @@ -738,14 +734,10 @@ describe('OneSignal', () => { identity: { external_id: externalId, }, - ...baseIdentity, + ...BASE_IDENTITY, subscriptions: [ { - device_model: '', - device_os: DEVICE_OS, - enabled: true, - notification_types: 1, - sdk: __VERSION__, + ...BASE_SUB, token: PUSH_TOKEN, type: 'ChromePush', web_auth: 'w3cAuth', @@ -755,13 +747,10 @@ describe('OneSignal', () => { }); let pushSub: SubscriptionSchema | undefined; - await vi.waitUntil( - async () => { - pushSub = (await db.getAll('subscriptions'))[0]; - return pushSub && !IDManager.isLocalId(pushSub.id); - }, - { interval: 1 }, - ); + await vi.waitUntil(async () => { + pushSub = (await db.getAll('subscriptions'))[0]; + return pushSub && !IDManager.isLocalId(pushSub.id); + }); }); }); }); @@ -785,14 +774,9 @@ describe('OneSignal', () => { // existing user let identityModel = OneSignal.coreDirector.getIdentityModel(); - identityModel.setProperty( - 'external_id', - 'jd-1', - ModelChangeTags.NO_PROPOGATE, - ); + updateIdentityModel('external_id', 'jd-1'); setCreateUserResponse({}); - setUpdateSubscriptionResponse(); OneSignal.logout(); @@ -849,15 +833,11 @@ describe('OneSignal', () => { const subscriptions = await db.getAll('subscriptions'); expect(subscriptions).toEqual([ { - device_model: '', - device_os: DEVICE_OS, - enabled: true, + ...BASE_SUB, id: pushSub.id, modelId: expect.any(String), modelName: 'subscriptions', - notification_types: 1, onesignalId: ONESIGNAL_ID, - sdk: __VERSION__, token: pushSub.token, type: 'ChromePush', }, @@ -891,10 +871,7 @@ describe('OneSignal', () => { test('can send a custom event', async () => { setSendCustomEventResponse(); - - OneSignal.coreDirector - .getIdentityModel() - .setProperty('external_id', 'some-id', ModelChangeTags.NO_PROPOGATE); + updateIdentityModel('external_id', 'some-id'); OneSignal.User.trackEvent(name); await vi.waitUntil(() => sendCustomEventFn.mock.calls.length === 1); @@ -922,12 +899,8 @@ describe('OneSignal', () => { }); setSendCustomEventResponse(); - const identityModel = OneSignal.coreDirector.getIdentityModel(); - identityModel.setProperty( - 'external_id', - 'some-id', - ModelChangeTags.NO_PROPOGATE, - ); + updateIdentityModel('onesignal_id', IDManager.createLocalId()); + updateIdentityModel('external_id', 'some-id'); OneSignal.login('some-id-2'); OneSignal.User.trackEvent(name, properties); @@ -940,13 +913,11 @@ describe('OneSignal', () => { // should translate ids for the custom event await vi.waitUntil(() => createUserFn.mock.calls.length === 1, { - interval: 1, + interval: 0, }); expect(sendCustomEventFn).not.toHaveBeenCalled(); - await vi.waitUntil(() => sendCustomEventFn.mock.calls.length === 1, { - interval: 1, - }); + await vi.waitUntil(() => sendCustomEventFn.mock.calls.length === 1); expect(sendCustomEventFn).toHaveBeenCalledWith({ events: [ { @@ -1030,9 +1001,7 @@ describe('OneSignal', () => { }); test('custom event can execute before login for an existing user w/ external id', async () => { - OneSignal.coreDirector - .getIdentityModel() - .setProperty('external_id', 'some-id', ModelChangeTags.NO_PROPOGATE); + updateIdentityModel('external_id', 'some-id'); setSendCustomEventResponse(); setCreateUserResponse({ @@ -1159,11 +1128,15 @@ describe('OneSignal', () => { ); // its fine if login op is last since its the only one that can be executed - const setPropertyOp = queue[0]; - expect(setPropertyOp.operation.name).toBe('set-property'); - const loginOp = queue[2]; + const loginOp = queue[0]; expect(loginOp.operation.name).toBe('login-user'); + const setPropertyOp = queue[1]; + expect(setPropertyOp.operation.name).toBe('set-property'); + + const transferOp = queue[2]; + expect(transferOp.operation.name).toBe('transfer-subscription'); + // tags should still be sync expect(tags).toEqual({ 'some-tag': 'some-value', @@ -1203,29 +1176,12 @@ Object.defineProperty(global.navigator, 'serviceWorker', { const errorSpy = vi.spyOn(Log, 'error').mockImplementation(() => ''); const debugSpy = vi.spyOn(Log, 'debug'); -const baseIdentity = { - properties: { - language: 'en', - timezone_id: 'America/Los_Angeles', - }, - refresh_device_metadata: true, -}; - const rawPushSubscription = new RawPushSubscription(); rawPushSubscription.w3cEndpoint = new URL(PUSH_TOKEN); rawPushSubscription.w3cP256dh = 'w3cP256dh'; rawPushSubscription.w3cAuth = 'w3cAuth'; rawPushSubscription.safariDeviceToken = 'safariDeviceToken'; -const getDbSubscriptions = async (length: number) => { - let subscriptions: SubscriptionSchema[] = []; - await vi.waitUntil(async () => { - subscriptions = await db.getAll('subscriptions'); - return subscriptions.length === length; - }); - return subscriptions; -}; - const getPropertiesItem = async () => (await db.getAll('properties'))[0]; const subscribeFcmFromPageSpy = vi.spyOn( diff --git a/src/onesignal/User.ts b/src/onesignal/User.ts index 4925a9ce3..bccc68d74 100644 --- a/src/onesignal/User.ts +++ b/src/onesignal/User.ts @@ -1,10 +1,14 @@ +import { IdentityConstants } from 'src/core/constants'; import { SubscriptionModel } from 'src/core/models/SubscriptionModel'; +import { LoginUserOperation } from 'src/core/operations/LoginUserOperation'; +import { ModelChangeTags } from 'src/core/types/models'; import { EmptyArgumentError, MalformedArgumentError, ReservedArgumentError, WrongTypeArgumentError, } from 'src/shared/errors/common'; +import MainHelper from 'src/shared/helpers/MainHelper'; import { isObject, isValidEmail } from 'src/shared/helpers/validators'; import Log from 'src/shared/libraries/Log'; import { IDManager } from 'src/shared/managers/IDManager'; @@ -25,14 +29,22 @@ export default class User { static createOrGetInstance(): User { if (!User.singletonInstance) { User.singletonInstance = new User(); + const identityModel = OneSignal.coreDirector.getIdentityModel(); + if (!identityModel.onesignalId) { + const onesignalId = IDManager.createLocalId(); + identityModel.setProperty( + IdentityConstants.ONESIGNAL_ID, + onesignalId, + ModelChangeTags.NO_PROPAGATE, + ); + } } return User.singletonInstance; } - get onesignalId(): string | undefined { - const oneSignalId = OneSignal.coreDirector.getIdentityModel().onesignalId; - return !IDManager.isLocalId(oneSignalId) ? oneSignalId : undefined; + get onesignalId(): string { + return OneSignal.coreDirector.getIdentityModel().onesignalId; } private validateStringLabel(label: string, labelName: string): void { @@ -112,65 +124,25 @@ export default class User { this.updateIdentityModel(newAliases); } - private async addSubscriptionToModels({ - type, - token, - }: { - type: SubscriptionTypeValue; - token: string; - }): Promise { - const hasSubscription = OneSignal.coreDirector.subscriptionModelStore - .list() - .find((model) => model.token === token && model.type === type); - if (hasSubscription) return; - - const subscription = { - id: IDManager.createLocalId(), - enabled: true, - notification_types: NotificationType.Subscribed, - onesignalId: OneSignal.coreDirector.getIdentityModel().onesignalId, - token, - type, - }; - - const newSubscription = new SubscriptionModel(); - newSubscription.mergeData(subscription); - OneSignal.coreDirector.addSubscriptionModel(newSubscription); - } - - /** - * Temporary fix, for now we expect the user to call login before adding an email/sms subscription - */ - private validateUserExists(): boolean { - const hasOneSignalId = - !!OneSignal.coreDirector.getIdentityModel().onesignalId; - if (!hasOneSignalId) { - Log.error('User must be logged in first.'); - } - return hasOneSignalId; - } - - public async addEmail(email: string): Promise { - if (!this.validateUserExists()) return; + public addEmail(email: string): void { logMethodCall('addEmail', { email }); this.validateStringLabel(email, 'email'); if (!isValidEmail(email)) throw MalformedArgumentError('email'); - this.addSubscriptionToModels({ + addSubscriptionToModels({ type: SubscriptionType.Email, token: email, }); } - public async addSms(sms: string): Promise { - if (!this.validateUserExists()) return; + public addSms(sms: string): void { logMethodCall('addSms', { sms }); this.validateStringLabel(sms, 'sms'); - this.addSubscriptionToModels({ + addSubscriptionToModels({ type: SubscriptionType.SMS, token: sms, }); @@ -214,6 +186,10 @@ export default class User { } public addTags(tags: { [key: string]: string }): void { + if (IDManager.isLocalId(this.onesignalId)) { + Log.warn('Login or subscribe to sync tags'); + } + logMethodCall('addTags', { tags }); this.validateObject(tags, 'tags'); @@ -266,11 +242,16 @@ export default class User { } public trackEvent(name: string, properties: Record = {}) { - if (!this.validateUserExists()) return; + // login operation / non-local onesignalId is needed to send custom events + const onesignalId = this.onesignalId; + if (IDManager.isLocalId(onesignalId) && !hasLoginOp(onesignalId)) { + Log.error('User must be logged in first.'); + return; + } + if (!isObjectSerializable(properties)) { - return Log.error( - 'Custom event properties must be a JSON-serializable object', - ); + Log.error('Properties must be JSON-serializable'); + return; } logMethodCall('trackEvent', { name, properties }); @@ -281,6 +262,54 @@ export default class User { } } +function hasLoginOp(onesignalId: string) { + return OneSignal.coreDirector.operationRepo.queue.find( + (op) => + op.operation instanceof LoginUserOperation && + op.operation.onesignalId === onesignalId, + ); +} + +function addSubscriptionToModels({ + type, + token, +}: { + type: SubscriptionTypeValue; + token: string; +}): void { + const hasSubscription = OneSignal.coreDirector.subscriptionModelStore + .list() + .find((model) => model.token === token && model.type === type); + if (hasSubscription) return; + + const identityModel = OneSignal.coreDirector.getIdentityModel(); + const onesignalId = identityModel.onesignalId; + + // Check if we need to enqueue a login operation for local IDs + if (IDManager.isLocalId(onesignalId)) { + const appId = MainHelper.getAppId(); + + if (!hasLoginOp(onesignalId)) { + OneSignal.coreDirector.operationRepo.enqueue( + new LoginUserOperation(appId, onesignalId, identityModel.externalId), + ); + } + } + + const subscription = { + id: IDManager.createLocalId(), + enabled: true, + notification_types: NotificationType.Subscribed, + onesignalId, + token, + type, + }; + + const newSubscription = new SubscriptionModel(); + newSubscription.mergeData(subscription); + OneSignal.coreDirector.addSubscriptionModel(newSubscription); +} + /** * Returns true if the value is a JSON-serializable object. */ diff --git a/src/onesignal/UserDirector.ts b/src/onesignal/UserDirector.ts index 0a196fa87..db86f0e6b 100644 --- a/src/onesignal/UserDirector.ts +++ b/src/onesignal/UserDirector.ts @@ -11,15 +11,16 @@ export default class UserDirector { const identityModel = OneSignal.coreDirector.getIdentityModel(); const appId = MainHelper.getAppId(); - const allSubscriptions = - await OneSignal.coreDirector.getAllSubscriptionsModels(); - const hasAnySubscription = allSubscriptions.length > 0; + const hasAnySubscription = + OneSignal.coreDirector.subscriptionModelStore.list().length > 0; + const hasExternalId = !!identityModel.externalId; if (!hasAnySubscription && !hasExternalId) { - return Log.info( + Log.error( 'No subscriptions or external ID found, skipping user creation', ); + return; } const pushOp = await OneSignal.coreDirector.getPushSubscriptionModel(); diff --git a/src/onesignal/UserNamespace.test.ts b/src/onesignal/UserNamespace.test.ts index 0fee921ea..52554d81c 100644 --- a/src/onesignal/UserNamespace.test.ts +++ b/src/onesignal/UserNamespace.test.ts @@ -1,5 +1,4 @@ import { ONESIGNAL_ID, PUSH_TOKEN } from '__test__/constants'; -import { setAddAliasResponse } from '__test__/support/helpers/requests'; import { updateIdentityModel } from '__test__/support/helpers/setup'; import { ModelChangeTags } from 'src/core/types/models'; import Log from 'src/shared/libraries/Log'; @@ -16,8 +15,8 @@ vi.useFakeTimers(); describe('UserNamespace', () => { let userNamespace: UserNamespace; - beforeEach(async () => { - await TestEnvironment.initialize({}); + beforeEach(() => { + TestEnvironment.initialize({}); userNamespace = new UserNamespace(true); }); @@ -29,8 +28,9 @@ describe('UserNamespace', () => { updateIdentityModel('onesignal_id', undefined); expect(userNamespace.onesignalId).toBe(undefined); - updateIdentityModel('onesignal_id', IDManager.createLocalId()); - expect(userNamespace.onesignalId).toBe(undefined); + const localId = IDManager.createLocalId(); + updateIdentityModel('onesignal_id', localId); + expect(userNamespace.onesignalId).toBe(localId); updateIdentityModel('onesignal_id', ONESIGNAL_ID); expect(userNamespace.onesignalId).toBe(ONESIGNAL_ID); @@ -45,10 +45,6 @@ describe('UserNamespace', () => { }); describe('Alias Management', () => { - beforeEach(() => { - setAddAliasResponse(); - }); - test('can add a single alias', () => { const label = 'some-label'; const id = 'some-id'; @@ -441,7 +437,7 @@ describe('UserNamespace', () => { test_property: 'test_value', }; - updateIdentityModel('onesignal_id', undefined); + updateIdentityModel('onesignal_id', IDManager.createLocalId()); userNamespace.trackEvent(name, {}); expect(errorSpy).toHaveBeenCalledWith('User must be logged in first.'); errorSpy.mockClear(); @@ -450,14 +446,14 @@ describe('UserNamespace', () => { identityModel.setProperty( 'onesignal_id', ONESIGNAL_ID, - ModelChangeTags.NO_PROPOGATE, + ModelChangeTags.NO_PROPAGATE, ); // should validate properties // @ts-expect-error - mock invalid argument userNamespace.trackEvent(name, 123); expect(errorSpy).toHaveBeenCalledWith( - 'Custom event properties must be a JSON-serializable object', + 'Properties must be JSON-serializable', ); // big ints can't be serialized @@ -465,7 +461,7 @@ describe('UserNamespace', () => { data: 10n, }); expect(errorSpy).toHaveBeenCalledWith( - 'Custom event properties must be a JSON-serializable object', + 'Properties must be JSON-serializable', ); userNamespace.trackEvent(name, properties); diff --git a/src/page/managers/LoginManager.ts b/src/page/managers/LoginManager.ts index a12d18b3b..b2ecba849 100644 --- a/src/page/managers/LoginManager.ts +++ b/src/page/managers/LoginManager.ts @@ -1,8 +1,10 @@ +import { IdentityConstants } from 'src/core/constants'; import { LoginUserOperation } from 'src/core/operations/LoginUserOperation'; import { TransferSubscriptionOperation } from 'src/core/operations/TransferSubscriptionOperation'; import { ModelChangeTags } from 'src/core/types/models'; import { db } from 'src/shared/database/client'; import MainHelper from 'src/shared/helpers/MainHelper'; +import { IDManager } from 'src/shared/managers/IDManager'; import OneSignal from '../../onesignal/OneSignal'; import UserDirector from '../../onesignal/UserDirector'; import Log from '../../shared/libraries/Log'; @@ -24,7 +26,9 @@ export default class LoginManager { } let identityModel = OneSignal.coreDirector.getIdentityModel(); - const currentOneSignalId = identityModel.onesignalId; + const currentOneSignalId = !IDManager.isLocalId(identityModel.onesignalId) + ? identityModel.onesignalId + : undefined; const currentExternalId = identityModel.externalId; // if the current externalId is the same as the one we're trying to set, do nothing @@ -39,33 +43,36 @@ export default class LoginManager { // avoid duplicate identity requests, this is needed if dev calls init and login in quick succession e.g. // e.g. OneSignalDeferred.push(OneSignal) => OneSignal.init({...})); OneSignalDeferred.push(OneSignal) => OneSignal.login('some-external-id')); identityModel.setProperty( - 'external_id', + IdentityConstants.EXTERNAL_ID, externalId, ModelChangeTags.HYDRATE, ); const newIdentityOneSignalId = identityModel.onesignalId; - const appId = MainHelper.getAppId(); - const pushOp = await OneSignal.coreDirector.getPushSubscriptionModel(); - if (pushOp) { - OneSignal.coreDirector.operationRepo.enqueue( - new TransferSubscriptionOperation( + const promises: Promise[] = [ + OneSignal.coreDirector.getPushSubscriptionModel().then((pushOp) => { + if (pushOp) { + OneSignal.coreDirector.operationRepo.enqueue( + new TransferSubscriptionOperation( + appId, + newIdentityOneSignalId, + pushOp.id, + ), + ); + } + }), + OneSignal.coreDirector.operationRepo.enqueueAndWait( + new LoginUserOperation( appId, newIdentityOneSignalId, - pushOp.id, + externalId, + !currentExternalId ? currentOneSignalId : undefined, ), - ); - } - - await OneSignal.coreDirector.operationRepo.enqueueAndWait( - new LoginUserOperation( - appId, - newIdentityOneSignalId, - externalId, - !currentExternalId ? currentOneSignalId : undefined, ), - ); + ]; + + await Promise.all(promises); } static async logout(): Promise { diff --git a/src/page/managers/slidedownManager/SlidedownManager.test.ts b/src/page/managers/slidedownManager/SlidedownManager.test.ts new file mode 100644 index 000000000..c6bc76ece --- /dev/null +++ b/src/page/managers/slidedownManager/SlidedownManager.test.ts @@ -0,0 +1,222 @@ +import { BASE_IDENTITY, BASE_SUB } from '__test__/constants'; +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import { + createUserFn, + getNotificationIcons, + setCreateUserResponse, +} from '__test__/support/helpers/requests'; +import { + setupIdentityModel, + setupLoadStylesheet, +} from '__test__/support/helpers/setup'; +import ChannelCaptureContainer from 'src/page/slidedown/ChannelCaptureContainer'; +import { IDManager } from 'src/shared/managers/IDManager'; +import { DelayedPromptType } from 'src/shared/prompts/constants'; +import { SubscriptionType } from 'src/shared/subscriptions/constants'; + +beforeEach(() => { + getNotificationIcons(); + TestEnvironment.initialize({ + userConfig: config, + initUserAndPushSubscription: true, + }); + document.body.innerHTML = ''; + setupIdentityModel(IDManager.createLocalId()); + setupLoadStylesheet(); + mockPhoneLibraryLoading(); +}); + +test('can show email slidedown', async () => { + const addEmailSpy = vi.spyOn(OneSignal.User, 'addEmail'); + setCreateUserResponse(); + await OneSignal.Slidedown.promptEmail(); + + expect(getMessageText()).toBe(message); + + const submitButton = getSubmitButton(); + const emailInput = getEmailInput(); + + // should validate empty email + expect(getEmailValidationMessage()).toBe('Please enter a valid email'); + expect(addEmailSpy).not.toHaveBeenCalled(); + + // can add email subscription + emailInput.value = 'test@test.com'; + submitButton.click(); + + expect(addEmailSpy).toHaveBeenCalled(); + await vi.waitUntil(() => createUserFn.mock.calls.length > 0); + expect(createUserFn).toHaveBeenCalledWith({ + identity: {}, + ...BASE_IDENTITY, + subscriptions: [ + { + ...BASE_SUB, + token: 'test@test.com', + type: SubscriptionType.Email, + }, + ], + }); +}); + +test('can show sms slidedown', async () => { + const addSmsSpy = vi.spyOn(OneSignal.User, 'addSms'); + setCreateUserResponse(); + await OneSignal.Slidedown.promptSms(); + + expect(getMessageText()).toBe(message); + + const submitButton = getSubmitButton(); + const smsInput = getSmsInput(); + + // has validation message + expect(getSmsValidationMessage()).toBe('Please enter a valid phone number'); + + // can add sms subscription + smsInput.value = '+11234567890'; + submitButton.click(); + + expect(addSmsSpy).toHaveBeenCalled(); + await vi.waitUntil(() => createUserFn.mock.calls.length > 0); + expect(createUserFn).toHaveBeenCalledWith({ + identity: {}, + ...BASE_IDENTITY, + subscriptions: [ + { + ...BASE_SUB, + token: '+1234567890', + type: SubscriptionType.SMS, + }, + ], + }); +}); + +test('can add sms and email', async () => { + const addSmsSpy = vi.spyOn(OneSignal.User, 'addSms'); + const addEmailSpy = vi.spyOn(OneSignal.User, 'addEmail'); + setCreateUserResponse(); + await OneSignal.Slidedown.promptSmsAndEmail(); + + const submitButton = getSubmitButton(); + const smsInput = getSmsInput(); + const emailInput = getEmailInput(); + + // can add sms and email subscription + smsInput.value = '+11234567890'; + emailInput.value = 'test@test.com'; + submitButton.click(); + + expect(addSmsSpy).toHaveBeenCalled(); + expect(addEmailSpy).toHaveBeenCalled(); + await vi.waitUntil(() => createUserFn.mock.calls.length > 0); + expect(createUserFn).toHaveBeenCalledWith({ + identity: {}, + ...BASE_IDENTITY, + subscriptions: [ + { + ...BASE_SUB, + token: 'test@test.com', + type: SubscriptionType.Email, + }, + { + ...BASE_SUB, + token: '+1234567890', + type: SubscriptionType.SMS, + }, + ], + }); +}); + +// helpers +const getMessageText = () => + (document.querySelector('.slidedown-body-message') as HTMLElement)?.innerText; + +const getSubmitButton = () => + document.querySelector( + '#onesignal-slidedown-allow-button', + ) as HTMLButtonElement; + +const getEmailInput = () => + document.querySelector('#onesignal-email-input') as HTMLInputElement; + +const getEmailValidationMessage = () => + ( + document.querySelector('#onesignal-email-validation-element') + ?.childNodes[1] as HTMLElement + )?.innerText; + +const getSmsInput = () => + document.querySelector('#iti-onesignal-sms-input') as HTMLInputElement; + +const getSmsValidationMessage = () => + ( + document.querySelector('#onesignal-sms-validation-element') + ?.childNodes[1] as HTMLElement + )?.innerText; + +const message = 'Receive the latest news, updates and offers as they happen.'; + +const config = { + promptOptions: { + slidedown: { + prompts: [ + { + autoPrompt: true, + text: { + acceptButton: 'Submit', + cancelButton: 'No Thanks', + actionMessage: message, + emailLabel: 'Email', + }, + type: DelayedPromptType.Email, + }, + { + autoPrompt: true, + text: { + acceptButton: 'Submit', + cancelButton: 'No Thanks', + actionMessage: message, + smsLabel: 'Phone Number', + }, + type: DelayedPromptType.Sms, + }, + { + autoPrompt: true, + text: { + acceptButton: 'Submit', + cancelButton: 'No Thanks', + actionMessage: message, + emailLabel: 'Email', + smsLabel: 'Phone Number', + }, + type: DelayedPromptType.SmsAndEmail, + }, + ], + }, + }, +}; + +export const mockPhoneLibraryLoading = () => { + vi.spyOn( + ChannelCaptureContainer.prototype, + 'loadPhoneLibraryScripts', + ).mockImplementation(async () => { + OneSignal._didLoadITILibrary = true; + + // @ts-expect-error - mock intl-tel-input + window.intlTelInput = vi.fn().mockImplementation((input) => ({ + getNumber: () => '+1234567890', // Return formatted number + isValidNumber: () => true, + getNumberType: () => 0, + destroy: () => {}, + })); + + window.intlTelInputUtils = { + numberType: { MOBILE: 1 }, + // @ts-expect-error - mock intl-tel-input + numberFormat: { E164: 0 }, + }; + + return Promise.resolve(); + }); +}; diff --git a/src/page/services/DynamicResourceLoader.test.ts b/src/page/services/DynamicResourceLoader.test.ts index 8959d22a6..698a7b966 100644 --- a/src/page/services/DynamicResourceLoader.test.ts +++ b/src/page/services/DynamicResourceLoader.test.ts @@ -8,16 +8,123 @@ import { } from './DynamicResourceLoader'; describe('DynamicResourceLoader', () => { - beforeEach(async () => { - await TestEnvironment.initialize(); + beforeEach(() => { + TestEnvironment.initialize(); }); + afterEach(() => { + vi.restoreAllMocks(); // Clean up all mocks after each test + }); + + // Helper function to mock successful loading + const mockSuccessfulLoading = () => { + const originalCreateElement = document.createElement; + const originalAppendChild = document.head?.appendChild; + + vi.spyOn(document, 'createElement').mockImplementation( + (tagName: string) => { + const element = originalCreateElement.call( + document, + tagName, + ) as HTMLElement; + + // Store handlers on the element + let onloadHandler: ((event: Event) => void) | null = null; + let onerrorHandler: ((event: Event) => void) | null = null; + + Object.defineProperty(element, 'onload', { + set: (handler) => { + onloadHandler = handler; + }, + get: () => onloadHandler, + }); + + Object.defineProperty(element, 'onerror', { + set: (handler) => { + onerrorHandler = handler; + }, + get: () => onerrorHandler, + }); + + return element; + }, + ); + + if (originalAppendChild) { + vi.spyOn(document.head, 'appendChild').mockImplementation((node) => { + const result = originalAppendChild.call(document.head, node); + + // Trigger onload after element is added + setImmediate(() => { + const element = node as any; + if (element.onload) { + element.onload(new Event('load')); + } + }); + + return result; + }); + } + }; + + // Helper function to mock failed loading + const mockFailedLoading = () => { + const originalCreateElement = document.createElement; + const originalAppendChild = document.head?.appendChild; + + vi.spyOn(document, 'createElement').mockImplementation( + (tagName: string) => { + const element = originalCreateElement.call( + document, + tagName, + ) as HTMLElement; + + let onloadHandler: ((event: Event) => void) | null = null; + let onerrorHandler: ((event: Event) => void) | null = null; + + Object.defineProperty(element, 'onload', { + set: (handler) => { + onloadHandler = handler; + }, + get: () => onloadHandler, + }); + + Object.defineProperty(element, 'onerror', { + set: (handler) => { + onerrorHandler = handler; + }, + get: () => onerrorHandler, + }); + + return element; + }, + ); + + if (originalAppendChild) { + vi.spyOn(document.head, 'appendChild').mockImplementation((node) => { + const result = originalAppendChild.call(document.head, node); + + // Trigger onerror after element is added + setImmediate(() => { + const element = node as any; + if (element.onerror) { + element.onerror(new Event('error')); + } + }); + + return result; + }); + } + }; + test('should load sdk stylesheet', async () => { + mockSuccessfulLoading(); // Set up success mock for this test only + const cssURL = 'https://onesignal.com/sdks/web/v16/OneSignalSDK.page.styles.css'; server.use( http.get(cssURL, () => { - return HttpResponse.json('body { color: red; }'); + return HttpResponse.text('body { color: red; }'); }), ); @@ -28,7 +135,6 @@ describe('DynamicResourceLoader', () => { const stylesheets = document.head.querySelectorAll( 'link[rel="stylesheet"]', ); - const url = `${cssURL}?v=${__VERSION__}`; expect(stylesheets[0].getAttribute('href')).toBe(url); expect(loader.getCache()).toEqual({ @@ -37,12 +143,15 @@ describe('DynamicResourceLoader', () => { }); test('should load sdk script', async () => { + mockSuccessfulLoading(); // Set up success mock for this test only + const scriptURL = 'https://onesignal.com/sdks/web/v16/OneSignalSDK.page.js'; server.use( http.get(scriptURL, () => { - return HttpResponse.json('body { color: red; }'); + return HttpResponse.text(''); }), ); + const loader = new DynamicResourceLoader(); const state = await loader.loadIfNew( ResourceType.Script, @@ -59,6 +168,8 @@ describe('DynamicResourceLoader', () => { }); test('should handle load error', async () => { + mockFailedLoading(); // Set up failure mock for this test only + console.error = vi.fn(); const scriptURL = 'https://onesignal.com/sdks/web/v15/OneSignalSDK.page.js'; server.use( @@ -68,7 +179,6 @@ describe('DynamicResourceLoader', () => { ); const loader = new DynamicResourceLoader(); - const state = await loader.loadIfNew( ResourceType.Script, new URL(scriptURL), diff --git a/src/page/slidedown/ChannelCaptureContainer.ts b/src/page/slidedown/ChannelCaptureContainer.ts index 8aba0e3b0..df4cf7987 100644 --- a/src/page/slidedown/ChannelCaptureContainer.ts +++ b/src/page/slidedown/ChannelCaptureContainer.ts @@ -189,10 +189,13 @@ export default class ChannelCaptureContainer { `#${CHANNEL_CAPTURE_CONTAINER_CSS_IDS.onesignalSmsInput}`, ); if (onesignalSmsInput && !!window.intlTelInput) { - this.itiOneSignal = window.intlTelInput(onesignalSmsInput, { - autoPlaceholder: 'off', - separateDialCode: true, - }); + this.itiOneSignal = window.intlTelInput( + onesignalSmsInput as HTMLInputElement, + { + autoPlaceholder: 'off', + separateDialCode: true, + }, + ); } else { Log.error( 'OneSignal: there was a problem initializing International Telephone Input', @@ -283,7 +286,7 @@ export default class ChannelCaptureContainer { ); } - private async loadPhoneLibraryScripts(): Promise { + async loadPhoneLibraryScripts(): Promise { if (OneSignal._didLoadITILibrary) { return; } @@ -370,7 +373,7 @@ export default class ChannelCaptureContainer { getValueFromSmsInput(): string { return ( this.itiOneSignal?.getNumber( - window.intlTelInputUtils.numberFormat.E164, + window.intlTelInputUtils?.numberFormat.E164, ) || '' ); } diff --git a/src/page/utils/BrowserSupportsPush.test.ts b/src/page/utils/BrowserSupportsPush.test.ts index b6cc8a067..ae690467f 100644 --- a/src/page/utils/BrowserSupportsPush.test.ts +++ b/src/page/utils/BrowserSupportsPush.test.ts @@ -5,7 +5,7 @@ import { } from './BrowserSupportsPush'; describe('BrowserSupportsPush', () => { - beforeEach(async () => { + beforeEach(() => { Object.defineProperty(global, 'PushSubscriptionOptions', { value: { prototype: { @@ -14,7 +14,7 @@ describe('BrowserSupportsPush', () => { }, writable: true, }); - await TestEnvironment.initialize(); + TestEnvironment.initialize(); }); test('can check if browser supports push notifications', () => { diff --git a/src/shared/config/app.test.ts b/src/shared/config/app.test.ts index 8ca39e9ec..25b28bd14 100644 --- a/src/shared/config/app.test.ts +++ b/src/shared/config/app.test.ts @@ -29,8 +29,8 @@ vi.spyOn(OneSignalApi, 'jsonpLib').mockImplementation((url, fn) => { }); describe('Config Helpers', () => { - beforeEach(async () => { - await TestEnvironment.initialize(); + beforeEach(() => { + TestEnvironment.initialize(); server.use( http.get('**/sync/*/web', () => HttpResponse.json(serverConfig)), ); diff --git a/src/shared/database/client.test.ts b/src/shared/database/client.test.ts index 31ae11e46..a10bf3dfb 100644 --- a/src/shared/database/client.test.ts +++ b/src/shared/database/client.test.ts @@ -289,6 +289,7 @@ describe('migrations', () => { const db = await getDb(5); await populateLegacySubscriptions(db); // user is logged in + // @ts-expect-error - for testing legacy migration await db.put('identity', { modelId: '4', modelName: 'identity', diff --git a/src/shared/database/types.ts b/src/shared/database/types.ts index 159c1c04c..ef3165251 100644 --- a/src/shared/database/types.ts +++ b/src/shared/database/types.ts @@ -60,16 +60,9 @@ export interface SubscriptionSchema { export interface IdentitySchema { modelId: string; modelName: 'identity'; - onesignal_id?: string; - /** - * @deprecated - use onesignal_id instead - */ - onesignalId?: string; + onesignal_id: string; external_id?: string; - /** - * @deprecated - use external_id instead - */ - externalId?: string; + [key: string]: string | undefined; } export interface PropertiesSchema { diff --git a/src/shared/helpers/init.test.ts b/src/shared/helpers/init.test.ts index a39cb63a3..189f5091f 100644 --- a/src/shared/helpers/init.test.ts +++ b/src/shared/helpers/init.test.ts @@ -8,8 +8,8 @@ import * as InitHelper from './init'; let isSubscriptionExpiringSpy: MockInstance; -beforeEach(async () => { - await TestEnvironment.initialize(); +beforeEach(() => { + TestEnvironment.initialize(); isSubscriptionExpiringSpy = vi.spyOn( OneSignal.context.subscriptionManager, 'isSubscriptionExpiring', @@ -75,9 +75,7 @@ test('onSdkInitialized: does not send on session update', async () => { }); test('correct degree of persistNotification setting should be stored', async () => { - await TestEnvironment.initialize({ - initOptions: {}, - }); + TestEnvironment.initialize(); const appConfig = TestContext.getFakeMergedConfig(); OneSignal.context = new Context(appConfig); diff --git a/src/shared/listeners.test.ts b/src/shared/listeners.test.ts index 5253a9b52..9ec92fe92 100644 --- a/src/shared/listeners.test.ts +++ b/src/shared/listeners.test.ts @@ -13,8 +13,8 @@ import { SubscriptionManagerPage } from './managers/subscription/page'; vi.useFakeTimers(); -beforeEach(async () => { - await TestEnvironment.initialize(); +beforeEach(() => { + TestEnvironment.initialize(); }); describe('checkAndTriggerSubscriptionChanged', () => { diff --git a/src/shared/managers/SubscriptionManager.test.ts b/src/shared/managers/SubscriptionManager.test.ts index eb7ae0486..2504b2dcf 100644 --- a/src/shared/managers/SubscriptionManager.test.ts +++ b/src/shared/managers/SubscriptionManager.test.ts @@ -1,10 +1,9 @@ -import { DEVICE_OS, EXTERNAL_ID } from '__test__/constants'; +import { BASE_IDENTITY, BASE_SUB, EXTERNAL_ID } from '__test__/constants'; import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; import { setupSubModelStore } from '__test__/support/environment/TestEnvironmentHelpers'; import { createUserFn, setCreateUserResponse, - setGetUserResponse, setUpdateSubscriptionResponse, updateSubscriptionFn, } from '__test__/support/helpers/requests'; @@ -32,13 +31,12 @@ const getRawSubscription = (): RawPushSubscription => { }; describe('SubscriptionManager', () => { - beforeEach(async () => { - await TestEnvironment.initialize(); + beforeEach(() => { + TestEnvironment.initialize(); }); describe('updatePushSubscriptionModelWithRawSubscription', () => { test('should create the push subscription model if it does not exist', async () => { - setGetUserResponse(); setCreateUserResponse(); const rawSubscription = getRawSubscription(); @@ -58,11 +56,7 @@ describe('SubscriptionManager', () => { expect(IDManager.isLocalId(id)).toBe(true); expect(subModels[0].toJSON()).toEqual({ id, - device_model: '', - device_os: DEVICE_OS, - enabled: true, - notification_types: 1, - sdk: __VERSION__, + ...BASE_SUB, token: rawSubscription.w3cEndpoint?.toString(), type: 'ChromePush', web_auth: rawSubscription.w3cAuth, @@ -72,18 +66,10 @@ describe('SubscriptionManager', () => { await vi.waitUntil(() => createUserFn.mock.calls.length > 0); expect(createUserFn).toHaveBeenCalledWith({ identity: {}, - properties: { - language: 'en', - timezone_id: 'America/Los_Angeles', - }, - refresh_device_metadata: true, + ...BASE_IDENTITY, subscriptions: [ { - device_model: '', - device_os: DEVICE_OS, - enabled: true, - notification_types: 1, - sdk: __VERSION__, + ...BASE_SUB, token: rawSubscription.w3cEndpoint?.toString(), type: 'ChromePush', web_auth: rawSubscription.w3cAuth, @@ -126,18 +112,10 @@ describe('SubscriptionManager', () => { identity: { external_id: 'some-external-id', }, - properties: { - language: 'en', - timezone_id: 'America/Los_Angeles', - }, - refresh_device_metadata: true, + ...BASE_IDENTITY, subscriptions: [ { - device_model: '', - device_os: DEVICE_OS, - enabled: true, - notification_types: 1, - sdk: __VERSION__, + ...BASE_SUB, token: rawSubscription.w3cEndpoint?.toString(), type: 'ChromePush', }, @@ -146,8 +124,6 @@ describe('SubscriptionManager', () => { }); test('should update the push subscription model if it already exists', async () => { - setCreateUserResponse(); - setGetUserResponse(); setUpdateSubscriptionResponse({ subscriptionId: '123' }); const rawSubscription = getRawSubscription(); diff --git a/src/shared/managers/sessionManager/SessionManager.test.ts b/src/shared/managers/sessionManager/SessionManager.test.ts index a4f6743b8..4db5abd24 100644 --- a/src/shared/managers/sessionManager/SessionManager.test.ts +++ b/src/shared/managers/sessionManager/SessionManager.test.ts @@ -1,11 +1,6 @@ import { EXTERNAL_ID } from '__test__/constants'; import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; -import { - setAddAliasResponse, - setCreateUserResponse, - setGetUserResponse, - setUpdateUserResponse, -} from '__test__/support/helpers/requests'; +import { setAddAliasResponse } from '__test__/support/helpers/requests'; import LoginManager from 'src/page/managers/LoginManager'; import Log from 'src/shared/libraries/Log'; import { SessionOrigin } from 'src/shared/session/constants'; @@ -15,12 +10,9 @@ vi.spyOn(Log, 'error').mockImplementation(() => ''); describe('SessionManager', () => { describe('Switching Users', () => { - beforeEach(async () => { - setGetUserResponse(); - setCreateUserResponse(); - setUpdateUserResponse(); + beforeEach(() => { setAddAliasResponse(); - await TestEnvironment.initialize(); + TestEnvironment.initialize(); }); test('handleOnFocus should wait for login promise', async () => { diff --git a/src/shared/slidedown/constants.ts b/src/shared/slidedown/constants.ts index 3a7acd491..5c415b67b 100644 --- a/src/shared/slidedown/constants.ts +++ b/src/shared/slidedown/constants.ts @@ -1,8 +1,6 @@ export const SLIDEDOWN_CSS_CLASSES = { - allowButton: 'onesignal-slidedown-allow-button', body: 'slidedown-body', buttonIndicatorHolder: 'onesignal-button-indicator-holder', - cancelButton: 'onesignal-slidedown-cancel-button', container: 'onesignal-slidedown-container', dialog: 'onesignal-slidedown-dialog', footer: 'slidedown-footer', diff --git a/src/sw/serviceWorker/ServiceWorker.test.ts b/src/sw/serviceWorker/ServiceWorker.test.ts index 70da8724c..b9745edf1 100644 --- a/src/sw/serviceWorker/ServiceWorker.test.ts +++ b/src/sw/serviceWorker/ServiceWorker.test.ts @@ -77,8 +77,8 @@ const serverConfig = TestContext.getFakeServerAppConfig( ); describe('ServiceWorker', () => { - beforeAll(async () => { - await TestEnvironment.initialize(); + beforeAll(() => { + TestEnvironment.initialize(); // @ts-expect-error - Notification is not defined in the global scope global.Notification = { diff --git a/tsconfig.json b/tsconfig.json index 6b211f31b..db905d074 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,7 +34,7 @@ "__test__/*": ["./__test__/*"], "src/*": ["./src/*"] }, - "types": ["vitest/globals"] + "types": ["vitest/globals", "node"] }, "include": ["src", "__test__"] }