diff --git a/.eslintrc b/.eslintrc index 8c3137f..256a3c4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -16,6 +16,7 @@ "before": true, "beforeEach": true, "after": true, + "afterEach": true, "uetq": true, "UET": true }, diff --git a/package.json b/package.json index 4a18560..9e5758c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "lint:fix": "eslint src/ test/src/ --fix", "test": "npm run build && npm run build:test && DEBUG=false karma start test/karma.config.js", "test:debug": "npm run build && npm run build:test && DEBUG=true karma start test/karma.config.js", - "watch": "ENVIRONMENT=production rollup --config rollup.config.js -w" + "watch": "ENVIRONMENT=production rollup --config rollup.config.js -w", + "watch:tests": "ENVIRONMENT=production rollup --config rollup.test.config.js -w" + }, "devDependencies": { "@semantic-release/changelog": "^5.0.1", diff --git a/src/BingAdsEventForwarder.js b/src/BingAdsEventForwarder.js index 5f9c4ec..9ae3575 100644 --- a/src/BingAdsEventForwarder.js +++ b/src/BingAdsEventForwarder.js @@ -27,18 +27,43 @@ var MessageType = { Commerce: 16, }; +var bingConsentValues = { Denied: 'denied', Granted: 'granted' }; +var bingConsentProperties = ['ad_storage']; +var bingToMpConsentSettingsMapping = { + ad_storage: 'defaultAdStorageConsentWeb', +}; + var constructor = function() { var self = this; var isInitialized = false; var forwarderSettings = null; var reportingService = null; + self.consentMappings = []; + self.consentPayloadAsString = ''; + self.consentPayloadDefaults = {}; + self.name = name; function initForwarder(settings, service, testMode) { forwarderSettings = settings; reportingService = service; + if (forwarderSettings.consentMappingWeb) { + self.consentMappings = parseSettingsString( + forwarderSettings.consentMappingWeb + ); + } + self.consentPayloadDefaults = getConsentSettings(forwarderSettings); + + var initialConsentPayload = cloneObject(self.consentPayloadDefaults); + var userConsentState = getUserConsentState(); + + var updatedConsentPayload = generateConsentPayload( + userConsentState, + self.consentMappings + ); + try { if (!testMode) { (function(window, document, tag, url, queue) { @@ -47,6 +72,7 @@ var constructor = function() { var i; (window[queue] = window[queue] || []), (window.uetq = window.uetq || []), + sendConsentDefaultToBing(initialConsentPayload), (f = function() { var obj = { ti: forwarderSettings.tagId, @@ -54,7 +80,10 @@ var constructor = function() { }; (obj.q = window[queue]), (window[queue] = new UET(obj)), - window[queue].push('pageLoad'); + maybeSendConsentUpdateToBing( + updatedConsentPayload + ); + window[queue].push('pageLoad'); }), (n = document.createElement(tag)), (n.src = url), @@ -128,10 +157,13 @@ var constructor = function() { "Can't log event on forwarder: " + name + ', not initialized' ); } - try { var obj = createUetObject(event, 'pageLoad'); + var eventConsentState = getEventConsentState(event.ConsentState); + + maybeSendConsentUpdateToBing(eventConsentState); + window.uetq.push(obj); } catch (e) { return "Can't log event on forwarder: " + name + ': ' + e; @@ -180,10 +212,123 @@ var constructor = function() { return obj; } + function getEventConsentState(eventConsentState) { + return eventConsentState && eventConsentState.getGDPRConsentState + ? eventConsentState.getGDPRConsentState() + : {}; + } + + function generateConsentPayload(consentState, mappings) { + if (!mappings) { + return {}; + } + + var payload = cloneObject(self.consentPayloadDefaults); + if (mappings && mappings.length > 0) { + for (var i = 0; i < mappings.length; i++) { + var mappingEntry = mappings[i]; + var mpMappedConsentName = mappingEntry.map.toLowerCase(); + var bingMappedConsentName = mappingEntry.value; + + if ( + consentState[mpMappedConsentName] && + bingConsentProperties.indexOf(bingMappedConsentName) !== -1 + ) { + payload[bingMappedConsentName] = consentState[ + mpMappedConsentName + ].Consented + ? bingConsentValues.Granted + : bingConsentValues.Denied; + } + } + } + + return payload; + } + + function maybeSendConsentUpdateToBing(consentState) { + if ( + self.consentPayloadAsString && + self.consentMappings && + !isEmpty(consentState) + ) { + var updatedConsentPayload = generateConsentPayload( + consentState, + self.consentMappings + ); + + var eventConsentAsString = JSON.stringify(updatedConsentPayload); + + if (eventConsentAsString !== self.consentPayloadAsString) { + window.uetq.push('consent', 'update', updatedConsentPayload); + self.consentPayloadAsString = JSON.stringify( + updatedConsentPayload + ); + } + } + } + + function sendConsentDefaultToBing(consentPayload) { + self.consentPayloadAsString = JSON.stringify(consentPayload); + + window.uetq.push('consent', 'default', consentPayload); + } + this.init = initForwarder; this.process = processEvent; }; +function getUserConsentState() { + var userConsentState = {}; + + if (mParticle.Identity && mParticle.Identity.getCurrentUser) { + var currentUser = mParticle.Identity.getCurrentUser(); + + if (!currentUser) { + return {}; + } + + var consentState = mParticle.Identity.getCurrentUser().getConsentState(); + + if (consentState && consentState.getGDPRConsentState) { + userConsentState = consentState.getGDPRConsentState(); + } + } + + return userConsentState; +} + +function getConsentSettings(settings) { + var consentSettings = {}; + + Object.keys(bingToMpConsentSettingsMapping).forEach(function( + bingConsentKey + ) { + var mpConsentSettingKey = + bingToMpConsentSettingsMapping[bingConsentKey]; + var bingConsentValuesKey = settings[mpConsentSettingKey]; + + // Microsoft recommends that for most countries, we should default to 'Granted' + // if a default value is not provided + // https://help.ads.microsoft.com/apex/index/3/en/60119 + if (bingConsentValuesKey && mpConsentSettingKey) { + consentSettings[bingConsentKey] = bingConsentValues[ + bingConsentValuesKey + ] + ? bingConsentValues[bingConsentValuesKey] + : bingConsentValues.Granted; + } else { + consentSettings[bingConsentKey] = bingConsentValues.Granted; + } + }); + + return consentSettings; +} + +function parseSettingsString(settingsString) { + return JSON.parse(settingsString.replace(/"/g, '"')); +} + function getId() { return moduleId; } @@ -228,6 +373,14 @@ if (typeof window !== 'undefined') { } } +function isEmpty(value) { + return value == null || !(Object.keys(value) || value).length; +} + +function cloneObject(obj) { + return JSON.parse(JSON.stringify(obj)); +} + module.exports = { register: register, }; diff --git a/test/src/tests.js b/test/src/tests.js index 931fd89..834d0f3 100644 --- a/test/src/tests.js +++ b/test/src/tests.js @@ -63,24 +63,58 @@ describe('Bing Ads Event Forwarder', function() { beforeEach(function() { reportService.reset(); window.uetq = []; - mParticle.forwarder.init( - { - tagId: 'tagId', - }, - reportService.cb, - true - ); }); describe('Init the BingAds SDK', function() { + beforeEach(function() { + mParticle.forwarder.init( + { + tagId: 'tagId', + }, + reportService.cb, + true + ); + }); + it('should init', function(done) { window.uetq.length.should.equal(0); done(); }); + + it('should init with a consent payload', function(done) { + mParticle.forwarder.init( + { + tagId: 'tagId', + }, + reportService.cb, + false // Disable testMode so we can test init + ); + + // UETQ queues up events as array elements and then parses them internally. + // The first 3 elements of this array will be the consent payload + window.uetq.length.should.eql(3); + window.uetq[0].should.equal('consent'); + window.uetq[1].should.equal('default'); + window.uetq[2].should.eql({ + ad_storage: 'granted', + }); + + done(); + }); }); describe('Track Events', function() { + beforeEach(function() { + mParticle.forwarder.init( + { + tagId: 'tagId', + }, + reportService.cb, + true + ); + }); + it('should log events', function(done) { var obj = { EventDataType: MessageType.PageEvent, @@ -139,4 +173,523 @@ describe('Bing Ads Event Forwarder', function() { done(); }); }); + + describe('Consent', function() { + var consentMap = [ + { + jsmap: null, + map: 'marketing_consent', + maptype: 'ConsentPurposes', + value: 'ad_storage', + }, + ]; + + beforeEach(function() { + mParticle.forwarders = []; + }); + + afterEach(function() { + window.uetq = []; + }); + + it('should consent information to window.uetq', function(done) { + mParticle.forwarder.init( + { + tagId: 'tagId', + }, + reportService.cb, + false // Disable testMode so we can test init + ); + + var obj = { + EventDataType: MessageType.PageEvent, + EventName: 'Test Page Event', + CustomFlags: { + 'Bing.EventValue': 10, + }, + ConsentState: { + getGDPRConsentState: function() { + return { + some_consent: { + Consented: true, + Timestamp: 1557935884509, + ConsentDocument: 'fake_consent_document', + Location: 'This is fake', + HardwareId: '123456', + }, + }; + }, + }, + }; + + var expectedConsentPayload = [ + 'consent', + 'update', + { ad_storage: 'granted' }, + ]; + + var expectedEventPayload = { + ea: 'pageLoad', + ec: 'This is my name!', + el: 'Test Page Event', + ev: 10, + }; + + mParticle.forwarder.process(obj); + + // UETQ queues up events as array elements and then parses them internally. + // The first 3 elements of this array will be the consent payload + // The 4th element will be the event payload + window.uetq.length.should.eql(4); + window.uetq[0].should.equal('consent'); + window.uetq[1].should.equal('default'); + window.uetq[2].should.eql(expectedConsentPayload[2]); + window.uetq[3].should.eql(expectedEventPayload); + + done(); + }); + + it('should construct a Default Consent State Payload of `granted` from Mappings when `defaultAdStorageConsentWeb` is undefined', function(done) { + mParticle.forwarder.init( + { + tagId: 'tagId', + consentMappingWeb: + '[{"jsmap":null,"map":"Marketing","maptype":"ConsentPurposes","value":"ad_storage"}]', + }, + reportService.cb, + false // Disable testMode so we can test init + ); + + var expectedConsentPayload = [ + 'consent', + 'default', + { ad_storage: 'granted' }, // Microsoft recommends defaulting to granted + ]; + + // UETQ queues up events as array elements and then parses them internally. + // The first 3 elements of this array will be the consent payload + window.uetq.length.should.eql(3); + window.uetq.should.eql(expectedConsentPayload); + + done(); + }); + + it('should construct a Default Consent State Payload from Default Settings and construct an Update Consent State Payload from Mappings', function(done) { + mParticle.forwarder.init( + { + tagId: 'tagId', + consentMappingWeb: JSON.stringify(consentMap), + defaultAdStorageConsentWeb: 'Denied', // Should be overridden by user consent state + }, + reportService.cb, + false // Disable testMode so we can test init + ); + + var expectedInitialConsentPayload = [ + 'consent', + 'default', + { ad_storage: 'denied' }, + ]; + + var expectedUpdatedConsentPayload = [ + 'consent', + 'update', + { ad_storage: 'granted' }, + ]; + + window.uetq.length.should.eql(3); + window.uetq.should.eql(expectedInitialConsentPayload); + + var obj = { + EventDataType: MessageType.PageEvent, + EventName: 'Test Page Event', + CustomFlags: { + 'Bing.EventValue': 10, + }, + ConsentState: { + getGDPRConsentState: function() { + return { + marketing_consent: { + Consented: true, + Timestamp: 1557935884509, + ConsentDocument: 'Marketing_Consent', + Location: 'This is fake', + HardwareId: '123456', + }, + }; + }, + + getCCPAConsentState: function() { + return { + data_sale_opt_out: { + Consented: false, + Timestamp: Date.now(), + Document: 'some_consent', + }, + }; + }, + }, + }; + + mParticle.forwarder.process(obj); + + // UETQ should now have 7 elements + // The first 3 elements of this array will be the consent payload + // The next 3 elments will be the consent update payload + // The 7th element will be the event payload + + window.uetq.length.should.eql(7); + window.uetq[3].should.equal('consent'); + window.uetq[4].should.equal('update'); + window.uetq[5].should.eql(expectedUpdatedConsentPayload[2]); + + done(); + }); + + it('should default to `granted` if Consent Settings are `Unspecified`', function(done) { + mParticle.forwarder.init( + { + tagId: 'tagId', + consentMappingWeb: JSON.stringify(consentMap), + defaultAdStorageConsentWeb: 'Unspecified', // Should be overridden by user consent state + }, + reportService.cb, + false // Disable testMode so we can test init + ); + + var expectedConsentPayload = [ + 'consent', + 'default', + { ad_storage: 'granted' }, + ]; + + window.uetq.length.should.eql(3); + window.uetq.should.eql(expectedConsentPayload); + done(); + }); + + it('should construct a Consent State Update Payload when consent changes and defaultAdStorageConsentWeb is undefined', function(done) { + mParticle.forwarder.init( + { + tagId: 'tagId', + consentMappingWeb: JSON.stringify(consentMap), + }, + reportService.cb, + false // Disable testMode so we can test init + ); + + var expectedInitialConsentPayload = [ + 'consent', + 'default', + { ad_storage: 'granted' }, + ]; + + var expectedUpdatedConsentPayload = [ + 'consent', + 'update', + { ad_storage: 'denied' }, + ]; + + // UETQ queues up events as array elements and then parses them internally. + // The first 3 elements of this array will be the consent payload + + window.uetq.length.should.eql(3); + window.uetq[0].should.equal('consent'); + window.uetq[1].should.equal('default'); + window.uetq[2].should.eql(expectedInitialConsentPayload[2]); + + var obj = { + EventDataType: MessageType.PageEvent, + EventName: 'Test Page Event', + CustomFlags: { + 'Bing.EventValue': 10, + }, + ConsentState: { + getGDPRConsentState: function() { + return { + marketing_consent: { + Consented: false, + Timestamp: 1557935884509, + ConsentDocument: 'Marketing_Consent', + Location: 'This is fake', + HardwareId: '123456', + }, + }; + }, + + getCCPAConsentState: function() { + return { + data_sale_opt_out: { + Consented: false, + Timestamp: Date.now(), + Document: 'some_consent', + }, + }; + }, + }, + }; + + mParticle.forwarder.process(obj); + + // UETQ should now have 7 elements + // The first 3 elements of this array will be the initial consent payload + // The next 3 elments will be the consent update payload + // The 7th element will be the event payload + + window.uetq.length.should.eql(7); + window.uetq[3].should.equal('consent'); + window.uetq[4].should.equal('update'); + window.uetq[5].should.eql(expectedUpdatedConsentPayload[2]); + + done(); + }); + + it('should construct a Consent State Update Payload when consent changes', function(done) { + mParticle.forwarder.init( + { + tagId: 'tagId', + consentMappingWeb: JSON.stringify(consentMap), + defaultAdStorageConsentWeb: 'Denied', + }, + reportService.cb, + false // Disable testMode so we can test init + ); + + var expectedInitialConsentPayload = [ + 'consent', + 'default', + { ad_storage: 'denied' }, + ]; + + var expectedUpdatedConsentPayload = [ + 'consent', + 'update', + { ad_storage: 'granted' }, + ]; + + // UETQ queues up events as array elements and then parses them internally. + // The first 3 elements of this array will be the consent payload + + window.uetq.length.should.eql(3); + window.uetq[0].should.equal('consent'); + window.uetq[1].should.equal('default'); + window.uetq[2].should.eql(expectedInitialConsentPayload[2]); + + var obj = { + EventDataType: MessageType.PageEvent, + EventName: 'Test Page Event', + CustomFlags: { + 'Bing.EventValue': 10, + }, + ConsentState: { + getGDPRConsentState: function() { + return { + marketing_consent: { + Consented: true, + Timestamp: 1557935884509, + ConsentDocument: 'Marketing_Consent', + Location: 'This is fake', + HardwareId: '123456', + }, + }; + }, + + getCCPAConsentState: function() { + return { + data_sale_opt_out: { + Consented: false, + Timestamp: Date.now(), + Document: 'some_consent', + }, + }; + }, + }, + }; + + mParticle.forwarder.process(obj); + + // UETQ should now have 7 elements + // The first 3 elements of this array will be the initial consent payload + // The next 3 elments will be the consent update payload + // The 7th element will be the event payload + + window.uetq.length.should.eql(7); + window.uetq[3].should.equal('consent'); + window.uetq[4].should.equal('update'); + window.uetq[5].should.eql(expectedUpdatedConsentPayload[2]); + + done(); + }); + + it('should NOT construct a Consent State Update Payload if consent DOES NOT change', function(done) { + mParticle.forwarder.init( + { + tagId: 'tagId', + consentMappingWeb: JSON.stringify(consentMap), + }, + reportService.cb, + false // Disable testMode so we can test init + ); + + var expectedInitialConsentPayload = [ + 'consent', + 'default', + { ad_storage: 'granted' }, + ]; + + // UETQ queues up events as array elements and then parses them internally. + // The first 3 elements of this array will be the consent payload + + window.uetq.length.should.eql(3); + window.uetq[0].should.equal('consent'); + window.uetq[1].should.equal('default'); + window.uetq[2].should.eql(expectedInitialConsentPayload[2]); + + var obj = { + EventDataType: MessageType.PageEvent, + EventName: 'Test Page Event', + CustomFlags: { + 'Bing.EventValue': 10, + }, + ConsentState: { + getGDPRConsentState: function() { + return { + marketing_consent: { + Consented: true, + Timestamp: 1557935884509, + ConsentDocument: 'Marketing_Consent', + Location: 'This is fake', + HardwareId: '123456', + }, + }; + }, + + getCCPAConsentState: function() { + return { + data_sale_opt_out: { + Consented: false, + Timestamp: Date.now(), + Document: 'some_consent', + }, + }; + }, + }, + }; + + mParticle.forwarder.process(obj); + + // UETQ should now have 4 elements + // The first 3 elements of this array will be the initial consent payload + // The 4th element will be the event payload + + window.uetq.length.should.eql(4); + window.uetq[3].should.eql({ + ea: 'pageLoad', + ec: 'This is my name!', + el: 'Test Page Event', + ev: 10, + }); + window.uetq.indexOf('update').should.equal(-1); + + done(); + }); + + it('should create a Consent State Default of Granted if consent mappings and settings are undefined', function(done) { + mParticle.forwarder.init( + { + tagId: 'tagId', + }, + reportService.cb, + false // Disable testMode so we can test init + ); + + var expectedInitialConsentPayload = [ + 'consent', + 'default', + { ad_storage: 'granted' }, + ]; + + // UETQ queues up events as array elements and then parses them internally. + // The first 3 elements of this array will be the consent payload + + window.uetq.length.should.eql(3); + window.uetq[0].should.equal('consent'); + window.uetq[1].should.equal('default'); + window.uetq[2].should.eql(expectedInitialConsentPayload[2]); + + done(); + }); + + it('should ONLY construct Default Consent State Payloads if consent mappings is undefined but settings defaults are defined and consent does not change', function(done) { + mParticle.forwarder.init( + { + tagId: 'tagId', + defaultAdStorageConsentWeb: 'Denied', + }, + reportService.cb, + false // Disable testMode so we can test init + ); + + var expectedInitialConsentPayload = [ + 'consent', + 'default', + { ad_storage: 'denied' }, + ]; + + // UETQ queues up events as array elements and then parses them internally. + // The first 3 elements of this array will be the consent payload + + window.uetq.length.should.eql(3); + window.uetq[0].should.equal('consent'); + window.uetq[1].should.equal('default'); + window.uetq[2].should.eql(expectedInitialConsentPayload[2]); + + var obj = { + EventDataType: MessageType.PageEvent, + EventName: 'Test Page Event', + CustomFlags: { + 'Bing.EventValue': 10, + }, + ConsentState: { + getGDPRConsentState: function() { + return { + marketing_consent: { + Consented: false, + Timestamp: 1557935884509, + ConsentDocument: 'Marketing_Consent', + Location: 'This is fake', + HardwareId: '123456', + }, + }; + }, + + getCCPAConsentState: function() { + return { + data_sale_opt_out: { + Consented: false, + Timestamp: Date.now(), + Document: 'some_consent', + }, + }; + }, + }, + }; + + mParticle.forwarder.process(obj); + + // UETQ should now have 4 elements + // The first 3 elements of this array will be the initial consent payload + // The 4th element will be the event payload + + window.uetq.length.should.eql(4); + window.uetq[3].should.eql({ + ea: 'pageLoad', + ec: 'This is my name!', + el: 'Test Page Event', + ev: 10, + }); + + done(); + }); + }); });