diff --git a/libraries/cmp/cmpEventUtils.ts b/libraries/cmp/cmpEventUtils.ts new file mode 100644 index 00000000000..4619e9605c9 --- /dev/null +++ b/libraries/cmp/cmpEventUtils.ts @@ -0,0 +1,121 @@ +/** + * Shared utilities for CMP event listener management + * Used by TCF and GPP consent management modules + */ + +import { logError, logInfo } from "../../src/utils.js"; + +export interface CmpEventManager { + cmpApi: any; + listenerId: number | undefined; + setCmpApi(cmpApi: any): void; + getCmpApi(): any; + setCmpListenerId(listenerId: number | undefined): void; + getCmpListenerId(): number | undefined; + removeCmpEventListener(): void; + resetCmpApis(): void; +} + +/** + * Base CMP event manager implementation + */ +export abstract class BaseCmpEventManager implements CmpEventManager { + cmpApi: any = null; + listenerId: number | undefined = undefined; + + setCmpApi(cmpApi: any): void { + this.cmpApi = cmpApi; + } + + getCmpApi(): any { + return this.cmpApi; + } + + setCmpListenerId(listenerId: number | undefined): void { + this.listenerId = listenerId; + } + + getCmpListenerId(): number | undefined { + return this.listenerId; + } + + resetCmpApis(): void { + this.cmpApi = null; + this.listenerId = undefined; + } + + /** + * Helper method to get base removal parameters + * Can be used by subclasses that need to remove event listeners + */ + protected getRemoveListenerParams(): Record | null { + const cmpApi = this.getCmpApi(); + const listenerId = this.getCmpListenerId(); + + // Comprehensive validation for all possible failure scenarios + if (cmpApi && typeof cmpApi === 'function' && listenerId !== undefined && listenerId !== null) { + return { + command: "removeEventListener", + callback: () => this.resetCmpApis(), + parameter: listenerId + }; + } + return null; + } + + /** + * Abstract method - each subclass implements its own removal logic + */ + abstract removeCmpEventListener(): void; +} + +/** + * TCF-specific CMP event manager + */ +export class TcfCmpEventManager extends BaseCmpEventManager { + private getConsentData: () => any; + + constructor(getConsentData?: () => any) { + super(); + this.getConsentData = getConsentData || (() => null); + } + + removeCmpEventListener(): void { + const params = this.getRemoveListenerParams(); + if (params) { + const consentData = this.getConsentData(); + params.apiVersion = consentData?.apiVersion || 2; + logInfo('Removing TCF CMP event listener'); + this.getCmpApi()(params); + } + } +} + +/** + * GPP-specific CMP event manager + * GPP doesn't require event listener removal, so this is empty + */ +export class GppCmpEventManager extends BaseCmpEventManager { + removeCmpEventListener(): void { + const params = this.getRemoveListenerParams(); + if (params) { + logInfo('Removing GPP CMP event listener'); + this.getCmpApi()(params); + } + } +} + +/** + * Factory function to create appropriate CMP event manager + */ +export function createCmpEventManager(type: 'tcf' | 'gpp', getConsentData?: () => any): CmpEventManager { + switch (type) { + case 'tcf': + return new TcfCmpEventManager(getConsentData); + case 'gpp': + return new GppCmpEventManager(); + default: + logError(`Unknown CMP type: ${type}`); + return null; + } +} diff --git a/libraries/consentManagement/cmUtils.ts b/libraries/consentManagement/cmUtils.ts index 88dfffef9cd..d8012241fc7 100644 --- a/libraries/consentManagement/cmUtils.ts +++ b/libraries/consentManagement/cmUtils.ts @@ -112,6 +112,12 @@ export interface BaseCMConfig { * for the user to interact with the CMP. */ actionTimeout?: number; + /** + * Flag to enable or disable the consent management module. + * When set to false, the module will be reset and disabled. + * Defaults to true when not specified. + */ + enabled?: boolean; } export interface IABCMConfig { @@ -136,6 +142,7 @@ export function configParser( parseConsentData, getNullConsent, cmpHandlers, + cmpEventCleanup, DEFAULT_CMP = 'iab', DEFAULT_CONSENT_TIMEOUT = 10000 } = {} as any @@ -167,6 +174,19 @@ export function configParser( getHook('requestBids').getHooks({hook: requestBidsHook}).remove(); buildActivityParams.getHooks({hook: attachActivityParams}).remove(); requestBidsHook = null; + logInfo(`${displayName} consentManagement module has been deactivated...`); + } + } + + function resetConsentDataHandler() { + reset(); + // Call module-specific CMP event cleanup if provided + if (typeof cmpEventCleanup === 'function') { + try { + cmpEventCleanup(); + } catch (e) { + logError(`Error during CMP event cleanup for ${displayName}:`, e); + } } } @@ -177,6 +197,14 @@ export function configParser( reset(); return {}; } + + // Check if module is explicitly disabled + if (cmConfig?.enabled === false) { + logWarn(msg(`config enabled is set to false, disabling consent manager module`)); + resetConsentDataHandler(); + return {}; + } + let cmpHandler; if (isStr(cmConfig.cmpApi)) { cmpHandler = cmConfig.cmpApi; diff --git a/modules/consentManagementGpp.ts b/modules/consentManagementGpp.ts index 905cffda213..fcbcaeb664c 100644 --- a/modules/consentManagementGpp.ts +++ b/modules/consentManagementGpp.ts @@ -11,10 +11,14 @@ import {enrichFPD} from '../src/fpd/enrichment.js'; import {cmpClient, MODE_CALLBACK} from '../libraries/cmp/cmpClient.js'; import {PbPromise, defer} from '../src/utils/promise.js'; import {type CMConfig, configParser} from '../libraries/consentManagement/cmUtils.js'; +import {createCmpEventManager, type CmpEventManager} from '../libraries/cmp/cmpEventUtils.js'; import {CONSENT_GPP} from "../src/consentHandler.ts"; export let consentConfig = {} as any; +// CMP event manager instance for GPP +let gppCmpEventManager: CmpEventManager | null = null; + type RelevantCMPData = { applicableSections: number[] gppString: string; @@ -101,6 +105,13 @@ export class GPPClient { logWarn(`Unrecognized GPP CMP version: ${pingData.apiVersion}. Continuing using GPP API version ${this.apiVersion}...`); } this.initialized = true; + + // Initialize CMP event manager and set CMP API + if (!gppCmpEventManager) { + gppCmpEventManager = createCmpEventManager('gpp'); + } + gppCmpEventManager.setCmpApi(this.cmp); + this.cmp({ command: 'addEventListener', callback: (event, success) => { @@ -120,6 +131,10 @@ export class GPPClient { if (gppDataHandler.getConsentData() != null && event?.pingData != null && !this.isCMPReady(event.pingData)) { gppDataHandler.setConsentData(null); } + + if (event?.listenerId !== null && event?.listenerId !== undefined) { + gppCmpEventManager?.setCmpListenerId(event?.listenerId); + } } }); } @@ -218,13 +233,23 @@ export function resetConsentData() { GPPClient.INST = null; } +export function removeCmpListener() { + // Clean up CMP event listeners before resetting + if (gppCmpEventManager) { + gppCmpEventManager.removeCmpEventListener(); + gppCmpEventManager = null; + } + resetConsentData(); +} + const parseConfig = configParser({ namespace: 'gpp', displayName: 'GPP', consentDataHandler: gppDataHandler, parseConsentData, getNullConsent: () => toConsentData(null), - cmpHandlers: cmpCallMap + cmpHandlers: cmpCallMap, + cmpEventCleanup: removeCmpListener }); export function setConsentConfig(config) { diff --git a/modules/consentManagementTcf.ts b/modules/consentManagementTcf.ts index e693132c8af..673a2d6f269 100644 --- a/modules/consentManagementTcf.ts +++ b/modules/consentManagementTcf.ts @@ -11,6 +11,7 @@ import {registerOrtbProcessor, REQUEST} from '../src/pbjsORTB.js'; import {enrichFPD} from '../src/fpd/enrichment.js'; import {cmpClient} from '../libraries/cmp/cmpClient.js'; import {configParser} from '../libraries/consentManagement/cmUtils.js'; +import {createCmpEventManager, type CmpEventManager} from '../libraries/cmp/cmpEventUtils.js'; import {CONSENT_GDPR} from "../src/consentHandler.ts"; import type {CMConfig} from "../libraries/consentManagement/cmUtils.ts"; @@ -24,6 +25,9 @@ const cmpCallMap = { 'iab': lookupIabConsent, }; +// CMP event manager instance for TCF +export let tcfCmpEventManager: CmpEventManager | null = null; + /** * @see https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework * @see https://github.com/InteractiveAdvertisingBureau/iabtcf-es/tree/master/modules/core#iabtcfcore @@ -87,6 +91,9 @@ function lookupIabConsent(setProvisionalConsent) { if (tcfData.gdprApplies === false || tcfData.eventStatus === 'tcloaded' || tcfData.eventStatus === 'useractioncomplete') { try { + if (tcfData.listenerId !== null && tcfData.listenerId !== undefined) { + tcfCmpEventManager?.setCmpListenerId(tcfData.listenerId); + } gdprDataHandler.setConsentData(parseConsentData(tcfData)); resolve(); } catch (e) { @@ -113,6 +120,12 @@ function lookupIabConsent(setProvisionalConsent) { logInfo('Detected CMP is outside the current iframe where Prebid.js is located, calling it now...'); } + // Initialize CMP event manager and set CMP API + if (!tcfCmpEventManager) { + tcfCmpEventManager = createCmpEventManager('tcf', () => gdprDataHandler.getConsentData()); + } + tcfCmpEventManager.setCmpApi(cmp); + cmp({ command: 'addEventListener', callback: cmpResponseCallback @@ -159,14 +172,25 @@ export function resetConsentData() { gdprDataHandler.reset(); } +export function removeCmpListener() { + // Clean up CMP event listeners before resetting + if (tcfCmpEventManager) { + tcfCmpEventManager.removeCmpEventListener(); + tcfCmpEventManager = null; + } + resetConsentData(); +} + const parseConfig = configParser({ namespace: 'gdpr', displayName: 'TCF', consentDataHandler: gdprDataHandler, cmpHandlers: cmpCallMap, parseConsentData, - getNullConsent: () => toConsentData(null) + getNullConsent: () => toConsentData(null), + cmpEventCleanup: removeCmpListener } as any) + /** * A configuration function that initializes some module variables, as well as add a hook into the requestBids function */ diff --git a/src/consentHandler.ts b/src/consentHandler.ts index b69f9df0a1a..b3fa9594673 100644 --- a/src/consentHandler.ts +++ b/src/consentHandler.ts @@ -108,12 +108,19 @@ export class ConsentHandler { } getConsentData(): T { - return this.#data; + if (this.#enabled) { + return this.#data; + } + return null; } get hash() { if (this.#dirty) { - this.#hash = cyrb53Hash(JSON.stringify(this.#data && this.hashFields ? this.hashFields.map(f => this.#data[f]) : this.#data)) + this.#hash = cyrb53Hash( + JSON.stringify( + this.#data && this.hashFields ? this.hashFields.map((f) => this.#data[f]) : this.#data + ) + ); this.#dirty = false; } return this.#hash; @@ -132,16 +139,18 @@ class UspConsentHandler extends ConsentHandler> { - hashFields = ['gdprApplies', 'consentString'] + hashFields = ["gdprApplies", "consentString"]; getConsentMeta() { const consentData = this.getConsentData(); if (consentData && consentData.vendorData && this.generatedTime) { return { gdprApplies: consentData.gdprApplies as boolean, - consentStringSize: (isStr(consentData.vendorData.tcString)) ? consentData.vendorData.tcString.length : 0, + consentStringSize: isStr(consentData.vendorData.tcString) + ? consentData.vendorData.tcString.length + : 0, generatedAt: this.generatedTime, - apiVersion: consentData.apiVersion - } + apiVersion: consentData.apiVersion, + }; } } } diff --git a/test/spec/libraries/cmUtils_spec.js b/test/spec/libraries/cmUtils_spec.js index 7f9c2c932b3..be2e31a8e18 100644 --- a/test/spec/libraries/cmUtils_spec.js +++ b/test/spec/libraries/cmUtils_spec.js @@ -1,5 +1,5 @@ import * as utils from 'src/utils.js'; -import {lookupConsentData, consentManagementHook} from '../../../libraries/consentManagement/cmUtils.js'; +import {lookupConsentData, consentManagementHook, configParser} from '../../../libraries/consentManagement/cmUtils.js'; describe('consent management utils', () => { let sandbox, clock; @@ -226,4 +226,126 @@ describe('consent management utils', () => { }) }); }); + + describe('configParser', () => { + let namespace, displayName, consentDataHandler, parseConsentData, getNullConsent, cmpHandlers, cmpEventCleanup; + let getConsentConfig, resetConsentDataHandler; + + beforeEach(() => { + namespace = 'test'; + displayName = 'TEST'; + resetConsentDataHandler = sinon.stub(); + consentDataHandler = { + reset: sinon.stub(), + removeCmpEventListener: sinon.stub(), + getConsentData: sinon.stub(), + setConsentData: sinon.stub() + }; + parseConsentData = sinon.stub().callsFake(data => data); + getNullConsent = sinon.stub().returns({consent: null}); + cmpHandlers = { + iab: sinon.stub().returns(Promise.resolve()) + }; + cmpEventCleanup = sinon.stub(); + + // Create a spy for resetConsentDataHandler to verify it's called + const configParserInstance = configParser({ + namespace, + displayName, + consentDataHandler, + parseConsentData, + getNullConsent, + cmpHandlers, + cmpEventCleanup + }); + + getConsentConfig = configParserInstance; + }); + + it('should reset and return empty object when config is not defined', () => { + const result = getConsentConfig(); + expect(result).to.deep.equal({}); + sinon.assert.calledWith(utils.logWarn, sinon.match('config not defined')); + }); + + it('should reset and return empty object when config is not an object', () => { + const result = getConsentConfig({[namespace]: 'not an object'}); + expect(result).to.deep.equal({}); + sinon.assert.calledWith(utils.logWarn, sinon.match('config not defined')); + }); + + describe('when module is explicitly disabled', () => { + it('should reset consent data handler and return empty object when enabled is false', () => { + const result = getConsentConfig({[namespace]: {enabled: false}}); + + expect(result).to.deep.equal({}); + sinon.assert.calledWith(utils.logWarn, sinon.match('config enabled is set to false')); + }); + + it('should call cmpEventCleanup when enabled is false', () => { + getConsentConfig({[namespace]: {enabled: false}}); + + sinon.assert.called(cmpEventCleanup); + sinon.assert.calledWith(utils.logWarn, sinon.match('config enabled is set to false')); + }); + + it('should handle cmpEventCleanup errors gracefully', () => { + const cleanupError = new Error('Cleanup failed'); + cmpEventCleanup.throws(cleanupError); + + getConsentConfig({[namespace]: {enabled: false}}); + + sinon.assert.called(cmpEventCleanup); + sinon.assert.calledWith(utils.logError, sinon.match('Error during CMP event cleanup'), cleanupError); + }); + + it('should not call cmpEventCleanup when enabled is true', () => { + getConsentConfig({[namespace]: {enabled: true, cmpApi: 'iab'}}); + + sinon.assert.notCalled(cmpEventCleanup); + }); + + it('should not call cmpEventCleanup when enabled is not specified', () => { + getConsentConfig({[namespace]: {cmpApi: 'iab'}}); + + sinon.assert.notCalled(cmpEventCleanup); + }); + }); + + describe('cmpEventCleanup parameter', () => { + it('should work without cmpEventCleanup parameter', () => { + const configParserWithoutCleanup = configParser({ + namespace, + displayName, + consentDataHandler, + parseConsentData, + getNullConsent, + cmpHandlers + // No cmpEventCleanup provided + }); + + const result = configParserWithoutCleanup({[namespace]: {enabled: false}}); + expect(result).to.deep.equal({}); + // Should not throw error when cmpEventCleanup is undefined + }); + + it('should only call cmpEventCleanup if it is a function', () => { + const configParserWithNonFunction = configParser({ + namespace, + displayName, + consentDataHandler, + parseConsentData, + getNullConsent, + cmpHandlers, + cmpEventCleanup: 'not a function' + }); + + const result = configParserWithNonFunction({[namespace]: {enabled: false}}); + expect(result).to.deep.equal({}); + // Should not throw error when cmpEventCleanup is not a function + }); + }); + + // Additional tests for other configParser functionality could be added here + }); }); diff --git a/test/spec/libraries/cmp/cmpEventUtils_spec.js b/test/spec/libraries/cmp/cmpEventUtils_spec.js new file mode 100644 index 00000000000..c3262549b20 --- /dev/null +++ b/test/spec/libraries/cmp/cmpEventUtils_spec.js @@ -0,0 +1,330 @@ +import * as utils from 'src/utils.js'; +import { + BaseCmpEventManager, + TcfCmpEventManager, + GppCmpEventManager, + createCmpEventManager +} from '../../../../libraries/cmp/cmpEventUtils.js'; + +describe('CMP Event Utils', () => { + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + ['logError', 'logInfo', 'logWarn'].forEach(n => sandbox.stub(utils, n)); + }); + afterEach(() => { + sandbox.restore(); + }); + + describe('BaseCmpEventManager', () => { + let manager; + + // Create a concrete implementation for testing the abstract class + class TestCmpEventManager extends BaseCmpEventManager { + removeCmpEventListener() { + const params = this.getRemoveListenerParams(); + if (params) { + this.getCmpApi()(params); + } + } + } + + beforeEach(() => { + manager = new TestCmpEventManager(); + }); + + describe('setCmpApi and getCmpApi', () => { + it('should set and get CMP API', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + expect(manager.getCmpApi()).to.equal(mockCmpApi); + }); + + it('should initialize with null CMP API', () => { + expect(manager.getCmpApi()).to.be.null; + }); + }); + + describe('setCmpListenerId and getCmpListenerId', () => { + it('should set and get listener ID', () => { + manager.setCmpListenerId(123); + expect(manager.getCmpListenerId()).to.equal(123); + }); + + it('should handle undefined listener ID', () => { + manager.setCmpListenerId(undefined); + expect(manager.getCmpListenerId()).to.be.undefined; + }); + + it('should handle zero as valid listener ID', () => { + manager.setCmpListenerId(0); + expect(manager.getCmpListenerId()).to.equal(0); + }); + + it('should initialize with undefined listener ID', () => { + expect(manager.getCmpListenerId()).to.be.undefined; + }); + }); + + describe('resetCmpApis', () => { + it('should reset both CMP API and listener ID', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(456); + + manager.resetCmpApis(); + + expect(manager.getCmpApi()).to.be.null; + expect(manager.getCmpListenerId()).to.be.undefined; + }); + }); + + describe('getRemoveListenerParams', () => { + it('should return params when CMP API and listener ID are valid', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(123); + + const params = manager.getRemoveListenerParams(); + + expect(params).to.not.be.null; + expect(params.command).to.equal('removeEventListener'); + expect(params.parameter).to.equal(123); + expect(params.callback).to.be.a('function'); + }); + + it('should return null when CMP API is null', () => { + manager.setCmpApi(null); + manager.setCmpListenerId(123); + + const params = manager.getRemoveListenerParams(); + expect(params).to.be.null; + }); + + it('should return null when CMP API is not a function', () => { + manager.setCmpApi('not a function'); + manager.setCmpListenerId(123); + + const params = manager.getRemoveListenerParams(); + expect(params).to.be.null; + }); + + it('should return null when listener ID is undefined', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(undefined); + + const params = manager.getRemoveListenerParams(); + expect(params).to.be.null; + }); + + it('should return null when listener ID is null', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(null); + + const params = manager.getRemoveListenerParams(); + expect(params).to.be.null; + }); + + it('should return params when listener ID is 0', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(0); + + const params = manager.getRemoveListenerParams(); + expect(params).to.not.be.null; + expect(params.parameter).to.equal(0); + }); + + it('should call resetCmpApis when callback is executed', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(123); + + const params = manager.getRemoveListenerParams(); + params.callback(); + + expect(manager.getCmpApi()).to.be.null; + expect(manager.getCmpListenerId()).to.be.undefined; + }); + }); + + describe('removeCmpEventListener', () => { + it('should call CMP API with params when conditions are met', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(123); + + manager.removeCmpEventListener(); + + sinon.assert.calledOnce(mockCmpApi); + const callArgs = mockCmpApi.getCall(0).args[0]; + expect(callArgs.command).to.equal('removeEventListener'); + expect(callArgs.parameter).to.equal(123); + }); + + it('should not call CMP API when conditions are not met', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + // No listener ID set + + manager.removeCmpEventListener(); + + sinon.assert.notCalled(mockCmpApi); + }); + }); + }); + + describe('TcfCmpEventManager', () => { + let manager, mockGetConsentData; + + beforeEach(() => { + mockGetConsentData = sinon.stub(); + manager = new TcfCmpEventManager(mockGetConsentData); + }); + + it('should initialize with provided getConsentData function', () => { + expect(manager.getConsentData).to.equal(mockGetConsentData); + }); + + it('should initialize with default getConsentData when not provided', () => { + const defaultManager = new TcfCmpEventManager(); + expect(defaultManager.getConsentData()).to.be.null; + }); + + describe('removeCmpEventListener', () => { + it('should call CMP API with TCF-specific params including apiVersion', () => { + const mockCmpApi = sinon.stub(); + const consentData = { apiVersion: 2 }; + mockGetConsentData.returns(consentData); + + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(456); + + manager.removeCmpEventListener(); + + sinon.assert.calledOnce(mockCmpApi); + sinon.assert.calledWith(utils.logInfo, 'Removing TCF CMP event listener'); + + const callArgs = mockCmpApi.getCall(0).args[0]; + expect(callArgs.command).to.equal('removeEventListener'); + expect(callArgs.parameter).to.equal(456); + expect(callArgs.apiVersion).to.equal(2); + }); + + it('should use default apiVersion when consent data has no apiVersion', () => { + const mockCmpApi = sinon.stub(); + const consentData = {}; // No apiVersion + mockGetConsentData.returns(consentData); + + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(789); + + manager.removeCmpEventListener(); + + const callArgs = mockCmpApi.getCall(0).args[0]; + expect(callArgs.apiVersion).to.equal(2); + }); + + it('should use default apiVersion when consent data is null', () => { + const mockCmpApi = sinon.stub(); + mockGetConsentData.returns(null); + + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(789); + + manager.removeCmpEventListener(); + + const callArgs = mockCmpApi.getCall(0).args[0]; + expect(callArgs.apiVersion).to.equal(2); + }); + + it('should not call CMP API when conditions are not met', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + // No listener ID set + + manager.removeCmpEventListener(); + + sinon.assert.notCalled(mockCmpApi); + sinon.assert.notCalled(utils.logInfo); + }); + }); + }); + + describe('GppCmpEventManager', () => { + let manager; + + beforeEach(() => { + manager = new GppCmpEventManager(); + }); + + describe('removeCmpEventListener', () => { + it('should call CMP API with GPP-specific params', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + manager.setCmpListenerId(321); + + manager.removeCmpEventListener(); + + sinon.assert.calledOnce(mockCmpApi); + sinon.assert.calledWith(utils.logInfo, 'Removing GPP CMP event listener'); + + const callArgs = mockCmpApi.getCall(0).args[0]; + expect(callArgs.command).to.equal('removeEventListener'); + expect(callArgs.parameter).to.equal(321); + expect(callArgs.apiVersion).to.be.undefined; // GPP doesn't set apiVersion + }); + + it('should not call CMP API when conditions are not met', () => { + const mockCmpApi = sinon.stub(); + manager.setCmpApi(mockCmpApi); + // No listener ID set + + manager.removeCmpEventListener(); + + sinon.assert.notCalled(mockCmpApi); + sinon.assert.notCalled(utils.logInfo); + }); + }); + }); + + describe('createCmpEventManager', () => { + it('should create TcfCmpEventManager for tcf type', () => { + const mockGetConsentData = sinon.stub(); + const manager = createCmpEventManager('tcf', mockGetConsentData); + + expect(manager).to.be.instanceOf(TcfCmpEventManager); + expect(manager.getConsentData).to.equal(mockGetConsentData); + }); + + it('should create TcfCmpEventManager without getConsentData function', () => { + const manager = createCmpEventManager('tcf'); + + expect(manager).to.be.instanceOf(TcfCmpEventManager); + expect(manager.getConsentData()).to.be.null; + }); + + it('should create GppCmpEventManager for gpp type', () => { + const manager = createCmpEventManager('gpp'); + + expect(manager).to.be.instanceOf(GppCmpEventManager); + }); + + it('should log error and return null for unknown type', () => { + const manager = createCmpEventManager('unknown'); + + expect(manager).to.be.null; + sinon.assert.calledWith(utils.logError, 'Unknown CMP type: unknown'); + }); + + it('should ignore getConsentData parameter for gpp type', () => { + const mockGetConsentData = sinon.stub(); + const manager = createCmpEventManager('gpp', mockGetConsentData); + + expect(manager).to.be.instanceOf(GppCmpEventManager); + // GPP manager doesn't use getConsentData + }); + }); +}); diff --git a/test/spec/modules/consentManagementGpp_spec.js b/test/spec/modules/consentManagementGpp_spec.js index 6adf159ff55..e143311a880 100644 --- a/test/spec/modules/consentManagementGpp_spec.js +++ b/test/spec/modules/consentManagementGpp_spec.js @@ -211,6 +211,7 @@ describe('consentManagementGpp', function () { } }; gppClient = mockClient(); + gppDataHandler.enable(); }); describe('updateConsent', () => { diff --git a/test/spec/modules/consentManagement_spec.js b/test/spec/modules/consentManagement_spec.js index a033c56ddcd..dfe5f3d6a0e 100644 --- a/test/spec/modules/consentManagement_spec.js +++ b/test/spec/modules/consentManagement_spec.js @@ -1,4 +1,4 @@ -import {consentConfig, gdprScope, resetConsentData, setConsentConfig, } from 'modules/consentManagementTcf.js'; +import {consentConfig, gdprScope, resetConsentData, setConsentConfig, tcfCmpEventManager} from 'modules/consentManagementTcf.js'; import {gdprDataHandler} from 'src/adapterManager.js'; import * as utils from 'src/utils.js'; import {config} from 'src/config.js'; @@ -736,6 +736,125 @@ describe('consentManagement', function () { expect(consent.gdprApplies).to.be.true; expect(consent.apiVersion).to.equal(2); }); + + it('should set CMP listener ID when listenerId is provided in tcfData', async function () { + const testConsentData = { + tcString: 'abc12345234', + gdprApplies: true, + purposeOneTreatment: false, + eventStatus: 'tcloaded', + listenerId: 123 + }; + + // Create a spy that will be applied when tcfCmpEventManager is created + let setCmpListenerIdSpy = sinon.spy(tcfCmpEventManager, 'setCmpListenerId'); + + cmpStub = sinon.stub(window, '__tcfapi').callsFake((...args) => { + args[2](testConsentData, true); + }); + + await setConsentConfig(goodConfig); + expect(await runHook()).to.be.true; + + sinon.assert.calledOnce(setCmpListenerIdSpy); + sinon.assert.calledWith(setCmpListenerIdSpy, 123); + + setCmpListenerIdSpy.restore(); + }); + + it('should not set CMP listener ID when listenerId is null', async function () { + const testConsentData = { + tcString: 'abc12345234', + gdprApplies: true, + purposeOneTreatment: false, + eventStatus: 'tcloaded', + listenerId: null + }; + + // Create a spy that will be applied when tcfCmpEventManager is created + let setCmpListenerIdSpy = sinon.spy(tcfCmpEventManager, 'setCmpListenerId'); + + cmpStub = sinon.stub(window, '__tcfapi').callsFake((...args) => { + args[2](testConsentData, true); + }); + + await setConsentConfig(goodConfig); + expect(await runHook()).to.be.true; + + sinon.assert.notCalled(setCmpListenerIdSpy); + + setCmpListenerIdSpy.restore(); + }); + + it('should not set CMP listener ID when listenerId is undefined', async function () { + const testConsentData = { + tcString: 'abc12345234', + gdprApplies: true, + purposeOneTreatment: false, + eventStatus: 'tcloaded', + listenerId: undefined + }; + + // Create a spy that will be applied when tcfCmpEventManager is created + let setCmpListenerIdSpy = sinon.spy(tcfCmpEventManager, 'setCmpListenerId'); + + cmpStub = sinon.stub(window, '__tcfapi').callsFake((...args) => { + args[2](testConsentData, true); + }); + + await setConsentConfig(goodConfig); + expect(await runHook()).to.be.true; + + sinon.assert.notCalled(setCmpListenerIdSpy); + setCmpListenerIdSpy.restore(); + }); + + it('should set CMP listener ID when listenerId is 0 (valid listener ID)', async function () { + const testConsentData = { + tcString: 'abc12345234', + gdprApplies: true, + purposeOneTreatment: false, + eventStatus: 'tcloaded', + listenerId: 0 + }; + + // Create a spy that will be applied when tcfCmpEventManager is created + let setCmpListenerIdSpy = sinon.spy(tcfCmpEventManager, 'setCmpListenerId'); + + cmpStub = sinon.stub(window, '__tcfapi').callsFake((...args) => { + args[2](testConsentData, true); + }); + + await setConsentConfig(goodConfig); + expect(await runHook()).to.be.true; + + sinon.assert.calledOnce(setCmpListenerIdSpy); + sinon.assert.calledWith(setCmpListenerIdSpy, 0); + setCmpListenerIdSpy.restore(); + }); + + it('should set CMP API reference when CMP is found', async function () { + const testConsentData = { + tcString: 'abc12345234', + gdprApplies: true, + purposeOneTreatment: false, + eventStatus: 'tcloaded' + }; + + // Create a spy that will be applied when tcfCmpEventManager is created + let setCmpApiSpy = sinon.spy(tcfCmpEventManager, 'setCmpApi'); + + cmpStub = sinon.stub(window, '__tcfapi').callsFake((...args) => { + args[2](testConsentData, true); + }); + + await setConsentConfig(goodConfig); + expect(await runHook()).to.be.true; + + sinon.assert.calledOnce(setCmpApiSpy); + expect(setCmpApiSpy.getCall(0).args[0]).to.be.a('function'); + setCmpApiSpy.restore(); + }); }); }); }); diff --git a/test/spec/unit/core/adapterManager_spec.js b/test/spec/unit/core/adapterManager_spec.js index c8d49c15548..0be07704ed2 100644 --- a/test/spec/unit/core/adapterManager_spec.js +++ b/test/spec/unit/core/adapterManager_spec.js @@ -2615,6 +2615,7 @@ describe('adapterManager tests', function () { describe('gdpr consent module', function () { it('inserts gdprConsent object to bidRequest only when module was enabled', function () { + gdprDataHandler.enable(); gdprDataHandler.setConsentData({ consentString: 'abc123def456', consentRequired: true