diff --git a/modules/.submodules.json b/modules/.submodules.json index 09063deea40..130e7e8817e 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -4,7 +4,8 @@ "id5IdSystem", "criteortusIdSystem", "parrableIdSystem", - "liveIntentIdSystem" + "liveIntentIdSystem", + "criteoIdSystem" ], "adpod": [ "freeWheelAdserverVideo", diff --git a/modules/criteoIdSystem.js b/modules/criteoIdSystem.js new file mode 100644 index 00000000000..fe89de5d341 --- /dev/null +++ b/modules/criteoIdSystem.js @@ -0,0 +1,132 @@ +/** + * This module adds Criteo Real Time User Sync to the User ID module + * The {@link module:modules/userId} module is required + * @module modules/criteoIdSystem + * @requires module:modules/userId + */ + +import * as utils from '../src/utils' +import * as ajax from '../src/ajax' +import * as urlLib from '../src/url' +import { getRefererInfo } from '../src/refererDetection' +import { submodule } from '../src/hook'; + +const bididStorageKey = 'cto_bidid'; +const bundleStorageKey = 'cto_bundle'; +const cookieWriteableKey = 'cto_test_cookie'; +const cookiesMaxAge = 13 * 30 * 24 * 60 * 60 * 1000; + +const pastDateString = new Date(0).toString(); +const expirationString = new Date(utils.timestamp() + cookiesMaxAge).toString(); + +function areCookiesWriteable() { + utils.setCookie(cookieWriteableKey, '1'); + const canWrite = utils.getCookie(cookieWriteableKey) === '1'; + utils.setCookie(cookieWriteableKey, '', pastDateString); + return canWrite; +} + +function extractProtocolHost (url, returnOnlyHost = false) { + const parsedUrl = urlLib.parse(url) + return returnOnlyHost + ? `${parsedUrl.hostname}` + : `${parsedUrl.protocol}://${parsedUrl.hostname}${parsedUrl.port ? ':' + parsedUrl.port : ''}/`; +} + +function getFromAllStorages(key) { + return utils.getCookie(key) || utils.getDataFromLocalStorage(key); +} + +function saveOnAllStorages(key, value) { + if (key && value) { + utils.setCookie(key, value, expirationString); + utils.setDataInLocalStorage(key, value); + } +} + +function deleteFromAllStorages(key) { + utils.setCookie(key, '', pastDateString); + utils.removeDataFromLocalStorage(key); +} + +function getCriteoDataFromAllStorages() { + return { + bundle: getFromAllStorages(bundleStorageKey), + bidId: getFromAllStorages(bididStorageKey), + } +} + +function buildCriteoUsersyncUrl(topUrl, domain, bundle, areCookiesWriteable, isPublishertagPresent) { + const url = 'https://gum.criteo.com/sid/json?origin=prebid' + + `${topUrl ? '&topUrl=' + encodeURIComponent(topUrl) : ''}` + + `${domain ? '&domain=' + encodeURIComponent(domain) : ''}` + + `${bundle ? '&bundle=' + encodeURIComponent(bundle) : ''}` + + `${areCookiesWriteable ? '&cw=1' : ''}` + + `${isPublishertagPresent ? '&pbt=1' : ''}` + + return url; +} + +function callCriteoUserSync(parsedCriteoData) { + const cw = areCookiesWriteable(); + const topUrl = extractProtocolHost(getRefererInfo().referer); + const domain = extractProtocolHost(document.location.href, true); + const isPublishertagPresent = typeof criteo_pubtag !== 'undefined'; // eslint-disable-line camelcase + + const url = buildCriteoUsersyncUrl( + topUrl, + domain, + parsedCriteoData.bundle, + cw, + isPublishertagPresent + ); + + ajax.ajaxBuilder()( + url, + response => { + const jsonResponse = JSON.parse(response); + if (jsonResponse.bidId) { + saveOnAllStorages(bididStorageKey, jsonResponse.bidId); + } else { + deleteFromAllStorages(bididStorageKey); + } + + if (jsonResponse.acwsUrl) { + const urlsToCall = typeof jsonResponse.acwsUrl === 'string' ? [jsonResponse.acwsUrl] : jsonResponse.acwsUrl; + urlsToCall.forEach(url => utils.triggerPixel(url)); + } else if (jsonResponse.bundle) { + saveOnAllStorages(bundleStorageKey, jsonResponse.bundle); + } + } + ); +} + +/** @type {Submodule} */ +export const criteoIdSubmodule = { + /** + * used to link submodule with config + * @type {string} + */ + name: 'criteo', + /** + * decode the stored id value for passing to bid requests + * @function + * @returns {{criteoId: string} | undefined} + */ + decode(bidId) { + return bidId; + }, + /** + * get the Criteo Id from local storages and initiate a new user sync + * @function + * @returns {{id: {criteoId: string} | undefined}}} + */ + getId() { + let localData = getCriteoDataFromAllStorages(); + callCriteoUserSync(localData); + + return { id: localData.bidId ? { criteoId: localData.bidId } : undefined } + } +}; + +submodule('userId', criteoIdSubmodule); diff --git a/test/spec/modules/criteoIdSystem_spec.js b/test/spec/modules/criteoIdSystem_spec.js new file mode 100644 index 00000000000..44decfd56b6 --- /dev/null +++ b/test/spec/modules/criteoIdSystem_spec.js @@ -0,0 +1,135 @@ +import { criteoIdSubmodule } from 'modules/criteoIdSystem'; +import * as utils from 'src/utils'; +import * as ajaxLib from 'src/ajax'; +import * as urlLib from 'src/url'; + +const pastDateString = new Date(0).toString() + +function mockResponse(responseText, fakeResponse = (url, callback) => callback(responseText)) { + return function() { + return fakeResponse; + } +} + +describe('CriteoId module', function () { + const cookiesMaxAge = 13 * 30 * 24 * 60 * 60 * 1000; + + const nowTimestamp = new Date().getTime(); + + let getCookieStub; + let setCookieStub; + let getLocalStorageStub; + let setLocalStorageStub; + let removeFromLocalStorageStub; + let timeStampStub; + let parseUrlStub; + let ajaxBuilderStub; + let triggerPixelStub; + + beforeEach(function (done) { + getCookieStub = sinon.stub(utils, 'getCookie'); + setCookieStub = sinon.stub(utils, 'setCookie'); + getLocalStorageStub = sinon.stub(utils, 'getDataFromLocalStorage'); + setLocalStorageStub = sinon.stub(utils, 'setDataInLocalStorage'); + removeFromLocalStorageStub = sinon.stub(utils, 'removeDataFromLocalStorage'); + timeStampStub = sinon.stub(utils, 'timestamp').returns(nowTimestamp); + ajaxBuilderStub = sinon.stub(ajaxLib, 'ajaxBuilder').callsFake(mockResponse('{}')); + parseUrlStub = sinon.stub(urlLib, 'parse').returns({protocol: 'https', hostname: 'testdev.com'}) + triggerPixelStub = sinon.stub(utils, 'triggerPixel'); + done(); + }); + + afterEach(function () { + getCookieStub.restore(); + setCookieStub.restore(); + getLocalStorageStub.restore(); + setLocalStorageStub.restore(); + removeFromLocalStorageStub.restore(); + timeStampStub.restore(); + ajaxBuilderStub.restore(); + triggerPixelStub.restore(); + parseUrlStub.restore(); + }); + + const storageTestCases = [ + { cookie: 'bidId', localStorage: 'bidId2', expected: 'bidId' }, + { cookie: 'bidId', localStorage: undefined, expected: 'bidId' }, + { cookie: undefined, localStorage: 'bidId', expected: 'bidId' }, + { cookie: undefined, localStorage: undefined, expected: undefined }, + ] + + storageTestCases.forEach(testCase => it('getId() should return the bidId when it exists in local storages', function () { + getCookieStub.withArgs('cto_bidid').returns(testCase.cookie); + getLocalStorageStub.withArgs('cto_bidid').returns(testCase.localStorage); + + const id = criteoIdSubmodule.getId(); + expect(id).to.be.deep.equal({id: testCase.expected ? { criteoId: testCase.expected } : undefined}); + })) + + it('decode() should return the bidId when it exists in local storages', function () { + const id = criteoIdSubmodule.decode('testDecode'); + expect(id).to.equal('testDecode') + }); + + it('should call user sync url with the right params', function () { + getCookieStub.withArgs('cto_test_cookie').returns('1'); + getCookieStub.withArgs('cto_bundle').returns('bundle'); + window.criteo_pubtag = {} + + const emptyObj = '{}'; + let ajaxStub = sinon.stub().callsFake((url, callback) => callback(emptyObj)); + ajaxBuilderStub.callsFake(mockResponse(undefined, ajaxStub)) + + criteoIdSubmodule.getId(); + const expectedUrl = `https://gum.criteo.com/sid/json?origin=prebid&topUrl=https%3A%2F%2Ftestdev.com%2F&domain=testdev.com&bundle=bundle&cw=1&pbt=1`; + + expect(ajaxStub.calledWith(expectedUrl)).to.be.true; + + window.criteo_pubtag = undefined; + }); + + const responses = [ + { bundle: 'bundle', bidId: 'bidId', acwsUrl: 'acwsUrl' }, + { bundle: 'bundle', bidId: undefined, acwsUrl: 'acwsUrl' }, + { bundle: 'bundle', bidId: 'bidId', acwsUrl: undefined }, + { bundle: undefined, bidId: 'bidId', acwsUrl: 'acwsUrl' }, + { bundle: 'bundle', bidId: undefined, acwsUrl: undefined }, + { bundle: undefined, bidId: 'bidId', acwsUrl: undefined }, + { bundle: undefined, bidId: undefined, acwsUrl: 'acwsUrl' }, + { bundle: undefined, bidId: undefined, acwsUrl: ['acwsUrl', 'acwsUrl2'] }, + { bundle: undefined, bidId: undefined, acwsUrl: undefined }, + ] + + responses.forEach(response => describe('test user sync response behavior', function () { + const expirationTs = new Date(nowTimestamp + cookiesMaxAge).toString(); + + beforeEach(function (done) { + const fakeResponse = (url, callback) => { + callback(JSON.stringify(response)); + setTimeout(done, 0); + } + ajaxBuilderStub.callsFake(mockResponse(undefined, fakeResponse)); + criteoIdSubmodule.getId(); + }) + + it('should save bidId if it exists', function () { + if (response.acwsUrl) { + expect(triggerPixelStub.called).to.be.true; + expect(setCookieStub.calledWith('cto_bundle')).to.be.false; + expect(setLocalStorageStub.calledWith('cto_bundle')).to.be.false; + } else if (response.bundle) { + expect(setCookieStub.calledWith('cto_bundle', response.bundle, expirationTs)).to.be.true; + expect(setLocalStorageStub.calledWith('cto_bundle', response.bundle)).to.be.true; + expect(triggerPixelStub.called).to.be.false; + } + + if (response.bidId) { + expect(setCookieStub.calledWith('cto_bidid', response.bidId, expirationTs)).to.be.true; + expect(setLocalStorageStub.calledWith('cto_bidid', response.bidId)).to.be.true; + } else { + expect(setCookieStub.calledWith('cto_bidid', '', pastDateString)).to.be.true; + expect(removeFromLocalStorageStub.calledWith('cto_bidid')).to.be.true; + } + }); + })); +});