diff --git a/integrationExamples/gpt/optableRtdProvider_example.html b/integrationExamples/gpt/optableRtdProvider_example.html index 5e1c8a77cb..e803175869 100644 --- a/integrationExamples/gpt/optableRtdProvider_example.html +++ b/integrationExamples/gpt/optableRtdProvider_example.html @@ -2,8 +2,6 @@ Optable RTD submodule example - Prebid.js - - @@ -130,11 +128,6 @@ ]; pbjs.setConfig({ - optableRtdConfig: { // optional, check the doc for explanation - email: 'email-sha256-hash', - phone: 'phone-sha256-hash', - postal_code: 'postal_code', - }, debug: true, // use only for testing, remove in production realTimeData: { auctionDelay: 1000, // should be set lower in production use @@ -143,26 +136,23 @@ name: 'optable', waitForIt: true, params: { - // bundleUrl: "https://prebidtest.solutions.cdn.optable.co/public-assets/prebidtest-sdk.js?hello=world", - // adserverTargeting: false, - // handleRtd: async (reqBidsConfigObj, optableExtraData, mergeFn) => { - // const optableBundle = /** @type {Object} */ (window.optable); - // console.warn('Entering custom RTD handler'); - // console.warn('reqBidsConfigObj', reqBidsConfigObj); - // console.warn('optableExtraData', optableExtraData); - // console.warn('mergeFn', mergeFn); - // - // // Call Optable DCN for targeting data and return the ORTB2 object - // const targetingData = await optableBundle.instance.targeting(); - // - // if (!targetingData || !targetingData.ortb2) { - // return; - // } - // - // mergeFn( - // reqBidsConfigObj.ortb2Fragments.global, - // targetingData.ortb2, - // ); + // REQUIRED PARAMETERS: + host: 'na.edge.optable.co', // Your DCN hostname + node: 'prebidtest', // Your node identifier + site: 'prebidtest-sdk', // Your site identifier + + // OPTIONAL PARAMETERS: + cookies: false, // Cookie mode (default: true, set to false for cookieless) + // timeout: '500ms', // API timeout hint + // ids: ['i4:1.2.3.4'], // User identifiers (also auto-extracted from Prebid userId module) + // hids: ['hid1'], // Hint identifiers + + // CUSTOM HANDLER (optional): + // handleRtd: (reqBidsConfigObj, targetingData, mergeFn) => { + // console.log('Custom RTD handler'); + // console.log('Targeting data:', targetingData); + // // Perform custom logic here + // mergeFn(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2); // } } } @@ -172,12 +162,36 @@ pbjs.addAdUnits(adUnits); + // Track if we've already displayed the data (only show once) + let displayedEnrichedData = false; + pbjs.onEvent('bidRequested', function (data) { try { - window.optable.cmd.push(() => { + // Display the enriched user data from Optable RTD + if (!displayedEnrichedData && data.ortb2 && data.ortb2.user) { + console.log('Full bid request ortb2:', data.ortb2); + console.log('User data:', data.ortb2.user); + + const userData = data.ortb2.user; + const eidCount = userData.eids ? userData.eids.length : 0; + const dataCount = userData.data ? userData.data.length : 0; + + console.log(`Found ${eidCount} EIDs and ${dataCount} data segments`); + document.getElementById('enriched-optable').style.display = 'block'; - document.getElementById('enriched-optable-data').textContent = JSON.stringify(data.ortb2.user, null, 2); - }); + document.getElementById('enriched-optable-data').textContent = JSON.stringify(userData, null, 2); + displayedEnrichedData = true; + } + + // Display split test variant if present in adUnit ortb2Imp + if (data.bids && data.bids.length > 0) { + data.bids.forEach(bid => { + const splitTestVariant = bid.ortb2Imp?.ext?.optable?.splitTestVariant; + if (splitTestVariant) { + console.log(`Split test variant for ${bid.adUnitCode}: ${splitTestVariant}`); + } + }); + } } catch (e) { console.error('Exception while trying to display enriched data', e); } diff --git a/modules/optableRtdProvider.js b/modules/optableRtdProvider.js index 29638ba3a9..1ea7089147 100644 --- a/modules/optableRtdProvider.js +++ b/modules/optableRtdProvider.js @@ -1,47 +1,208 @@ +/** + * Optable Real-Time Data (RTD) Provider Module for Prebid.js + * See modules/optableRtdProvider.md for full documentation + */ + import {MODULE_TYPE_RTD} from '../src/activities/modules.js'; -import {loadExternalScript} from '../src/adloader.js'; -import {config} from '../src/config.js'; import {submodule} from '../src/hook.js'; import {deepAccess, mergeDeep, prefixLog} from '../src/utils.js'; +import {ajax} from '../src/ajax.js'; +import {getStorageManager} from '../src/storageManager.js'; const MODULE_NAME = 'optable'; export const LOG_PREFIX = `[${MODULE_NAME} RTD]:`; const optableLog = prefixLog(LOG_PREFIX); const {logMessage, logWarn, logError} = optableLog; +const storage = getStorageManager({moduleType: MODULE_TYPE_RTD, moduleName: MODULE_NAME}); + +// localStorage key for targeting cache (direct API mode only) +const OPTABLE_CACHE_KEY = 'optable-cache:targeting'; + +// Storage key prefix for passport (visitor ID) - compatible with Web SDK format +const PASSPORT_KEY_PREFIX = 'OPTABLE_PASSPORT_'; /** - * Extracts the parameters for Optable RTD module from the config object passed at instantiation + * Parse and validate module configuration * @param {Object} moduleConfig Configuration object for the module + * @returns {Object} Parsed configuration */ export const parseConfig = (moduleConfig) => { - let bundleUrl = deepAccess(moduleConfig, 'params.bundleUrl', null); + // Check for deprecated bundleUrl parameter + const bundleUrl = deepAccess(moduleConfig, 'params.bundleUrl', null); + if (bundleUrl) { + logError('bundleUrl parameter is no longer supported. Please either: (1) Load Optable SDK directly in your page HTML, OR (2) Switch to Direct API mode using host/node/site parameters. See documentation for details.'); + return null; + } + + const host = deepAccess(moduleConfig, 'params.host', null); + const node = deepAccess(moduleConfig, 'params.node', null); + const site = deepAccess(moduleConfig, 'params.site', null); const adserverTargeting = deepAccess(moduleConfig, 'params.adserverTargeting', true); - const handleRtd = deepAccess(moduleConfig, 'params.handleRtd', null); const instance = deepAccess(moduleConfig, 'params.instance', null); - // If present, trim the bundle URL - if (typeof bundleUrl === 'string') { - bundleUrl = bundleUrl.trim(); - } + const hasDirectApiConfig = host && node && site; - // Verify that bundleUrl is a valid URL: only secure (HTTPS) URLs are allowed - if (typeof bundleUrl === 'string' && bundleUrl.length && !bundleUrl.startsWith('https://')) { - logError('Invalid URL format for bundleUrl in moduleConfig. Only HTTPS URLs are allowed.'); - return {bundleUrl: null, adserverTargeting, handleRtd: null}; + if (host !== null && (typeof host !== 'string' || !host.trim())) { + logError('host parameter must be a non-empty string'); + return null; } + if (node !== null && (typeof node !== 'string' || !node.trim())) { + logError('node parameter must be a non-empty string'); + return null; + } + if (site !== null && (typeof site !== 'string' || !site.trim())) { + logError('site parameter must be a non-empty string'); + return null; + } + + const cookies = deepAccess(moduleConfig, 'params.cookies', true); + const timeout = deepAccess(moduleConfig, 'params.timeout', null); + const ids = deepAccess(moduleConfig, 'params.ids', []); + const hids = deepAccess(moduleConfig, 'params.hids', []); + const handleRtd = deepAccess(moduleConfig, 'params.handleRtd', null); if (handleRtd && typeof handleRtd !== 'function') { logError('handleRtd must be a function'); - return {bundleUrl, adserverTargeting, handleRtd: null}; + return null; } - const result = {bundleUrl, adserverTargeting, handleRtd}; - if (instance !== null) { - result.instance = instance; + if (!Array.isArray(ids)) { + logError('ids parameter must be an array'); + return null; + } + if (!Array.isArray(hids)) { + logError('hids parameter must be an array'); + return null; } - return result; + + return { + host: host ? host.trim() : null, + node: node ? node.trim() : null, + site: site ? site.trim() : null, + cookies, + timeout, + ids, + hids, + handleRtd, + adserverTargeting, + instance, + hasDirectApiConfig + }; } +// Global session ID (generated once per page load) +let sessionID = null; + +// Track if we've made a targeting API call this session (to avoid redundant calls) +let targetingCallMade = false; + +/** + * Generates a random session ID (base64url encoded 16-byte random value) + * @returns {string} Session ID + */ +export const generateSessionID = () => { + if (sessionID) { + return sessionID; + } + + // Generate 16 random bytes + const arr = new Uint8Array(16); + crypto.getRandomValues(arr); + + // Convert to base64url (URL-safe, no padding) + sessionID = btoa(String.fromCharCode(...arr)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + + return sessionID; +}; + +/** + * Base64 encode a string + * @param {string} str String to encode + * @returns {string} Base64 encoded string + */ +const encodeBase64 = (str) => { + return btoa(str); +}; + +/** + * Generate storage key for passport based on host/node + * @param {string} host DCN host + * @param {string} node Node identifier + * @returns {string} Storage key + */ +const generatePassportKey = (host, node) => { + const base = `${host}/${node}`; + return `${PASSPORT_KEY_PREFIX}${encodeBase64(base)}`; +}; + +/** + * Get passport from localStorage + * @param {string} host DCN host + * @param {string} node Node identifier + * @returns {string|null} Passport value or null + */ +export const getPassport = (host, node) => { + const key = generatePassportKey(host, node); + return storage.getDataFromLocalStorage(key); +}; + +/** + * Set passport in localStorage + * @param {string} host DCN host + * @param {string} node Node identifier + * @param {string} passport Passport value + */ +export const setPassport = (host, node, passport) => { + const key = generatePassportKey(host, node); + storage.setDataInLocalStorage(key, passport); +}; + +/** + * Get cached targeting data from localStorage + * @returns {Object|null} Cached targeting data or null + */ +const getCachedTargeting = () => { + const cacheData = storage.getDataFromLocalStorage(OPTABLE_CACHE_KEY); + if (cacheData) { + try { + const parsedData = JSON.parse(cacheData); + const eidCount = parsedData?.ortb2?.user?.eids?.length || 0; + logMessage(`Found cached targeting with ${eidCount} EIDs`); + return eidCount > 0 ? parsedData : null; + } catch (e) { + logWarn('Failed to parse cached targeting', e); + } + } + return null; +}; + +/** + * Set targeting data in localStorage + * @param {Object} targetingData Targeting response + */ +const setCachedTargeting = (targetingData) => { + if (!targetingData || !targetingData.ortb2) { + return; + } + storage.setDataInLocalStorage(OPTABLE_CACHE_KEY, JSON.stringify(targetingData)); +}; + +/** + * Check if Optable Web SDK is available on the page + * @param {string|null} instance SDK instance name (default: 'instance') + * @returns {boolean} True if SDK is available + */ +const isSDKAvailable = (instance = null) => { + const instanceKey = instance || 'instance'; + return typeof window !== 'undefined' && + window.optable && + window.optable[instanceKey] && + typeof window.optable[instanceKey].targeting === 'function'; +}; + /** * Wait for Optable SDK event to fire with targeting data * @param {string} eventName Name of the event to listen for @@ -60,7 +221,6 @@ const waitForOptableEvent = (eventName) => { const eventListener = (event) => { logMessage(`Received ${eventName} event`); - // Extract targeting data from event detail const targetingData = event.detail; window.removeEventListener(eventName, eventListener); resolve(targetingData); @@ -72,130 +232,427 @@ const waitForOptableEvent = (eventName) => { }; /** - * Default function to handle/enrich RTD data - * @param reqBidsConfigObj Bid request configuration object - * @param optableExtraData Additional data to be used by the Optable SDK - * @param mergeFn Function to merge data + * Handle RTD data using SDK mode (event-based) + * @param {Function} handleRtdFn Custom handler function or default + * @param {Object} reqBidsConfigObj Bid request configuration + * @param {Function} mergeFn Merge function * @returns {Promise} */ -export const defaultHandleRtd = async (reqBidsConfigObj, optableExtraData, mergeFn) => { - // Wait for the Optable SDK to dispatch targeting data via event - let targetingData = await waitForOptableEvent('optable-targeting:change'); +const handleSDKMode = async (handleRtdFn, reqBidsConfigObj, mergeFn) => { + const targetingData = await waitForOptableEvent('optable-targeting:change'); if (!targetingData || !targetingData.ortb2) { - logWarn('No targeting data found'); + logWarn('No targeting data from SDK event'); return; } - mergeFn( - reqBidsConfigObj.ortb2Fragments.global, - targetingData.ortb2, - ); - logMessage('Prebid\'s global ORTB2 object after merge: ', reqBidsConfigObj.ortb2Fragments.global); + if (handleRtdFn.constructor.name === 'AsyncFunction') { + await handleRtdFn(reqBidsConfigObj, targetingData, mergeFn); + } else { + handleRtdFn(reqBidsConfigObj, targetingData, mergeFn); + } }; /** - * Get data from Optable and merge it into the global ORTB2 object - * @param {Function} handleRtdFn Function to handle RTD data - * @param {Object} reqBidsConfigObj Bid request configuration object - * @param {Object} optableExtraData Additional data to be used by the Optable SDK - * @param {Function} mergeFn Function to merge data + * Extract consent information from Prebid's userConsent parameter + * @param {Object} userConsent User consent object passed by Prebid RTD framework + * @returns {Object} Consent object with GPP, GDPR strings, and deviceAccess flag */ -export const mergeOptableData = async (handleRtdFn, reqBidsConfigObj, optableExtraData, mergeFn) => { - if (handleRtdFn.constructor.name === 'AsyncFunction') { - await handleRtdFn(reqBidsConfigObj, optableExtraData, mergeFn); +const extractConsent = (userConsent) => { + const consent = { + deviceAccess: true, + gpp: null, + gppSectionIDs: null, + gdpr: null, + gdprApplies: null + }; + + // Extract GPP consent if available + if (userConsent?.gpp) { + consent.gpp = userConsent.gpp.gppString || null; + consent.gppSectionIDs = userConsent.gpp.applicableSections || null; + } + + // Extract GDPR consent if available + if (userConsent?.gdpr) { + consent.gdprApplies = userConsent.gdpr.gdprApplies; + consent.gdpr = userConsent.gdpr.consentString || null; + + // Extract deviceAccess from TCF Purpose 1 if available + if (userConsent.gdpr.vendorData) { + const purpose1Consent = userConsent.gdpr.vendorData.purpose?.consents?.[1] || + userConsent.gdpr.vendorData.publisher?.consents?.[1]; + if (purpose1Consent !== undefined) { + consent.deviceAccess = purpose1Consent; + } + } + } + + return consent; +}; + +/** + * Extract user identifiers from config and Prebid userId module + * @param {Array} configIds IDs from module config + * @param {Array} configHids HIDs from module config + * @param {Object} reqBidsConfigObj Bid request configuration + * @returns {Object} Object with ids and hids arrays + */ +const extractIdentifiers = (configIds, configHids, reqBidsConfigObj) => { + const ids = [...configIds]; + const hids = [...configHids]; + + // Note: We don't extract IDs from Prebid userId module (ortb2.user.ext.eids) + // because those are in ORTB format, not Optable's ID format (e:hash, c:ppid, etc.) + // Optable-specific IDs should be provided via params.ids configuration + + // Add default __passport__ ID if no other IDs provided + // This allows targeting to work with just passport + IP enrichment + if (ids.length === 0) { + ids.push('__passport__'); + } + + return { ids, hids }; +}; + +/** + * Build targeting API request URL with all query parameters + * @param {Object} params Parameters object + * @returns {string} Complete URL for targeting API + */ +const buildTargetingURL = (params) => { + const {host, node, site, ids, hids, consent, sessionId, passport, cookies, timeout} = params; + + const searchParams = new URLSearchParams(); + + ids.forEach(id => searchParams.append('id', id)); + hids.forEach(hid => searchParams.append('hid', hid)); + + searchParams.set('o', site); + searchParams.set('t', node); + searchParams.set('sid', sessionId); + searchParams.set('osdk', 'prebid-rtd-1.0.0'); + + if (consent.gpp) { + searchParams.set('gpp', consent.gpp); + } + if (consent.gppSectionIDs && Array.isArray(consent.gppSectionIDs)) { + searchParams.set('gpp_sid', consent.gppSectionIDs.join(',')); + } + if (consent.gdpr) { + searchParams.set('gdpr_consent', consent.gdpr); + } + if (consent.gdprApplies !== null && consent.gdprApplies !== undefined) { + searchParams.set('gdpr', consent.gdprApplies ? '1' : '0'); + } + + if (cookies) { + searchParams.set('cookies', 'yes'); } else { - handleRtdFn(reqBidsConfigObj, optableExtraData, mergeFn); + searchParams.set('cookies', 'no'); + searchParams.set('passport', passport || ''); + } + + if (timeout) { + searchParams.set('timeout', timeout); } + + const url = `https://${host}/v2/targeting?${searchParams.toString()}`; + return url; +}; + +/** + * Call the targeting API and return the response + * @param {Object} params Configuration parameters + * @returns {Promise} Targeting response or null + */ +const callTargetingAPI = (params) => { + return new Promise((resolve) => { + const url = buildTargetingURL(params); + const {host, node} = params; + + logMessage(`Calling targeting API: ${url.split('?')[0]}`); + + const ajaxOptions = { + method: 'GET', + withCredentials: params.consent.deviceAccess, + success: (responseText) => { + try { + const response = JSON.parse(responseText); + const eidCount = response?.ortb2?.user?.eids?.length || 0; + logMessage(`Targeting API returned ${eidCount} EIDs`); + + // Update passport if present in response + if (response.passport) { + logMessage('Updating passport from API response'); + setPassport(host, node, response.passport); + // Remove passport from response to prevent it being included in bid requests + delete response.passport; + } + + resolve(response); + } catch (e) { + logError('Failed to parse targeting API response', e); + resolve(null); + } + }, + error: (error) => { + logError('Targeting API call failed', error); + resolve(null); + } + }; + + ajax(url, ajaxOptions); + }); }; /** + * Default function to handle/enrich RTD data by merging targeting data into ortb2 * @param {Object} reqBidsConfigObj Bid request configuration object - * @param {Function} callback Called on completion - * @param {Object} moduleConfig Configuration for Optable RTD module - * @param {Object} userConsent + * @param {Object} targetingData Targeting data from API + * @param {Function} mergeFn Function to merge data + * @returns {void} */ -export const getBidRequestData = (reqBidsConfigObj, callback, moduleConfig, userConsent) => { +export const defaultHandleRtd = (reqBidsConfigObj, targetingData, mergeFn) => { + if (!targetingData || !targetingData.ortb2) { + logWarn('No targeting data found'); + return; + } + + const eidCount = targetingData.ortb2?.user?.eids?.length || 0; + logMessage(`defaultHandleRtd: received targeting data with ${eidCount} EIDs`); + logMessage('Merging ortb2 data into global ORTB2 fragments...'); + + mergeFn( + reqBidsConfigObj.ortb2Fragments.global, + targetingData.ortb2, + ); + + logMessage(`EIDs merged into ortb2Fragments.global.user.eids (${eidCount} EIDs)`); + + // Also add to user.ext.eids for additional coverage + if (targetingData.ortb2.user?.eids) { + const targetORTB2 = reqBidsConfigObj.ortb2Fragments.global; + targetORTB2.user = targetORTB2.user ?? {}; + targetORTB2.user.ext = targetORTB2.user.ext ?? {}; + targetORTB2.user.ext.eids = targetORTB2.user.ext.eids ?? []; + + logMessage('Also merging Optable EIDs into ortb2.user.ext.eids...'); + + // Merge EIDs into user.ext.eids + targetingData.ortb2.user.eids.forEach(eid => { + targetORTB2.user.ext.eids.push(eid); + }); + + logMessage(`EIDs also available in ortb2.user.ext.eids (${eidCount} EIDs)`); + } + + // Add split_test_assignment to adUnits ortb2Imp.ext.optable if present + if (targetingData.split_test_assignment) { + logMessage(`Split test assignment detected: ${targetingData.split_test_assignment}`); + + if (reqBidsConfigObj.adUnits && Array.isArray(reqBidsConfigObj.adUnits)) { + reqBidsConfigObj.adUnits.forEach(adUnit => { + adUnit.ortb2Imp = adUnit.ortb2Imp || {}; + adUnit.ortb2Imp.ext = adUnit.ortb2Imp.ext || {}; + adUnit.ortb2Imp.ext.optable = adUnit.ortb2Imp.ext.optable || {}; + adUnit.ortb2Imp.ext.optable.splitTestAssignment = targetingData.split_test_assignment; + }); + logMessage(`Split test assignment added to ${reqBidsConfigObj.adUnits.length} ad units`); + } + } + + logMessage(`SUCCESS: ${eidCount} EIDs will be included in bid requests`); +}; + +/** + * Main function called by Prebid to get bid request data + * Automatically detects SDK mode or Direct API mode + * @param {Object} reqBidsConfigObj Bid request configuration object from Prebid + * @param {Function} callback Must be called when complete to continue auction + * @param {Object} moduleConfig RTD module configuration from pbjs.setConfig() + * @param {Object} userConsent User consent object from Prebid (GDPR/GPP/USP) + * @param {number} timeout Timeout in ms from RTD framework (derived from auctionDelay config) + */ +export const getBidRequestData = async (reqBidsConfigObj, callback, moduleConfig, userConsent, timeout) => { try { - // Extract the bundle URL from the module configuration - const {bundleUrl, handleRtd} = parseConfig(moduleConfig); + const parsedConfig = parseConfig(moduleConfig); + if (!parsedConfig) { + logError('Invalid configuration, skipping Optable RTD'); + callback(); + return; + } + + const {host, node, site, cookies, timeout: configTimeout, ids: configIds, hids: configHids, handleRtd, instance, hasDirectApiConfig} = parsedConfig; const handleRtdFn = handleRtd || defaultHandleRtd; - const optableExtraData = config.getConfig('optableRtdConfig') || {}; - - if (bundleUrl) { - // If bundleUrl is present, load the Optable JS bundle - // by using the loadExternalScript function - logMessage('Custom bundle URL found in config: ', bundleUrl); - - // Load Optable JS bundle and merge the data - loadExternalScript(bundleUrl, MODULE_TYPE_RTD, MODULE_NAME, () => { - logMessage('Successfully loaded Optable JS bundle'); - mergeOptableData(handleRtdFn, reqBidsConfigObj, optableExtraData, mergeDeep).then(callback, callback); - }, document); - } else { - // At this point, we assume that the Optable JS bundle is already - // present on the page. If it is, we can directly merge the data - // by passing the callback to the optable.cmd.push function. - logMessage('Custom bundle URL not found in config. ' + - 'Assuming Optable JS bundle is already present on the page'); - window.optable = window.optable || { cmd: [] }; - window.optable.cmd.push(() => { - logMessage('Optable JS bundle found on the page'); - mergeOptableData(handleRtdFn, reqBidsConfigObj, optableExtraData, mergeDeep).then(callback, callback); - }); + + // Mode 1: SDK mode - If Optable Web SDK is loaded (window.optable), use its event system + // instead of making direct API calls. SDK handles caching, consent, and provides ad server targeting. + if (isSDKAvailable(instance)) { + logMessage('Optable Web SDK detected, using SDK mode'); + logMessage('Waiting for SDK to dispatch targeting data via event'); + + await handleSDKMode(handleRtdFn, reqBidsConfigObj, mergeDeep); + callback(); + return; + } + + // Mode 2: Direct API mode - Make direct HTTP calls to Optable targeting API. + // No ad server targeting support, but lighter weight (no external SDK required). + if (!hasDirectApiConfig) { + logError('Neither Web SDK nor direct API configuration found. Please configure host, node, and site parameters, or load the Optable Web SDK.'); + callback(); + return; + } + + logMessage('Using direct API mode (SDK not detected)'); + + const effectiveTimeout = (timeout && timeout > 100) ? timeout - 100 : configTimeout; + + logMessage(`Configuration: host=${host}, node=${node}, site=${site}, cookies=${cookies}`); + if (effectiveTimeout) { + logMessage(`Timeout: ${effectiveTimeout}ms${timeout ? ` (derived from auctionDelay: ${timeout}ms - 100ms)` : ' (from config)'}`); + } + + const sessionId = generateSessionID(); + const consent = extractConsent(userConsent); + + logMessage(`Session ID: ${sessionId}`); + logMessage(`Consent: GPP=${!!consent.gpp}, GDPR=${!!consent.gdpr}`); + + const {ids, hids} = extractIdentifiers(configIds, configHids, reqBidsConfigObj); + logMessage(`Identifiers: ${ids.length} id(s), ${hids.length} hid(s)`); + + const passport = getPassport(host, node); + logMessage(`Passport: ${passport ? 'found' : 'not found'}`); + + // Check if we have cached data - if so, use it immediately + const cachedData = getCachedTargeting(); + if (cachedData) { + logMessage('Cache found, using cached data'); + handleRtdFn(reqBidsConfigObj, cachedData, mergeDeep); + callback(); + + // Only refresh in background if we haven't made a call this session yet + if (!targetingCallMade) { + logMessage('First auction this session - refreshing cache in background'); + targetingCallMade = true; + + // Update cache in background (don't await) + callTargetingAPI({ + host, + node, + site, + ids, + hids, + consent, + sessionId, + passport, + cookies, + timeout: effectiveTimeout + }).then(data => { + if (data) { + logMessage('Background API call completed, cache updated'); + setCachedTargeting(data); + if (data.passport) { + setPassport(host, node, data.passport); + } + } + }).catch(error => { + logWarn('Background API call failed:', error); + }); + } else { + logMessage('Already made a targeting call this session - skipping refresh'); + } + + return; + } + + // No cache - wait for API call + logMessage('No cache found, waiting for API call'); + targetingCallMade = true; + const targetingData = await callTargetingAPI({ + host, + node, + site, + ids, + hids, + consent, + sessionId, + passport, + cookies, + timeout: effectiveTimeout + }); + + if (!targetingData) { + logWarn('No targeting data returned from API'); + callback(); + return; } + + setCachedTargeting(targetingData); + handleRtdFn(reqBidsConfigObj, targetingData, mergeDeep); + + callback(); } catch (error) { - // If an error occurs, log it and call the callback - // to continue with the auction - logError(error); + logError('getBidRequestData error: ', error); callback(); } } /** * Get Optable targeting data and merge it into the ad units + * Only works when Optable Web SDK is present on the page + * * @param adUnits Array of ad units * @param moduleConfig Module configuration * @param userConsent User consent * @param auction Auction object - * @returns {Object} Targeting data + * @returns {Object} Targeting data (empty object if SDK not available) */ export const getTargetingData = (adUnits, moduleConfig, userConsent, auction) => { - // Extract `adserverTargeting` and `instance` from the module configuration - const {adserverTargeting, instance} = parseConfig(moduleConfig); - logMessage('Ad Server targeting: ', adserverTargeting); + const parsedConfig = parseConfig(moduleConfig); + if (!parsedConfig) { + logWarn('Invalid configuration in getTargetingData'); + return {}; + } + + const {adserverTargeting, instance} = parsedConfig; + + if (!isSDKAvailable(instance)) { + logMessage('getTargetingData: Web SDK not available, ad server targeting disabled'); + logMessage('For ad server targeting, please load the Optable Web SDK'); + return {}; + } if (!adserverTargeting) { - logMessage('Ad server targeting is disabled'); + logMessage('Ad server targeting is disabled via config'); return {}; } - const targetingData = {}; // Resolve the SDK instance object based on the instance string // Default to 'instance' if not provided const instanceKey = instance || 'instance'; const sdkInstance = window?.optable?.[instanceKey]; - if (!sdkInstance) { + + if (!sdkInstance || !sdkInstance.targetingKeyValuesFromCache) { logWarn(`No Optable SDK instance found for: ${instanceKey}`); - return targetingData; + return {}; } - // Get the Optable targeting data from the cache - const optableTargetingData = sdkInstance?.targetingKeyValuesFromCache?.() || targetingData; + const optableTargetingData = sdkInstance.targetingKeyValuesFromCache() || {}; - // If no Optable targeting data is found, return an empty object if (!Object.keys(optableTargetingData).length) { - logWarn('No Optable targeting data found'); - return targetingData; + logWarn('No Optable targeting data found in SDK cache'); + return {}; } - // Merge the Optable targeting data into the ad units + const targetingData = {}; adUnits.forEach(adUnit => { targetingData[adUnit] = targetingData[adUnit] || {}; mergeDeep(targetingData[adUnit], optableTargetingData); }); - // If the key contains no data, remove it Object.keys(targetingData).forEach((adUnit) => { Object.keys(targetingData[adUnit]).forEach((key) => { if (!targetingData[adUnit][key] || !targetingData[adUnit][key].length) { @@ -203,7 +660,7 @@ export const getTargetingData = (adUnits, moduleConfig, userConsent, auction) => } }); - // If the ad unit contains no data, remove it + // If the key contains no data, remove it if (!Object.keys(targetingData[adUnit]).length) { delete targetingData[adUnit]; } diff --git a/modules/optableRtdProvider.md b/modules/optableRtdProvider.md index 4ac0d4541f..adc47672e4 100644 --- a/modules/optableRtdProvider.md +++ b/modules/optableRtdProvider.md @@ -12,7 +12,45 @@ Prebid.js minimum version: 9.53.2+, or 10.2+ ## Description -Optable RTD submodule enriches the OpenRTB request by populating `user.ext.eids` and `user.data` using an identity graph and audience segmentation service hosted by Optable on behalf of the publisher. This RTD submodule primarily relies on the Optable bundle loaded on the page, which leverages the Optable-specific Visitor ID and other PPIDs to interact with the identity graph, enriching the bid request with additional user IDs and audience data. +Optable RTD submodule enriches the OpenRTB bid request by populating `user.ext.eids` and `user.data` using an identity graph and audience segmentation service hosted by Optable on behalf of the publisher. + +**This module supports TWO modes of operation with automatic detection:** + +### Mode 1: Web SDK Mode (Recommended for ad server targeting) + +Uses Optable Web SDK loaded on page via event-based integration. + +**Setup:** +- Load Optable Web SDK: `` +- Configure RTD module with optional params only + +**Features:** +- Bid request enrichment (EIDs passed to SSPs) +- Ad server targeting (key-values for GAM/other ad servers) +- Event-based (waits for 'optable-targeting:change' event) +- SDK handles API calls, consent, caching, etc. + +### Mode 2: Direct API Mode (Lightweight, SDK-less) + +Makes direct HTTP calls to Optable targeting API without any external SDK. + +**Setup:** +- No SDK required - module makes direct HTTPS GET requests +- Configure RTD module with host, node, site parameters + +**Features:** +- Bid request enrichment (EIDs passed to SSPs) +- NO ad server targeting (use SDK mode for this) +- Cache-first with fallback strategy (fast page loads) +- Consent from Prebid's userConsent parameter (no CMP calls) +- Timeout derived from auctionDelay (automatic) + +### Mode Detection + +The module automatically detects which mode to use: +1. If `window.optable` is present → SDK mode +2. If SDK absent but host/node/site configured → Direct API mode +3. If neither → Error ## Usage @@ -26,28 +64,89 @@ gulp build --modules="rtdModule,optableRtdProvider,appnexusBidAdapter,..." > Note that Optable RTD module is dependent on the global real-time data module, `rtdModule`. -### Preloading Optable SDK bundle +### Configuration -In order to use the module you first need to register with Optable and obtain a bundle URL. The bundle URL may be specified as a `bundleUrl` parameter to the script, or otherwise it can be added directly to the page source as: +This module is configured as part of the `realTimeData.dataProviders`. -```html - -``` +**SDK Mode Configuration (with Optable Web SDK loaded):** -### Configuration +```javascript +// Load SDK first in your page: +// -This module is configured as part of the `realTimeData.dataProviders`. +pbjs.setConfig({ + debug: true, + realTimeData: { + auctionDelay: 200, + dataProviders: [ + { + name: 'optable', + waitForIt: true, + params: { + adserverTargeting: true, // Enable ad server targeting + instance: 'instance' // SDK instance name (default: 'instance') + }, + }, + ], + }, +}); +``` + +**Direct API Mode Configuration (SDK-less):** ```javascript pbjs.setConfig({ debug: true, // we recommend turning this on for testing as it adds more logging realTimeData: { + auctionDelay: 200, // recommended for real-time data + dataProviders: [ + { + name: 'optable', + waitForIt: true, + params: { + host: 'dcn.customer.com', // REQUIRED: Your Optable DCN hostname + node: 'prod-us', // REQUIRED: Your node identifier + site: 'my-site', // REQUIRED: Your site identifier + }, + }, + ], + }, +}); +``` + +**Advanced Configuration:** + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 200, dataProviders: [ { name: 'optable', + waitForIt: true, params: { - adserverTargeting: true, // optional, true by default, set to true to also set GAM targeting keywords to ad slots - instance: window.optable.rtd.instance, // optional, defaults to window.optable.rtd.instance if not specified + // REQUIRED PARAMETERS: + host: 'dcn.customer.com', // Your Optable DCN hostname + node: 'prod-us', // Your node identifier + site: 'my-site', // Your site identifier + + // OPTIONAL PARAMETERS: + cookies: false, // Set to false for cookieless mode (default: true) + timeout: '500ms', // API timeout hint + ids: ['user-id-1', 'user-id-2'], // User identifiers (also auto-extracted from userId module) + hids: ['hint-id'], // Hint identifiers + + // CUSTOM HANDLER (optional): + handleRtd: (reqBidsConfigObj, targetingData, mergeFn) => { + // Custom logic to handle targeting data + console.log('Custom RTD handler called'); + console.log('Targeting data:', targetingData); + + // Perform any custom processing here + + // Merge the data into bid requests + mergeFn(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2); + } }, }, ], @@ -57,50 +156,376 @@ pbjs.setConfig({ ### Parameters -| Name | Type | Description | Default | Notes | -|--------------------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|----------| -| name | String | Real time data module name | Always `optable` | | -| params | Object | | | | -| params.adserverTargeting | Boolean | If set to `true`, targeting keywords will be passed to the ad server upon auction completion | `true` | Optional | -| params.instance | Object | Optable SDK instance to use for targeting data. | `window.optable.rtd.instance` | Optional | -| params.handleRtd | Function | An optional function that uses Optable data to enrich `reqBidsConfigObj` with the real-time data. If not provided, the module will do a default call to Optable bundle. The function signature is `[async] (reqBidsConfigObj, optableExtraData, mergeFn) => {}` | `null` | Optional | +| Name | Type | Description | Default | Required | Mode | +|-------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|----------|------| +| name | String | Real time data module name | Always `optable` | Yes | Both | +| waitForIt | Boolean | Should be set to `true` to ensure targeting data is available before auction | `false` | Recommended | Both | +| params | Object | Configuration parameters | | Yes | Both | +| **params.adserverTargeting** | **Boolean** | **Enable ad server targeting key-values (SDK mode only)** | **`true`** | **No** | **SDK** | +| **params.instance** | **String** | **SDK instance name** | **`'instance'`** | **No** | **SDK** | +| params.host | String | Your Optable DCN hostname (e.g., `dcn.customer.com`) | None | **Yes** | Direct API | +| params.node | String | Node identifier for your DCN | None | **Yes** | Direct API | +| params.site | String | Site identifier configured in your DCN | None | **Yes** | Direct API | +| params.cookies | Boolean | Cookie mode. Set to `false` for cookieless targeting using passport | `true` | No | Direct API | +| params.timeout | String | API timeout hint (e.g., `"500ms"`) | `null` | No | Direct API | +| params.ids | Array | Array of user identifier strings. These are combined with identifiers auto-extracted from Prebid userId module | `[]` | No | Direct API | +| params.hids | Array | Array of hint identifier strings | `[]` | No | Direct API | +| params.handleRtd | Function | Custom function to handle/enrich RTD data. Function signature: `(reqBidsConfigObj, targetingData, mergeFn) => {}`. If not provided, the module uses a default handler that merges targeting data into ortb2Fragments.global | `null` | No | Both | + +## How It Works + +### 1. Initialization + +When Prebid's auction starts, the Optable RTD module: + +1. Validates the configuration (checks for required `host`, `node`, and `site` parameters) +2. Checks for cached targeting data in localStorage +3. If no cache is found, proceeds to make an API call + +### 2. Data Collection + +Before calling the targeting API, the module automatically: + +- Generates a session ID (once per page load) +- Extracts consent information from Prebid's consent management modules (GPP/GDPR) +- Collects user identifiers from: + - The `ids` parameter in configuration + - Prebid's userId module (via `ortb2.user.ext.eids`) +- Retrieves the passport (visitor ID) from localStorage for cookieless mode + +### 3. API Call + +The module makes a GET request to `https://{host}/v2/targeting` with the following parameters: + +- `o`: Site identifier (required) +- `t`: Node identifier (required) +- `id`: User identifiers (multiple) +- `hid`: Hint identifiers (multiple) +- `osdk`: SDK version identifier +- `sid`: Session ID +- `cookies`: Cookie mode (`yes` or `no`) +- `passport`: Visitor ID for cookieless mode +- `gpp`: GPP consent string +- `gpp_sid`: GPP section IDs +- `gdpr_consent`: GDPR consent string +- `gdpr`: GDPR applies flag (`0` or `1`) +- `timeout`: Timeout hint + +### 4. Response Handling + +The targeting API returns an ORTB2 object with: + +- `ortb2.user.eids`: Extended user IDs for bid enrichment +- `ortb2.user.data`: Audience segments +- `passport`: Updated passport value (for cookieless mode) + +The module then: + +1. Updates the passport in localStorage if provided +2. Caches the response in localStorage +3. Merges the ORTB2 data into `reqBidsConfigObj.ortb2Fragments.global` +4. Also adds EIDs to `ortb2.user.ext.eids` for additional coverage + +### 5. Bid Enrichment + +The enriched ORTB2 data is automatically included in all bid requests, allowing bidders to: + +- Access extended user IDs for better user recognition +- Target based on audience segments +- Improve bid decisioning with richer user context + +## Consent Management + +The module automatically extracts consent information from Prebid's consent management configuration: + +```javascript +// Example consent management config +pbjs.setConfig({ + consentManagement: { + gpp: { + // GPP consent config + }, + gdpr: { + // GDPR consent config + } + } +}); +``` + +The consent strings are automatically passed to the targeting API. No additional configuration is needed. + +## Identifier Collection + +User identifiers are collected from two sources: + +### 1. Configuration + +Provide identifiers directly in the RTD configuration: + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site', + ids: ['email-hash-123', 'phone-hash-456'], + hids: ['hint-id-789'] +} +``` + +### 2. Prebid userId Module + +The module automatically extracts identifiers from other Prebid userId modules: + +```javascript +// If you have other userId modules configured +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'id5Id', + params: { + partner: 173 + } + }, { + name: 'unifiedId', + params: { + // ... + } + }] + } +}); +``` + +All identifiers are combined and sent to the targeting API. + +## Cookie vs Cookieless Mode + +### Cookie Mode (Default) + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site', + cookies: true // or omit this parameter +} +``` + +In cookie mode, the DCN uses first-party cookies for visitor identification. + +### Cookieless Mode + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site', + cookies: false +} +``` + +In cookieless mode: +- The module manages a "passport" (visitor ID) in localStorage +- The passport is sent with each targeting API call +- The API returns an updated passport, which is stored for future calls +- No cookies are set or read + +## Caching + +The module caches targeting responses in localStorage under the key `optable-cache:targeting` to improve performance: + +- On the first page view, an API call is made +- The response is cached in localStorage +- On subsequent page views, cached data is used immediately +- A new API call updates the cache in the background -## Publisher Customized RTD Handler Function +## Node Configuration -When there is more pre-processing or post-processing needed prior/post calling Optable bundle - a custom `handleRtd` -function can be supplied to do that. -This function will also be responsible for the `reqBidsConfigObj` enrichment. -It will also receive the `optableExtraData` object, which can contain the extra data required for the enrichment and -shouldn't be shared with other RTD providers/bidders. -`mergeFn` parameter taken by `handleRtd` is a standard Prebid.js utility function that take an object to be enriched and -an object to enrich with: the second object's fields will be merged into the first one (also see the code of an example -mentioned below): +The `node` parameter is required and identifies your DCN node: ```javascript -mergeFn( - reqBidsConfigObj.ortb2Fragments.global, // or other nested object as needed - rtdData, -); +params: { + host: 'dcn.customer.com', + node: 'prod-us', // Your node identifier + site: 'my-site' +} ``` -A `handleRtd` function implementation has access to its surrounding context including capturing a `pbjs` object, calling `pbjs.getConfig()` and f.e. reading off the `consentManagement` config to make the appropriate decision based on it. +The node identifier routes the API call to the specified node and maintains separate passports per node. + +## Custom RTD Handler + +For advanced use cases, provide a custom `handleRtd` function: + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site', + handleRtd: (reqBidsConfigObj, targetingData, mergeFn) => { + console.log('Targeting data received:', targetingData); + + // Custom validation + if (!targetingData || !targetingData.ortb2) { + console.warn('Invalid targeting data'); + return; + } + + // Custom transformation + const customOrtb2 = { + user: { + ...targetingData.ortb2.user, + ext: { + ...targetingData.ortb2.user.ext, + customField: 'customValue' + } + } + }; + + // Merge into bid requests + mergeFn(reqBidsConfigObj.ortb2Fragments.global, customOrtb2); + + // Additional custom logic... + } +} +``` + +## Testing + +Use Prebid's debug mode to see detailed logs: + +```javascript +pbjs.setConfig({ + debug: true, + // ... rest of config +}); +``` + +Logs will show: +- Configuration validation +- API call details +- Consent extraction +- Identifier collection +- Caching behavior +- Data merging into bid requests ## Example -If you want to see an example of how the optable RTD module works, run the following command: +To see a working example: ```bash gulp serve --modules=optableRtdProvider,consentManagementGpp,consentManagementTcf,appnexusBidAdapter ``` -and then open the following URL in your browser: +Then open: [`http://localhost:9999/integrationExamples/gpt/optableRtdProvider_example.html`](http://localhost:9999/integrationExamples/gpt/optableRtdProvider_example.html) Open the browser console to see the logs. -## Maintainer contacts +## Migration from External Web SDK Approach + +If you were previously using an external Web SDK loaded via `bundleUrl` parameter: + +### Old Configuration (External Web SDK): +```javascript +params: { + bundleUrl: 'https://cdn.optable.co/bundle.js', + adserverTargeting: true +} +``` + +### New Configuration (Direct API): +```javascript +params: { + host: 'dcn.customer.com', // Your DCN hostname + node: 'prod-us', // Your node identifier + site: 'my-site' // Your site identifier +} +``` + +### Key Differences: + +1. **No External Loading**: Module no longer loads SDK from CDN, uses direct API calls instead +2. **Required Parameters**: `host`, `node`, and `site` are now required +3. **Ad Server Targeting**: Not supported. Use the Web SDK separately if you need GAM targeting keywords +4. **Custom Handler Signature**: Changed from `(reqBidsConfigObj, optableExtraData, mergeFn, skipCache)` to `(reqBidsConfigObj, targetingData, mergeFn)` +5. **Faster**: No external script loading delay +6. **Simpler**: Fewer dependencies and configuration options + +## Excluded Features + +The following Web SDK features are intentionally **not** supported in this RTD module to maintain simplicity: + +- A/B testing framework +- Additional targeting signals (page URL ref) +- Ad server targeting keywords (use Web SDK for this) +- Event dispatching system +- Complex multi-storage key strategies + +See `modules/optableRtdProvider_EXCLUDED_FEATURES.md` for details. + +## Troubleshooting + +### "host parameter is required and must be a string" + +Ensure you've configured the `host` parameter: + +```javascript +params: { + host: 'dcn.customer.com', // Required! + node: 'prod-us', + site: 'my-site' +} +``` + +### "site parameter is required and must be a string" + +Ensure you've configured the `site` parameter: + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', + site: 'my-site' // Required! +} +``` + +### "node parameter is required and must be a string" + +Ensure you've configured the `node` parameter: + +```javascript +params: { + host: 'dcn.customer.com', + node: 'prod-us', // Required! + site: 'my-site' +} +``` + +### No targeting data returned + +Check: +1. Is `waitForIt: true` set in the dataProvider config? +2. Is `auctionDelay` set appropriately (e.g., 200ms)? +3. Are there identifiers available (check `ids` param and userId module)? +4. Check browser console for API errors +5. Verify your DCN host, node and site identifier are correct + +### Consent issues + +Ensure Prebid's consent management modules are configured: + +```javascript +pbjs.setConfig({ + consentManagement: { + gdpr: { ... }, + gpp: { ... } + } +}); +``` + +## Maintainer Contacts Any suggestions or questions can be directed to [prebid@optable.co](mailto:prebid@optable.co). -Alternatively please open a new [issue](https://github.com/prebid/prebid-server-java/issues/new) or [pull request](https://github.com/prebid/prebid-server-java/pulls) in this repository. +Alternatively please open a new [issue](https://github.com/prebid/Prebid.js/issues/new) or [pull request](https://github.com/prebid/Prebid.js/pulls) in this repository. diff --git a/test/spec/modules/optableRtdProvider_spec.js b/test/spec/modules/optableRtdProvider_spec.js index 271d31d018..060ae72e85 100644 --- a/test/spec/modules/optableRtdProvider_spec.js +++ b/test/spec/modules/optableRtdProvider_spec.js @@ -1,323 +1,589 @@ import { parseConfig, + generateSessionID, + getPassport, + setPassport, defaultHandleRtd, - mergeOptableData, getBidRequestData, getTargetingData, optableSubmodule, + LOG_PREFIX, } from 'modules/optableRtdProvider'; +import {getStorageManager} from 'src/storageManager.js'; +import * as ajax from 'src/ajax.js'; describe('Optable RTD Submodule', function () { + let sandbox; + let storage; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + storage = getStorageManager({moduleType: 'rtd', moduleName: 'optable'}); + sandbox.stub(storage, 'getDataFromLocalStorage'); + sandbox.stub(storage, 'setDataInLocalStorage'); + }); + + afterEach(() => { + sandbox.restore(); + // Clear localStorage between tests + if (typeof localStorage !== 'undefined') { + localStorage.clear(); + } + }); + describe('parseConfig', function () { - it('parses valid config correctly', function () { - const config = { + it('parses valid config with required Direct API parameters', function () { + const moduleConfig = { params: { - bundleUrl: 'https://cdn.optable.co/bundle.js', - adserverTargeting: true, - handleRtd: () => {} + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node' } }; - expect(parseConfig(config)).to.deep.equal({ - bundleUrl: 'https://cdn.optable.co/bundle.js', - adserverTargeting: true, - handleRtd: config.params.handleRtd, - }); + const result = parseConfig(moduleConfig); + expect(result).to.not.be.null; + expect(result.host).to.equal('dcn.customer.com'); + expect(result.site).to.equal('my-site'); + expect(result.node).to.equal('my-node'); + expect(result.cookies).to.be.true; + expect(result.ids).to.deep.equal([]); + expect(result.hids).to.deep.equal([]); + expect(result.hasDirectApiConfig).to.be.true; }); - it('trims bundleUrl if it contains extra spaces', function () { - const config = {params: {bundleUrl: ' https://cdn.optable.co/bundle.js '}}; - expect(parseConfig(config).bundleUrl).to.equal('https://cdn.optable.co/bundle.js'); + it('parses SDK mode config without Direct API params', function () { + const moduleConfig = { + params: { + adserverTargeting: true, + instance: 'custom' + } + }; + const result = parseConfig(moduleConfig); + expect(result).to.not.be.null; + expect(result.adserverTargeting).to.be.true; + expect(result.instance).to.equal('custom'); + expect(result.hasDirectApiConfig).to.be.false; }); - it('returns null bundleUrl for invalid bundleUrl format', function () { - expect(parseConfig({params: {bundleUrl: 'invalidURL'}}).bundleUrl).to.be.null; - expect(parseConfig({params: {bundleUrl: 'www.invalid.com'}}).bundleUrl).to.be.null; + it('trims host, site, and node values', function () { + const moduleConfig = { + params: { + host: ' dcn.customer.com ', + site: ' my-site ', + node: ' my-node ' + } + }; + const result = parseConfig(moduleConfig); + expect(result.host).to.equal('dcn.customer.com'); + expect(result.site).to.equal('my-site'); + expect(result.node).to.equal('my-node'); }); - it('returns null bundleUrl for non-HTTPS bundleUrl', function () { - expect(parseConfig({params: {bundleUrl: 'http://cdn.optable.co/bundle.js'}}).bundleUrl).to.be.null; - expect(parseConfig({params: {bundleUrl: '//cdn.optable.co/bundle.js'}}).bundleUrl).to.be.null; - expect(parseConfig({params: {bundleUrl: '/bundle.js'}}).bundleUrl).to.be.null; + it('accepts config with null host/site/node (SDK mode)', function () { + const moduleConfig = {params: {adserverTargeting: true}}; + const result = parseConfig(moduleConfig); + expect(result).to.not.be.null; + expect(result.hasDirectApiConfig).to.be.false; }); - it('defaults adserverTargeting to true if missing', function () { - expect(parseConfig( - {params: {bundleUrl: 'https://cdn.optable.co/bundle.js'}} - ).adserverTargeting).to.be.true; + it('returns null if host is empty string', function () { + const moduleConfig = {params: {host: '', site: 'my-site', node: 'my-node'}}; + expect(parseConfig(moduleConfig)).to.be.null; }); - it('returns null handleRtd if handleRtd is not a function', function () { - expect(parseConfig({params: {handleRtd: 'notAFunction'}}).handleRtd).to.be.null; + it('returns null if site is empty string', function () { + const moduleConfig = {params: {host: 'dcn.customer.com', site: '', node: 'my-node'}}; + expect(parseConfig(moduleConfig)).to.be.null; }); - }); - describe('defaultHandleRtd', function () { - let sandbox, reqBidsConfigObj, mergeFn; + it('returns null if node is empty string', function () { + const moduleConfig = {params: {host: 'dcn.customer.com', site: 'my-site', node: ''}}; + expect(parseConfig(moduleConfig)).to.be.null; + }); - beforeEach(() => { - sandbox = sinon.createSandbox(); - reqBidsConfigObj = {ortb2Fragments: {global: {}}}; - mergeFn = sinon.spy(); - window.optable = { - instance: { - targeting: sandbox.stub(), - targetingFromCache: sandbox.stub(), - }, + it('parses optional parameters correctly', function () { + const handleRtdFn = () => {}; + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'prod-us', + cookies: false, + timeout: '500ms', + ids: ['id1', 'id2'], + hids: ['hid1'], + handleRtd: handleRtdFn, + adserverTargeting: false, + instance: 'custom' + } }; + const result = parseConfig(moduleConfig); + expect(result.node).to.equal('prod-us'); + expect(result.cookies).to.be.false; + expect(result.timeout).to.equal('500ms'); + expect(result.ids).to.deep.equal(['id1', 'id2']); + expect(result.hids).to.deep.equal(['hid1']); + expect(result.handleRtd).to.equal(handleRtdFn); + expect(result.adserverTargeting).to.be.false; + expect(result.instance).to.equal('custom'); }); - afterEach(() => { - sandbox.restore(); + it('returns null if handleRtd is not a function', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + handleRtd: 'notAFunction' + } + }; + expect(parseConfig(moduleConfig)).to.be.null; }); - it('merges valid targeting data into the global ORTB2 object', async function () { - const targetingData = {ortb2: {user: {ext: {optable: 'testData'}}}}; - window.optable.instance.targetingFromCache.returns(targetingData); - window.optable.instance.targeting.resolves(targetingData); + it('returns null if ids is not an array', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + ids: 'notAnArray' + } + }; + expect(parseConfig(moduleConfig)).to.be.null; + }); - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.calledWith(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2)).to.be.true; + it('returns null if hids is not an array', function () { + const moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + hids: 'notAnArray' + } + }; + expect(parseConfig(moduleConfig)).to.be.null; }); - it('does nothing if targeting data is missing the ortb2 property', async function () { - window.optable.instance.targetingFromCache.returns({}); + it('returns null and logs error for deprecated bundleUrl parameter', function () { + const moduleConfig = { + params: { + bundleUrl: 'https://example.cdn.optable.co/bundle.js' + } + }; + expect(parseConfig(moduleConfig)).to.be.null; + }); + }); - // Dispatch event with empty ortb2 data after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: {} - }); - window.dispatchEvent(event); - }, 10); + describe('generateSessionID', function () { + it('generates a session ID', function () { + const sid = generateSessionID(); + expect(sid).to.be.a('string'); + expect(sid.length).to.be.greaterThan(0); + }); - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.called).to.be.false; + it('returns the same session ID on subsequent calls (singleton)', function () { + const sid1 = generateSessionID(); + const sid2 = generateSessionID(); + expect(sid1).to.equal(sid2); }); - it('uses targeting data from cache if available', async function () { - const targetingData = {ortb2: {user: {ext: {optable: 'testData'}}}}; - window.optable.instance.targetingFromCache.returns(targetingData); + it('generates base64url encoded string without +/= characters', function () { + const sid = generateSessionID(); + expect(sid).to.not.match(/[+/=]/); + }); + }); - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.calledWith(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2)).to.be.true; + describe('passport storage', function () { + it('getPassport retrieves passport from localStorage', function () { + storage.getDataFromLocalStorage.returns('test-passport-value'); + const passport = getPassport('dcn.customer.com', 'node1'); + expect(passport).to.equal('test-passport-value'); + expect(storage.getDataFromLocalStorage.calledOnce).to.be.true; }); - it('calls targeting function if no data is found in cache', async function () { - const targetingData = {ortb2: {user: {ext: {optable: 'testData'}}}}; - window.optable.instance.targetingFromCache.returns(null); + it('setPassport stores passport in localStorage', function () { + setPassport('dcn.customer.com', 'node1', 'new-passport-value'); + expect(storage.setDataInLocalStorage.calledOnce).to.be.true; + const args = storage.setDataInLocalStorage.getCall(0).args; + expect(args[1]).to.equal('new-passport-value'); + }); - // Dispatch event with targeting data after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: targetingData - }); - window.dispatchEvent(event); - }, 10); + it('generates different keys for different hosts', function () { + setPassport('dcn1.customer.com', 'node1', 'passport1'); + setPassport('dcn2.customer.com', 'node1', 'passport2'); + expect(storage.setDataInLocalStorage.calledTwice).to.be.true; + const key1 = storage.setDataInLocalStorage.getCall(0).args[0]; + const key2 = storage.setDataInLocalStorage.getCall(1).args[0]; + expect(key1).to.not.equal(key2); + }); - await defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); - expect(mergeFn.calledWith(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2)).to.be.true; + it('generates different keys for same host with different nodes', function () { + setPassport('dcn.customer.com', 'node1', 'passport1'); + setPassport('dcn.customer.com', 'node2', 'passport2'); + const key1 = storage.setDataInLocalStorage.getCall(0).args[0]; + const key2 = storage.setDataInLocalStorage.getCall(1).args[0]; + expect(key1).to.not.equal(key2); }); }); - describe('mergeOptableData', function () { - let sandbox, mergeFn, handleRtdFn, reqBidsConfigObj; + describe('defaultHandleRtd', function () { + let reqBidsConfigObj, mergeFn; beforeEach(() => { - sandbox = sinon.createSandbox(); - mergeFn = sinon.spy(); reqBidsConfigObj = {ortb2Fragments: {global: {}}}; + mergeFn = sinon.spy(); }); - afterEach(() => { - sandbox.restore(); + it('merges valid targeting data into the global ORTB2 object', function () { + const targetingData = { + ortb2: { + user: { + eids: [{source: 'optable.co', uids: [{id: 'test-id'}]}] + } + } + }; + + defaultHandleRtd(reqBidsConfigObj, targetingData, mergeFn); + expect(mergeFn.calledWith(reqBidsConfigObj.ortb2Fragments.global, targetingData.ortb2)).to.be.true; }); - it('calls handleRtdFn synchronously if it is a regular function', async function () { - handleRtdFn = sinon.spy(); - await mergeOptableData(handleRtdFn, reqBidsConfigObj, {}, mergeFn); - expect(handleRtdFn.calledOnceWith(reqBidsConfigObj, {}, mergeFn)).to.be.true; + it('does nothing if targeting data is missing ortb2', function () { + defaultHandleRtd(reqBidsConfigObj, {}, mergeFn); + expect(mergeFn.called).to.be.false; }); - it('calls handleRtdFn asynchronously if it is an async function', async function () { - handleRtdFn = sinon.stub().resolves(); - await mergeOptableData(handleRtdFn, reqBidsConfigObj, {}, mergeFn); - expect(handleRtdFn.calledOnceWith(reqBidsConfigObj, {}, mergeFn)).to.be.true; + it('does nothing if targeting data is null', function () { + defaultHandleRtd(reqBidsConfigObj, null, mergeFn); + expect(mergeFn.called).to.be.false; }); }); - describe('getBidRequestData', function () { - let sandbox, reqBidsConfigObj, callback, moduleConfig; + describe('getBidRequestData - Direct API Mode', function () { + let reqBidsConfigObj, callback, moduleConfig, ajaxStub; beforeEach(() => { - sandbox = sinon.createSandbox(); - reqBidsConfigObj = {ortb2Fragments: {global: {}}}; + reqBidsConfigObj = { + ortb2Fragments: { + global: {} + } + }; callback = sinon.spy(); - moduleConfig = {params: {bundleUrl: 'https://cdn.optable.co/bundle.js'}}; + moduleConfig = { + params: { + host: 'dcn.customer.com', + site: 'my-site', + node: 'my-node', + ids: ['id1'], + hids: [] + } + }; - sandbox.stub(window, 'optable').value({cmd: []}); - sandbox.stub(window.document, 'createElement'); - sandbox.stub(window.document, 'head'); + ajaxStub = sandbox.stub(ajax, 'ajax'); + // Stub window.optable to ensure Direct API mode + delete window.optable; }); - afterEach(() => { - sandbox.restore(); - }); + it('calls targeting API with correct parameters', async function () { + storage.getDataFromLocalStorage.returns(null); // No cache - it('loads Optable JS bundle if bundleUrl is provided', function () { - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - expect(window.document.createElement.called).to.be.true; - }); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('https://dcn.customer.com/v2/targeting'); + expect(url).to.include('o=my-site'); + expect(url).to.include('t=my-node'); + expect(url).to.include('id=id1'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); - it('uses existing Optable instance if no bundleUrl is provided', function () { - moduleConfig.params.bundleUrl = null; - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - expect(window.optable.cmd.length).to.equal(1); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(ajaxStub.calledOnce).to.be.true; + expect(callback.calledOnce).to.be.true; }); - it('calls callback when assuming the bundle is present', function (done) { - moduleConfig.params.bundleUrl = null; - window.optable = { - cmd: [], - instance: { - targetingFromCache: sandbox.stub().returns(null) + it('uses cached data immediately and updates cache in background', async function () { + const cachedData = { + ortb2: { + user: { + eids: [{source: 'cached.com', uids: [{id: 'cached-id'}]}] + } } }; + storage.getDataFromLocalStorage.returns(JSON.stringify(cachedData)); + + let apiCallMade = false; + ajaxStub.callsFake((url, options) => { + apiCallMade = true; + // Simulate async API call + setTimeout(() => { + options.success('{"ortb2":{"user":{"eids":[{"source":"fresh.com"}]}}}'); + }, 10); + }); - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - // Check that the function is queued - expect(window.optable.cmd.length).to.equal(1); + // Callback should be called immediately with cached data + expect(callback.calledOnce).to.be.true; + // API call should still be made for background update + expect(apiCallMade).to.be.true; + }); - // Dispatch the event after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: {ortb2: {user: {ext: {optable: 'testData'}}}} - }); - window.dispatchEvent(event); - }, 10); + it('waits for API call when no cache available', async function () { + storage.getDataFromLocalStorage.returns(null); // No cache - // Manually trigger the queued function - window.optable.cmd[0](); + ajaxStub.callsFake((url, options) => { + options.success('{"ortb2":{"user":{"eids":[{"source":"fresh.com"}]}}}'); + }); - setTimeout(() => { - expect(callback.calledOnce).to.be.true; - done(); - }, 100); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(ajaxStub.calledOnce).to.be.true; + expect(callback.calledOnce).to.be.true; + expect(storage.setDataInLocalStorage.called).to.be.true; // Cache updated }); - it('mergeOptableData catches error and executes callback when something goes wrong', function (done) { - moduleConfig.params.bundleUrl = null; - moduleConfig.params.handleRtd = () => { throw new Error('Test error'); }; + it('handles API errors gracefully', async function () { + storage.getDataFromLocalStorage.returns(null); - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + ajaxStub.callsFake((url, options) => { + options.error('Network error'); + }); - expect(window.optable.cmd.length).to.equal(1); - window.optable.cmd[0](); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - setTimeout(() => { - expect(callback.calledOnce).to.be.true; - done(); - }, 50); + expect(callback.calledOnce).to.be.true; }); - it('getBidRequestData catches error and executes callback when something goes wrong', function (done) { - moduleConfig.params.bundleUrl = null; - moduleConfig.params.handleRtd = 'not a function'; - window.optable = { - cmd: [], - instance: { - targetingFromCache: sandbox.stub().returns(null) + it('includes consent parameters in API call', async function () { + storage.getDataFromLocalStorage.returns(null); + + const userConsent = { + gpp: { + gppString: 'DBABMA~test', + applicableSections: [2, 6] + }, + gdpr: { + gdprApplies: true, + consentString: 'CPXxxx' } }; - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('gpp=DBABMA~test'); + expect(url).to.include('gpp_sid=2,6'); + expect(url).to.include('gdpr_consent=CPXxxx'); + expect(url).to.include('gdpr=1'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); - expect(window.optable.cmd.length).to.equal(1); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, userConsent); + expect(ajaxStub.calledOnce).to.be.true; + }); - // Dispatch event after a short delay - setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: {ortb2: {user: {ext: {optable: 'testData'}}}} - }); - window.dispatchEvent(event); - }, 10); + it('adds default __passport__ ID when no IDs configured', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.ids = []; - // Execute the queued command - window.optable.cmd[0](); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('id=__passport__'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); - setTimeout(() => { - expect(callback.calledOnce).to.be.true; - done(); - }, 100); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; }); - it("doesn't fail when optable is not available", function (done) { - moduleConfig.params.bundleUrl = null; - delete window.optable; + it('includes passport in cookieless mode', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.cookies = false; + storage.getDataFromLocalStorage.withArgs(sinon.match(/OPTABLE_PASSPORT/)).returns('test-passport'); - getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + ajaxStub.callsFake((url, options) => { + expect(url).to.include('cookies=no'); + expect(url).to.include('passport=test-passport'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; + }); + + it('includes cookies=yes in cookie mode', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.cookies = true; + + ajaxStub.callsFake((url, options) => { + expect(url).to.include('cookies=yes'); + expect(url).to.not.include('passport='); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; + }); + + it('updates passport from API response', async function () { + storage.getDataFromLocalStorage.returns(null); + + ajaxStub.callsFake((url, options) => { + options.success('{"ortb2":{"user":{"eids":[]}},"passport":"new-passport-value"}'); + }); - // The code should have created window.optable with cmd array - expect(window.optable).to.exist; - expect(window.optable.cmd.length).to.equal(1); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - // Simulate optable bundle initializing and executing commands - window.optable.instance = { - targetingFromCache: () => null + // Check that passport was stored + const passportCall = storage.setDataInLocalStorage.getCalls().find(call => + call.args[0].includes('OPTABLE_PASSPORT') + ); + expect(passportCall).to.exist; + expect(passportCall.args[1]).to.equal('new-passport-value'); + }); + + it('derives timeout from auctionDelay', async function () { + storage.getDataFromLocalStorage.returns(null); + const auctionTimeout = 2500; + + ajaxStub.callsFake((url, options) => { + // Should use auctionDelay - 100ms = 2400ms + expect(url).to.include('timeout=2400'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}, auctionTimeout); + expect(ajaxStub.calledOnce).to.be.true; + }); + + it('uses config timeout when auctionDelay not available', async function () { + storage.getDataFromLocalStorage.returns(null); + moduleConfig.params.timeout = 1000; + + ajaxStub.callsFake((url, options) => { + expect(url).to.include('timeout=1000'); + options.success('{"ortb2":{"user":{"eids":[]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + expect(ajaxStub.calledOnce).to.be.true; + }); + + it('uses custom handleRtd function if provided', async function () { + storage.getDataFromLocalStorage.returns(null); + const customHandleRtd = sinon.spy(); + moduleConfig.params.handleRtd = customHandleRtd; + + ajaxStub.callsFake((url, options) => { + options.success('{"ortb2":{"user":{"eids":[{"source":"test.com"}]}}}'); + }); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(customHandleRtd.calledOnce).to.be.true; + expect(callback.calledOnce).to.be.true; + }); + + it('handles invalid config gracefully', async function () { + moduleConfig.params.host = null; + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(ajaxStub.called).to.be.false; + expect(callback.calledOnce).to.be.true; + }); + }); + + describe('getBidRequestData - SDK Mode', function () { + let reqBidsConfigObj, callback, moduleConfig; + + beforeEach(() => { + reqBidsConfigObj = {ortb2Fragments: {global: {}}}; + callback = sinon.spy(); + moduleConfig = {params: {instance: 'instance', adserverTargeting: true}}; + + // Mock SDK availability + window.optable = { + instance: { + targeting: sinon.stub(), + targetingFromCache: sinon.stub().returns(null) + } + }; + }); + + afterEach(() => { + delete window.optable; + }); + + it('uses SDK mode when window.optable is available', async function () { + const targetingData = { + ortb2: { + user: {eids: [{source: 'optable.co', uids: [{id: 'sdk-id'}]}]} + } }; - // Dispatch event after a short delay + // Simulate SDK event setTimeout(() => { - const event = new CustomEvent('optable-targeting:change', { - detail: {ortb2: {user: {ext: {optable: 'testData'}}}} - }); + const event = new CustomEvent('optable-targeting:change', {detail: targetingData}); window.dispatchEvent(event); }, 10); - // Execute the queued command (simulating optable bundle execution) - window.optable.cmd[0](); + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); - setTimeout(() => { - expect(callback.calledOnce).to.be.true; - done(); - }, 100); + expect(callback.calledOnce).to.be.true; + }); + + it('uses SDK cached data if available', async function () { + const cachedData = { + ortb2: {user: {eids: [{source: 'cached.com'}]}} + }; + window.optable.instance.targetingFromCache.returns(cachedData); + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(callback.calledOnce).to.be.true; }); }); - describe('getTargetingData', function () { - let sandbox, moduleConfig; + describe('getBidRequestData - Mode Detection', function () { + let reqBidsConfigObj, callback; beforeEach(() => { - sandbox = sinon.createSandbox(); - moduleConfig = {params: {adserverTargeting: true}}; - window.optable = {instance: {targetingKeyValuesFromCache: sandbox.stub().returns({key1: 'value1'})}}; + reqBidsConfigObj = {ortb2Fragments: {global: {}}}; + callback = sinon.spy(); + delete window.optable; }); - afterEach(() => { - sandbox.restore(); + it('errors when neither SDK nor Direct API params configured', async function () { + const moduleConfig = {params: {}}; + + await getBidRequestData(reqBidsConfigObj, callback, moduleConfig, {}); + + expect(callback.calledOnce).to.be.true; + // Should log error about missing configuration }); + }); - it('returns correct targeting data when Optable data is available', function () { + describe('getTargetingData', function () { + it('returns empty object when SDK not available', function () { + delete window.optable; + const moduleConfig = {params: {host: 'test', site: 'test', node: 'test'}}; const result = getTargetingData(['adUnit1'], moduleConfig, {}, {}); - expect(result).to.deep.equal({adUnit1: {key1: 'value1'}}); + expect(result).to.deep.equal({}); }); - it('returns empty object when no Optable data is found', function () { - window.optable.instance.targetingKeyValuesFromCache.returns({}); - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); - }); + it('returns targeting data when SDK available', function () { + window.optable = { + instance: { + targetingKeyValuesFromCache: sinon.stub().returns({ + 'optable_segment': ['seg1', 'seg2'] + }) + } + }; - it('returns empty object when adserverTargeting is disabled', function () { - moduleConfig.params.adserverTargeting = false; - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); - }); + const moduleConfig = {params: {adserverTargeting: true}}; + const result = getTargetingData(['adUnit1'], moduleConfig, {}, {}); - it('returns empty object when provided keys contain no data', function () { - window.optable.instance.targetingKeyValuesFromCache.returns({key1: []}); - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); + expect(result).to.have.property('adUnit1'); + expect(result.adUnit1).to.have.property('optable_segment'); - window.optable.instance.targetingKeyValuesFromCache.returns({key1: [], key2: [], key3: []}); - expect(getTargetingData(['adUnit1'], moduleConfig, {}, {})).to.deep.equal({}); + delete window.optable; }); }); @@ -326,4 +592,13 @@ describe('Optable RTD Submodule', function () { expect(optableSubmodule.init()).to.be.true; }); }); + + describe('submodule structure', function () { + it('exports the correct submodule structure', function () { + expect(optableSubmodule.name).to.equal('optable'); + expect(optableSubmodule.init).to.be.a('function'); + expect(optableSubmodule.getBidRequestData).to.be.a('function'); + expect(optableSubmodule.getTargetingData).to.be.a('function'); + }); + }); });