diff --git a/src/page/bell/Bell.test.ts b/src/page/bell/Bell.test.ts index 90f742d3f..f2dc548f9 100644 --- a/src/page/bell/Bell.test.ts +++ b/src/page/bell/Bell.test.ts @@ -1,4 +1,5 @@ import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import { vi } from 'vitest'; import OneSignalEvent from '../../shared/services/OneSignalEvent'; import Bell from './Bell'; import { BellEvent, BellState } from './constants'; @@ -47,4 +48,76 @@ describe('Bell', () => { to: BellState._Subscribed, }); }); + + test('_updateState sets blocked when permission denied', async () => { + const bell = new Bell({ enable: false }); + const permSpy = vi + .spyOn(OneSignal._context._permissionManager, '_getPermissionStatus') + .mockResolvedValue('denied'); + const enabledSpy = vi + .spyOn( + OneSignal._context._subscriptionManager, + '_isPushNotificationsEnabled', + ) + .mockResolvedValue(false); + bell._updateState(); + await Promise.resolve(); + await Promise.resolve(); + expect(bell._blocked).toBe(true); + expect(permSpy).toHaveBeenCalled(); + expect(enabledSpy).toHaveBeenCalled(); + }); + + test('_setCustomColorsIfSpecified applies styles and adds CSS to head', async () => { + const bell = new Bell({ enable: false }); + document.body.innerHTML = ` +
+
+ + + + + +
+
+
+
+
+ +
+
+
+ `; + bell._options.colors = { + 'circle.background': '#111', + 'circle.foreground': '#222', + 'badge.background': '#333', + 'badge.bordercolor': '#444', + 'badge.foreground': '#555', + 'dialog.button.background': '#666', + 'dialog.button.foreground': '#777', + 'dialog.button.background.hovering': '#888', + 'dialog.button.background.active': '#999', + 'pulse.color': '#abc', + }; + bell._setCustomColorsIfSpecified(); + const background = document.querySelector('.background')!; + expect(background.getAttribute('style')).toContain('#111'); + const badge = document.querySelector( + '.onesignal-bell-launcher-badge', + )!; + expect(badge.getAttribute('style')).toContain('rgb(51, 51, 51)'); + const styleHover = document.getElementById( + 'onesignal-background-hover-style', + ); + expect(styleHover).not.toBeNull(); + }); + + test('_addCssToHead appends once', () => { + const bell = new Bell({ enable: false }); + bell._addCssToHead('x', '.a{color:red}'); + bell._addCssToHead('x', '.b{color:blue}'); + const style = document.getElementById('x')!; + expect(style.textContent).toContain('.a{color:red}'); + }); }); diff --git a/src/page/managers/PromptsManager.test.ts b/src/page/managers/PromptsManager.test.ts index af6a3e04b..d82aeba8c 100644 --- a/src/page/managers/PromptsManager.test.ts +++ b/src/page/managers/PromptsManager.test.ts @@ -1,9 +1,14 @@ import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; import { setupLoadStylesheet } from '__test__/support/helpers/setup'; +import { DelayedPromptType } from 'src/shared/prompts/constants'; +import type { + AppUserConfigPromptOptions, + DelayedPromptOptions, + DelayedPromptTypeValue, +} from 'src/shared/prompts/types'; import { Browser } from 'src/shared/useragent/constants'; import * as detect from 'src/shared/useragent/detect'; import { PromptsManager } from './PromptsManager'; - const getBrowserNameSpy = vi.spyOn(detect, 'getBrowserName'); const getBrowserVersionSpy = vi.spyOn(detect, 'getBrowserVersion'); const isMobileBrowserSpy = vi.spyOn(detect, 'isMobileBrowser'); @@ -52,4 +57,62 @@ describe('PromptsManager', () => { await pm['_internalShowSlidedownPrompt'](); expect(installSpy).toHaveBeenCalledTimes(1); }); + + test('_internalShowDelayedPrompt forces slidedown when interaction required', async () => { + requiresUserInteractionSpy.mockReturnValue(true); + const pm = new PromptsManager(OneSignal._context); + const nativeSpy = vi + .spyOn(pm, '_internalShowNativePrompt') + .mockResolvedValue(true); + const slidedownSpy = vi + .spyOn( + PromptsManager.prototype, + '_internalShowSlidedownPrompt' as keyof PromptsManager, + ) + .mockResolvedValue(undefined as void); + await pm._internalShowDelayedPrompt(DelayedPromptType._Native, 0); + expect(nativeSpy).not.toHaveBeenCalled(); + expect(slidedownSpy).toHaveBeenCalled(); + }); + + test('_spawnAutoPrompts triggers native when condition met and not forced', async () => { + const pm = new PromptsManager(OneSignal._context); + const getOptsSpy = vi + .spyOn( + pm as unknown as { + _getDelayedPromptOptions: ( + opts: AppUserConfigPromptOptions | undefined, + type: DelayedPromptTypeValue, + ) => DelayedPromptOptions; + }, + '_getDelayedPromptOptions', + ) + .mockImplementation( + (): DelayedPromptOptions => ({ + enabled: true, + autoPrompt: true, + timeDelay: 0, + pageViews: 0, + }), + ); + const condSpy = vi + .spyOn( + pm as unknown as { _isPageViewConditionMet: (o?: unknown) => boolean }, + '_isPageViewConditionMet', + ) + .mockReturnValue(true); + const delayedSpy = vi + .spyOn( + PromptsManager.prototype, + '_internalShowDelayedPrompt' as keyof PromptsManager, + ) + .mockResolvedValue(undefined as void); + requiresUserInteractionSpy.mockReturnValue(false); + getBrowserNameSpy.mockReturnValue(Browser._Chrome); + getBrowserVersionSpy.mockReturnValue(62); + await pm._spawnAutoPrompts(); + expect(getOptsSpy).toHaveBeenCalled(); + expect(condSpy).toHaveBeenCalled(); + expect(delayedSpy).toHaveBeenCalledWith(DelayedPromptType._Native, 0); + }); }); diff --git a/src/shared/managers/CustomLinkManager.test.ts b/src/shared/managers/CustomLinkManager.test.ts new file mode 100644 index 000000000..28eb8ec89 --- /dev/null +++ b/src/shared/managers/CustomLinkManager.test.ts @@ -0,0 +1,105 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import { setupLoadStylesheet } from '__test__/support/helpers/setup'; +import { + CUSTOM_LINK_CSS_CLASSES, + CUSTOM_LINK_CSS_SELECTORS, +} from 'src/shared/slidedown/constants'; +import { vi, type MockInstance } from 'vitest'; +import { ResourceLoadState } from '../../page/services/DynamicResourceLoader'; +import { CustomLinkManager } from './CustomLinkManager'; + +describe('CustomLinkManager', () => { + let isPushEnabledSpy: MockInstance; + beforeEach(() => { + TestEnvironment.initialize(); + document.body.innerHTML = ` + + `; + isPushEnabledSpy = vi.spyOn( + OneSignal._context._subscriptionManager, + '_isPushNotificationsEnabled', + ); + }); + + test('_initialize returns when disabled or stylesheet fails', async () => { + const mgrDisabled = new CustomLinkManager({ enabled: false }); + await expect(mgrDisabled._initialize()).resolves.toBeUndefined(); + + // Stylesheet not loaded + const mgr = new CustomLinkManager({ + enabled: true, + text: { explanation: 'x', subscribe: 'Sub' }, + }); + vi.spyOn( + OneSignal._context._dynamicResourceLoader, + '_loadSdkStylesheet', + ).mockResolvedValue(ResourceLoadState._Failed); + await mgr._initialize(); + // nothing injected + const containers = document.querySelectorAll( + CUSTOM_LINK_CSS_SELECTORS._ContainerSelector, + ); + expect(containers.length).toBe(1); + expect(containers[0].children.length).toBe(0); + }); + + test('_initialize hides containers when subscribed and unsubscribe disabled', async () => { + await setupLoadStylesheet(); + isPushEnabledSpy.mockResolvedValue(true); + const mgr = new CustomLinkManager({ + enabled: true, + unsubscribeEnabled: false, + text: { + explanation: 'hello', + subscribe: 'Subscribe', + unsubscribe: 'Unsubscribe', + }, + }); + await mgr._initialize(); + const containers = document.querySelectorAll( + CUSTOM_LINK_CSS_SELECTORS._ContainerSelector, + ); + expect( + containers[0].classList.contains(CUSTOM_LINK_CSS_CLASSES._Hide), + ).toBe(true); + }); + + test('_initialize injects markup and click toggles subscription', async () => { + await setupLoadStylesheet(); + isPushEnabledSpy.mockResolvedValue(false); + const optInSpy = vi + .spyOn(OneSignal.User.PushSubscription, 'optIn') + .mockResolvedValue(); + const optOutSpy = vi + .spyOn(OneSignal.User.PushSubscription, 'optOut') + .mockResolvedValue(); + const mgr = new CustomLinkManager({ + enabled: true, + unsubscribeEnabled: true, + text: { + explanation: 'hello', + subscribe: 'Subscribe', + unsubscribe: 'Unsubscribe', + }, + style: 'button', + size: 'medium', + color: { text: '#fff', button: '#000' }, + }); + await mgr._initialize(); + const button = document.querySelector( + `.${CUSTOM_LINK_CSS_CLASSES._SubscribeClass}`, + ); + expect(button).not.toBeNull(); + expect(button?.textContent).toBe('Subscribe'); + + await button?.click(); + expect(optInSpy).toHaveBeenCalled(); + + // simulate subscribed now (set optedIn getter) + vi.spyOn(OneSignal.User.PushSubscription, 'optedIn', 'get').mockReturnValue( + true, + ); + await button?.click(); + expect(optOutSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/shared/managers/ServiceWorkerManager.test.ts b/src/shared/managers/ServiceWorkerManager.test.ts new file mode 100644 index 000000000..ff65ef475 --- /dev/null +++ b/src/shared/managers/ServiceWorkerManager.test.ts @@ -0,0 +1,162 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import * as detect from 'src/shared/environment/detect'; +import * as helpers from 'src/shared/helpers/service-worker'; +import Path from 'src/shared/models/Path'; +import * as registration from 'src/sw/helpers/registration'; +import { vi, type MockInstance } from 'vitest'; +import Log from '../libraries/Log'; +import { ServiceWorkerManager } from './ServiceWorkerManager'; + +describe('ServiceWorkerManager', () => { + const config = { + workerPath: new Path('/OneSignalSDKWorker.js'), + registrationOptions: { scope: '/' }, + } as const; + + let getSWRegistrationSpy: MockInstance; + beforeEach(() => { + TestEnvironment.initialize(); + vi.restoreAllMocks(); + getSWRegistrationSpy = vi.spyOn(registration, 'getSWRegistration'); + }); + + test('_getActiveState returns None when no registration', async () => { + getSWRegistrationSpy.mockResolvedValue(undefined); + const mgr = new ServiceWorkerManager(OneSignal._context, config); + await expect(mgr._getActiveState()).resolves.toBe( + helpers.ServiceWorkerActiveState._None, + ); + }); + + test('_getActiveState detects OneSignal vs third-party from file name', async () => { + const fakeReg = {} as ServiceWorkerRegistration; + getSWRegistrationSpy.mockResolvedValue(fakeReg); + const sw = (url: string): ServiceWorker => + ({ + scriptURL: url, + state: 'activated', + onstatechange: null, + postMessage: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as unknown as ServiceWorker; + const swSpy = vi.spyOn(registration, 'getAvailableServiceWorker'); + swSpy.mockReturnValue( + sw(new URL('https://example.com/OneSignalSDKWorker.js').toString()), + ); + const mgr = new ServiceWorkerManager(OneSignal._context, config); + await expect(mgr._getActiveState()).resolves.toBe( + helpers.ServiceWorkerActiveState._OneSignalWorker, + ); + + // third-party + swSpy.mockReturnValue( + sw(new URL('https://example.com/othersw.js').toString()), + ); + await expect(mgr._getActiveState()).resolves.toBe( + helpers.ServiceWorkerActiveState._ThirdParty, + ); + }); + + test('_haveParamsChanged covers no registration, scope diff, missing scriptURL, href diff and same', async () => { + const mgr = new ServiceWorkerManager(OneSignal._context, config); + // no registration + vi.spyOn(mgr, '_getRegistration').mockResolvedValue(undefined); + await expect(mgr['_haveParamsChanged']()).resolves.toBe(true); + + // registration with different scope + const regWithScope = { + scope: 'https://example.com/oldPath', + } as ServiceWorkerRegistration; + vi.spyOn(mgr, '_getRegistration').mockResolvedValue(regWithScope); + const infoSpy = vi.spyOn(Log, '_info').mockImplementation(() => undefined); + await expect(mgr['_haveParamsChanged']()).resolves.toBe(true); + expect(infoSpy).toHaveBeenCalled(); + + // same scope but no worker scriptURL + vi.spyOn(mgr, '_getRegistration').mockResolvedValue({ + scope: 'https://example.com/', + } as ServiceWorkerRegistration); + vi.spyOn(registration, 'getAvailableServiceWorker').mockReturnValue(null); + await expect(mgr['_haveParamsChanged']()).resolves.toBe(true); + + // different href + vi.mocked(registration.getAvailableServiceWorker).mockReturnValue({ + scriptURL: 'https://example.com/old.js', + } as ServiceWorker); + vi.spyOn(helpers, 'getServiceWorkerHref').mockReturnValue( + 'https://example.com/new.js', + ); + await expect(mgr['_haveParamsChanged']()).resolves.toBe(true); + + // same href + vi.mocked(helpers.getServiceWorkerHref).mockReturnValue( + 'https://example.com/old.js', + ); + await expect(mgr['_haveParamsChanged']()).resolves.toBe(false); + }); + + test('_shouldInstallWorker branches', async () => { + const mgr = new ServiceWorkerManager(OneSignal._context, config); + + const supportsSpy = vi + .spyOn(detect, 'supportsServiceWorkers') + .mockReturnValue(false); + await expect( + ( + mgr as unknown as { _shouldInstallWorker: () => Promise } + )._shouldInstallWorker(), + ).resolves.toBe(false); + + supportsSpy.mockReturnValue(true); + const savedConfig = OneSignal.config; + OneSignal.config = null; + await expect( + ( + mgr as unknown as { _shouldInstallWorker: () => Promise } + )._shouldInstallWorker(), + ).resolves.toBe(false); + OneSignal.config = savedConfig; + + vi.spyOn(mgr, '_getActiveState').mockResolvedValue( + helpers.ServiceWorkerActiveState._None, + ); + vi.spyOn( + OneSignal._context._permissionManager, + '_getNotificationPermission', + ).mockResolvedValue('granted'); + await expect( + ( + mgr as unknown as { _shouldInstallWorker: () => Promise } + )._shouldInstallWorker(), + ).resolves.toBe(true); + + vi.spyOn(mgr, '_getActiveState').mockResolvedValue( + helpers.ServiceWorkerActiveState._OneSignalWorker, + ); + vi.spyOn( + mgr as unknown as { _haveParamsChanged: () => Promise }, + '_haveParamsChanged', + ).mockResolvedValue(true); + await expect( + ( + mgr as unknown as { _shouldInstallWorker: () => Promise } + )._shouldInstallWorker(), + ).resolves.toBe(true); + + vi.spyOn( + mgr as unknown as { _haveParamsChanged: () => Promise }, + '_haveParamsChanged', + ).mockResolvedValue(false); + vi.spyOn( + mgr as unknown as { _workerNeedsUpdate: () => Promise }, + '_workerNeedsUpdate', + ).mockResolvedValue(false); + await expect( + ( + mgr as unknown as { _shouldInstallWorker: () => Promise } + )._shouldInstallWorker(), + ).resolves.toBe(false); + }); +}); diff --git a/src/shared/managers/UpdateManager.test.ts b/src/shared/managers/UpdateManager.test.ts new file mode 100644 index 000000000..a331e7bde --- /dev/null +++ b/src/shared/managers/UpdateManager.test.ts @@ -0,0 +1,75 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import { SubscriptionModel } from 'src/core/models/SubscriptionModel'; +import User from 'src/onesignal/User'; +import * as pageview from 'src/shared/helpers/pageview'; +import { SessionOrigin } from 'src/shared/session/constants'; +import { NotificationType } from 'src/shared/subscriptions/constants'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { UpdateManager } from './UpdateManager'; + +describe('UpdateManager', () => { + let mgr: UpdateManager; + beforeEach(() => { + TestEnvironment.initialize(); + vi.restoreAllMocks(); + mgr = new UpdateManager(OneSignal._context); + }); + + test('_sendPushDeviceRecordUpdate early returns with no user, otherwise calls _sendOnSessionUpdate once', async () => { + // No user + delete User._singletonInstance; + const spy = vi.spyOn(mgr, '_sendOnSessionUpdate').mockResolvedValue(); + await mgr._sendPushDeviceRecordUpdate(); + expect(spy).not.toHaveBeenCalled(); + + // With user present and first call triggers onSession + User._createOrGetInstance(); + await mgr._sendPushDeviceRecordUpdate(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('_sendOnSessionUpdate flows: already sent, not first page, not registered, skip when unsubscribed, success path sets flag', async () => { + // Already sent + (mgr as unknown as { _onSessionSent: boolean })._onSessionSent = true; + await mgr._sendOnSessionUpdate(); + // reset + (mgr as unknown as { _onSessionSent: boolean })._onSessionSent = false; + + // Not first page + const firstSpy = vi + .spyOn(pageview, 'isFirstPageView') + .mockReturnValue(false); + await mgr._sendOnSessionUpdate(); + firstSpy.mockReturnValue(true); + + // Not registered with OneSignal + const alreadySpy = vi + .spyOn( + OneSignal._context._subscriptionManager, + '_isAlreadyRegisteredWithOneSignal', + ) + .mockResolvedValue(false); + await mgr._sendOnSessionUpdate(); + + // Registered but not subscribed and enableOnSession not true -> skip + alreadySpy.mockResolvedValue(true); + const pushSpy = vi + .spyOn(OneSignal._coreDirector, '_getPushSubscriptionModel') + .mockResolvedValue({ + _notification_types: 0, + } as unknown as SubscriptionModel); + const upsertSpy = vi + .spyOn(OneSignal._context._sessionManager, '_upsertSession') + .mockResolvedValue(); + await mgr._sendOnSessionUpdate(); + expect(upsertSpy).not.toHaveBeenCalled(); + + // Subscribed path + pushSpy.mockResolvedValue({ + _notification_types: NotificationType._Subscribed, + id: 'sub', + } as unknown as SubscriptionModel); + await mgr._sendOnSessionUpdate(); + expect(upsertSpy).toHaveBeenCalledWith(SessionOrigin._UserNewSession); + }); +}); diff --git a/src/shared/managers/sessionManager/SessionManager.test.ts b/src/shared/managers/sessionManager/SessionManager.test.ts index 22c7bb8de..bc2555d02 100644 --- a/src/shared/managers/sessionManager/SessionManager.test.ts +++ b/src/shared/managers/sessionManager/SessionManager.test.ts @@ -5,8 +5,10 @@ import LoginManager from 'src/page/managers/LoginManager'; import * as detect from 'src/shared/environment/detect'; import Log from 'src/shared/libraries/Log'; import { SessionOrigin } from 'src/shared/session/constants'; +import type { SessionOriginValue } from 'src/shared/session/types'; +import { vi, type MockInstance } from 'vitest'; +import User from '../../../onesignal/User'; import { SessionManager } from './SessionManager'; - const supportsServiceWorkersSpy = vi.spyOn(detect, 'supportsServiceWorkers'); vi.spyOn(Log, '_error').mockImplementation(() => ''); @@ -132,13 +134,30 @@ describe('SessionManager', () => { }); describe('Core behaviors', () => { + let sm: SessionManager; + let notifySpy: MockInstance; + let deactSpy: MockInstance; + beforeEach(() => { TestEnvironment.initialize(); + sm = new SessionManager(OneSignal._context); + notifySpy = vi.spyOn(sm, '_notifySWToUpsertSession'); + deactSpy = vi + .spyOn( + sm as unknown as { + _notifySWToDeactivateSession: ( + onesignalId: string, + subscriptionId: string, + sessionOrigin: SessionOriginValue, + ) => Promise; + }, + '_notifySWToDeactivateSession', + ) + .mockResolvedValue(undefined); }); test('_notifySWToUpsertSession posts to worker when SW supported', async () => { supportsServiceWorkersSpy.mockReturnValue(true); - const sm = new SessionManager(OneSignal._context); const unicastSpy = vi .spyOn(OneSignal._context._workerMessenger, '_unicast') .mockResolvedValue(undefined); @@ -153,15 +172,12 @@ describe('SessionManager', () => { test('_upsertSession does nothing when no user is present', async () => { supportsServiceWorkersSpy.mockReturnValue(true); - const sm = new SessionManager(OneSignal._context); - const notifySpy = vi.spyOn(sm, '_notifySWToUpsertSession'); await sm._upsertSession(SessionOrigin._UserCreate); expect(notifySpy).not.toHaveBeenCalled(); }); test('_upsertSession installs listeners when SW supported', async () => { supportsServiceWorkersSpy.mockReturnValue(true); - const sm = new SessionManager(OneSignal._context); const setupSpy = vi.spyOn(sm, '_setupSessionEventListeners'); await sm._upsertSession(SessionOrigin._Focus); expect(setupSpy).toHaveBeenCalled(); @@ -169,12 +185,65 @@ describe('SessionManager', () => { test('_upsertSession emits SESSION_STARTED when SW not supported', async () => { supportsServiceWorkersSpy.mockReturnValue(false); - const sm = new SessionManager(OneSignal._context); const emitSpy = vi .spyOn(OneSignal._emitter, '_emit') .mockResolvedValue(OneSignal._emitter); await sm._upsertSession(SessionOrigin._UserCreate); expect(emitSpy).toHaveBeenCalledWith(OneSignal.EVENTS.SESSION_STARTED); }); + + test('_handleVisibilityChange visible triggers upsert; hidden triggers deactivate and removes listeners', async () => { + // ensure user present + User._createOrGetInstance(); + + vi.spyOn( + sm as unknown as { + _getOneSignalAndSubscriptionIds: () => Promise<{ + onesignalId: string; + subscriptionId: string; + }>; + }, + '_getOneSignalAndSubscriptionIds', + ).mockResolvedValue({ + onesignalId: 'o', + subscriptionId: 's', + }); + + // visible and focused + const visSpy = vi + .spyOn(document, 'visibilityState', 'get') + .mockReturnValue('visible' as DocumentVisibilityState); + const focusSpy = vi.spyOn(document, 'hasFocus').mockReturnValue(true); + notifySpy.mockResolvedValue(undefined); + await sm._handleVisibilityChange(); + expect(notifySpy).toHaveBeenCalled(); + visSpy.mockRestore(); + focusSpy.mockRestore(); + + // hidden path removes listeners + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue( + 'hidden' as DocumentVisibilityState, + ); + deactSpy.mockResolvedValue(undefined); + OneSignal._cache.isFocusEventSetup = true; + OneSignal._cache.isBlurEventSetup = true; + OneSignal._cache.focusHandler = () => undefined; + OneSignal._cache.blurHandler = () => undefined; + await sm._handleVisibilityChange(); + expect(deactSpy).toHaveBeenCalled(); + expect(OneSignal._cache.isFocusEventSetup).toBe(false); + expect(OneSignal._cache.isBlurEventSetup).toBe(false); + }); + + test('_handleOnFocus/Blur target guard prevents duplicate', async () => { + // ensure user present + User._createOrGetInstance(); + notifySpy.mockResolvedValue(undefined); + deactSpy.mockResolvedValue(undefined); + await sm._handleOnFocus(new Event('focus')); + await sm._handleOnBlur(new Event('blur')); + expect(notifySpy).not.toHaveBeenCalled(); + expect(deactSpy).not.toHaveBeenCalled(); + }); }); });