diff --git a/modules/nodalsAiRtdProvider.js b/modules/nodalsAiRtdProvider.js new file mode 100644 index 00000000000..db4c72f1419 --- /dev/null +++ b/modules/nodalsAiRtdProvider.js @@ -0,0 +1,305 @@ +import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; +import { loadExternalScript } from '../src/adloader.js'; +import { ajax } from '../src/ajax.js'; +import { submodule } from '../src/hook.js'; +import { getRefererInfo } from '../src/refererDetection.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { prefixLog } from '../src/utils.js'; + +const MODULE_NAME = 'nodalsAi'; +const GVLID = 1360; +const PUB_ENDPOINT_ORIGIN = 'https://nodals.io'; +const LOCAL_STORAGE_KEY = 'signals.nodals.ai'; +const STORAGE_TTL = 3600; // 1 hour in seconds + +const fillTemplate = (strings, ...keys) => { + return function (values) { + return strings.reduce((result, str, i) => { + const key = keys[i - 1]; + return result + (key ? values[key] || '' : '') + str; + }); + }; +}; + +const PUB_ENDPOINT_PATH = fillTemplate`/p/v1/${'propertyId'}/config?${'consentParams'}`; +const { logInfo, logWarn, logError } = prefixLog('[NodalsAiRTDProvider]'); + +class NodalsAiRtdProvider { + // Public properties + name = MODULE_NAME; + gvlid = GVLID; + + // Exposed for testing + storage = getStorageManager({ + moduleType: MODULE_TYPE_RTD, + moduleName: MODULE_NAME, + }); + + STORAGE_KEY = LOCAL_STORAGE_KEY; + + // Private properties + #propertyId = null; + #overrides = {}; + + // Public methods + + /** + * Initialises the class with the provided config and user consent. + * @param {Object} config - Configuration object for the module. + * @param {Object} userConsent - User consent object for GDPR or other purposes. + */ + init(config, userConsent) { + const params = config?.params || {}; + if ( + this.#isValidConfig(params) && + this.#hasRequiredUserConsent(userConsent) + ) { + this.#propertyId = params.propertyId; + this.#setOverrides(params); + const storedData = this.#readFromStorage( + this.#overrides?.storageKey || this.STORAGE_KEY + ); + if (storedData === null) { + this.#fetchRules(userConsent); + } else { + this.#loadAdLibraries(storedData.deps || []); + } + return true; + } else { + logWarn('Invalid configuration or missing user consent.'); + return false; + } + } + + /** + * Retrieves targeting data by fetching and processing signals. + * @param {Array} adUnitArray - Array of ad units. + * @param {Object} config - Configuration object. + * @param {Object} userConsent - User consent object. + * @returns {Object} - Targeting data. + */ + getTargetingData(adUnitArray, config, userConsent) { + let targetingData = {}; + if (!this.#hasRequiredUserConsent(userConsent)) { + return targetingData; + } + const storedData = this.#readFromStorage( + this.#overrides?.storageKey || this.STORAGE_KEY + ); + if (storedData === null) { + return targetingData; + } + const facts = Object.assign({}, storedData?.facts ?? {}); + facts['page.url'] = getRefererInfo().page; + const targetingEngine = window?.$nodals?.adTargetingEngine['latest']; + try { + targetingEngine.init(config, facts); + targetingData = targetingEngine.getTargetingData( + adUnitArray, + storedData, + userConsent + ); + } catch (error) { + logError(`Error determining targeting keys: ${error}`); + } + return targetingData; + } + + // Private methods + #setOverrides(params) { + if (params?.storage?.ttl && typeof params.storage.ttl === 'number') { + this.#overrides.storageTTL = params.storage.ttl; + } + this.#overrides.storageKey = params?.storage?.key; + this.#overrides.endpointOrigin = params?.endpoint?.origin; + } + + /** + * Validates if the provided module input parameters are valid. + * @param {Object} params - Parameters object from the module configuration. + * @returns {boolean} - True if parameters are valid, false otherwise. + */ + // eslint-disable-next-line no-dupe-class-members + #isValidConfig(params) { + // Basic validation logic + if (typeof params === 'object' && params?.propertyId) { + return true; + } + logWarn('Invalid configuration'); + return false; + } + + /** + * Checks if the user has provided the required consent. + * @param {Object} userConsent - User consent object. + * @returns {boolean} - True if the user consent is valid, false otherwise. + */ + // eslint-disable-next-line no-dupe-class-members + #hasRequiredUserConsent(userConsent) { + if (userConsent?.gdpr?.gdprApplies !== true) { + return true; + } + if ( + userConsent?.gdpr?.vendorData?.vendor?.consents?.[this.gvlid] === false + ) { + return false; + } else if (userConsent?.gdpr?.vendorData?.purpose?.consents[1] === false) { + return false; + } + return true; + } + + /** + * @param {string} key - The key of the data to retrieve. + * @returns {string|null} - The data from localStorage, or null if not found. + */ + // eslint-disable-next-line no-dupe-class-members + #readFromStorage(key) { + if ( + this.storage.hasLocalStorage() && + this.storage.localStorageIsEnabled() + ) { + try { + const entry = this.storage.getDataFromLocalStorage(key); + if (!entry) { + return null; + } + const dataEnvelope = JSON.parse(entry); + if (this.#dataIsStale(dataEnvelope)) { + this.storage.removeDataFromLocalStorage(key); + return null; + } + if (!dataEnvelope.data) { + throw new Error('Data envelope is missing \'data\' property.'); + } + return dataEnvelope.data; + } catch (error) { + logError(`Corrupted data in local storage: ${error}`); + return null; + } + } else { + logError('Local storage is not available or not enabled.'); + return null; + } + } + + /** + * Writes data to localStorage. + * @param {string} key - The key under which to store the data. + * @param {Object} data - The data to store. + */ + // eslint-disable-next-line no-dupe-class-members + #writeToStorage(key, data) { + if ( + this.storage.hasLocalStorage() && + this.storage.localStorageIsEnabled() + ) { + const storageObject = { + createdAt: Date.now(), + data, + }; + this.storage.setDataInLocalStorage(key, JSON.stringify(storageObject)); + } else { + logError('Local storage is not available or not enabled.'); + } + } + + /** + * Checks if the provided data is stale. + * @param {Object} dataEnvelope - The data envelope object. + * @returns {boolean} - True if the data is stale, false otherwise. + */ + // eslint-disable-next-line no-dupe-class-members + #dataIsStale(dataEnvelope) { + const currentTime = Date.now(); + const dataTime = dataEnvelope.createdAt || 0; + const staleThreshold = this.#overrides?.storageTTL ?? dataEnvelope?.data?.meta?.ttl ?? STORAGE_TTL; + return currentTime - dataTime >= (staleThreshold * 1000); + } + + // eslint-disable-next-line no-dupe-class-members + #getEndpointUrl(userConsent) { + const endpointOrigin = + this.#overrides.endpointOrigin || PUB_ENDPOINT_ORIGIN; + const parameterMap = { + gdpr_consent: userConsent?.gdpr?.consentString ?? '', + gdpr: userConsent?.gdpr?.gdprApplies ? '1' : '0', + us_privacy: userConsent?.uspConsent ?? '', + gpp: userConsent?.gpp?.gppString ?? '', + gpp_sid: + userConsent.gpp && Array.isArray(userConsent.gpp.applicableSections) + ? userConsent.gpp.applicableSections.join(',') + : '', + }; + const querystring = new URLSearchParams(parameterMap).toString(); + const values = { + propertyId: this.#propertyId, + consentParams: querystring, + }; + const path = PUB_ENDPOINT_PATH(values); + return `${endpointOrigin}${path}`; + } + + /** + * Initiates the request to fetch rule data from the publisher endpoint. + */ + // eslint-disable-next-line no-dupe-class-members + #fetchRules(userConsent) { + const endpointUrl = this.#getEndpointUrl(userConsent); + + const callback = { + success: (response, req) => { + this.#handleServerResponse(response, req); + }, + error: (error, req) => { + this.#handleServerError(error, req); + }, + }; + + const options = { + method: 'GET', + withCredentials: false, + }; + + logInfo(`Fetching ad rules from: ${endpointUrl}`); + ajax(endpointUrl, callback, null, options); + } + + /** + * Handles the server response, processes it and extracts relevant data. + * @param {Object} response - The server response object. + * @returns {Object} - Processed data from the response. + */ + // eslint-disable-next-line no-dupe-class-members + #handleServerResponse(response, req) { + let data; + try { + data = JSON.parse(response); + } catch (error) { + throw `Error parsing response: ${error}`; + } + this.#writeToStorage(this.#overrides?.storageKey || this.STORAGE_KEY, data); + this.#loadAdLibraries(data.deps || []); + } + + // eslint-disable-next-line no-dupe-class-members + #handleServerError(error, req) { + logError(`Publisher endpoint response error: ${error}`); + } + + // eslint-disable-next-line no-dupe-class-members + #loadAdLibraries(deps) { + // eslint-disable-next-line no-unused-vars + for (const [key, value] of Object.entries(deps)) { + if (typeof value === 'string') { + loadExternalScript(value, MODULE_TYPE_RTD, MODULE_NAME, () => { + // noop + }); + } + } + } +} + +export const nodalsAiRtdSubmodule = new NodalsAiRtdProvider(); + +submodule('realTimeData', nodalsAiRtdSubmodule); diff --git a/modules/nodalsAiRtdProvider.md b/modules/nodalsAiRtdProvider.md new file mode 100644 index 00000000000..78cfe534cef --- /dev/null +++ b/modules/nodalsAiRtdProvider.md @@ -0,0 +1,58 @@ +# Nodals AI Real-Time Data Module + +## Overview + +Module Name: Nodals AI Rtd Provider +Module Type: Rtd Provider +Maintainer: prebid-integrations@nodals.ai + +Nodals AI provides a real-time data prebid module that will analyse first-party signals present on page load, determine the value of them to Nodals’ advertisers and add a key-value to the ad server call to indicate that value. The Nodals AI RTD module loads external code as part of this process. + +In order to be able to utilise this module, please contact [info@nodals.ai](mailto:info@nodals.ai) for account setup and detailed GAM setup instructions. + +## Build + +First, ensure that you include the generic Prebid RTD Module _and_ the Nodals AI RTD module into your Prebid build: + +```bash +gulp build --modules=rtdModule,nodalsAiRtdProvider +``` + +## Configuration + +Update your Prebid configuration to enable the Nodals AI RTD module, as illustrated in the example below: + +```javascript +pbjs.setConfig({ + ..., + realTimeData: { + auctionDelay: 100, // optional auction delay + dataProviders: [{ + name: 'nodalsAi', + waitForIt: true, // should be true only if there's an `auctionDelay` + params: { + propertyId: 'c10516af' // obtain your property id from Nodals AI support + } + }] + }, + ... +}) +``` + +Configuration parameters: + +{: .table .table-bordered .table-striped } + +| Name | Scope | Description | Example | Type | +| --------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------- | --------------- | +| `name` | required | Real time data module name: Always `'nodalsAi'` | `'nodalsAi'` | `String` | +| `waitForIt` | optional | Set to `true` if there's an `auctionDelay` defined (defaults to `false`) | `false` | `Boolean` | +| `params` | required | Submodule configuration parameters | `{}` | `Object` | +| `params.propertyId` | required | Publisher specific identifier, provided by Nodals AI | `'76346cf3'` | `String` | +| `params.storage` | optional | Optional storage configiration | `{}` | `Object` | +| `params.storage.key` | optional | Storage key used to store Nodals AI data in local storage | `'yourKey'` | `String` | +| `params.storage.ttl` | optional | Time in seconds to retain Nodals AI data in storage until a refresh is required | `900` | `Integer` | +| `params.ptr` | optional | Optional partner configiration | `{}` | `Object` | +| `params.ptr.permutive` | optional | Optional configiration for Permutive Audience Platform | `{}` | `Object` | +| `params.ptr.permutive.cohorts` | optional | A method for the publisher to explicitly supply Permutive Cohort IDs, disabling automatic fetching by this RTD module | `['66711', '39032', '311']` | `Array` | +| `params.ptr.permutive.storageKey` | optional | Publisher specific Permutive storage key where cohort data is held. | `'_psegs'` | `String` | diff --git a/src/adloader.js b/src/adloader.js index bf695dd627b..87beca5a5b9 100644 --- a/src/adloader.js +++ b/src/adloader.js @@ -37,6 +37,7 @@ const _approvedLoadExternalJSList = [ '51Degrees', 'symitridap', 'wurfl', + 'nodalsAi', // UserId Submodules 'justtag', 'tncId', diff --git a/test/spec/modules/nodalsAiRtdProvider_spec.js b/test/spec/modules/nodalsAiRtdProvider_spec.js new file mode 100644 index 00000000000..b93d48c1f5f --- /dev/null +++ b/test/spec/modules/nodalsAiRtdProvider_spec.js @@ -0,0 +1,555 @@ +import { expect } from 'chai'; +import { MODULE_TYPE_RTD } from 'src/activities/modules.js'; +import { loadExternalScriptStub } from 'test/mocks/adloaderStub.js'; +import { server } from 'test/mocks/xhr.js'; + +import { nodalsAiRtdSubmodule } from 'modules/nodalsAiRtdProvider.js'; + +const jsonResponseHeaders = { + 'Content-Type': 'application/json', +}; + +const successPubEndpointResponse = { + deps: { + '1.0.0': 'https://static.nodals.io/sdk/rule/1.0.0/engine.js', + '1.1.0': 'https://static.nodals.io/sdk/rule/1.1.0/engine.js', + }, + facts: { + 'browser.name': 'safari', + 'geo.country': 'AR', + }, + campaigns: [ + { + id: 1234, + ads: [ + { + delivery_id: '1234', + property_id: 'fd32da', + weighting: 1, + kvs: [ + { + k: 'nodals', + v: '1', + }, + ], + rules: { + engine: { + version: '1.0.0', + }, + conditions: { + ANY: [ + { + fact: 'id', + op: 'allin', + val: ['1', '2', '3'], + }, + ], + NONE: [ + { + fact: 'ua.browser', + op: 'eq', + val: 'opera', + }, + ], + }, + }, + }, + ], + }, + ], +}; + +const engineGetTargetingDataReturnValue = { + adUnit1: { + adv1: 'foobarbaz', + }, +}; + +const generateGdprConsent = (consent = {}) => { + const defaults = { + gdprApplies: true, + purpose1Consent: true, + nodalsConsent: true, + }; + const mergedConsent = Object.assign({}, defaults, consent); + return { + gdpr: { + gdprApplies: mergedConsent.gdprApplies, + consentString: mergedConsent.consentString, + vendorData: { + purpose: { + consents: { + 1: mergedConsent.purpose1Consent, + 3: true, + 4: true, + 5: true, + 6: true, + 9: true, + }, + }, + specialFeatureOptins: { + 1: true, + }, + vendor: { + consents: { + 1360: mergedConsent.nodalsConsent, + }, + }, + }, + }, + }; +}; + +const setDataInLocalStorage = (data) => { + const storageData = { ...data }; + nodalsAiRtdSubmodule.storage.setDataInLocalStorage( + nodalsAiRtdSubmodule.STORAGE_KEY, + JSON.stringify(storageData) + ); +}; + +describe('NodalsAI RTD Provider', () => { + let sandbox; + let validConfig; + + beforeEach(() => { + validConfig = { params: { propertyId: '10312dd2' } }; + + sandbox = sinon.sandbox.create(); + nodalsAiRtdSubmodule.storage.removeDataFromLocalStorage( + nodalsAiRtdSubmodule.STORAGE_KEY + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('Module properties', () => { + it('should have the name property set correctly', function () { + expect(nodalsAiRtdSubmodule.name).equals('nodalsAi'); + }); + + it('should expose the correct TCF Global Vendor ID', function () { + expect(nodalsAiRtdSubmodule.gvlid).equals(1360); + }); + }); + + describe('init()', () => { + describe('when initialised with empty consent data', () => { + const userConsent = {}; + + it('should return true when initialized with valid config and empty user consent', function () { + const result = nodalsAiRtdSubmodule.init(validConfig, userConsent); + expect(result).to.be.true; + expect(server.requests.length).to.equal(1); + }); + + it('should return false when initialized with invalid config', () => { + const config = { params: { invalid: true } }; + const result = nodalsAiRtdSubmodule.init(config, userConsent); + expect(result).to.be.false; + expect(server.requests.length).to.equal(0); + }); + }); + + describe('when initialised with valid config data', () => { + it('should return false when user is under GDPR jurisdiction and purpose1 has not been granted', () => { + const userConsent = generateGdprConsent({ purpose1Consent: false }); + const result = nodalsAiRtdSubmodule.init(validConfig, userConsent); + expect(result).to.be.false; + }); + + it('should return false when user is under GDPR jurisdiction and Nodals AI as a vendor has no consent', () => { + const userConsent = generateGdprConsent({ nodalsConsent: false }); + const result = nodalsAiRtdSubmodule.init(validConfig, userConsent); + expect(result).to.be.false; + }); + + it('should return true when user is under GDPR jurisdiction and all consent provided', function () { + const userConsent = generateGdprConsent(); + const result = nodalsAiRtdSubmodule.init(validConfig, userConsent); + expect(result).to.be.true; + }); + + it('should return true when user is not under GDPR jurisdiction', () => { + const userConsent = generateGdprConsent({ gdprApplies: false }); + const result = nodalsAiRtdSubmodule.init(validConfig, userConsent); + expect(result).to.be.true; + }); + }); + + describe('when initialised with valid config and data already in storage', () => { + it('should return true and not make a remote request when stored data is valid', function () { + setDataInLocalStorage({ data: { foo: 'bar' }, createdAt: Date.now() }); + const result = nodalsAiRtdSubmodule.init(validConfig, {}); + expect(result).to.be.true; + expect(server.requests.length).to.equal(0); + }); + + it('should return true and make a remote request when stored data has no TTL defined', function () { + setDataInLocalStorage({ data: { foo: 'bar' } }); + const result = nodalsAiRtdSubmodule.init(validConfig, {}); + expect(result).to.be.true; + expect(server.requests.length).to.equal(1); + }); + + it('should return true and make a remote request when stored data has expired', function () { + setDataInLocalStorage({ data: { foo: 'bar' }, createdAt: 100 }); + const result = nodalsAiRtdSubmodule.init(validConfig, {}); + expect(result).to.be.true; + expect(server.requests.length).to.equal(1); + }); + + it('should detect stale data if override TTL is exceeded', function () { + const fiveMinutesAgoMs = Date.now() - (5 * 60 * 1000); + setDataInLocalStorage({ + data: { foo: 'bar' }, + createdAt: fiveMinutesAgoMs, + }); + const config = Object.assign({}, validConfig); + config.params.storage = { ttl: 4 * 60 }; + const result = nodalsAiRtdSubmodule.init(config, {}); + expect(result).to.be.true; + expect(server.requests.length).to.equal(1); + }); + + it('should detect stale data if remote defined TTL is exceeded', function () { + const fiveMinutesAgoMs = Date.now() - (5 * 60 * 1000); + setDataInLocalStorage({ + data: { foo: 'bar', meta: { ttl: 4 * 60 } }, + createdAt: fiveMinutesAgoMs, + }); + const result = nodalsAiRtdSubmodule.init(validConfig, {}); + expect(result).to.be.true; + expect(server.requests.length).to.equal(1); + }); + + it('should respect pub defined TTL over remote defined TTL', function () { + const fiveMinutesAgoMs = Date.now() - (5 * 60 * 1000); + setDataInLocalStorage({ + data: { foo: 'bar', meta: { ttl: 4 * 60 } }, + createdAt: fiveMinutesAgoMs, + }); + const config = Object.assign({}, validConfig); + config.params.storage = { ttl: 6 * 60 }; + const result = nodalsAiRtdSubmodule.init(config, {}); + expect(result).to.be.true; + expect(server.requests.length).to.equal(0); + }); + + it('should NOT detect stale data if override TTL is not exceeded', function () { + const fiveMinutesAgoMs = Date.now() - 5 * 60 * 1000; + setDataInLocalStorage({ + data: { foo: 'bar' }, + createdAt: fiveMinutesAgoMs, + }); + const config = Object.assign({}, validConfig); + config.params.storage = { ttl: 6 * 60 }; + const result = nodalsAiRtdSubmodule.init(config, {}); + expect(result).to.be.true; + expect(server.requests.length).to.equal(0); + }); + + it('should return true and make a remote request when data stored under default key, but override key specified', () => { + setDataInLocalStorage({ data: { foo: 'bar' }, createdAt: Date.now() }); + const config = Object.assign({}, validConfig); + config.params.storage = { key: '_foobarbaz_' }; + const result = nodalsAiRtdSubmodule.init(config, {}); + expect(result).to.be.true; + expect(server.requests.length).to.equal(1); + }); + }); + + describe('when performing requests to the publisher endpoint', () => { + it('should construct the correct URL to the default origin', () => { + const userConsent = generateGdprConsent(); + nodalsAiRtdSubmodule.init(validConfig, userConsent); + + let request = server.requests[0]; + + expect(request.method).to.equal('GET'); + expect(request.withCredentials).to.be.false; + const requestUrl = new URL(request.url); + expect(requestUrl.origin).to.equal('https://nodals.io'); + }); + + it('should construct the URL to the overridden origin when specified in the config', () => { + const config = Object.assign({}, validConfig); + config.params.endpoint = { origin: 'http://localhost:8000' }; + const userConsent = generateGdprConsent(); + nodalsAiRtdSubmodule.init(config, userConsent); + + let request = server.requests[0]; + + expect(request.method).to.equal('GET'); + expect(request.withCredentials).to.be.false; + const requestUrl = new URL(request.url); + expect(requestUrl.origin).to.equal('http://localhost:8000'); + }); + + it('should construct the correct URL with the correct path', () => { + const userConsent = generateGdprConsent(); + nodalsAiRtdSubmodule.init(validConfig, userConsent); + + let request = server.requests[0]; + const requestUrl = new URL(request.url); + expect(requestUrl.pathname).to.equal('/p/v1/10312dd2/config'); + }); + + it('should construct the correct URL with the correct GDPR query params', () => { + const consentData = { + consentString: 'foobarbaz', + }; + const userConsent = generateGdprConsent(consentData); + nodalsAiRtdSubmodule.init(validConfig, userConsent); + + let request = server.requests[0]; + const requestUrl = new URL(request.url); + expect(requestUrl.searchParams.get('gdpr')).to.equal('1'); + expect(requestUrl.searchParams.get('gdpr_consent')).to.equal( + 'foobarbaz' + ); + expect(requestUrl.searchParams.get('us_privacy')).to.equal(''); + expect(requestUrl.searchParams.get('gpp')).to.equal(''); + expect(requestUrl.searchParams.get('gpp_sid')).to.equal(''); + }); + }); + + describe('when handling responses from the publisher endpoint', () => { + it('should store successful response data in local storage', () => { + const userConsent = generateGdprConsent(); + nodalsAiRtdSubmodule.init(validConfig, userConsent); + + let request = server.requests[0]; + request.respond( + 200, + jsonResponseHeaders, + JSON.stringify(successPubEndpointResponse) + ); + + const storedData = JSON.parse( + nodalsAiRtdSubmodule.storage.getDataFromLocalStorage( + nodalsAiRtdSubmodule.STORAGE_KEY + ) + ); + expect(request.method).to.equal('GET'); + expect(storedData).to.have.property('createdAt'); + expect(storedData.data).to.deep.equal(successPubEndpointResponse); + }); + + it('should store successful response data in local storage under the override key', () => { + const userConsent = generateGdprConsent(); + const config = Object.assign({}, validConfig); + config.params.storage = { key: '_foobarbaz_' }; + nodalsAiRtdSubmodule.init(config, userConsent); + + let request = server.requests[0]; + request.respond( + 200, + jsonResponseHeaders, + JSON.stringify(successPubEndpointResponse) + ); + + const storedData = JSON.parse( + nodalsAiRtdSubmodule.storage.getDataFromLocalStorage('_foobarbaz_') + ); + expect(request.method).to.equal('GET'); + expect(storedData).to.have.property('createdAt'); + expect(storedData.data).to.deep.equal(successPubEndpointResponse); + }); + + it('should attempt to load the referenced script libraries contained in the response payload', () => { + const userConsent = generateGdprConsent(); + nodalsAiRtdSubmodule.init(validConfig, userConsent); + + let request = server.requests[0]; + request.respond( + 200, + jsonResponseHeaders, + JSON.stringify(successPubEndpointResponse) + ); + + expect(loadExternalScriptStub.calledTwice).to.be.true; + expect( + loadExternalScriptStub.calledWith( + successPubEndpointResponse.deps['1.0.0'], + MODULE_TYPE_RTD, + nodalsAiRtdSubmodule.name + ) + ).to.be.true; + expect( + loadExternalScriptStub.calledWith( + successPubEndpointResponse.deps['1.1.0'], + MODULE_TYPE_RTD, + nodalsAiRtdSubmodule.name + ) + ).to.be.true; + }); + }); + }); + + describe('getTargetingData()', () => { + afterEach(() => { + if (window.$nodals) { + delete window.$nodals; + } + }); + + const stubVersionedTargetingEngine = (returnValue, raiseError = false) => { + const version = 'latest'; + const initStub = sinon.stub(); + const getTargetingDataStub = sinon.stub(); + if (raiseError) { + getTargetingDataStub.throws(new Error('Stubbed error')); + } else { + getTargetingDataStub.returns(returnValue); + } + window.$nodals = window.$nodals || {}; + window.$nodals.adTargetingEngine = window.$nodals.adTargetingEngine || {}; + window.$nodals.adTargetingEngine[version] = { + init: initStub, + getTargetingData: getTargetingDataStub, + }; + return window.$nodals.adTargetingEngine[version]; + }; + + it('should return an empty object when no data is available in local storage', () => { + const result = nodalsAiRtdSubmodule.getTargetingData( + ['adUnit1'], + validConfig, + {} + ); + expect(result).to.deep.equal({}); + }); + + it('should return an empty object when getTargetingData throws error', () => { + stubVersionedTargetingEngine({}, true); // TODO: Change the data + const userConsent = generateGdprConsent(); + setDataInLocalStorage({ + data: successPubEndpointResponse, + createdAt: Date.now(), + }); + const result = nodalsAiRtdSubmodule.getTargetingData( + ['adUnit1'], + validConfig, + userConsent + ); + expect(result).to.deep.equal({}); + }); + + it('should initialise the versioned targeting engine', () => { + const returnData = {}; + const engine = stubVersionedTargetingEngine(returnData); + const userConsent = generateGdprConsent(); + setDataInLocalStorage({ + data: successPubEndpointResponse, + createdAt: Date.now(), + }); + nodalsAiRtdSubmodule.getTargetingData( + ['adUnit1'], + validConfig, + userConsent + ); + + expect(engine.init.called).to.be.true; + const args = engine.init.getCall(0).args; + expect(args[0]).to.deep.equal(validConfig); + expect(args[1]).to.deep.include(successPubEndpointResponse.facts); + }); + + it('should proxy the correct data to engine.init()', () => { + const engine = stubVersionedTargetingEngine( + engineGetTargetingDataReturnValue + ); + const userConsent = generateGdprConsent(); + setDataInLocalStorage({ + data: successPubEndpointResponse, + createdAt: Date.now(), + }); + nodalsAiRtdSubmodule.getTargetingData( + ['adUnit1', 'adUnit2'], + validConfig, + userConsent + ); + + expect(engine.init.called).to.be.true; + const args = engine.init.getCall(0).args; + expect(args[0]).to.deep.equal(validConfig); + expect(args[1]).to.be.an('object').with.keys(['browser.name', 'geo.country', 'page.url']); + }); + + it('should proxy the correct data to engine.getTargetingData()', () => { + const engine = stubVersionedTargetingEngine( + engineGetTargetingDataReturnValue + ); + const userConsent = generateGdprConsent(); + setDataInLocalStorage({ + data: successPubEndpointResponse, + createdAt: Date.now(), + }); + nodalsAiRtdSubmodule.getTargetingData( + ['adUnit1', 'adUnit2'], + validConfig, + userConsent + ); + + expect(engine.getTargetingData.called).to.be.true; + const args = engine.getTargetingData.getCall(0).args; + expect(args[0]).to.deep.equal(['adUnit1', 'adUnit2']); + expect(args[1]).to.deep.include(successPubEndpointResponse); + expect(args[2]).to.deep.equal(userConsent); + }); + + it('should return the response from engine.getTargetingData when data is available and we have consent under GDPR jurisdiction', () => { + stubVersionedTargetingEngine(engineGetTargetingDataReturnValue); + const userConsent = generateGdprConsent(); + setDataInLocalStorage({ + data: successPubEndpointResponse, + createdAt: Date.now(), + }); + + const result = nodalsAiRtdSubmodule.getTargetingData( + ['adUnit1'], + validConfig, + userConsent + ); + + expect(result).to.deep.equal(engineGetTargetingDataReturnValue); + }); + + it('should return the response from engine.getTargetingData when data is available and we are NOT under GDPR jurisdiction', () => { + stubVersionedTargetingEngine(engineGetTargetingDataReturnValue); + const userConsent = generateGdprConsent({ gdprApplies: false }); + setDataInLocalStorage({ + data: successPubEndpointResponse, + createdAt: Date.now(), + }); + + const result = nodalsAiRtdSubmodule.getTargetingData( + ['adUnit1'], + validConfig, + userConsent + ); + + expect(result).to.deep.equal(engineGetTargetingDataReturnValue); + }); + + it('should return an empty object when data is available, but user has not provided consent to Nodals AI as a vendor', () => { + stubVersionedTargetingEngine(engineGetTargetingDataReturnValue); + const userConsent = generateGdprConsent({ nodalsConsent: false }); + setDataInLocalStorage({ + data: successPubEndpointResponse, + createdAt: Date.now(), + }); + + const result = nodalsAiRtdSubmodule.getTargetingData( + ['adUnit1'], + validConfig, + userConsent + ); + + expect(result).to.deep.equal({}); + }); + }); +});