From 03f12be5214853a68bff12c38dd4d408eb20b01c Mon Sep 17 00:00:00 2001 From: Anton Baranov Date: Fri, 12 Nov 2021 11:11:15 -0800 Subject: [PATCH 1/3] Yieldmo Synthetic Inventory Module: ad prefetch from the yieldmo ad server --- modules/yieldmoSyntheticInventoryModule.js | 319 ++++++++++- .../yieldmoSyntheticInventoryModule_spec.js | 517 ++++++++++++++++-- 2 files changed, 787 insertions(+), 49 deletions(-) diff --git a/modules/yieldmoSyntheticInventoryModule.js b/modules/yieldmoSyntheticInventoryModule.js index bca778a7b43..8412713dda6 100644 --- a/modules/yieldmoSyntheticInventoryModule.js +++ b/modules/yieldmoSyntheticInventoryModule.js @@ -1,26 +1,35 @@ import { config } from '../src/config.js'; -import { isGptPubadsDefined } from '../src/utils.js'; +import { isGptPubadsDefined, isFn } from '../src/utils.js'; +import strIncludes from 'core-js-pure/features/string/includes.js'; export const MODULE_NAME = 'Yieldmo Synthetic Inventory Module'; +export const AD_SERVER_ENDPOINT = 'https://ads.yieldmo.com/v002/t_ads/ads'; +export const AD_REQUEST_TYPE = 'GET'; +const USPAPI_VERSION = 1; + +let cmpVersion = 0; +let cmpResolved = false; export function init(config) { + checkSandbox(window); validateConfig(config); - if (!isGptPubadsDefined()) { - window.googletag = window.googletag || {}; - window.googletag.cmd = window.googletag.cmd || []; - } - - const googletag = window.googletag; - const containerName = 'ym_sim_container_' + config.placementId; - - googletag.cmd.push(() => { - if (window.document.body) { - googletagCmd(config, containerName, googletag); - } else { - window.document.addEventListener('DOMContentLoaded', () => googletagCmd(config, containerName, googletag)); + const consentData = () => { + const consentDataObj = {}; + return (api, result) => { + consentDataObj[api] = result; + if ('cmp' in consentDataObj && 'usp' in consentDataObj) { + if (!isGptPubadsDefined()) { + window.top.googletag = window.top.googletag || {}; + window.top.googletag.cmd = window.top.googletag.cmd || []; + } + getAd(`${AD_SERVER_ENDPOINT}?${serialize(collectData(config.placementId, consentDataObj))}`, config); + } } - }); + }; + const consentDataHandler = consentData(); + lookupIabConsent((a) => consentDataHandler('cmp', a), (e) => consentDataHandler('cmp', false)); + lookupUspConsent((a) => consentDataHandler('usp', a), (e) => consentDataHandler('usp', false)); } export function validateConfig(config) { @@ -32,10 +41,11 @@ export function validateConfig(config) { } } -function googletagCmd(config, containerName, googletag) { - const gamContainer = window.document.createElement('div'); +function googletagCmd(config, googletag) { + const gamContainer = window.top.document.createElement('div'); + const containerName = 'ym_sim_container_' + config.placementId; gamContainer.id = containerName; - window.document.body.appendChild(gamContainer); + window.top.document.body.appendChild(gamContainer); googletag.defineSlot(config.adUnitPath, [1, 1], containerName) .addService(googletag.pubads()) .setTargeting('ym_sim_p_id', config.placementId); @@ -43,4 +53,277 @@ function googletagCmd(config, containerName, googletag) { googletag.display(containerName); } +function collectData(placementId, consentDataObj) { + const timeStamp = new Date().getTime(); + const connection = window.navigator.connection || {}; + const description = Array.prototype.slice.call(document.getElementsByTagName('meta')) + .filter((meta) => meta.getAttribute('name') === 'description')[0]; + const pageDimensions = { + density: window.top.devicePixelRatio || 0, + height: window.top.screen.height || window.top.screen.availHeight || window.top.outerHeight || window.top.innerHeight || 481, + width: window.top.screen.width || window.top.screen.availWidth || window.top.outerWidth || window.top.innerWidth || 321, + }; + + return { + bust: timeStamp, + dnt: window.top.doNotTrack === '1' || window.top.navigator.doNotTrack === '1' || false, + pr: document.referrer || '', + _s: 1, + e: 4, + page_url: window.top.location.href, + p: placementId, + description: description ? description.content.substring(0, 1000) : '', + title: document.title, + scrd: pageDimensions.density, + h: pageDimensions.height, + w: pageDimensions.width, + pft: timeStamp, + ct: timeStamp, + connect: connection.effectiveType, + bwe: connection.downlink ? connection.downlink + 'Mb/sec' : '', + rtt: connection.rtt, + sd: connection.saveData, + us_privacy: (consentDataObj.usp && consentDataObj.usp.usPrivacy) || '', + cmp: (consentDataObj.cmp && consentDataObj.cmp.tcString) || '' + }; +} + +function serialize(dataObj) { + const str = []; + for (let p in dataObj) { + if (dataObj.hasOwnProperty(p) && (dataObj[p] || dataObj[p] === false)) { + str.push(encodeURIComponent(p) + '=' + encodeURIComponent(dataObj[p])); + } + } + return str.join('&'); +} + +function processResponse(response) { + let responseBody; + try { + responseBody = JSON.parse(response.responseText); + } catch (err) { + throw new Error(`${MODULE_NAME}: response body is not valid JSON`); + } + if (response.status !== 200 || !responseBody.data || !responseBody.data.length || !responseBody.data[0].ads || !responseBody.data[0].ads.length) { + throw new Error(`${MODULE_NAME}: NOAD`); + } + return responseBody; +} + +function getAd(url, config) { + const req = new XMLHttpRequest(); + req.open(AD_REQUEST_TYPE, url, true); + req.onload = (e) => { + const response = processResponse(e.target); + window.top.__ymAds = response; + const googletag = window.top.googletag; + googletag.cmd.push(() => { + if (window.top.document.body) { + googletagCmd(config, googletag); + } else { + window.top.document.addEventListener('DOMContentLoaded', () => googletagCmd(config, googletag)); + } + }); + }; + req.send(null); +} + +function checkSandbox(w) { + try { + return !w.top.document && w.top !== w && !w.frameElement; + } catch (e) { + throw new Error(`${MODULE_NAME}: module was placed in the sandbox iframe`); + } +} + +function lookupIabConsent(cmpSuccess, cmpError) { + function findCMP() { + let f = window; + let cmpFrame; + let cmpFunction; + + while (!cmpFrame) { + try { + if (isFn(f.__tcfapi)) { + cmpVersion = 2; + cmpFunction = f.__tcfapi; + cmpFrame = f; + continue; + } + } catch (e) { } + + try { + if (f.frames['__tcfapiLocator']) { + cmpVersion = 2; + cmpFrame = f; + continue; + } + } catch (e) { } + + if (f === window.top) break; + f = f.parent; + } + return { + cmpFrame, + cmpFunction + }; + } + + function cmpResponseCallback(tcfData, success) { + if (success) { + setTimeout(() => { + if (!cmpResolved) { + cmpSuccess(tcfData); + } + }, 3000); + if (tcfData.gdprApplies === false || tcfData.eventStatus === 'tcloaded' || tcfData.eventStatus === 'useractioncomplete') { + cmpSuccess(tcfData); + cmpResolved = true; + } + } else { + cmpError('CMP unable to register callback function. Please check CMP setup.'); + } + } + + let { cmpFrame, cmpFunction } = findCMP(); + + if (!cmpFrame) { + return cmpError('CMP not found.'); + } + + if (isFn(cmpFunction)) { + cmpFunction('addEventListener', cmpVersion, cmpResponseCallback); + } else { + callCmpWhileInIframe('addEventListener', cmpFrame, cmpResponseCallback); + } + + function callCmpWhileInIframe(commandName, cmpFrame, moduleCallback) { + let apiName = '__tcfapi'; + let callName = `${apiName}Call`; + let callId = Math.random() + ''; + let msg = { + [callName]: { + command: commandName, + version: cmpVersion, + parameter: undefined, + callId: callId + } + }; + + cmpFrame.postMessage(msg, '*'); + + window.addEventListener('message', readPostMessageResponse, false); + + function readPostMessageResponse(event) { + let cmpDataPkgName = `${apiName}Return`; + let json = (typeof event.data === 'string' && strIncludes(event.data, cmpDataPkgName)) ? JSON.parse(event.data) : event.data; + if (json[cmpDataPkgName] && json[cmpDataPkgName].callId) { + let payload = json[cmpDataPkgName]; + + if (payload.callId === callId) { + moduleCallback(payload.returnValue, payload.success); + } + } + } + } +} + +function lookupUspConsent(uspSuccess, uspError) { + function findUsp() { + let f = window; + let uspapiFrame; + let uspapiFunction; + + while (!uspapiFrame) { + try { + if (isFn(f.__uspapi)) { + uspapiFunction = f.__uspapi; + uspapiFrame = f; + continue; + } + } catch (e) {} + + try { + if (f.frames['__uspapiLocator']) { + uspapiFrame = f; + continue; + } + } catch (e) {} + if (f === window.top) break; + f = f.parent; + } + return { + uspapiFrame, + uspapiFunction, + }; + } + + function handleUspApiResponseCallbacks() { + const uspResponse = {}; + + function afterEach() { + if (uspResponse.usPrivacy) { + uspSuccess(uspResponse); + } else { + uspError('Unable to get USP consent string.'); + } + } + + return { + consentDataCallback: (consentResponse, success) => { + if (success && consentResponse.uspString) { + uspResponse.usPrivacy = consentResponse.uspString; + } + afterEach(); + }, + }; + } + + let callbackHandler = handleUspApiResponseCallbacks(); + let { uspapiFrame, uspapiFunction } = findUsp(); + + if (!uspapiFrame) { + return uspError('USP CMP not found.'); + } + + if (isFn(uspapiFunction)) { + uspapiFunction( + 'getUSPData', + USPAPI_VERSION, + callbackHandler.consentDataCallback + ); + } else { + callUspApiWhileInIframe( + 'getUSPData', + uspapiFrame, + callbackHandler.consentDataCallback + ); + } + + function callUspApiWhileInIframe(commandName, uspapiFrame, moduleCallback) { + let callId = Math.random() + ''; + let msg = { + __uspapiCall: { + command: commandName, + version: USPAPI_VERSION, + callId: callId, + }, + }; + + uspapiFrame.postMessage(msg, '*'); + + window.addEventListener('message', readPostMessageResponse, false); + + function readPostMessageResponse(event) { + const res = event && event.data && event.data.__uspapiReturn; + if (res && res.callId) { + if (res.callId === callId) { + moduleCallback(res.returnValue, res.success); + } + } + } + } +} + config.getConfig('yieldmo_synthetic_inventory', config => init(config.yieldmo_synthetic_inventory)); diff --git a/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js b/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js index 55b4e7255f7..120f896c078 100644 --- a/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js +++ b/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js @@ -11,7 +11,7 @@ const mockedYmConfig = { }; const setGoogletag = () => { - window.googletag = { + window.top.googletag = { cmd: [], defineSlot: sinon.stub(), addService: sinon.stub(), @@ -20,12 +20,25 @@ const setGoogletag = () => { enableServices: sinon.stub(), display: sinon.stub(), }; - window.googletag.defineSlot.returns(window.googletag); - window.googletag.addService.returns(window.googletag); - window.googletag.pubads.returns({getSlots: sinon.stub()}); - return window.googletag; + window.top.googletag.defineSlot.returns(window.top.googletag); + window.top.googletag.addService.returns(window.top.googletag); + window.top.googletag.pubads.returns({getSlots: sinon.stub()}); + return window.top.googletag; } +const getQuearyParamsFromUrl = (url) => + [...new URL(url).searchParams] + .reduce( + (agg, param) => { + const [key, value] = param; + + agg[key] = value; + + return agg; + }, + {} + ); + describe('Yieldmo Synthetic Inventory Module', function() { let config = Object.assign({}, mockedYmConfig); let googletagBkp; @@ -39,18 +52,12 @@ describe('Yieldmo Synthetic Inventory Module', function() { window.googletag = googletagBkp; }); - it('should be enabled with valid required params', function() { - expect(function () { - init(mockedYmConfig); - }).not.to.throw() - }); - it('should throw an error if placementId is missed', function() { const {placementId, ...config} = mockedYmConfig; expect(function () { validateConfig(config); - }).throw(`${MODULE_NAME}: placementId required`) + }).throw(`${MODULE_NAME}: placementId required`); }); it('should throw an error if adUnitPath is missed', function() { @@ -58,32 +65,480 @@ describe('Yieldmo Synthetic Inventory Module', function() { expect(function () { validateConfig(config); - }).throw(`${MODULE_NAME}: adUnitPath required`) + }).throw(`${MODULE_NAME}: adUnitPath required`); }); - it('should add correct googletag.cmd', function() { - const containerName = 'ym_sim_container_' + mockedYmConfig.placementId; - const gtag = setGoogletag(); + describe('getAd', () => { + let requestMock = { + open: sinon.stub(), + send: sinon.stub(), + }; + const originalXMLHttpRequest = window.XMLHttpRequest; + const originalConnection = window.navigator.connection; + let clock; + let adServerRequest; + let response; + const responseData = { + data: [{ + ads: [{ + foo: 'bar', + }] + }] + }; + + beforeEach(() => { + window.XMLHttpRequest = function FakeXMLHttpRequest() { + this.open = requestMock.open; + this.send = requestMock.send; + + adServerRequest = this; + }; + + response = { + target: { + responseText: JSON.stringify(responseData), + status: 200, + } + }; + + clock = sinon.useFakeTimers(); + Object.defineProperty(window.navigator, 'connection', { value: {}, writable: true }); + }); - init(mockedYmConfig); + afterEach(() => { + window.XMLHttpRequest = originalXMLHttpRequest; + + requestMock.open.resetBehavior(); + requestMock.open.resetHistory(); + requestMock.send.resetBehavior(); + requestMock.send.resetHistory(); + + adServerRequest = undefined; + + clock.restore(); + }); + + after(() => { + window.navigator.connection = originalConnection; + }); + + it('should open ad request to ad server', () => { + init(mockedYmConfig); + + const adServerHost = (new URL(requestMock.open.getCall(0).args[1])).host; + expect(adServerHost).to.be.equal('ads.yieldmo.com'); + }); + + it('should properly combine ad request query', () => { + const pageDimensions = { + density: window.top.devicePixelRatio || 0, + height: window.top.screen.height || window.screen.top.availHeight || window.top.outerHeight || window.top.innerHeight || 481, + width: window.top.screen.width || window.screen.top.availWidth || window.top.outerWidth || window.top.innerWidth || 321, + }; + + init(mockedYmConfig); - expect(gtag.cmd.length).to.equal(1); + const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); + + const timeStamp = queryParams.bust; + + expect(queryParams).to.deep.equal({ + _s: '1', + dnt: 'false', + e: '4', + h: `${pageDimensions.height}`, + p: mockedYmConfig.placementId, + page_url: window.top.location.href, + pr: window.top.location.href, + scrd: `${pageDimensions.density}`, + w: `${pageDimensions.width}`, + title: document.title, + }); + }); + + it('should send ad request to ad server', () => { + init(mockedYmConfig); + + expect(requestMock.send.calledOnceWith(null)).to.be.true; + }); + + it('should throw an error if can not parse response', () => { + response.target.responseText = undefined; + + init(mockedYmConfig); + + expect(() => adServerRequest.onload(response)).to.throw(); + }); + + it('should throw an error if status is not 200', () => { + response.target.status = 500; + + init(mockedYmConfig); + + expect(() => adServerRequest.onload(response)).to.throw(); + }); + + it('should throw an error if there is no data in response', () => { + response.target.responseText = '{}'; + + init(mockedYmConfig); + + expect(() => adServerRequest.onload(response)).to.throw(); + }); + + it('should throw an error if there is no ads in response data', () => { + response.target.responseText = '{ data: [{}] }'; + + init(mockedYmConfig); + + expect(() => adServerRequest.onload(response)).to.throw(); + }); + + it('should store ad response in window object', () => { + init(mockedYmConfig); + + adServerRequest.onload(response); + + expect(window.top.__ymAds).to.deep.equal(responseData); + }); + + it('should add correct googletag.cmd', function() { + const containerName = 'ym_sim_container_' + mockedYmConfig.placementId; + const gtag = setGoogletag(); + + init(mockedYmConfig); + + adServerRequest.onload(response); + + expect(gtag.cmd.length).to.equal(1); + + gtag.cmd[0](); + + expect(gtag.addService.getCall(0)).to.not.be.null; + expect(gtag.setTargeting.getCall(0)).to.not.be.null; + expect(gtag.setTargeting.getCall(0).args[0]).to.exist.and.to.equal('ym_sim_p_id'); + expect(gtag.setTargeting.getCall(0).args[1]).to.exist.and.to.equal(mockedYmConfig.placementId); + expect(gtag.defineSlot.getCall(0)).to.not.be.null; + expect(gtag.enableServices.getCall(0)).to.not.be.null; + expect(gtag.display.getCall(0)).to.not.be.null; + expect(gtag.display.getCall(0).args[0]).to.exist.and.to.equal(containerName); + expect(gtag.pubads.getCall(0)).to.not.be.null; + + const gamContainerEl = window.top.document.getElementById(containerName); + expect(gamContainerEl).to.not.be.null; + + gamContainerEl.parentNode.removeChild(gamContainerEl); + }); + }); + + describe('lookupIabConsent', () => { + const callId = Math.random(); + const cmpFunction = sinon.stub(); + const originalXMLHttpRequest = window.XMLHttpRequest; + let requestMock = { + open: sinon.stub(), + send: sinon.stub(), + }; + let clock; + let postMessageStub; + let mathRandomStub; + let addEventListenerStub; + + beforeEach(() => { + postMessageStub = sinon.stub(window, 'postMessage'); + mathRandomStub = sinon.stub(Math, 'random'); + addEventListenerStub = sinon.stub(window, 'addEventListener'); + + window.XMLHttpRequest = function FakeXMLHttpRequest() { + this.open = requestMock.open; + this.send = requestMock.send; + }; + + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + window.XMLHttpRequest = originalXMLHttpRequest; + + postMessageStub.restore(); + mathRandomStub.restore(); + addEventListenerStub.restore(); + + cmpFunction.resetBehavior(); + cmpFunction.resetHistory(); + + requestMock.open.resetBehavior(); + requestMock.open.resetHistory(); + requestMock.send.resetBehavior(); + requestMock.send.resetHistory(); + + clock.restore(); + }); + + it('should get cmp function', () => { + window.__tcfapi = cmpFunction; + + init(mockedYmConfig); + + window.__tcfapi = undefined; + + expect(cmpFunction.calledOnceWith('addEventListener', 2)).to.be.true; + }); + + it('should call api without cmp consent if can not get it', () => { + cmpFunction.callsFake((e, version, callback) => { + callback(undefined, false); + }); + + window.__tcfapi = cmpFunction; + + init(mockedYmConfig); + + window.__tcfapi = undefined; + + expect(requestMock.open.calledOnce).to.be.true; + }); + + it('should add cmp consent string to ad server request params if gdprApplies is false', () => { + const tcfData = { gdprApplies: false, tcString: 'testTcString' }; + + cmpFunction.callsFake((e, version, callback) => { + callback(tcfData, true); + }); + + window.__tcfapi = cmpFunction; + + init(mockedYmConfig); - gtag.cmd[0](); + window.__tcfapi = undefined; + + const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); + + expect(queryParams.cmp).to.be.equal(tcfData.tcString); + }); + + it('should add cmp consent string to ad server request params if eventStatus is tcloaded', () => { + const tcfData = { eventStatus: 'tcloaded', tcString: 'testTcString' }; + + cmpFunction.callsFake((e, version, callback) => { + callback(tcfData, true); + }); + + window.__tcfapi = cmpFunction; + + init(mockedYmConfig); + + window.__tcfapi = undefined; + + const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); + + expect(queryParams.cmp).to.be.equal(tcfData.tcString); + }); + + it('should add cmp consent string to ad server request params if eventStatus is useractioncomplete', () => { + const tcfData = { eventStatus: 'useractioncomplete', tcString: 'testTcString' }; + + cmpFunction.callsFake((e, version, callback) => { + callback(tcfData, true); + }); + + window.__tcfapi = cmpFunction; + + init(mockedYmConfig); + + window.__tcfapi = undefined; + + const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); + + expect(queryParams.cmp).to.be.equal(tcfData.tcString); + }); + + it('should post message if cmp consent is loaded from another iframe', () => { + window.frames['__tcfapiLocator'] = 'cmpframe'; + + init(mockedYmConfig); + + window.frames['__tcfapiLocator'] = undefined; + + expect(window.postMessage.callCount).to.be.equal(1); + }); + + it('should add event listener for message event if usp consent is loaded from another iframe', () => { + window.frames['__tcfapiLocator'] = 'cmpframe'; + + init(mockedYmConfig); + + window.frames['__tcfapiLocator'] = undefined; + + expect(window.addEventListener.calledOnceWith('message')).to.be.true; + }); + + it('should add cmp consent string to ad server request params when called from iframe', () => { + const callId = Math.random(); + const tcfData = { gdprApplies: false, tcString: 'testTcString' }; + const cmpEvent = { + data: { + __tcfapiReturn: { + callId: `${callId}`, + returnValue: tcfData, + success: true, + } + }, + }; + + mathRandomStub.returns(callId); + addEventListenerStub.callsFake( + (e, callback) => { + callback(cmpEvent) + } + ); + + window.frames['__tcfapiLocator'] = 'cmpframe'; + + init(mockedYmConfig); + + window.frames['__tcfapiLocator'] = undefined; + + const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); + + expect(queryParams.cmp).to.be.equal(tcfData.tcString); + }); + }); + + describe('lookupUspConsent', () => { + const callId = Math.random(); + const uspFunction = sinon.stub(); + const originalXMLHttpRequest = window.XMLHttpRequest; + let requestMock = { + open: sinon.stub(), + send: sinon.stub(), + }; + let clock; + let postMessageStub; + let mathRandomStub; + let addEventListenerStub; + + beforeEach(() => { + postMessageStub = sinon.stub(window, 'postMessage'); + mathRandomStub = sinon.stub(Math, 'random'); + addEventListenerStub = sinon.stub(window, 'addEventListener'); + + window.XMLHttpRequest = function FakeXMLHttpRequest() { + this.open = requestMock.open; + this.send = requestMock.send; + }; + + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + window.XMLHttpRequest = originalXMLHttpRequest; + + postMessageStub.restore(); + mathRandomStub.restore(); + addEventListenerStub.restore(); + + uspFunction.resetBehavior(); + uspFunction.resetHistory(); + + requestMock.open.resetBehavior(); + requestMock.open.resetHistory(); + requestMock.send.resetBehavior(); + requestMock.send.resetHistory(); + + clock.restore(); + }); + + it('should get cmp function', () => { + window.__uspapi = uspFunction; + + init(mockedYmConfig); + + window.__uspapi = undefined; + + expect(uspFunction.calledOnceWith('getUSPData', 1)).to.be.true; + }); + + it('should call api without usp consent if can not get it', () => { + uspFunction.callsFake((e, version, callback) => { + callback(undefined, false); + }); + + window.__uspapi = uspFunction; + + init(mockedYmConfig); + + window.__uspapi = undefined; + + expect(requestMock.open.calledOnce).to.be.true; + }); + + it('should add usp consent string to ad server request params', () => { + const uspData = { uspString: 'testUspString' }; + + uspFunction.callsFake((e, version, callback) => { + callback(uspData, true); + }); + + window.__uspapi = uspFunction; + + init(mockedYmConfig); + + window.__uspapi = undefined; + + const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); + + expect(queryParams.us_privacy).to.be.equal(uspData.uspString); + }); + + it('should post message if usp consent is loaded from another iframe', () => { + window.frames['__uspapiLocator'] = 'uspframe'; + + init(mockedYmConfig); + + window.frames['__uspapiLocator'] = undefined; + + expect(window.postMessage.callCount).to.be.equal(1); + }); + + it('should add event listener for message event if usp consent is loaded from another iframe', () => { + window.frames['__uspapiLocator'] = 'uspframe'; + + init(mockedYmConfig); + + window.frames['__uspapiLocator'] = undefined; + + expect(window.addEventListener.calledOnceWith('message')).to.be.true; + }); + + it('should add usp consent string to ad server request params when called from iframe', () => { + const uspData = { uspString: 'testUspString' }; + const uspEvent = { + data: { + __uspapiReturn: { + callId: `${callId}`, + returnValue: uspData, + success: true, + } + }, + }; + + mathRandomStub.returns(callId); + addEventListenerStub.callsFake( + (e, callback) => { + callback(uspEvent) + } + ); + + window.frames['__uspapiLocator'] = 'cmpframe'; + + init(mockedYmConfig); - expect(gtag.addService.getCall(0)).to.not.be.null; - expect(gtag.setTargeting.getCall(0)).to.not.be.null; - expect(gtag.setTargeting.getCall(0).args[0]).to.exist.and.to.equal('ym_sim_p_id'); - expect(gtag.setTargeting.getCall(0).args[1]).to.exist.and.to.equal(mockedYmConfig.placementId); - expect(gtag.defineSlot.getCall(0)).to.not.be.null; - expect(gtag.enableServices.getCall(0)).to.not.be.null; - expect(gtag.display.getCall(0)).to.not.be.null; - expect(gtag.display.getCall(0).args[0]).to.exist.and.to.equal(containerName); - expect(gtag.pubads.getCall(0)).to.not.be.null; + window.frames['__uspapiLocator'] = undefined; - const gamContainerEl = window.document.getElementById(containerName); - expect(gamContainerEl).to.not.be.null; + const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); - gamContainerEl.parentNode.removeChild(gamContainerEl); + expect(queryParams.us_privacy).to.be.equal(uspData.uspString); + }); }); }); From f7feefe5ad01893a049da956a00df82808d870e8 Mon Sep 17 00:00:00 2001 From: Anton Baranov Date: Wed, 12 Jan 2022 12:49:43 -0800 Subject: [PATCH 2/3] https://github.com/prebid/Prebid.js/pull/7803 fixes * getAd changed to inbuilt ajax method --- modules/yieldmoSyntheticInventoryModule.js | 73 ++++----- .../yieldmoSyntheticInventoryModule_spec.js | 146 ++++++++---------- 2 files changed, 96 insertions(+), 123 deletions(-) diff --git a/modules/yieldmoSyntheticInventoryModule.js b/modules/yieldmoSyntheticInventoryModule.js index 8412713dda6..cda90c2f578 100644 --- a/modules/yieldmoSyntheticInventoryModule.js +++ b/modules/yieldmoSyntheticInventoryModule.js @@ -1,10 +1,10 @@ import { config } from '../src/config.js'; import { isGptPubadsDefined, isFn } from '../src/utils.js'; +import * as ajax from '../src/ajax.js' import strIncludes from 'core-js-pure/features/string/includes.js'; export const MODULE_NAME = 'Yieldmo Synthetic Inventory Module'; export const AD_SERVER_ENDPOINT = 'https://ads.yieldmo.com/v002/t_ads/ads'; -export const AD_REQUEST_TYPE = 'GET'; const USPAPI_VERSION = 1; let cmpVersion = 0; @@ -23,7 +23,22 @@ export function init(config) { window.top.googletag = window.top.googletag || {}; window.top.googletag.cmd = window.top.googletag.cmd || []; } - getAd(`${AD_SERVER_ENDPOINT}?${serialize(collectData(config.placementId, consentDataObj))}`, config); + ajax.ajaxBuilder()(`${AD_SERVER_ENDPOINT}?${serialize(collectData(config.placementId, consentDataObj))}`, { + success: (responceText, responseObj) => { + window.top.__ymAds = processResponse(responseObj); + const googletag = window.top.googletag; + googletag.cmd.push(() => { + if (window.top.document.body) { + googletagCmd(config, googletag); + } else { + window.top.document.addEventListener('DOMContentLoaded', () => googletagCmd(config, googletag)); + } + }); + }, + error: (message, err) => { + throw err; + } + }); } } }; @@ -58,11 +73,6 @@ function collectData(placementId, consentDataObj) { const connection = window.navigator.connection || {}; const description = Array.prototype.slice.call(document.getElementsByTagName('meta')) .filter((meta) => meta.getAttribute('name') === 'description')[0]; - const pageDimensions = { - density: window.top.devicePixelRatio || 0, - height: window.top.screen.height || window.top.screen.availHeight || window.top.outerHeight || window.top.innerHeight || 481, - width: window.top.screen.width || window.top.screen.availWidth || window.top.outerWidth || window.top.innerWidth || 321, - }; return { bust: timeStamp, @@ -74,15 +84,15 @@ function collectData(placementId, consentDataObj) { p: placementId, description: description ? description.content.substring(0, 1000) : '', title: document.title, - scrd: pageDimensions.density, - h: pageDimensions.height, - w: pageDimensions.width, + scrd: window.top.devicePixelRatio || 0, + h: window.top.screen.height || window.top.screen.availHeight || window.top.outerHeight || window.top.innerHeight || 481, + w: window.top.screen.width || window.top.screen.availWidth || window.top.outerWidth || window.top.innerWidth || 321, pft: timeStamp, ct: timeStamp, - connect: connection.effectiveType, - bwe: connection.downlink ? connection.downlink + 'Mb/sec' : '', - rtt: connection.rtt, - sd: connection.saveData, + connect: typeof connection.effectiveType !== 'undefined' ? connection.effectiveType : undefined, + bwe: typeof connection.downlink !== 'undefined' ? connection.downlink + 'Mb/sec' : undefined, + rtt: typeof connection.rtt !== 'undefined' ? String(connection.rtt) : undefined, + sd: typeof connection.saveData !== 'undefined' ? String(connection.saveData) : undefined, us_privacy: (consentDataObj.usp && consentDataObj.usp.usPrivacy) || '', cmp: (consentDataObj.cmp && consentDataObj.cmp.tcString) || '' }; @@ -98,35 +108,20 @@ function serialize(dataObj) { return str.join('&'); } -function processResponse(response) { - let responseBody; +function processResponse(res) { + let parsedResponseBody; try { - responseBody = JSON.parse(response.responseText); + parsedResponseBody = JSON.parse(res.responseText); } catch (err) { - throw new Error(`${MODULE_NAME}: response body is not valid JSON`); + throw new Error(`${MODULE_NAME}: response is not valid JSON`); } - if (response.status !== 200 || !responseBody.data || !responseBody.data.length || !responseBody.data[0].ads || !responseBody.data[0].ads.length) { - throw new Error(`${MODULE_NAME}: NOAD`); + if (res && res.status === 204) { + throw new Error(`${MODULE_NAME}: no content success status`); } - return responseBody; -} - -function getAd(url, config) { - const req = new XMLHttpRequest(); - req.open(AD_REQUEST_TYPE, url, true); - req.onload = (e) => { - const response = processResponse(e.target); - window.top.__ymAds = response; - const googletag = window.top.googletag; - googletag.cmd.push(() => { - if (window.top.document.body) { - googletagCmd(config, googletag); - } else { - window.top.document.addEventListener('DOMContentLoaded', () => googletagCmd(config, googletag)); - } - }); - }; - req.send(null); + if (parsedResponseBody.data && parsedResponseBody.data.length && parsedResponseBody.data[0].error_code) { + throw new Error(`${MODULE_NAME}: no ad, error_code: ${parsedResponseBody.data[0].error_code}`); + } + return parsedResponseBody; } function checkSandbox(w) { diff --git a/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js b/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js index 120f896c078..e04f9f01388 100644 --- a/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js +++ b/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js @@ -1,4 +1,5 @@ import { expect } from 'chai'; +import * as ajax from 'src/ajax.js'; import { init, MODULE_NAME, @@ -68,16 +69,15 @@ describe('Yieldmo Synthetic Inventory Module', function() { }).throw(`${MODULE_NAME}: adUnitPath required`); }); - describe('getAd', () => { - let requestMock = { - open: sinon.stub(), - send: sinon.stub(), - }; - const originalXMLHttpRequest = window.XMLHttpRequest; - const originalConnection = window.navigator.connection; - let clock; - let adServerRequest; - let response; + describe('Ajax ad request', () => { + let sandbox; + + const setAjaxStub = (cb) => { + const ajaxStub = sandbox.stub().callsFake(cb); + sandbox.stub(ajax, 'ajaxBuilder').callsFake(() => ajaxStub); + return ajaxStub; + } + const responseData = { data: [{ ads: [{ @@ -87,118 +87,92 @@ describe('Yieldmo Synthetic Inventory Module', function() { }; beforeEach(() => { - window.XMLHttpRequest = function FakeXMLHttpRequest() { - this.open = requestMock.open; - this.send = requestMock.send; - - adServerRequest = this; - }; - - response = { - target: { - responseText: JSON.stringify(responseData), - status: 200, - } - }; - - clock = sinon.useFakeTimers(); - Object.defineProperty(window.navigator, 'connection', { value: {}, writable: true }); + sandbox = sinon.sandbox.create(); }); afterEach(() => { - window.XMLHttpRequest = originalXMLHttpRequest; - - requestMock.open.resetBehavior(); - requestMock.open.resetHistory(); - requestMock.send.resetBehavior(); - requestMock.send.resetHistory(); - - adServerRequest = undefined; - - clock.restore(); - }); - - after(() => { - window.navigator.connection = originalConnection; + sandbox.restore(); }); it('should open ad request to ad server', () => { + const ajaxStub = setAjaxStub((url, callbackObj) => {}); + init(mockedYmConfig); - const adServerHost = (new URL(requestMock.open.getCall(0).args[1])).host; - expect(adServerHost).to.be.equal('ads.yieldmo.com'); + expect((new URL(ajaxStub.getCall(0).args[0])).host).to.be.equal('ads.yieldmo.com'); }); it('should properly combine ad request query', () => { - const pageDimensions = { - density: window.top.devicePixelRatio || 0, - height: window.top.screen.height || window.screen.top.availHeight || window.top.outerHeight || window.top.innerHeight || 481, - width: window.top.screen.width || window.screen.top.availWidth || window.top.outerWidth || window.top.innerWidth || 321, - }; + const title = 'Test title value'; + const ajaxStub = setAjaxStub((url, callbackObj) => {}); + const documentStubTitle = sandbox.stub(document, 'title').value(title); + const connection = window.navigator.connection || {}; init(mockedYmConfig); - - const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); - + const queryParams = getQuearyParamsFromUrl(ajaxStub.getCall(0).args[0]); const timeStamp = queryParams.bust; - expect(queryParams).to.deep.equal({ + const paramsToCompare = { + title, _s: '1', dnt: 'false', e: '4', - h: `${pageDimensions.height}`, p: mockedYmConfig.placementId, page_url: window.top.location.href, pr: window.top.location.href, - scrd: `${pageDimensions.density}`, - w: `${pageDimensions.width}`, - title: document.title, - }); - }); - - it('should send ad request to ad server', () => { - init(mockedYmConfig); + bust: timeStamp, + pft: timeStamp, + ct: timeStamp, + connect: typeof connection.effectiveType !== 'undefined' ? connection.effectiveType : undefined, + bwe: typeof connection.downlink !== 'undefined' ? connection.downlink + 'Mb/sec' : undefined, + rtt: typeof connection.rtt !== 'undefined' ? String(connection.rtt) : undefined, + sd: typeof connection.saveData !== 'undefined' ? String(connection.saveData) : undefined, + scrd: String(window.top.devicePixelRatio || 0), + h: String(window.top.screen.height || window.screen.top.availHeight || window.top.outerHeight || window.top.innerHeight || 481), + w: String(window.top.screen.width || window.screen.top.availWidth || window.top.outerWidth || window.top.innerWidth || 321), + }; - expect(requestMock.send.calledOnceWith(null)).to.be.true; + expect(queryParams).to.eql(JSON.parse(JSON.stringify(paramsToCompare))); }); - it('should throw an error if can not parse response', () => { - response.target.responseText = undefined; + it('should send ad request to ad server', () => { + const ajaxStub = setAjaxStub((url, callbackObj) => {}); init(mockedYmConfig); - expect(() => adServerRequest.onload(response)).to.throw(); + expect(ajaxStub.calledOnce).to.be.true; }); - it('should throw an error if status is not 200', () => { - response.target.status = 500; - - init(mockedYmConfig); + it('should throw an error if can not parse response', () => { + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.success('', {responseText: '__invalid_JSON__', status: 200}); + }); - expect(() => adServerRequest.onload(response)).to.throw(); + expect(() => init(mockedYmConfig)).to.throw('Yieldmo Synthetic Inventory Module: response is not valid JSON'); }); - it('should throw an error if there is no data in response', () => { - response.target.responseText = '{}'; - - init(mockedYmConfig); + it('should throw an error if status is 204', () => { + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.success('', {status: 204, responseText: '{}'}); + }); - expect(() => adServerRequest.onload(response)).to.throw(); + expect(() => init(mockedYmConfig)).to.throw('Yieldmo Synthetic Inventory Module: no content success status'); }); - it('should throw an error if there is no ads in response data', () => { - response.target.responseText = '{ data: [{}] }'; - - init(mockedYmConfig); + it('should throw an error if error_code present in the ad response', () => { + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.success('', {status: 200, responseText: '{"data": [{"error_code": "NOAD"}]}'}); + }); - expect(() => adServerRequest.onload(response)).to.throw(); + expect(() => init(mockedYmConfig)).to.throw('Yieldmo Synthetic Inventory Module: no ad, error_code: NOAD'); }); it('should store ad response in window object', () => { - init(mockedYmConfig); - - adServerRequest.onload(response); + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.success(JSON.stringify(responseData), {status: 200, responseText: JSON.stringify(responseData)}); + }); + init(mockedYmConfig); expect(window.top.__ymAds).to.deep.equal(responseData); }); @@ -206,9 +180,11 @@ describe('Yieldmo Synthetic Inventory Module', function() { const containerName = 'ym_sim_container_' + mockedYmConfig.placementId; const gtag = setGoogletag(); - init(mockedYmConfig); + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.success(JSON.stringify(responseData), {status: 200, responseText: '{"data": [{"ads": []}]}'}); + }); - adServerRequest.onload(response); + init(mockedYmConfig); expect(gtag.cmd.length).to.equal(1); @@ -252,6 +228,7 @@ describe('Yieldmo Synthetic Inventory Module', function() { window.XMLHttpRequest = function FakeXMLHttpRequest() { this.open = requestMock.open; this.send = requestMock.send; + this.setRequestHeader = () => {}; }; clock = sinon.useFakeTimers(); @@ -426,6 +403,7 @@ describe('Yieldmo Synthetic Inventory Module', function() { window.XMLHttpRequest = function FakeXMLHttpRequest() { this.open = requestMock.open; this.send = requestMock.send; + this.setRequestHeader = () => {}; }; clock = sinon.useFakeTimers(); From 11ccd43b063f546a115203868e159779dc63cdc7 Mon Sep 17 00:00:00 2001 From: Anton Baranov Date: Mon, 11 Apr 2022 13:29:04 -0700 Subject: [PATCH 3/3] consentManagement promise refactoring --- modules/yieldmoSyntheticInventoryModule.js | 354 ++++-------- .../yieldmoSyntheticInventoryModule_spec.js | 516 +++++------------- 2 files changed, 248 insertions(+), 622 deletions(-) diff --git a/modules/yieldmoSyntheticInventoryModule.js b/modules/yieldmoSyntheticInventoryModule.js index cda90c2f578..2d2b0a3ee33 100644 --- a/modules/yieldmoSyntheticInventoryModule.js +++ b/modules/yieldmoSyntheticInventoryModule.js @@ -1,53 +1,74 @@ import { config } from '../src/config.js'; -import { isGptPubadsDefined, isFn } from '../src/utils.js'; +import { isGptPubadsDefined } from '../src/utils.js'; import * as ajax from '../src/ajax.js' -import strIncludes from 'core-js-pure/features/string/includes.js'; - -export const MODULE_NAME = 'Yieldmo Synthetic Inventory Module'; -export const AD_SERVER_ENDPOINT = 'https://ads.yieldmo.com/v002/t_ads/ads'; -const USPAPI_VERSION = 1; +import { gdprDataHandler, uspDataHandler } from '../src/adapterManager.js'; + +const MODULE_NAME = 'yieldmoSyntheticInventory'; +const AD_SERVER_ENDPOINT = 'https://ads.yieldmo.com/v002/t_ads/ads'; +const GET_CONFIG_TIMEOUT = 10; // might be 0, 10 just in case + +export const testExports = { + MODULE_NAME, + validateConfig, + setGoogleTag, + setAd, + getConsentData, + getConfigs, + processAdResponse, + getAd +}; + +function getConsentData() { + return new Promise((resolve) => { + Promise.allSettled([ + gdprDataHandler.promise, + uspDataHandler.promise + ]) + .then(([ cmp, usp ]) => { + resolve({ + cmp: cmp.value, + usp: usp.value + }); + }) + }); +} -let cmpVersion = 0; -let cmpResolved = false; +function setGoogleTag() { + if (!isGptPubadsDefined()) { + window.top.googletag = window.top.googletag || {}; + window.top.googletag.cmd = window.top.googletag.cmd || []; + } +} -export function init(config) { - checkSandbox(window); - validateConfig(config); +function setAd(config, ad) { + window.top.__ymAds = processAdResponse(ad); + const googletag = window.top.googletag; + googletag.cmd.push(() => { + if (window.top.document.body) { + googletagCmd(config, googletag); + } else { + window.top.document.addEventListener('DOMContentLoaded', () => googletagCmd(config, googletag)); + } + }); +} - const consentData = () => { - const consentDataObj = {}; - return (api, result) => { - consentDataObj[api] = result; - if ('cmp' in consentDataObj && 'usp' in consentDataObj) { - if (!isGptPubadsDefined()) { - window.top.googletag = window.top.googletag || {}; - window.top.googletag.cmd = window.top.googletag.cmd || []; - } - ajax.ajaxBuilder()(`${AD_SERVER_ENDPOINT}?${serialize(collectData(config.placementId, consentDataObj))}`, { - success: (responceText, responseObj) => { - window.top.__ymAds = processResponse(responseObj); - const googletag = window.top.googletag; - googletag.cmd.push(() => { - if (window.top.document.body) { - googletagCmd(config, googletag); - } else { - window.top.document.addEventListener('DOMContentLoaded', () => googletagCmd(config, googletag)); - } - }); - }, - error: (message, err) => { - throw err; - } - }); +function getAd(config, consentData) { + const url = `${AD_SERVER_ENDPOINT}?${serialize(collectData(config.placementId, consentData))}`; + return new Promise((resolve, reject) => + ajax.ajaxBuilder()(url, { + success: (responseText, responseObj) => { + resolve(responseObj); + }, + error: (message, err) => { + reject(new Error(`${MODULE_NAME}: ad server error: ${err.status}`)); } - } - }; - const consentDataHandler = consentData(); - lookupIabConsent((a) => consentDataHandler('cmp', a), (e) => consentDataHandler('cmp', false)); - lookupUspConsent((a) => consentDataHandler('usp', a), (e) => consentDataHandler('usp', false)); + })) + .catch(err => { + throw err; + }); } -export function validateConfig(config) { +function validateConfig(config) { if (!('placementId' in config)) { throw new Error(`${MODULE_NAME}: placementId required`); } @@ -93,8 +114,8 @@ function collectData(placementId, consentDataObj) { bwe: typeof connection.downlink !== 'undefined' ? connection.downlink + 'Mb/sec' : undefined, rtt: typeof connection.rtt !== 'undefined' ? String(connection.rtt) : undefined, sd: typeof connection.saveData !== 'undefined' ? String(connection.saveData) : undefined, - us_privacy: (consentDataObj.usp && consentDataObj.usp.usPrivacy) || '', - cmp: (consentDataObj.cmp && consentDataObj.cmp.tcString) || '' + us_privacy: consentDataObj.usp || '', + cmp: (consentDataObj.cmp && consentDataObj.cmp.consentString) || '' }; } @@ -108,15 +129,19 @@ function serialize(dataObj) { return str.join('&'); } -function processResponse(res) { +function processAdResponse(res) { + if (res.status >= 300) { + throw new Error(`${MODULE_NAME}: ad server error: ${res.status}`); + // 204 is a valid response, but we're throwing because it's always good to know + // probably something has been wrong configured (placementId / adUnitPath / userConsent ...) + } else if (res.status === 204) { + throw new Error(`${MODULE_NAME}: ${res.status} - no ad to serve`); + } let parsedResponseBody; try { parsedResponseBody = JSON.parse(res.responseText); } catch (err) { - throw new Error(`${MODULE_NAME}: response is not valid JSON`); - } - if (res && res.status === 204) { - throw new Error(`${MODULE_NAME}: no content success status`); + throw new Error(`${MODULE_NAME}: JSON validation error`); } if (parsedResponseBody.data && parsedResponseBody.data.length && parsedResponseBody.data[0].error_code) { throw new Error(`${MODULE_NAME}: no ad, error_code: ${parsedResponseBody.data[0].error_code}`); @@ -131,194 +156,47 @@ function checkSandbox(w) { throw new Error(`${MODULE_NAME}: module was placed in the sandbox iframe`); } } - -function lookupIabConsent(cmpSuccess, cmpError) { - function findCMP() { - let f = window; - let cmpFrame; - let cmpFunction; - - while (!cmpFrame) { - try { - if (isFn(f.__tcfapi)) { - cmpVersion = 2; - cmpFunction = f.__tcfapi; - cmpFrame = f; - continue; - } - } catch (e) { } - - try { - if (f.frames['__tcfapiLocator']) { - cmpVersion = 2; - cmpFrame = f; - continue; - } - } catch (e) { } - - if (f === window.top) break; - f = f.parent; - } - return { - cmpFrame, - cmpFunction - }; - } - - function cmpResponseCallback(tcfData, success) { - if (success) { - setTimeout(() => { - if (!cmpResolved) { - cmpSuccess(tcfData); - } - }, 3000); - if (tcfData.gdprApplies === false || tcfData.eventStatus === 'tcloaded' || tcfData.eventStatus === 'useractioncomplete') { - cmpSuccess(tcfData); - cmpResolved = true; - } - } else { - cmpError('CMP unable to register callback function. Please check CMP setup.'); - } - } - - let { cmpFrame, cmpFunction } = findCMP(); - - if (!cmpFrame) { - return cmpError('CMP not found.'); - } - - if (isFn(cmpFunction)) { - cmpFunction('addEventListener', cmpVersion, cmpResponseCallback); - } else { - callCmpWhileInIframe('addEventListener', cmpFrame, cmpResponseCallback); - } - - function callCmpWhileInIframe(commandName, cmpFrame, moduleCallback) { - let apiName = '__tcfapi'; - let callName = `${apiName}Call`; - let callId = Math.random() + ''; - let msg = { - [callName]: { - command: commandName, - version: cmpVersion, - parameter: undefined, - callId: callId - } - }; - - cmpFrame.postMessage(msg, '*'); - - window.addEventListener('message', readPostMessageResponse, false); - - function readPostMessageResponse(event) { - let cmpDataPkgName = `${apiName}Return`; - let json = (typeof event.data === 'string' && strIncludes(event.data, cmpDataPkgName)) ? JSON.parse(event.data) : event.data; - if (json[cmpDataPkgName] && json[cmpDataPkgName].callId) { - let payload = json[cmpDataPkgName]; - - if (payload.callId === callId) { - moduleCallback(payload.returnValue, payload.success); - } - } - } - } -} - -function lookupUspConsent(uspSuccess, uspError) { - function findUsp() { - let f = window; - let uspapiFrame; - let uspapiFunction; - - while (!uspapiFrame) { - try { - if (isFn(f.__uspapi)) { - uspapiFunction = f.__uspapi; - uspapiFrame = f; - continue; - } - } catch (e) {} - - try { - if (f.frames['__uspapiLocator']) { - uspapiFrame = f; - continue; - } - } catch (e) {} - if (f === window.top) break; - f = f.parent; - } - return { - uspapiFrame, - uspapiFunction, - }; - } - - function handleUspApiResponseCallbacks() { - const uspResponse = {}; - - function afterEach() { - if (uspResponse.usPrivacy) { - uspSuccess(uspResponse); - } else { - uspError('Unable to get USP consent string.'); - } - } - - return { - consentDataCallback: (consentResponse, success) => { - if (success && consentResponse.uspString) { - uspResponse.usPrivacy = consentResponse.uspString; - } - afterEach(); - }, - }; - } - - let callbackHandler = handleUspApiResponseCallbacks(); - let { uspapiFrame, uspapiFunction } = findUsp(); - - if (!uspapiFrame) { - return uspError('USP CMP not found.'); - } - - if (isFn(uspapiFunction)) { - uspapiFunction( - 'getUSPData', - USPAPI_VERSION, - callbackHandler.consentDataCallback - ); - } else { - callUspApiWhileInIframe( - 'getUSPData', - uspapiFrame, - callbackHandler.consentDataCallback - ); - } - - function callUspApiWhileInIframe(commandName, uspapiFrame, moduleCallback) { - let callId = Math.random() + ''; - let msg = { - __uspapiCall: { - command: commandName, - version: USPAPI_VERSION, - callId: callId, - }, - }; - - uspapiFrame.postMessage(msg, '*'); - - window.addEventListener('message', readPostMessageResponse, false); - - function readPostMessageResponse(event) { - const res = event && event.data && event.data.__uspapiReturn; - if (res && res.callId) { - if (res.callId === callId) { - moduleCallback(res.returnValue, res.success); - } - } - } - } +/** + * Configs will be available only next JS event loop iteration after calling config.getConfig, + * but... if user won't provide the configs, callback will never be executed + * because of that we're using promises for the code readability (to prevent callback hell), + * and setTimeout(__, 0) as a fallback in case configs wasn't provided... +*/ +function getConfigs() { + const promisifyGetConfig = configName => + new Promise((resolve) => + config.getConfig(configName, config => resolve(config))); + + const getConfigPromise = (moduleName) => { + let timer; + // Promise has a higher priority than callback, so it should be there first + return Promise.race([ + promisifyGetConfig(moduleName), + // will be rejected if config wasn't provided in GET_CONFIG_TIMEOUT ms + new Promise((resolve, reject) => timer = setTimeout(reject, + GET_CONFIG_TIMEOUT, + new Error(`${MODULE_NAME}: ${moduleName} was not configured`))) + ]).finally(() => + clearTimeout(timer)); + }; + // We're expecting to get both yieldmoSyntheticInventory + // and consentManagement configs, so if one of them configs will be rejected -- + // getConfigs will be rejected as well + return Promise.all([ + getConfigPromise('yieldmoSyntheticInventory'), + getConfigPromise('consentManagement'), + ]) } -config.getConfig('yieldmo_synthetic_inventory', config => init(config.yieldmo_synthetic_inventory)); +getConfigs() + .then(configs => { + const siConfig = configs[0].yieldmoSyntheticInventory; + validateConfig(siConfig); + checkSandbox(window); + setGoogleTag(); + getConsentData() + .then(consentData => + getAd(siConfig, consentData)) + .then(ad => + setAd(siConfig, ad)) + }) diff --git a/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js b/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js index e04f9f01388..8e3a9016ed8 100644 --- a/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js +++ b/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js @@ -1,10 +1,7 @@ import { expect } from 'chai'; import * as ajax from 'src/ajax.js'; -import { - init, - MODULE_NAME, - validateConfig -} from 'modules/yieldmoSyntheticInventoryModule'; +import { testExports } from 'modules/yieldmoSyntheticInventoryModule'; +import { config } from 'src/config.js'; const mockedYmConfig = { placementId: '123456', @@ -41,43 +38,69 @@ const getQuearyParamsFromUrl = (url) => ); describe('Yieldmo Synthetic Inventory Module', function() { - let config = Object.assign({}, mockedYmConfig); let googletagBkp; + let sandbox; beforeEach(function () { googletagBkp = window.googletag; delete window.googletag; + sandbox = sinon.sandbox.create(); }); afterEach(function () { window.googletag = googletagBkp; + sandbox.restore(); }); - it('should throw an error if placementId is missed', function() { - const {placementId, ...config} = mockedYmConfig; + describe('Module config initialization', () => { + it('getConfigs should call config.getConfig twice to get yieldmoSyntheticInventory and consentManagement configs', function() { + const getConfigStub = sandbox.stub(config, 'getConfig').returns({}); - expect(function () { - validateConfig(config); - }).throw(`${MODULE_NAME}: placementId required`); + return testExports.getConfigs() + .catch(() => { + expect(getConfigStub.calledWith('yieldmoSyntheticInventory')).to.equal(true); + expect(getConfigStub.calledWith('consentManagement')).to.equal(true); + }); + }); + + it('should throw an error if config.placementId is missing', function() { + const { placementId, ...rest } = mockedYmConfig; + + expect(function () { + testExports.validateConfig(rest); + }).throw(`${testExports.MODULE_NAME}: placementId required`); + }); + + it('should throw an error if config.adUnitPath is missing', function() { + const { adUnitPath, ...rest } = mockedYmConfig; + + expect(function () { + testExports.validateConfig(rest); + }).throw(`${testExports.MODULE_NAME}: adUnitPath required`); + }); }); - it('should throw an error if adUnitPath is missed', function() { - const {adUnitPath, ...config} = mockedYmConfig; + describe('getConsentData', () => { + it('should always resolves with object contained "cmp" and "usp" keys', () => { + const consentDataMock = { + cmp: null, + usp: null + }; - expect(function () { - validateConfig(config); - }).throw(`${MODULE_NAME}: adUnitPath required`); + return testExports.getConsentData() + .then(consentDataObj => + expect(consentDataObj).to.eql(consentDataMock)); + }); }); - describe('Ajax ad request', () => { + describe('Get ad', () => { let sandbox; const setAjaxStub = (cb) => { const ajaxStub = sandbox.stub().callsFake(cb); sandbox.stub(ajax, 'ajaxBuilder').callsFake(() => ajaxStub); return ajaxStub; - } - + }; const responseData = { data: [{ ads: [{ @@ -94,86 +117,89 @@ describe('Yieldmo Synthetic Inventory Module', function() { sandbox.restore(); }); - it('should open ad request to ad server', () => { - const ajaxStub = setAjaxStub((url, callbackObj) => {}); + it('should make ad request to ad server', () => { + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.success('', {responseText: '', status: 200}); + }); + + return testExports.getAd(mockedYmConfig, {cmp: null, usp: null}) + .then(res => { expect(ajaxStub.calledOnce).to.be.true }); + }); - init(mockedYmConfig); + it('should throw an error if server returns an error', () => { + const response = {status: 500}; + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.error('', { status: 500 }); + }); - expect((new URL(ajaxStub.getCall(0).args[0])).host).to.be.equal('ads.yieldmo.com'); + return testExports.getAd(mockedYmConfig, {cmp: null, usp: null}) + .catch(err => { + expect(err.message).to.be.equal(`${testExports.MODULE_NAME}: ad server error: ${response.status}`) + }); }); - it('should properly combine ad request query', () => { + it('should properly create ad request url', () => { const title = 'Test title value'; - const ajaxStub = setAjaxStub((url, callbackObj) => {}); + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.success('', {responseText: '', status: 200}); + }); const documentStubTitle = sandbox.stub(document, 'title').value(title); const connection = window.navigator.connection || {}; - init(mockedYmConfig); - const queryParams = getQuearyParamsFromUrl(ajaxStub.getCall(0).args[0]); - const timeStamp = queryParams.bust; - - const paramsToCompare = { - title, - _s: '1', - dnt: 'false', - e: '4', - p: mockedYmConfig.placementId, - page_url: window.top.location.href, - pr: window.top.location.href, - bust: timeStamp, - pft: timeStamp, - ct: timeStamp, - connect: typeof connection.effectiveType !== 'undefined' ? connection.effectiveType : undefined, - bwe: typeof connection.downlink !== 'undefined' ? connection.downlink + 'Mb/sec' : undefined, - rtt: typeof connection.rtt !== 'undefined' ? String(connection.rtt) : undefined, - sd: typeof connection.saveData !== 'undefined' ? String(connection.saveData) : undefined, - scrd: String(window.top.devicePixelRatio || 0), - h: String(window.top.screen.height || window.screen.top.availHeight || window.top.outerHeight || window.top.innerHeight || 481), - w: String(window.top.screen.width || window.screen.top.availWidth || window.top.outerWidth || window.top.innerWidth || 321), - }; - - expect(queryParams).to.eql(JSON.parse(JSON.stringify(paramsToCompare))); + return testExports.getAd(mockedYmConfig, {cmp: null, usp: null}) + .then(res => { + const queryParams = getQuearyParamsFromUrl(ajaxStub.getCall(0).args[0]); + const timeStamp = queryParams.bust; + + const paramsToCompare = { + title, + _s: '1', + dnt: 'false', + e: '4', + p: mockedYmConfig.placementId, + page_url: window.top.location.href, + pr: window.top.location.href, + bust: timeStamp, + pft: timeStamp, + ct: timeStamp, + connect: typeof connection.effectiveType !== 'undefined' ? connection.effectiveType : undefined, + bwe: typeof connection.downlink !== 'undefined' ? connection.downlink + 'Mb/sec' : undefined, + rtt: typeof connection.rtt !== 'undefined' ? String(connection.rtt) : undefined, + sd: typeof connection.saveData !== 'undefined' ? String(connection.saveData) : undefined, + scrd: String(window.top.devicePixelRatio || 0), + h: String(window.top.screen.height || window.screen.top.availHeight || window.top.outerHeight || window.top.innerHeight || 481), + w: String(window.top.screen.width || window.screen.top.availWidth || window.top.outerWidth || window.top.innerWidth || 321), + }; + + expect(queryParams).to.eql(JSON.parse(JSON.stringify(paramsToCompare))); + }) + .catch(err => { throw err; }); }); + }); - it('should send ad request to ad server', () => { - const ajaxStub = setAjaxStub((url, callbackObj) => {}); + describe('setAd', () => { + let sandbox; - init(mockedYmConfig); + const setAjaxStub = (cb) => { + const ajaxStub = sandbox.stub().callsFake(cb); + sandbox.stub(ajax, 'ajaxBuilder').callsFake(() => ajaxStub); + return ajaxStub; + } - expect(ajaxStub.calledOnce).to.be.true; + beforeEach(() => { + sandbox = sinon.sandbox.create(); }); - it('should throw an error if can not parse response', () => { - const ajaxStub = setAjaxStub((url, callbackObj) => { - callbackObj.success('', {responseText: '__invalid_JSON__', status: 200}); - }); - - expect(() => init(mockedYmConfig)).to.throw('Yieldmo Synthetic Inventory Module: response is not valid JSON'); + afterEach(() => { + sandbox.restore(); }); - it('should throw an error if status is 204', () => { - const ajaxStub = setAjaxStub((url, callbackObj) => { - callbackObj.success('', {status: 204, responseText: '{}'}); - }); - - expect(() => init(mockedYmConfig)).to.throw('Yieldmo Synthetic Inventory Module: no content success status'); - }); + it('should set window.top.googletag and window.top.googletag.cmd', () => { + expect(window.top.googletag).to.be.undefined; - it('should throw an error if error_code present in the ad response', () => { - const ajaxStub = setAjaxStub((url, callbackObj) => { - callbackObj.success('', {status: 200, responseText: '{"data": [{"error_code": "NOAD"}]}'}); - }); + testExports.setGoogleTag(); - expect(() => init(mockedYmConfig)).to.throw('Yieldmo Synthetic Inventory Module: no ad, error_code: NOAD'); - }); - - it('should store ad response in window object', () => { - const ajaxStub = setAjaxStub((url, callbackObj) => { - callbackObj.success(JSON.stringify(responseData), {status: 200, responseText: JSON.stringify(responseData)}); - }); - - init(mockedYmConfig); - expect(window.top.__ymAds).to.deep.equal(responseData); + expect(window.top.googletag).to.be.eql({cmd: []}); }); it('should add correct googletag.cmd', function() { @@ -184,7 +210,11 @@ describe('Yieldmo Synthetic Inventory Module', function() { callbackObj.success(JSON.stringify(responseData), {status: 200, responseText: '{"data": [{"ads": []}]}'}); }); - init(mockedYmConfig); + testExports.setAd(mockedYmConfig, { + responseText: `{ + "data": [] + }` + }); expect(gtag.cmd.length).to.equal(1); @@ -207,316 +237,34 @@ describe('Yieldmo Synthetic Inventory Module', function() { }); }); - describe('lookupIabConsent', () => { - const callId = Math.random(); - const cmpFunction = sinon.stub(); - const originalXMLHttpRequest = window.XMLHttpRequest; - let requestMock = { - open: sinon.stub(), - send: sinon.stub(), - }; - let clock; - let postMessageStub; - let mathRandomStub; - let addEventListenerStub; + describe('processAdResponse', () => { + it('should throw if ad response has 204 code', () => { + const response = { status: 204 } - beforeEach(() => { - postMessageStub = sinon.stub(window, 'postMessage'); - mathRandomStub = sinon.stub(Math, 'random'); - addEventListenerStub = sinon.stub(window, 'addEventListener'); - - window.XMLHttpRequest = function FakeXMLHttpRequest() { - this.open = requestMock.open; - this.send = requestMock.send; - this.setRequestHeader = () => {}; - }; - - clock = sinon.useFakeTimers(); + expect(() => testExports.processAdResponse(response)) + .to.throw(`${testExports.MODULE_NAME}: ${response.status} - no ad to serve`) }); - afterEach(() => { - window.XMLHttpRequest = originalXMLHttpRequest; + it('should throw if ad response has 204 code', () => { + const response = { status: 200, responseText: '__invalid_json__' } - postMessageStub.restore(); - mathRandomStub.restore(); - addEventListenerStub.restore(); - - cmpFunction.resetBehavior(); - cmpFunction.resetHistory(); - - requestMock.open.resetBehavior(); - requestMock.open.resetHistory(); - requestMock.send.resetBehavior(); - requestMock.send.resetHistory(); - - clock.restore(); - }); - - it('should get cmp function', () => { - window.__tcfapi = cmpFunction; - - init(mockedYmConfig); - - window.__tcfapi = undefined; - - expect(cmpFunction.calledOnceWith('addEventListener', 2)).to.be.true; + expect(() => testExports.processAdResponse(response)) + .to.throw(`${testExports.MODULE_NAME}: JSON validation error`) }); - it('should call api without cmp consent if can not get it', () => { - cmpFunction.callsFake((e, version, callback) => { - callback(undefined, false); - }); - - window.__tcfapi = cmpFunction; - - init(mockedYmConfig); - - window.__tcfapi = undefined; - - expect(requestMock.open.calledOnce).to.be.true; - }); - - it('should add cmp consent string to ad server request params if gdprApplies is false', () => { - const tcfData = { gdprApplies: false, tcString: 'testTcString' }; - - cmpFunction.callsFake((e, version, callback) => { - callback(tcfData, true); - }); - - window.__tcfapi = cmpFunction; - - init(mockedYmConfig); - - window.__tcfapi = undefined; - - const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); - - expect(queryParams.cmp).to.be.equal(tcfData.tcString); - }); - - it('should add cmp consent string to ad server request params if eventStatus is tcloaded', () => { - const tcfData = { eventStatus: 'tcloaded', tcString: 'testTcString' }; - - cmpFunction.callsFake((e, version, callback) => { - callback(tcfData, true); - }); - - window.__tcfapi = cmpFunction; - - init(mockedYmConfig); - - window.__tcfapi = undefined; - - const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); - - expect(queryParams.cmp).to.be.equal(tcfData.tcString); - }); - - it('should add cmp consent string to ad server request params if eventStatus is useractioncomplete', () => { - const tcfData = { eventStatus: 'useractioncomplete', tcString: 'testTcString' }; - - cmpFunction.callsFake((e, version, callback) => { - callback(tcfData, true); - }); - - window.__tcfapi = cmpFunction; - - init(mockedYmConfig); - - window.__tcfapi = undefined; - - const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); - - expect(queryParams.cmp).to.be.equal(tcfData.tcString); - }); - - it('should post message if cmp consent is loaded from another iframe', () => { - window.frames['__tcfapiLocator'] = 'cmpframe'; - - init(mockedYmConfig); - - window.frames['__tcfapiLocator'] = undefined; - - expect(window.postMessage.callCount).to.be.equal(1); - }); - - it('should add event listener for message event if usp consent is loaded from another iframe', () => { - window.frames['__tcfapiLocator'] = 'cmpframe'; - - init(mockedYmConfig); - - window.frames['__tcfapiLocator'] = undefined; - - expect(window.addEventListener.calledOnceWith('message')).to.be.true; - }); - - it('should add cmp consent string to ad server request params when called from iframe', () => { - const callId = Math.random(); - const tcfData = { gdprApplies: false, tcString: 'testTcString' }; - const cmpEvent = { - data: { - __tcfapiReturn: { - callId: `${callId}`, - returnValue: tcfData, - success: true, - } - }, - }; - - mathRandomStub.returns(callId); - addEventListenerStub.callsFake( - (e, callback) => { - callback(cmpEvent) - } - ); - - window.frames['__tcfapiLocator'] = 'cmpframe'; - - init(mockedYmConfig); - - window.frames['__tcfapiLocator'] = undefined; - - const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); - - expect(queryParams.cmp).to.be.equal(tcfData.tcString); - }); - }); - - describe('lookupUspConsent', () => { - const callId = Math.random(); - const uspFunction = sinon.stub(); - const originalXMLHttpRequest = window.XMLHttpRequest; - let requestMock = { - open: sinon.stub(), - send: sinon.stub(), - }; - let clock; - let postMessageStub; - let mathRandomStub; - let addEventListenerStub; - - beforeEach(() => { - postMessageStub = sinon.stub(window, 'postMessage'); - mathRandomStub = sinon.stub(Math, 'random'); - addEventListenerStub = sinon.stub(window, 'addEventListener'); - - window.XMLHttpRequest = function FakeXMLHttpRequest() { - this.open = requestMock.open; - this.send = requestMock.send; - this.setRequestHeader = () => {}; - }; - - clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - window.XMLHttpRequest = originalXMLHttpRequest; - - postMessageStub.restore(); - mathRandomStub.restore(); - addEventListenerStub.restore(); - - uspFunction.resetBehavior(); - uspFunction.resetHistory(); - - requestMock.open.resetBehavior(); - requestMock.open.resetHistory(); - requestMock.send.resetBehavior(); - requestMock.send.resetHistory(); - - clock.restore(); - }); - - it('should get cmp function', () => { - window.__uspapi = uspFunction; - - init(mockedYmConfig); - - window.__uspapi = undefined; - - expect(uspFunction.calledOnceWith('getUSPData', 1)).to.be.true; - }); - - it('should call api without usp consent if can not get it', () => { - uspFunction.callsFake((e, version, callback) => { - callback(undefined, false); - }); - - window.__uspapi = uspFunction; - - init(mockedYmConfig); - - window.__uspapi = undefined; - - expect(requestMock.open.calledOnce).to.be.true; - }); - - it('should add usp consent string to ad server request params', () => { - const uspData = { uspString: 'testUspString' }; - - uspFunction.callsFake((e, version, callback) => { - callback(uspData, true); - }); - - window.__uspapi = uspFunction; - - init(mockedYmConfig); - - window.__uspapi = undefined; - - const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); - - expect(queryParams.us_privacy).to.be.equal(uspData.uspString); - }); - - it('should post message if usp consent is loaded from another iframe', () => { - window.frames['__uspapiLocator'] = 'uspframe'; - - init(mockedYmConfig); - - window.frames['__uspapiLocator'] = undefined; - - expect(window.postMessage.callCount).to.be.equal(1); - }); - - it('should add event listener for message event if usp consent is loaded from another iframe', () => { - window.frames['__uspapiLocator'] = 'uspframe'; - - init(mockedYmConfig); - - window.frames['__uspapiLocator'] = undefined; - - expect(window.addEventListener.calledOnceWith('message')).to.be.true; - }); - - it('should add usp consent string to ad server request params when called from iframe', () => { - const uspData = { uspString: 'testUspString' }; - const uspEvent = { - data: { - __uspapiReturn: { - callId: `${callId}`, - returnValue: uspData, - success: true, - } - }, + it('should throw if ad response has error_code', () => { + const response = { + responseText: `{ + "data": [ + { + "error_code": "NOAD" + } + ] + }` }; - mathRandomStub.returns(callId); - addEventListenerStub.callsFake( - (e, callback) => { - callback(uspEvent) - } - ); - - window.frames['__uspapiLocator'] = 'cmpframe'; - - init(mockedYmConfig); - - window.frames['__uspapiLocator'] = undefined; - - const queryParams = getQuearyParamsFromUrl(requestMock.open.getCall(0).args[1]); - - expect(queryParams.us_privacy).to.be.equal(uspData.uspString); + expect(() => testExports.processAdResponse(response)) + .to.throw(`${testExports.MODULE_NAME}: no ad, error_code: ${JSON.parse(response.responseText).data[0].error_code}`) }); }); });