diff --git a/__test__/setupTests.ts b/__test__/setupTests.ts index 1362888d6..dfd7d5f8a 100644 --- a/__test__/setupTests.ts +++ b/__test__/setupTests.ts @@ -60,3 +60,6 @@ export const mockJsonp = () => { downloadSpy = vi.spyOn(OneSignalApi, 'downloadServerAppConfig'); downloadSpy.mockResolvedValue(serverConfig); }; + +// Polyfill Web Animations API method used by AnimatedElement +HTMLElement.prototype.getAnimations = () => []; diff --git a/package.json b/package.json index b00e8f78e..51f056847 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "test": "vitest run --coverage", "test:watch": "vitest watch --coverage", "validate:build": "node build/scripts/validate.js", - "lint": "eslint src --ext .js,.jsx,.ts,.tsx; prettylint 'src/**/*' 'test/**/*' '__test__/**/*' --no-editorconfig" + "lint": "eslint src --ext .js,.jsx,.ts,.tsx; prettylint 'src/**/*' 'test/**/*' '__test__/**/*' --no-editorconfig", + "lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix; prettylint 'src/**/*' 'test/**/*' '__test__/**/*' --no-editorconfig --fix" }, "config": { "sdkVersion": "160510" diff --git a/src/page/bell/Badge.test.ts b/src/page/bell/Badge.test.ts new file mode 100644 index 000000000..79b3035a9 --- /dev/null +++ b/src/page/bell/Badge.test.ts @@ -0,0 +1,27 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import Badge from './Badge'; + +describe('Badge', () => { + beforeEach(() => { + TestEnvironment.initialize(); + document.body.innerHTML = ` +
+ `; + }); + + test('_updateCount increments and clamps to empty when <= 0', () => { + const badge = new Badge(); + // Start at empty, increment twice + badge._content = ''; + badge._increment(); + expect(badge._content).toBe('1'); + badge._increment(); + expect(badge._content).toBe('2'); + + // Decrement twice → goes to 0 and clears to empty string + badge._decrement(); + expect(badge._content).toBe('1'); + badge._decrement(); + expect(badge._content).toBe(''); + }); +}); diff --git a/src/page/bell/Bell.test.ts b/src/page/bell/Bell.test.ts new file mode 100644 index 000000000..90f742d3f --- /dev/null +++ b/src/page/bell/Bell.test.ts @@ -0,0 +1,50 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import OneSignalEvent from '../../shared/services/OneSignalEvent'; +import Bell from './Bell'; +import { BellEvent, BellState } from './constants'; + +// @ts-expect-error - _installEventHooks is not assignable +const spyInstall = vi.spyOn(Bell.prototype, '_installEventHooks'); +const updateStateSpy = vi.spyOn(Bell.prototype, '_updateState'); +describe('Bell', () => { + beforeEach(() => { + // Set up OneSignal globals/context to avoid accidental runtime lookups + TestEnvironment.initialize(); + }); + + test('constructor early-returns when enable=false and applies defaults', () => { + const bell = new Bell({ enable: false }); + expect(bell._options.size).toBe('medium'); + expect(bell._options.position).toBe('bottom-right'); + expect(bell._options.theme).toBe('default'); + expect(spyInstall).not.toHaveBeenCalled(); + expect(updateStateSpy).not.toHaveBeenCalled(); + }); + + test('constructor validates and installs hooks when enable=true', () => { + // Valid non-defaults to ensure validation path runs + const bell = new Bell({ + enable: true, + size: 'small', + position: 'bottom-left', + theme: 'inverse', + showBadgeAfter: 10, + showLauncherAfter: 1, + }); + expect(bell).toBeTruthy(); + expect(spyInstall).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledTimes(1); + }); + + test('_setState triggers event when changed', () => { + const bell = new Bell({ enable: false }); + const trigger = vi.spyOn(OneSignalEvent, '_trigger'); + // transition should emit + bell._setState(BellState._Subscribed); + + expect(trigger).toHaveBeenCalledWith(BellEvent._StateChanged, { + from: BellState._Uninitialized, + to: BellState._Subscribed, + }); + }); +}); diff --git a/src/page/bell/Button.test.ts b/src/page/bell/Button.test.ts new file mode 100644 index 000000000..2cf0e3543 --- /dev/null +++ b/src/page/bell/Button.test.ts @@ -0,0 +1,34 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import Bell from './Bell'; +import Button from './Button'; +import { MessageType } from './constants'; + +describe('Button', () => { + beforeEach(() => { + TestEnvironment.initialize(); + document.body.innerHTML = ` +
+
+ `; + }); + + test('_onClick concurrency guard and early-return when message showing', async () => { + const bell = new Bell({ enable: false }); + const button = new Button(bell); + + // Simulate message being shown of type Message by adding the show class + const msgEl = document.querySelector( + '.onesignal-bell-launcher-message', + ) as HTMLElement; + msgEl.classList.add('onesignal-bell-launcher-message-opened'); + bell._message._contentType = MessageType._Message; + + const toggleSpy = vi.spyOn(button, '_toggleDialog'); + + // Force concurrent scenario: set handling to true then call + button._isHandlingClick = false; + await button._onClick(); + expect(toggleSpy).not.toHaveBeenCalled(); + expect(button._isHandlingClick).toBe(false); + }); +}); diff --git a/src/page/bell/Dialog.test.ts b/src/page/bell/Dialog.test.ts new file mode 100644 index 000000000..873746394 --- /dev/null +++ b/src/page/bell/Dialog.test.ts @@ -0,0 +1,48 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import type { MockInstance } from 'vitest'; +import Bell from './Bell'; +import Dialog from './Dialog'; +import { BellState } from './constants'; + +describe('Dialog', () => { + let isPushNotificationsEnabledSpy: MockInstance; + + beforeEach(() => { + TestEnvironment.initialize(); + document.body.innerHTML = ` +
+
+
+ `; + isPushNotificationsEnabledSpy = vi.spyOn( + OneSignal._context._subscriptionManager, + '_isPushNotificationsEnabled', + ); + }); + + test('_show populates content and toggles shown flag', async () => { + const bell = new Bell({ enable: false }); + // Put bell in unsubscribed state to render subscribe button + bell._state = BellState._Unsubscribed; + isPushNotificationsEnabledSpy.mockResolvedValue(false); + + const dialog = new Dialog(bell); + expect(dialog._shown).toBe(false); + await dialog._show(); + expect(dialog._shown).toBe(true); + // Button should be present for subscribe + expect(dialog._subscribeButton).not.toBeNull(); + }); + + test('_hide removes shown class and keeps state consistent', async () => { + const bell = new Bell({ enable: false }); + bell._state = BellState._Unsubscribed; + isPushNotificationsEnabledSpy.mockResolvedValue(false); + + const dialog = new Dialog(bell); + await dialog._show(); + expect(dialog._shown).toBe(true); + await dialog._hide(); + expect(dialog._shown).toBe(false); + }); +}); diff --git a/src/page/bell/Launcher.test.ts b/src/page/bell/Launcher.test.ts new file mode 100644 index 000000000..28921a332 --- /dev/null +++ b/src/page/bell/Launcher.test.ts @@ -0,0 +1,40 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import Bell from './Bell'; +import Launcher from './Launcher'; + +describe('Launcher', () => { + beforeEach(() => { + TestEnvironment.initialize(); + document.body.innerHTML = ` +
+ `; + }); + + test('_activateIfInactive sets wasInactive and activates only when inactive', async () => { + const bell = new Bell({ enable: false }); // disable side-effects + const launcher = new Launcher(bell); + + // Mark element as inactive by adding inactive class + launcher._element?.classList.add('onesignal-bell-launcher-inactive'); + expect(launcher._active).toBe(false); + await launcher._activateIfInactive(); + expect(launcher._wasInactive).toBe(true); + expect(launcher._active).toBe(true); + + // Calling again should be a no-op (remains active, wasInactive unchanged) + await launcher._activateIfInactive(); + expect(launcher._wasInactive).toBe(true); + expect(launcher._active).toBe(true); + }); + + test('_inactivateIfWasInactive only fires when previously inactive path set', async () => { + const bell = new Bell({ enable: false }); + const launcher = new Launcher(bell); + // Mark that it was activated from inactive state + launcher._wasInactive = true; + + await launcher._inactivateIfWasInactive(); + expect(launcher._wasInactive).toBe(false); + expect(launcher._active).toBe(false); + }); +}); diff --git a/src/page/bell/Message.test.ts b/src/page/bell/Message.test.ts new file mode 100644 index 000000000..aeebaca04 --- /dev/null +++ b/src/page/bell/Message.test.ts @@ -0,0 +1,29 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import Bell from './Bell'; +import Message from './Message'; + +vi.useFakeTimers(); + +describe('Message', () => { + beforeEach(() => { + TestEnvironment.initialize(); + document.body.innerHTML = ` +
+
+
+
+ `; + }); + + test('_display shows then hides after duration and resets content type', async () => { + const bell = new Bell({ enable: false }); + const message = new Message(bell); + const promise = message._display('message', 'Hello', 1000); + // advance timers to cover delay + await vi.runOnlyPendingTimersAsync(); + await promise; + // After display finishes, contentType should reset to 'tip' + expect(message._contentType).toBe('tip'); + expect(message._shown).toBe(false); + }); +}); diff --git a/src/page/managers/LoginManager.test.ts b/src/page/managers/LoginManager.test.ts new file mode 100644 index 000000000..8224e7c32 --- /dev/null +++ b/src/page/managers/LoginManager.test.ts @@ -0,0 +1,71 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import { updateIdentityModel } from '__test__/support/helpers/setup'; +import { SubscriptionModel } from 'src/core/models/SubscriptionModel'; +import { db } from 'src/shared/database/client'; +import Log from 'src/shared/libraries/Log'; +import * as userDirector from '../../onesignal/userDirector'; +import LoginManager from './LoginManager'; + +const createUserOnServerSpy = vi.spyOn(userDirector, 'createUserOnServer'); + +describe('LoginManager', () => { + beforeEach(() => { + TestEnvironment.initialize(); + }); + + test('login: skips when externalId unchanged and logs debug', async () => { + const debugSpy = vi + .spyOn(Log, '_debug') + .mockImplementation(() => undefined); + await updateIdentityModel('external_id', 'same-id'); + + await LoginManager.login('same-id'); + expect(debugSpy).toHaveBeenCalledWith( + 'Login: External ID already set, skipping login', + ); + }); + + test('login: stores token when provided and enqueues operations', async () => { + const dbSpy = vi.spyOn(db, 'put'); + // mock push subscription exists so transfer op enqueues + const createPushSub = () => ({ + id: 'push-sub-id', + }); + vi.spyOn( + OneSignal._coreDirector, + '_getPushSubscriptionModel', + ).mockResolvedValue(createPushSub() as SubscriptionModel); + const enqueueSpy = vi.spyOn( + OneSignal._coreDirector._operationRepo, + '_enqueue', + ); + const enqueueAndWaitSpy = vi + .spyOn(OneSignal._coreDirector._operationRepo, '_enqueueAndWait') + .mockResolvedValue(undefined); + + await LoginManager.login('new-external-id', 'jwt-token-123'); + expect(dbSpy).toHaveBeenCalledWith('Ids', { + id: 'jwt-token-123', + type: 'jwtToken', + }); + expect(enqueueSpy).toHaveBeenCalled(); + expect(enqueueAndWaitSpy).toHaveBeenCalled(); + }); + + test('logout: no external id logs debug and returns', async () => { + const debugSpy = vi + .spyOn(Log, '_debug') + .mockImplementation(() => undefined); + await updateIdentityModel('external_id', undefined); + await LoginManager.logout(); + expect(debugSpy).toHaveBeenCalledWith( + 'Logout: User is not logged in, skipping logout', + ); + }); + + test('logout: with external id resets models and creates anonymous user', async () => { + await updateIdentityModel('external_id', 'abc'); + await LoginManager.logout(); + expect(createUserOnServerSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/page/managers/PromptsManager.test.ts b/src/page/managers/PromptsManager.test.ts new file mode 100644 index 000000000..af6a3e04b --- /dev/null +++ b/src/page/managers/PromptsManager.test.ts @@ -0,0 +1,55 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import { setupLoadStylesheet } from '__test__/support/helpers/setup'; +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'); +const isTabletBrowserSpy = vi.spyOn(detect, 'isTabletBrowser'); +const requiresUserInteractionSpy = vi.spyOn(detect, 'requiresUserInteraction'); + +describe('PromptsManager', () => { + beforeEach(() => { + TestEnvironment.initialize(); + }); + + test('_shouldForceSlidedownOverNative returns true on Chrome>=63 mobile/tablet', async () => { + getBrowserNameSpy.mockReturnValue(Browser._Chrome); + getBrowserVersionSpy.mockReturnValue(70); + isMobileBrowserSpy.mockReturnValue(true); + isTabletBrowserSpy.mockReturnValue(false); + requiresUserInteractionSpy.mockReturnValue(false); + + const pm = new PromptsManager(OneSignal._context); + expect(pm['_shouldForceSlidedownOverNative']()).toBe(true); + }); + + test('_shouldForceSlidedownOverNative returns true when requiresUserInteraction', async () => { + getBrowserNameSpy.mockReturnValue(Browser._Firefox); + getBrowserVersionSpy.mockReturnValue(100); + isMobileBrowserSpy.mockReturnValue(false); + isTabletBrowserSpy.mockReturnValue(false); + requiresUserInteractionSpy.mockReturnValue(true); + + const pm = new PromptsManager(OneSignal._context); + expect(pm['_shouldForceSlidedownOverNative']()).toBe(true); + }); + + test('event hooks install only once for slidedown path', async () => { + await setupLoadStylesheet(); + const pm = new PromptsManager(OneSignal._context); + + // stub _createSlidedown to avoid side effects + vi.spyOn( + OneSignal._context._slidedownManager, + '_createSlidedown', + ).mockResolvedValue(undefined); + const installSpy = vi.spyOn(pm, '_installEventHooksForSlidedown'); + + await pm['_internalShowSlidedownPrompt'](); + await pm['_internalShowSlidedownPrompt'](); + expect(installSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/page/managers/slidedownManager/SlidedownManager.test.ts b/src/page/managers/slidedownManager/SlidedownManager.test.ts index 88163bc6c..3ce9b74fa 100644 --- a/src/page/managers/slidedownManager/SlidedownManager.test.ts +++ b/src/page/managers/slidedownManager/SlidedownManager.test.ts @@ -314,7 +314,7 @@ export const mockPhoneLibraryLoading = () => { window.intlTelInputUtils = { numberType: { MOBILE: 1 }, - // @ts-expect-error - mock intl-tel-input + // @ts-ignore @ts-expect-error - mock intl-tel-input numberFormat: { E164: 0 }, }; return Promise.resolve(); diff --git a/src/page/managers/tagManager/TagManager.test.ts b/src/page/managers/tagManager/TagManager.test.ts new file mode 100644 index 000000000..c5b2623ec --- /dev/null +++ b/src/page/managers/tagManager/TagManager.test.ts @@ -0,0 +1,44 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import UserNamespace from 'src/onesignal/UserNamespace'; +import Log from 'src/shared/libraries/Log'; +import TagManager from './TagManager'; + +const addTagsSpy = vi.spyOn(UserNamespace.prototype, 'addTags'); +describe('TagManager', () => { + beforeEach(() => { + TestEnvironment.initialize(); + }); + + test('_storeTagValuesToUpdate/_storeRemotePlayerTags set internal state', () => { + const tm = new TagManager(OneSignal._context); + tm._storeRemotePlayerTags({ a: '1' }); + tm._storeTagValuesToUpdate({ a: true, b: false }); + expect(OneSignal._context._tagManager._remoteTags).toEqual({ a: '1' }); + expect(tm['_tagsFromTaggingContainer']).toEqual({ + a: true, + b: false, + }); + }); + + test('_sendTags calls addTags when diff is non-empty and returns diff', async () => { + const tm = new TagManager(OneSignal._context); + tm._storeRemotePlayerTags({ a: '1' }); + tm._storeTagValuesToUpdate({ a: false, b: true }); // converts to { a:0, b:1 } and diff => { a:0, b:1 } vs { a:1 } -> { a:0, b:1 } + const result = await tm._sendTags(); + expect(addTagsSpy).toHaveBeenCalledWith(result); + expect(result).toMatchObject({ a: '0', b: '1' }); + }); + + test('_sendTags returns {} and warns when no change', async () => { + const warnSpy = vi.spyOn(Log, '_warn').mockImplementation(() => ''); + const tm = new TagManager(OneSignal._context); + // Ensure this instance uses the same remote tags that will be diffed + tm._remoteTags = { c: '1' }; + tm._storeTagValuesToUpdate({ c: true }); // converts to { c:'1' } -> diff {} + + const result = await tm._sendTags(); + expect(result).toEqual({}); + expect(warnSpy).toHaveBeenCalled(); + expect(addTagsSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/context/helpers.test.ts b/src/shared/context/helpers.test.ts new file mode 100644 index 000000000..72632b8d1 --- /dev/null +++ b/src/shared/context/helpers.test.ts @@ -0,0 +1,47 @@ +import { enforceAlias, enforceAppId, timeoutPromise } from './helpers'; + +vi.useFakeTimers(); + +describe('context/helpers', () => { + describe('timeoutPromise', () => { + test('resolves before timeout', async () => { + const p = new Promise((resolve) => setTimeout(() => resolve('ok'), 50)); + const raced = timeoutPromise(p, 100); + vi.advanceTimersByTime(60); + await expect(raced).resolves.toBe('ok'); + }); + + test('rejects on timeout', async () => { + const never = new Promise((_r) => undefined); + const raced = timeoutPromise(never, 10); + vi.advanceTimersByTime(11); + await expect(raced).rejects.toThrow('Async operation timed out'); + }); + }); + + describe('enforceAppId', () => { + test('throws on empty', () => { + expect(() => enforceAppId(undefined)).toThrow('App id cannot be empty'); + expect(() => enforceAppId(null)).toThrow('App id cannot be empty'); + }); + + test('does not throw on valid', () => { + expect(() => enforceAppId('abc')).not.toThrow(); + }); + }); + + describe('enforceAlias', () => { + test('throws when label or id is empty', () => { + expect(() => enforceAlias({ label: '', id: '' })).toThrow( + 'Alias label cannot be empty', + ); + expect(() => enforceAlias({ label: 'x', id: '' })).toThrow( + 'Alias id cannot be empty', + ); + }); + + test('does not throw on valid', () => { + expect(() => enforceAlias({ label: 'x', id: 'y' })).not.toThrow(); + }); + }); +}); diff --git a/src/shared/helpers/init.test.ts b/src/shared/helpers/init.test.ts index 38c75260e..e4087b1e0 100644 --- a/src/shared/helpers/init.test.ts +++ b/src/shared/helpers/init.test.ts @@ -16,10 +16,6 @@ beforeEach(() => { ); }); -afterEach(() => { - vi.restoreAllMocks(); -}); - /** onSdkInitialized */ test('onSdkInitialized: ensure public sdk initialized triggered', async () => { OneSignal._emitter.on(OneSignal.EVENTS.SDK_INITIALIZED_PUBLIC, () => { diff --git a/src/shared/managers/IDManager.test.ts b/src/shared/managers/IDManager.test.ts new file mode 100644 index 000000000..35f800e99 --- /dev/null +++ b/src/shared/managers/IDManager.test.ts @@ -0,0 +1,26 @@ +import { IDManager } from './IDManager'; + +describe('IDManager', () => { + test('_createLocalId uses prefix and is unique-ish', () => { + // mock crypto.randomUUID to deterministic value + let i = 0; + const uuids = [ + 'a-b-c-d-e', + 'f-g-h-i-j', + ] satisfies `${string}-${string}-${string}-${string}-${string}`[]; + vi.spyOn(window.crypto, 'randomUUID').mockImplementation(() => uuids[i++]); + + const a = IDManager._createLocalId(); + const b = IDManager._createLocalId(); + expect(a.startsWith(IDManager.LOCAL_PREFIX)).toBe(true); + expect(b.startsWith(IDManager.LOCAL_PREFIX)).toBe(true); + expect(a).not.toBe(b); + }); + + test('_isLocalId values', () => { + expect(IDManager._isLocalId(`${IDManager.LOCAL_PREFIX}abc`)).toBe(true); + expect(IDManager._isLocalId('xyz')).toBe(false); + expect(IDManager._isLocalId(undefined)).toBe(false); + expect(IDManager._isLocalId(null as unknown as string)).toBe(false); + }); +}); diff --git a/src/shared/managers/PermissionManager.test.ts b/src/shared/managers/PermissionManager.test.ts new file mode 100644 index 000000000..725054352 --- /dev/null +++ b/src/shared/managers/PermissionManager.test.ts @@ -0,0 +1,51 @@ +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import MockNotification from '__test__/support/mocks/MockNotification'; +import * as detect from '../environment/detect'; +import PermissionManager from './PermissionManager'; + +const useSafariLegacyPushSpy = vi.spyOn(detect, 'useSafariLegacyPush'); + +describe('PermissionManager', () => { + beforeEach(() => { + TestEnvironment.initialize(); + }); + + describe('_getPermissionStatus', () => { + test('resolves from context manager', async () => { + const pm = new PermissionManager(); + const spy = vi + .spyOn( + OneSignal._context._permissionManager, + '_getNotificationPermission', + ) + .mockResolvedValue('granted'); + await expect(pm._getPermissionStatus()).resolves.toBe('granted'); + expect(spy).toHaveBeenCalled(); + }); + + test('throws if context is undefined', async () => { + // @ts-expect-error - testing undefined context + OneSignal._context = undefined; + const pm = new PermissionManager(); + await expect(pm._getPermissionStatus()).rejects.toThrow( + 'OneSignal.context is undefined. Call init first', + ); + }); + }); + + test('_getNotificationPermission uses legacy Safari path and requires webId', async () => { + useSafariLegacyPushSpy.mockImplementation(() => true); + const pm = new PermissionManager(); + await expect(pm._getNotificationPermission(undefined)).rejects.toThrow( + '"safariWebId" is empty', + ); + }); + + test('_getNotificationPermission uses W3C Notification.permission when not legacy', async () => { + useSafariLegacyPushSpy.mockImplementation(() => false); + MockNotification.permission = 'default'; + + const pm = new PermissionManager(); + await expect(pm['_getNotificationPermission']()).resolves?.toBe('default'); + }); +}); diff --git a/src/shared/managers/sessionManager/SessionManager.test.ts b/src/shared/managers/sessionManager/SessionManager.test.ts index ed9c03b20..22c7bb8de 100644 --- a/src/shared/managers/sessionManager/SessionManager.test.ts +++ b/src/shared/managers/sessionManager/SessionManager.test.ts @@ -2,10 +2,13 @@ import { EXTERNAL_ID } from '__test__/constants'; import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; import { setAddAliasResponse } from '__test__/support/helpers/requests'; 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 { SessionManager } from './SessionManager'; +const supportsServiceWorkersSpy = vi.spyOn(detect, 'supportsServiceWorkers'); + vi.spyOn(Log, '_error').mockImplementation(() => ''); describe('SessionManager', () => { @@ -127,4 +130,51 @@ describe('SessionManager', () => { expect(winner).toBe('login'); }); }); + + describe('Core behaviors', () => { + beforeEach(() => { + TestEnvironment.initialize(); + }); + + 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); + + await sm['_notifySWToUpsertSession']( + 'one', + 'sub', + SessionOrigin._UserCreate, + ); + expect(unicastSpy).toHaveBeenCalled(); + }); + + 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(); + }); + + 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); + }); + }); });