From 0ee4efdfde777045bdf5e00539efcfb878ff22d6 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Mon, 3 Jun 2024 05:43:25 -0700 Subject: [PATCH] PAAPI: add support for protected audience extensions and "direct" buyers (`igb`) (#11277) * set ortb2Imp.ext.igs * PAAPI: add ortbConverter support for request ext.igi and response ext.igi.igs * Update paapi hook to accept auction config as one field * mergeBuyers * partitionBuyers * add fpd to perBuyerSignals * complete igb treatment * improve names * parse ext.igi.igb * naming improvement * update debugging mod to work with igb * fix mergeBuyers to work with actual URL origins * rename componentBuyers to componentSeller * signal componentSeller config to bid adapters --- modules/debugging/bidInterceptor.js | 11 +- modules/debugging/debugging.js | 2 +- modules/debugging/pbsInterceptor.js | 2 +- modules/paapi.js | 241 +++++- modules/prebidServerBidAdapter/index.js | 4 +- .../prebidServerBidAdapter/ortbConverter.js | 2 +- src/adapters/bidderFactory.js | 17 +- test/spec/modules/debugging_mod_spec.js | 32 +- test/spec/modules/paapi_spec.js | 693 +++++++++++++++--- .../modules/prebidServerBidAdapter_spec.js | 12 +- test/spec/unit/core/bidderFactory_spec.js | 62 +- 11 files changed, 887 insertions(+), 191 deletions(-) diff --git a/modules/debugging/bidInterceptor.js b/modules/debugging/bidInterceptor.js index 3afaacaeb81..67b24b3f222 100644 --- a/modules/debugging/bidInterceptor.js +++ b/modules/debugging/bidInterceptor.js @@ -152,10 +152,17 @@ Object.assign(BidInterceptor.prototype, { }, paapiReplacer(paapiDef, ruleNo) { + function wrap(configs = []) { + return configs.map(config => { + return Object.keys(config).some(k => !['config', 'igb'].includes(k)) + ? {config} + : config + }); + } if (Array.isArray(paapiDef)) { - return () => paapiDef; + return () => wrap(paapiDef); } else if (typeof paapiDef === 'function') { - return paapiDef + return (...args) => wrap(paapiDef(...args)) } else { this.logger.logError(`Invalid 'paapi' definition for debug bid interceptor (in rule #${ruleNo})`); } diff --git a/modules/debugging/debugging.js b/modules/debugging/debugging.js index 2fd1731dc4e..63887074f04 100644 --- a/modules/debugging/debugging.js +++ b/modules/debugging/debugging.js @@ -103,7 +103,7 @@ export function bidderBidInterceptor(next, interceptBids, spec, bids, bidRequest bids, bidRequest, addBid: cbs.onBid, - addPaapiConfig: (config, bidRequest) => cbs.onPaapi({bidId: bidRequest.bidId, config}), + addPaapiConfig: (config, bidRequest) => cbs.onPaapi({bidId: bidRequest.bidId, ...config}), done })); if (bids.length === 0) { diff --git a/modules/debugging/pbsInterceptor.js b/modules/debugging/pbsInterceptor.js index 5c2eb458b18..c514031ad0e 100644 --- a/modules/debugging/pbsInterceptor.js +++ b/modules/debugging/pbsInterceptor.js @@ -29,7 +29,7 @@ export function makePbsInterceptor({createBid}) { adUnitCode: bidRequest.adUnitCode, ortb2: bidderRequest.ortb2, ortb2Imp: bidRequest.ortb2Imp, - config + ...config }) }, done diff --git a/modules/paapi.js b/modules/paapi.js index 9122ecce1a0..28252d0bb7a 100644 --- a/modules/paapi.js +++ b/modules/paapi.js @@ -3,7 +3,7 @@ */ import {config} from '../src/config.js'; import {getHook, module} from '../src/hook.js'; -import {deepSetValue, logInfo, logWarn, mergeDeep, parseSizesInput} from '../src/utils.js'; +import {deepSetValue, logInfo, logWarn, mergeDeep, deepEqual, parseSizesInput, deepAccess} from '../src/utils.js'; import {IMP, PBS, registerOrtbProcessor, RESPONSE} from '../src/pbjsORTB.js'; import * as events from '../src/events.js'; import {EVENTS} from '../src/constants.js'; @@ -24,7 +24,7 @@ export function registerSubmodule(submod) { module('paapi', registerSubmodule); -function auctionConfigs() { +function auctionStore() { const store = new WeakMap(); return function (auctionId, init = {}) { const auction = auctionManager.index.getAuction({auctionId}); @@ -36,8 +36,10 @@ function auctionConfigs() { }; } -const pendingForAuction = auctionConfigs(); -const configsForAuction = auctionConfigs(); +const pendingConfigsForAuction = auctionStore(); +const configsForAuction = auctionStore(); +const pendingBuyersForAuction = auctionStore(); + let latestAuctionForAdUnit = {}; let moduleConfig = {}; @@ -65,7 +67,7 @@ export function init(cfg, configNamespace) { } } -getHook('addComponentAuction').before(addComponentAuctionHook); +getHook('addPaapiConfig').before(addPaapiConfigHook); getHook('makeBidRequests').after(markForFledge); events.on(EVENTS.AUCTION_END, onAuctionEnd); @@ -89,6 +91,23 @@ function getSlotSignals(bidsReceived = [], bidRequests = []) { return cfg; } +export function buyersToAuctionConfigs(igbRequests, merge = mergeBuyers, config = moduleConfig?.componentSeller ?? {}, partitioners = { + compact: (igbRequests) => partitionBuyers(igbRequests.map(req => req[1])).map(part => [{}, part]), + expand: partitionBuyersByBidder +}) { + if (!config.auctionConfig) { + logWarn(MODULE, 'Cannot use IG buyers: paapi.componentSeller.auctionConfig not set', igbRequests.map(req => req[1])); + return []; + } + const partition = partitioners[config.separateAuctions ? 'expand' : 'compact']; + return partition(igbRequests) + .map(([request, igbs]) => { + const auctionConfig = mergeDeep(merge(igbs), config.auctionConfig); + auctionConfig.auctionSignals = setFPD(auctionConfig.auctionSignals || {}, request); + return auctionConfig; + }); +} + function onAuctionEnd({auctionId, bidsReceived, bidderRequests, adUnitCodes, adUnits}) { const adUnitsByCode = Object.fromEntries(adUnits?.map(au => [au.code, au]) || []) const allReqs = bidderRequests?.flatMap(br => br.bids); @@ -97,7 +116,14 @@ function onAuctionEnd({auctionId, bidsReceived, bidderRequests, adUnitCodes, adU paapiConfigs[au] = null; !latestAuctionForAdUnit.hasOwnProperty(au) && (latestAuctionForAdUnit[au] = null); }); - Object.entries(pendingForAuction(auctionId) || {}).forEach(([adUnitCode, auctionConfigs]) => { + const pendingConfigs = pendingConfigsForAuction(auctionId); + const pendingBuyers = pendingBuyersForAuction(auctionId); + if (pendingConfigs && pendingBuyers) { + Object.entries(pendingBuyers).forEach(([adUnitCode, igbRequests]) => { + buyersToAuctionConfigs(igbRequests).forEach(auctionConfig => append(pendingConfigs, adUnitCode, auctionConfig)) + }) + } + Object.entries(pendingConfigs || {}).forEach(([adUnitCode, auctionConfigs]) => { const forThisAdUnit = (bid) => bid.adUnitCode === adUnitCode; const slotSignals = getSlotSignals(bidsReceived?.filter(forThisAdUnit), allReqs?.filter(forThisAdUnit)); paapiConfigs[adUnitCode] = { @@ -126,25 +152,117 @@ function onAuctionEnd({auctionId, bidsReceived, bidderRequests, adUnitCodes, adU ); } -function setFPDSignals(auctionConfig, fpd) { - auctionConfig.auctionSignals = mergeDeep({}, {prebid: fpd}, auctionConfig.auctionSignals); +function append(target, key, value) { + !target.hasOwnProperty(key) && (target[key] = []); + target[key].push(value); } -export function addComponentAuctionHook(next, request, componentAuctionConfig) { +function setFPD(target, {ortb2, ortb2Imp}) { + ortb2 != null && deepSetValue(target, 'prebid.ortb2', mergeDeep({}, ortb2, target.prebid?.ortb2)); + ortb2Imp != null && deepSetValue(target, 'prebid.ortb2Imp', mergeDeep({}, ortb2Imp, target.prebid?.ortb2Imp)); + return target; +} + +export function addPaapiConfigHook(next, request, paapiConfig) { if (getFledgeConfig().enabled) { - const {adUnitCode, auctionId, ortb2, ortb2Imp} = request; - const configs = pendingForAuction(auctionId); - if (configs != null) { - setFPDSignals(componentAuctionConfig, {ortb2, ortb2Imp}); - !configs.hasOwnProperty(adUnitCode) && (configs[adUnitCode] = []); - configs[adUnitCode].push(componentAuctionConfig); - } else { - logWarn(MODULE, `Received component auction config for auction that has closed (auction '${auctionId}', adUnit '${adUnitCode}')`, componentAuctionConfig); + const {adUnitCode, auctionId} = request; + + // eslint-disable-next-line no-inner-declarations + function storePendingData(store, data) { + const target = store(auctionId); + if (target != null) { + append(target, adUnitCode, data) + } else { + logWarn(MODULE, `Received PAAPI config for auction that has closed (auction '${auctionId}', adUnit '${adUnitCode}')`, data); + } + } + + const {config, igb} = paapiConfig; + if (config) { + config.auctionSignals = setFPD(config.auctionSignals || {}, request); + (config.interestGroupBuyers || []).forEach(buyer => { + deepSetValue(config, `perBuyerSignals.${buyer}`, setFPD(config.perBuyerSignals?.[buyer] || {}, request)); + }) + storePendingData(pendingConfigsForAuction, config); + } + if (igb && checkOrigin(igb)) { + igb.pbs = setFPD(igb.pbs || {}, request); + storePendingData(pendingBuyersForAuction, [request, igb]) } } - next(request, componentAuctionConfig); + next(request, paapiConfig); +} + +export const IGB_TO_CONFIG = { + cur: 'perBuyerCurrencies', + pbs: 'perBuyerSignals', + ps: 'perBuyerPrioritySignals', + maxbid: 'auctionSignals.prebid.perBuyerMaxbid', +} + +function checkOrigin(igb) { + if (igb.origin) return true; + logWarn('PAAPI buyer does not specify origin and will be ignored', igb); } +/** + * Convert a list of InterestGroupBuyer (igb) objects into a partial auction config. + * https://github.com/InteractiveAdvertisingBureau/openrtb/blob/main/extensions/community_extensions/Protected%20Audience%20Support.md + */ +export function mergeBuyers(igbs) { + const buyers = new Set(); + return Object.assign( + igbs.reduce((config, igb) => { + if (checkOrigin(igb)) { + if (!buyers.has(igb.origin)) { + buyers.add(igb.origin); + Object.entries(IGB_TO_CONFIG).forEach(([igbField, configField]) => { + if (igb[igbField] != null) { + const entry = deepAccess(config, configField) || {} + entry[igb.origin] = igb[igbField]; + deepSetValue(config, configField, entry); + } + }); + } else { + logWarn(MODULE, `Duplicate buyer: ${igb.origin}. All but the first will be ignored`, igbs); + } + } + return config; + }, {}), + { + interestGroupBuyers: Array.from(buyers.keys()) + } + ); +} + +/** + * Partition a list of InterestGroupBuyer (igb) object into sets that can each be merged into a single auction. + * If the same buyer (origin) appears more than once, it will be split across different partition unless the igb objects + * are identical. + */ +export function partitionBuyers(igbs) { + return igbs.reduce((partitions, igb) => { + if (checkOrigin(igb)) { + let partition = partitions.find(part => !part.hasOwnProperty(igb.origin) || deepEqual(part[igb.origin], igb)); + if (!partition) { + partition = {}; + partitions.push(partition); + } + partition[igb.origin] = igb; + } + return partitions; + }, []).map(part => Object.values(part)); +} + +export function partitionBuyersByBidder(igbRequests) { + const requests = {}; + const igbs = {}; + igbRequests.forEach(([request, igb]) => { + !requests.hasOwnProperty(request.bidder) && (requests[request.bidder] = request); + append(igbs, request.bidder, igb); + }) + return Object.entries(igbs).map(([bidder, igbs]) => [requests[bidder], igbs]) +} /** * Get PAAPI auction configuration. * @@ -197,9 +315,30 @@ export function markForFledge(next, bidderRequests) { bidderRequests.forEach((bidderReq) => { config.runWithBidder(bidderReq.bidderCode, () => { const {enabled, ae} = getFledgeConfig(); - Object.assign(bidderReq, {fledgeEnabled: enabled}); + Object.assign(bidderReq, { + fledgeEnabled: enabled, + paapi: { + enabled, + componentSeller: !!moduleConfig.componentSeller?.auctionConfig + } + }); bidderReq.bids.forEach(bidReq => { - deepSetValue(bidReq, 'ortb2Imp.ext.ae', bidReq.ortb2Imp?.ext?.ae ?? ae); + // https://github.com/InteractiveAdvertisingBureau/openrtb/blob/main/extensions/community_extensions/Protected%20Audience%20Support.md + const igsAe = bidReq.ortb2Imp?.ext?.igs != null + ? bidReq.ortb2Imp.ext.igs.ae || 1 + : null + const extAe = bidReq.ortb2Imp?.ext?.ae; + if (igsAe !== extAe && igsAe != null && extAe != null) { + logWarn(MODULE, `Bid request defines conflicting ortb2Imp.ext.ae and ortb2Imp.ext.igs, using the latter`, bidReq); + } + const bidAe = igsAe ?? extAe ?? ae; + if (bidAe) { + deepSetValue(bidReq, 'ortb2Imp.ext.ae', bidAe); + bidReq.ortb2Imp.ext.igs = Object.assign({ + ae: bidAe, + biddable: 1 + }, bidReq.ortb2Imp.ext.igs) + } }); }); }); @@ -208,41 +347,72 @@ export function markForFledge(next, bidderRequests) { } export function setImpExtAe(imp, bidRequest, context) { - if (imp.ext?.ae && !context.bidderRequest.fledgeEnabled) { + if (!context.bidderRequest.fledgeEnabled) { delete imp.ext?.ae; + delete imp.ext?.igs; } } registerOrtbProcessor({type: IMP, name: 'impExtAe', fn: setImpExtAe}); -// to make it easier to share code between the PBS adapter and adapters whose backend is PBS, break up -// fledge response processing in two steps: first aggregate all the auction configs by their imp... - -export function parseExtPrebidFledge(response, ortbResponse, context) { - (ortbResponse.ext?.prebid?.fledge?.auctionconfigs || []).forEach((cfg) => { - const impCtx = context.impContext[cfg.impid]; +function paapiResponseParser(configs, response, context) { + configs.forEach((config) => { + const impCtx = context.impContext[config.impid]; if (!impCtx?.imp?.ext?.ae) { - logWarn('Received fledge auction configuration for an impression that was not in the request or did not ask for it', cfg, impCtx?.imp); + logWarn(MODULE, 'Received auction configuration for an impression that was not in the request or did not ask for it', config, impCtx?.imp); } else { - impCtx.fledgeConfigs = impCtx.fledgeConfigs || []; - impCtx.fledgeConfigs.push(cfg); + impCtx.paapiConfigs = impCtx.paapiConfigs || []; + impCtx.paapiConfigs.push(config); } }); } +export function parseExtIgi(response, ortbResponse, context) { + paapiResponseParser( + (ortbResponse.ext?.igi || []).flatMap(igi => { + return (igi?.igs || []).map(igs => { + if (igs.impid !== igi.impid && igs.impid != null && igi.impid != null) { + logWarn(MODULE, 'ORTB response ext.igi.igs.impid conflicts with parent\'s impid', igi); + } + return { + config: igs.config, + impid: igs.impid ?? igi.impid + } + }).concat((igi?.igb || []).map(igb => ({ + igb, + impid: igi.impid + }))) + }), + response, + context + ) +} + +// to make it easier to share code between the PBS adapter and adapters whose backend is PBS, break up +// fledge response processing in two steps: first aggregate all the auction configs by their imp... + +export function parseExtPrebidFledge(response, ortbResponse, context) { + paapiResponseParser( + (ortbResponse.ext?.prebid?.fledge?.auctionconfigs || []), + response, + context + ) +} + registerOrtbProcessor({type: RESPONSE, name: 'extPrebidFledge', fn: parseExtPrebidFledge, dialects: [PBS]}); +registerOrtbProcessor({type: RESPONSE, name: 'extIgiIgs', fn: parseExtIgi}); // ...then, make them available in the adapter's response. This is the client side version, for which the // interpretResponse api is {fledgeAuctionConfigs: [{bidId, config}]} -export function setResponseFledgeConfigs(response, ortbResponse, context) { +export function setResponsePaapiConfigs(response, ortbResponse, context) { const configs = Object.values(context.impContext) - .flatMap((impCtx) => (impCtx.fledgeConfigs || []).map(cfg => ({ + .flatMap((impCtx) => (impCtx.paapiConfigs || []).map(cfg => ({ bidId: impCtx.bidRequest.bidId, - config: cfg.config + ...cfg }))); if (configs.length > 0) { - response.fledgeAuctionConfigs = configs; + response.paapi = configs; } } @@ -250,6 +420,5 @@ registerOrtbProcessor({ type: RESPONSE, name: 'fledgeAuctionConfigs', priority: -1, - fn: setResponseFledgeConfigs, - dialects: [PBS] + fn: setResponsePaapiConfigs, }); diff --git a/modules/prebidServerBidAdapter/index.js b/modules/prebidServerBidAdapter/index.js index d95b8d3ecfc..8c5b89b5794 100644 --- a/modules/prebidServerBidAdapter/index.js +++ b/modules/prebidServerBidAdapter/index.js @@ -19,7 +19,7 @@ import { import { EVENTS, REJECTION_REASON, S2S } from '../../src/constants.js'; import adapterManager, {s2sActivityParams} from '../../src/adapterManager.js'; import {config} from '../../src/config.js'; -import {addComponentAuction, isValid} from '../../src/adapters/bidderFactory.js'; +import {addPaapiConfig, isValid} from '../../src/adapters/bidderFactory.js'; import * as events from '../../src/events.js'; import {includes} from '../../src/polyfill.js'; import {S2S_VENDORS} from './config.js'; @@ -509,7 +509,7 @@ export function PrebidServer() { } }, onFledge: (params) => { - addComponentAuction({auctionId: bidRequests[0].auctionId, ...params}, params.config); + addPaapiConfig({auctionId: bidRequests[0].auctionId, ...params}, {config: params.config}); } }) } diff --git a/modules/prebidServerBidAdapter/ortbConverter.js b/modules/prebidServerBidAdapter/ortbConverter.js index 7f4ffc84070..1ec3eb0ccd0 100644 --- a/modules/prebidServerBidAdapter/ortbConverter.js +++ b/modules/prebidServerBidAdapter/ortbConverter.js @@ -232,7 +232,7 @@ const PBS_CONVERTER = ortbConverter({ }, fledgeAuctionConfigs(orig, response, ortbResponse, context) { const configs = Object.values(context.impContext) - .flatMap((impCtx) => (impCtx.fledgeConfigs || []).map(cfg => { + .flatMap((impCtx) => (impCtx.paapiConfigs || []).map(cfg => { const bidderReq = impCtx.actualBidderRequests.find(br => br.bidderCode === cfg.bidder); const bidReq = impCtx.actualBidRequests.get(cfg.bidder); return { diff --git a/src/adapters/bidderFactory.js b/src/adapters/bidderFactory.js index 1d10c3161e5..dae0902d4cc 100644 --- a/src/adapters/bidderFactory.js +++ b/src/adapters/bidderFactory.js @@ -296,7 +296,7 @@ export function newBidder(spec) { onPaapi: (paapiConfig) => { const bidRequest = bidRequestMap[paapiConfig.bidId]; if (bidRequest) { - addComponentAuction(bidRequest, paapiConfig.config); + addPaapiConfig(bidRequest, paapiConfig); } else { logWarn('Received fledge auction configuration for an unknown bidId', paapiConfig); } @@ -363,12 +363,13 @@ export function newBidder(spec) { // Transition from 'fledge' to 'paapi' // TODO: remove this in prebid 9 -const PAAPI_RESPONSE_PROPS = ['paapiAuctionConfigs', 'fledgeAuctionConfigs']; -const RESPONSE_PROPS = ['bids'].concat(PAAPI_RESPONSE_PROPS); +const PAAPI_PROPS = ['paapi', 'fledgeAuctionConfigs']; +const RESPONSE_PROPS = ['bids'].concat(PAAPI_PROPS); + function getPaapiConfigs(adapterResponse) { - const [paapi, fledge] = PAAPI_RESPONSE_PROPS.map(prop => adapterResponse[prop]); + const [paapi, fledge] = PAAPI_PROPS.map(prop => adapterResponse[prop]); if (paapi != null && fledge != null) { - throw new Error(`Adapter response should use ${PAAPI_RESPONSE_PROPS[0]} over ${PAAPI_RESPONSE_PROPS[1]}, not both`); + throw new Error(`Adapter response should use ${PAAPI_PROPS[0]} over ${PAAPI_PROPS[1]}, not both`); } return paapi ?? fledge; } @@ -437,7 +438,7 @@ export const processBidderRequests = hook('sync', function (spec, bids, bidderRe // adapters can reply with: // a single bid // an array of bids - // a BidderAuctionResponse object ({bids: [*], paapiAuctionConfigs: [*]}) + // a BidderAuctionResponse object let bids, paapiConfigs; if (response && !Object.keys(response).some(key => !RESPONSE_PROPS.includes(key))) { @@ -548,8 +549,8 @@ export const registerSyncInner = hook('async', function(spec, responses, gdprCon } }, 'registerSyncs') -export const addComponentAuction = hook('sync', (request, fledgeAuctionConfig) => { -}, 'addComponentAuction'); +export const addPaapiConfig = hook('sync', (request, paapiConfig) => { +}, 'addPaapiConfig'); // check that the bid has a width and height set function validBidSize(adUnitCode, bid, {index = auctionManager.index} = {}) { diff --git a/test/spec/modules/debugging_mod_spec.js b/test/spec/modules/debugging_mod_spec.js index ab99ba2aa0c..4de51cc02a4 100644 --- a/test/spec/modules/debugging_mod_spec.js +++ b/test/spec/modules/debugging_mod_spec.js @@ -167,8 +167,8 @@ describe('bid interceptor', () => { describe('paapi', () => { it('should accept literals', () => { const mockConfig = [ - {paapi: 1}, - {paapi: 2} + {config: {paapi: 1}}, + {config: {paapi: 2}} ] const paapi = matchingRule({paapi: mockConfig}).paapi({}); expect(paapi).to.eql(mockConfig); @@ -179,6 +179,30 @@ describe('bid interceptor', () => { const args = [{}, {}, {}]; matchingRule({paapi: paapiDef}).paapi(...args); expect(paapiDef.calledOnceWith(...args.map(sinon.match.same))).to.be.true; + }); + + Object.entries({ + 'literal': (cfg) => [cfg], + 'function': (cfg) => () => [cfg] + }).forEach(([t, makeConfigs]) => { + describe(`when paapi is defined as a ${t}`, () => { + it('should wrap top-level configs in "config"', () => { + const cfg = {decisionLogicURL: 'example'}; + expect(matchingRule({paapi: makeConfigs(cfg)}).paapi({})).to.eql([{ + config: cfg + }]) + }); + + Object.entries({ + 'config': {config: 1}, + 'igb': {igb: 1}, + 'config and igb': {config: 1, igb: 2} + }).forEach(([t, cfg]) => { + it(`should not wrap configs that define top-level ${t}`, () => { + expect(matchingRule({paapi: makeConfigs(cfg)}).paapi({})).to.eql([cfg]); + }) + }) + }) }) }) @@ -274,8 +298,8 @@ describe('bid interceptor', () => { it('should call addPaapiConfigs when provided', () => { const mockPaapiConfigs = [ - {paapi: 1}, - {paapi: 2} + {config: {paapi: 1}}, + {config: {paapi: 2}} ] setRules({ when: {id: 2}, diff --git a/test/spec/modules/paapi_spec.js b/test/spec/modules/paapi_spec.js index c7d6d88bd12..bc1faa23833 100644 --- a/test/spec/modules/paapi_spec.js +++ b/test/spec/modules/paapi_spec.js @@ -6,13 +6,19 @@ import {hook} from '../../../src/hook.js'; import 'modules/appnexusBidAdapter.js'; import 'modules/rubiconBidAdapter.js'; import { - addComponentAuctionHook, + addPaapiConfigHook, getPAAPIConfig, parseExtPrebidFledge, registerSubmodule, setImpExtAe, - setResponseFledgeConfigs, - reset + setResponsePaapiConfigs, + reset, + parseExtIgi, + mergeBuyers, + IGB_TO_CONFIG, + partitionBuyers, + partitionBuyersByBidder, + buyersToAuctionConfigs } from 'modules/paapi.js'; import * as events from 'src/events.js'; import { EVENTS } from 'src/constants.js'; @@ -20,6 +26,7 @@ import {getGlobal} from '../../../src/prebidGlobal.js'; import {auctionManager} from '../../../src/auctionManager.js'; import {stubAuctionIndex} from '../../helpers/indexStub.js'; import {AuctionIndex} from '../../../src/auctionIndex.js'; +import {deepAccess, deepClone} from '../../../src/utils.js'; describe('paapi module', () => { let sandbox; @@ -38,15 +45,18 @@ describe('paapi module', () => { ].forEach(configNS => { describe(`using ${configNS} for configuration`, () => { describe('getPAAPIConfig', function () { - let nextFnSpy, fledgeAuctionConfig; + let nextFnSpy, auctionConfig, paapiConfig; before(() => { config.setConfig({[configNS]: {enabled: true}}); }); beforeEach(() => { - fledgeAuctionConfig = { + auctionConfig = { seller: 'bidder', mock: 'config' }; + paapiConfig = { + config: auctionConfig + } nextFnSpy = sinon.spy(); }); @@ -58,18 +68,93 @@ describe('paapi module', () => { it('should call next()', function () { const request = {auctionId, adUnitCode: 'auc'}; - addComponentAuctionHook(nextFnSpy, request, fledgeAuctionConfig); - sinon.assert.calledWith(nextFnSpy, request, fledgeAuctionConfig); + addPaapiConfigHook(nextFnSpy, request, paapiConfig); + sinon.assert.calledWith(nextFnSpy, request, paapiConfig); + }); + + describe('igb', () => { + let igb1, igb2, buyerAuctionConfig; + beforeEach(() => { + igb1 = { + origin: 'buyer1' + }; + igb2 = { + origin: 'buyer2' + } + buyerAuctionConfig = { + seller: 'seller', + decisionLogicURL: 'seller-decision-logic' + } + config.mergeConfig({ + [configNS]: { + componentSeller: { + auctionConfig: buyerAuctionConfig + } + } + }); + }); + + function addIgb(request, igb) { + addPaapiConfigHook(nextFnSpy, Object.assign({auctionId}, request), {igb}); + } + + it('should be collected into an auction config', () => { + addIgb({adUnitCode: 'au1'}, igb1); + addIgb({adUnitCode: 'au1'}, igb2); + events.emit(EVENTS.AUCTION_END, {auctionId, adUnitCodes: ['au1']}); + const buyerConfig = getPAAPIConfig({auctionId}).au1.componentAuctions[0]; + sinon.assert.match(buyerConfig, { + interestGroupBuyers: [igb1.origin, igb2.origin], + ...buyerAuctionConfig + }) + }); + + describe('FPD', () => { + let ortb2, ortb2Imp; + beforeEach(() => { + ortb2 = {'fpd': 1}; + ortb2Imp = {'fpd': 2} + }); + + function getBuyerAuctionConfig() { + addIgb({adUnitCode: 'au1', ortb2, ortb2Imp}, igb1); + events.emit(EVENTS.AUCTION_END, {auctionId, adUnitCodes: ['au1']}); + return getPAAPIConfig({auctionId}).au1.componentAuctions[0]; + } + + it('should be added to auction config', () => { + sinon.assert.match(getBuyerAuctionConfig().perBuyerSignals[igb1.origin], { + prebid: { + ortb2, + ortb2Imp + } + }) + }); + + it('should not override existing perBuyerSignals', () => { + const original = { + ortb2: { + fpd: 'original' + } + }; + igb1.pbs = { + prebid: deepClone(original) + } + sinon.assert.match(getBuyerAuctionConfig().perBuyerSignals[igb1.origin], { + prebid: original + }) + }) + }); }); describe('should collect auction configs', () => { let cf1, cf2; beforeEach(() => { - cf1 = {...fledgeAuctionConfig, id: 1, seller: 'b1'}; - cf2 = {...fledgeAuctionConfig, id: 2, seller: 'b2'}; - addComponentAuctionHook(nextFnSpy, {auctionId, adUnitCode: 'au1'}, cf1); - addComponentAuctionHook(nextFnSpy, {auctionId, adUnitCode: 'au2'}, cf2); - events.emit(EVENTS.AUCTION_END, { auctionId, adUnitCodes: ['au1', 'au2', 'au3'] }); + cf1 = {...auctionConfig, id: 1, seller: 'b1'}; + cf2 = {...auctionConfig, id: 2, seller: 'b2'}; + addPaapiConfigHook(nextFnSpy, {auctionId, adUnitCode: 'au1'}, {config: cf1}); + addPaapiConfigHook(nextFnSpy, {auctionId, adUnitCode: 'au2'}, {config: cf2}); + events.emit(EVENTS.AUCTION_END, {auctionId, adUnitCodes: ['au1', 'au2', 'au3']}); }); it('and make them available at end of auction', () => { @@ -124,16 +209,16 @@ describe('paapi module', () => { it('should drop auction configs after end of auction', () => { events.emit(EVENTS.AUCTION_END, { auctionId }); - addComponentAuctionHook(nextFnSpy, {auctionId, adUnitCode: 'au'}, fledgeAuctionConfig); + addPaapiConfigHook(nextFnSpy, {auctionId, adUnitCode: 'au'}, paapiConfig); events.emit(EVENTS.AUCTION_END, { auctionId }); expect(getPAAPIConfig({auctionId})).to.eql({}); }); it('should use first size as requestedSize', () => { - addComponentAuctionHook(nextFnSpy, { + addPaapiConfigHook(nextFnSpy, { auctionId, adUnitCode: 'au1', - }, fledgeAuctionConfig); + }, paapiConfig); events.emit(EVENTS.AUCTION_END, { auctionId, adUnits: [ @@ -153,28 +238,63 @@ describe('paapi module', () => { }) }) - it('should augment auctionSignals with FPD', () => { - addComponentAuctionHook(nextFnSpy, { - auctionId, - adUnitCode: 'au1', - ortb2: {fpd: 1}, - ortb2Imp: {fpd: 2} - }, fledgeAuctionConfig); - events.emit(EVENTS.AUCTION_END, { auctionId }); - sinon.assert.match(getPAAPIConfig({auctionId}), { - au1: { - componentAuctions: [{ - ...fledgeAuctionConfig, - auctionSignals: { - prebid: { - ortb2: {fpd: 1}, - ortb2Imp: {fpd: 2} - } + describe('FPD', () => { + let ortb2, ortb2Imp; + beforeEach(() => { + ortb2 = {fpd: 1}; + ortb2Imp = {fpd: 2}; + }); + + function getComponentAuctionConfig() { + addPaapiConfigHook(nextFnSpy, { + auctionId, + adUnitCode: 'au1', + ortb2: {fpd: 1}, + ortb2Imp: {fpd: 2} + }, paapiConfig); + events.emit(EVENTS.AUCTION_END, {auctionId}); + return getPAAPIConfig({auctionId}).au1.componentAuctions[0]; + } + + it('should be added to auctionSignals', () => { + sinon.assert.match(getComponentAuctionConfig().auctionSignals, { + prebid: {ortb2, ortb2Imp} + }) + }); + it('should not override existing auctionSignals', () => { + auctionConfig.auctionSignals = {prebid: {ortb2: {fpd: 'original'}}} + sinon.assert.match(getComponentAuctionConfig().auctionSignals, { + prebid: { + ortb2: {fpd: 'original'}, + ortb2Imp + } + }) + }) + + it('should be added to perBuyerSignals', () => { + auctionConfig.interestGroupBuyers = ['buyer1', 'buyer2']; + const pbs = getComponentAuctionConfig().perBuyerSignals; + sinon.assert.match(pbs, { + buyer1: {prebid: {ortb2, ortb2Imp}}, + buyer2: {prebid: {ortb2, ortb2Imp}} + }) + }); + + it('should not override existing perBuyerSignals', () => { + auctionConfig.interestGroupBuyers = ['buyer']; + const original = { + prebid: { + ortb2: { + fpd: 'original' } - }] + } + } + auctionConfig.perBuyerSignals = { + buyer: deepClone(original) } + sinon.assert.match(getComponentAuctionConfig().perBuyerSignals.buyer, original); }); - }); + }) describe('submodules', () => { let submods; @@ -193,13 +313,13 @@ describe('paapi module', () => { submods.forEach(submod => sinon.assert.calledWith(submod.onAuctionConfig, auctionId, {au: null})); }); it('is invoked with relevant configs', () => { - addComponentAuctionHook(nextFnSpy, {auctionId, adUnitCode: 'au1'}, fledgeAuctionConfig); - addComponentAuctionHook(nextFnSpy, {auctionId, adUnitCode: 'au2'}, fledgeAuctionConfig); - events.emit(EVENTS.AUCTION_END, { auctionId, adUnitCodes: ['au1', 'au2', 'au3'] }); + addPaapiConfigHook(nextFnSpy, {auctionId, adUnitCode: 'au1'}, paapiConfig); + addPaapiConfigHook(nextFnSpy, {auctionId, adUnitCode: 'au2'}, paapiConfig); + events.emit(EVENTS.AUCTION_END, {auctionId, adUnitCodes: ['au1', 'au2', 'au3']}); submods.forEach(submod => { sinon.assert.calledWith(submod.onAuctionConfig, auctionId, { - au1: {componentAuctions: [fledgeAuctionConfig]}, - au2: {componentAuctions: [fledgeAuctionConfig]}, + au1: {componentAuctions: [auctionConfig]}, + au2: {componentAuctions: [auctionConfig]}, au3: null }) }); @@ -208,13 +328,13 @@ describe('paapi module', () => { submods[0].onAuctionConfig.callsFake((auctionId, configs, markAsUsed) => { markAsUsed('au1'); }); - addComponentAuctionHook(nextFnSpy, {auctionId, adUnitCode: 'au1'}, fledgeAuctionConfig); - events.emit(EVENTS.AUCTION_END, { auctionId, adUnitCodes: ['au1'] }); + addPaapiConfigHook(nextFnSpy, {auctionId, adUnitCode: 'au1'}, paapiConfig); + events.emit(EVENTS.AUCTION_END, {auctionId, adUnitCodes: ['au1']}); expect(getPAAPIConfig()).to.eql({}); }); it('keeps them available if they do not', () => { - addComponentAuctionHook(nextFnSpy, {auctionId, adUnitCode: 'au1'}, fledgeAuctionConfig); - events.emit(EVENTS.AUCTION_END, { auctionId, adUnitCodes: ['au1'] }); + addPaapiConfigHook(nextFnSpy, {auctionId, adUnitCode: 'au1'}, paapiConfig); + events.emit(EVENTS.AUCTION_END, {auctionId, adUnitCodes: ['au1']}); expect(getPAAPIConfig()).to.not.be.empty; }) }); @@ -317,7 +437,7 @@ describe('paapi module', () => { }); it('should populate bidfloor/bidfloorcur', () => { - addComponentAuctionHook(nextFnSpy, {auctionId, adUnitCode: 'au'}, fledgeAuctionConfig); + addPaapiConfigHook(nextFnSpy, {auctionId, adUnitCode: 'au'}, paapiConfig); events.emit(EVENTS.AUCTION_END, payload); const cfg = getPAAPIConfig({auctionId}).au; const signals = cfg.auctionSignals; @@ -361,9 +481,9 @@ describe('paapi module', () => { [AUCTION2]: [['au2', 'au3'], []], }).forEach(([auctionId, [adUnitCodes, noConfigAdUnitCodes]]) => { adUnitCodes.forEach(adUnitCode => { - const cfg = {...fledgeAuctionConfig, auctionId, adUnitCode}; + const cfg = {...auctionConfig, auctionId, adUnitCode}; configs[auctionId][adUnitCode] = cfg; - addComponentAuctionHook(nextFnSpy, {auctionId, adUnitCode}, cfg); + addPaapiConfigHook(nextFnSpy, {auctionId, adUnitCode}, {config: cfg}); }); events.emit(EVENTS.AUCTION_END, { auctionId, adUnitCodes: adUnitCodes.concat(noConfigAdUnitCodes) }); }); @@ -478,8 +598,8 @@ describe('paapi module', () => { ] }]; - function expectFledgeFlags(...enableFlags) { - const bidRequests = Object.fromEntries( + function mark() { + return Object.fromEntries( adapterManager.makeBidRequests( adUnits, Date.now(), @@ -489,12 +609,26 @@ describe('paapi module', () => { [] ).map(b => [b.bidderCode, b]) ); + } + function expectFledgeFlags(...enableFlags) { + const bidRequests = mark(); expect(bidRequests.appnexus.fledgeEnabled).to.eql(enableFlags[0].enabled); + expect(bidRequests.appnexus.paapi?.enabled).to.eql(enableFlags[0].enabled); bidRequests.appnexus.bids.forEach(bid => expect(bid.ortb2Imp.ext.ae).to.eql(enableFlags[0].ae)); expect(bidRequests.rubicon.fledgeEnabled).to.eql(enableFlags[1].enabled); + expect(bidRequests.rubicon.paapi?.enabled).to.eql(enableFlags[1].enabled); bidRequests.rubicon.bids.forEach(bid => expect(bid.ortb2Imp?.ext?.ae).to.eql(enableFlags[1].ae)); + + Object.values(bidRequests).flatMap(req => req.bids).forEach(bid => { + if (bid.ortb2Imp?.ext?.ae) { + sinon.assert.match(bid.ortb2Imp.ext.igs, { + ae: bid.ortb2Imp.ext.ae, + biddable: 1 + }); + } + }) } describe('with setBidderConfig()', () => { @@ -534,6 +668,34 @@ describe('paapi module', () => { expectFledgeFlags({enabled: true, ae: 1}, {enabled: true, ae: 1}); }); + Object.entries({ + 'not set': { + cfg: {}, + componentSeller: false + }, + 'set': { + cfg: { + componentSeller: { + auctionConfig: { + decisionLogicURL: 'publisher.example' + } + } + }, + componentSeller: true + } + }).forEach(([t, {cfg, componentSeller}]) => { + it(`should set request paapi.componentSeller = ${componentSeller} when config componentSeller is ${t}`, () => { + config.setConfig({ + [configNS]: { + enabled: true, + defaultForSlots: 1, + ...cfg + } + }); + Object.values(mark()).forEach(br => expect(br.paapi?.componentSeller).to.eql(componentSeller)); + }) + }) + it('should not override pub-defined ext.ae', () => { config.setConfig({ bidderSequence: 'fixed', @@ -545,104 +707,437 @@ describe('paapi module', () => { Object.assign(adUnits[0], {ortb2Imp: {ext: {ae: 0}}}); expectFledgeFlags({enabled: true, ae: 0}, {enabled: true, ae: 0}); }); + + it('should populate ext.igs when request has ext.ae', () => { + config.setConfig({ + bidderSequence: 'fixed', + [configNS]: { + enabled: true + } + }); + Object.assign(adUnits[0], {ortb2Imp: {ext: {ae: 3}}}); + expectFledgeFlags({enabled: true, ae: 3}, {enabled: true, ae: 3}); + }) + + it('should not override pub-defined ext.igs', () => { + config.setConfig({ + [configNS]: { + enabled: true + } + }); + Object.assign(adUnits[0], {ortb2Imp: {ext: {ae: 1, igs: {biddable: 0}}}}); + const bidReqs = mark(); + Object.values(bidReqs).flatMap(req => req.bids).forEach(bid => { + sinon.assert.match(bid.ortb2Imp.ext, { + ae: 1, + igs: { + ae: 1, + biddable: 0 + } + }) + }) + }); + + it('should fill ext.ae from ext.igs, if defined', () => { + config.setConfig({ + [configNS]: { + enabled: true + } + }); + Object.assign(adUnits[0], {ortb2Imp: {ext: {igs: {}}}}); + expectFledgeFlags({enabled: true, ae: 1}, {enabled: true, ae: 1}) + }); }); }); }); }); + describe('igb', () => { + let igb1, igb2; + const buyer1 = 'https://buyer1.example'; + const buyer2 = 'https://buyer2.example'; + beforeEach(() => { + igb1 = { + origin: buyer1, + cur: 'EUR', + maxbid: 1, + pbs: { + signal: 1 + }, + ps: { + priority: 1 + } + }; + igb2 = { + origin: buyer2, + cur: 'USD', + maxbid: 2, + pbs: { + signal: 2 + }, + ps: { + priority: 2 + } + } + }); + + describe('mergeBuyers', () => { + it('should merge multiple igb into a partial auction config', () => { + sinon.assert.match(mergeBuyers([igb1, igb2]), { + interestGroupBuyers: [buyer1, buyer2], + perBuyerCurrencies: { + [buyer1]: 'EUR', + [buyer2]: 'USD' + }, + perBuyerSignals: { + [buyer1]: { + signal: 1 + }, + [buyer2]: { + signal: 2 + } + }, + perBuyerPrioritySignals: { + [buyer1]: { + priority: 1 + }, + [buyer2]: { + priority: 2 + } + }, + auctionSignals: { + prebid: { + perBuyerMaxbid: { + [buyer1]: 1, + [buyer2]: 2 + } + } + } + }); + }); + + Object.entries(IGB_TO_CONFIG).forEach(([igbField, configField]) => { + it(`should not set ${configField} if ${igbField} is undefined`, () => { + delete igb1[igbField]; + expect(deepAccess(mergeBuyers([igb1, igb2]), configField)[buyer1]).to.not.exist; + }); + }); + + it('ignores igbs that have no origin', () => { + delete igb1.origin; + expect(mergeBuyers([igb1, igb2])).to.eql(mergeBuyers([igb2])); + }); + + it('ignores igbs with duplicate origin', () => { + igb2.origin = igb1.origin; + expect(mergeBuyers([igb1, igb2])).to.eql(mergeBuyers([igb1])); + }) + }); + + describe('partitionBuyers', () => { + it('should return a single partition when there are no duplicates', () => { + expect(partitionBuyers([igb1, igb2])).to.eql([[igb1, igb2]]); + }); + it('should ignore igbs that have no origin', () => { + delete igb1.origin; + expect(partitionBuyers([igb1, igb2])).to.eql([[igb2]]); + }) + it('should return a single partition when duplicates exist, but do not conflict', () => { + expect(partitionBuyers([igb1, igb2, deepClone(igb1)])).to.eql([[igb1, igb2]]); + }); + it('should return multiple partitions when there are conflicts', () => { + const igb3 = deepClone(igb1); + const igb4 = deepClone(igb1); + igb3.pbs.signal = 'conflict'; + igb4.ps.signal = 'conflict'; + expect(partitionBuyers([igb1, igb2, igb3, igb4])).to.eql([ + [igb1, igb2], + [igb3], + [igb4] + ]); + }) + }); + + describe('partitionBuyersByBidder', () => { + it('should split requests by bidder', () => { + expect(partitionBuyersByBidder([[{bidder: 'a'}, igb1], [{bidder: 'b'}, igb2]])).to.eql([ + [{bidder: 'a'}, [igb1]], + [{bidder: 'b'}, [igb2]] + ]); + }); + + it('accepts repeated buyers, if from different bidders', () => { + expect(partitionBuyersByBidder([ + [{bidder: 'a', extra: 'data'}, igb1], + [{bidder: 'b', more: 'data'}, igb1], + [{bidder: 'a'}, igb2], + [{bidder: 'b'}, igb2] + ])).to.eql([ + [{bidder: 'a', extra: 'data'}, [igb1, igb2]], + [{bidder: 'b', more: 'data'}, [igb1, igb2]] + ]) + }) + describe('buyersToAuctionConfig', () => { + let config, partitioners, merge, igbRequests; + beforeEach(() => { + config = { + auctionConfig: { + decisionLogicURL: 'mock-decision-logic' + } + } + partitioners = { + compact: sinon.stub(), + expand: sinon.stub(), + } + let i = 0; + merge = sinon.stub().callsFake(() => ({config: i++})); + igbRequests = [ + [{}, igb1], + [{}, igb2] + ]; + }); + + function toAuctionConfig(reqs = igbRequests) { + return buyersToAuctionConfigs(reqs, merge, config, partitioners); + } + + it('uses compact partitions by default, and returns an auction config for each one', () => { + partitioners.compact.returns([[{}, 1], [{}, 2]]); + const [cf1, cf2] = toAuctionConfig(); + sinon.assert.match(cf1, { + ...config.auctionConfig, + config: 0 + }); + sinon.assert.match(cf2, { + ...config.auctionConfig, + config: 1 + }) + sinon.assert.calledWith(partitioners.compact, igbRequests); + [1, 2].forEach(mockPart => sinon.assert.calledWith(merge, mockPart)); + }); + + it('uses per-bidder partition when config has separateAuctions', () => { + config.separateAuctions = true; + partitioners.expand.returns([]); + toAuctionConfig(); + sinon.assert.called(partitioners.expand); + }); + + it('does not return any auction config when configuration does not specify auctionConfig', () => { + delete config.auctionConfig; + expect(toAuctionConfig()).to.eql([]); + Object.values(partitioners).forEach(part => sinon.assert.notCalled(part)); + }); + + it('sets FPD in auction signals when partitioner returns it', () => { + const fpd = { + ortb2: {fpd: 1}, + ortb2Imp: {fpd: 2} + } + partitioners.compact.returns([[{}], [fpd]]); + const [cf1, cf2] = toAuctionConfig(); + expect(cf1.auctionSignals?.prebid).to.not.exist; + expect(cf2.auctionSignals.prebid).to.eql(fpd); + }) + }) + }); + }); + describe('ortb processors for fledge', () => { it('imp.ext.ae should be removed if fledge is not enabled', () => { - const imp = {ext: {ae: 1}}; + const imp = {ext: {ae: 1, igs: {}}}; setImpExtAe(imp, {}, {bidderRequest: {}}); expect(imp.ext.ae).to.not.exist; + expect(imp.ext.igs).to.not.exist; }); it('imp.ext.ae should be left intact if fledge is enabled', () => { - const imp = {ext: {ae: 2}}; + const imp = {ext: {ae: 2, igs: {biddable: 0}}}; setImpExtAe(imp, {}, {bidderRequest: {fledgeEnabled: true}}); - expect(imp.ext.ae).to.equal(2); + expect(imp.ext).to.eql({ + ae: 2, + igs: { + biddable: 0 + } + }) }); - describe('parseExtPrebidFledge', () => { - function packageConfigs(configs) { - return { - ext: { - prebid: { - fledge: { - auctionconfigs: configs - } - } - } - }; - } + describe('response parsing', () => { function generateImpCtx(fledgeFlags) { return Object.fromEntries(Object.entries(fledgeFlags).map(([impid, fledgeEnabled]) => [impid, {imp: {ext: {ae: fledgeEnabled}}}])); } - function generateCfg(impid, ...ids) { - return ids.map((id) => ({impid, config: {id}})); - } - - function extractResult(ctx) { + function extractResult(type, ctx) { return Object.fromEntries( Object.entries(ctx) - .map(([impid, ctx]) => [impid, ctx.fledgeConfigs?.map(cfg => cfg.config.id)]) + .map(([impid, ctx]) => [impid, ctx.paapiConfigs?.map(cfg => cfg[type].id)]) .filter(([_, val]) => val != null) ); } - it('should collect fledge configs by imp', () => { - const ctx = { - impContext: generateImpCtx({e1: 1, e2: 1, d1: 0}) - }; - const resp = packageConfigs( - generateCfg('e1', 1, 2, 3) - .concat(generateCfg('e2', 4) - .concat(generateCfg('d1', 5, 6))) - ); - parseExtPrebidFledge({}, resp, ctx); - expect(extractResult(ctx.impContext)).to.eql({ - e1: [1, 2, 3], - e2: [4], - }); - }); - it('should not choke if fledge config references unknown imp', () => { - const ctx = {impContext: generateImpCtx({i: 1})}; - const resp = packageConfigs(generateCfg('unknown', 1)); - parseExtPrebidFledge({}, resp, ctx); - expect(extractResult(ctx.impContext)).to.eql({}); + Object.entries({ + 'parseExtPrebidFledge': { + parser: parseExtPrebidFledge, + responses: { + 'ext.prebid.fledge'(configs) { + return { + ext: { + prebid: { + fledge: { + auctionconfigs: configs + } + } + } + }; + }, + } + }, + 'parseExtIgi': { + parser: parseExtIgi, + responses: { + 'ext.igi.igs'(configs) { + return { + ext: { + igi: [{ + igs: configs + }] + } + } + }, + 'ext.igi.igs with impid on igi'(configs) { + return { + ext: { + igi: configs.map(cfg => { + const impid = cfg.impid; + delete cfg.impid; + return { + impid, + igs: [cfg] + } + }) + } + } + }, + 'ext.igi.igs with conflicting impid'(configs) { + return { + ext: { + igi: [{ + impid: 'conflict', + igs: configs + }] + } + } + } + } + } + }).forEach(([t, {parser, responses}]) => { + describe(t, () => { + Object.entries(responses).forEach(([t, packageConfigs]) => { + describe(`when response uses ${t}`, () => { + function generateCfg(impid, ...ids) { + return ids.map((id) => ({impid, config: {id}})); + } + + it('should collect auction configs by imp', () => { + const ctx = { + impContext: generateImpCtx({e1: 1, e2: 1, d1: 0}) + }; + const resp = packageConfigs( + generateCfg('e1', 1, 2, 3) + .concat(generateCfg('e2', 4) + .concat(generateCfg('d1', 5, 6))) + ); + parser({}, resp, ctx); + expect(extractResult('config', ctx.impContext)).to.eql({ + e1: [1, 2, 3], + e2: [4], + }); + }); + it('should not choke if fledge config references unknown imp', () => { + const ctx = {impContext: generateImpCtx({i: 1})}; + const resp = packageConfigs(generateCfg('unknown', 1)); + parser({}, resp, ctx); + expect(extractResult('config', ctx.impContext)).to.eql({}); + }); + }) + }) + }) }); - }); - describe('setResponseFledgeConfigs', () => { - it('should set fledgeAuctionConfigs paired with their corresponding bid id', () => { + + describe('response ext.igi.igb', () => { + it('should collect igb by imp', () => { + const ctx = { + impContext: generateImpCtx({e1: 1, e2: 1, d1: 0}) + }; + const resp = { + ext: { + igi: [ + { + impid: 'e1', + igb: [ + {id: 1}, + {id: 2} + ] + }, + { + impid: 'e2', + igb: [ + {id: 3} + ] + }, + { + impid: 'd1', + igb: [ + {id: 4} + ] + } + ] + } + } + parseExtIgi({}, resp, ctx); + expect(extractResult('igb', ctx.impContext)).to.eql({ + e1: [1, 2], + e2: [3], + }); + }) + }) + }) + + describe('setResponsePaapiConfigs', () => { + it('should set paapi configs/igb paired with their corresponding bid id', () => { const ctx = { impContext: { 1: { bidRequest: {bidId: 'bid1'}, - fledgeConfigs: [{config: {id: 1}}, {config: {id: 2}}] + paapiConfigs: [{config: {id: 1}}, {config: {id: 2}}] }, 2: { bidRequest: {bidId: 'bid2'}, - fledgeConfigs: [{config: {id: 3}}] + paapiConfigs: [{config: {id: 3}}] }, 3: { bidRequest: {bidId: 'bid3'} + }, + 4: { + bidRequest: {bidId: 'bid1'}, + paapiConfigs: [{igb: {id: 4}}] } } }; const resp = {}; - setResponseFledgeConfigs(resp, {}, ctx); - expect(resp.fledgeAuctionConfigs).to.eql([ + setResponsePaapiConfigs(resp, {}, ctx); + expect(resp.paapi).to.eql([ {bidId: 'bid1', config: {id: 1}}, {bidId: 'bid1', config: {id: 2}}, {bidId: 'bid2', config: {id: 3}}, + {bidId: 'bid1', igb: {id: 4}} ]); }); - it('should not set fledgeAuctionConfigs if none exist', () => { + it('should not set paapi if no config or igb exists', () => { const resp = {}; - setResponseFledgeConfigs(resp, {}, { + setResponsePaapiConfigs(resp, {}, { impContext: { 1: { - fledgeConfigs: [] + paapiConfigs: [] }, 2: {} } diff --git a/test/spec/modules/prebidServerBidAdapter_spec.js b/test/spec/modules/prebidServerBidAdapter_spec.js index 8e92cecd36b..c6335227202 100644 --- a/test/spec/modules/prebidServerBidAdapter_spec.js +++ b/test/spec/modules/prebidServerBidAdapter_spec.js @@ -24,14 +24,14 @@ import 'modules/priceFloors.js'; import 'modules/consentManagement.js'; import 'modules/consentManagementUsp.js'; import 'modules/schain.js'; -import 'modules/fledgeForGpt.js'; +import 'modules/paapi.js'; import * as redactor from 'src/activities/redactor.js'; import * as activityRules from 'src/activities/rules.js'; import {hook} from '../../../src/hook.js'; import {decorateAdUnitsWithNativeParams} from '../../../src/native.js'; import {auctionManager} from '../../../src/auctionManager.js'; import {stubAuctionIndex} from '../../helpers/indexStub.js'; -import {addComponentAuction, registerBidder} from 'src/adapters/bidderFactory.js'; +import {addPaapiConfig, registerBidder} from 'src/adapters/bidderFactory.js'; import {getGlobal} from '../../../src/prebidGlobal.js'; import {syncAddFPDToBidderRequest} from '../../helpers/fpd.js'; import {deepSetValue} from '../../../src/utils.js'; @@ -3487,11 +3487,11 @@ describe('S2S Adapter', function () { } before(() => { - addComponentAuction.before(fledgeHook); + addPaapiConfig.before(fledgeHook); }); after(() => { - addComponentAuction.getHooks({hook: fledgeHook}).remove(); + addPaapiConfig.getHooks({hook: fledgeHook}).remove(); }) beforeEach(function () { @@ -3520,8 +3520,8 @@ describe('S2S Adapter', function () { function expectFledgeCalls() { const auctionId = bidderRequests[0].auctionId; - sinon.assert.calledWith(fledgeStub, sinon.match({auctionId, adUnitCode: AU, ortb2: bidderRequests[0].ortb2, ortb2Imp: bidderRequests[0].bids[0].ortb2Imp}), {id: 1}) - sinon.assert.calledWith(fledgeStub, sinon.match({auctionId, adUnitCode: AU, ortb2: undefined, ortb2Imp: undefined}), {id: 2}) + sinon.assert.calledWith(fledgeStub, sinon.match({auctionId, adUnitCode: AU, ortb2: bidderRequests[0].ortb2, ortb2Imp: bidderRequests[0].bids[0].ortb2Imp}), {config: {id: 1}}) + sinon.assert.calledWith(fledgeStub, sinon.match({auctionId, adUnitCode: AU, ortb2: undefined, ortb2Imp: undefined}), {config: {id: 2}}) } it('calls addComponentAuction alongside addBidResponse', function () { diff --git a/test/spec/unit/core/bidderFactory_spec.js b/test/spec/unit/core/bidderFactory_spec.js index 1e70c938a57..ef6d1de0b30 100644 --- a/test/spec/unit/core/bidderFactory_spec.js +++ b/test/spec/unit/core/bidderFactory_spec.js @@ -1,4 +1,4 @@ -import {addComponentAuction, isValid, newBidder, registerBidder} from 'src/adapters/bidderFactory.js'; +import {addPaapiConfig, addIGBuyer, isValid, newBidder, registerBidder} from 'src/adapters/bidderFactory.js'; import adapterManager from 'src/adapterManager.js'; import * as ajax from 'src/ajax.js'; import {expect} from 'chai'; @@ -1460,6 +1460,9 @@ describe('bidderFactory', () => { bidId: '1', config: { foo: 'bar' + }, + igb: { + foo: 'bar' } } @@ -1482,72 +1485,69 @@ describe('bidderFactory', () => { sinon.assert.calledWith(addBidResponseStub, 'mock/placement', sinon.match(bid)); }) - describe('when response has PAAPI auction config', function() { + describe('when response has PAAPI config', function() { let paapiStub; function paapiHook(next, ...args) { paapiStub(...args); } + function runBidder(response) { + const bidder = newBidder(spec); + spec.interpretResponse.returns(response); + bidder.callBids(bidRequest, addBidResponseStub, doneStub, ajaxStub, onTimelyResponseStub, wrappedCallback); + } + before(() => { - addComponentAuction.before(paapiHook); + addPaapiConfig.before(paapiHook); }); after(() => { - addComponentAuction.getHooks({hook: paapiHook}).remove(); + addPaapiConfig.getHooks({hook: paapiHook}).remove(); }) beforeEach(function () { paapiStub = sinon.stub(); }); - const PAAPI_PROPS = ['fledgeAuctionConfigs', 'paapiAuctionConfigs']; + const PAAPI_PROPS = ['fledgeAuctionConfigs', 'paapi']; it(`should not accept both ${PAAPI_PROPS.join(' and ')}`, () => { - const bidder = newBidder(spec); - spec.interpretResponse.returns(Object.fromEntries(PAAPI_PROPS.map(prop => [prop, [paapiConfig]]))) expect(() => { - bidder.callBids(bidRequest, addBidResponseStub, doneStub, ajaxStub, onTimelyResponseStub, wrappedCallback); - }).to.throw; + runBidder(Object.fromEntries(PAAPI_PROPS.map(prop => [prop, [paapiConfig]]))) + }).to.throw(); }) PAAPI_PROPS.forEach(paapiProp => { describe(`using ${paapiProp}`, () => { - it('should call paapi hook with PAAPI configs', function() { - const bidder = newBidder(spec); - spec.interpretResponse.returns({ + it('should call paapi config hook with auction configs', function() { + runBidder({ bids: bids, [paapiProp]: [paapiConfig] - }); - bidder.callBids(bidRequest, addBidResponseStub, doneStub, ajaxStub, onTimelyResponseStub, wrappedCallback); - + }) expect(paapiStub.calledOnce).to.equal(true); - sinon.assert.calledWith(paapiStub, bidRequest.bids[0], paapiConfig.config); - expect(addBidResponseStub.calledOnce).to.equal(true); - expect(addBidResponseStub.firstCall.args[0]).to.equal('mock/placement'); + sinon.assert.calledWith(paapiStub, bidRequest.bids[0], paapiConfig); + sinon.assert.calledWith(addBidResponseStub, 'mock/placement', sinon.match(bids[0])); }) Object.entries({ 'missing': undefined, 'an empty array': [] }).forEach(([t, bids]) => { - it(`should call paapi hook with PAAPI configs even when bids is ${t}`, function() { - const bidder = newBidder(spec); - spec.interpretResponse.returns({ + it(`should call paapi config hook with PAAPI configs even when bids is ${t}`, function() { + runBidder({ bids, [paapiProp]: [paapiConfig] - }); - bidder.callBids(bidRequest, addBidResponseStub, doneStub, ajaxStub, onTimelyResponseStub, wrappedCallback); - + }) expect(paapiStub.calledOnce).to.be.true; - sinon.assert.calledWith(paapiStub, bidRequest.bids[0], paapiConfig.config); + sinon.assert.calledWith(paapiStub, bidRequest.bids[0], paapiConfig); expect(addBidResponseStub.calledOnce).to.equal(false); - }) - }) - }) - }) - }) - }) + }); + }); + }); + }); + }); + }); }); describe('bid response isValid', () => {