diff --git a/integrationExamples/gpt/51DegreesRtdProvider_example.html b/integrationExamples/gpt/51DegreesRtdProvider_example.html new file mode 100644 index 00000000000..5f66dd9c8e2 --- /dev/null +++ b/integrationExamples/gpt/51DegreesRtdProvider_example.html @@ -0,0 +1,196 @@ + + + + + + + + + + + + 51Degrees RTD submodule example - Prebid.js + + +

51Degrees RTD submodule - example of usage

+ +

div-banner-native-1

+
+

No response

+ +
+ +

div-banner-native-2

+
+

No response

+ +
+ +
+

Testing/Debugging Guidance

+
    +
  1. Make sure you have debug: true under pbjs.setConfig in this example code (be sure to remove it for production!) +
  2. Make sure you have replaced <YOUR RESOURCE KEY> in this example code with the one you have obtained + from the 51Degrees Configurator Tool
  3. +
  4. Open DevTools Console in your browser and refresh the page
  5. +
  6. Observe the enriched ortb device data shown below and also in the console as part of the [51Degrees RTD Submodule]: reqBidsConfigObj: message (under reqBidsConfigObj.global.device)
  7. +
+ +
+ + + diff --git a/modules/51DegreesRtdProvider.js b/modules/51DegreesRtdProvider.js new file mode 100644 index 00000000000..ec2e5235445 --- /dev/null +++ b/modules/51DegreesRtdProvider.js @@ -0,0 +1,244 @@ +import {loadExternalScript} from '../src/adloader.js'; +import {submodule} from '../src/hook.js'; +import {prefixLog, deepAccess, mergeDeep} from '../src/utils.js'; + +const MODULE_NAME = '51Degrees'; +export const LOG_PREFIX = `[${MODULE_NAME} RTD Submodule]:`; +const {logMessage, logWarn, logError} = prefixLog(LOG_PREFIX); + +// ORTB device types +const ORTB_DEVICE_TYPE = { + UNKNOWN: 0, + MOBILE_TABLET: 1, + PERSONAL_COMPUTER: 2, + CONNECTED_TV: 3, + PHONE: 4, + TABLET: 5, + CONNECTED_DEVICE: 6, + SET_TOP_BOX: 7, + OOH_DEVICE: 8 +}; + +// Map of 51Degrees device types to ORTB device types. See +// https://51degrees.com/developers/property-dictionary?item=Device%7CDevice +// for available properties and values. +const ORTB_DEVICE_TYPE_MAP = new Map([ + ['Phone', ORTB_DEVICE_TYPE.PHONE], + ['Console', ORTB_DEVICE_TYPE.SET_TOP_BOX], + ['Desktop', ORTB_DEVICE_TYPE.PERSONAL_COMPUTER], + ['EReader', ORTB_DEVICE_TYPE.PERSONAL_COMPUTER], + ['IoT', ORTB_DEVICE_TYPE.CONNECTED_DEVICE], + ['Kiosk', ORTB_DEVICE_TYPE.OOH_DEVICE], + ['MediaHub', ORTB_DEVICE_TYPE.SET_TOP_BOX], + ['Mobile', ORTB_DEVICE_TYPE.MOBILE_TABLET], + ['Router', ORTB_DEVICE_TYPE.CONNECTED_DEVICE], + ['SmallScreen', ORTB_DEVICE_TYPE.CONNECTED_DEVICE], + ['SmartPhone', ORTB_DEVICE_TYPE.MOBILE_TABLET], + ['SmartSpeaker', ORTB_DEVICE_TYPE.CONNECTED_DEVICE], + ['SmartWatch', ORTB_DEVICE_TYPE.CONNECTED_DEVICE], + ['Tablet', ORTB_DEVICE_TYPE.TABLET], + ['Tv', ORTB_DEVICE_TYPE.CONNECTED_TV], + ['Vehicle Display', ORTB_DEVICE_TYPE.PERSONAL_COMPUTER] +]); + +/** + * Extracts the parameters for 51Degrees RTD module from the config object passed at instantiation + * @param {Object} moduleConfig Configuration object of the 51Degrees RTD module + * @param {Object} reqBidsConfigObj Configuration object for the bidders, currently not used + */ +export const extractConfig = (moduleConfig, reqBidsConfigObj) => { + // Resource key + const resourceKey = deepAccess(moduleConfig, 'params.resourceKey'); + // On-premise JS URL + const onPremiseJSUrl = deepAccess(moduleConfig, 'params.onPremiseJSUrl'); + + if (!resourceKey && !onPremiseJSUrl) { + throw new Error(LOG_PREFIX + ' Missing parameter resourceKey or onPremiseJSUrl in moduleConfig'); + } else if (resourceKey && onPremiseJSUrl) { + throw new Error(LOG_PREFIX + ' Only one of resourceKey or onPremiseJSUrl should be provided in moduleConfig'); + } + if (resourceKey === '') { + throw new Error(LOG_PREFIX + ' replace in configuration with a resource key obtained from https://configure.51degrees.com/tWrhNfY6'); + } + + return {resourceKey, onPremiseJSUrl}; +} + +/** + * Gets 51Degrees JS URL + * @param {Object} pathData API path data + * @param {string} [pathData.resourceKey] Resource key + * @param {string} [pathData.onPremiseJSUrl] On-premise JS URL + * @returns {string} 51Degrees JS URL + */ +export const get51DegreesJSURL = (pathData) => { + if (pathData.onPremiseJSUrl) { + return pathData.onPremiseJSUrl; + } + return `https://cloud.51degrees.com/api/v4/${pathData.resourceKey}.js`; +} + +/** + * Check if meta[http-equiv="Delegate-CH"] tag is present in the document head and points to 51Degrees cloud + * + * The way to delegate processing User-Agent Client Hints to a 3rd party is either + * via setting Permissions-Policy + Accept-CH response headers or Delegate-CH meta-http equiv. + * Of those two, Delegate-CH meta http-equiv is an easier and more performant option + * (client hints are sent on the very first request without a round trip required). + * Using the getHighEntropyValues() API is an alternative; + * however, Google is likely to restrict it as part of the Privacy Sandbox in future + * versions of Chrome, so we want to be future-proof and transparent here. + * Hence, a check that would output the warning if the user does not have proper delegation of UA-CH. + * + * @returns {boolean} True if 51Degrees meta is present + * @returns {boolean} False if 51Degrees meta is not present + */ +export const is51DegreesMetaPresent = () => { + const meta51 = document.head.querySelectorAll('meta[http-equiv="Delegate-CH"]'); + if (!meta51.length) { + return false; + } + return Array.from(meta51).some( + meta => !meta.content + ? false + : meta.content.includes('cloud.51degrees') + ); +} + +/** + * Sets the value of a key in the ORTB2 object if the value is not empty + * + * @param {Object} obj The object to set the key in + * @param {string} key The key to set + * @param {any} value The value to set + */ +export const setOrtb2KeyIfNotEmpty = (obj, key, value) => { + if (!key) { + throw new Error(LOG_PREFIX + ' Key is required'); + } + + if (value) { + obj[key] = value; + } +} + +/** + * Converts 51Degrees device data to ORTB2 format + * + * @param {Object} device + * @param {string} [device.deviceid] Device ID (unique 51Degrees identifier) + * @param {string} [device.devicetype] + * @param {string} [device.hardwarevendor] + * @param {string} [device.hardwaremodel] + * @param {string[]} [device.hardwarename] + * @param {string} [device.platformname] + * @param {string} [device.platformversion] + * @param {number} [device.screenpixelsheight] + * @param {number} [device.screenpixelswidth] + * @param {number} [device.pixelratio] + * @param {number} [device.screeninchesheight] + * + * @returns {Object} + */ +export const convert51DegreesDeviceToOrtb2 = (device) => { + const ortb2Device = {}; + + if (!device) { + return ortb2Device; + } + + const deviceModel = + device.hardwaremodel || ( + device.hardwarename && device.hardwarename.length + ? device.hardwarename.join(',') + : null + ); + + const devicePPI = device.screenpixelsheight && device.screeninchesheight + ? Math.round(device.screenpixelsheight / device.screeninchesheight) + : null; + + setOrtb2KeyIfNotEmpty(ortb2Device, 'devicetype', ORTB_DEVICE_TYPE_MAP.get(device.devicetype)); + setOrtb2KeyIfNotEmpty(ortb2Device, 'make', device.hardwarevendor); + setOrtb2KeyIfNotEmpty(ortb2Device, 'model', deviceModel); + setOrtb2KeyIfNotEmpty(ortb2Device, 'os', device.platformname); + setOrtb2KeyIfNotEmpty(ortb2Device, 'osv', device.platformversion); + setOrtb2KeyIfNotEmpty(ortb2Device, 'h', device.screenpixelsheight); + setOrtb2KeyIfNotEmpty(ortb2Device, 'w', device.screenpixelswidth); + setOrtb2KeyIfNotEmpty(ortb2Device, 'pxratio', device.pixelratio); + setOrtb2KeyIfNotEmpty(ortb2Device, 'ppi', devicePPI); + + if (device.deviceid) { + ortb2Device.ext = { + 'fiftyonedegrees_deviceId': device.deviceid + }; + } + + return ortb2Device; +} + +/** + * @param {Object} reqBidsConfigObj Bid request configuration object + * @param {Function} callback Called on completion + * @param {Object} moduleConfig Configuration for 1plusX RTD module + * @param {Object} userConsent + */ +export const getBidRequestData = (reqBidsConfigObj, callback, moduleConfig, userConsent) => { + try { + // Get the required config + const {resourceKey, onPremiseJSUrl} = extractConfig(moduleConfig, reqBidsConfigObj); + logMessage('Resource key: ', resourceKey); + logMessage('On-premise JS URL: ', onPremiseJSUrl); + + // Get 51Degrees JS URL, which is either cloud or on-premise + const scriptURL = get51DegreesJSURL(resourceKey ? {resourceKey} : {onPremiseJSUrl}); + logMessage('URL of the script to be injected: ', scriptURL); + + // Check if 51Degrees meta is present (cloud only) + if (resourceKey) { + logMessage('Checking if 51Degrees meta is present in the document head'); + if (!is51DegreesMetaPresent()) { + logWarn('Delegate-CH meta tag is not present in the document head'); + } + } + + // Inject 51Degrees script, get device data and merge it into the ORTB2 object + loadExternalScript(scriptURL, MODULE_NAME, () => { + logMessage('Successfully injected 51Degrees script'); + const fod = /** @type {Object} */ (window.fod); + // Convert and merge device data in the callback + fod.complete((data) => { + logMessage('51Degrees raw data: ', data); + mergeDeep( + reqBidsConfigObj.ortb2Fragments.global, + {device: convert51DegreesDeviceToOrtb2(data.device)}, + ); + logMessage('reqBidsConfigObj: ', reqBidsConfigObj); + callback(); + }); + }); + } catch (error) { + // In case of an error, log it and continue + logError(error); + callback(); + } +} + +/** + * Init + * @param {Object} config Module configuration + * @param {boolean} userConsent User consent + * @returns true + */ +const init = (config, userConsent) => { + return true; +} + +// 51Degrees RTD submodule object to be registered +export const fiftyOneDegreesSubmodule = { + name: MODULE_NAME, + init, + getBidRequestData, +} + +submodule('realTimeData', fiftyOneDegreesSubmodule); diff --git a/modules/51DegreesRtdProvider.md b/modules/51DegreesRtdProvider.md new file mode 100644 index 00000000000..991e756a0d7 --- /dev/null +++ b/modules/51DegreesRtdProvider.md @@ -0,0 +1,146 @@ +# 51Degrees RTD Submodule + +## Overview + + Module Name: 51Degrees Rtd Provider + Module Type: Rtd Provider + Maintainer: support@51degrees.com + +## Description + +51Degrees module enriches an OpenRTB request with [51Degrees Device Data](https://51degrees.com/documentation/index.html). + +51Degrees module sets the following fields of the device object: `make`, `model`, `os`, `osv`, `h`, `w`, `ppi`, `pxratio` - interested bidder adapters may use these fields as needed. In addition the module sets `device.ext.fiftyonedegrees_deviceId` to a permanent device ID which can be rapidly looked up in on premise data exposing over 250 properties including the device age, chip set, codec support, and price, operating system and app/browser versions, age, and embedded features. + +The module supports on premise and cloud device detection services with free options for both. + +A free resource key for use with 51Degrees cloud service can be obtained from [51Degrees cloud configuration](https://configure.51degrees.com/tWrhNfY6). This is the simplest approach to trial the module. + +An interface compatible self hosted service can be used with .NET, Java, Node, PHP, and Python. See [51Degrees examples](https://51degrees.com/documentation/_examples__device_detection__getting_started__web__on_premise.html). + +Free cloud and on premise solutions can be expanded to support unlimited requests, additional properties, and automatic daily on premise data updates via a [subscription](https://51degrees.com/pricing). + +## Usage + +### Integration + +Compile the 51Degrees RTD Module with other modules and adapters into your Prebid.js build: + +``` +gulp build --modules="rtdModule,51DegreesRtdProvider,appnexusBidAdapter,..." +``` + +> Note that the 51Degrees RTD module is dependent on the global real-time data module, `rtdModule`. + +### Prerequisites + +#### Resource Key +In order to use the module please first obtain a Resource Key using the [Configurator tool](https://configure.51degrees.com/tWrhNfY6) - choose the following properties: +* DeviceId +* DeviceType +* HardwareVendor +* HardwareName +* HardwareModel +* PlatformName +* PlatformVersion +* ScreenPixelsHeight +* ScreenPixelsWidth +* ScreenInchesHeight +* ScreenInchesWidth +* PixelRatio (optional) + +PixelRatio is desirable, but it's a paid property requiring a paid license. Also free API service is limited to 500,000 requests per month - consider picking a [51Degrees pricing plan](https://51degrees.com/pricing) that fits your needs. + +#### User Agent Client Hint (UA-CH) Permissions + +Some UA-CH headers are not available to third parties. To allow 51Degrees cloud service to access these headers for more accurate detection and lower latency, it is highly recommended to set `Permissions-Policy` in one of two ways: + +In the HTML of the publisher's web page where Prebid.js wrapper is integrated: + +```html + +``` + +Or in the Response Headers of the publisher's web server: + +```http +Permissions-Policy: ch-ua-arch=(self "https://cloud.51degrees.com"), ch-ua-full-version=(self "https://cloud.51degrees.com"), ch-ua-full-version-list=(self "https://cloud.51degrees.com"), ch-ua-model=(self "https://cloud.51degrees.com"), ch-ua-platform=(self "https://cloud.51degrees.com"), ch-ua-platform-version=(self "https://cloud.51degrees.com") + +Accept-CH: sec-ch-ua-arch, sec-ch-ua-full-version, sec-ch-ua-full-version-list, sec-ch-ua-model, sec-ch-ua-platform, sec-ch-ua-platform-version +``` + +See the [51Degrees documentation](https://51degrees.com/documentation/_device_detection__features__u_a_c_h__overview.html) for more information concerning UA-CH and permissions. + +##### Why not use GetHighEntropyValues API instead? + +Thanks for asking. + +The script this module injects has a fall back to the GetHighEntropyValues API, but does not rely on it as a first (or only) choice route - please see the illustrative cases below. Albeit it seems easier, GHEV API is not supported by all browsers (so the decision to call it should be conditional) and also even in Chrome this API will likely be a subject to the Privacy Budget in the future. + +In summary we recommend using `Delegate-CH` http-equiv as the preferred method of obtaining the necessary evidence because it is the fastest and future proof method. + +##### Illustrative Cases + +* if the device is iPhone/iPad then there is no point checking for or calling GetHighEntropyValues at the moment because iOS does not support this API. However this might change in the future. Platforms like iOS require additional techniques to identify the model which are not covered via a single API call, and change from version to version of the operating system and browser rendering engine. **When used with iOS 51Degrees resolves the [iPhone/iPad model groups](https://51degrees.com/documentation/4.4/_device_detection__features__apple_device_table.html) using these techniques.** That is one of the benefits the module brings to the Prebid community as most solutions do not resolve iPhone/iPad model groups. More on Apple Device Detection [here](https://51degrees.com/documentation/4.4/_device_detection__features__apple_detection.html). + +* if the browser is Firefox on Android or Desktop then there is similarly no point requesting GHEV as the API is not supported. + +* if the browser is Chrome then the `Delegate-CH` if enabled by the publisher would enable the browser to provide the necessary evidence. However if this is not implemented - then the dynamic script would fall back to GHEV which is slower. + +### Configuration + +This module is configured as part of the `realTimeData.dataProviders` + +```javascript +pbjs.setConfig({ + debug: true, // turn on for testing, remove in production + realTimeData: { + auctionDelay: 1000, // should be set lower in production use + dataProviders: [ + { + name: '51Degrees', + waitForIt: true, // should be true, otherwise the auctionDelay will be ignored + params: { + // Get your resource key from https://configure.51degrees.com/tWrhNfY6 to connect to cloud.51degrees.com + resourceKey: '', + // alternatively, you can use the on-premise version of the 51Degrees service and connect to your chosen end point + // onPremiseJSUrl: 'https://localhost/51Degrees.core.js' + }, + }, + ], + }, +}); +``` + +### Parameters + +> Note that `resourceKey` and `onPremiseJSUrl` are mutually exclusive parameters. Use strictly one of them: either a `resourceKey` for cloud integration and `onPremiseJSUrl` for the on-premise self-hosted integration. + +| Name | Type | Description | Default | +|:----------------------|:--------|:---------------------------------------------------------------------------------------------|:-------------------| +| name | String | Real time data module name | Always '51Degrees' | +| waitForIt | Boolean | Should be `true` if there's an `auctionDelay` defined (mandatory) | `false` | +| params | Object | | | +| params.resourceKey | String | Your 51Degrees Cloud Resource Key | | +| params.onPremiseJSUrl | String | Direct URL to your self-hosted on-premise JS file (e.g. https://localhost/51Degrees.core.js) | | + +## Example + +> Note: you need to have a valid resource key to run the example.\ +> It should be set in the configuration instead of ``.\ +> It is located in the `integrationExamples/gpt/51DegreesRtdProvider_example.html` file. + +If you want to see an example of how the 51Degrees RTD module works,\ +run the following command: + +`gulp serve --modules=rtdModule,51DegreesRtdProvider,appnexusBidAdapter` + +and then open the following URL in your browser: + +`http://localhost:9999/integrationExamples/gpt/51DegreesRtdProvider_example.html` + +Open the browser console to see the logs. + +## Customer Notices + +When using the 51Degrees cloud service publishers need to reference the 51Degrees [client services privacy policy](https://51degrees.com/terms/client-services-privacy-policy) in their customer notices. \ No newline at end of file diff --git a/src/adloader.js b/src/adloader.js index c2da2646320..30693560133 100644 --- a/src/adloader.js +++ b/src/adloader.js @@ -34,6 +34,7 @@ const _approvedLoadExternalJSList = [ 'contxtful', 'id5', 'lucead', + '51Degrees', ]; /** diff --git a/src/native.js b/src/native.js index f001200000d..05347e47f1f 100644 --- a/src/native.js +++ b/src/native.js @@ -15,6 +15,8 @@ import {includes} from './polyfill.js'; import {auctionManager} from './auctionManager.js'; import {NATIVE_ASSET_TYPES, NATIVE_IMAGE_TYPES, PREBID_NATIVE_DATA_KEYS_TO_ORTB, NATIVE_KEYS_THAT_ARE_NOT_ASSETS, NATIVE_KEYS} from './constants.js'; import {NATIVE} from './mediaTypes.js'; +import {getRenderingData} from './adRendering.js'; +import {getCreativeRendererSource} from './creativeRenderers.js'; /** * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest @@ -434,11 +436,24 @@ export function getNativeRenderingData(bid, adUnit, keys) { } function assetsMessage(data, adObject, keys, {index = auctionManager.index} = {}) { - return { + const msg = { message: 'assetResponse', adId: data.adId, - ...getNativeRenderingData(adObject, index.getAdUnit(adObject), keys) }; + let renderData = getRenderingData(adObject).native; + if (renderData) { + // if we have native rendering data (set up by the nativeRendering module) + // include it in full ("all assets") together with the renderer. + // this is to allow PUC to use dynamic renderers without requiring changes in creative setup + msg.native = Object.assign({}, renderData); + msg.renderer = getCreativeRendererSource(adObject); + if (keys != null) { + renderData.assets = renderData.assets.filter(({key}) => keys.includes(key)) + } + } else { + renderData = getNativeRenderingData(adObject, index.getAdUnit(adObject), keys); + } + return Object.assign(msg, renderData); } const NATIVE_KEYS_INVERTED = Object.fromEntries(Object.entries(NATIVE_KEYS).map(([k, v]) => [v, k])); diff --git a/test/spec/modules/51DegreesRtdProvider_spec.js b/test/spec/modules/51DegreesRtdProvider_spec.js new file mode 100644 index 00000000000..9b634970ebb --- /dev/null +++ b/test/spec/modules/51DegreesRtdProvider_spec.js @@ -0,0 +1,276 @@ +import { + extractConfig, + get51DegreesJSURL, + is51DegreesMetaPresent, + setOrtb2KeyIfNotEmpty, + convert51DegreesDeviceToOrtb2, + getBidRequestData, + fiftyOneDegreesSubmodule, +} from 'modules/51DegreesRtdProvider'; + +const inject51DegreesMeta = () => { + const meta = document.createElement('meta'); + meta.httpEquiv = 'Delegate-CH'; + meta.content = 'sec-ch-ua-full-version-list https://cloud.51degrees.com; sec-ch-ua-model https://cloud.51degrees.com; sec-ch-ua-platform https://cloud.51degrees.com; sec-ch-ua-platform-version https://cloud.51degrees.com'; + document.head.appendChild(meta); +}; + +describe('51DegreesRtdProvider', function() { + describe('extractConfig', function() { + it('returns the resourceKey from the moduleConfig', function() { + const reqBidsConfigObj = {}; + const moduleConfig = {params: {resourceKey: 'TEST_RESOURCE_KEY'}}; + expect(extractConfig(moduleConfig, reqBidsConfigObj)).to.deep.equal({ + resourceKey: 'TEST_RESOURCE_KEY', + onPremiseJSUrl: undefined, + }); + }); + + it('returns the onPremiseJSUrl from the moduleConfig', function() { + const reqBidsConfigObj = {}; + const moduleConfig = {params: {onPremiseJSUrl: 'https://example.com/51Degrees.core.js'}}; + expect(extractConfig(moduleConfig, reqBidsConfigObj)).to.deep.equal({ + onPremiseJSUrl: 'https://example.com/51Degrees.core.js', + resourceKey: undefined, + }); + }); + + it('throws an error if neither resourceKey nor onPremiseJSUrl is provided', function() { + const reqBidsConfigObj = {}; + const moduleConfig = {params: {}}; + expect(() => extractConfig(moduleConfig, reqBidsConfigObj)).to.throw(); + }); + + it('throws an error if both resourceKey and onPremiseJSUrl are provided', function() { + const reqBidsConfigObj = {}; + const moduleConfig = {params: { + resourceKey: 'TEST_RESOURCE_KEY', + onPremiseJSUrl: 'https://example.com/51Degrees.core.js', + }}; + expect(() => extractConfig(moduleConfig, reqBidsConfigObj)).to.throw(); + }); + + it('throws an error if the resourceKey is equal to "" from example', function() { + const reqBidsConfigObj = {}; + const moduleConfig = {params: {resourceKey: ''}}; + expect(() => extractConfig(moduleConfig, reqBidsConfigObj)).to.throw(); + }); + }); + + describe('get51DegreesJSURL', function() { + it('returns the cloud URL if the resourceKey is provided', function() { + const config = {resourceKey: 'TEST_RESOURCE_KEY'}; + expect(get51DegreesJSURL(config)).to.equal( + 'https://cloud.51degrees.com/api/v4/TEST_RESOURCE_KEY.js' + ); + }); + + it('returns the on-premise URL if the onPremiseJSUrl is provided', function() { + const config = {onPremiseJSUrl: 'https://example.com/51Degrees.core.js'}; + expect(get51DegreesJSURL(config)).to.equal('https://example.com/51Degrees.core.js'); + }); + }); + + describe('is51DegreesMetaPresent', function() { + let initialHeadInnerHTML; + + before(function() { + initialHeadInnerHTML = document.head.innerHTML; + }); + + afterEach(function() { + document.head.innerHTML = initialHeadInnerHTML; + }); + + it('returns true if the 51Degrees meta tag is present', function () { + inject51DegreesMeta(); + expect(is51DegreesMetaPresent()).to.be.true; + }); + + it('returns false if the 51Degrees meta tag is not present', function() { + expect(is51DegreesMetaPresent()).to.be.false; + }); + + it('works with multiple meta tags, even if those are not to include any `content`', function() { + const meta1 = document.createElement('meta'); + meta1.httpEquiv = 'Delegate-CH'; + document.head.appendChild(meta1); + + inject51DegreesMeta(); + + const meta2 = document.createElement('meta'); + meta2.httpEquiv = 'Delegate-CH'; + document.head.appendChild(meta2); + + expect(is51DegreesMetaPresent()).to.be.true; + }); + }); + + describe('setOrtb2KeyIfNotEmpty', function() { + it('sets value of ORTB2 key if it is not empty', function() { + const data = {}; + setOrtb2KeyIfNotEmpty(data, 'TEST_ORTB2_KEY', 'TEST_ORTB2_VALUE'); + expect(data).to.deep.equal({TEST_ORTB2_KEY: 'TEST_ORTB2_VALUE'}); + }); + + it('throws an error if the key is empty', function() { + const data = {}; + expect(() => setOrtb2KeyIfNotEmpty(data, '', 'TEST_ORTB2_VALUE')).to.throw(); + }); + + it('does not set value of ORTB2 key if it is empty', function() { + const data = {}; + setOrtb2KeyIfNotEmpty(data, 'TEST_ORTB2_KEY', ''); + setOrtb2KeyIfNotEmpty(data, 'TEST_ORTB2_KEY', 0); + setOrtb2KeyIfNotEmpty(data, 'TEST_ORTB2_KEY', null); + setOrtb2KeyIfNotEmpty(data, 'TEST_ORTB2_KEY', undefined); + expect(data).to.deep.equal({}); + }); + }); + + describe('convert51DegreesDeviceToOrtb2', function() { + const fiftyOneDegreesDevice = { + 'screenpixelswidth': 5120, + 'screenpixelsheight': 1440, + 'hardwarevendor': 'Apple', + 'hardwaremodel': 'Macintosh', + 'hardwarename': [ + 'Macintosh', + ], + 'platformname': 'macOS', + 'platformversion': '14.1.2', + 'screeninchesheight': 13.27, + 'screenincheswidth': 47.17, + 'devicetype': 'Desktop', + 'pixelratio': 1, + 'deviceid': '17595-131215-132535-18092', + }; + + it('converts 51Degrees device data to ORTB2 format', function() { + expect(convert51DegreesDeviceToOrtb2(fiftyOneDegreesDevice)).to.deep.equal({ + devicetype: 2, + make: 'Apple', + model: 'Macintosh', + os: 'macOS', + osv: '14.1.2', + h: 1440, + w: 5120, + ppi: 109, + pxratio: 1, + ext: { + fiftyonedegrees_deviceId: '17595-131215-132535-18092', + }, + }); + }); + + it('returns an empty object if the device data is not provided', function() { + expect(convert51DegreesDeviceToOrtb2()).to.deep.equal({}); + }); + + it('does not set the deviceid if it is not provided', function() { + const device = {...fiftyOneDegreesDevice}; + delete device.deviceid; + expect(convert51DegreesDeviceToOrtb2(device)).to.not.have.any.keys('ext'); + }); + + it('sets the model to hardwarename if hardwaremodel is not provided', function() { + const device = {...fiftyOneDegreesDevice}; + delete device.hardwaremodel; + expect(convert51DegreesDeviceToOrtb2(device)).to.deep.include({model: 'Macintosh'}); + }); + + it('does not set the model if hardwarename is empty', function() { + const device = {...fiftyOneDegreesDevice}; + delete device.hardwaremodel; + device.hardwarename = []; + expect(convert51DegreesDeviceToOrtb2(device)).to.not.have.any.keys('model'); + }); + + it('does not set the ppi if screeninchesheight is not provided', function() { + const device = {...fiftyOneDegreesDevice}; + delete device.screeninchesheight; + expect(convert51DegreesDeviceToOrtb2(device)).to.not.have.any.keys('ppi'); + }); + }); + + describe('getBidRequestData', function() { + let initialHeadInnerHTML; + const reqBidsConfigObj = { + ortb2Fragments: { + global: { + device: {}, + }, + }, + }; + + before(function() { + initialHeadInnerHTML = document.head.innerHTML; + + const mockScript = document.createElement('script'); + mockScript.innerHTML = ` + const fiftyOneDegreesDevice = { + 'screenpixelswidth': 5120, + 'screenpixelsheight': 1440, + 'hardwarevendor': 'Apple', + 'hardwaremodel': 'Macintosh', + 'hardwarename': [ + 'Macintosh', + ], + 'platformname': 'macOS', + 'platformversion': '14.1.2', + 'screeninchesheight': 13.27, + 'screenincheswidth': 47.17, + 'devicetype': 'Desktop', + 'pixelratio': 1, + 'deviceid': '17595-131215-132535-18092', + }; + window.fod = {complete: (_callback) => _callback({device: fiftyOneDegreesDevice})}; + `; + document.head.appendChild(mockScript); + }); + + after(function() { + document.head.innerHTML = initialHeadInnerHTML; + }); + + it('calls the callback even if submodule fails (wrong config)', function() { + const callback = sinon.spy(); + const moduleConfig = {params: {}}; + getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(callback.calledOnce).to.be.true; + }); + + it('calls the callback even if submodule fails (on-premise, non-working URL)', async function() { + const callback = sinon.spy(); + const moduleConfig = {params: {onPremiseJSUrl: 'http://localhost:12345/test/51Degrees.core.js'}}; + + getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(callback.calledOnce).to.be.true; + }); + + it('calls the callback even if submodule fails (invalid resource key)', async function() { + const callback = sinon.spy(); + const moduleConfig = {params: {resourceKey: 'INVALID_RESOURCE_KEY'}}; + + getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(callback.calledOnce).to.be.true; + }); + + it('works with Delegate-CH meta tag', async function() { + inject51DegreesMeta(); + const callback = sinon.spy(); + const moduleConfig = {params: {resourceKey: 'INVALID_RESOURCE_KEY'}}; + getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(callback.calledOnce).to.be.true; + }); + }); + + describe('init', function() { + it('initialises the 51Degrees RTD provider', function() { + expect(fiftyOneDegreesSubmodule.init()).to.be.true; + }); + }); +}); diff --git a/test/spec/native_spec.js b/test/spec/native_spec.js index 5d1a43cc57f..a611a9dd789 100644 --- a/test/spec/native_spec.js +++ b/test/spec/native_spec.js @@ -14,12 +14,15 @@ import { legacyPropertiesToOrtbNative, fireImpressionTrackers, fireClickTrackers, - setNativeResponseProperties, + setNativeResponseProperties, getNativeRenderingData, } from 'src/native.js'; import { NATIVE_KEYS } from 'src/constants.js'; import { stubAuctionIndex } from '../helpers/indexStub.js'; import { convertOrtbRequestToProprietaryNative, fromOrtbNativeRequest } from '../../src/native.js'; import {auctionManager} from '../../src/auctionManager.js'; +import {getRenderingData} from '../../src/adRendering.js'; +import {getCreativeRendererSource} from '../../src/creativeRenderers.js'; +import {deepClone} from '../../src/utils.js'; const utils = require('src/utils'); const bid = { @@ -180,6 +183,7 @@ const bidWithUndefinedFields = { }; describe('native.js', function () { + let sandbox; let triggerPixelStub; let insertHtmlIntoIframeStub; @@ -188,13 +192,13 @@ describe('native.js', function () { } beforeEach(function () { - triggerPixelStub = sinon.stub(utils, 'triggerPixel'); - insertHtmlIntoIframeStub = sinon.stub(utils, 'insertHtmlIntoIframe'); + sandbox = sinon.createSandbox(); + triggerPixelStub = sandbox.stub(utils, 'triggerPixel'); + insertHtmlIntoIframeStub = sandbox.stub(utils, 'insertHtmlIntoIframe'); }); afterEach(function () { - utils.triggerPixel.restore(); - utils.insertHtmlIntoIframe.restore(); + sandbox.restore(); }); it('gets native targeting keys', function () { @@ -382,171 +386,227 @@ describe('native.js', function () { })) }); - it('creates native asset message', function () { - const messageRequest = { - message: 'Prebid Native', - action: 'assetRequest', - adId: '123', - assets: ['hb_native_body', 'hb_native_image', 'hb_native_linkurl'], - }; - - const message = getAssetMessage(messageRequest, bid); - - expect(message.assets.length).to.equal(3); - expect(message.assets).to.deep.include({ - key: 'body', - value: bid.native.body, - }); - expect(message.assets).to.deep.include({ - key: 'image', - value: bid.native.image.url, - }); - expect(message.assets).to.deep.include({ - key: 'clickUrl', - value: bid.native.clickUrl, - }); - }); - - it('creates native all asset message', function () { - const messageRequest = { - message: 'Prebid Native', - action: 'allAssetRequest', - adId: '123', - }; - - const message = getAllAssetsMessage(messageRequest, bid); - - expect(message.assets.length).to.equal(10); - expect(message.assets).to.deep.include({ - key: 'body', - value: bid.native.body, - }); - expect(message.assets).to.deep.include({ - key: 'image', - value: bid.native.image.url, - }); - expect(message.assets).to.deep.include({ - key: 'clickUrl', - value: bid.native.clickUrl, - }); - expect(message.assets).to.deep.include({ - key: 'title', - value: bid.native.title, - }); - expect(message.assets).to.deep.include({ - key: 'icon', - value: bid.native.icon.url, - }); - expect(message.assets).to.deep.include({ - key: 'cta', - value: bid.native.cta, - }); - expect(message.assets).to.deep.include({ - key: 'sponsoredBy', - value: bid.native.sponsoredBy, - }); - expect(message.assets).to.deep.include({ - key: 'foo', - value: bid.native.ext.foo, - }); - expect(message.assets).to.deep.include({ - key: 'baz', - value: bid.native.ext.baz, - }); - }); - - it('creates native all asset message with only defined fields', function () { - const messageRequest = { - message: 'Prebid Native', - action: 'allAssetRequest', - adId: '123', - }; - - const message = getAllAssetsMessage(messageRequest, bidWithUndefinedFields); - - expect(message.assets.length).to.equal(4); - expect(message.assets).to.deep.include({ - key: 'clickUrl', - value: bid.native.clickUrl, - }); - expect(message.assets).to.deep.include({ - key: 'title', - value: bid.native.title, - }); - expect(message.assets).to.deep.include({ - key: 'sponsoredBy', - value: bid.native.sponsoredBy, - }); - expect(message.assets).to.deep.include({ - key: 'foo', - value: bid.native.ext.foo, - }); - }); - - it('creates native all asset message with complete format', function () { - const messageRequest = { - message: 'Prebid Native', - action: 'allAssetRequest', - adId: '123', - }; - - const message = getAllAssetsMessage(messageRequest, completeNativeBid); + Object.entries({ + 'returns native data': { + renderDataHook(next, bidResponse) { + next.bail({ + native: getNativeRenderingData(bidResponse, adUnit) + }); + }, + renderSourceHook(next) { + next.bail('mock-native-renderer'); + }, + withRenderer: true + }, + 'does not return native data': { + renderDataHook(next) { + next.bail({}) + }, + renderSourceHook(next) { + next.bail('mock-display-renderer'); + }, + withRenderer: false + } + }).forEach(([t, {renderDataHook, renderSourceHook, withRenderer}]) => { + describe(`when getRenderingData ${t}`, () => { + before(() => { + getRenderingData.before(renderDataHook, 100); + getCreativeRendererSource.before(renderSourceHook, 100); + }); + after(() => { + getRenderingData.getHooks({hook: renderDataHook}).remove(); + getCreativeRendererSource.getHooks({hook: renderSourceHook}).remove(); + }); + + function checkRenderer(message) { + if (withRenderer) { + expect(message.renderer).to.eql('mock-native-renderer') + Object.entries(message).forEach(([key, val]) => { + if (!['native', 'adId', 'message', 'assets', 'renderer'].includes(key)) { + expect(message.native[key]).to.eql(val); + } + }) + message.assets.forEach(asset => { + expect(message.native.assets).to.contain(asset); + }) + } else { + expect(message.renderer).to.not.exist; + expect(message.native).to.not.exist; + } + } - expect(message.assets.length).to.equal(10); - expect(message.assets).to.deep.include({ - key: 'body', - value: bid.native.body, - }); - expect(message.assets).to.deep.include({ - key: 'image', - value: bid.native.image.url, - }); - expect(message.assets).to.deep.include({ - key: 'clickUrl', - value: bid.native.clickUrl, - }); - expect(message.assets).to.deep.include({ - key: 'title', - value: bid.native.title, - }); - expect(message.assets).to.deep.include({ - key: 'icon', - value: bid.native.icon.url, - }); - expect(message.assets).to.deep.include({ - key: 'cta', - value: bid.native.cta, - }); - expect(message.assets).to.deep.include({ - key: 'sponsoredBy', - value: bid.native.sponsoredBy, - }); - expect(message.assets).to.deep.include({ - key: 'privacyLink', - value: ortbBid.native.ortb.privacy, - }); - expect(message.assets).to.deep.include({ - key: 'foo', - value: bid.native.ext.foo, - }); - expect(message.assets).to.deep.include({ - key: 'baz', - value: bid.native.ext.baz, + it('creates native asset message', function () { + const messageRequest = { + message: 'Prebid Native', + action: 'assetRequest', + adId: '123', + assets: ['hb_native_body', 'hb_native_image', 'hb_native_linkurl'], + }; + + const message = getAssetMessage(messageRequest, bid); + + expect(message.assets.length).to.equal(3); + expect(message.assets).to.deep.include({ + key: 'body', + value: bid.native.body, + }); + expect(message.assets).to.deep.include({ + key: 'image', + value: bid.native.image.url, + }); + expect(message.assets).to.deep.include({ + key: 'clickUrl', + value: bid.native.clickUrl, + }); + checkRenderer(message); + }); + + it('creates native all asset message', function () { + const messageRequest = { + message: 'Prebid Native', + action: 'allAssetRequest', + adId: '123', + }; + + const message = getAllAssetsMessage(messageRequest, bid); + + expect(message.assets.length).to.equal(10); + expect(message.assets).to.deep.include({ + key: 'body', + value: bid.native.body, + }); + expect(message.assets).to.deep.include({ + key: 'image', + value: bid.native.image.url, + }); + expect(message.assets).to.deep.include({ + key: 'clickUrl', + value: bid.native.clickUrl, + }); + expect(message.assets).to.deep.include({ + key: 'title', + value: bid.native.title, + }); + expect(message.assets).to.deep.include({ + key: 'icon', + value: bid.native.icon.url, + }); + expect(message.assets).to.deep.include({ + key: 'cta', + value: bid.native.cta, + }); + expect(message.assets).to.deep.include({ + key: 'sponsoredBy', + value: bid.native.sponsoredBy, + }); + expect(message.assets).to.deep.include({ + key: 'foo', + value: bid.native.ext.foo, + }); + expect(message.assets).to.deep.include({ + key: 'baz', + value: bid.native.ext.baz, + }); + checkRenderer(message); + }); + + it('creates native all asset message with only defined fields', function () { + const messageRequest = { + message: 'Prebid Native', + action: 'allAssetRequest', + adId: '123', + }; + + const message = getAllAssetsMessage(messageRequest, bidWithUndefinedFields); + + expect(message.assets.length).to.equal(4); + expect(message.assets).to.deep.include({ + key: 'clickUrl', + value: bid.native.clickUrl, + }); + expect(message.assets).to.deep.include({ + key: 'title', + value: bid.native.title, + }); + expect(message.assets).to.deep.include({ + key: 'sponsoredBy', + value: bid.native.sponsoredBy, + }); + expect(message.assets).to.deep.include({ + key: 'foo', + value: bid.native.ext.foo, + }); + checkRenderer(message); + }); + + it('creates native all asset message with complete format', function () { + const messageRequest = { + message: 'Prebid Native', + action: 'allAssetRequest', + adId: '123', + }; + + const message = getAllAssetsMessage(messageRequest, completeNativeBid); + + expect(message.assets.length).to.equal(10); + expect(message.assets).to.deep.include({ + key: 'body', + value: bid.native.body, + }); + expect(message.assets).to.deep.include({ + key: 'image', + value: bid.native.image.url, + }); + expect(message.assets).to.deep.include({ + key: 'clickUrl', + value: bid.native.clickUrl, + }); + expect(message.assets).to.deep.include({ + key: 'title', + value: bid.native.title, + }); + expect(message.assets).to.deep.include({ + key: 'icon', + value: bid.native.icon.url, + }); + expect(message.assets).to.deep.include({ + key: 'cta', + value: bid.native.cta, + }); + expect(message.assets).to.deep.include({ + key: 'sponsoredBy', + value: bid.native.sponsoredBy, + }); + expect(message.assets).to.deep.include({ + key: 'privacyLink', + value: ortbBid.native.ortb.privacy, + }); + expect(message.assets).to.deep.include({ + key: 'foo', + value: bid.native.ext.foo, + }); + expect(message.assets).to.deep.include({ + key: 'baz', + value: bid.native.ext.baz, + }); + checkRenderer(message); + }); + + it('if necessary, adds ortb response when the request was in ortb', () => { + const messageRequest = { + message: 'Prebid Native', + action: 'allAssetRequest', + adId: '123', + }; + adUnit = {mediaTypes: {native: {ortb: ortbRequest}}, nativeOrtbRequest: ortbRequest} + const message = getAllAssetsMessage(messageRequest, bid); + const expected = toOrtbNativeResponse(bid.native, ortbRequest) + expect(message.ortb).to.eql(expected); + checkRenderer(message); + }); }); }); - - it('if necessary, adds ortb response when the request was in ortb', () => { - const messageRequest = { - message: 'Prebid Native', - action: 'allAssetRequest', - adId: '123', - }; - adUnit = {mediaTypes: {native: {ortb: ortbRequest}}, nativeOrtbRequest: ortbRequest} - const message = getAllAssetsMessage(messageRequest, bid); - const expected = toOrtbNativeResponse(bid.native, ortbRequest) - expect(message.ortb).to.eql(expected); - }) - }) + }); const SAMPLE_ORTB_REQUEST = toOrtbNativeRequest({ title: 'vtitle',