diff --git a/modules/bridgewellBidAdapter.js b/modules/bridgewellBidAdapter.js index 0cddb61779..80e98dbd37 100644 --- a/modules/bridgewellBidAdapter.js +++ b/modules/bridgewellBidAdapter.js @@ -45,33 +45,82 @@ export const spec = { validBidRequests = convertOrtbRequestToProprietaryNative(validBidRequests); const adUnits = []; - var bidderUrl = REQUEST_ENDPOINT + Math.random(); + const bidderUrl = REQUEST_ENDPOINT + Math.random(); _each(validBidRequests, function (bid) { + const passthrough = bid.ortb2Imp?.ext?.prebid?.passthrough; + const filteredPassthrough = passthrough ? Object.fromEntries( + Object.entries({ + bucket: passthrough.bucket, + client: passthrough.client, + gamAdCode: passthrough.gamAdCode, + gamLoc: passthrough.gamLoc, + colo: passthrough.colo, + device: passthrough.device, + lang: passthrough.lang, + pt: passthrough.pt, + region: passthrough.region, + site: passthrough.site, + ver: passthrough.ver + }).filter(([_, value]) => value !== undefined) + ) : undefined; + const adUnit = { adUnitCode: bid.adUnitCode, requestId: bid.bidId, + transactionId: bid.transactionId, + adUnitId: bid.adUnitId, + sizes: bid.sizes, mediaTypes: bid.mediaTypes || { banner: { sizes: bid.sizes } }, - userIds: bid.userId || {}, - userIdAsEids: bid.userIdAsEids || {} + ortb2Imp: { + ext: { + prebid: { + passthrough: filteredPassthrough + }, + data: { + adserver: { + name: bid.ortb2Imp?.ext?.data?.adserver?.name, + adslot: bid.ortb2Imp?.ext?.data?.adserver?.adslot + }, + pbadslot: bid.ortb2Imp?.ext?.data?.pbadslot + }, + gpid: bid.ortb2Imp?.ext?.gpid + }, + banner: { + pos: bid.ortb2Imp?.banner?.pos + } + } }; - if (bid.params.cid) { + + if (bid.params?.cid) { adUnit.cid = bid.params.cid; - } else { + } else if (bid.params?.ChannelID) { adUnit.ChannelID = bid.params.ChannelID; } + + let floorInfo = {}; + if (typeof bid.getFloor === 'function') { + const mediaType = bid.mediaTypes?.banner ? BANNER : (bid.mediaTypes?.native ? NATIVE : '*'); + const sizes = bid.mediaTypes?.banner?.sizes || bid.sizes || []; + const size = sizes.length === 1 ? sizes[0] : '*'; + floorInfo = bid.getFloor({currency: 'USD', mediaType: mediaType, size: size}) || {}; + } + adUnit.floor = floorInfo.floor; + adUnit.currency = floorInfo.currency; adUnits.push(adUnit); }); let topUrl = ''; - if (bidderRequest && bidderRequest.refererInfo) { + if (bidderRequest?.refererInfo?.page) { topUrl = bidderRequest.refererInfo.page; } + const firstBid = validBidRequests[0] || {}; + return { method: 'POST', url: bidderUrl, @@ -82,10 +131,23 @@ export const spec = { }, inIframe: inIframe(), url: topUrl, - referrer: bidderRequest.refererInfo.ref, + referrer: bidderRequest?.refererInfo?.ref, + auctionId: firstBid?.auctionId, + bidderRequestId: firstBid?.bidderRequestId, + src: firstBid?.src, + userIds: firstBid?.userId || {}, + userIdAsEids: firstBid?.userIdAsEids || [], + auctionsCount: firstBid?.auctionsCount, + bidRequestsCount: firstBid?.bidRequestsCount, + bidderRequestsCount: firstBid?.bidderRequestsCount, + bidderWinsCount: firstBid?.bidderWinsCount, + deferBilling: firstBid?.deferBilling, + metrics: firstBid?.metrics || {}, adUnits: adUnits, // TODO: please do not send internal data structures over the network - refererInfo: bidderRequest.refererInfo.legacy}, + refererInfo: bidderRequest?.refererInfo?.legacy, + ortb2: bidderRequest?.ortb2 + }, validBidRequests: validBidRequests }; }, diff --git a/modules/insticatorBidAdapter.js b/modules/insticatorBidAdapter.js index 447947e43a..abb91ce3f3 100644 --- a/modules/insticatorBidAdapter.js +++ b/modules/insticatorBidAdapter.js @@ -5,7 +5,7 @@ import {deepAccess, generateUUID, logError, isArray, isInteger, isArrayOfNums, d import {getStorageManager} from '../src/storageManager.js'; const BIDDER_CODE = 'insticator'; -const ENDPOINT = 'https://ex.ingage.tech/v1/openrtb'; // production endpoint +const ENDPOINT = 'https://ex.ingage.tech/v1/openrtb'; const USER_ID_KEY = 'hb_insticator_uid'; const USER_ID_COOKIE_EXP = 2592000000; // 30 days const BID_TTL = 300; // 5 minutes @@ -29,7 +29,16 @@ export const OPTIONAL_VIDEO_PARAMS = { 'playbackend': (value) => isInteger(value) && [1, 2, 3].includes(value), 'delivery': (value) => isArrayOfNums(value), 'pos': (value) => isInteger(value) && [0, 1, 2, 3, 4, 5, 6, 7].includes(value), - 'api': (value) => isArrayOfNums(value)}; + 'api': (value) => isArrayOfNums(value), + // Ad Pod specific parameters (ORTB 2.6) + 'podid': (value) => typeof value === 'string' && value.length > 0, + 'podseq': (value) => isInteger(value) && value >= 0, + 'poddur': (value) => isInteger(value) && value > 0, + 'slotinpod': (value) => isInteger(value) && [-1, 0, 1, 2].includes(value), + 'mincpmpersec': (value) => typeof value === 'number' && value > 0, + 'maxseq': (value) => isInteger(value) && value > 0, + 'rqddurs': (value) => isArrayOfNums(value) && value.every(v => v > 0), +}; const ORTB_SITE_FIRST_PARTY_DATA = { 'cat': v => Array.isArray(v) && v.every(c => typeof c === 'string'), @@ -114,11 +123,11 @@ function buildVideo(bidRequest) { const optionalParams = {}; for (const param in OPTIONAL_VIDEO_PARAMS) { - if (bidRequestVideo[param] && OPTIONAL_VIDEO_PARAMS[param](bidRequestVideo[param])) { + if (bidRequestVideo[param] != null && OPTIONAL_VIDEO_PARAMS[param](bidRequestVideo[param])) { optionalParams[param] = bidRequestVideo[param]; } // remove invalid optional params from bidder specific overrides - if (videoBidderParams[param] && !OPTIONAL_VIDEO_PARAMS[param](videoBidderParams[param])) { + if (videoBidderParams[param] != null && !OPTIONAL_VIDEO_PARAMS[param](videoBidderParams[param])) { delete videoBidderParams[param]; } } @@ -135,6 +144,20 @@ function buildVideo(bidRequest) { optionalParams['context'] = context; } + // Map Prebid.js adpod fields to ORTB 2.6 video fields + const adPodDurationSec = deepAccess(bidRequest, 'mediaTypes.video.adPodDurationSec'); + if (adPodDurationSec && isInteger(adPodDurationSec) && adPodDurationSec > 0) { + optionalParams['poddur'] = adPodDurationSec; + } + + const durationRangeSec = deepAccess(bidRequest, 'mediaTypes.video.durationRangeSec'); + if (durationRangeSec && isArrayOfNums(durationRangeSec) && durationRangeSec.length > 0) { + const validDurations = durationRangeSec.filter(v => v > 0); + if (validDurations.length > 0) { + optionalParams['rqddurs'] = validDurations; + } + } + const videoObj = { mimes, w, @@ -442,8 +465,9 @@ function buildRequest(validBidRequests, bidderRequest) { return req; } -function buildBid(bid, bidderRequest) { +function buildBid(bid, bidderRequest, seatbid) { const originalBid = ((bidderRequest.bids) || []).find((b) => b.bidId === bid.impid); + let meta = {} if (bid.ext && bid.ext.meta) { @@ -454,27 +478,79 @@ function buildBid(bid, bidderRequest) { meta.advertiserDomains = bid.adomain } + // ORTB 2.6: Add category support + if (bid.cat && Array.isArray(bid.cat) && bid.cat.length > 0) { + meta.primaryCatId = bid.cat[0]; + if (bid.cat.length > 1) { + meta.secondaryCatIds = bid.cat.slice(1); + } + } + + // ORTB 2.6: Add seat from seatbid + if (seatbid && seatbid.seat) { + meta.seat = seatbid.seat; + } + + // ORTB 2.6: Add creative attributes + if (bid.attr && Array.isArray(bid.attr)) { + meta.attr = bid.attr; + } + + // Determine media type using multiple signals let mediaType = 'banner'; - if (bid.adm && bid.adm.includes(' 0 ? Math.min(bid.exp, configTTL) : configTTL; + const bidResponse = { requestId: bid.impid, creativeId: bid.crid, cpm: bid.price, currency: 'USD', netRevenue: true, - ttl: bid.exp || config.getConfig('insticator.bidTTL') || BID_TTL, + ttl: ttl, width: bid.w, height: bid.h, mediaType: mediaType, ad: bid.adm, - adUnitCode: originalBid.adUnitCode, + adUnitCode: originalBid?.adUnitCode, ...(Object.keys(meta).length > 0 ? {meta} : {}) }; + // ORTB 2.6: Add deal ID + if (bid.dealid) { + bidResponse.dealId = bid.dealid; + } + + // ORTB 2.6: Add billing URL for billing notification + if (bid.burl) { + bidResponse.burl = bid.burl; + } + + // ORTB 2.6: Add notice URL for win notification + if (bid.nurl) { + bidResponse.nurl = bid.nurl; + } + if (mediaType === 'video') { bidResponse.vastXml = bid.adm; + + // ORTB 2.6: Add video duration for adpod support + if (bid.dur && isInteger(bid.dur) && bid.dur > 0) { + bidResponse.video = bidResponse.video || {}; + bidResponse.video.durationSeconds = bid.dur; + } } // Inticator bid adaptor only returns `vastXml` for video bids. No VastUrl or videoCache. @@ -493,7 +569,7 @@ function buildBid(bid, bidderRequest) { } function buildBidSet(seatbid, bidderRequest) { - return seatbid.bid.map((bid) => buildBid(bid, bidderRequest)); + return seatbid.bid.map((bid) => buildBid(bid, bidderRequest, seatbid)); } function validateSize(size) { @@ -652,9 +728,19 @@ export const spec = { if (deepAccess(validBidRequests[0], 'params.bid_endpoint_request_url')) { endpointUrl = deepAccess(validBidRequests[0], 'params.bid_endpoint_request_url').replace(/^http:/, 'https:'); } + + // Add publisherId as query parameter if present and non-empty + const publisherId = deepAccess(validBidRequests[0], 'params.publisherId'); + if (publisherId && publisherId.trim() !== '') { + const urlObj = new URL(endpointUrl); + urlObj.searchParams.set('publisherId', publisherId); + endpointUrl = urlObj.toString(); + } } if (validBidRequests.length > 0) { + const ortbRequest = buildRequest(validBidRequests, bidderRequest); + requests.push({ method: 'POST', url: endpointUrl, @@ -662,7 +748,7 @@ export const spec = { contentType: 'application/json', withCredentials: true, }, - data: JSON.stringify(buildRequest(validBidRequests, bidderRequest)), + data: JSON.stringify(ortbRequest), bidderRequest, }); } @@ -673,11 +759,22 @@ export const spec = { interpretResponse: function (serverResponse, request) { const bidderRequest = request.bidderRequest; const body = serverResponse.body; - if (!body || body.id !== bidderRequest.bidderRequestId) { - logError('insticator: response id does not match bidderRequestId'); + + // Handle 204 No Content or empty response body (valid "no bid" scenario) + if (!body || !body.id) { + return []; + } + + // Validate response ID matches request ID + if (body.id !== bidderRequest.bidderRequestId) { + logError('insticator: response id does not match bidderRequestId', { + responseId: body.id, + bidderRequestId: bidderRequest.bidderRequestId + }); return []; } + // No seatbid means no bids (valid scenario) if (!body.seatbid) { return []; } @@ -686,7 +783,9 @@ export const spec = { buildBidSet(seatbid, bidderRequest) ); - return bidsets.reduce((a, b) => a.concat(b), []); + const finalBids = bidsets.reduce((a, b) => a.concat(b), []); + + return finalBids; }, getUserSyncs: function (options, responses) { diff --git a/modules/panxoBidAdapter.js b/modules/panxoBidAdapter.js new file mode 100644 index 0000000000..6f9eb80ae8 --- /dev/null +++ b/modules/panxoBidAdapter.js @@ -0,0 +1,357 @@ +/** + * @module modules/panxoBidAdapter + * @description Bid Adapter for Prebid.js - AI-referred traffic monetization + * @see https://docs.panxo.ai for Signal script installation + */ + +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER } from '../src/mediaTypes.js'; +import { deepAccess, logWarn, isFn, isPlainObject } from '../src/utils.js'; +import { getStorageManager } from '../src/storageManager.js'; + +const BIDDER_CODE = 'panxo'; +const ENDPOINT_URL = 'https://panxo-sys.com/openrtb/2.5/bid'; +const USER_ID_KEY = 'panxo_uid'; +const SYNC_URL = 'https://panxo-sys.com/usersync'; +const DEFAULT_CURRENCY = 'USD'; +const TTL = 300; +const NET_REVENUE = true; + +export const storage = getStorageManager({ bidderCode: BIDDER_CODE }); + +export function getPanxoUserId() { + try { + return storage.getDataFromLocalStorage(USER_ID_KEY); + } catch (e) { + // storageManager handles errors internally + } + return null; +} + +function buildBanner(bid) { + const sizes = deepAccess(bid, 'mediaTypes.banner.sizes') || bid.sizes || []; + if (sizes.length === 0) return null; + + return { + format: sizes.map(size => ({ w: size[0], h: size[1] })), + w: sizes[0][0], + h: sizes[0][1] + }; +} + +function getFloorPrice(bid, size) { + if (isFn(bid.getFloor)) { + try { + const floorInfo = bid.getFloor({ + currency: DEFAULT_CURRENCY, + mediaType: BANNER, + size: size + }); + if (floorInfo && floorInfo.floor) { + return floorInfo.floor; + } + } catch (e) { + // Floor module error + } + } + return deepAccess(bid, 'params.floor') || 0; +} + +function buildUser(panxoUid, bidderRequest) { + const user = { buyeruid: panxoUid }; + + // GDPR consent + const gdprConsent = deepAccess(bidderRequest, 'gdprConsent'); + if (gdprConsent && gdprConsent.consentString) { + user.ext = { consent: gdprConsent.consentString }; + } + + // First Party Data - user + const fpd = deepAccess(bidderRequest, 'ortb2.user'); + if (isPlainObject(fpd)) { + user.ext = { ...user.ext, ...fpd.ext }; + if (fpd.data) user.data = fpd.data; + } + + return user; +} + +function buildRegs(bidderRequest) { + const regs = { ext: {} }; + + // GDPR - only set when gdprApplies is explicitly true or false, not undefined + const gdprConsent = deepAccess(bidderRequest, 'gdprConsent'); + if (gdprConsent && typeof gdprConsent.gdprApplies === 'boolean') { + regs.ext.gdpr = gdprConsent.gdprApplies ? 1 : 0; + } + + // CCPA / US Privacy + const uspConsent = deepAccess(bidderRequest, 'uspConsent'); + if (uspConsent) { + regs.ext.us_privacy = uspConsent; + } + + // GPP + const gppConsent = deepAccess(bidderRequest, 'gppConsent'); + if (gppConsent) { + regs.ext.gpp = gppConsent.gppString; + regs.ext.gpp_sid = gppConsent.applicableSections; + } + + // COPPA + const coppa = deepAccess(bidderRequest, 'ortb2.regs.coppa'); + if (coppa) { + regs.coppa = 1; + } + + return regs; +} + +function buildDevice() { + const device = { + ua: navigator.userAgent, + language: navigator.language, + js: 1, + dnt: navigator.doNotTrack === '1' ? 1 : 0 + }; + + if (typeof screen !== 'undefined') { + device.w = screen.width; + device.h = screen.height; + } + + return device; +} + +function buildSite(bidderRequest) { + const site = { + page: deepAccess(bidderRequest, 'refererInfo.page') || '', + domain: deepAccess(bidderRequest, 'refererInfo.domain') || '', + ref: deepAccess(bidderRequest, 'refererInfo.ref') || '' + }; + + // First Party Data - site + const fpd = deepAccess(bidderRequest, 'ortb2.site'); + if (isPlainObject(fpd)) { + Object.assign(site, { + name: fpd.name, + cat: fpd.cat, + sectioncat: fpd.sectioncat, + pagecat: fpd.pagecat, + content: fpd.content + }); + if (fpd.ext) site.ext = fpd.ext; + } + + return site; +} + +function buildSource(bidderRequest) { + const source = { + tid: deepAccess(bidderRequest, 'ortb2.source.tid') || bidderRequest.auctionId + }; + + // Supply Chain (schain) - read from ortb2 where Prebid normalizes it + const schain = deepAccess(bidderRequest, 'ortb2.source.ext.schain'); + if (isPlainObject(schain)) { + source.ext = { schain: schain }; + } + + return source; +} + +export const spec = { + code: BIDDER_CODE, + gvlid: 1527, // IAB TCF Global Vendor List ID + supportedMediaTypes: [BANNER], + + isBidRequestValid(bid) { + const propertyKey = deepAccess(bid, 'params.propertyKey'); + if (!propertyKey) { + logWarn('Panxo: Missing required param "propertyKey"'); + return false; + } + if (!deepAccess(bid, 'mediaTypes.banner')) { + logWarn('Panxo: Only banner mediaType is supported'); + return false; + } + return true; + }, + + buildRequests(validBidRequests, bidderRequest) { + const panxoUid = getPanxoUserId(); + if (!panxoUid) { + logWarn('Panxo: panxo_uid not found. Ensure Signal script is loaded before Prebid.'); + return []; + } + + // Group bids by propertyKey to handle multiple properties on same page + const bidsByPropertyKey = {}; + validBidRequests.forEach(bid => { + const key = deepAccess(bid, 'params.propertyKey'); + if (!bidsByPropertyKey[key]) { + bidsByPropertyKey[key] = []; + } + bidsByPropertyKey[key].push(bid); + }); + + // Build one request per propertyKey + const requests = []; + Object.keys(bidsByPropertyKey).forEach(propertyKey => { + const bidsForKey = bidsByPropertyKey[propertyKey]; + + const impressions = bidsForKey.map((bid) => { + const banner = buildBanner(bid); + if (!banner) return null; + + const sizes = deepAccess(bid, 'mediaTypes.banner.sizes') || []; + const primarySize = sizes[0] || [300, 250]; + + // Build impression object + const imp = { + id: bid.bidId, + banner: banner, + bidfloor: getFloorPrice(bid, primarySize), + bidfloorcur: DEFAULT_CURRENCY, + secure: 1, + tagid: bid.adUnitCode + }; + + // Merge full ortb2Imp object (instl, pmp, ext, etc.) + const ortb2Imp = deepAccess(bid, 'ortb2Imp'); + if (isPlainObject(ortb2Imp)) { + Object.keys(ortb2Imp).forEach(key => { + if (key === 'ext') { + imp.ext = { ...imp.ext, ...ortb2Imp.ext }; + } else if (imp[key] === undefined) { + imp[key] = ortb2Imp[key]; + } + }); + } + + return imp; + }).filter(Boolean); + + if (impressions.length === 0) return; + + const openrtbRequest = { + id: bidderRequest.bidderRequestId, + imp: impressions, + site: buildSite(bidderRequest), + device: buildDevice(), + user: buildUser(panxoUid, bidderRequest), + regs: buildRegs(bidderRequest), + source: buildSource(bidderRequest), + at: 1, + cur: [DEFAULT_CURRENCY], + tmax: bidderRequest.timeout || 1000 + }; + + requests.push({ + method: 'POST', + url: `${ENDPOINT_URL}?key=${encodeURIComponent(propertyKey)}&source=prebid`, + data: openrtbRequest, + options: { contentType: 'text/plain', withCredentials: false }, + bidderRequest: bidderRequest + }); + }); + + return requests; + }, + + interpretResponse(serverResponse, request) { + const bids = []; + const response = serverResponse.body; + + if (!response || !response.seatbid) return bids; + + const bidRequestMap = {}; + if (request.bidderRequest && request.bidderRequest.bids) { + request.bidderRequest.bids.forEach(bid => { + bidRequestMap[bid.bidId] = bid; + }); + } + + response.seatbid.forEach(seatbid => { + if (!seatbid.bid) return; + + seatbid.bid.forEach(bid => { + const originalBid = bidRequestMap[bid.impid]; + if (!originalBid) return; + + bids.push({ + requestId: bid.impid, + cpm: bid.price, + currency: response.cur || DEFAULT_CURRENCY, + width: bid.w, + height: bid.h, + creativeId: bid.crid || bid.id, + dealId: bid.dealid || null, + netRevenue: NET_REVENUE, + ttl: bid.exp || TTL, + ad: bid.adm, + nurl: bid.nurl, + meta: { + advertiserDomains: bid.adomain || [], + mediaType: BANNER + } + }); + }); + }); + + return bids; + }, + + getUserSyncs(syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent) { + const syncs = []; + + if (syncOptions.pixelEnabled) { + let syncUrl = SYNC_URL + '?source=prebid'; + + // GDPR - only include when gdprApplies is explicitly true or false + if (gdprConsent) { + if (typeof gdprConsent.gdprApplies === 'boolean') { + syncUrl += `&gdpr=${gdprConsent.gdprApplies ? 1 : 0}`; + } + if (gdprConsent.consentString) { + syncUrl += `&gdpr_consent=${encodeURIComponent(gdprConsent.consentString)}`; + } + } + + // US Privacy + if (uspConsent) { + syncUrl += `&us_privacy=${encodeURIComponent(uspConsent)}`; + } + + // GPP + if (gppConsent) { + if (gppConsent.gppString) { + syncUrl += `&gpp=${encodeURIComponent(gppConsent.gppString)}`; + } + if (gppConsent.applicableSections) { + syncUrl += `&gpp_sid=${encodeURIComponent(gppConsent.applicableSections.join(','))}`; + } + } + + syncs.push({ type: 'image', url: syncUrl }); + } + + return syncs; + }, + + onBidWon(bid) { + if (bid.nurl) { + const winUrl = bid.nurl.replace(/\$\{AUCTION_PRICE\}/g, bid.cpm); + const img = document.createElement('img'); + img.src = winUrl; + img.style.display = 'none'; + document.body.appendChild(img); + } + }, + + onTimeout(timeoutData) { + logWarn('Panxo: Bid timeout', timeoutData); + } +}; + +registerBidder(spec); diff --git a/modules/panxoBidAdapter.md b/modules/panxoBidAdapter.md new file mode 100644 index 0000000000..17a1df185f --- /dev/null +++ b/modules/panxoBidAdapter.md @@ -0,0 +1,229 @@ +# Overview + +``` +Module Name: Panxo Bid Adapter +Module Type: Bidder Adapter +Maintainer: tech@panxo.ai +``` + +# Description + +Panxo is a specialized SSP for AI-referred traffic monetization. This adapter enables publishers to monetize traffic coming from AI assistants like ChatGPT, Perplexity, Claude, and Gemini through Prebid.js header bidding. + +**Important**: This adapter requires the Panxo Signal script to be installed on the publisher's page. The Signal script must load before Prebid.js to ensure proper user identification and AI traffic detection. + +# Prerequisites + +1. Register your property at [app.panxo.ai](https://app.panxo.ai) +2. Obtain your `propertyKey` from the Panxo dashboard +3. Install the Panxo Signal script in your page's ``: + +```html + +``` + +# Bid Params + +| Name | Scope | Description | Example | Type | +|------|-------|-------------|---------|------| +| `propertyKey` | required | Your unique property identifier from Panxo dashboard | `'abc123def456'` | `string` | +| `floor` | optional | Minimum CPM floor price in USD | `0.50` | `number` | + +# Configuration Example + +```javascript +var adUnits = [{ + code: 'banner-ad', + mediaTypes: { + banner: { + sizes: [[300, 250], [728, 90]] + } + }, + bids: [{ + bidder: 'panxo', + params: { + propertyKey: 'your-property-key-here' + } + }] +}]; +``` + +# Full Page Example + +```html + + + + + + + + + + + + +
+ + +``` + +# Supported Media Types + +| Type | Support | +|------|---------| +| Banner | Yes | +| Video | No | +| Native | No | + +# Privacy & Consent + +**IAB TCF Global Vendor List ID: 1527** + +This adapter is registered with the IAB Europe Transparency and Consent Framework. Publishers using a CMP (Consent Management Platform) should ensure Panxo (Vendor ID 1527) is included in their vendor list. + +This adapter supports: + +- **GDPR/TCF 2.0**: Consent string is passed in bid requests. GVL ID: 1527 +- **CCPA/US Privacy**: USP string is passed in bid requests +- **GPP**: Global Privacy Platform strings are supported +- **COPPA**: Child-directed content flags are respected + +## CMP Configuration + +If you use a Consent Management Platform (Cookiebot, OneTrust, Quantcast Choice, etc.), ensure that: + +1. Panxo (Vendor ID: 1527) is included in your vendor list +2. Users can grant/deny consent specifically for Panxo +3. The CMP loads before Prebid.js to ensure consent is available + +Example TCF configuration with Prebid: + +```javascript +pbjs.setConfig({ + consentManagement: { + gdpr: { + cmpApi: 'iab', + timeout: 8000, + defaultGdprScope: true + }, + usp: { + cmpApi: 'iab', + timeout: 1000 + } + } +}); +``` + +# User Sync + +Panxo supports pixel-based user sync. Enable it in your Prebid configuration: + +```javascript +pbjs.setConfig({ + userSync: { + filterSettings: { + pixel: { + bidders: ['panxo'], + filter: 'include' + } + } + } +}); +``` + +# First Party Data + +This adapter supports First Party Data via the `ortb2` configuration: + +```javascript +pbjs.setConfig({ + ortb2: { + site: { + name: 'Example Site', + cat: ['IAB1'], + content: { + keywords: 'technology, ai' + } + }, + user: { + data: [{ + name: 'example-data-provider', + segment: [{ id: 'segment-1' }] + }] + } + } +}); +``` + +# Supply Chain (schain) + +Supply chain information is automatically passed when configured: + +```javascript +pbjs.setConfig({ + schain: { + validation: 'relaxed', + config: { + ver: '1.0', + complete: 1, + nodes: [{ + asi: 'publisher-domain.com', + sid: '12345', + hp: 1 + }] + } + } +}); +``` + +# Floor Prices + +This adapter supports the Prebid Price Floors Module. Configure floors as needed: + +```javascript +pbjs.setConfig({ + floors: { + enforcement: { floorDeals: true }, + data: { + default: 0.50, + schema: { fields: ['mediaType'] }, + values: { 'banner': 0.50 } + } + } +}); +``` + +# Win Notifications + +This adapter automatically fires win notification URLs (nurl) when a bid wins the auction. No additional configuration is required. + +# Contact + +For support or questions: +- Email: tech@panxo.ai +- Documentation: https://docs.panxo.ai diff --git a/modules/rubiconBidAdapter.js b/modules/rubiconBidAdapter.js index 47c311ceb9..a59f6b6379 100644 --- a/modules/rubiconBidAdapter.js +++ b/modules/rubiconBidAdapter.js @@ -739,6 +739,13 @@ export const spec = { [bid.width, bid.height] = sizeMap[ad.size_id].split('x').map(num => Number(num)); } + if (ad.bid_cat && ad.bid_cat.length) { + bid.meta.primaryCatId = ad.bid_cat[0]; + if (ad.bid_cat.length > 1) { + bid.meta.secondaryCatIds = ad.bid_cat.slice(1); + } + } + // add server-side targeting bid.rubiconTargeting = (Array.isArray(ad.targeting) ? ad.targeting : []) .reduce((memo, item) => { diff --git a/modules/yieldoneBidAdapter.js b/modules/yieldoneBidAdapter.js index c71dadd157..244da9aa3a 100644 --- a/modules/yieldoneBidAdapter.js +++ b/modules/yieldoneBidAdapter.js @@ -126,6 +126,12 @@ export const spec = { payload.gpid = gpid; } + // instl + const instl = deepAccess(bidRequest, 'ortb2Imp.instl'); + if (instl === 1 || instl === '1') { + payload.instl = 1; + } + return { method: 'GET', url: ENDPOINT_URL, diff --git a/package-lock.json b/package-lock.json index 4d17094239..57c7d89e06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15839,8 +15839,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "license": "MIT" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" }, "node_modules/lodash.clone": { "version": "4.5.0", @@ -32509,7 +32510,9 @@ } }, "lodash": { - "version": "4.17.21" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" }, "lodash.clone": { "version": "4.5.0", diff --git a/test/spec/modules/bridgewellBidAdapter_spec.js b/test/spec/modules/bridgewellBidAdapter_spec.js index 3802d97614..edb8286f93 100644 --- a/test/spec/modules/bridgewellBidAdapter_spec.js +++ b/test/spec/modules/bridgewellBidAdapter_spec.js @@ -165,16 +165,20 @@ describe('bridgewellBidAdapter', function () { expect(payload).to.be.an('object'); expect(payload.adUnits).to.be.an('array'); expect(payload.url).to.exist.and.to.equal('https://www.bridgewell.com/'); + expect(payload.userIds).to.deep.equal(userId); + expect(payload.userIdAsEids).to.deep.equal(userIdAsEids); + expect(payload.auctionId).to.equal('1d1a030790a475'); + expect(payload.bidderRequestId).to.equal('22edbae2733bf6'); for (let i = 0, max_i = payload.adUnits.length; i < max_i; i++) { const u = payload.adUnits[i]; expect(u).to.have.property('ChannelID').that.is.a('string'); expect(u).to.not.have.property('cid'); expect(u).to.have.property('adUnitCode').and.to.equal('adunit-code-2'); expect(u).to.have.property('requestId').and.to.equal('3150ccb55da321'); - expect(u).to.have.property('userIds'); - expect(u.userIds).to.deep.equal(userId); - expect(u).to.have.property('userIdAsEids'); - expect(u.userIdAsEids).to.deep.equal(userIdAsEids); + expect(u).to.have.property('transactionId'); + expect(u).to.have.property('sizes'); + expect(u).to.have.property('mediaTypes'); + expect(u).to.have.property('ortb2Imp'); } }); @@ -213,16 +217,20 @@ describe('bridgewellBidAdapter', function () { expect(payload).to.be.an('object'); expect(payload.adUnits).to.be.an('array'); expect(payload.url).to.exist.and.to.equal('https://www.bridgewell.com/'); + expect(payload.userIds).to.deep.equal(userId); + expect(payload.userIdAsEids).to.deep.equal(userIdAsEids); + expect(payload.auctionId).to.equal('1d1a030790a475'); + expect(payload.bidderRequestId).to.equal('22edbae2733bf6'); for (let i = 0, max_i = payload.adUnits.length; i < max_i; i++) { const u = payload.adUnits[i]; expect(u).to.have.property('cid').that.is.a('number'); expect(u).to.not.have.property('ChannelID'); expect(u).to.have.property('adUnitCode').and.to.equal('adunit-code-2'); expect(u).to.have.property('requestId').and.to.equal('3150ccb55da321'); - expect(u).to.have.property('userIds'); - expect(u.userIds).to.deep.equal(userId); - expect(u).to.have.property('userIdAsEids'); - expect(u.userIdAsEids).to.deep.equal(userIdAsEids); + expect(u).to.have.property('transactionId'); + expect(u).to.have.property('sizes'); + expect(u).to.have.property('mediaTypes'); + expect(u).to.have.property('ortb2Imp'); } }); @@ -240,6 +248,178 @@ describe('bridgewellBidAdapter', function () { const validBidRequests = request.validBidRequests; expect(validBidRequests).to.deep.equal(bidRequests); }); + + it('should include ortb2Imp fields in adUnit', function () { + const bidderRequest = { + refererInfo: { + page: 'https://www.bridgewell.com/', + legacy: { + referer: 'https://www.bridgewell.com/', + } + } + } + const bidRequestsWithOrtb2 = [ + { + 'bidder': 'bridgewell', + 'params': { + 'cid': 1234, + }, + 'adUnitCode': 'adunit-code-1', + 'transactionId': 'trans-123', + 'adUnitId': 'adunit-123', + 'sizes': [[300, 250]], + 'mediaTypes': { + 'banner': { + 'sizes': [[300, 250]] + } + }, + 'bidId': 'bid-123', + 'ortb2Imp': { + 'ext': { + 'data': { + 'adserver': { + 'name': 'gam', + 'adslot': '/1234/test' + }, + 'pbadslot': '/1234/test-pbadslot' + }, + 'gpid': 'test-gpid', + 'prebid': { + 'passthrough': { + 'bucket': 'test-bucket', + 'client': 'test-client', + 'gamAdCode': 'test-gam', + 'undefinedField': undefined + } + } + }, + 'banner': { + 'pos': 1 + } + }, + 'userId': userId, + 'userIdAsEids': userIdAsEids, + } + ]; + + const request = spec.buildRequests(bidRequestsWithOrtb2, bidderRequest); + const adUnit = request.data.adUnits[0]; + + expect(adUnit.transactionId).to.equal('trans-123'); + expect(adUnit.adUnitId).to.equal('adunit-123'); + expect(adUnit.sizes).to.deep.equal([[300, 250]]); + expect(adUnit.ortb2Imp.ext.data.adserver.name).to.equal('gam'); + expect(adUnit.ortb2Imp.ext.data.adserver.adslot).to.equal('/1234/test'); + expect(adUnit.ortb2Imp.ext.data.pbadslot).to.equal('/1234/test-pbadslot'); + expect(adUnit.ortb2Imp.ext.gpid).to.equal('test-gpid'); + expect(adUnit.ortb2Imp.banner.pos).to.equal(1); + expect(adUnit.ortb2Imp.ext.prebid.passthrough).to.deep.equal({ + bucket: 'test-bucket', + client: 'test-client', + gamAdCode: 'test-gam' + }); + expect(adUnit.ortb2Imp.ext.prebid.passthrough).to.not.have.property('undefinedField'); + }); + + it('should include floor information when getFloor is available', function () { + const bidderRequest = { + refererInfo: { + page: 'https://www.bridgewell.com/', + legacy: { + referer: 'https://www.bridgewell.com/', + } + } + } + const bidRequestsWithFloor = [ + { + 'bidder': 'bridgewell', + 'params': { + 'cid': 1234, + }, + 'adUnitCode': 'adunit-code-1', + 'mediaTypes': { + 'banner': { + 'sizes': [[300, 250]] + } + }, + 'bidId': 'bid-123', + 'getFloor': function() { + return { + floor: 1.5, + currency: 'USD' + }; + }, + 'userId': userId, + 'userIdAsEids': userIdAsEids, + } + ]; + + const request = spec.buildRequests(bidRequestsWithFloor, bidderRequest); + const adUnit = request.data.adUnits[0]; + + expect(adUnit.floor).to.equal(1.5); + expect(adUnit.currency).to.equal('USD'); + }); + + it('should include additional bid request fields in payload', function () { + const bidderRequest = { + refererInfo: { + page: 'https://www.bridgewell.com/', + ref: 'https://www.referrer.com/', + legacy: { + referer: 'https://www.bridgewell.com/', + } + }, + ortb2: { + site: { + name: 'test-site' + } + } + } + const bidRequestsWithMetrics = [ + { + 'bidder': 'bridgewell', + 'params': { + 'cid': 1234, + }, + 'adUnitCode': 'adunit-code-1', + 'mediaTypes': { + 'banner': { + 'sizes': [[300, 250]] + } + }, + 'bidId': 'bid-123', + 'bidderRequestId': 'bidder-req-123', + 'auctionId': 'auction-123', + 'src': 's2s', + 'auctionsCount': 5, + 'bidRequestsCount': 10, + 'bidderRequestsCount': 3, + 'bidderWinsCount': 2, + 'deferBilling': true, + 'metrics': { + 'test': 'metric' + }, + 'userId': userId, + 'userIdAsEids': userIdAsEids, + } + ]; + + const request = spec.buildRequests(bidRequestsWithMetrics, bidderRequest); + const payload = request.data; + + expect(payload.auctionId).to.equal('auction-123'); + expect(payload.bidderRequestId).to.equal('bidder-req-123'); + expect(payload.src).to.equal('s2s'); + expect(payload.auctionsCount).to.equal(5); + expect(payload.bidRequestsCount).to.equal(10); + expect(payload.bidderRequestsCount).to.equal(3); + expect(payload.bidderWinsCount).to.equal(2); + expect(payload.deferBilling).to.equal(true); + expect(payload.metrics).to.deep.equal({test: 'metric'}); + expect(payload.referrer).to.equal('https://www.referrer.com/'); + expect(payload.ortb2).to.deep.equal({site: {name: 'test-site'}}); + }); }); describe('interpretResponse', function () { diff --git a/test/spec/modules/insticatorBidAdapter_spec.js b/test/spec/modules/insticatorBidAdapter_spec.js index cee99ca8c3..dd604c4958 100644 --- a/test/spec/modules/insticatorBidAdapter_spec.js +++ b/test/spec/modules/insticatorBidAdapter_spec.js @@ -812,6 +812,172 @@ describe('InsticatorBidAdapter', function () { const data = JSON.parse(requests[0].data); expect(data.site.publisher).to.not.an('object'); }); + + it('should include publisherId as query parameter in endpoint URL', function () { + const tempBiddRequest = { + ...bidRequest, + } + tempBiddRequest.params = { + ...tempBiddRequest.params, + publisherId: '86dd03a1-053f-4e3e-90e7-389070a0c62c' + } + const requests = spec.buildRequests([tempBiddRequest], bidderRequest); + expect(requests[0].url).to.include('publisherId=86dd03a1-053f-4e3e-90e7-389070a0c62c'); + }); + + it('should not include publisherId query param if publisherId is not present', function () { + const tempBiddRequest = { + ...bidRequest, + } + // Ensure no publisherId in params + delete tempBiddRequest.params.publisherId; + const requests = spec.buildRequests([tempBiddRequest], bidderRequest); + expect(requests[0].url).to.not.include('publisherId'); + }); + + it('should not include publisherId query param if publisherId is empty string', function () { + const tempBiddRequest = { + ...bidRequest, + } + tempBiddRequest.params = { + ...tempBiddRequest.params, + publisherId: '' + } + const requests = spec.buildRequests([tempBiddRequest], bidderRequest); + expect(requests[0].url).to.not.include('publisherId'); + }); + + it('should include publisherId query param with custom endpoint URL', function () { + const tempBiddRequest = { + ...bidRequest, + } + tempBiddRequest.params = { + ...tempBiddRequest.params, + publisherId: 'test-publisher-123', + bid_endpoint_request_url: 'https://custom.endpoint.com/v1/bid' + } + const requests = spec.buildRequests([tempBiddRequest], bidderRequest); + expect(requests[0].url).to.equal('https://custom.endpoint.com/v1/bid?publisherId=test-publisher-123'); + }); + + // ORTB 2.6 Ad Pod video params tests + describe('Ad Pod video params', function () { + it('should include Ad Pod params when present in video mediaType', function () { + const adPodBidRequest = { + ...bidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4', 'video/mpeg'], + w: 640, + h: 480, + podid: 'pod-123', + podseq: 1, + poddur: 300, + slotinpod: 1, + mincpmpersec: 0.02, + maxseq: 5, + rqddurs: [15, 30, 60], + }, + }, + }; + const requests = spec.buildRequests([adPodBidRequest], bidderRequest); + const data = JSON.parse(requests[0].data); + + expect(data.imp[0].video).to.have.property('podid', 'pod-123'); + expect(data.imp[0].video).to.have.property('podseq', 1); + expect(data.imp[0].video).to.have.property('poddur', 300); + expect(data.imp[0].video).to.have.property('slotinpod', 1); + expect(data.imp[0].video).to.have.property('mincpmpersec', 0.02); + expect(data.imp[0].video).to.have.property('maxseq', 5); + expect(data.imp[0].video).to.have.property('rqddurs').that.deep.equals([15, 30, 60]); + }); + + it('should map adPodDurationSec to poddur', function () { + const adPodBidRequest = { + ...bidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 640, + h: 480, + adPodDurationSec: 120, + }, + }, + }; + const requests = spec.buildRequests([adPodBidRequest], bidderRequest); + const data = JSON.parse(requests[0].data); + + expect(data.imp[0].video).to.have.property('poddur', 120); + }); + + it('should map durationRangeSec to rqddurs', function () { + const adPodBidRequest = { + ...bidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 640, + h: 480, + durationRangeSec: [15, 30, 45], + }, + }, + }; + const requests = spec.buildRequests([adPodBidRequest], bidderRequest); + const data = JSON.parse(requests[0].data); + + expect(data.imp[0].video).to.have.property('rqddurs').that.deep.equals([15, 30, 45]); + }); + + it('should not include invalid Ad Pod params', function () { + const adPodBidRequest = { + ...bidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 640, + h: 480, + podid: '', // invalid - empty string + podseq: -1, // invalid - negative + poddur: 0, // invalid - zero + slotinpod: 5, // invalid - not in [-1, 0, 1, 2] + mincpmpersec: -0.5, // invalid - negative + maxseq: 0, // invalid - zero + rqddurs: [0, -15], // invalid - contains non-positive values + }, + }, + }; + const requests = spec.buildRequests([adPodBidRequest], bidderRequest); + const data = JSON.parse(requests[0].data); + + expect(data.imp[0].video).to.not.have.property('podid'); + expect(data.imp[0].video).to.not.have.property('podseq'); + expect(data.imp[0].video).to.not.have.property('poddur'); + expect(data.imp[0].video).to.not.have.property('slotinpod'); + expect(data.imp[0].video).to.not.have.property('mincpmpersec'); + expect(data.imp[0].video).to.not.have.property('maxseq'); + expect(data.imp[0].video).to.not.have.property('rqddurs'); + }); + + it('should validate slotinpod accepts valid values [-1, 0, 1, 2]', function () { + [-1, 0, 1, 2].forEach(slotValue => { + const adPodBidRequest = { + ...bidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 640, + h: 480, + slotinpod: slotValue, + }, + }, + }; + const requests = spec.buildRequests([adPodBidRequest], bidderRequest); + const data = JSON.parse(requests[0].data); + + expect(data.imp[0].video).to.have.property('slotinpod', slotValue); + }); + }); + }); }); describe('interpretResponse', function () { @@ -929,7 +1095,7 @@ describe('InsticatorBidAdapter', function () { cpm: 0.5, currency: 'USD', netRevenue: true, - ttl: 60, + ttl: 60, // MIN(60, 300) = 60 - bid.exp is upper bound width: 300, height: 200, mediaType: 'banner', @@ -937,7 +1103,8 @@ describe('InsticatorBidAdapter', function () { adUnitCode: 'adunit-code-1', meta: { advertiserDomains: ['test1.com'], - test: 1 + test: 1, + seat: 'some-dsp' } }, { @@ -953,7 +1120,8 @@ describe('InsticatorBidAdapter', function () { meta: { advertiserDomains: [ 'test2.com' - ] + ], + seat: 'some-dsp' }, ad: 'adm2', adUnitCode: 'adunit-code-2', @@ -971,7 +1139,8 @@ describe('InsticatorBidAdapter', function () { meta: { advertiserDomains: [ 'test3.com' - ] + ], + seat: 'some-dsp' }, ad: 'adm3', adUnitCode: 'adunit-code-3', @@ -996,6 +1165,515 @@ describe('InsticatorBidAdapter', function () { delete response.body.seatbid; expect(spec.interpretResponse(response, bidRequests)).to.have.length(0); }); + + it('should return empty response for 204 No Content (undefined body)', function () { + const response = { body: undefined }; + expect(spec.interpretResponse(response, bidRequests)).to.have.length(0); + }); + + it('should return empty response for 204 No Content (null body)', function () { + const response = { body: null }; + expect(spec.interpretResponse(response, bidRequests)).to.have.length(0); + }); + + it('should return empty response for empty object body', function () { + const response = { body: {} }; + expect(spec.interpretResponse(response, bidRequests)).to.have.length(0); + }); + + // ORTB 2.6 Response Fields Tests + describe('ORTB 2.6 response fields', function () { + const ortb26BidRequests = { + method: 'POST', + url: 'https://ex.ingage.tech/v1/openrtb', + options: { + contentType: 'application/json', + withCredentials: true, + }, + data: '', + bidderRequest: { + bidderRequestId: '22edbae2733bf6', + auctionId: '74f78609-a92d-4cf1-869f-1b244bbfb5d2', + timeout: 300, + bids: [ + { + bidder: 'insticator', + params: { + adUnitId: '1a2b3c4d5e6f1a2b3c4d' + }, + adUnitCode: 'adunit-code-1', + sizes: [[300, 250]], + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bidId: 'bid1', + } + ] + } + }; + + it('should map category (cat) to meta.primaryCatId and meta.secondaryCatIds', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + cat: ['IAB1', 'IAB2-1', 'IAB3'], + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse.meta).to.have.property('primaryCatId', 'IAB1'); + expect(bidResponse.meta).to.have.property('secondaryCatIds').that.deep.equals(['IAB2-1', 'IAB3']); + }); + + it('should map single category without secondaryCatIds', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + cat: ['IAB1'], + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse.meta).to.have.property('primaryCatId', 'IAB1'); + expect(bidResponse.meta).to.not.have.property('secondaryCatIds'); + }); + + it('should map seat to meta.seat', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-seat-123', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + adomain: ['test.com'], + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse.meta).to.have.property('seat', 'dsp-seat-123'); + }); + + it('should map creative attributes (attr) to meta.attr', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + attr: [1, 2, 3], + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse.meta).to.have.property('attr').that.deep.equals([1, 2, 3]); + }); + + it('should map dealid to bidResponse.dealId', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + dealid: 'deal-abc-123', + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse).to.have.property('dealId', 'deal-abc-123'); + }); + + it('should map billing URL (burl) to bidResponse.burl', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + burl: 'https://billing.example.com/win?price=${AUCTION_PRICE}', + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse).to.have.property('burl', 'https://billing.example.com/win?price=${AUCTION_PRICE}'); + }); + + it('should map notice URL (nurl) to bidResponse.nurl', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + nurl: 'https://win.example.com/notify?price=${AUCTION_PRICE}', + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse).to.have.property('nurl', 'https://win.example.com/notify?price=${AUCTION_PRICE}'); + }); + + it('should map video duration (dur) to bidResponse.video.durationSeconds', function () { + const videoBidRequests = { + ...ortb26BidRequests, + bidderRequest: { + ...ortb26BidRequests.bidderRequest, + bids: [{ + ...ortb26BidRequests.bidderRequest.bids[0], + mediaTypes: { + video: { + mimes: ['video/mp4'], + playerSize: [[640, 480]], + } + } + }] + } + }; + + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 640, + h: 480, + adm: '', + dur: 30, + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, videoBidRequests)[0]; + + expect(bidResponse).to.have.property('video'); + expect(bidResponse.video).to.have.property('durationSeconds', 30); + }); + + it('should not set video.context when original request is not adpod', function () { + const instreamBidRequests = { + ...ortb26BidRequests, + bidderRequest: { + ...ortb26BidRequests.bidderRequest, + bids: [{ + ...ortb26BidRequests.bidderRequest.bids[0], + mediaTypes: { + video: { + mimes: ['video/mp4'], + playerSize: [[640, 480]], + context: 'instream', + } + } + }] + } + }; + + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 640, + h: 480, + adm: '', + dur: 30, + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, instreamBidRequests)[0]; + + expect(bidResponse).to.have.property('video'); + expect(bidResponse.video).to.have.property('durationSeconds', 30); + expect(bidResponse.video).to.not.have.property('context'); + }); + + it('should use MIN of bid.exp and BID_TTL for ttl (bid.exp is upper bound)', function () { + // When bid.exp (60) is less than BID_TTL (300), use 60 + const responseWithLowExp = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + exp: 60, + }] + }] + } + }; + const bidResponseLow = spec.interpretResponse(responseWithLowExp, ortb26BidRequests)[0]; + expect(bidResponseLow.ttl).to.equal(60); // MIN(60, 300) = 60 + + // When bid.exp (600) is greater than BID_TTL (300), use 300 + const responseWithHighExp = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + exp: 600, + }] + }] + } + }; + const bidResponseHigh = spec.interpretResponse(responseWithHighExp, ortb26BidRequests)[0]; + expect(bidResponseHigh.ttl).to.equal(300); // MIN(600, 300) = 300 + }); + + it('should default ttl to BID_TTL when bid.exp is not provided', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + // no exp field + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.ttl).to.equal(300); // defaults to configTTL when no bid.exp + }); + + it('should include all ORTB 2.6 fields in a single response', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'full-dsp', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 2.5, + w: 300, + h: 250, + adm: 'adm1', + adomain: ['advertiser.com'], + cat: ['IAB1', 'IAB2'], + attr: [1, 2], + dealid: 'premium-deal', + burl: 'https://billing.example.com/win', + exp: 450, + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + // Check all ORTB 2.6 fields + expect(bidResponse.meta.advertiserDomains).to.deep.equal(['advertiser.com']); + expect(bidResponse.meta.primaryCatId).to.equal('IAB1'); + expect(bidResponse.meta.secondaryCatIds).to.deep.equal(['IAB2']); + expect(bidResponse.meta.seat).to.equal('full-dsp'); + expect(bidResponse.meta.attr).to.deep.equal([1, 2]); + expect(bidResponse.dealId).to.equal('premium-deal'); + expect(bidResponse.burl).to.equal('https://billing.example.com/win'); + expect(bidResponse.ttl).to.equal(300); // MIN(450, 300) = 300 - bid.exp is upper bound + }); + + // Media Type Detection Tests + describe('media type detection', function () { + it('should detect video using mtype=2 (ORTB 2.6 standard)', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'some non-vast content', + mtype: 2, // video + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.mediaType).to.equal('video'); + }); + + it('should detect banner using mtype=1 (ORTB 2.6 standard)', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: '', // VAST content but mtype says banner + mtype: 1, // banner + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.mediaType).to.equal('banner'); + }); + + it('should detect video using case-insensitive VAST detection', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: '', // lowercase vast + // no mtype + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.mediaType).to.equal('video'); + }); + + it('should default to banner when no video signals present', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: '
banner ad
', + // no mtype, no VAST + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.mediaType).to.equal('banner'); + }); + + it('should detect banner when VAST-like content is inside script tag', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: '
banner
', + // no mtype + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.mediaType).to.equal('banner'); + }); + }); + }); }); describe('getUserSyncs', function () { diff --git a/test/spec/modules/panxoBidAdapter_spec.js b/test/spec/modules/panxoBidAdapter_spec.js new file mode 100644 index 0000000000..4ce770aec1 --- /dev/null +++ b/test/spec/modules/panxoBidAdapter_spec.js @@ -0,0 +1,346 @@ +import { expect } from 'chai'; +import { spec, storage } from 'modules/panxoBidAdapter.js'; +import { BANNER } from 'src/mediaTypes.js'; + +describe('PanxoBidAdapter', function () { + const PROPERTY_KEY = 'abc123def456'; + const USER_ID = 'test-user-id-12345'; + + // Mock storage.getDataFromLocalStorage + let getDataStub; + + beforeEach(function () { + getDataStub = sinon.stub(storage, 'getDataFromLocalStorage'); + getDataStub.withArgs('panxo_uid').returns(USER_ID); + }); + + afterEach(function () { + getDataStub.restore(); + }); + + describe('isBidRequestValid', function () { + it('should return true when propertyKey is present', function () { + const bid = { + bidder: 'panxo', + params: { propertyKey: PROPERTY_KEY }, + mediaTypes: { banner: { sizes: [[300, 250]] } } + }; + expect(spec.isBidRequestValid(bid)).to.be.true; + }); + + it('should return false when propertyKey is missing', function () { + const bid = { + bidder: 'panxo', + params: {}, + mediaTypes: { banner: { sizes: [[300, 250]] } } + }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when banner mediaType is missing', function () { + const bid = { + bidder: 'panxo', + params: { propertyKey: PROPERTY_KEY }, + mediaTypes: { video: {} } + }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + }); + + describe('buildRequests', function () { + const bidderRequest = { + bidderRequestId: 'test-request-id', + auctionId: 'test-auction-id', + timeout: 1500, + refererInfo: { + page: 'https://example.com/page', + domain: 'example.com', + ref: 'https://google.com' + } + }; + + const validBidRequests = [{ + bidder: 'panxo', + bidId: 'bid-id-1', + adUnitCode: 'ad-unit-1', + params: { propertyKey: PROPERTY_KEY }, + mediaTypes: { banner: { sizes: [[300, 250], [728, 90]] } } + }]; + + it('should build a valid OpenRTB request', function () { + const requests = spec.buildRequests(validBidRequests, bidderRequest); + + expect(requests).to.be.an('array').with.lengthOf(1); + expect(requests[0].method).to.equal('POST'); + expect(requests[0].url).to.include('panxo-sys.com/openrtb/2.5/bid'); + expect(requests[0].url).to.include(`key=${PROPERTY_KEY}`); + expect(requests[0].data).to.be.an('object'); + }); + + it('should include user.buyeruid from localStorage', function () { + const requests = spec.buildRequests(validBidRequests, bidderRequest); + + expect(requests[0].data.user).to.be.an('object'); + expect(requests[0].data.user.buyeruid).to.equal(USER_ID); + }); + + it('should build correct impressions', function () { + const requests = spec.buildRequests(validBidRequests, bidderRequest); + + expect(requests[0].data.imp).to.be.an('array'); + expect(requests[0].data.imp[0].id).to.equal('bid-id-1'); + expect(requests[0].data.imp[0].banner.format).to.have.lengthOf(2); + expect(requests[0].data.imp[0].tagid).to.equal('ad-unit-1'); + }); + + it('should return empty array when panxo_uid is not found', function () { + getDataStub.withArgs('panxo_uid').returns(null); + const requests = spec.buildRequests(validBidRequests, bidderRequest); + + expect(requests).to.be.an('array').that.is.empty; + }); + + it('should include GDPR consent when gdprApplies is true', function () { + const gdprBidderRequest = { + ...bidderRequest, + gdprConsent: { + gdprApplies: true, + consentString: 'CO-test-consent-string' + } + }; + const requests = spec.buildRequests(validBidRequests, gdprBidderRequest); + + expect(requests[0].data.regs.ext.gdpr).to.equal(1); + expect(requests[0].data.user.ext.consent).to.equal('CO-test-consent-string'); + }); + + it('should not include gdpr flag when gdprApplies is undefined', function () { + const gdprBidderRequest = { + ...bidderRequest, + gdprConsent: { + gdprApplies: undefined, + consentString: 'CO-test-consent-string' + } + }; + const requests = spec.buildRequests(validBidRequests, gdprBidderRequest); + + expect(requests[0].data.regs.ext.gdpr).to.be.undefined; + expect(requests[0].data.user.ext.consent).to.equal('CO-test-consent-string'); + }); + + it('should include USP consent when available', function () { + const uspBidderRequest = { + ...bidderRequest, + uspConsent: '1YNN' + }; + const requests = spec.buildRequests(validBidRequests, uspBidderRequest); + + expect(requests[0].data.regs.ext.us_privacy).to.equal('1YNN'); + }); + + it('should include schain when available', function () { + const schainBidderRequest = { + ...bidderRequest, + ortb2: { + source: { + ext: { + schain: { + ver: '1.0', + complete: 1, + nodes: [{ asi: 'example.com', sid: '12345', hp: 1 }] + } + } + } + } + }; + const requests = spec.buildRequests(validBidRequests, schainBidderRequest); + + expect(requests[0].data.source.ext.schain).to.deep.equal(schainBidderRequest.ortb2.source.ext.schain); + }); + + it('should use floor from getFloor function', function () { + const bidWithFloor = [{ + ...validBidRequests[0], + getFloor: () => ({ currency: 'USD', floor: 1.50 }) + }]; + const requests = spec.buildRequests(bidWithFloor, bidderRequest); + + expect(requests[0].data.imp[0].bidfloor).to.equal(1.50); + }); + + it('should include full ortb2Imp object in impression', function () { + const bidWithOrtb2Imp = [{ + ...validBidRequests[0], + ortb2Imp: { + instl: 1, + ext: { data: { customField: 'value' } } + } + }]; + const requests = spec.buildRequests(bidWithOrtb2Imp, bidderRequest); + + expect(requests[0].data.imp[0].instl).to.equal(1); + expect(requests[0].data.imp[0].ext.data.customField).to.equal('value'); + }); + + it('should split requests by different propertyKeys', function () { + const multiPropertyBids = [ + { + bidder: 'panxo', + bidId: 'bid-id-1', + adUnitCode: 'ad-unit-1', + params: { propertyKey: 'property-a' }, + mediaTypes: { banner: { sizes: [[300, 250]] } } + }, + { + bidder: 'panxo', + bidId: 'bid-id-2', + adUnitCode: 'ad-unit-2', + params: { propertyKey: 'property-b' }, + mediaTypes: { banner: { sizes: [[728, 90]] } } + } + ]; + const requests = spec.buildRequests(multiPropertyBids, bidderRequest); + + expect(requests).to.have.lengthOf(2); + expect(requests[0].url).to.include('key=property-a'); + expect(requests[1].url).to.include('key=property-b'); + }); + }); + + describe('interpretResponse', function () { + const request = { + bidderRequest: { + bids: [{ bidId: 'bid-id-1', adUnitCode: 'ad-unit-1' }] + } + }; + + const serverResponse = { + body: { + id: 'response-id', + seatbid: [{ + seat: 'panxo', + bid: [{ + impid: 'bid-id-1', + price: 2.50, + w: 300, + h: 250, + adm: '
Ad creative
', + crid: 'creative-123', + adomain: ['advertiser.com'], + nurl: 'https://panxo-sys.com/win?price=${AUCTION_PRICE}' + }] + }], + cur: 'USD' + } + }; + + it('should parse valid bid response', function () { + const bids = spec.interpretResponse(serverResponse, request); + + expect(bids).to.have.lengthOf(1); + expect(bids[0].requestId).to.equal('bid-id-1'); + expect(bids[0].cpm).to.equal(2.50); + expect(bids[0].width).to.equal(300); + expect(bids[0].height).to.equal(250); + expect(bids[0].currency).to.equal('USD'); + expect(bids[0].netRevenue).to.be.true; + expect(bids[0].ad).to.equal('
Ad creative
'); + expect(bids[0].meta.advertiserDomains).to.include('advertiser.com'); + }); + + it('should return empty array for empty response', function () { + const emptyResponse = { body: {} }; + const bids = spec.interpretResponse(emptyResponse, request); + + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should return empty array for no seatbid', function () { + const noSeatbidResponse = { body: { id: 'test', seatbid: [] } }; + const bids = spec.interpretResponse(noSeatbidResponse, request); + + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should include nurl in bid response', function () { + const bids = spec.interpretResponse(serverResponse, request); + + expect(bids[0].nurl).to.include('panxo-sys.com/win'); + }); + }); + + describe('getUserSyncs', function () { + it('should return pixel sync when enabled', function () { + const syncOptions = { pixelEnabled: true }; + const syncs = spec.getUserSyncs(syncOptions); + + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].type).to.equal('image'); + expect(syncs[0].url).to.include('panxo-sys.com/usersync'); + }); + + it('should return empty array when pixel sync disabled', function () { + const syncOptions = { pixelEnabled: false }; + const syncs = spec.getUserSyncs(syncOptions); + + expect(syncs).to.be.an('array').that.is.empty; + }); + + it('should include GDPR params when gdprApplies is true', function () { + const syncOptions = { pixelEnabled: true }; + const gdprConsent = { gdprApplies: true, consentString: 'test-consent' }; + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + + expect(syncs[0].url).to.include('gdpr=1'); + expect(syncs[0].url).to.include('gdpr_consent=test-consent'); + }); + + it('should not include gdpr flag when gdprApplies is undefined', function () { + const syncOptions = { pixelEnabled: true }; + const gdprConsent = { gdprApplies: undefined, consentString: 'test-consent' }; + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + + expect(syncs[0].url).to.not.include('gdpr='); + expect(syncs[0].url).to.include('gdpr_consent=test-consent'); + }); + + it('should include USP params when available', function () { + const syncOptions = { pixelEnabled: true }; + const uspConsent = '1YNN'; + const syncs = spec.getUserSyncs(syncOptions, [], null, uspConsent); + + expect(syncs[0].url).to.include('us_privacy=1YNN'); + }); + }); + + describe('onBidWon', function () { + it('should fire win notification pixel', function () { + const bid = { + nurl: 'https://panxo-sys.com/win?price=${AUCTION_PRICE}', + cpm: 2.50 + }; + + // Mock document.createElement + const imgStub = { src: '', style: {} }; + const createElementStub = sinon.stub(document, 'createElement').returns(imgStub); + const appendChildStub = sinon.stub(document.body, 'appendChild'); + + spec.onBidWon(bid); + + expect(imgStub.src).to.include('price=2.5'); + + createElementStub.restore(); + appendChildStub.restore(); + }); + }); + + describe('spec properties', function () { + it('should have correct bidder code', function () { + expect(spec.code).to.equal('panxo'); + }); + + it('should support banner media type', function () { + expect(spec.supportedMediaTypes).to.include(BANNER); + }); + }); +}); diff --git a/test/spec/modules/rubiconBidAdapter_spec.js b/test/spec/modules/rubiconBidAdapter_spec.js index 70e55c5a7e..bd9990f75d 100644 --- a/test/spec/modules/rubiconBidAdapter_spec.js +++ b/test/spec/modules/rubiconBidAdapter_spec.js @@ -3860,6 +3860,98 @@ describe('the rubicon adapter', function () { expect(bids[1].meta.mediaType).to.equal('video'); }); + it('should handle primaryCatId and secondaryCatIds when bid.bid_cat is present in response', function () { + const response = { + 'status': 'ok', + 'account_id': 14062, + 'site_id': 70608, + 'zone_id': 530022, + 'size_id': 15, + 'alt_size_ids': [ + 43 + ], + 'tracking': '', + 'inventory': {}, + 'ads': [ + { + 'status': 'ok', + 'impression_id': '153dc240-8229-4604-b8f5-256933b9374c', + 'size_id': '15', + 'ad_id': '6', + 'advertiser': 7, + 'network': 8, + 'creative_id': 'crid-9', + 'type': 'script', + 'script': 'alert(\'foo\')', + 'campaign_id': 10, + 'cpm': 0.811, + 'emulated_format': 'video', + 'targeting': [ + { + 'key': 'rpfl_14062', + 'values': [ + '15_tier_all_test' + ] + } + ], + 'bid_cat': ['IAB1-1'] + }, + { + 'status': 'ok', + 'impression_id': '153dc240-8229-4604-b8f5-256933b9374d', + 'size_id': '43', + 'ad_id': '7', + 'advertiser': 7, + 'network': 8, + 'creative_id': 'crid-9', + 'type': 'script', + 'script': 'alert(\'foo\')', + 'campaign_id': 10, + 'cpm': 0.911, + 'targeting': [ + { + 'key': 'rpfl_14062', + 'values': [ + '43_tier_all_test' + ] + } + ], + 'bid_cat': ['IAB1-2', 'IAB1-3'] + }, + { + 'status': 'ok', + 'impression_id': '153dc240-8229-4604-b8f5-256933b9374d', + 'size_id': '43', + 'ad_id': '7', + 'advertiser': 7, + 'network': 8, + 'creative_id': 'crid-9', + 'type': 'script', + 'script': 'alert(\'foo\')', + 'campaign_id': 10, + 'cpm': 10, + 'targeting': [ + { + 'key': 'rpfl_14062', + 'values': [ + '43_tier_all_test' + ] + } + ] + } + ] + }; + const bids = spec.interpretResponse({body: response}, { + bidRequest: bidderRequest.bids[0] + }); + expect(bids[0].meta.primaryCatId).to.be.undefined; + expect(bids[0].meta.secondaryCatIds).to.be.undefined; + expect(bids[1].meta.primaryCatId).to.equal(response.ads[1].bid_cat[0]); + expect(bids[1].meta.secondaryCatIds).to.deep.equal(response.ads[1].bid_cat.slice(1)); + expect(bids[2].meta.primaryCatId).to.equal(response.ads[0].bid_cat[0]); + expect(bids[2].meta.secondaryCatIds).to.be.undefined; + }); + describe('singleRequest enabled', function () { it('handles bidRequest of type Array and returns associated adUnits', function () { const overrideMap = []; diff --git a/test/spec/modules/yieldoneBidAdapter_spec.js b/test/spec/modules/yieldoneBidAdapter_spec.js index ea9ca43edb..3bafe1b83b 100644 --- a/test/spec/modules/yieldoneBidAdapter_spec.js +++ b/test/spec/modules/yieldoneBidAdapter_spec.js @@ -548,6 +548,52 @@ describe('yieldoneBidAdapter', function () { expect(request[0].data.gpid).to.equal('gpid_sample'); }); }); + + describe('instl', function () { + it('dont send instl if undefined', function () { + const bidRequests = [ + { + params: {placementId: '0'}, + }, + { + params: {placementId: '1'}, + ortb2Imp: {}, + }, + { + params: {placementId: '2'}, + ortb2Imp: undefined, + }, + { + params: {placementId: '3'}, + ortb2Imp: {instl: undefined}, + }, + ]; + const request = spec.buildRequests(bidRequests, bidderRequest); + + bidRequests.forEach((bidRequest, ind) => { + expect(request[ind].data).to.not.have.property('instl'); + }) + }); + + it('should send instl if available', function () { + const bidRequests = [ + { + params: {placementId: '0'}, + ortb2Imp: {instl: '1'}, + }, + { + params: {placementId: '1'}, + ortb2Imp: {instl: 1}, + }, + ]; + const request = spec.buildRequests(bidRequests, bidderRequest); + + bidRequests.forEach((bidRequest, ind) => { + expect(request[ind].data).to.have.property('instl'); + expect(request[ind].data.instl).to.equal(1); + }) + }); + }); }); describe('interpretResponse', function () {