diff --git a/integrationExamples/realTimeData/datamageRtdProvider_example.html b/integrationExamples/realTimeData/datamageRtdProvider_example.html new file mode 100644 index 00000000000..636782ec77e --- /dev/null +++ b/integrationExamples/realTimeData/datamageRtdProvider_example.html @@ -0,0 +1,143 @@ + + + + + + OpsMage Prebid Test Page + + + + + + + + + + +

OpsMage Prebid Test Page

+
+
The tech world is currently buzzing over the highly anticipated market debut of fakeDSP, a trailblazing + startup poised to redefine the landscape of digital signal processing. Leveraging proprietary neuromorphic + algorithms and quantum-ready architecture, fakeDSP promises to accelerate real-time data synthesis by speeds + previously thought impossible. With early analysts calling it a definitive disruptor in both the + telecommunications and audio-engineering sectors, the company’s entrance signifies a major leap forward in how + complex signals are analyzed and reconstructed in the AI era.
+ + + \ No newline at end of file diff --git a/modules/.submodules.json b/modules/.submodules.json index 244104c93da..38cd6cccb29 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -84,6 +84,7 @@ "cleanioRtdProvider", "confiantRtdProvider", "contxtfulRtdProvider", + "datamageRtdProvider", "dgkeywordRtdProvider", "dynamicAdBoostRtdProvider", "experianRtdProvider", @@ -139,4 +140,4 @@ "adplayerproVideoProvider" ] } -} +} \ No newline at end of file diff --git a/modules/datamageRtdProvider.js b/modules/datamageRtdProvider.js new file mode 100644 index 00000000000..178eb35a47f --- /dev/null +++ b/modules/datamageRtdProvider.js @@ -0,0 +1,286 @@ +import { submodule } from '../src/hook.js'; +import { logError, logWarn, logInfo, generateUUID } from '../src/utils.js'; +import { ajaxBuilder } from '../src/ajax.js'; + +const MODULE_NAME = 'datamage'; + +let fetchPromise = null; +let lastTargeting = null; + +function _resetForTest() { + fetchPromise = null; // Clear the network promise cache + lastTargeting = null; // Clear the data targeting cache +} + +function asStringArray(v) { + if (v == null) return []; + if (Array.isArray(v)) return v.map((x) => String(x)); + return [String(v)]; +} + +function ensureSiteContentData(globalOrtb2) { + if (!globalOrtb2.site) globalOrtb2.site = {}; + if (!globalOrtb2.site.content) globalOrtb2.site.content = {}; + if (!Array.isArray(globalOrtb2.site.content.data)) globalOrtb2.site.content.data = []; + return globalOrtb2.site.content.data; +} + +function buildSegments(iabCatIds, iabCats) { + const ids = asStringArray(iabCatIds); + const names = Array.isArray(iabCats) ? iabCats.map((x) => String(x)) : []; + return ids.map((id, idx) => { + const seg = { id }; + if (names[idx]) seg.name = names[idx]; + return seg; + }); +} + +function padBase64(b64) { + const mod = b64.length % 4; + return mod ? (b64 + '='.repeat(4 - mod)) : b64; +} + +function cleanPageUrl(urlStr) { + try { + const u = new URL(urlStr); + + // 1. Strip the port (keep your existing logic) + if (u.port) u.port = ''; + + // 2. Define common tracking and analytics parameters + const trackingParams = [ + 'fbclid', // Facebook + 'igshid', // Instagram + 'gclid', // Google Ads + 'wbraid', // Google Ads (iOS) + 'gbraid', // Google Ads (iOS) + '_gl', // Google Analytics cross-domain + 'utm_source', // UTMs (Google Analytics, etc.) + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'utm_id', + 'msclkid', // Microsoft/Bing Ads + 'twclid', // Twitter + 'ttclid', // TikTok + 'yclid', // Yandex + 'mc_eid', // Mailchimp + 'ScCid', // Snapchat + 's_kwcid' // Adobe Analytics + ]; + + // 3. Safely remove them from the query string + trackingParams.forEach(param => { + if (u.searchParams.has(param)) { + u.searchParams.delete(param); + } + }); + + return u.toString(); + } catch (e) { + // Fallback to the raw string if URL parsing fails + return urlStr; + } +} + +function buildApiUrl(params) { + const apiKey = params.api_key || ''; + const selector = params.selector || ''; + const rawPageUrl = (typeof window !== 'undefined' && window.location?.href) ? window.location.href : ''; + + // Use the new cleaning function here + const pageUrl = cleanPageUrl(rawPageUrl); + + let encodedUrl = ''; + try { + // Safely encode UTF-8 characters before passing to btoa() + const utf8SafeUrl = unescape(encodeURIComponent(pageUrl)); + encodedUrl = padBase64(btoa(utf8SafeUrl)); + } catch (e) { + logWarn('DataMage: Failed to base64 encode URL', e); + } + + return `https://opsmage-api.io/context/v3/get?api_key=${encodeURIComponent(apiKey)}&content_id=${encodedUrl}&prebid=true&selector=${encodeURIComponent(selector)}`; +} + +function fetchContextData(apiUrl, fetchTimeoutMs) { + if (fetchPromise) return fetchPromise; + + const ajax = ajaxBuilder(fetchTimeoutMs); + fetchPromise = new Promise((resolve, reject) => { + ajax(apiUrl, { + success: (responseText) => { + try { + resolve(JSON.parse(responseText)); + } catch (err) { + fetchPromise = null; // Clear cache on parse error to allow retry + reject(err); + } + }, + error: (err) => { + fetchPromise = null; // Clear cache on network error to allow retry + reject(err); + } + }); + }); + + return fetchPromise; +} + +/** + * Helper to parse the API payload so we don't repeat mapping logic + */ +function mapApiPayload(cc) { + const arrayKeys = ['brand_ids', 'sentiment_ids', 'location_ids', 'public_figure_ids', 'restricted_cat_ids', 'restricted_cats']; + const scalarKeys = ['ops_mage_data_id', 'res_score', 'res_score_bucket']; + + const ext = {}; + const targetingArrays = {}; + lastTargeting = {}; + + const iabCatIds = asStringArray(cc.iab_cat_ids); + + // Clean up IAB Cats by keeping only the most specific segment (after the last pipe) + const iabCats = asStringArray(cc.iab_cats).map(cat => { + const parts = cat.split('|'); + return parts[parts.length - 1]; + }); + + // Safely assign IAB keys only if they have data + if (iabCatIds.length > 0) { + targetingArrays.om_iab_cat_ids = iabCatIds; + lastTargeting.om_iab_cat_ids = iabCatIds.join(','); + } + + // NOTE: om_iab_cats is intentionally excluded from targetingArrays and lastTargeting + // to save ad server slot limits. The cleaned names are only used for the ORTB segment below. + + // Safely assign optional array keys + arrayKeys.forEach((key) => { + const vals = asStringArray(cc[key]); + if (vals.length > 0) { // Only populate if there is actual data + ext[key] = vals; + targetingArrays[`om_${key}`] = vals; + lastTargeting[`om_${key}`] = vals.join(','); + } + }); + + // Safely assign optional scalar keys + scalarKeys.forEach((key) => { + if (cc[key] != null && cc[key] !== '') { // Guard against nulls and empty strings + ext[key] = cc[key]; + targetingArrays[`om_${key}`] = [String(cc[key])]; + lastTargeting[`om_${key}`] = String(cc[key]); + } + }); + + return { ext, targetingArrays, segment: buildSegments(iabCatIds, iabCats) }; +} + +// ========================================== +// 1. PUBLISHER TARGETING (Independent of Auction) +// ========================================== +function init(rtdConfig, userConsent) { + logInfo('DATAMAGE: init() called. Fetching data for GAM...'); + + const params = (rtdConfig && rtdConfig.params) || {}; + if (!params.api_key) logWarn('DataMage: Missing api_key'); + + const apiUrl = buildApiUrl(params); + const fetchTimeoutMs = Number(params.fetch_timeout_ms ?? 2500); + + // Start network request instantly + fetchContextData(apiUrl, fetchTimeoutMs).then((resJson) => { + if (!resJson?.content_classification) { + lastTargeting = null; // Clear stale cache on empty payload + return; + } + + const { targetingArrays } = mapApiPayload(resJson.content_classification); + + window.googletag = window.googletag || { cmd: [] }; + window.googletag.cmd.push(() => { + // --- MODERN GPT API IMPLEMENTATION --- + const pageTargeting = {}; + + // 1. Build a single object containing all valid targeting pairs + Object.entries(targetingArrays).forEach(([key, value]) => { + if (value && value.length) { + pageTargeting[key] = value; + } + }); + + // 2. Apply page-level targeting in a single configuration call + if (Object.keys(pageTargeting).length > 0) { + window.googletag.setConfig({ targeting: pageTargeting }); + } + }); + }).catch(() => { + lastTargeting = null; // Clear stale cache on error + }); + + return true; +} + +// ========================================== +// 2. ADVERTISER TARGETING (Tied to Auction) +// ========================================== +function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { + logInfo('DATAMAGE: getBidRequestData() triggered. Attaching to ORTB2...'); + + if (!reqBidsConfigObj?.ortb2Fragments?.global) { + callback(); + return; + } + + const params = (rtdConfig && rtdConfig.params) || {}; + const apiUrl = buildApiUrl(params); + const fetchTimeoutMs = Number(params.fetch_timeout_ms ?? 2500); + + reqBidsConfigObj.auctionId = reqBidsConfigObj.auctionId || generateUUID(); + + // This will instantly resolve from the cache created in init() + fetchContextData(apiUrl, fetchTimeoutMs) + .then((resJson) => { + if (!resJson?.content_classification) { + lastTargeting = null; // FIX: Clear stale cache on empty payload + return; + } + + const { ext, segment } = mapApiPayload(resJson.content_classification); + + const ortbContentDataObj = { name: 'data-mage.com', segment, ext }; + ensureSiteContentData(reqBidsConfigObj.ortb2Fragments.global).push(ortbContentDataObj); + }) + .catch((error) => { + lastTargeting = null; // FIX: Clear stale cache on error + logError('DataMage: Fetch error', error); + }) + .finally(() => callback()); // Release the auction! +} + +function getTargetingData(adUnitCodes, rtdConfig, userConsent) { + if (!lastTargeting) return {}; + + const out = {}; + + // Iterate over the array of string codes passed by Prebid + (adUnitCodes || []).forEach((code) => { + if (typeof code === 'string' && code) { + out[code] = { ...lastTargeting }; + } + }); + + return out; +} + +export const datamageRtdSubmodule = { + name: MODULE_NAME, + init, + getBidRequestData, + getTargetingData, + _resetForTest +}; + +submodule('realTimeData', datamageRtdSubmodule); diff --git a/modules/datamageRtdProvider.md b/modules/datamageRtdProvider.md new file mode 100644 index 00000000000..c2562eaa916 --- /dev/null +++ b/modules/datamageRtdProvider.md @@ -0,0 +1,90 @@ + +# DataMage RTD Submodule + +DataMage provides real-time contextual classification (IAB Categories, Sentiment, Brands, Locations, Public Figures, Restricted Categories, and related IDs) that can be used to enrich demand signals and Google Ad Manager targeting. + +## What it does + +DataMage automatically supports two outcomes in a Prebid + GAM setup without requiring any custom glue-code on the page: + +1. **Passes data to Google Ad Manager (Direct GPT targeting)** + +* The moment Prebid initializes, DataMage fetches classification for the current page and automatically pushes the targeting keys directly to GPT using the modern `googletag.setConfig({ targeting: ... })` API at the page level. +* This ensures the data is available for all ad slots and works **even if there are no bids** or if the auction times out. + +2. **Passes data to bidders (ORTB2 enrichment)** + +* Using a memoized cache from the initial fetch, DataMage seamlessly inserts the contextual results into the bid request using OpenRTB (`ortb2Fragments.global.site.content.data`), allowing bidders to receive the enriched signals instantly. + +## Keys provided + +DataMage automatically maps and provides the following targeting keys (when available in the API response): + +* `om_iab_cat_ids` +* `om_iab_cats` +* `om_brand_ids` +* `om_sentiment_ids` +* `om_location_ids` +* `om_public_figure_ids` +* `om_restricted_cat_ids` +* `om_restricted_cats` +* `om_ops_mage_data_id` +* `om_res_score_bucket` +* `om_res_score` (only when present) + +## Prebid config + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 1000, // Gives the module time to fetch data before bids are sent, suggested minimum 1000 + dataProviders: [{ + name: "datamage", + waitForIt: true, // CRITICAL: Forces Prebid to wait for the module to fetch data before resolving the auction + params: { + api_key: "YOUR_API_KEY", + selector: "article", + fetch_timeout_ms: 2500 + } + }] + } +}); + +``` + +## GAM set up requirements + +Because DataMage automatically injects targeting globally via `setConfig`, your page implementation only requires a standard Prebid setup. + +To ensure DataMage key-values are included in your GAM requests: + +1. Call `googletag.pubads().disableInitialLoad()` before your ad requests. +2. Define your slots and call `googletag.enableServices()`. +3. Run `pbjs.requestBids(...)`. +4. Inside the `bidsBackHandler` callback: +* Call `pbjs.setTargetingForGPTAsync()` (to set standard Prebid `hb_` pricing keys). +* Call `googletag.pubads().refresh()` to trigger the GAM request. + + + +GAM will automatically combine the standard Prebid slot-level pricing keys with the page-level DataMage contextual keys. + +*Note that you will need a real API key provisioned by DataMage to use this module in production.* + +### Example: + +```javascript +pbjs.requestBids({ + bidsBackHandler: function () { + // Push standard header bidding keys to GPT + pbjs.setTargetingForGPTAsync(); + + // Refresh the ad slots. Datamage page-level keys are already injected! + googletag.cmd.push(function () { + googletag.pubads().refresh(); + }); + }, + timeout: 1500 +}); + +``` diff --git a/test/spec/modules/datamageRtdProvider_spec.js b/test/spec/modules/datamageRtdProvider_spec.js new file mode 100644 index 00000000000..fddb448b978 --- /dev/null +++ b/test/spec/modules/datamageRtdProvider_spec.js @@ -0,0 +1,303 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { datamageRtdSubmodule } from 'modules/datamageRtdProvider.js'; +import * as ajaxUtils from 'src/ajax.js'; +import * as utils from 'src/utils.js'; + +describe('datamageRtdSubmodule (DataMage RTD Provider)', function () { + let sandbox; + let ajaxBuilderStub; + let setConfigStub; + let btoaStub; + let origGoogletag; // Stores the original global to prevent breaking other tests + + function makeReqBidsConfigObj() { + return { + auctionId: 'auction-1', + ortb2Fragments: { global: {} } + }; + } + + function makeProcessedResponse(overrides = {}) { + return { + content_classification: { + ops_mage_data_id: '7d54b2d30a4e441a0f698dfae8f5b1b5', + res_score: 1, + res_score_bucket: 'high', + iab_cats: [ + 'Technology & Computing', + 'Technology & Computing|Artificial Intelligence', + 'Business & Finance' + ], + iab_cat_ids: ['596', '597', '52'], + brand_ids: ['eefd8446', 'b78b9ee2'], + sentiment_ids: ['95487831', '92bfd7eb'], + location_ids: ['60efc224'], + public_figure_ids: ['55eefb4a'], + restricted_cat_ids: [], + ...overrides + } + }; + } + + beforeEach(function () { + sandbox = sinon.createSandbox(); + + // Stub logging so they don't spam the test console + sandbox.stub(utils, 'logInfo'); + sandbox.stub(utils, 'logWarn'); + sandbox.stub(utils, 'logError'); + + // Reset module-scoped cache + datamageRtdSubmodule._resetForTest(); + + // Safely backup the original googletag object + origGoogletag = window.googletag; + + // Mock window.googletag and spy on setConfig + setConfigStub = sandbox.stub(); + window.googletag = { + cmd: { + push: function (fn) { fn(); } // Execute immediately for testing + }, + setConfig: setConfigStub + }; + + // Stub Prebid's internal ajaxBuilder + ajaxBuilderStub = sandbox.stub(ajaxUtils, 'ajaxBuilder'); + + // Keep tests deterministic + allow port-strip assertion + btoaStub = sandbox.stub(window, 'btoa').callsFake((s) => `b64(${s})`); + }); + + afterEach(function () { + sandbox.restore(); + // Restore the original googletag object so we don't break the E-Planning adapter + window.googletag = origGoogletag; + }); + + describe('init()', function () { + it('should return true and trigger GAM injection asynchronously via setConfig', function (done) { + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + const ok = datamageRtdSubmodule.init({ name: 'datamage', params: { api_key: 'x' } }, {}); + expect(ok).to.equal(true); + + // Simulate the API resolving + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.success(JSON.stringify(makeProcessedResponse())); + + // Use setTimeout to wait for the Promise chain to resolve + setTimeout(() => { + expect(setConfigStub.calledOnce).to.be.true; + + const configArg = setConfigStub.firstCall.args[0]; + expect(configArg).to.have.property('targeting'); + + const targeting = configArg.targeting; + expect(targeting).to.have.property('om_iab_cat_ids').that.deep.equals(['596', '597', '52']); + expect(targeting).to.have.property('om_brand_ids').that.deep.equals(['eefd8446', 'b78b9ee2']); + expect(targeting).to.have.property('om_res_score').that.deep.equals(['1']); + expect(targeting).to.not.have.property('om_restricted_cat_ids'); + + done(); + }, 0); + }); + }); + + describe('getBidRequestData()', function () { + it('should inject into ORTB2 when fetch resolves', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + const rtdConfig = { + name: 'datamage', + params: { api_key: 'k', selector: 'article' } + }; + + datamageRtdSubmodule.getBidRequestData(req, () => { + expect(req.ortb2Fragments.global).to.have.nested.property('site.content.data'); + const dataArr = req.ortb2Fragments.global.site.content.data; + expect(dataArr).to.be.an('array').with.length.greaterThan(0); + expect(dataArr[0]).to.have.property('name', 'data-mage.com'); + expect(dataArr[0]).to.have.property('segment'); + expect(dataArr[0].segment).to.deep.include({ id: '596', name: 'Technology & Computing' }); + done(); + }, rtdConfig, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.success(JSON.stringify(makeProcessedResponse())); + }); + + it('should only make ONE network request when init and getBidRequestData are both called (Memoization)', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + const rtdConfig = { params: { api_key: 'k' } }; + + // 1. Init fires (simulating page load) + datamageRtdSubmodule.init(rtdConfig); + + // 2. getBidRequestData fires (simulating auction start) + datamageRtdSubmodule.getBidRequestData(req, () => { + // Assert the network was only hit once despite two entry points + expect(fakeAjax.calledOnce).to.be.true; + done(); + }, rtdConfig, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.success(JSON.stringify(makeProcessedResponse())); + }); + + it('should NOT inject after network error', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + datamageRtdSubmodule.getBidRequestData(req, () => { + expect(req.ortb2Fragments.global.site?.content?.data).to.be.undefined; + expect(setConfigStub.called).to.be.false; + done(); + }, { name: 'datamage', params: { api_key: 'k' } }, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.error('Network Failed'); + }); + + it('should strip port from URL before encoding', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + datamageRtdSubmodule.getBidRequestData(req, () => { + expect(btoaStub.called).to.equal(true); + const btoaArg = btoaStub.firstCall.args[0]; + + expect(btoaArg).to.be.a('string'); + expect(btoaArg).to.not.match(/\/\/[^/]+:\d+\//); + done(); + }, { name: 'datamage', params: { api_key: 'k' } }, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.error('err'); + }); + + it('should gracefully handle btoa encoding failures without crashing the auction', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + // Force btoa to throw an error (simulating a Latin-1 DOMException for unhandled characters) + btoaStub.throws(new Error('String contains an invalid character')); + + datamageRtdSubmodule.getBidRequestData(req, () => { + // 1. Ensure the auction still releases (callback is fired) + expect(fakeAjax.calledOnce).to.be.true; + + // 2. Ensure the API URL was still built, just with an empty content_id + const ajaxUrl = fakeAjax.firstCall.args[0]; + expect(ajaxUrl).to.include('content_id='); + expect(ajaxUrl).to.not.include('content_id=b64'); // Should not contain our stub's prefix + + done(); + }, { name: 'datamage', params: { api_key: 'k' } }, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.error('err'); + }); + + it('should strip common tracking parameters from the URL before encoding', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + // 1. Store the original URL so we can restore it cleanly + const originalUrl = window.location.href; + + // 2. Use the History API to safely append query parameters without redefining the location object + window.history.replaceState({}, '', '?utm_source=fb&id=42&gclid=123'); + + datamageRtdSubmodule.getBidRequestData(req, () => { + const btoaArg = btoaStub.firstCall.args[0]; + + // 3. Ensure tracking params are gone, but valid params remain + expect(btoaArg).to.not.include('utm_source'); + expect(btoaArg).to.not.include('gclid'); + expect(btoaArg).to.include('id=42'); + + // 4. Restore the original URL so we don't pollute other tests + window.history.replaceState({}, '', originalUrl); + done(); + }, { name: 'datamage', params: { api_key: 'k' } }, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.error('err'); + }); + + it('should clear stale cache (lastTargeting) if the fetch yields no payload', function (done) { + const req1 = makeReqBidsConfigObj(); + const req2 = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + const rtdConfig = { name: 'datamage', params: { api_key: 'k' } }; + + // 1. First auction: successful fetch populates the cache + datamageRtdSubmodule.getBidRequestData(req1, () => { + // 2. Verify cache is populated + let out = datamageRtdSubmodule.getTargetingData(['div-1'], {}, {}); + expect(out['div-1']).to.have.property('om_res_score', '1'); + + // 3. Reset module state safely via the internal helper + datamageRtdSubmodule._resetForTest(); + + // 4. Second auction: simulate an empty response + datamageRtdSubmodule.getBidRequestData(req2, () => { + // 5. Verify the cache was wiped out + out = datamageRtdSubmodule.getTargetingData(['div-1'], {}, {}); + expect(out).to.deep.equal({}); + done(); + }, rtdConfig, {}); + + // Resolve the second auction with an empty payload + const callbacks2 = fakeAjax.secondCall.args[1]; + callbacks2.success(JSON.stringify({})); + }, rtdConfig, {}); + + // Resolve the first auction with a good payload + const callbacks1 = fakeAjax.firstCall.args[1]; + callbacks1.success(JSON.stringify(makeProcessedResponse())); + }); + }); + + describe('getTargetingData()', function () { + it('should return {} if no successful fetch has happened yet', function () { + const out = datamageRtdSubmodule.getTargetingData(['div-1'], {}, {}); + expect(out).to.deep.equal({}); + }); + + it('should return per-adunit legacy targeting (string-joined lists) after response resolves', function (done) { + const req = makeReqBidsConfigObj(); + let fakeAjax = sinon.stub(); + ajaxBuilderStub.returns(fakeAjax); + + datamageRtdSubmodule.getBidRequestData(req, () => { + const out = datamageRtdSubmodule.getTargetingData(['div-1', 'div-2'], {}, {}); + expect(out).to.have.property('div-1'); + expect(out).to.have.property('div-2'); + + expect(out['div-1']).to.have.property('om_iab_cat_ids', '596,597,52'); + expect(out['div-1']).to.have.property('om_brand_ids', 'eefd8446,b78b9ee2'); + expect(out['div-1']).to.have.property('om_res_score', '1'); + + done(); + }, { name: 'datamage', params: { api_key: 'k' } }, {}); + + const callbacks = fakeAjax.firstCall.args[1]; + callbacks.success(JSON.stringify(makeProcessedResponse())); + }); + }); +});