From 73ce2666ac672ce5e866621bbe157312b3ad0c7d Mon Sep 17 00:00:00 2001 From: Jason Quaccia Date: Tue, 18 Feb 2025 07:48:42 -0800 Subject: [PATCH] New Module: MinBidToWin Notifications: Created a new module to support sending minbidtowin notifications to bidders (#11086) * created client side loss notifications module * addressed feedback and created tests * addressed feedback * addressed feedback, updated tests * removed some console logs and unecessary changes used for local testing * removed linebreak * added publisher opt-in logic and refactored onBidWonHandler * updated tests and prevAuctionInfo init logic * detach event listeners on deactivation --------- Co-authored-by: Demetrio Girardi --- .../previousAuctionInfo.js | 135 ++++++++++++++ .../libraries/previousAuctionInfo_spec.js | 167 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 libraries/previousAuctionInfo/previousAuctionInfo.js create mode 100644 test/spec/libraries/previousAuctionInfo_spec.js diff --git a/libraries/previousAuctionInfo/previousAuctionInfo.js b/libraries/previousAuctionInfo/previousAuctionInfo.js new file mode 100644 index 00000000000..0b964d7abe4 --- /dev/null +++ b/libraries/previousAuctionInfo/previousAuctionInfo.js @@ -0,0 +1,135 @@ +import {on as onEvent, off as offEvent} from '../../src/events.js'; +import { EVENTS } from '../../src/constants.js'; +import { config } from '../../src/config.js'; + +export let previousAuctionInfoEnabled = false; +let enabledBidders = []; + +export let auctionState = {}; + +export const resetPreviousAuctionInfo = (cb = deinitHandlers) => { + previousAuctionInfoEnabled = false; + enabledBidders = []; + auctionState = {}; + cb(); +}; + +export const initPreviousAuctionInfo = (cb = initHandlers) => { + config.getConfig('previousAuctionInfo', (conf) => { + if (!conf.previousAuctionInfo) { + if (previousAuctionInfoEnabled) { resetPreviousAuctionInfo(); } + return; + } + + previousAuctionInfoEnabled = true; + cb(); + }); +}; + +export const enablePreviousAuctionInfo = (sspConfig) => { + const { bidderCode } = sspConfig; + const enabledBidder = enabledBidders.find(bidder => bidder.bidderCode === bidderCode); + + if (!enabledBidder) enabledBidders.push({ bidderCode, maxQueueLength: sspConfig.maxQueueLength || 10 }); +} + +export const initHandlers = () => { + onEvent(EVENTS.AUCTION_END, onAuctionEndHandler); + onEvent(EVENTS.BID_WON, onBidWonHandler); + onEvent(EVENTS.BID_REQUESTED, onBidRequestedHandler); +}; + +const deinitHandlers = () => { + offEvent(EVENTS.AUCTION_END, onAuctionEndHandler); + offEvent(EVENTS.BID_WON, onBidWonHandler); + offEvent(EVENTS.BID_REQUESTED, onBidRequestedHandler); +} + +export const onAuctionEndHandler = (auctionDetails) => { + try { + const receivedBidsMap = {}; + const rejectedBidsMap = {}; + const highestBidsByAdUnitCode = {}; + + if (auctionDetails.bidsReceived?.length) { + auctionDetails.bidsReceived.forEach((bid) => { + receivedBidsMap[bid.requestId] = bid; + if (!highestBidsByAdUnitCode[bid.adUnitCode] || bid.cpm > highestBidsByAdUnitCode[bid.adUnitCode].cpm) { + highestBidsByAdUnitCode[bid.adUnitCode] = bid; + } + }); + } + + if (auctionDetails.bidsRejected?.length) { + auctionDetails.bidsRejected.forEach(bidRejected => { + rejectedBidsMap[bidRejected.requestId] = bidRejected; + }); + } + + if (auctionDetails.bidderRequests?.length) { + auctionDetails.bidderRequests.forEach(bidderRequest => { + const enabledBidder = enabledBidders.find(bidder => bidder.bidderCode === bidderRequest.bidderCode); + + if (enabledBidder) { + auctionState[bidderRequest.bidderCode] = auctionState[bidderRequest.bidderCode] || []; + + bidderRequest.bids.forEach(bid => { + const previousAuctionInfoPayload = { + bidderRequestId: bidderRequest.bidderRequestId, + bidId: bid.bidId, + rendered: 0, + source: 'pbjs', + adUnitCode: bid.adUnitCode, + highestTargetedBidCpm: highestBidsByAdUnitCode[bid.adUnitCode]?.adserverTargeting?.hb_pb || '', + targetedBidCpm: receivedBidsMap[bid.bidId]?.adserverTargeting?.hb_pb || '', + highestBidCpm: highestBidsByAdUnitCode[bid.adUnitCode]?.cpm || 0, + bidderCpm: receivedBidsMap[bid.bidId]?.cpm || 'nobid', + bidderOriginalCpm: receivedBidsMap[bid.bidId]?.originalCpm || 'nobid', + bidderCurrency: receivedBidsMap[bid.bidId]?.currency || 'nobid', + bidderOriginalCurrency: receivedBidsMap[bid.bidId]?.originalCurrency || 'nobid', + bidderErrorCode: rejectedBidsMap[bid.bidId] ? rejectedBidsMap[bid.bidId].rejectionReason : -1, + timestamp: auctionDetails.timestamp, + transactionId: bid.transactionId, // this field gets removed before injecting previous auction info into the bid stream + } + + if (auctionState[bidderRequest.bidderCode].length > enabledBidder.maxQueueLength) { + auctionState[bidderRequest.bidderCode].shift(); + } + + auctionState[bidderRequest.bidderCode].push(previousAuctionInfoPayload); + }); + } + }); + } + } catch (error) {} +} + +export const onBidWonHandler = (winningBid) => { + const winningTid = winningBid.transactionId; + + Object.values(auctionState).flat().forEach(prevAuctPayload => { + if (prevAuctPayload.transactionId === winningTid) { + prevAuctPayload.rendered = 1; + } + }); +}; + +export const onBidRequestedHandler = (bidRequest) => { + try { + const enabledBidder = enabledBidders.find(bidder => bidder.bidderCode === bidRequest.bidderCode); + if (enabledBidder && auctionState[bidRequest.bidderCode]) { + auctionState[bidRequest.bidderCode].forEach(prevAuctPayload => { + if (prevAuctPayload.transactionId) delete prevAuctPayload.transactionId; + }); + + bidRequest.ortb2 = Object.assign({}, bidRequest.ortb2); + bidRequest.ortb2.ext = Object.assign({}, bidRequest.ortb2.ext); + bidRequest.ortb2.ext.prebid = Object.assign({}, bidRequest.ortb2.ext.prebid); + + bidRequest.ortb2.ext.prebid.previousauctioninfo = auctionState[bidRequest.bidderCode]; + delete auctionState[bidRequest.bidderCode]; + } + } catch (error) {} +} + +initPreviousAuctionInfo(); diff --git a/test/spec/libraries/previousAuctionInfo_spec.js b/test/spec/libraries/previousAuctionInfo_spec.js new file mode 100644 index 00000000000..9806ccd7f31 --- /dev/null +++ b/test/spec/libraries/previousAuctionInfo_spec.js @@ -0,0 +1,167 @@ +import * as previousAuctionInfo from 'libraries/previousAuctionInfo/previousAuctionInfo.js'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { config } from 'src/config.js'; + +describe('previous auction info', () => { + let sandbox; + let initHandlersStub; + + const auctionDetails = { + auctionId: 'auction123', + bidsReceived: [ + { requestId: 'bid123', bidderCode: 'testBidder1', cpm: 1, adUnitCode: 'adUnit1', currency: 'USD', originalCpm: 1.1, originalCurrency: 'USD' }, + { requestId: 'bidabc', bidderCode: 'testBidder2', cpm: 2, adUnitCode: 'adUnit1', currency: 'EUR', originalCpm: 2.1, originalCurrency: 'EUR' }, + { requestId: 'bidxyz', bidderCode: 'testBidder3', cpm: 3, adUnitCode: 'adUnit2', currency: 'USD', originalCpm: 3.2, originalCurrency: 'USD' } + ], + bidsRejected: [ + { requestId: 'bid456', rejectionReason: 1 }, + { requestId: 'bid789', rejectionReason: 2 } + ], + bidderRequests: [ + { + bidderCode: 'testBidder1', + bidderRequestId: 'req1', + bids: [ + { bidId: 'bid123', ortb2: { cur: ['USD'] }, ortb2Imp: { ext: { tid: 'trans123' } }, adUnitCode: 'adUnit1' } + ] + }, + { + bidderCode: 'testBidder2', + bidderRequestId: 'req2', + bids: [ + { bidId: 'bidabc', ortb2: { cur: ['EUR'] }, ortb2Imp: { ext: { tid: 'trans456' } }, adUnitCode: 'adUnit1' } + ] + }, + { + bidderCode: 'testBidder3', + bidderRequestId: 'req3', + bids: [ + { bidId: 'bidxyz', ortb2: { cur: ['USD'] }, ortb2Imp: { ext: { tid: 'trans789' } }, adUnitCode: 'adUnit2' } + ] + } + ], + timestamp: Date.now(), + }; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + previousAuctionInfo.resetPreviousAuctionInfo(); + initHandlersStub = sandbox.stub(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('config', () => { + it('should initialize the module if publisher enabled', () => { + previousAuctionInfo.initPreviousAuctionInfo(initHandlersStub); + config.setConfig({ previousAuctionInfo: true }); + sandbox.assert.calledOnce(initHandlersStub); + }); + + it('should not enable previous auction info if config.previousAuctionInfo is not set', () => { + sandbox.restore(); + previousAuctionInfo.initPreviousAuctionInfo(initHandlersStub); + config.setConfig({ previousAuctionInfo: false }); + expect(previousAuctionInfo.previousAuctionInfoEnabled).to.be.false; + }); + }); + + describe('onAuctionEndHandler', () => { + it('should store auction data for enabled bidders in auctionState', () => { + const config = { bidderCode: 'testBidder2' }; + previousAuctionInfo.enablePreviousAuctionInfo(config); + previousAuctionInfo.onAuctionEndHandler(auctionDetails); + + expect(previousAuctionInfo.auctionState).to.have.property('testBidder2'); + expect(previousAuctionInfo.auctionState['testBidder2']).to.be.an('array').with.lengthOf(1); + + const storedData = previousAuctionInfo.auctionState['testBidder2'][0]; + + expect(storedData).to.include({ + bidderRequestId: 'req2', + bidId: 'bidabc', + rendered: 0, + source: 'pbjs', + adUnitCode: 'adUnit1', + highestBidCpm: 2, + bidderCpm: 2, + bidderOriginalCpm: 2.1, + bidderCurrency: 'EUR', + bidderOriginalCurrency: 'EUR', + bidderErrorCode: -1, + timestamp: auctionDetails.timestamp + }); + }); + + it('should store auction data for multiple bidders correctly', () => { + const config1 = { bidderCode: 'testBidder1' }; + const config2 = { bidderCode: 'testBidder3' }; + previousAuctionInfo.enablePreviousAuctionInfo(config1); + previousAuctionInfo.enablePreviousAuctionInfo(config2); + previousAuctionInfo.onAuctionEndHandler(auctionDetails); + + expect(previousAuctionInfo.auctionState).to.have.property('testBidder1'); + expect(previousAuctionInfo.auctionState).to.have.property('testBidder3'); + + expect(previousAuctionInfo.auctionState['testBidder1'][0]).to.include({ + bidId: 'bid123', + highestBidCpm: 2, + adUnitCode: 'adUnit1', + bidderCpm: 1, + bidderCurrency: 'USD' + }); + + expect(previousAuctionInfo.auctionState['testBidder3'][0]).to.include({ + bidId: 'bidxyz', + highestBidCpm: 3, + adUnitCode: 'adUnit2', + bidderCpm: 3, + bidderCurrency: 'USD' + }); + }); + + it('should not store auction data for disabled bidders', () => { + previousAuctionInfo.onAuctionEndHandler(auctionDetails); + expect(previousAuctionInfo.auctionState).to.not.have.property('testBidder2'); + }); + }); + + describe('onBidWonHandler', () => { + it('should update the rendered field in auctionState when a pbjs bid wins', () => { + const config = { bidderCode: 'testBidder3' }; + previousAuctionInfo.enablePreviousAuctionInfo(config); + + previousAuctionInfo.auctionState['testBidder3'] = [ + { transactionId: 'trans789', rendered: 0 } + ]; + + const winningBid = { + transactionId: 'trans789' + }; + + previousAuctionInfo.onBidWonHandler(winningBid); + + expect(previousAuctionInfo.auctionState['testBidder3'][0]).to.include({ rendered: 1 }); + }); + + it('should not update the rendered field if no matching transactionId is found', () => { + const config = { bidderCode: 'testBidder3' }; + previousAuctionInfo.enablePreviousAuctionInfo(config); + + previousAuctionInfo.auctionState['testBidder3'] = [ + { transactionId: 'someOtherTid', rendered: 0 } + ]; + + const winningBid = { + transactionId: 'trans789' + }; + + previousAuctionInfo.onBidWonHandler(winningBid); + + expect(previousAuctionInfo.auctionState['testBidder3'][0]).to.include({ rendered: 0 }); + }); + }); +});