diff --git a/modules/yieldmoSyntheticInventoryModule.js b/modules/yieldmoSyntheticInventoryModule.js index bca778a7b43..2d2b0a3ee33 100644 --- a/modules/yieldmoSyntheticInventoryModule.js +++ b/modules/yieldmoSyntheticInventoryModule.js @@ -1,29 +1,74 @@ import { config } from '../src/config.js'; import { isGptPubadsDefined } from '../src/utils.js'; +import * as ajax from '../src/ajax.js' +import { gdprDataHandler, uspDataHandler } from '../src/adapterManager.js'; -export const MODULE_NAME = 'Yieldmo Synthetic Inventory Module'; +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 function init(config) { - validateConfig(config); +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 + }); + }) + }); +} + +function setGoogleTag() { if (!isGptPubadsDefined()) { - window.googletag = window.googletag || {}; - window.googletag.cmd = window.googletag.cmd || []; + window.top.googletag = window.top.googletag || {}; + window.top.googletag.cmd = window.top.googletag.cmd || []; } +} - const googletag = window.googletag; - const containerName = 'ym_sim_container_' + config.placementId; - +function setAd(config, ad) { + window.top.__ymAds = processAdResponse(ad); + const googletag = window.top.googletag; googletag.cmd.push(() => { - if (window.document.body) { - googletagCmd(config, containerName, googletag); + if (window.top.document.body) { + googletagCmd(config, googletag); } else { - window.document.addEventListener('DOMContentLoaded', () => googletagCmd(config, containerName, googletag)); + window.top.document.addEventListener('DOMContentLoaded', () => googletagCmd(config, googletag)); } }); } -export function validateConfig(config) { +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}`)); + } + })) + .catch(err => { + throw err; + }); +} + +function validateConfig(config) { if (!('placementId' in config)) { throw new Error(`${MODULE_NAME}: placementId required`); } @@ -32,10 +77,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 +89,114 @@ function googletagCmd(config, containerName, googletag) { googletag.display(containerName); } -config.getConfig('yieldmo_synthetic_inventory', config => init(config.yieldmo_synthetic_inventory)); +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]; + + 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: 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: 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 || '', + cmp: (consentDataObj.cmp && consentDataObj.cmp.consentString) || '' + }; +} + +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 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}: 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}`); + } + return parsedResponseBody; +} + +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`); + } +} +/** + * 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'), + ]) +} + +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 55b4e7255f7..8e3a9016ed8 100644 --- a/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js +++ b/test/spec/modules/yieldmoSyntheticInventoryModule_spec.js @@ -1,9 +1,7 @@ import { expect } from 'chai'; -import { - init, - MODULE_NAME, - validateConfig -} from 'modules/yieldmoSyntheticInventoryModule'; +import * as ajax from 'src/ajax.js'; +import { testExports } from 'modules/yieldmoSyntheticInventoryModule'; +import { config } from 'src/config.js'; const mockedYmConfig = { placementId: '123456', @@ -11,7 +9,7 @@ const mockedYmConfig = { }; const setGoogletag = () => { - window.googletag = { + window.top.googletag = { cmd: [], defineSlot: sinon.stub(), addService: sinon.stub(), @@ -20,70 +18,253 @@ 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; + let sandbox; beforeEach(function () { googletagBkp = window.googletag; delete window.googletag; + sandbox = sinon.sandbox.create(); }); afterEach(function () { window.googletag = googletagBkp; + sandbox.restore(); }); - it('should be enabled with valid required params', function() { - expect(function () { - init(mockedYmConfig); - }).not.to.throw() + 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({}); + + 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 placementId is missed', function() { - const {placementId, ...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}: placementId required`) + return testExports.getConsentData() + .then(consentDataObj => + expect(consentDataObj).to.eql(consentDataMock)); + }); }); - it('should throw an error if adUnitPath is missed', function() { - const {adUnitPath, ...config} = mockedYmConfig; + 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: [{ + foo: 'bar', + }] + }] + }; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + 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 }); + }); + + it('should throw an error if server returns an error', () => { + const response = {status: 500}; + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.error('', { status: 500 }); + }); + + 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 create ad request url', () => { + const title = 'Test title value'; + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.success('', {responseText: '', status: 200}); + }); + const documentStubTitle = sandbox.stub(document, 'title').value(title); + const connection = window.navigator.connection || {}; + + return testExports.getAd(mockedYmConfig, {cmp: null, usp: null}) + .then(res => { + const queryParams = getQuearyParamsFromUrl(ajaxStub.getCall(0).args[0]); + const timeStamp = queryParams.bust; - expect(function () { - validateConfig(config); - }).throw(`${MODULE_NAME}: adUnitPath required`) + 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 add correct googletag.cmd', function() { - const containerName = 'ym_sim_container_' + mockedYmConfig.placementId; - const gtag = setGoogletag(); + describe('setAd', () => { + let sandbox; + + const setAjaxStub = (cb) => { + const ajaxStub = sandbox.stub().callsFake(cb); + sandbox.stub(ajax, 'ajaxBuilder').callsFake(() => ajaxStub); + return ajaxStub; + } + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should set window.top.googletag and window.top.googletag.cmd', () => { + expect(window.top.googletag).to.be.undefined; + + testExports.setGoogleTag(); + + expect(window.top.googletag).to.be.eql({cmd: []}); + }); + + it('should add correct googletag.cmd', function() { + const containerName = 'ym_sim_container_' + mockedYmConfig.placementId; + const gtag = setGoogletag(); + + const ajaxStub = setAjaxStub((url, callbackObj) => { + callbackObj.success(JSON.stringify(responseData), {status: 200, responseText: '{"data": [{"ads": []}]}'}); + }); + + testExports.setAd(mockedYmConfig, { + responseText: `{ + "data": [] + }` + }); + + 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); + }); + }); - init(mockedYmConfig); + describe('processAdResponse', () => { + it('should throw if ad response has 204 code', () => { + const response = { status: 204 } - expect(gtag.cmd.length).to.equal(1); + expect(() => testExports.processAdResponse(response)) + .to.throw(`${testExports.MODULE_NAME}: ${response.status} - no ad to serve`) + }); - gtag.cmd[0](); + it('should throw if ad response has 204 code', () => { + const response = { status: 200, responseText: '__invalid_json__' } - 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; + expect(() => testExports.processAdResponse(response)) + .to.throw(`${testExports.MODULE_NAME}: JSON validation error`) + }); - const gamContainerEl = window.document.getElementById(containerName); - expect(gamContainerEl).to.not.be.null; + it('should throw if ad response has error_code', () => { + const response = { + responseText: `{ + "data": [ + { + "error_code": "NOAD" + } + ] + }` + }; - gamContainerEl.parentNode.removeChild(gamContainerEl); + expect(() => testExports.processAdResponse(response)) + .to.throw(`${testExports.MODULE_NAME}: no ad, error_code: ${JSON.parse(response.responseText).data[0].error_code}`) + }); }); });