From 3423e7b12233a55e5b0382c03a657e86b6756932 Mon Sep 17 00:00:00 2001 From: Samuel Adu Date: Wed, 21 Oct 2020 10:24:27 +0100 Subject: [PATCH] Verizon Media user id module (#5786) * Initial work on Verizon Media User ID module * Submodule tests * Add sample eid object for Verizon Media * Documentation update * Switch to HTTP GET, update tests. * Remove single test restriction. * Documentation update * Addressing initial PR feedback. * Accept pixelId parameter to construct VMUID URL * Fix tests following API signature change * Add IAB vendor ID Co-authored-by: slimkrazy --- modules/.submodules.json | 3 +- modules/userId/eids.js | 6 + modules/userId/eids.md | 12 ++ modules/verizonMediaIdSystem.js | 103 ++++++++++ modules/verizonMediaSystemId.md | 33 ++++ .../spec/modules/verizonMediaIdSystem_spec.js | 182 ++++++++++++++++++ 6 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 modules/verizonMediaIdSystem.js create mode 100644 modules/verizonMediaSystemId.md create mode 100644 test/spec/modules/verizonMediaIdSystem_spec.js diff --git a/modules/.submodules.json b/modules/.submodules.json index 78fd9376dd1..9b523a0c73a 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -17,7 +17,8 @@ "haloIdSystem", "quantcastIdSystem", "idxIdSystem", - "fabrickIdSystem" + "fabrickIdSystem", + "verizonMediaIdSystem" ], "adpod": [ "freeWheelAdserverVideo", diff --git a/modules/userId/eids.js b/modules/userId/eids.js index f6c58a5a0bf..8118607fbde 100644 --- a/modules/userId/eids.js +++ b/modules/userId/eids.js @@ -157,6 +157,12 @@ const USER_IDS_CONFIG = { source: 'idx.lat', atype: 1 }, + + // Verizon Media + 'vmuid': { + source: 'verizonmedia.com', + atype: 1 + } }; // this function will create an eid object for the given UserId sub-module diff --git a/modules/userId/eids.md b/modules/userId/eids.md index 7dc149cd47a..3e51eff3165 100644 --- a/modules/userId/eids.md +++ b/modules/userId/eids.md @@ -90,6 +90,7 @@ userIdAsEids = [ atype: 1 }] }, + { source: 'sharedid.org', uids: [{ @@ -100,6 +101,7 @@ userIdAsEids = [ } }] }, + { source: 'zeotap.com', uids: [{ @@ -107,6 +109,7 @@ userIdAsEids = [ atype: 1 }] }, + { source: 'audigent.com', uids: [{ @@ -114,12 +117,21 @@ userIdAsEids = [ atype: 1 }] }, + { source: 'quantcast.com', uids: [{ id: 'some-random-id-value', atype: 1 }] + }, + + { + source: 'verizonmedia.com', + uids: [{ + id: 'some-random-id-value', + atype: 1 + }] } ] ``` diff --git a/modules/verizonMediaIdSystem.js b/modules/verizonMediaIdSystem.js new file mode 100644 index 00000000000..617561765cc --- /dev/null +++ b/modules/verizonMediaIdSystem.js @@ -0,0 +1,103 @@ +/** + * This module adds verizonMediaId to the User ID module + * The {@link module:modules/userId} module is required + * @module modules/verizonMediaIdSystem + * @requires module:modules/userId + */ + +import {ajax} from '../src/ajax.js'; +import {submodule} from '../src/hook.js'; +import * as utils from '../src/utils.js'; + +const MODULE_NAME = 'verizonMediaId'; +const VENDOR_ID = 25; +const PLACEHOLDER = '__PIXEL_ID__'; +const VMUID_ENDPOINT = `https://ups.analytics.yahoo.com/ups/${PLACEHOLDER}/fed`; + +function isEUConsentRequired(consentData) { + return !!(consentData && consentData.gdpr && consentData.gdpr.gdprApplies); +} + +/** @type {Submodule} */ +export const verizonMediaIdSubmodule = { + /** + * used to link submodule with config + * @type {string} + */ + name: MODULE_NAME, + /** + * Vendor id of Verizon Media EMEA Limited + * @type {Number} + */ + gvlid: VENDOR_ID, + /** + * decode the stored id value for passing to bid requests + * @function + * @returns {{vmuid: string} | undefined} + */ + decode(value) { + return (value && typeof value.vmuid === 'string') ? {vmuid: value.vmuid} : undefined; + }, + /** + * get the VerizonMedia Id + * @function + * @param {SubmoduleConfig} [config] + * @param {ConsentData} [consentData] + * @returns {IdResponse|undefined} + */ + getId(config, consentData) { + const params = config.params || {}; + if (!params || typeof params.he !== 'string' || + (typeof params.pixelId === 'undefined' && typeof params.endpoint === 'undefined')) { + utils.logError('The verizonMediaId submodule requires the \'he\' and \'pixelId\' parameters to be defined.'); + return; + } + + const data = { + '1p': [1, '1', true].includes(params['1p']) ? '1' : '0', + he: params.he, + gdpr: isEUConsentRequired(consentData) ? '1' : '0', + euconsent: isEUConsentRequired(consentData) ? consentData.gdpr.consentString : '', + us_privacy: consentData && consentData.uspConsent ? consentData.uspConsent : '' + }; + + if (params.pixelId) { + data.pixelId = params.pixelId + } + + const resp = function (callback) { + const callbacks = { + success: response => { + let responseObj; + if (response) { + try { + responseObj = JSON.parse(response); + } catch (error) { + utils.logError(error); + } + } + callback(responseObj); + }, + error: error => { + utils.logError(`${MODULE_NAME}: ID fetch encountered an error`, error); + callback(); + } + }; + const endpoint = VMUID_ENDPOINT.replace(PLACEHOLDER, params.pixelId); + let url = `${params.endpoint || endpoint}?${utils.formatQS(data)}`; + verizonMediaIdSubmodule.getAjaxFn()(url, callbacks, null, {method: 'GET', withCredentials: true}); + }; + return {callback: resp}; + }, + + /** + * Return the function used to perform XHR calls. + * Utilised for each of testing. + * @returns {Function} + */ + getAjaxFn() { + return ajax; + } +}; + +submodule('userId', verizonMediaIdSubmodule); diff --git a/modules/verizonMediaSystemId.md b/modules/verizonMediaSystemId.md new file mode 100644 index 00000000000..8d0e0bddaa9 --- /dev/null +++ b/modules/verizonMediaSystemId.md @@ -0,0 +1,33 @@ +## Verizon Media User ID Submodule + +Verizon Media User ID Module. + +### Prebid Params + +``` +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'verizonMediaId', + storage: { + name: 'vmuid', + type: 'html5', + expires: 30 + }, + params: { + pixelId: 58776, + he: '0bef996248d63cea1529cb86de31e9547a712d9f380146e98bbd39beec70355a' + } + }] + } +}); +``` +## Parameter Descriptions for the `usersync` Configuration Section +The below parameters apply only to the Verizon Media User ID Module integration. + +| Param under usersync.userIds[] | Scope | Type | Description | Example | +| --- | --- | --- | --- | --- | +| name | Required | String | ID value for the Verizon Media module - `"verizonMediaId"` | `"verizonMediaId"` | +| params | Required | Object | Data for Verizon Media ID initialization. | | +| params.pixelId | Required | Number | The Verizon Media supplied publisher specific pixel Id | `8976` | +| params.he | Required | String | The SHA-256 hashed user email address | `"529cb86de31e9547a712d9f380146e98bbd39beec"` | diff --git a/test/spec/modules/verizonMediaIdSystem_spec.js b/test/spec/modules/verizonMediaIdSystem_spec.js new file mode 100644 index 00000000000..a30be5a2569 --- /dev/null +++ b/test/spec/modules/verizonMediaIdSystem_spec.js @@ -0,0 +1,182 @@ +import {expect} from 'chai'; +import * as utils from 'src/utils.js'; +import {verizonMediaIdSubmodule} from 'modules/verizonMediaIdSystem.js'; + +describe('Verizon Media ID Submodule', () => { + const HASHED_EMAIL = '6bda6f2fa268bf0438b5423a9861a2cedaa5dec163c03f743cfe05c08a8397b2'; + const PIXEL_ID = '1234'; + const PROD_ENDPOINT = `https://ups.analytics.yahoo.com/ups/${PIXEL_ID}/fed`; + const OVERRIDE_ENDPOINT = 'https://foo/bar'; + + it('should have the correct module name declared', () => { + expect(verizonMediaIdSubmodule.name).to.equal('verizonMediaId'); + }); + + it('should have the correct TCFv2 Vendor ID declared', () => { + expect(verizonMediaIdSubmodule.gvlid).to.equal(25); + }); + + describe('getId()', () => { + let ajaxStub; + let getAjaxFnStub; + let consentData; + beforeEach(() => { + ajaxStub = sinon.stub(); + getAjaxFnStub = sinon.stub(verizonMediaIdSubmodule, 'getAjaxFn'); + getAjaxFnStub.returns(ajaxStub); + + consentData = { + gdpr: { + gdprApplies: 1, + consentString: 'GDPR_CONSENT_STRING' + }, + uspConsent: 'USP_CONSENT_STRING' + }; + }); + + afterEach(() => { + getAjaxFnStub.restore(); + }); + + function invokeGetIdAPI(configParams, consentData) { + let result = verizonMediaIdSubmodule.getId({ + params: configParams + }, consentData); + if (typeof result === 'object') { + result.callback(sinon.stub()); + } + return result; + } + + it('returns undefined if he and pixelId params are not passed', () => { + expect(invokeGetIdAPI({}, consentData)).to.be.undefined; + expect(ajaxStub.callCount).to.equal(0); + }); + + it('returns undefined if the pixelId param is not passed', () => { + expect(invokeGetIdAPI({ + he: HASHED_EMAIL + }, consentData)).to.be.undefined; + expect(ajaxStub.callCount).to.equal(0); + }); + + it('returns undefined if the he param is not passed', () => { + expect(invokeGetIdAPI({ + pixelId: PIXEL_ID + }, consentData)).to.be.undefined; + expect(ajaxStub.callCount).to.equal(0); + }); + + it('returns an object with the callback function if the correct params are passed', () => { + let result = invokeGetIdAPI({ + he: HASHED_EMAIL, + pixelId: PIXEL_ID + }, consentData); + expect(result).to.be.an('object').that.has.all.keys('callback'); + expect(result.callback).to.be.a('function'); + }); + + it('Makes an ajax GET request to the production API endpoint with query params', () => { + invokeGetIdAPI({ + he: HASHED_EMAIL, + pixelId: PIXEL_ID + }, consentData); + + const expectedParams = { + he: HASHED_EMAIL, + pixelId: PIXEL_ID, + '1p': '0', + gdpr: '1', + euconsent: consentData.gdpr.consentString, + us_privacy: consentData.uspConsent + }; + const requestQueryParams = utils.parseQS(ajaxStub.firstCall.args[0].split('?')[1]); + + expect(ajaxStub.firstCall.args[0].indexOf(`${PROD_ENDPOINT}?`)).to.equal(0); + expect(requestQueryParams).to.deep.equal(expectedParams); + expect(ajaxStub.firstCall.args[3]).to.deep.equal({method: 'GET', withCredentials: true}); + }); + + it('Makes an ajax GET request to the specified override API endpoint with query params', () => { + invokeGetIdAPI({ + he: HASHED_EMAIL, + endpoint: OVERRIDE_ENDPOINT + }, consentData); + + const expectedParams = { + he: HASHED_EMAIL, + '1p': '0', + gdpr: '1', + euconsent: consentData.gdpr.consentString, + us_privacy: consentData.uspConsent + }; + const requestQueryParams = utils.parseQS(ajaxStub.firstCall.args[0].split('?')[1]); + + expect(ajaxStub.firstCall.args[0].indexOf(`${OVERRIDE_ENDPOINT}?`)).to.equal(0); + expect(requestQueryParams).to.deep.equal(expectedParams); + expect(ajaxStub.firstCall.args[3]).to.deep.equal({method: 'GET', withCredentials: true}); + }); + + it('sets the callbacks param of the ajax function call correctly', () => { + invokeGetIdAPI({ + he: HASHED_EMAIL, + pixelId: PIXEL_ID, + }, consentData); + + expect(ajaxStub.firstCall.args[1]).to.be.an('object').that.has.all.keys(['success', 'error']); + }); + + it('sets GDPR consent data flag correctly when call is under GDPR jurisdiction.', () => { + invokeGetIdAPI({ + he: HASHED_EMAIL, + pixelId: PIXEL_ID, + }, consentData); + + const requestQueryParams = utils.parseQS(ajaxStub.firstCall.args[0].split('?')[1]); + expect(requestQueryParams.gdpr).to.equal('1'); + expect(requestQueryParams.euconsent).to.equal(consentData.gdpr.consentString); + }); + + it('sets GDPR consent data flag correctly when call is NOT under GDPR jurisdiction.', () => { + consentData.gdpr.gdprApplies = false; + + invokeGetIdAPI({ + he: HASHED_EMAIL, + pixelId: PIXEL_ID, + }, consentData); + + const requestQueryParams = utils.parseQS(ajaxStub.firstCall.args[0].split('?')[1]); + expect(requestQueryParams.gdpr).to.equal('0'); + expect(requestQueryParams.euconsent).to.equal(''); + }); + + [1, '1', true].forEach(firstPartyParamValue => { + it(`sets 1p payload property to '1' for a config value of ${firstPartyParamValue}`, () => { + invokeGetIdAPI({ + '1p': firstPartyParamValue, + he: HASHED_EMAIL, + pixelId: PIXEL_ID, + }, consentData); + + const requestQueryParams = utils.parseQS(ajaxStub.firstCall.args[0].split('?')[1]); + expect(requestQueryParams['1p']).to.equal('1'); + }); + }); + }); + + describe('decode()', () => { + const VALID_API_RESPONSE = { + vmuid: '1234' + }; + it('should return a newly constructed object with the vmuid property', () => { + expect(verizonMediaIdSubmodule.decode(VALID_API_RESPONSE)).to.deep.equal(VALID_API_RESPONSE); + expect(verizonMediaIdSubmodule.decode(VALID_API_RESPONSE)).to.not.equal(VALID_API_RESPONSE); + }); + + [{}, '', {foo: 'bar'}].forEach((response) => { + it(`should return undefined for an invalid response "${JSON.stringify(response)}"`, () => { + expect(verizonMediaIdSubmodule.decode(response)).to.be.undefined; + }); + }); + }); +});