diff --git a/src/listeners/__tests__/browser.spec.ts b/src/listeners/__tests__/browser.spec.ts index 8af13e8b..5ec91eff 100644 --- a/src/listeners/__tests__/browser.spec.ts +++ b/src/listeners/__tests__/browser.spec.ts @@ -85,11 +85,12 @@ beforeAll(() => { }); }); -// clean mocks -afterEach(() => { +// clear mocks +beforeEach(() => { (global.window.addEventListener as jest.Mock).mockClear(); (global.window.removeEventListener as jest.Mock).mockClear(); if (global.window.navigator.sendBeacon) (global.window.navigator.sendBeacon as jest.Mock).mockClear(); + Object.values(fakeSplitApi).forEach(method => method.mockClear()); }); // delete mocks from global @@ -227,3 +228,34 @@ test('Browser JS listener / standalone mode / Impressions debug mode without sen // restore sendBeacon API global.navigator.sendBeacon = sendBeacon; }); + +test('Browser JS listener / standalone mode / user consent status', () => { + const syncManagerMock = {}; + const settings = { ...fullSettings }; + + // @ts-expect-error + const listener = new BrowserSignalListener(syncManagerMock, settings, fakeStorageOptimized as IStorageSync, fakeSplitApi); + + listener.start(); + + settings.userConsent = 'UNKNOWN'; + triggerUnloadEvent(); + settings.userConsent = 'DECLINED'; + triggerUnloadEvent(); + + // Unload event was triggered when user consent was unknown and declined. Thus sendBeacon and post services should not be called + expect(global.window.navigator.sendBeacon).toBeCalledTimes(0); + expect(fakeSplitApi.postTestImpressionsBulk).not.toBeCalled(); + expect(fakeSplitApi.postEventsBulk).not.toBeCalled(); + expect(fakeSplitApi.postTestImpressionsCount).not.toBeCalled(); + + settings.userConsent = 'GRANTED'; + triggerUnloadEvent(); + settings.userConsent = undefined; + triggerUnloadEvent(); + + // Unload event was triggered when user consent was granted and undefined. Thus sendBeacon should be called 6 times (3 times per event in optimized mode). + expect(global.window.navigator.sendBeacon).toBeCalledTimes(6); + + listener.stop(); +}); diff --git a/src/listeners/browser.ts b/src/listeners/browser.ts index 9705af2c..502c4e43 100644 --- a/src/listeners/browser.ts +++ b/src/listeners/browser.ts @@ -11,6 +11,7 @@ import { OPTIMIZED, DEBUG } from '../utils/constants'; import { objectAssign } from '../utils/lang/objectAssign'; import { CLEANUP_REGISTERING, CLEANUP_DEREGISTERING } from '../logger/constants'; import { ISyncManager } from '../sync/types'; +import { isConsentGranted } from '../utils/consent'; // 'unload' event is used instead of 'beforeunload', since 'unload' is not a cancelable event, so no other listeners can stop the event from occurring. const UNLOAD_DOM_EVENT = 'unload'; @@ -65,15 +66,18 @@ export class BrowserSignalListener implements ISignalListener { flushData() { if (!this.syncManager) return; // In consumer mode there is not sync manager and data to flush - const eventsUrl = this.settings.urls.events; - const extraMetadata = { - // sim stands for Sync/Split Impressions Mode - sim: this.settings.sync.impressionsMode === OPTIMIZED ? OPTIMIZED : DEBUG - }; + // Flush data if there is user consent + if (isConsentGranted(this.settings)) { + const eventsUrl = this.settings.urls.events; + const extraMetadata = { + // sim stands for Sync/Split Impressions Mode + sim: this.settings.sync.impressionsMode === OPTIMIZED ? OPTIMIZED : DEBUG + }; - this._flushData(eventsUrl + '/testImpressions/beacon', this.storage.impressions, this.serviceApi.postTestImpressionsBulk, this.fromImpressionsCollector, extraMetadata); - this._flushData(eventsUrl + '/events/beacon', this.storage.events, this.serviceApi.postEventsBulk); - if (this.storage.impressionCounts) this._flushData(eventsUrl + '/testImpressions/count/beacon', this.storage.impressionCounts, this.serviceApi.postTestImpressionsCount, fromImpressionCountsCollector); + this._flushData(eventsUrl + '/testImpressions/beacon', this.storage.impressions, this.serviceApi.postTestImpressionsBulk, this.fromImpressionsCollector, extraMetadata); + this._flushData(eventsUrl + '/events/beacon', this.storage.events, this.serviceApi.postEventsBulk); + if (this.storage.impressionCounts) this._flushData(eventsUrl + '/testImpressions/count/beacon', this.storage.impressionCounts, this.serviceApi.postTestImpressionsCount, fromImpressionCountsCollector); + } // Close streaming connection if (this.syncManager.pushManager) this.syncManager.pushManager.stop(); @@ -84,7 +88,7 @@ export class BrowserSignalListener implements ISignalListener { if (!cache.isEmpty()) { const dataPayload = fromCacheToPayload ? fromCacheToPayload(cache.state()) : cache.state(); if (!this._sendBeacon(url, dataPayload, extraMetadata)) { - postService(JSON.stringify(dataPayload)).catch(() => { }); // no-op just to catch a possible exceptions + postService(JSON.stringify(dataPayload)).catch(() => { }); // no-op just to catch a possible exception } cache.clear(); } diff --git a/src/sync/syncManagerOnline.ts b/src/sync/syncManagerOnline.ts index 425b9d9f..b95be139 100644 --- a/src/sync/syncManagerOnline.ts +++ b/src/sync/syncManagerOnline.ts @@ -6,7 +6,7 @@ import { IPushManager } from './streaming/types'; import { IPollingManager, IPollingManagerCS } from './polling/types'; import { PUSH_SUBSYSTEM_UP, PUSH_SUBSYSTEM_DOWN } from './streaming/constants'; import { SYNC_START_POLLING, SYNC_CONTINUE_POLLING, SYNC_STOP_POLLING } from '../logger/constants'; -import { CONSENT_GRANTED } from '../utils/constants'; +import { isConsentGranted } from '../utils/consent'; /** * Online SyncManager factory. @@ -26,7 +26,7 @@ export function syncManagerOnlineFactory( */ return function (params: ISyncManagerFactoryParams): ISyncManagerCS { - const { log, streamingEnabled } = params.settings; + const { settings, settings: { log, streamingEnabled } } = params; /** Polling Manager */ const pollingManager = pollingManagerFactory && pollingManagerFactory(params); @@ -40,12 +40,6 @@ export function syncManagerOnlineFactory( // It is not inyected as push and polling managers, because at the moment it is required const submitter = submitterManagerFactory(params); - function isUserConsentGranted() { - const userConsent = params.settings.userConsent; - // undefined userConsent is handled as granted to avoid a breaking change with users that don't use this features - return !userConsent || userConsent === CONSENT_GRANTED; - } - /** Sync Manager logic */ function startPolling() { @@ -102,7 +96,7 @@ export function syncManagerOnlineFactory( } // start periodic data recording (events, impressions, telemetry). - if (isUserConsentGranted()) submitter.start(); + if (isConsentGranted(settings)) submitter.start(); }, /** @@ -124,7 +118,7 @@ export function syncManagerOnlineFactory( }, flush() { - if (isUserConsentGranted()) return submitter.execute(); + if (isConsentGranted(settings)) return submitter.execute(); else return Promise.resolve(); }, diff --git a/src/utils/consent.ts b/src/utils/consent.ts new file mode 100644 index 00000000..20ca745c --- /dev/null +++ b/src/utils/consent.ts @@ -0,0 +1,8 @@ +import { ISettings } from '../types'; +import { CONSENT_GRANTED } from './constants'; + +export function isConsentGranted(settings: ISettings) { + const userConsent = settings.userConsent; + // undefined userConsent is handled as granted (default) + return !userConsent || userConsent === CONSENT_GRANTED; +}