From 2ccb9b7cd580e8d4d3b55ce8e0a985fac505c0e7 Mon Sep 17 00:00:00 2001 From: Anton Baranov Date: Fri, 12 Nov 2021 11:11:15 -0800 Subject: [PATCH 1/2] 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 8bec1fbcb22a8623b1024afd34481d52926da6dd Mon Sep 17 00:00:00 2001 From: Anton Baranov Date: Wed, 12 Jan 2022 12:49:43 -0800 Subject: [PATCH 2/2] 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();