From 4d6bc01361afd73ea21c316a3e76c12ca9a8a992 Mon Sep 17 00:00:00 2001 From: zeeye <56828723+zeeye@users.noreply.github.com> Date: Mon, 11 Nov 2024 11:16:53 +0000 Subject: [PATCH 01/14] feat: max-848 Prebid: setup development harness. max-849: Prebid: Make prebid RTB ORTB request to /bid (#1) ### [Prebid: setup development harness](https://mobkoi.atlassian.net/browse/MAX-848) Set up a local development environment for testing and iterating on Prebid customization changes. Sub-tasks: Install Prebid.js dependencies. Create a custom Prebid.js Adapter (mobkoiBidAdapter) and build a custom Prebid.js package to serve locally (the custom Prebid.js package is available to serve to a local webpage). Initialize Ad Service Bid endpoint, ensuring it can serve dummy bid objects to the client. Initialize Ad Server Ad endpoint to serve dummy ads/creatives that display on the sample website. Set up a sample website for end-to-end testing, including page load, Prebid.js, Ad Service Bid endpoint, returning bids to the front-end, Ad Server Ad endpoint, and loading ads on the page. ### [Prebid: Make prebid RTB ORTB request to /bid](https://mobkoi.atlassian.net/browse/MAX-849) Update Prebid.js to create ORTB-formatted bid requests for the /bid endpoint. Sub-tasks: Modify Prebid request formatting to ORTB. Validate bid responses from /bid with ORTB formatting. Integrate the new ORTB bid request structure in the /bid endpoint base on the data provided by Prebid.js. Create unit tests. --- modules/mobkoiBidAdapter.js | 66 +++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 modules/mobkoiBidAdapter.js diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js new file mode 100644 index 00000000000..b69d02761d9 --- /dev/null +++ b/modules/mobkoiBidAdapter.js @@ -0,0 +1,66 @@ +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER } from '../src/mediaTypes.js'; + +const BIDDER_CODE = 'mobkoi'; +const DEFAULT_AD_SERVER_BASE_URL = 'https://adserver.mobkoi.com'; +/** + * The name of the parameter that the publisher can use to specify the ad server endpoint. + */ +const PARAM_NAME_AD_SERVER_BASE_URL = 'adServerBaseUrl'; + +const getBidServerEndpointBase = (prebidBidRequest) => { + return prebidBidRequest.params[PARAM_NAME_AD_SERVER_BASE_URL] || DEFAULT_AD_SERVER_BASE_URL; +} + +const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: 30, + }, + imp(buildImp, bidRequest, context) { + return buildImp(bidRequest, context); + }, + bidResponse(buildPrebidBidResponse, ortbBidResponse, context) { + return buildPrebidBidResponse(ortbBidResponse, context); + }, +}); + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER], + + isBidRequestValid(bid) { + return true; + }, + + buildRequests(prebidBidRequests, prebidBidderRequest) { + return prebidBidRequests.map(currentPrebidBidRequest => { + return { + method: 'POST', + url: getBidServerEndpointBase(currentPrebidBidRequest) + '/bid', + options: { + contentType: 'application/json', + }, + data: { + ortb: converter.toORTB({ bidRequests: [currentPrebidBidRequest], bidderRequest: prebidBidderRequest }), + publisherBidParams: currentPrebidBidRequest.params, + }, + }; + }); + }, + + interpretResponse(serverResponse, customBidRequest) { + if (!serverResponse.body) return []; + + const responseBody = {...serverResponse.body, seatbid: serverResponse.body.seatbid}; + const prebidBidResponse = converter.fromORTB({ + request: customBidRequest.data.ortb, + response: responseBody, + }); + + return prebidBidResponse.bids; + }, +}; + +registerBidder(spec); From 7c57cf08af79d009af8f411080d5736f60845784 Mon Sep 17 00:00:00 2001 From: zeeye <56828723+zeeye@users.noreply.github.com> Date: Mon, 11 Nov 2024 11:21:07 +0000 Subject: [PATCH 02/14] feat: max-852: Prebid: Log bid win to adserver (#3) > Related PRs https://github.com/mobkoi/adserver/pull/6 ### [Prebid: Log bid win to adserver](https://mobkoi.atlassian.net/browse/MAX-852) Implement logging of bid wins directly to the ad server. Sub-tasks: Capture winning bid events in the Prebid.js custom adapter in various steps of biding process. --- modules/mobkoiBidAdapter.js | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js index b69d02761d9..be10f3fa13c 100644 --- a/modules/mobkoiBidAdapter.js +++ b/modules/mobkoiBidAdapter.js @@ -1,6 +1,7 @@ import { ortbConverter } from '../libraries/ortbConverter/converter.js'; import { registerBidder } from '../src/adapters/bidderFactory.js'; import { BANNER } from '../src/mediaTypes.js'; +import { _each, replaceMacros, deepAccess, deepSetValue } from '../src/utils.js'; const BIDDER_CODE = 'mobkoi'; const DEFAULT_AD_SERVER_BASE_URL = 'https://adserver.mobkoi.com'; @@ -8,6 +9,13 @@ const DEFAULT_AD_SERVER_BASE_URL = 'https://adserver.mobkoi.com'; * The name of the parameter that the publisher can use to specify the ad server endpoint. */ const PARAM_NAME_AD_SERVER_BASE_URL = 'adServerBaseUrl'; +/** + * The list of ORTB response fields that are used in the macros. Field + * replacement is self-implemented in the adapter. Use dot-notated path for + * nested fields. For example, 'ad.ext.adomain'. For more information, visit + * https://www.npmjs.com/package/dset and https://www.npmjs.com/package/dlv. + */ +const ORTB_RESPONSE_FIELDS_SUPPORT_MACROS = ['adm', 'nurl', 'lurl']; const getBidServerEndpointBase = (prebidBidRequest) => { return prebidBidRequest.params[PARAM_NAME_AD_SERVER_BASE_URL] || DEFAULT_AD_SERVER_BASE_URL; @@ -19,10 +27,34 @@ const converter = ortbConverter({ ttl: 30, }, imp(buildImp, bidRequest, context) { + context[PARAM_NAME_AD_SERVER_BASE_URL] = getBidServerEndpointBase(bidRequest); return buildImp(bidRequest, context); }, bidResponse(buildPrebidBidResponse, ortbBidResponse, context) { - return buildPrebidBidResponse(ortbBidResponse, context); + const macros = { + // ORTB macros + // AUCTION_PRICE: Don't replace the price macro because it's already replaced by Prebid.js. + AUCTION_IMP_ID: ortbBidResponse.impid, + AUCTION_CURRENCY: ortbBidResponse.cur, + AUCTION_BID_ID: context.bidderRequest.auctionId, + + // Custom macros + BIDDING_API_BASE_URL: context[PARAM_NAME_AD_SERVER_BASE_URL], + CREATIVE_ID: ortbBidResponse.crid, + CAMPAIGN_ID: ortbBidResponse.cid, + }; + + _each(ORTB_RESPONSE_FIELDS_SUPPORT_MACROS, ortbField => { + deepSetValue( + ortbBidResponse, + ortbField, + replaceMacros(deepAccess(ortbBidResponse, ortbField), macros) + ); + }); + + const prebidBid = buildPrebidBidResponse(ortbBidResponse, context); + prebidBid.ortbBidResponse = ortbBidResponse; + return prebidBid; }, }); From 834f3f1b9402af3f7facb6c0347fd2d288927a12 Mon Sep 17 00:00:00 2001 From: zeeye <56828723+zeeye@users.noreply.github.com> Date: Thu, 14 Nov 2024 14:07:42 +0000 Subject: [PATCH 03/14] feat: max-853: Prebid: Log bid loss to adserver (#4) ### [Prebid: Log bid loss to adserver](https://mobkoi.atlassian.net/browse/MAX-853) Implement logging of failed bid events for monitoring purposes. Sub-tasks: Initialise a Prebid custom analytic adapter. Capture bid failure events within Prebid.js during various steps of the bidding process Initialise the endpoint for receiving bid loss signals. Logs will log into Grafana, but this will be done in a separate ticket --- modules/mobkoiAnalyticsAdapter.js | 128 ++++++++++++++++++++++++++++++ modules/mobkoiAnalyticsAdapter.md | 9 +++ modules/mobkoiBidAdapter.js | 16 ++-- 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 modules/mobkoiAnalyticsAdapter.js create mode 100644 modules/mobkoiAnalyticsAdapter.md diff --git a/modules/mobkoiAnalyticsAdapter.js b/modules/mobkoiAnalyticsAdapter.js new file mode 100644 index 00000000000..31102499e17 --- /dev/null +++ b/modules/mobkoiAnalyticsAdapter.js @@ -0,0 +1,128 @@ +import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; +import adapterManager from '../src/adapterManager.js'; +import { EVENTS } from '../src/constants.js'; +import { logInfo, logError, _each, triggerPixel, logWarn } from '../src/utils.js'; +import { ajax } from '../src/ajax.js'; +import { BID_RESPONSE } from '../src/pbjsORTB.js'; + +const BIDDER_CODE = 'mobkoi'; +const analyticsType = 'endpoint'; +const GVL_ID = 898; +const { + BID_WON, + BIDDER_DONE, +} = EVENTS; + +/** + * The options that are passed in from the page + */ +let initOptions = {}; + +async function sendGetRequest(url) { + return new Promise((resolve, reject) => { + try { + logInfo('triggerPixel', url); + triggerPixel(url, resolve); + } catch (error) { + try { + logWarn(`triggerPixel failed. URL: (${url}) Falling back to ajax. Error: `, error); + ajax(url, resolve, null, { + contentType: 'application/json', + method: 'GET', + withCredentials: false, // No user-specific data is tied to the request + referrerPolicy: 'unsafe-url', + crossOrigin: true + }); + } catch (error) { + // If failed with both methods, reject the promise + reject(error); + } + } + }); +} + +function isMobkoiBid(prebidBid) { + return prebidBid && prebidBid.bidderCode === BIDDER_CODE; +} + +function triggerAllLossBidLossBeacon(prebidBid, mobkoiContext) { + _each(Object.values(mobkoiContext.prebidAndOrtbBids), (bidContext) => { + const { ortbBid, bidWin, lurlTriggered } = bidContext; + if (ortbBid.lurl && !bidWin && !lurlTriggered) { + logInfo('triggerLossBeacon', bidWin, prebidBid); + sendGetRequest(ortbBid.lurl); + // Don't wait for the response to continue to avoid race conditions + bidContext.lurlTriggered = true; + } + }); +} + +function appendToContext(prebidBid, mobkoiContext) { + mobkoiContext.prebidAndOrtbBids[prebidBid.adId] = { + prebidBid, + ortbBid: prebidBid.ortbBid, + bidWin: false, + lurlTriggered: false + }; +} + +let mobkoiAnalytics = Object.assign(adapter({analyticsType}), { + mobkoiContext: { + prebidAndOrtbBids: {} + }, + track({ + eventType, + args + }) { + logInfo(`eventType: ${eventType}`, args); + + switch (eventType) { + case BID_RESPONSE: + appendToContext(args, this.mobkoiContext); + break; + case BID_WON: + if (isMobkoiBid(args)) { + this.mobkoiContext.prebidAndOrtbBids[args.adId].bidWin = true; + } + + triggerAllLossBidLossBeacon(args, this.mobkoiContext); + break; + case BIDDER_DONE: + if (args.bidderCode !== BIDDER_CODE) { + break; + } + triggerAllLossBidLossBeacon(args, this.mobkoiContext); + break; + default: + break; + } + } +}); + +// save the base class function +mobkoiAnalytics.originEnableAnalytics = mobkoiAnalytics.enableAnalytics; + +// override enableAnalytics so we can get access to the config passed in from the page +mobkoiAnalytics.enableAnalytics = function (config) { + initOptions = config.options; + if (!config.options.publisherId) { + logError('PublisherId option is not defined. Analytics won\'t work'); + return; + } + + if (!config.options.endpoint) { + logError('Endpoint option is not defined. Analytics won\'t work'); + return; + } + + logInfo('mobkoiAnalytics.enableAnalytics', initOptions); + mobkoiAnalytics.originEnableAnalytics(config); // call the base class function +}; + +adapterManager.registerAnalyticsAdapter({ + adapter: mobkoiAnalytics, + code: BIDDER_CODE, + gvlid: GVL_ID +}); + +export default mobkoiAnalytics; diff --git a/modules/mobkoiAnalyticsAdapter.md b/modules/mobkoiAnalyticsAdapter.md new file mode 100644 index 00000000000..07e10be184b --- /dev/null +++ b/modules/mobkoiAnalyticsAdapter.md @@ -0,0 +1,9 @@ +# Overview + +Module Name: Mobkoi Analytics Adapter +Module Type: Analytics Adapter +Maintainer: platformteam@mobkoi.com + +# Description + +Analytics adapter for Mobkoi. Contact platformteam@mobkoi.com for information. \ No newline at end of file diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js index be10f3fa13c..41ba93a6257 100644 --- a/modules/mobkoiBidAdapter.js +++ b/modules/mobkoiBidAdapter.js @@ -4,7 +4,6 @@ import { BANNER } from '../src/mediaTypes.js'; import { _each, replaceMacros, deepAccess, deepSetValue } from '../src/utils.js'; const BIDDER_CODE = 'mobkoi'; -const DEFAULT_AD_SERVER_BASE_URL = 'https://adserver.mobkoi.com'; /** * The name of the parameter that the publisher can use to specify the ad server endpoint. */ @@ -18,10 +17,15 @@ const PARAM_NAME_AD_SERVER_BASE_URL = 'adServerBaseUrl'; const ORTB_RESPONSE_FIELDS_SUPPORT_MACROS = ['adm', 'nurl', 'lurl']; const getBidServerEndpointBase = (prebidBidRequest) => { - return prebidBidRequest.params[PARAM_NAME_AD_SERVER_BASE_URL] || DEFAULT_AD_SERVER_BASE_URL; + const adServerBaseUrl = prebidBidRequest.params[PARAM_NAME_AD_SERVER_BASE_URL]; + + if (!adServerBaseUrl) { + throw new Error(`The "${PARAM_NAME_AD_SERVER_BASE_URL}" parameter is required in Ad unit bid params.`); + } + return adServerBaseUrl; } -const converter = ortbConverter({ +export const converter = ortbConverter({ context: { netRevenue: true, ttl: 30, @@ -33,7 +37,7 @@ const converter = ortbConverter({ bidResponse(buildPrebidBidResponse, ortbBidResponse, context) { const macros = { // ORTB macros - // AUCTION_PRICE: Don't replace the price macro because it's already replaced by Prebid.js. + AUCTION_PRICE: ortbBidResponse.price, AUCTION_IMP_ID: ortbBidResponse.impid, AUCTION_CURRENCY: ortbBidResponse.cur, AUCTION_BID_ID: context.bidderRequest.auctionId, @@ -53,7 +57,9 @@ const converter = ortbConverter({ }); const prebidBid = buildPrebidBidResponse(ortbBidResponse, context); - prebidBid.ortbBidResponse = ortbBidResponse; + // Save the ORTB response for later use in the other parts of the adapter as + // well as the within the analytics adapter. + prebidBid.ortbBid = ortbBidResponse; return prebidBid; }, }); From 0c03a423f5f876be56109dfd84a6f3098c617d0e Mon Sep 17 00:00:00 2001 From: zeeye <56828723+zeeye@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:07:16 +0000 Subject: [PATCH 04/14] feat: max-876: Prebid: Analytic Adapter Log debug info to adserver (#5) > Related PR: https://github.com/mobkoi/adserver/pull/10 ### [Prebid: Analytic Adapter Log debug info to adserver](https://mobkoi.atlassian.net/browse/MAX-876) Add logging for debugging information to assist with monitoring and troubleshooting. Sub-tasks Record events at different stages of bid processing on the client side via the custom analytic adapter Save event messages locally on the client. Tag each message with one of three levels: info, warn, or debug. --- modules/mobkoiAnalyticsAdapter.js | 1361 +++++++++++++++++++++++++++-- modules/mobkoiBidAdapter.js | 208 +++-- 2 files changed, 1443 insertions(+), 126 deletions(-) diff --git a/modules/mobkoiAnalyticsAdapter.js b/modules/mobkoiAnalyticsAdapter.js index 31102499e17..90b9db4ff7b 100644 --- a/modules/mobkoiAnalyticsAdapter.js +++ b/modules/mobkoiAnalyticsAdapter.js @@ -1,100 +1,687 @@ import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; import adapterManager from '../src/adapterManager.js'; import { EVENTS } from '../src/constants.js'; -import { logInfo, logError, _each, triggerPixel, logWarn } from '../src/utils.js'; import { ajax } from '../src/ajax.js'; -import { BID_RESPONSE } from '../src/pbjsORTB.js'; +import { + logInfo, + logWarn, + logError, + _each, + pick, + triggerPixel, + debugTurnedOn, + mergeDeep, + isEmpty, + deepClone, + deepAccess, +} from '../src/utils.js'; const BIDDER_CODE = 'mobkoi'; const analyticsType = 'endpoint'; const GVL_ID = 898; +/** + * !IMPORTANT: Must match the value in the mobkoiBidAdapter.js + * The name of the parameter that the publisher can use to specify the ad server endpoint. + */ +const PARAM_NAME_AD_SERVER_BASE_URL = 'adServerBaseUrl'; + +/** + * Order by events lifecycle + */ const { + // Order events + AUCTION_INIT, + BID_RESPONSE, + AUCTION_END, + AD_RENDER_SUCCEEDED, BID_WON, BIDDER_DONE, + + // Error events (Not in order) + AUCTION_TIMEOUT, + NO_BID, + BID_REJECTED, + BIDDER_ERROR, + AD_RENDER_FAILED, } = EVENTS; +const CUSTOM_EVENTS = { + BID_LOSS: 'bidLoss', +}; + +const DEBUG_EVENT_LEVELS = { + info: 'info', + warn: 'warn', + error: 'error', +}; + /** - * The options that are passed in from the page + * Some fields contain large data that are not useful for debugging. This + * constant contains the fields that should be omitted from the payload and in + * error messages. */ -let initOptions = {}; +const COMMON_FIELDS_TO_OMIT = ['ad', 'adm']; -async function sendGetRequest(url) { - return new Promise((resolve, reject) => { - try { - logInfo('triggerPixel', url); - triggerPixel(url, resolve); - } catch (error) { +class LocalContext { + /** + * A map of impression ID (ORTB terms) to BidContext object + */ + bidContexts = {}; + + /** + * Shouldn't be accessed directly. Use getPayloadByImpId method instead. + * Payload are indexed by impression ID. + */ + _impressionPayloadCache = { + // [impid]: { ... } + }; + /** + * The payload that is common to all bid contexts. The payload will be + * submitted to the server along with the debug events. + */ + getImpressionPayload(impid) { + if (!impid) { + throw new Error(`Impression ID is required. Given: "${impid}".`); + } + + return this._impressionPayloadCache[impid] || {}; + } + /** + * Update the payload for all impressions. The new values will be merged to + * the existing payload. + * @param {*} subPayloads Object containing new values to be merged indexed by SUB_PAYLOAD_TYPES + */ + mergeToAllImpressionsPayload(subPayloads) { + // Create clone for each impression ID and update the payload cache + _each(this.getAllBidderRequestImpIds(), currentImpid => { + // Avoid modifying the original object + const cloneSubPayloads = deepClone(subPayloads); + + // Initialise the payload cache if it doesn't exist + if (!this._impressionPayloadCache[currentImpid]) { + this._impressionPayloadCache[currentImpid] = {}; + } + + // Merge the new values to the existing payload + utils.mergePayloadAndAddCustomFields( + this._impressionPayloadCache[currentImpid], + cloneSubPayloads, + // Add the identity fields to all sub payloads + { + impid: currentImpid, + publisherId: this.publisherId, + } + ); + }); + } + + /** + * The Prebid auction object but only contains the key fields that we + * interested in. + */ + auction = null; + + /** + * Auction.bidderRequests object + */ + bidderRequests = null; + + get publisherId() { + if (!this.bidderRequests) { + throw new Error('Bidder requests are not available. Accessing before assigning.'); + } + return utils.getPublisherId(this.bidderRequests[0]); + } + + get adServerBaseUrl() { + if ( + !Array.isArray(this.bidderRequests) && + this.bidderRequests.length > 0 + ) { + throw new Error('Bidder requests are not available. Accessing before assigning.' + + JSON.stringify(this.bidderRequests, null, 2) + ); + } + + return utils.getAdServerEndpointBaseUrl(this.bidderRequests[0]); + } + + /** + * Extract all impression IDs from all bid requests. + */ + getAllBidderRequestImpIds() { + if (!Array.isArray(this.bidderRequests)) { + return []; + } + return this.bidderRequests.flatMap(br => br.bids.map(bid => utils.getImpId(bid))); + } + + /** + * Cache the debug events that are common to all bid contexts. + * When a new bid context is created, the events will be pushed to the new + * context. + */ + commonBidContextEvents = []; + + initialise(auction) { + this.auction = pick(auction, ['auctionId', 'auctionEnd']); + this.bidderRequests = auction.bidderRequests; + } + + /** + * Retrieve the BidContext object by the bid object. If the bid context is not + * available, it will create a new one. The new bid context will returned. + * @param {*} bid can be a prebid bid response or ortb bid response + * @returns BidContext object + */ + retrieveBidContext(bid) { + const ortbId = (() => { try { - logWarn(`triggerPixel failed. URL: (${url}) Falling back to ajax. Error: `, error); - ajax(url, resolve, null, { - contentType: 'application/json', - method: 'GET', - withCredentials: false, // No user-specific data is tied to the request - referrerPolicy: 'unsafe-url', - crossOrigin: true - }); + const id = utils.getOrtbId(bid); + if (!id) { + throw new Error( + 'ORTB ID is not available in the given bid object:' + + JSON.stringify(utils.omitRecursive(bid, COMMON_FIELDS_TO_OMIT), null, 2)); + } + return id; } catch (error) { - // If failed with both methods, reject the promise - reject(error); + throw new Error( + 'Failed to retrieve ORTB ID from bid object. Please ensure the given object contains an ORTB ID field.\n' + + `Sub Error: ${error.message}` + ); } + })(); + const bidContext = this.bidContexts[ortbId]; + + if (bidContext) { + return bidContext; } - }); -} -function isMobkoiBid(prebidBid) { - return prebidBid && prebidBid.bidderCode === BIDDER_CODE; -} + /** + * Create a new context object and return it. + */ + let newBidContext = new BidContext({ + localContext: this, + prebidOrOrtbBidResponse: bid, + }); -function triggerAllLossBidLossBeacon(prebidBid, mobkoiContext) { - _each(Object.values(mobkoiContext.prebidAndOrtbBids), (bidContext) => { - const { ortbBid, bidWin, lurlTriggered } = bidContext; - if (ortbBid.lurl && !bidWin && !lurlTriggered) { - logInfo('triggerLossBeacon', bidWin, prebidBid); - sendGetRequest(ortbBid.lurl); - // Don't wait for the response to continue to avoid race conditions - bidContext.lurlTriggered = true; + /** + * Add the data that store in local context to the new bid context. + */ + _each( + this.commonBidContextEvents, + event => newBidContext.pushEvent( + { + eventInstance: event, + subPayloads: null, // Merge the payload later + }) + ); + // Merge cached payloads to the new bid context + newBidContext.mergePayload(this.getImpressionPayload(newBidContext.impid)); + + this.bidContexts[ortbId] = newBidContext; + return newBidContext; + } + + /** + * Immediately trigger the loss beacon for all bids (bid contexts) that haven't won the auction. + */ + triggerAllLossBidLossBeacon() { + _each(this.bidContexts, (bidContext) => { + const { ortbBidResponse, bidWin, lurlTriggered } = bidContext; + if (ortbBidResponse.lurl && !bidWin && !lurlTriggered) { + logInfo('TriggerLossBeacon. impid:', ortbBidResponse.impid); + utils.sendGetRequest(ortbBidResponse.lurl); + // Update the flog. Don't wait for the response to continue to avoid race conditions + bidContext.lurlTriggered = true; + } + }); + } + + /** + * Push an debug event to all bid contexts. This is useful for events that are + * related to all bids in the auction. + * @param {*} eventType Prebid event type or custom event type + * @param {*} level Debug level of the event. It can be one of the following: + * - info + * - warn + * - error + * @param {*} timestamp Default to current timestamp if not provided. + * @param {*} note Optional field. Additional information about the event. + * @param {*} payload Field values from event args that are useful for + * debugging. Payload cross events will merge into one object. + */ + pushEventToAllBidContexts({eventType, level, timestamp, note, subPayloads}) { + // Create one event for each impression ID + _each(this.getAllBidderRequestImpIds(), impid => { + const eventClone = new Event({ + eventType, + impid, + publisherId: this.publisherId, + level, + timestamp, + note, + }); + // Save to the LocalContext + this.commonBidContextEvents.push(eventClone); + this.mergeToAllImpressionsPayload(subPayloads); + }); + + // If there are no bid contexts, push the event to the common events list + if (isEmpty(this.bidContexts)) { + this._commonBidContextEventsFlushed = false; + return; + } + + // Once the bid contexts are available, push the event to all bid contexts + _each(this.bidContexts, (bidContext) => { + bidContext.pushEvent({ + eventInstance: new Event({ + eventType, + impid: bidContext.impid, + publisherId: this.publisherId, + level, + timestamp, + note, + }), + subPayloads: this.getImpressionPayload(bidContext.impid), + }); + }); + } + + /** + * A flag to indicate if the common events have been flushed to the server. + * This is useful to avoid submitting the same events multiple times. + */ + _commonBidContextEventsFlushed = false; + + /** + * Flush all debug events in all bid contexts as well as the common events (in + * Local Context) to the server. + */ + async flushAllDebugEvents() { + if (this.commonBidContextEvents.length < 0 && isEmpty(this.bidContexts)) { + logInfo('No debug events to flush'); + return; } - }); + + const flushPromises = []; + const debugEndpoint = `${this.adServerBaseUrl}/debug`; + + // If there are no bid contexts, and there are error events, submit the + // common events to the server + if ( + isEmpty(this.bidContexts) && + !this._commonBidContextEventsFlushed && + this.commonBidContextEvents.some( + event => event.level === DEBUG_EVENT_LEVELS.error || + event.level === DEBUG_EVENT_LEVELS.warn + ) + ) { + logInfo('Flush common events to the server'); + const debugReports = this.bidderRequests.flatMap(currentBidderRequest => { + return currentBidderRequest.bids.map(bid => { + const impid = utils.getImpId(bid); + return { + impid: impid, + events: this.commonBidContextEvents, + bidWin: null, + // Unroll the payload object to the top level to make it easier for + // Grafana to process the data. + ...this.getImpressionPayload(impid), + }; + }); + }); + + _each(debugReports, debugReport => { + flushPromises.push(utils.postAjax( + debugEndpoint, + debugReport + )); + }); + + this._commonBidContextEventsFlushed = true; + } + + flushPromises.push( + ...Object.values(this.bidContexts) + .map(async (currentBidContext) => { + logInfo('Flush bid context events to the server', currentBidContext); + return utils.postAjax( + debugEndpoint, + { + impid: currentBidContext.impid, + bidWin: currentBidContext.bidWin, + events: currentBidContext.events, + // Unroll the payload object to the top level to make it easier for + // Grafana to process the data. + ...currentBidContext.subPayloads, + } + ); + })); + + await Promise.all(flushPromises); + } } -function appendToContext(prebidBid, mobkoiContext) { - mobkoiContext.prebidAndOrtbBids[prebidBid.adId] = { - prebidBid, - ortbBid: prebidBid.ortbBid, - bidWin: false, - lurlTriggered: false - }; +/** + * Select key fields from the given object based on the object type. This is + * useful for debugging to reduce the size of the API call payload. + * @param {*} objType The custom type of the object. Return by determineObjType function. + * @param {*} eventArgs The args object that is passed in to the event handler + * or any supported object. + * @returns the clone of the given object but only contains the key fields + */ +function pickKeyFields(objType, eventArgs) { + switch (objType) { + case SUB_PAYLOAD_TYPES.AUCTION: { + return pick(eventArgs, [ + 'auctionId', + 'adUnitCodes', + 'auctionStart', + 'auctionEnd', + 'auctionStatus', + 'bidderRequestId', + 'timeout', + 'timestamp', + ]); + } + case SUB_PAYLOAD_TYPES.BIDDER_REQUEST: { + return pick(eventArgs, [ + 'auctionId', + 'bidId', + 'bidderCode', + 'bidderRequestId', + 'timeout' + ]); + } + case SUB_PAYLOAD_TYPES.ORTB_BID: { + return pick(eventArgs, [ + 'impid', 'id', 'price', 'cur', 'crid', 'cid', 'lurl', 'cpm' + ]); + } + case SUB_PAYLOAD_TYPES.PREBID_RESPONSE_INTERPRETED: { + return { + ...pick(eventArgs, [ + 'requestId', + 'creativeId', + 'cpm', + 'currency', + 'bidderCode', + 'adUnitCode', + 'ttl', + 'adId', + 'width', + 'height', + 'requestTimestamp', + 'responseTimestamp', + 'seatBidId', + 'statusMessage', + 'timeToRespond', + 'rejectionReason', + 'ortbId', + 'auctionId', + 'mediaType', + 'bidderRequestId', + ]), + }; + } + case SUB_PAYLOAD_TYPES.PREBID_BID_REQUEST: { + return { + ...pick(eventArgs, [ + 'bidderRequestId' + ]), + bids: eventArgs.bids.map( + bid => pickKeyFields(SUB_PAYLOAD_TYPES.PREBID_RESPONSE_NOT_INTERPRETED, bid) + ), + }; + } + case SUB_PAYLOAD_TYPES.AD_DOC_AND_PREBID_BID: { + return { + // bid: 'Not included to reduce payload size', + doc: pick(eventArgs.doc, ['visibilityState', 'readyState', 'hidden']), + }; + } + case SUB_PAYLOAD_TYPES.AD_DOC_AND_PREBID_BID_WITH_ERROR: { + return { + // bid: 'Not included to reduce payload size', + reason: eventArgs.reason, + message: eventArgs.message, + doc: pick(eventArgs.doc, ['visibilityState', 'readyState', 'hidden']), + } + } + case SUB_PAYLOAD_TYPES.BIDDER_ERROR_ARGS: { + return { + bidderRequest: pickKeyFields(SUB_PAYLOAD_TYPES.BIDDER_REQUEST, eventArgs.bidderRequest), + error: eventArgs.error?.toJSON ? eventArgs.error?.toJSON() + : (eventArgs.error || 'Failed to convert error object to JSON'), + }; + } + default: { + // Include the entire object for debugging + return { eventArgs }; + } + } } let mobkoiAnalytics = Object.assign(adapter({analyticsType}), { - mobkoiContext: { - prebidAndOrtbBids: {} - }, - track({ + localContext: new LocalContext(), + async track({ eventType, - args + args: prebidEventArgs }) { - logInfo(`eventType: ${eventType}`, args); - - switch (eventType) { - case BID_RESPONSE: - appendToContext(args, this.mobkoiContext); - break; - case BID_WON: - if (isMobkoiBid(args)) { - this.mobkoiContext.prebidAndOrtbBids[args.adId].bidWin = true; + try { + switch (eventType) { + case AUCTION_INIT: { + utils.logTrackEvent(eventType, prebidEventArgs); + const argsType = utils.determineObjType(prebidEventArgs); + const auction = prebidEventArgs; + this.localContext.initialise(auction); + this.localContext.pushEventToAllBidContexts({ + eventType, + level: DEBUG_EVENT_LEVELS.info, + timestamp: auction.timestamp, + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs) + } + }); + break; } - - triggerAllLossBidLossBeacon(args, this.mobkoiContext); - break; - case BIDDER_DONE: - if (args.bidderCode !== BIDDER_CODE) { + case BID_RESPONSE: { + utils.logTrackEvent(eventType, prebidEventArgs); + const argsType = utils.determineObjType(prebidEventArgs); + const prebidBid = prebidEventArgs; + const bidContext = this.localContext.retrieveBidContext(prebidBid); + bidContext.pushEvent({ + eventInstance: new Event({ + eventType, + impid: bidContext.impid, + publisherId: this.localContext.publisherId, + level: DEBUG_EVENT_LEVELS.info, + timestamp: prebidEventArgs.timestamp || Date.now(), + }), + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs), + [SUB_PAYLOAD_TYPES.ORTB_BID]: pickKeyFields(SUB_PAYLOAD_TYPES.ORTB_BID, prebidEventArgs.ortbBidResponse), + } + }); break; } - triggerAllLossBidLossBeacon(args, this.mobkoiContext); - break; - default: - break; + case BID_WON: { + utils.logTrackEvent(eventType, prebidEventArgs); + const argsType = utils.determineObjType(prebidEventArgs); + const prebidBid = prebidEventArgs; + if (utils.isMobkoiBid(prebidBid)) { + this.localContext.retrieveBidContext(prebidBid).bidWin = true; + } + // Notify the server that the bidding results. + this.localContext.triggerAllLossBidLossBeacon(); + // Append the bid win/loss event to all bid contexts + _each(this.localContext.bidContexts, (currentBidContext) => { + currentBidContext.pushEvent({ + eventInstance: new Event({ + eventType: currentBidContext.bidWin ? eventType : CUSTOM_EVENTS.BID_LOSS, + impid: currentBidContext.impid, + publisherId: this.localContext.publisherId, + level: DEBUG_EVENT_LEVELS.info, + timestamp: prebidEventArgs.timestamp || Date.now(), + }), + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs), + } + }); + }); + break; + } + case AUCTION_TIMEOUT: + utils.logTrackEvent(eventType, prebidEventArgs); + const argsType = utils.determineObjType(prebidEventArgs); + const auction = prebidEventArgs; + this.localContext.pushEventToAllBidContexts({ + eventType, + level: DEBUG_EVENT_LEVELS.error, + timestamp: auction.timestamp, + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs) + } + }); + break; + case NO_BID: { + utils.logTrackEvent(eventType, prebidEventArgs); + const argsType = utils.determineObjType(prebidEventArgs); + this.localContext.pushEventToAllBidContexts({ + eventType, + level: DEBUG_EVENT_LEVELS.warn, + timestamp: prebidEventArgs.timestamp || Date.now(), + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs) + } + }); + break; + } + case BID_REJECTED: { + utils.logTrackEvent(eventType, prebidEventArgs); + const argsType = utils.determineObjType(prebidEventArgs); + const prebidBid = prebidEventArgs; + const bidContext = this.localContext.retrieveBidContext(prebidBid); + bidContext.pushEvent({ + eventInstance: new Event({ + eventType, + impid: bidContext.impid, + publisherId: this.localContext.publisherId, + level: DEBUG_EVENT_LEVELS.error, + timestamp: prebidEventArgs.timestamp || Date.now(), + note: prebidEventArgs.rejectionReason, + }), + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs) + } + }); + break; + }; + case BIDDER_ERROR: { + utils.logTrackEvent(eventType, prebidEventArgs) + const argsType = utils.determineObjType(prebidEventArgs); + this.localContext.pushEventToAllBidContexts({ + eventType, + level: DEBUG_EVENT_LEVELS.warn, + timestamp: prebidEventArgs.timestamp || Date.now(), + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs) + } + }); + break; + } + case AD_RENDER_FAILED: { + utils.logTrackEvent(eventType, prebidEventArgs); + const argsType = utils.determineObjType(prebidEventArgs); + const {bid: prebidBid} = prebidEventArgs; + const bidContext = this.localContext.retrieveBidContext(prebidBid); + bidContext.pushEvent({ + eventInstance: new Event({ + eventType, + impid: bidContext.impid, + publisherId: this.localContext.publisherId, + level: DEBUG_EVENT_LEVELS.error, + timestamp: prebidEventArgs.timestamp || Date.now(), + }), + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs) + } + }); + break; + } + case AD_RENDER_SUCCEEDED: { + utils.logTrackEvent(eventType, prebidEventArgs); + const argsType = utils.determineObjType(prebidEventArgs); + const prebidBid = prebidEventArgs.bid; + const bidContext = this.localContext.retrieveBidContext(prebidBid); + bidContext.pushEvent({ + eventInstance: new Event({ + eventType, + impid: bidContext.impid, + publisherId: this.localContext.publisherId, + level: DEBUG_EVENT_LEVELS.info, + timestamp: prebidEventArgs.timestamp || Date.now(), + }), + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs) + } + }); + break; + } + case AUCTION_END: { + utils.logTrackEvent(eventType, prebidEventArgs); + const argsType = utils.determineObjType(prebidEventArgs); + const auction = prebidEventArgs; + this.localContext.pushEventToAllBidContexts({ + eventType, + level: DEBUG_EVENT_LEVELS.info, + timestamp: auction.timestamp, + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs) + } + }); + break; + } + case BIDDER_DONE: { + utils.logTrackEvent(eventType, prebidEventArgs) + const argsType = utils.determineObjType(prebidEventArgs); + this.localContext.pushEventToAllBidContexts({ + eventType, + level: DEBUG_EVENT_LEVELS.info, + timestamp: prebidEventArgs.timestamp || Date.now(), + subPayloads: { + [argsType]: pickKeyFields(argsType, prebidEventArgs) + } + }); + this.localContext.triggerAllLossBidLossBeacon(); + await this.localContext.flushAllDebugEvents(); + break; + } + default: + // Do nothing in other events + break; + } + } catch (error) { + // If there is an unexpected error, such as a syntax error, we log + // log the error and submit the error to the server for debugging. + this.localContext.pushEventToAllBidContexts({ + eventType, + level: DEBUG_EVENT_LEVELS.error, + timestamp: prebidEventArgs.timestamp || Date.now(), + note: 'Error occurred when processing this event.', + subPayloads: { + // Include the entire object for debugging + [`errorInEvent_${eventType}`]: { + // Some fields contain large data. Omits them to reduce API call payload size + eventArgs: utils.omitRecursive(prebidEventArgs, COMMON_FIELDS_TO_OMIT), + error: error.message, + } + } + }); + // Throw the error to skip the current Prebid event + throw error; } } }); @@ -104,18 +691,6 @@ mobkoiAnalytics.originEnableAnalytics = mobkoiAnalytics.enableAnalytics; // override enableAnalytics so we can get access to the config passed in from the page mobkoiAnalytics.enableAnalytics = function (config) { - initOptions = config.options; - if (!config.options.publisherId) { - logError('PublisherId option is not defined. Analytics won\'t work'); - return; - } - - if (!config.options.endpoint) { - logError('Endpoint option is not defined. Analytics won\'t work'); - return; - } - - logInfo('mobkoiAnalytics.enableAnalytics', initOptions); mobkoiAnalytics.originEnableAnalytics(config); // call the base class function }; @@ -126,3 +701,637 @@ adapterManager.registerAnalyticsAdapter({ }); export default mobkoiAnalytics; + +class BidContext { + /** + * The impression ID (ORTB term) of the bid. This ID is initialised in Prebid + * bid requests. The ID is reserved in requests and responses but have + * different names from object to object. + */ + get impid() { + if (this.ortbBidResponse) { + return this.ortbBidResponse.impid; + } else if (this.prebidBidResponse) { + return this.prebidBidResponse.requestId; + } else if (this.prebidBidRequest) { + return this.prebidBidRequest.bidId; + } else if ( + this.subPayloads && + utils.getImpId(this.subPayloads) + ) { + return utils.getImpId(this.subPayloads); + } else { + throw new Error('ORTB bid response and Prebid bid response are not available for extracting Impression ID'); + } + } + + /** + * ORTB ID generated by Ad Server + */ + get ortbId() { + if (this.ortbBidResponse) { + return utils.getOrtbId(this.ortbBidResponse); + } else if (this.prebidBidResponse) { + return utils.getOrtbId(this.prebidBidResponse); + } else if (this.subPayloads) { + return utils.getOrtbId(this.subPayloads); + } else { + throw new Error('ORTB bid response and Prebid bid response are not available for extracting ORTB ID'); + } + }; + + get publisherId() { + if (this.prebidBidRequest) { + return utils.getPublisherId(this.prebidBidRequest); + } else { + throw new Error('ORTB bid response and Prebid bid response are not available for extracting Publisher ID'); + } + } + + /** + * The prebid bid request object before converted to ORTB request in our + * custom adapter. + */ + get prebidBidRequest() { + if (!this.prebidBidResponse) { + throw new Error('Prebid bid response is not available. Accessing before assigning.'); + } + + return this.localContext.bidderRequests.flatMap(br => br.bids) + .find(bidRequest => bidRequest.bidId === this.prebidBidResponse.requestId); + } + + /** + * To avoid overriding the subPayloads object, we merge the new values to the + * existing subPayloads object. + */ + _subPayloads = null; + /** + * A group of payloads that are useful for debugging. The payloads are indexed + * by SUB_PAYLOAD_TYPES. + */ + get subPayloads() { + return this._subPayloads; + } + /** + * To avoid overriding the subPayloads object, we merge the new values to the + * existing subPayloads object. Identity fields will automatically added to the + * new values. + * @param {*} newSubPayloads Object containing new values to be merged + */ + mergePayload(newSubPayloads) { + utils.mergePayloadAndAddCustomFields( + this._subPayloads, + newSubPayloads, + // Add the identity fields to all sub payloads + { + impid: this.impid, + publisherId: this.publisherId, + } + ); + } + + /** + * The prebid bid response object after converted from ORTB response in our + * custom adapter. + */ + prebidBidResponse = null; + + /** + * The raw ORTB bid response object from the server. + */ + ortbBidResponse = null; + + /** + * A flag to indicate if the bid has won the auction. It only updated to true + * if the winning bid is from Mobkoi in the BID_WON event. + */ + bidWin = false; + + /** + * A flag to indicate if the loss beacon has been triggered. + */ + lurlTriggered = false; + + /** + * A list of DebugEvent objects + */ + events = []; + + /** + * Keep the reference of LocalContext object for easy accessing data. + */ + localContext = null; + + /** + * A object to store related data of a bid for easy access. + * i.e. bid request and bid response. + * @param {*} param0 + */ + constructor({ + localContext, + prebidOrOrtbBidResponse: bidResponse, + }) { + this.localContext = localContext; + this._subPayloads = {}; + + if (!bidResponse) { + throw new Error('prebidOrOrtbBidResponse field is required'); + } + + const objType = utils.determineObjType(bidResponse); + if (![SUB_PAYLOAD_TYPES.ORTB_BID, SUB_PAYLOAD_TYPES.PREBID_RESPONSE_INTERPRETED].includes(objType)) { + throw new Error( + 'Unable to create a new Bid Context as the given object is not a bid response object. ' + + 'Expect a Prebid Bid Object or ORTB Bid Object. Given object:\n' + + JSON.stringify(utils.omitRecursive(bidResponse, COMMON_FIELDS_TO_OMIT), null, 2) + ); + } + + if (objType === SUB_PAYLOAD_TYPES.ORTB_BID) { + this.ortbBidResponse = bidResponse; + this.prebidBidResponse = null; + } else if (objType === SUB_PAYLOAD_TYPES.PREBID_RESPONSE_INTERPRETED) { + this.ortbBidResponse = bidResponse.ortbBidResponse; + this.prebidBidResponse = bidResponse; + } else { + throw new Error('Expect a Prebid Bid Object or ORTB Bid Object. Given object:\n' + + JSON.stringify(utils.omitRecursive(bidResponse, COMMON_FIELDS_TO_OMIT), null, 2)); + } + } + + /** + * Push a debug event to the context which will submitted to server for debugging. + * @param {*} eventInstance DebugEvent object. If it does not contain the same + * impid as the BidContext, event will be ignored. + * @param {*} subPayloads Object contains various payloads that obtained form + * the Prebid Event args. The payloads will be merged to the existing subPayloads. + */ + pushEvent({eventInstance, subPayloads}) { + if (!(eventInstance instanceof Event)) { + throw new Error('bugEvent must be an instance of DebugEvent'); + } + if (eventInstance.impid != this.impid) { + // Ignore the event if the impression ID is not matched. + return; + } + // Accept only object or null + if (subPayloads !== null && typeof subPayloads !== 'object') { + throw new Error('subPayloads must be an object or null'); + } + + this.events.push(eventInstance); + + if (subPayloads !== null) { + this.mergePayload(subPayloads); + } + } +} + +/** + * A class to represent an event happened in the bid processing lifecycle. + */ +class Event { + /** + * Impression ID must set before appending to event lists. + */ + impid = null; + + /** + * Publisher ID. It is a unique identifier for the publisher. + */ + publisherId = null; + + /** + * Prebid Event Type or Custom Event Type + */ + eventType = null; + /** + * Debug level of the event. It can be one of the following: + * - info + * - warn + * - error + */ + level = null; + /** + * Timestamp of the event. It represents the time when the event occurred. + */ + timestamp = null; + + constructor({eventType, impid, publisherId, level, timestamp, note = undefined}) { + if (!eventType) { + throw new Error('eventType is required'); + } + if (!impid) { + throw new Error(`Impression ID is required. Given: "${impid}"`); + } + + if (typeof publisherId !== 'string') { + throw new Error(`Publisher ID must be a string. Given: "${publisherId}"`); + } + + if (!DEBUG_EVENT_LEVELS[level]) { + throw new Error(`Event level must be one of ${Object.keys(DEBUG_EVENT_LEVELS).join(', ')}. Given: "${level}"`); + } + if (typeof timestamp !== 'number') { + throw new Error('Timestamp must be a number'); + } + this.eventType = eventType; + this.impid = impid; + this.publisherId = publisherId; + this.level = level; + this.timestamp = timestamp; + if (note) { + this.note = note; + } + + if ( + debugTurnedOn() && + ( + level === DEBUG_EVENT_LEVELS.error || + level === DEBUG_EVENT_LEVELS.warn + )) { + logWarn(`New Debug Event - Type: ${eventType} Level: ${level}.`); + } + } +} + +/** + * Various types of payloads that are submitted to the server for debugging. + * Mostly they are obtain from the Prebid event args. + */ +const SUB_PAYLOAD_TYPES = { + AUCTION: 'prebid_auction', + BIDDER_REQUEST: 'bidder_request', + ORTB_BID: 'ortb_bid', + PREBID_RESPONSE_INTERPRETED: 'prebid_bid_interpreted', + PREBID_RESPONSE_NOT_INTERPRETED: 'prebid_bid_not_interpreted', + PREBID_BID_REQUEST: 'prebid_bid_request', + AD_DOC_AND_PREBID_BID: 'ad_doc_and_prebid_bid', + AD_DOC_AND_PREBID_BID_WITH_ERROR: 'ad_doc_and_prebid_bid_with_error', + BIDDER_ERROR_ARGS: 'bidder_error_args', +}; + +/** + * Fields that are unique to objects used to identify the sub-payload type. + */ +const SUB_PAYLOAD_UNIQUE_FIELDS_LOOKUP = { + [SUB_PAYLOAD_TYPES.AUCTION]: ['auctionStatus'], + [SUB_PAYLOAD_TYPES.BIDDER_REQUEST]: ['bidderRequestId'], + [SUB_PAYLOAD_TYPES.ORTB_BID]: ['adm', 'impid'], + [SUB_PAYLOAD_TYPES.PREBID_RESPONSE_INTERPRETED]: ['requestId', 'ortbBidResponse'], + [SUB_PAYLOAD_TYPES.PREBID_RESPONSE_NOT_INTERPRETED]: ['requestId'], // This must be paste under PREBID_RESPONSE_INTERPRETED + [SUB_PAYLOAD_TYPES.PREBID_BID_REQUEST]: ['bidId'], + [SUB_PAYLOAD_TYPES.AD_DOC_AND_PREBID_BID]: ['doc', 'bid'], + [SUB_PAYLOAD_TYPES.AD_DOC_AND_PREBID_BID_WITH_ERROR]: ['bid', 'reason', 'message'], + [SUB_PAYLOAD_TYPES.BIDDER_ERROR_ARGS]: ['error', 'bidderRequest'], +}; + +/** + * Required fields for the sub payloads. The property value defines the type of the required field. + */ +const PAYLOAD_REQUIRED_FIELDS = { + impid: 'string', + publisherId: 'string', +} + +export const utils = { + /** + * Make a POST request to the given URL with the given data. + * @param {*} url + * @param {*} data JSON data + * @returns + */ + postAjax: async function (url, data) { + return new Promise((resolve, reject) => { + try { + logInfo('postAjax:', url, data); + ajax(url, resolve, JSON.stringify(data), { + contentType: 'application/json', + method: 'POST', + withCredentials: false, // No user-specific data is tied to the request + referrerPolicy: 'unsafe-url', + crossOrigin: true + }); + } catch (error) { + reject(new Error( + `Failed to make post request to endpoint "${url}". With data: ` + + JSON.stringify(utils.omitRecursive(data, COMMON_FIELDS_TO_OMIT), null, 2), + { error: error.message } + )); + } + }); + }, + + /** + * Make a GET request to the given URL. If the request fails, it will fall back + * to AJAX request. + * @param {*} url URL with the query string + * @returns + */ + sendGetRequest: async function(url) { + return new Promise((resolve, reject) => { + try { + logInfo('triggerPixel', url); + triggerPixel(url, resolve); + } catch (error) { + try { + logWarn(`triggerPixel failed. URL: (${url}) Falling back to ajax. Error: `, error); + ajax(url, resolve, null, { + contentType: 'application/json', + method: 'GET', + withCredentials: false, // No user-specific data is tied to the request + referrerPolicy: 'unsafe-url', + crossOrigin: true + }); + } catch (error) { + // If failed with both methods, reject the promise + reject(error); + } + } + }); + }, + + /** + * Check if the given Prebid bid is from Mobkoi. + * @param {*} prebidBid + * @returns + */ + isMobkoiBid: function (prebidBid) { + return prebidBid && prebidBid.bidderCode === BIDDER_CODE; + }, + + /** + * !IMPORTANT: Make sure the implementation of this function matches utils.getOrtbId in + * mobkoiAnalyticsAdapter.js. + * We use the bidderRequestId as the ortbId. We could do so because we only + * make one ORTB request per Prebid Bidder Request. + * The ID field named differently when the value passed on to different contexts. + * @param {*} bid Prebid Bidder Request Object or Prebid Bid Response/Request + * or ORTB Request/Response Object + * @returns {string} The ORTB ID + * @throws {Error} If the ORTB ID cannot be found in the given object. + */ + getOrtbId(bid) { + const ortbId = + // called bidderRequestId in Prebid Request + bid.bidderRequestId || + // called seatBidId in Prebid Bid Response Object + bid.seatBidId || + // called ortbId in Interpreted Prebid Response Object + bid.ortbId || + // called id in ORTB object + (Object.hasOwn(bid, 'imp') && bid.id); + + if (!ortbId) { + throw new Error( + 'Failed to obtain ORTB ID from the given object. Given object:\n' + + JSON.stringify(utils.omitRecursive(bid, COMMON_FIELDS_TO_OMIT), null, 2) + ); + } + + return ortbId; + }, + + /** + * Impression ID is named differently in different objects. This function will + * return the impression ID from the given bid object. + * @param {*} bid ORTB bid response or Prebid bid response or Prebid bid request + * @returns string | null + */ + getImpId: function (bid) { + return (bid && (bid.impid || bid.requestId || bid.bidId)) || null; + }, + + /** + * !IMPORTANT: Make sure the implementation of this function matches utils.getPublisherId in + * both adapters. + * Extract the publisher ID from the given object. + * @param {*} bid Prebid Bidder Request Object or Prebid Bid Response/Request + * or ORTB Request/Response Object + * @returns string + * @throws {Error} If the publisher ID is not found in the given object. + */ + getPublisherId: function (bid) { + const ortbPath = 'site.publisher.id'; + const prebidPath = `ortb2.${ortbPath}`; + + const publisherId = + deepAccess(bid, prebidPath) || + deepAccess(bid, ortbPath); + + if (!publisherId) { + throw new Error( + 'Failed to obtain publisher ID from the given object. ' + + `Please set it via the "${prebidPath}" field with pbjs.setBidderConfig.\n` + + 'Given object:\n' + + JSON.stringify(bid, null, 2) + ); + } + + return publisherId; + }, + + /** + * !IMPORTANT: Make sure the implementation of this function matches getAdServerEndpointBaseUrl + * in both adapters. + * Obtain the Ad Server Base URL from the given Prebid object. + * @param {*} bid Prebid Bidder Request Object or Prebid Bid Response/Request + * or ORTB Request/Response Object + * @returns {string} The Ad Server Base URL + * @throws {Error} If the ORTB ID cannot be found in the given + */ + getAdServerEndpointBaseUrl (bid) { + const path = `site.publisher.ext.${PARAM_NAME_AD_SERVER_BASE_URL}`; + const preBidPath = `ortb2.${path}`; + + const adServerBaseUrl = + // For Prebid Bid objects + deepAccess(bid, preBidPath) || + // For ORTB objects + deepAccess(bid, path); + + if (!adServerBaseUrl) { + throw new Error('Failed to find the Ad Server Base URL in the given object. ' + + `Please set it via the "${preBidPath}" field with pbjs.setBidderConfig.\n` + + 'Given Object:\n' + + JSON.stringify(bid, null, 2) + ); + } + + return adServerBaseUrl; + }, + + logTrackEvent: function (eventType, eventArgs) { + if (!debugTurnedOn()) { + return; + } + const argsType = (() => { + try { + return utils.determineObjType(eventArgs); + } catch (error) { + logError(`Error when logging track event: [${eventType}]\n`, error); + return 'Unknown'; + } + })(); + logInfo(`Track event: [${eventType}]. Args Type Determination: ${argsType}`, eventArgs); + }, + + /** + * Determine the type of the given object based on the object's fields. + * This is useful for identifying the type of object that is passed in to the + * handler functions. + * @param {*} eventArgs + * @returns string + */ + determineObjType: function (eventArgs) { + if (typeof eventArgs !== 'object' || eventArgs === null) { + throw new Error( + 'determineObjType: Expect an object. Given object is not an object or null. Given object:' + + JSON.stringify(utils.omitRecursive(eventArgs, COMMON_FIELDS_TO_OMIT), null, 2) + ); + } + + let objType = null; + for (const type of Object.values(SUB_PAYLOAD_TYPES)) { + const identifyFields = SUB_PAYLOAD_UNIQUE_FIELDS_LOOKUP[type]; + if (!identifyFields) { + throw new Error( + `Identify fields for type "${type}" is not defined in COMMON_OBJECT_UNIT_FIELDS.` + ); + } + // If all fields are available in the object, then it's the type we are looking for + if (identifyFields.every(field => eventArgs.hasOwnProperty(field))) { + objType = type; + break; + } + } + + if (!objType) { + throw new Error( + 'Unable to determine track args type. Please update COMMON_OBJECT_UNIT_FIELDS for the new object type.\n' + + 'Given object:\n' + + JSON.stringify(utils.omitRecursive(eventArgs, COMMON_FIELDS_TO_OMIT), null, 2) + ); + } + + return objType; + }, + + /** + * Merge a Payload object with new values. The payload object must be in + * specific format where root level keys are SUB_PAYLOAD_TYPES values and the + * property values must be an object of the given type. + * @param {*} targetPayload + * @param {*} newSubPayloads + * @param {*} customFields Custom fields that are required for the sub payloads. + */ + mergePayloadAndAddCustomFields: function (targetPayload, newSubPayloads, customFields = undefined) { + if (typeof targetPayload !== 'object') { + throw new Error('Target must be an object'); + } + + if (typeof newSubPayloads !== 'object') { + throw new Error('New values must be an object'); + } + + // Ensure all the required custom fields are available + if (customFields) { + _each(customFields, (fieldType, fieldName) => { + if (fieldType === 'string' && typeof newSubPayloads[fieldName] !== 'string') { + throw new Error( + `Field "${fieldName}" must be a string. Given: ${newSubPayloads[fieldName]}` + ); + } + }); + } + + mergeDeep(targetPayload, newSubPayloads); + + // Add the custom fields to the sub-payloads just added to the target payload + if (customFields) { + utils.addCustomFieldsToSubPayloads(targetPayload, customFields); + } + }, + + /** + * Should not use this function directly. Use mergePayloadAndCustomFields + * instead. This function add custom fields to the sub-payloads. The provided + * custom fields will be validated. + * @param {*} subPayloads A group of payloads that are useful for debugging. Indexed by SUB_PAYLOAD_TYPES. + * @param {*} customFields Custom fields that are required for the sub + * payloads. Fields are defined in PAYLOAD_REQUIRED_FIELDS. + */ + addCustomFieldsToSubPayloads: function (subPayloads, customFields) { + _each(subPayloads, (currentSubPayload, subPayloadType) => { + if (!Object.values(SUB_PAYLOAD_TYPES).includes(subPayloadType)) { + return; + } + + // Add the custom fields to the sub-payloads + mergeDeep(currentSubPayload, customFields); + }); + + // Before leaving the function, validate the payload to ensure all + // required fields are available. + utils.validateSubPayloads(subPayloads); + }, + + /** + * Recursively omit the given keys from the object. + * @param {*} obj - The object to process. + * @throws {Error} - If the given object is not valid. + */ + validateSubPayloads: function (subPayloads) { + _each(subPayloads, (currentSubPayload, subPayloadType) => { + if (!Object.values(SUB_PAYLOAD_TYPES).includes(subPayloadType)) { + return; + } + + const validationErrors = []; + // Validate the required fields + _each(PAYLOAD_REQUIRED_FIELDS, (requiredFieldType, requiredFieldName) => { + // eslint-disable-next-line valid-typeof + if (typeof currentSubPayload[requiredFieldName] !== requiredFieldType) { + validationErrors.push(new Error( + `Field "${requiredFieldName}" in "${subPayloadType}" must be a ${requiredFieldType}. Given: ${currentSubPayload[requiredFieldName]}` + )); + } + }); + + if (validationErrors.length > 0) { + throw new Error( + `Validation failed for "${subPayloadType}".\n` + + `Object: ${JSON.stringify(utils.omitRecursive(currentSubPayload, COMMON_FIELDS_TO_OMIT), null, 2)}\n` + + validationErrors.map(error => `Error: ${error.message}`).join('\n') + ); + } + }); + }, + + /** + * Recursively omit the given keys from the object. + * @param {*} obj - The object to process. + * @param {Array} keysToOmit - The keys to omit from the object. + * @param {*} [placeholder='OMITTED'] - The placeholder value to use for omitted keys. + * @returns {Object} - A clone of the given object with the specified keys omitted. + */ + omitRecursive: function (obj, keysToOmit, placeholder = 'OMITTED') { + return Object.keys(obj).reduce((acc, currentKey) => { + // If the current key is in the keys to omit, replace the value with the placeholder + if (keysToOmit.includes(currentKey)) { + acc[currentKey] = placeholder; + return acc; + } + + // If the current value is an object and not null, recursively omit keys + if (typeof obj[currentKey] === 'object' && obj[currentKey] !== null) { + acc[currentKey] = utils.omitRecursive(obj[currentKey], keysToOmit, placeholder); + } else { + // Otherwise, directly assign the value to the accumulator object + acc[currentKey] = obj[currentKey]; + } + return acc; + }, {}); + } +}; diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js index 41ba93a6257..b55a6203df8 100644 --- a/modules/mobkoiBidAdapter.js +++ b/modules/mobkoiBidAdapter.js @@ -1,10 +1,11 @@ import { ortbConverter } from '../libraries/ortbConverter/converter.js'; import { registerBidder } from '../src/adapters/bidderFactory.js'; import { BANNER } from '../src/mediaTypes.js'; -import { _each, replaceMacros, deepAccess, deepSetValue } from '../src/utils.js'; +import { _each, replaceMacros, deepAccess, deepSetValue, logError } from '../src/utils.js'; const BIDDER_CODE = 'mobkoi'; /** + * !IMPORTANT: This value must match the value in mobkoiAnalyticsAdapter.js * The name of the parameter that the publisher can use to specify the ad server endpoint. */ const PARAM_NAME_AD_SERVER_BASE_URL = 'adServerBaseUrl'; @@ -16,50 +17,24 @@ const PARAM_NAME_AD_SERVER_BASE_URL = 'adServerBaseUrl'; */ const ORTB_RESPONSE_FIELDS_SUPPORT_MACROS = ['adm', 'nurl', 'lurl']; -const getBidServerEndpointBase = (prebidBidRequest) => { - const adServerBaseUrl = prebidBidRequest.params[PARAM_NAME_AD_SERVER_BASE_URL]; - - if (!adServerBaseUrl) { - throw new Error(`The "${PARAM_NAME_AD_SERVER_BASE_URL}" parameter is required in Ad unit bid params.`); - } - return adServerBaseUrl; -} - export const converter = ortbConverter({ context: { netRevenue: true, ttl: 30, }, - imp(buildImp, bidRequest, context) { - context[PARAM_NAME_AD_SERVER_BASE_URL] = getBidServerEndpointBase(bidRequest); - return buildImp(bidRequest, context); - }, - bidResponse(buildPrebidBidResponse, ortbBidResponse, context) { - const macros = { - // ORTB macros - AUCTION_PRICE: ortbBidResponse.price, - AUCTION_IMP_ID: ortbBidResponse.impid, - AUCTION_CURRENCY: ortbBidResponse.cur, - AUCTION_BID_ID: context.bidderRequest.auctionId, + request(buildRequest, imps, bidderRequest, context) { + const ortbRequest = buildRequest(imps, bidderRequest, context); + const prebidBidRequest = context.bidRequests[0]; - // Custom macros - BIDDING_API_BASE_URL: context[PARAM_NAME_AD_SERVER_BASE_URL], - CREATIVE_ID: ortbBidResponse.crid, - CAMPAIGN_ID: ortbBidResponse.cid, - }; + ortbRequest.id = utils.getOrtbId(prebidBidRequest); - _each(ORTB_RESPONSE_FIELDS_SUPPORT_MACROS, ortbField => { - deepSetValue( - ortbBidResponse, - ortbField, - replaceMacros(deepAccess(ortbBidResponse, ortbField), macros) - ); - }); + return ortbRequest; + }, + bidResponse(buildPrebidBidResponse, ortbBidResponse, context) { + utils.replaceAllMacrosInPlace(ortbBidResponse, context); const prebidBid = buildPrebidBidResponse(ortbBidResponse, context); - // Save the ORTB response for later use in the other parts of the adapter as - // well as the within the analytics adapter. - prebidBid.ortbBid = ortbBidResponse; + utils.addCustomFieldsToPrebidBidResponse(prebidBid, ortbBidResponse); return prebidBid; }, }); @@ -69,23 +44,30 @@ export const spec = { supportedMediaTypes: [BANNER], isBidRequestValid(bid) { + if (!deepAccess(bid, 'ortb2.site.publisher.id')) { + logError('The "ortb2.site.publisher.id" field is required in the bid request.' + + 'Please set it via the "config.ortb2.site.publisher.id" field with pbjs.setBidderConfig.' + ); + return false; + } + return true; }, buildRequests(prebidBidRequests, prebidBidderRequest) { - return prebidBidRequests.map(currentPrebidBidRequest => { - return { - method: 'POST', - url: getBidServerEndpointBase(currentPrebidBidRequest) + '/bid', - options: { - contentType: 'application/json', - }, - data: { - ortb: converter.toORTB({ bidRequests: [currentPrebidBidRequest], bidderRequest: prebidBidderRequest }), - publisherBidParams: currentPrebidBidRequest.params, - }, - }; - }); + const adServerEndpoint = utils.getAdServerEndpointBaseUrl(prebidBidderRequest) + '/bid'; + + return { + method: 'POST', + url: adServerEndpoint, + options: { + contentType: 'application/json', + }, + data: converter.toORTB({ + bidRequests: prebidBidRequests, + bidderRequest: prebidBidderRequest + }), + }; }, interpretResponse(serverResponse, customBidRequest) { @@ -93,7 +75,7 @@ export const spec = { const responseBody = {...serverResponse.body, seatbid: serverResponse.body.seatbid}; const prebidBidResponse = converter.fromORTB({ - request: customBidRequest.data.ortb, + request: customBidRequest.data, response: responseBody, }); @@ -102,3 +84,129 @@ export const spec = { }; registerBidder(spec); + +const utils = { + + /** + * !IMPORTANT: Make sure the implementation of this function matches getAdServerEndpointBaseUrl + * in both adapters. + * Obtain the Ad Server Base URL from the given Prebid object. + * @param {*} bid Prebid Bidder Request Object or Prebid Bid Response/Request + * or ORTB Request/Response Object + * @returns {string} The Ad Server Base URL + * @throws {Error} If the ORTB ID cannot be found in the given + */ + getAdServerEndpointBaseUrl (bid) { + const ortbPath = `site.publisher.ext.${PARAM_NAME_AD_SERVER_BASE_URL}`; + const prebidPath = `ortb2.${ortbPath}`; + + const adServerBaseUrl = + deepAccess(bid, prebidPath) || + deepAccess(bid, ortbPath); + + if (!adServerBaseUrl) { + throw new Error('Failed to find the Ad Server Base URL in the given object. ' + + `Please set it via the "${prebidPath}" field with pbjs.setBidderConfig.\n` + + 'Given Object:\n' + + JSON.stringify(bid, null, 2) + ); + } + + return adServerBaseUrl; + }, + + /** + * !IMPORTANT: Make sure the implementation of this function matches utils.getPublisherId in + * both adapters. + * Extract the publisher ID from the given object. + * @param {*} prebidBidRequestOrOrtbBidRequest + * @returns string + * @throws {Error} If the publisher ID is not found in the given object. + */ + getPublisherId: function (prebidBidRequestOrOrtbBidRequest) { + const ortbPath = 'site.publisher.id'; + const prebidPath = `ortb2.${ortbPath}`; + + const publisherId = + deepAccess(prebidBidRequestOrOrtbBidRequest, prebidPath) || + deepAccess(prebidBidRequestOrOrtbBidRequest, ortbPath); + + if (!publisherId) { + throw new Error( + 'Failed to obtain publisher ID from the given object. ' + + `Please set it via the "${prebidPath}" field with pbjs.setBidderConfig.\n` + + 'Given object:\n' + + JSON.stringify(prebidBidRequestOrOrtbBidRequest, null, 2) + ); + } + + return publisherId; + }, + + /** + * !IMPORTANT: Make sure the implementation of this function matches utils.getOrtbId in + * mobkoiAnalyticsAdapter.js. + * We use the bidderRequestId as the ortbId. We could do so because we only + * make one ORTB request per Prebid Bidder Request. + * The ID field named differently when the value passed on to different contexts. + * @param {*} bid Prebid Bidder Request Object or Prebid Bid Response/Request + * or ORTB Request/Response Object + * @returns {string} The ORTB ID + * @throws {Error} If the ORTB ID cannot be found in the given object. + */ + getOrtbId(bid) { + const ortbId = + // called bidderRequestId in Prebid Request + bid.bidderRequestId || + // called seatBidId in Prebid Bid Response Object + bid.seatBidId || + // called ortbId in Interpreted Prebid Response Object + bid.ortbId || + // called id in ORTB object + (Object.hasOwn(bid, 'imp') && bid.id); + + if (!ortbId) { + throw new Error('Unable to find the ORTB ID in the bid object. Given Object:\n' + + JSON.stringify(bid, null, 2) + ); + } + + return ortbId; + }, + + /** + * Append custom fields to the prebid bid response. so that they can be accessed + * in various event handlers. + * @param {*} prebidBidResponse + * @param {*} ortbBidResponse + */ + addCustomFieldsToPrebidBidResponse(prebidBidResponse, ortbBidResponse) { + prebidBidResponse.ortbBidResponse = ortbBidResponse; + prebidBidResponse.ortbId = ortbBidResponse.id; + }, + + replaceAllMacrosInPlace(ortbBidResponse, context) { + const macros = { + // ORTB macros + AUCTION_PRICE: ortbBidResponse.price, + AUCTION_IMP_ID: ortbBidResponse.impid, + AUCTION_CURRENCY: ortbBidResponse.cur, + AUCTION_BID_ID: context.bidderRequest.auctionId, + + // Custom macros + BIDDING_API_BASE_URL: utils.getAdServerEndpointBaseUrl(context.bidderRequest), + CREATIVE_ID: ortbBidResponse.crid, + CAMPAIGN_ID: ortbBidResponse.cid, + ORTB_ID: ortbBidResponse.id, + PUBLISHER_ID: deepAccess(context, 'bidRequest.ortb2.site.publisher.id'), + }; + + _each(ORTB_RESPONSE_FIELDS_SUPPORT_MACROS, ortbField => { + deepSetValue( + ortbBidResponse, + ortbField, + replaceMacros(deepAccess(ortbBidResponse, ortbField), macros) + ); + }); + }, +} From 8a5725b86093d907d65aae4df4f195242e3639e4 Mon Sep 17 00:00:00 2001 From: nvkftw Date: Fri, 10 Jan 2025 18:25:22 +0100 Subject: [PATCH 05/14] feat: writing unit tests for mobkoi adapters (#6) Co-authored-by: nvkftw --- modules/mobkoiAnalyticsAdapter.js | 15 +- modules/mobkoiBidAdapter.js | 5 +- .../modules/mobkoiAnalyticsAdapter_spec.js | 508 ++++++++++++++++++ test/spec/modules/mobkoiBidAdapter_spec.js | 281 ++++++++++ 4 files changed, 799 insertions(+), 10 deletions(-) create mode 100644 test/spec/modules/mobkoiAnalyticsAdapter_spec.js create mode 100644 test/spec/modules/mobkoiBidAdapter_spec.js diff --git a/modules/mobkoiAnalyticsAdapter.js b/modules/mobkoiAnalyticsAdapter.js index 90b9db4ff7b..541639b77d2 100644 --- a/modules/mobkoiAnalyticsAdapter.js +++ b/modules/mobkoiAnalyticsAdapter.js @@ -49,7 +49,7 @@ const CUSTOM_EVENTS = { BID_LOSS: 'bidLoss', }; -const DEBUG_EVENT_LEVELS = { +export const DEBUG_EVENT_LEVELS = { info: 'info', warn: 'warn', error: 'error', @@ -62,7 +62,7 @@ const DEBUG_EVENT_LEVELS = { */ const COMMON_FIELDS_TO_OMIT = ['ad', 'adm']; -class LocalContext { +export class LocalContext { /** * A map of impression ID (ORTB terms) to BidContext object */ @@ -957,10 +957,10 @@ class Event { } /** - * Various types of payloads that are submitted to the server for debugging. - * Mostly they are obtain from the Prebid event args. - */ -const SUB_PAYLOAD_TYPES = { + * Various types of payloads that are submitted to the server for debugging. + * Mostly they are obtain from the Prebid event args. + */ +export const SUB_PAYLOAD_TYPES = { AUCTION: 'prebid_auction', BIDDER_REQUEST: 'bidder_request', ORTB_BID: 'ortb_bid', @@ -975,7 +975,7 @@ const SUB_PAYLOAD_TYPES = { /** * Fields that are unique to objects used to identify the sub-payload type. */ -const SUB_PAYLOAD_UNIQUE_FIELDS_LOOKUP = { +export const SUB_PAYLOAD_UNIQUE_FIELDS_LOOKUP = { [SUB_PAYLOAD_TYPES.AUCTION]: ['auctionStatus'], [SUB_PAYLOAD_TYPES.BIDDER_REQUEST]: ['bidderRequestId'], [SUB_PAYLOAD_TYPES.ORTB_BID]: ['adm', 'impid'], @@ -1200,6 +1200,7 @@ export const utils = { `Identify fields for type "${type}" is not defined in COMMON_OBJECT_UNIT_FIELDS.` ); } + // If all fields are available in the object, then it's the type we are looking for if (identifyFields.every(field => eventArgs.hasOwnProperty(field))) { objType = type; diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js index b55a6203df8..14e676aba22 100644 --- a/modules/mobkoiBidAdapter.js +++ b/modules/mobkoiBidAdapter.js @@ -78,14 +78,13 @@ export const spec = { request: customBidRequest.data, response: responseBody, }); - return prebidBidResponse.bids; }, }; registerBidder(spec); -const utils = { +export const utils = { /** * !IMPORTANT: Make sure the implementation of this function matches getAdServerEndpointBaseUrl @@ -198,7 +197,7 @@ const utils = { CREATIVE_ID: ortbBidResponse.crid, CAMPAIGN_ID: ortbBidResponse.cid, ORTB_ID: ortbBidResponse.id, - PUBLISHER_ID: deepAccess(context, 'bidRequest.ortb2.site.publisher.id'), + PUBLISHER_ID: deepAccess(context, 'bidRequest.ortb2.site.publisher.id') || deepAccess(context, 'bidderRequest.ortb2.site.publisher.id') }; _each(ORTB_RESPONSE_FIELDS_SUPPORT_MACROS, ortbField => { diff --git a/test/spec/modules/mobkoiAnalyticsAdapter_spec.js b/test/spec/modules/mobkoiAnalyticsAdapter_spec.js new file mode 100644 index 00000000000..9122b5e49f4 --- /dev/null +++ b/test/spec/modules/mobkoiAnalyticsAdapter_spec.js @@ -0,0 +1,508 @@ +import mobkoiAnalyticsAdapter, { DEBUG_EVENT_LEVELS, utils, SUB_PAYLOAD_UNIQUE_FIELDS_LOOKUP, SUB_PAYLOAD_TYPES } from 'modules/mobkoiAnalyticsAdapter.js'; +import {internal} from '../../../src/utils.js'; +import adapterManager from '../../../src/adapterManager.js'; +import * as events from 'src/events.js'; +import { EVENTS } from 'src/constants.js'; +import sinon from 'sinon'; + +const defaultTimeout = 10000; +const requestId = 'test-request-id' +const publisherId = 'mobkoiPublisherId' +const bidId = 'test-bid-id' +const bidderCode = 'mobkoi' +const transactionId = 'test-transaction-id' +const impressionId = 'test-impression-id' +const adUnitId = 'test-ad-unit-id' +const auctionId = 'test-auction-id' +const adServerBaseUrl = 'http://adServerBaseUrl'; + +const adm = '
test ad
'; +const lurl = 'test.com/loss'; +const nurl = 'test.com/win'; + +const performStandardAuction = (auctionEvents) => { + auctionEvents.forEach(auctionEvent => { + events.emit(auctionEvent.event, auctionEvent.data); + }); +} + +const getOrtb2 = () => ({ + site: { + publisher: { + id: publisherId, + ext: { adServerBaseUrl } + } + } +}) + +const getBidderResponse = () => ({ + body: { + id: bidId, + cur: 'USD', + seatbid: [ + { + seat: 'mobkoi_debug', + bid: [ + { + id: bidId, + impid: impressionId, + cid: 'campaign_1', + crid: 'creative_1', + price: 1, + cur: [ + 'USD' + ], + adomain: [ + 'advertiser.com' + ], + adm, + w: 300, + h: 250, + mtype: 1, + lurl, + nurl + } + ] + } + ], + } +}) + +const getMockEvents = () => { + const sizes = [800, 300]; + const timestamp = Date.now(); + const auctionOrBidError = {timestamp, error: 'error', bidderRequest: { bidderRequestId: requestId }} + + return { + AUCTION_TIMEOUT: auctionOrBidError, + AUCTION_INIT: { + timestamp, + auctionId, + auctionStatus: 'inProgress', + adUnits: [{ + adUnitId: adUnitId, + code: 'banner-ad', + mediaTypes: { banner: { sizes: [sizes] } }, + transactionId, + }], + bidderRequests: [{ + bidderRequestId: requestId, + bids: [getBidRequest()], + ortb2: getOrtb2() + }] + }, + BID_RESPONSE: { + auctionId, + timestamp, + requestId: bidId, + bidId, + ortbId: requestId, + cpm: 1.5, + currency: 'USD', + ortbBidResponse: { + id: requestId, + impid: bidId, + price: 1.5 + } + }, + NO_BID: auctionOrBidError, + BIDDER_DONE: { + timestamp, + auctionId, + bidderRequestId: requestId, + bids: [getBidRequest()], + ortb2: getOrtb2() + }, + BID_WON: { + timestamp, + auctionId, + requestId, + bidId, + ortbBidResponse: { + id: requestId, + impid: bidId + } + }, + AUCTION_END: { + timestamp, + auctionId, + auctionStatus: 'completed' + }, + AD_RENDER_SUCCEEDED: { + bid: { + timestamp, + requestId: bidId, + bidId, + ortbId: requestId, + ad: '
test ad
' + }, + doc: { visibilityState: 'visible' } + }, + AD_RENDER_FAILED: { + bid: { + timestamp, + requestId: bidId, + bidId, + ortbId: requestId, + ad: '
test ad
' + }, + reason: 'error', + message: 'error' + }, + BIDDER_ERROR: auctionOrBidError, + BID_REJECTED: { + timestamp, + error: 'error', + bidderRequestId: requestId + } + } +} + +const getBidRequest = () => ({ + bidder: bidderCode, + adUnitCode: 'banner-ad', + transactionId, + adUnitId, + bidId: bidId, + bidderRequestId: requestId, + auctionId, + ortb2: getOrtb2() +}) + +const getBidderRequest = () => ({ + bidderCode, + auctionId, + bidderRequestId: requestId, + bids: [getBidRequest()], + ortb2: getOrtb2() +}) + +describe('mobkoiAnalyticsAdapter', function () { + it('should registers with the adapter manager', function () { + // should refer to the BIDDER_CODE in the mobkoiAnalyticsAdapter + const adapter = adapterManager.getAnalyticsAdapter('mobkoi'); + expect(adapter).to.exist; + // should refer to the GVL_ID in the mobkoiAnalyticsAdapter + expect(adapter.gvlid).to.equal(898); + expect(adapter.adapter).to.equal(mobkoiAnalyticsAdapter); + }); + + describe('Tracks events', function () { + let adapter; + let sandbox; + let pushEventSpy; + let flushEventsSpy; + let triggerBeaconSpy; + let postAjaxStub; + let sendGetRequestStub; + + beforeEach(function () { + adapter = mobkoiAnalyticsAdapter; + sandbox = sinon.createSandbox({ + useFakeTimers: { + now: new Date(2025, 0, 8, 0, 1, 33, 425), + }, + }); + + // Disable then reenable the adapter in order to have a fresh context + adapter.disableAnalytics(); + adapter.enableAnalytics({ + options: { + endpoint: adServerBaseUrl, + pid: 'test-pid', + timeout: defaultTimeout, + } + }); + + sandbox.stub(internal, 'logInfo'); + sandbox.stub(internal, 'logWarn'); + sandbox.stub(internal, 'logError'); + + // Create spies after enabling analytics to ensure localContext exists + postAjaxStub = sandbox.stub(utils, 'postAjax'); + sendGetRequestStub = sandbox.stub(utils, 'sendGetRequest'); + pushEventSpy = sandbox.spy(adapter.localContext, 'pushEventToAllBidContexts'); + flushEventsSpy = sandbox.spy(adapter.localContext, 'flushAllDebugEvents'); + triggerBeaconSpy = sandbox.spy(adapter.localContext, 'triggerAllLossBidLossBeacon'); + }); + + afterEach(function () { + adapter.disableAnalytics(); + sandbox.restore(); + postAjaxStub.reset(); + sendGetRequestStub.reset(); + }); + + it('should call sendGetRequest while tracking BIDDER_DONE / BID_WON events', function () { + const { AUCTION_INIT, BID_RESPONSE, BID_WON } = getMockEvents(); + const bidResponse = { + ...BID_RESPONSE, + ortbBidResponse: { + ...BID_RESPONSE.ortbBidResponse, + lurl, + bidWin: false, + lurlTriggered: false + } + }; + const eventSequence = [ + { event: EVENTS.AUCTION_INIT, data: AUCTION_INIT }, + { event: EVENTS.BID_RESPONSE, data: bidResponse }, + { event: EVENTS.BID_WON, data: BID_WON }, + ] + + performStandardAuction(eventSequence); + + expect(sendGetRequestStub.callCount).to.equal(1); + expect(sendGetRequestStub.firstCall.args[0]).to.equal(lurl); + }) + + it('should call postAjax while tracking BIDDER_DONE event', function () { + const { AUCTION_INIT, BID_RESPONSE, BIDDER_DONE } = getMockEvents(); + + const eventSequence = [ + { event: EVENTS.AUCTION_INIT, data: AUCTION_INIT }, + { event: EVENTS.BID_RESPONSE, data: BID_RESPONSE }, + { event: EVENTS.BIDDER_DONE, data: BIDDER_DONE } + ]; + + performStandardAuction(eventSequence); + + expect(postAjaxStub.calledOnce).to.be.true; + expect(postAjaxStub.firstCall.args[0]).to.equal(`${adServerBaseUrl}/debug`); + }) + + it('should track complete auction workflow in correct sequence and trigger a loss beacon', function () { + const { AUCTION_INIT, BID_RESPONSE, AUCTION_END, AD_RENDER_SUCCEEDED, BIDDER_DONE } = getMockEvents(); + + const eventSequence = [ + { event: EVENTS.AUCTION_INIT, data: AUCTION_INIT }, + { event: EVENTS.BID_RESPONSE, data: BID_RESPONSE }, + { event: EVENTS.AD_RENDER_SUCCEEDED, data: AD_RENDER_SUCCEEDED }, + { event: EVENTS.AUCTION_END, data: AUCTION_END }, + { event: EVENTS.BIDDER_DONE, data: BIDDER_DONE } + ]; + + performStandardAuction(eventSequence); + expect(pushEventSpy.callCount).to.equal(3); // AUCTION_INIT, AUCTION_END, BIDDER_DONE + expect(flushEventsSpy.callCount).to.equal(1); + expect(triggerBeaconSpy.callCount).to.equal(1); // BIDDER_DONE + }); + + it('should track errors events', function () { + const { AUCTION_TIMEOUT, NO_BID, BID_REJECTED, BIDDER_ERROR, AD_RENDER_FAILED } = getMockEvents(); + + const eventSequence = [ + { event: EVENTS.AUCTION_TIMEOUT, data: AUCTION_TIMEOUT }, + { event: EVENTS.NO_BID, data: NO_BID }, + { event: EVENTS.BID_REJECTED, data: BID_REJECTED }, + { event: EVENTS.BIDDER_ERROR, data: BIDDER_ERROR }, + { event: EVENTS.AD_RENDER_FAILED, data: AD_RENDER_FAILED } + ]; + + performStandardAuction(eventSequence); + + expect(pushEventSpy.callCount).to.equal(3); // AUCTION_TIMEOUT, NO_BID, BIDDER_ERROR + }); + + it('should push unexpected error events to the localContext', async function () { + const { AUCTION_INIT } = getMockEvents(); + delete AUCTION_INIT.auctionStatus; + try { + await adapter.track({ + eventType: EVENTS.AUCTION_INIT, + args: AUCTION_INIT + }); + } catch { + expect(pushEventSpy.calledOnce).to.be.true; + const errorEventCall = pushEventSpy.getCall(0); + + expect(errorEventCall.args[0]).to.deep.include({ + eventType: EVENTS.AUCTION_INIT, + level: DEBUG_EVENT_LEVELS.error, + note: 'Error occurred when processing this event.' + }); + + const errorPayload = errorEventCall.args[0].subPayloads[`errorInEvent_${EVENTS.AUCTION_INIT}`]; + expect(errorPayload).to.exist; + expect(errorPayload.error).to.include('Unable to determine track args type'); + } + }); + }) + + describe('utils', function () { + let bidderRequest; + + beforeEach(function () { + bidderRequest = getBidderRequest(); + }); + + describe('isMobkoiBid', function () { + it('should return true when the bid is from mobkoi', function () { + expect(utils.isMobkoiBid(bidderRequest)).to.be.true; + }); + it('should return false when the bid is not from mobkoi', function () { + bidderRequest.bidderCode = 'anything'; + expect(utils.isMobkoiBid(bidderRequest)).to.be.false; + }); + }); + + describe('getOrtbId', function () { + it('should return the ortbId from the prebid request object (i.e bidderRequestId)', function () { + expect(utils.getOrtbId(bidderRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should return the ortbId from the prebid response object (i.e seatBidId)', function () { + const customBidRequest = { ...bidderRequest, seatBidId: bidderRequest.bidderRequestId }; + delete customBidRequest.bidderRequestId; + expect(utils.getOrtbId(customBidRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should return the ortbId from the interpreted prebid response object (i.e ortbId)', function () { + const customBidRequest = { ...bidderRequest, ortbId: bidderRequest.bidderRequestId }; + delete customBidRequest.bidderRequestId; + expect(utils.getOrtbId(customBidRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should return the ortbId from the ORTB request object (i.e has imp)', function () { + const customBidRequest = { ...bidderRequest, imp: {}, id: bidderRequest.bidderRequestId }; + delete customBidRequest.bidderRequestId; + expect(utils.getOrtbId(customBidRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should throw error when ortbId is missing', function () { + delete bidderRequest.bidderRequestId; + expect(() => { + utils.getOrtbId(bidderRequest); + }).to.throw(); + }); + }) + + describe('getImpId', function () { + let bidResponse; + beforeEach(function () { + const bidderResponse = getBidderResponse(); + bidResponse = bidderResponse.body.seatbid[0].bid[0]; + }); + + it('should return the impId from the impid field', function () { + expect(utils.getImpId(bidResponse)).to.equal(bidResponse.impid); + }); + + it('should return the impId from the requestId field', function () { + const customBidResponse = { ...bidResponse, requestId: bidResponse.impid }; + delete customBidResponse.impid; + expect(utils.getImpId(customBidResponse)).to.equal(bidResponse.impid); + }); + + it('should return the impId from the bidId field', function () { + const customBidResponse = { ...bidResponse, bidId: bidResponse.impid }; + delete customBidResponse.impid; + expect(utils.getImpId(customBidResponse)).to.equal(bidResponse.impid); + }); + + it('should return null if impId is missing', function () { + expect(utils.getImpId({})).to.be.null; + }); + }) + + describe('getPublisherId', function () { + it('should return the publisherId from the given object', function () { + expect(utils.getPublisherId(bidderRequest)).to.equal(bidderRequest.ortb2.site.publisher.id); + }); + + it('should throw error when publisherId is missing', function () { + delete bidderRequest.ortb2.site.publisher.id; + expect(() => { + utils.getPublisherId(bidderRequest); + }).to.throw(); + }); + }) + + describe('getAdServerEndpointBaseUrl', function () { + it('should return the adServerBaseUrl from the given object', function () { + expect(utils.getAdServerEndpointBaseUrl(bidderRequest)) + .to.equal(adServerBaseUrl); + }); + + it('should throw error when adServerBaseUrl is missing', function () { + delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; + + expect(() => { + utils.getAdServerEndpointBaseUrl(bidderRequest); + }).to.throw(); + }); + }) + + describe('determineObjType', function () { + [null, undefined, 123, 'string', true].forEach(value => { + it(`should throw an error when input is ${value}`, function() { + expect(() => { + utils.determineObjType(value); + }).to.throw(); + }); + }); + + it('should throw an error if the object type could not be determined', function () { + expect(() => { + utils.determineObjType({dumbAttribute: 'bid'}) + }).to.throw(); + }); + + Object.values(SUB_PAYLOAD_TYPES).forEach(type => { + it(`should return the ${type} type`, function () { + const eventArgs = {} + const uniqueFields = SUB_PAYLOAD_UNIQUE_FIELDS_LOOKUP[type] + uniqueFields.forEach(field => { + eventArgs[field] = 'random-value' + }) + expect(utils.determineObjType(eventArgs)).to.equal(type); + }) + }) + }) + + describe('mergePayloadAndCustomFields', function () { + it('should throw an error when the target is not an object', function () { + expect(() => { + utils.mergePayloadAndCustomFields(123, {}) + }).to.throw(); + }) + + it('should throw an error when the new values are not an object', function () { + expect(() => { + utils.mergePayloadAndCustomFields({}, 123) + }).to.throw(); + }) + + it('should throw an error if custom fields are provided and one of them is not a string', () => { + const customFields = {impid: 'bid-123', bidId: 123} + expect(() => { + utils.mergePayloadAndCustomFields({}, customFields) + }).to.throw(); + }) + }) + + describe('validateSubPayloads', function () { + it('should throw an error if the sub payloads required fields are not the correct type', function () { + expect(() => { + utils.validateSubPayloads({ + [SUB_PAYLOAD_TYPES.ORTB_BID]: { + impid: 123, + publisherId: 456 + } + }) + }).to.throw(); + }); + + it('should not throw when sub payloads have valid required fields', function () { + expect(() => { + utils.validateSubPayloads({ + [SUB_PAYLOAD_TYPES.ORTB_BID]: { + impid: '123', + publisherId: 'publisher-123' + } + }) + }).to.not.throw(); + }); + }); + }) +}); diff --git a/test/spec/modules/mobkoiBidAdapter_spec.js b/test/spec/modules/mobkoiBidAdapter_spec.js new file mode 100644 index 00000000000..8a615d9ce1e --- /dev/null +++ b/test/spec/modules/mobkoiBidAdapter_spec.js @@ -0,0 +1,281 @@ +import {spec, utils} from 'modules/mobkoiBidAdapter.js'; + +describe('Mobkoi bidding Adapter', function () { + const adServerBaseUrl = 'http://adServerBaseUrl'; + const requestId = 'test-request-id' + const publisherId = 'mobkoiPublisherId' + const bidId = 'test-bid-id' + const bidderCode = 'mobkoi' + const transactionId = 'test-transaction-id' + const adUnitId = 'test-ad-unit-id' + const auctionId = 'test-auction-id' + + const getOrtb2 = () => ({ + site: { + publisher: { + id: publisherId, + ext: { adServerBaseUrl } + } + } + }) + + const getBidRequest = () => ({ + bidder: bidderCode, + adUnitCode: 'banner-ad', + transactionId, + adUnitId, + bidId: bidId, + bidderRequestId: requestId, + auctionId, + ortb2: getOrtb2() + }) + + const getBidderRequest = () => ({ + bidderCode, + auctionId, + bidderRequestId: requestId, + bids: [getBidRequest()], + ortb2: getOrtb2() + }) + + const getConvertedBidRequest = () => ({ + id: requestId, + imp: [{ + id: bidId, + }], + ...getOrtb2(), + test: 0 + }) + + const adm = '
test ad
'; + const lurl = 'test.com/loss'; + const nurl = 'test.com/win'; + + const getBidderResponse = () => ({ + body: { + id: bidId, + cur: 'USD', + seatbid: [ + { + seat: 'mobkoi_debug', + bid: [ + { + id: bidId, + impid: bidId, + cid: 'campaign_1', + crid: 'creative_1', + price: 1, + cur: [ + 'USD' + ], + adomain: [ + 'advertiser.com' + ], + adm, + w: 300, + h: 250, + mtype: 1, + lurl, + nurl + } + ] + } + ], + } + }) + + describe('isBidRequestValid', function () { + let bid; + + beforeEach(function () { + bid = getBidderRequest().bids[0]; + }); + + it('should return true when publisher id exists in ortb2', function () { + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return false when publisher id is missing', function () { + delete bid.ortb2.site.publisher.id; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when publisher id is empty', function () { + bid.ortb2.site.publisher.id = ''; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + }) + + describe('buildRequests', function () { + let bidderRequest, convertedBidRequest; + + beforeEach(function () { + bidderRequest = getBidderRequest(); + convertedBidRequest = getConvertedBidRequest(); + }); + + it('should return valid request object with correct structure', function () { + const request = spec.buildRequests(bidderRequest.bids, bidderRequest); + const expectedUrl = adServerBaseUrl + '/bid'; + + expect(request.method).to.equal('POST'); + expect(request.options.contentType).to.equal('application/json'); + expect(request.url).to.equal(expectedUrl); + expect(request.data).to.deep.equal(convertedBidRequest); + }); + + it('should include converted ORTB data in request', function () { + const request = spec.buildRequests(bidderRequest.bids, bidderRequest); + const ortbData = request.data; + + expect(ortbData.id).to.equal(bidderRequest.bidderRequestId); + expect(ortbData.site.publisher.id).to.equal(bidderRequest.ortb2.site.publisher.id); + }); + + it('should throw error when adServerBaseUrl is missing', function () { + delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; + + expect(() => { + spec.buildRequests(bidderRequest.bids, bidderRequest); + }).to.throw(); + }); + }) + + describe('interpretResponse', function () { + let bidderRequest, bidRequest, bidderResponse; + + beforeEach(function () { + bidderRequest = getBidderRequest(); + bidRequest = spec.buildRequests(bidderRequest.bids, bidderRequest); + bidderResponse = getBidderResponse(); + }); + + it('should return empty array when response is empty', function () { + expect(spec.interpretResponse({}, {})).to.deep.equal([]); + }); + + it('should interpret valid bid response', function () { + const bidsResponse = spec.interpretResponse(bidderResponse, bidRequest); + expect(bidsResponse).to.not.be.empty; + const bid = bidsResponse[0]; + + expect(bid.ad).to.include(adm); + expect(bid.requestId).to.equal(bidderResponse.body.seatbid[0].bid[0].impid); + expect(bid.cpm).to.equal(bidderResponse.body.seatbid[0].bid[0].price); + expect(bid.width).to.equal(bidderResponse.body.seatbid[0].bid[0].w); + expect(bid.height).to.equal(bidderResponse.body.seatbid[0].bid[0].h); + expect(bid.creativeId).to.equal(bidderResponse.body.seatbid[0].bid[0].crid); + expect(bid.currency).to.equal(bidderResponse.body.cur); + expect(bid.netRevenue).to.be.true; + expect(bid.ttl).to.equal(30); + }); + }) + + describe('utils', function () { + let bidderRequest; + + beforeEach(function () { + bidderRequest = getBidderRequest(); + }); + + describe('getAdServerEndpointBaseUrl', function () { + it('should return the adServerBaseUrl from the given object', function () { + expect(utils.getAdServerEndpointBaseUrl(bidderRequest)) + .to.equal(adServerBaseUrl); + }); + + it('should throw error when adServerBaseUrl is missing', function () { + delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; + + expect(() => { + utils.getAdServerEndpointBaseUrl(bidderRequest); + }).to.throw(); + }); + }) + + describe('getPublisherId', function () { + it('should return the publisherId from the given object', function () { + expect(utils.getPublisherId(bidderRequest)).to.equal(bidderRequest.ortb2.site.publisher.id); + }); + + it('should throw error when publisherId is missing', function () { + delete bidderRequest.ortb2.site.publisher.id; + expect(() => { + utils.getPublisherId(bidderRequest); + }).to.throw(); + }); + }) + + describe('getOrtbId', function () { + it('should return the ortbId from the prebid request object (i.e bidderRequestId)', function () { + expect(utils.getOrtbId(bidderRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should return the ortbId from the prebid response object (i.e seatBidId)', function () { + const customBidRequest = { ...bidderRequest, seatBidId: bidderRequest.bidderRequestId }; + delete customBidRequest.bidderRequestId; + expect(utils.getOrtbId(customBidRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should return the ortbId from the interpreted prebid response object (i.e ortbId)', function () { + const customBidRequest = { ...bidderRequest, ortbId: bidderRequest.bidderRequestId }; + delete customBidRequest.bidderRequestId; + expect(utils.getOrtbId(customBidRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should return the ortbId from the ORTB request object (i.e has imp)', function () { + const customBidRequest = { ...bidderRequest, imp: {}, id: bidderRequest.bidderRequestId }; + delete customBidRequest.bidderRequestId; + expect(utils.getOrtbId(customBidRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should throw error when ortbId is missing', function () { + delete bidderRequest.bidderRequestId; + expect(() => { + utils.getOrtbId(bidderRequest); + }).to.throw(); + }); + }) + + describe('replaceAllMacrosInPlace', function () { + let bidderResponse, bidRequest, bidderRequest; + + beforeEach(function () { + bidderRequest = getBidderRequest(); + bidRequest = spec.buildRequests(bidderRequest.bids, bidderRequest); + bidderResponse = getBidderResponse(); + }); + + it('should replace all macros in adm, nurl, and lurl fields', function () { + const bid = bidderResponse.body.seatbid[0].bid[0]; + bid.nurl = '${BIDDING_API_BASE_URL}/win?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}'; + bid.lurl = '${BIDDING_API_BASE_URL}/loss?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}'; + bid.adm = '
${AUCTION_PRICE}${AUCTION_CURRENCY}${AUCTION_IMP_ID}${AUCTION_BID_ID}${CAMPAIGN_ID}${CREATIVE_ID}${PUBLISHER_ID}${ORTB_ID}${BIDDING_API_BASE_URL}
'; + + const BIDDING_API_BASE_URL = adServerBaseUrl; + const AUCTION_CURRENCY = bidderResponse.body.cur; + const AUCTION_BID_ID = bidderRequest.auctionId; + const AUCTION_PRICE = bid.price; + const AUCTION_IMP_ID = bid.impid; + const CREATIVE_ID = bid.crid; + const CAMPAIGN_ID = bid.cid; + const PUBLISHER_ID = bidderRequest.ortb2.site.publisher.id; + const ORTB_ID = bidderResponse.body.id; + + const context = { + bidRequest, + bidderRequest + } + utils.replaceAllMacrosInPlace(bid, context); + + expect(bid.adm).to.equal(`
${AUCTION_PRICE}${AUCTION_CURRENCY}${AUCTION_IMP_ID}${AUCTION_BID_ID}${CAMPAIGN_ID}${CREATIVE_ID}${PUBLISHER_ID}${ORTB_ID}${BIDDING_API_BASE_URL}
`); + expect(bid.lurl).to.equal( + `${BIDDING_API_BASE_URL}/loss?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}` + ); + expect(bid.nurl).to.equal( + `${BIDDING_API_BASE_URL}/win?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}` + ); + }); + }) + }) +}) From f5646654dd3b5ce39c6c8d5fab53eff7851f8220 Mon Sep 17 00:00:00 2001 From: zhihuiye Date: Mon, 13 Jan 2025 10:00:45 +0000 Subject: [PATCH 06/14] updated doc description --- modules/mobkoiAnalyticsAdapter.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/modules/mobkoiAnalyticsAdapter.js b/modules/mobkoiAnalyticsAdapter.js index 541639b77d2..93913c19d64 100644 --- a/modules/mobkoiAnalyticsAdapter.js +++ b/modules/mobkoiAnalyticsAdapter.js @@ -241,15 +241,16 @@ export class LocalContext { /** * Push an debug event to all bid contexts. This is useful for events that are * related to all bids in the auction. - * @param {*} eventType Prebid event type or custom event type - * @param {*} level Debug level of the event. It can be one of the following: + * @param {Object} params Object containing the event details + * @param {*} params.eventType Prebid event type or custom event type + * @param {*} params.level Debug level of the event. It can be one of the following: * - info * - warn * - error - * @param {*} timestamp Default to current timestamp if not provided. - * @param {*} note Optional field. Additional information about the event. - * @param {*} payload Field values from event args that are useful for - * debugging. Payload cross events will merge into one object. + * @param {*} params.timestamp Default to current timestamp if not provided. + * @param {*} params.note Optional field. Additional information about the event. + * @param {*} params.subPayloads Objects containing additional data that are + * obtain from to the Prebid events indexed by SUB_PAYLOAD_TYPES. */ pushEventToAllBidContexts({eventType, level, timestamp, note, subPayloads}) { // Create one event for each impression ID @@ -861,11 +862,10 @@ class BidContext { } /** - * Push a debug event to the context which will submitted to server for debugging. - * @param {*} eventInstance DebugEvent object. If it does not contain the same - * impid as the BidContext, event will be ignored. - * @param {*} subPayloads Object contains various payloads that obtained form - * the Prebid Event args. The payloads will be merged to the existing subPayloads. + * Push a debug event to the context which will be submitted to the server for debugging. + * @param {Object} params Object containing the following properties: + * @param {Event} params.eventInstance - DebugEvent object. If it does not contain the same impid as the BidContext, the event will be ignored. + * @param {Object|null} params.subPayloads - Object containing various payloads obtained from the Prebid Event args. The payloads will be merged into the existing subPayloads. */ pushEvent({eventInstance, subPayloads}) { if (!(eventInstance instanceof Event)) { @@ -1280,7 +1280,7 @@ export const utils = { /** * Recursively omit the given keys from the object. - * @param {*} obj - The object to process. + * @param {*} subPayloads - The payload objects index by their payload types. * @throws {Error} - If the given object is not valid. */ validateSubPayloads: function (subPayloads) { From 7ce67699be86cc020a850110853e76b37064fe32 Mon Sep 17 00:00:00 2001 From: zhihuiye Date: Mon, 13 Jan 2025 13:55:42 +0000 Subject: [PATCH 07/14] added the missing mobkoiBidAdapter md --- modules/mobkoiBidAdapter.md | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 modules/mobkoiBidAdapter.md diff --git a/modules/mobkoiBidAdapter.md b/modules/mobkoiBidAdapter.md new file mode 100644 index 00000000000..36601b025be --- /dev/null +++ b/modules/mobkoiBidAdapter.md @@ -0,0 +1,52 @@ +# HeaderBiddingAdapter + +# Overview + +``` +Module Name: Mobkoi Bidder Adapter +Module Type: Bidder Adapter +``` + +# Description + +Module that connects to Mobkoi Ad Server + +### Supported formats: +- Banner + +# Test Parameters +```js +const adUnits = [ + { + code: 'banner-ad', + mediaTypes: { + banner: { sizes: [300, 200] }, + }, + bids: [ + { + bidder: 'mobkoi', + }, + ], + }, +]; + +pbjs.que.push(function () { + pbjs.setBidderConfig({ + bidders: ['mobkoi'], + config: { + ortb2: { + site: { + publisher: { + id: 'module-test-publisher-id', + ext: { + adServerBaseUrl: 'https://adserver.dev.mobkoi.com', + }, + }, + }, + }, + }, + }); + + pbjs.addAdUnits(adUnits); +}); +``` From 18cdbf04b4340079bdc5f1dc458eb74634ac67a5 Mon Sep 17 00:00:00 2001 From: zhihuiye Date: Mon, 13 Jan 2025 14:03:23 +0000 Subject: [PATCH 08/14] small fix for our unit test --- modules/mobkoiBidAdapter.md | 5 +---- test/spec/modules/mobkoiBidAdapter_spec.js | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/mobkoiBidAdapter.md b/modules/mobkoiBidAdapter.md index 36601b025be..bf59585a3c8 100644 --- a/modules/mobkoiBidAdapter.md +++ b/modules/mobkoiBidAdapter.md @@ -1,11 +1,8 @@ -# HeaderBiddingAdapter - # Overview -``` Module Name: Mobkoi Bidder Adapter Module Type: Bidder Adapter -``` +Maintainer: platformteam@mobkoi.com # Description diff --git a/test/spec/modules/mobkoiBidAdapter_spec.js b/test/spec/modules/mobkoiBidAdapter_spec.js index 8a615d9ce1e..f71768e5b6b 100644 --- a/test/spec/modules/mobkoiBidAdapter_spec.js +++ b/test/spec/modules/mobkoiBidAdapter_spec.js @@ -40,6 +40,9 @@ describe('Mobkoi bidding Adapter', function () { const getConvertedBidRequest = () => ({ id: requestId, + cur: [ + 'USD' + ], imp: [{ id: bidId, }], From 35e35f7fe5ee763fe3b00a6013462f23329a3dc8 Mon Sep 17 00:00:00 2001 From: zeeye <56828723+zeeye@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:45:53 +0000 Subject: [PATCH 09/14] feat: max-970: Prebid.js Bidder Adapter: Retrieve Adapter Parameters from Bid Configuration Object (#8) Configuration Object](https://mobkoi.atlassian.net/browse/MAX-970) At this stage, we are only focused on bid win events, so there is no need for analytics adapter integration yet. To streamline the publisher's configuration for our custom bid adapter integration, we retrieve adapter parameters directly from the bid configuration object instead of using "bidderConfiguration." updated bid adapter doc --- modules/mobkoiBidAdapter.js | 77 ++++++++++++--- modules/mobkoiBidAdapter.md | 20 +--- test/spec/modules/mobkoiBidAdapter_spec.js | 110 ++++++++++++++------- 3 files changed, 144 insertions(+), 63 deletions(-) diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js index 14e676aba22..0a1a3eb6ab9 100644 --- a/modules/mobkoiBidAdapter.js +++ b/modules/mobkoiBidAdapter.js @@ -4,11 +4,17 @@ import { BANNER } from '../src/mediaTypes.js'; import { _each, replaceMacros, deepAccess, deepSetValue, logError } from '../src/utils.js'; const BIDDER_CODE = 'mobkoi'; -/** - * !IMPORTANT: This value must match the value in mobkoiAnalyticsAdapter.js - * The name of the parameter that the publisher can use to specify the ad server endpoint. - */ -const PARAM_NAME_AD_SERVER_BASE_URL = 'adServerBaseUrl'; +const GVL_ID = 898; + +const PUBLISHER_PARAMS = { + /** + * !IMPORTANT: This value must match the value in mobkoiAnalyticsAdapter.js + * The name of the parameter that the publisher can use to specify the ad server endpoint. + */ + PARAM_NAME_AD_SERVER_BASE_URL: 'adServerBaseUrl', + PARAM_NAME_PUBLISHER_ID: 'publisherId', +} + /** * The list of ORTB response fields that are used in the macros. Field * replacement is self-implemented in the adapter. Use dot-notated path for @@ -27,6 +33,8 @@ export const converter = ortbConverter({ const prebidBidRequest = context.bidRequests[0]; ortbRequest.id = utils.getOrtbId(prebidBidRequest); + deepSetValue(ortbRequest, 'site.publisher.id', utils.getPublisherId(prebidBidRequest)); + deepSetValue(ortbRequest, 'site.publisher.ext.adServerBaseUrl', utils.getAdServerEndpointBaseUrl(prebidBidRequest)); return ortbRequest; }, @@ -42,18 +50,37 @@ export const converter = ortbConverter({ export const spec = { code: BIDDER_CODE, supportedMediaTypes: [BANNER], + gvlid: GVL_ID, + /** + * Determines whether or not the given bid request is valid. + */ isBidRequestValid(bid) { - if (!deepAccess(bid, 'ortb2.site.publisher.id')) { - logError('The "ortb2.site.publisher.id" field is required in the bid request.' + - 'Please set it via the "config.ortb2.site.publisher.id" field with pbjs.setBidderConfig.' + if ( + !deepAccess(bid, `params.${PUBLISHER_PARAMS.PARAM_NAME_PUBLISHER_ID}`) && + !deepAccess(bid, 'ortb2.site.publisher.id') + ) { + logError(`The ${PUBLISHER_PARAMS.PARAM_NAME_PUBLISHER_ID} field is required in the bid request. ` + + 'Please follow the setup guideline to set the publisher ID field.' + ); + return false; + } + + if ( + !deepAccess(bid, `params.${PUBLISHER_PARAMS.PARAM_NAME_AD_SERVER_BASE_URL}`) && + !deepAccess(bid, 'ortb2.site.publisher.ext.adServerBaseUrl')) { + logError( + `The "${PUBLISHER_PARAMS.PARAM_NAME_AD_SERVER_BASE_URL}" field is required in the bid request. ` + + 'Please follow the setup guideline to set the field.' ); return false; } return true; }, - + /** + * Make a server request from the list of BidRequests. + */ buildRequests(prebidBidRequests, prebidBidderRequest) { const adServerEndpoint = utils.getAdServerEndpointBaseUrl(prebidBidderRequest) + '/bid'; @@ -69,7 +96,9 @@ export const spec = { }), }; }, - + /** + * Unpack the response from the server into a list of bids. + */ interpretResponse(serverResponse, customBidRequest) { if (!serverResponse.body) return []; @@ -85,7 +114,6 @@ export const spec = { registerBidder(spec); export const utils = { - /** * !IMPORTANT: Make sure the implementation of this function matches getAdServerEndpointBaseUrl * in both adapters. @@ -96,16 +124,26 @@ export const utils = { * @throws {Error} If the ORTB ID cannot be found in the given */ getAdServerEndpointBaseUrl (bid) { - const ortbPath = `site.publisher.ext.${PARAM_NAME_AD_SERVER_BASE_URL}`; + // (begin) Fields that would be automatically set if the publisher set it via pbjs.setBidderConfig. + const ortbPath = `site.publisher.ext.${PUBLISHER_PARAMS.PARAM_NAME_AD_SERVER_BASE_URL}`; const prebidPath = `ortb2.${ortbPath}`; + // (end) + + // (begin) Fields that would be set by the publisher in the bid + // configuration object in ad unit. + const paramPath = `params.${PUBLISHER_PARAMS.PARAM_NAME_AD_SERVER_BASE_URL}`; + const bidRequestFirstBidParam = `bids.0.${paramPath}`; + // (end) const adServerBaseUrl = + deepAccess(bid, paramPath) || + deepAccess(bid, bidRequestFirstBidParam) || deepAccess(bid, prebidPath) || deepAccess(bid, ortbPath); if (!adServerBaseUrl) { throw new Error('Failed to find the Ad Server Base URL in the given object. ' + - `Please set it via the "${prebidPath}" field with pbjs.setBidderConfig.\n` + + `Please follow the setup documentation to set "${PUBLISHER_PARAMS.PARAM_NAME_AD_SERVER_BASE_URL}".\n` + 'Given Object:\n' + JSON.stringify(bid, null, 2) ); @@ -123,10 +161,21 @@ export const utils = { * @throws {Error} If the publisher ID is not found in the given object. */ getPublisherId: function (prebidBidRequestOrOrtbBidRequest) { + // (begin) Fields that would be automatically set if the publisher set it + // via pbjs.setBidderConfig. const ortbPath = 'site.publisher.id'; const prebidPath = `ortb2.${ortbPath}`; + // (end) + + // (begin) Fields that would be set by the publisher in the bid + // configuration object in ad unit. + const paramPath = 'params.publisherId'; + const bidRequestFirstBidParam = `bids.0.${paramPath}`; + // (end) const publisherId = + deepAccess(prebidBidRequestOrOrtbBidRequest, paramPath) || + deepAccess(prebidBidRequestOrOrtbBidRequest, bidRequestFirstBidParam) || deepAccess(prebidBidRequestOrOrtbBidRequest, prebidPath) || deepAccess(prebidBidRequestOrOrtbBidRequest, ortbPath); @@ -135,7 +184,7 @@ export const utils = { 'Failed to obtain publisher ID from the given object. ' + `Please set it via the "${prebidPath}" field with pbjs.setBidderConfig.\n` + 'Given object:\n' + - JSON.stringify(prebidBidRequestOrOrtbBidRequest, null, 2) + JSON.stringify({functionParam: prebidBidRequestOrOrtbBidRequest}, null, 3) ); } diff --git a/modules/mobkoiBidAdapter.md b/modules/mobkoiBidAdapter.md index bf59585a3c8..84f0cae2483 100644 --- a/modules/mobkoiBidAdapter.md +++ b/modules/mobkoiBidAdapter.md @@ -22,28 +22,16 @@ const adUnits = [ bids: [ { bidder: 'mobkoi', + params: { + publisherId: 'module-test-publisher-id', + adServerBaseUrl: 'https://adserver.maximus.mobkoi.com', + } }, ], }, ]; pbjs.que.push(function () { - pbjs.setBidderConfig({ - bidders: ['mobkoi'], - config: { - ortb2: { - site: { - publisher: { - id: 'module-test-publisher-id', - ext: { - adServerBaseUrl: 'https://adserver.dev.mobkoi.com', - }, - }, - }, - }, - }, - }); - pbjs.addAdUnits(adUnits); }); ``` diff --git a/test/spec/modules/mobkoiBidAdapter_spec.js b/test/spec/modules/mobkoiBidAdapter_spec.js index f71768e5b6b..8159a7a2211 100644 --- a/test/spec/modules/mobkoiBidAdapter_spec.js +++ b/test/spec/modules/mobkoiBidAdapter_spec.js @@ -1,20 +1,20 @@ import {spec, utils} from 'modules/mobkoiBidAdapter.js'; describe('Mobkoi bidding Adapter', function () { - const adServerBaseUrl = 'http://adServerBaseUrl'; - const requestId = 'test-request-id' - const publisherId = 'mobkoiPublisherId' - const bidId = 'test-bid-id' + const testAdServerBaseUrl = 'http://test.adServerBaseUrl.com'; + const testRequestId = 'test-request-id' + const testPublisherId = 'mobkoiPublisherId' + const testBidId = 'test-bid-id' const bidderCode = 'mobkoi' - const transactionId = 'test-transaction-id' - const adUnitId = 'test-ad-unit-id' - const auctionId = 'test-auction-id' + const testTransactionId = 'test-transaction-id' + const testAdUnitId = 'test-ad-unit-id' + const testAuctionId = 'test-auction-id' const getOrtb2 = () => ({ site: { publisher: { - id: publisherId, - ext: { adServerBaseUrl } + id: testPublisherId, + ext: { adServerBaseUrl: testAdServerBaseUrl } } } }) @@ -22,29 +22,30 @@ describe('Mobkoi bidding Adapter', function () { const getBidRequest = () => ({ bidder: bidderCode, adUnitCode: 'banner-ad', - transactionId, - adUnitId, - bidId: bidId, - bidderRequestId: requestId, - auctionId, - ortb2: getOrtb2() + transactionId: testTransactionId, + adUnitId: testAdUnitId, + bidId: testBidId, + bidderRequestId: testRequestId, + auctionId: testAuctionId, + ortb2: getOrtb2(), + params: { + publisherId: testPublisherId, + adServerBaseUrl: testAdServerBaseUrl + } }) const getBidderRequest = () => ({ bidderCode, - auctionId, - bidderRequestId: requestId, + auctionId: testAuctionId, + bidderRequestId: testRequestId, bids: [getBidRequest()], ortb2: getOrtb2() }) const getConvertedBidRequest = () => ({ - id: requestId, - cur: [ - 'USD' - ], + id: testRequestId, imp: [{ - id: bidId, + id: testBidId, }], ...getOrtb2(), test: 0 @@ -56,15 +57,15 @@ describe('Mobkoi bidding Adapter', function () { const getBidderResponse = () => ({ body: { - id: bidId, + id: testBidId, cur: 'USD', seatbid: [ { seat: 'mobkoi_debug', bid: [ { - id: bidId, - impid: bidId, + id: testBidId, + impid: testBidId, cid: 'campaign_1', crid: 'creative_1', price: 1, @@ -94,17 +95,41 @@ describe('Mobkoi bidding Adapter', function () { bid = getBidderRequest().bids[0]; }); - it('should return true when publisher id exists in ortb2', function () { + it('should return true when publisher id only exists in ortb2', function () { + delete bid.params.publisherId; + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return true when publisher ID only exists in ad unit params', function () { + delete bid.ortb2.site.publisher.id; + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return true when adServerBaseUrl only exists in ortb2', function () { + delete bid.params.adServerBaseUrl; expect(spec.isBidRequestValid(bid)).to.equal(true); }); - it('should return false when publisher id is missing', function () { + it('should return true when adServerBaseUrl only exists in ad unit params', function () { + delete bid.ortb2.site.publisher.ext.adServerBaseUrl; + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return false when publisher id is missing both in ad unit params and ortb2', function () { delete bid.ortb2.site.publisher.id; + delete bid.params.publisherId; expect(spec.isBidRequestValid(bid)).to.equal(false); }); - it('should return false when publisher id is empty', function () { + it('should return false when publisher id is empty in ad unit params and ortb2', function () { bid.ortb2.site.publisher.id = ''; + bid.params.publisherId = ''; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when adServerBaseUrl is missing in ad unit params and ortb2', function () { + delete bid.ortb2.site.publisher.ext.adServerBaseUrl; + delete bid.params.adServerBaseUrl; expect(spec.isBidRequestValid(bid)).to.equal(false); }); }) @@ -119,7 +144,7 @@ describe('Mobkoi bidding Adapter', function () { it('should return valid request object with correct structure', function () { const request = spec.buildRequests(bidderRequest.bids, bidderRequest); - const expectedUrl = adServerBaseUrl + '/bid'; + const expectedUrl = testAdServerBaseUrl + '/bid'; expect(request.method).to.equal('POST'); expect(request.options.contentType).to.equal('application/json'); @@ -135,14 +160,31 @@ describe('Mobkoi bidding Adapter', function () { expect(ortbData.site.publisher.id).to.equal(bidderRequest.ortb2.site.publisher.id); }); - it('should throw error when adServerBaseUrl is missing', function () { + it('should obtain publisher ID from ad unit params if the value does not exist in ortb2.', function () { + delete bidderRequest.ortb2.site.publisher.id; + const request = spec.buildRequests(bidderRequest.bids, bidderRequest); + const ortbData = request.data; + + expect(ortbData.site.publisher.id).to.equal(bidderRequest.bids[0].params.publisherId); + }); + + it('should obtain adServerBaseUrl from ad unit params if the value does not exist in ortb2.', function () { delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; + const request = spec.buildRequests(bidderRequest.bids, bidderRequest); + const ortbData = request.data; + + expect(ortbData.site.publisher.ext.adServerBaseUrl).to.equal(bidderRequest.bids[0].params.adServerBaseUrl); + }); + + it('should throw error when adServerBaseUrl is missing both in ortb2 and bid params', function () { + delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; + delete bidderRequest.bids[0].params.adServerBaseUrl; expect(() => { spec.buildRequests(bidderRequest.bids, bidderRequest); }).to.throw(); }); - }) + }); describe('interpretResponse', function () { let bidderRequest, bidRequest, bidderResponse; @@ -184,11 +226,12 @@ describe('Mobkoi bidding Adapter', function () { describe('getAdServerEndpointBaseUrl', function () { it('should return the adServerBaseUrl from the given object', function () { expect(utils.getAdServerEndpointBaseUrl(bidderRequest)) - .to.equal(adServerBaseUrl); + .to.equal(testAdServerBaseUrl); }); it('should throw error when adServerBaseUrl is missing', function () { delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; + delete bidderRequest.bids[0].params.adServerBaseUrl; expect(() => { utils.getAdServerEndpointBaseUrl(bidderRequest); @@ -203,6 +246,7 @@ describe('Mobkoi bidding Adapter', function () { it('should throw error when publisherId is missing', function () { delete bidderRequest.ortb2.site.publisher.id; + delete bidderRequest.bids[0].params.publisherId; expect(() => { utils.getPublisherId(bidderRequest); }).to.throw(); @@ -255,7 +299,7 @@ describe('Mobkoi bidding Adapter', function () { bid.lurl = '${BIDDING_API_BASE_URL}/loss?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}'; bid.adm = '
${AUCTION_PRICE}${AUCTION_CURRENCY}${AUCTION_IMP_ID}${AUCTION_BID_ID}${CAMPAIGN_ID}${CREATIVE_ID}${PUBLISHER_ID}${ORTB_ID}${BIDDING_API_BASE_URL}
'; - const BIDDING_API_BASE_URL = adServerBaseUrl; + const BIDDING_API_BASE_URL = testAdServerBaseUrl; const AUCTION_CURRENCY = bidderResponse.body.cur; const AUCTION_BID_ID = bidderRequest.auctionId; const AUCTION_PRICE = bid.price; From 0e21d3596f73638497212bf887be3c450b7a1c41 Mon Sep 17 00:00:00 2001 From: zeeye <56828723+zeeye@users.noreply.github.com> Date: Tue, 28 Jan 2025 15:25:39 +0000 Subject: [PATCH 10/14] feat: max-956: We need the placement ID from Tag and HB Connector to be past to the AdServer (#9) ### [We need the placement ID from Tag and HB Connector to be past to the AdServer](https://mobkoi.atlassian.net/browse/MAX-956) --- modules/mobkoiBidAdapter.js | 65 ++++++++++++------- modules/mobkoiBidAdapter.md | 18 +++++- test/spec/modules/mobkoiBidAdapter_spec.js | 73 +++++++++------------- 3 files changed, 90 insertions(+), 66 deletions(-) diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js index 0a1a3eb6ab9..31286d1e8fc 100644 --- a/modules/mobkoiBidAdapter.js +++ b/modules/mobkoiBidAdapter.js @@ -5,6 +5,7 @@ import { _each, replaceMacros, deepAccess, deepSetValue, logError } from '../src const BIDDER_CODE = 'mobkoi'; const GVL_ID = 898; +export const DEFAULT_AD_SERVER_BASE_URL = 'https://adserver.maximus.mobkoi.com'; const PUBLISHER_PARAMS = { /** @@ -13,6 +14,7 @@ const PUBLISHER_PARAMS = { */ PARAM_NAME_AD_SERVER_BASE_URL: 'adServerBaseUrl', PARAM_NAME_PUBLISHER_ID: 'publisherId', + PARAM_NAME_PLACEMENT_ID: 'placementId', } /** @@ -35,6 +37,8 @@ export const converter = ortbConverter({ ortbRequest.id = utils.getOrtbId(prebidBidRequest); deepSetValue(ortbRequest, 'site.publisher.id', utils.getPublisherId(prebidBidRequest)); deepSetValue(ortbRequest, 'site.publisher.ext.adServerBaseUrl', utils.getAdServerEndpointBaseUrl(prebidBidRequest)); + // We only support one impression per request. + deepSetValue(ortbRequest, 'imp.0.tagid', utils.getPlacementId(prebidBidRequest)); return ortbRequest; }, @@ -67,12 +71,10 @@ export const spec = { } if ( - !deepAccess(bid, `params.${PUBLISHER_PARAMS.PARAM_NAME_AD_SERVER_BASE_URL}`) && - !deepAccess(bid, 'ortb2.site.publisher.ext.adServerBaseUrl')) { - logError( - `The "${PUBLISHER_PARAMS.PARAM_NAME_AD_SERVER_BASE_URL}" field is required in the bid request. ` + - 'Please follow the setup guideline to set the field.' - ); + !deepAccess(bid, `params.${PUBLISHER_PARAMS.PARAM_NAME_PLACEMENT_ID}`) + ) { + logError(`The ${PUBLISHER_PARAMS.PARAM_NAME_PLACEMENT_ID} field is required in the bid request. ` + + 'Please follow the setup guideline to set the placement ID field.') return false; } @@ -121,35 +123,56 @@ export const utils = { * @param {*} bid Prebid Bidder Request Object or Prebid Bid Response/Request * or ORTB Request/Response Object * @returns {string} The Ad Server Base URL - * @throws {Error} If the ORTB ID cannot be found in the given */ getAdServerEndpointBaseUrl (bid) { - // (begin) Fields that would be automatically set if the publisher set it via pbjs.setBidderConfig. + // Fields that would be automatically set if the publisher set it via pbjs.setBidderConfig. const ortbPath = `site.publisher.ext.${PUBLISHER_PARAMS.PARAM_NAME_AD_SERVER_BASE_URL}`; const prebidPath = `ortb2.${ortbPath}`; - // (end) - // (begin) Fields that would be set by the publisher in the bid + // Fields that would be set by the publisher in the bid // configuration object in ad unit. const paramPath = `params.${PUBLISHER_PARAMS.PARAM_NAME_AD_SERVER_BASE_URL}`; const bidRequestFirstBidParam = `bids.0.${paramPath}`; - // (end) const adServerBaseUrl = deepAccess(bid, paramPath) || deepAccess(bid, bidRequestFirstBidParam) || deepAccess(bid, prebidPath) || - deepAccess(bid, ortbPath); + deepAccess(bid, ortbPath) || + DEFAULT_AD_SERVER_BASE_URL; - if (!adServerBaseUrl) { - throw new Error('Failed to find the Ad Server Base URL in the given object. ' + - `Please follow the setup documentation to set "${PUBLISHER_PARAMS.PARAM_NAME_AD_SERVER_BASE_URL}".\n` + - 'Given Object:\n' + - JSON.stringify(bid, null, 2) + return adServerBaseUrl; + }, + + /** + * Extract the placement ID from the given object. + * @param {*} prebidBidRequestOrOrtbBidRequest + * @returns string + * @throws {Error} If the placement ID is not found in the given object. + */ + getPlacementId: function (prebidBidRequestOrOrtbBidRequest) { + // Fields that would be set by the publisher in the bid configuration object in ad unit. + const paramPath = 'params.placementId'; + const bidRequestFirstBidParam = `bids.0.${paramPath}`; + + // ORTB path for placement ID + const ortbPath = 'imp.0.tagid'; + + const placementId = + deepAccess(prebidBidRequestOrOrtbBidRequest, paramPath) || + deepAccess(prebidBidRequestOrOrtbBidRequest, bidRequestFirstBidParam) || + deepAccess(prebidBidRequestOrOrtbBidRequest, ortbPath); + + if (!placementId) { + throw new Error( + 'Failed to obtain placement ID from the given object. ' + + `Please set it via the "${paramPath}" field in the bid configuration.\n` + + 'Given object:\n' + + JSON.stringify({functionParam: prebidBidRequestOrOrtbBidRequest}, null, 3) ); } - return adServerBaseUrl; + return placementId; }, /** @@ -161,17 +184,15 @@ export const utils = { * @throws {Error} If the publisher ID is not found in the given object. */ getPublisherId: function (prebidBidRequestOrOrtbBidRequest) { - // (begin) Fields that would be automatically set if the publisher set it + // Fields that would be automatically set if the publisher set it // via pbjs.setBidderConfig. const ortbPath = 'site.publisher.id'; const prebidPath = `ortb2.${ortbPath}`; - // (end) - // (begin) Fields that would be set by the publisher in the bid + // Fields that would be set by the publisher in the bid // configuration object in ad unit. const paramPath = 'params.publisherId'; const bidRequestFirstBidParam = `bids.0.${paramPath}`; - // (end) const publisherId = deepAccess(prebidBidRequestOrOrtbBidRequest, paramPath) || diff --git a/modules/mobkoiBidAdapter.md b/modules/mobkoiBidAdapter.md index 84f0cae2483..e5c6c3734ab 100644 --- a/modules/mobkoiBidAdapter.md +++ b/modules/mobkoiBidAdapter.md @@ -24,7 +24,8 @@ const adUnits = [ bidder: 'mobkoi', params: { publisherId: 'module-test-publisher-id', - adServerBaseUrl: 'https://adserver.maximus.mobkoi.com', + placementId: 'moudle-test-placement-id', + adServerBaseUrl: 'https://not.an.adserver.endpoint.com', } }, ], @@ -35,3 +36,18 @@ pbjs.que.push(function () { pbjs.addAdUnits(adUnits); }); ``` + + +# Serve Prebid.js Locally + +To serve Prebid.js locally with specific modules, you can use the following command: + +```sh +gulp serve-fast --modules=consentManagementTcf,tcfControl,mobkoiBidAdapter +``` + +# Run bid adapter test locally + +```sh +gulp test --file=test/spec/modules/mobkoiBidAdapter_spec.js +``` diff --git a/test/spec/modules/mobkoiBidAdapter_spec.js b/test/spec/modules/mobkoiBidAdapter_spec.js index 8159a7a2211..31ce715992a 100644 --- a/test/spec/modules/mobkoiBidAdapter_spec.js +++ b/test/spec/modules/mobkoiBidAdapter_spec.js @@ -1,14 +1,19 @@ -import {spec, utils} from 'modules/mobkoiBidAdapter.js'; +import { + spec, + utils, + DEFAULT_AD_SERVER_BASE_URL +} from 'modules/mobkoiBidAdapter.js'; describe('Mobkoi bidding Adapter', function () { const testAdServerBaseUrl = 'http://test.adServerBaseUrl.com'; - const testRequestId = 'test-request-id' - const testPublisherId = 'mobkoiPublisherId' - const testBidId = 'test-bid-id' - const bidderCode = 'mobkoi' - const testTransactionId = 'test-transaction-id' - const testAdUnitId = 'test-ad-unit-id' - const testAuctionId = 'test-auction-id' + const testRequestId = 'test-request-id'; + const testPublisherId = 'mobkoiPublisherId'; + const testPlacementId = 'mobkoiPlacementId'; + const testBidId = 'test-bid-id'; + const bidderCode = 'mobkoi'; + const testTransactionId = 'test-transaction-id'; + const testAdUnitId = 'test-ad-unit-id'; + const testAuctionId = 'test-auction-id'; const getOrtb2 = () => ({ site: { @@ -30,7 +35,8 @@ describe('Mobkoi bidding Adapter', function () { ortb2: getOrtb2(), params: { publisherId: testPublisherId, - adServerBaseUrl: testAdServerBaseUrl + adServerBaseUrl: testAdServerBaseUrl, + placementId: testPlacementId } }) @@ -46,6 +52,7 @@ describe('Mobkoi bidding Adapter', function () { id: testRequestId, imp: [{ id: testBidId, + tagid: testPlacementId, }], ...getOrtb2(), test: 0 @@ -100,18 +107,12 @@ describe('Mobkoi bidding Adapter', function () { expect(spec.isBidRequestValid(bid)).to.equal(true); }); - it('should return true when publisher ID only exists in ad unit params', function () { - delete bid.ortb2.site.publisher.id; - expect(spec.isBidRequestValid(bid)).to.equal(true); - }); - - it('should return true when adServerBaseUrl only exists in ortb2', function () { - delete bid.params.adServerBaseUrl; + it('should return true when placement id exist in ad unit params', function () { expect(spec.isBidRequestValid(bid)).to.equal(true); }); - it('should return true when adServerBaseUrl only exists in ad unit params', function () { - delete bid.ortb2.site.publisher.ext.adServerBaseUrl; + it('should return true when publisher ID only exists in ad unit params', function () { + delete bid.ortb2.site.publisher.id; expect(spec.isBidRequestValid(bid)).to.equal(true); }); @@ -121,18 +122,17 @@ describe('Mobkoi bidding Adapter', function () { expect(spec.isBidRequestValid(bid)).to.equal(false); }); - it('should return false when publisher id is empty in ad unit params and ortb2', function () { - bid.ortb2.site.publisher.id = ''; - bid.params.publisherId = ''; + it('should return false when placement id is missing in ad unit params', function () { + delete bid.params.placementId; expect(spec.isBidRequestValid(bid)).to.equal(false); }); - it('should return false when adServerBaseUrl is missing in ad unit params and ortb2', function () { - delete bid.ortb2.site.publisher.ext.adServerBaseUrl; - delete bid.params.adServerBaseUrl; + it('should return false when publisher id is empty in ad unit params and ortb2', function () { + bid.ortb2.site.publisher.id = ''; + bid.params.publisherId = ''; expect(spec.isBidRequestValid(bid)).to.equal(false); }); - }) + }); describe('buildRequests', function () { let bidderRequest, convertedBidRequest; @@ -142,16 +142,6 @@ describe('Mobkoi bidding Adapter', function () { convertedBidRequest = getConvertedBidRequest(); }); - it('should return valid request object with correct structure', function () { - const request = spec.buildRequests(bidderRequest.bids, bidderRequest); - const expectedUrl = testAdServerBaseUrl + '/bid'; - - expect(request.method).to.equal('POST'); - expect(request.options.contentType).to.equal('application/json'); - expect(request.url).to.equal(expectedUrl); - expect(request.data).to.deep.equal(convertedBidRequest); - }); - it('should include converted ORTB data in request', function () { const request = spec.buildRequests(bidderRequest.bids, bidderRequest); const ortbData = request.data; @@ -176,13 +166,12 @@ describe('Mobkoi bidding Adapter', function () { expect(ortbData.site.publisher.ext.adServerBaseUrl).to.equal(bidderRequest.bids[0].params.adServerBaseUrl); }); - it('should throw error when adServerBaseUrl is missing both in ortb2 and bid params', function () { + it('should use the pro server url when the ad server base url is not set', function () { delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; delete bidderRequest.bids[0].params.adServerBaseUrl; - expect(() => { - spec.buildRequests(bidderRequest.bids, bidderRequest); - }).to.throw(); + const request = spec.buildRequests(bidderRequest.bids, bidderRequest); + expect(request.url).to.equal(DEFAULT_AD_SERVER_BASE_URL + '/bid'); }); }); @@ -229,13 +218,11 @@ describe('Mobkoi bidding Adapter', function () { .to.equal(testAdServerBaseUrl); }); - it('should throw error when adServerBaseUrl is missing', function () { + it('should return default prod ad server url when adServerBaseUrl is missing in params and ortb2', function () { delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; delete bidderRequest.bids[0].params.adServerBaseUrl; - expect(() => { - utils.getAdServerEndpointBaseUrl(bidderRequest); - }).to.throw(); + expect(utils.getAdServerEndpointBaseUrl(bidderRequest)).to.equal(DEFAULT_AD_SERVER_BASE_URL); }); }) From 25127b079585b65f49a8ef34331303c4e85cc482 Mon Sep 17 00:00:00 2001 From: zeeye <56828723+zeeye@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:58:40 +0000 Subject: [PATCH 11/14] feat: max-945: [Implementation] Cookie sync client-side with 1 or more SSPs (#7) > Related PRs > https://github.com/mobkoi/adserver/pull/25#pullrequestreview-2584590549 > https://github.com/mobkoi/bidding-testbed/pull/2#pullrequestreview-2584580407 > https://github.com/mobkoi/Prebid.js/pull/7 > https://github.com/mobkoi/render-lib/pull/5#pullrequestreview-2584630205 ## [[Implementation] Cookie sync client-side with 1 or more SSPs](https://mobkoi.atlassian.net/browse/MAX-945) Related to [this study on cookie sync](https://mobkoi.atlassian.net/wiki/spaces/SD/pages/230096897/Cookie+sync+-+Id+matching+table+design). Objective Implement user id mapping on the client side and document the cookie sync workflow. Since this is client-side, the matching table will be local to the user and will only contain the current user mapping. --- modules/mobkoiBidAdapter.js | 3 +- modules/mobkoiIdSystem.js | 145 +++++++++++++++ modules/mobkoiIdSystem.md | 38 ++++ test/spec/modules/mobkoiIdSystem_spec.js | 224 +++++++++++++++++++++++ 4 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 modules/mobkoiIdSystem.js create mode 100644 modules/mobkoiIdSystem.md create mode 100644 test/spec/modules/mobkoiIdSystem_spec.js diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js index 31286d1e8fc..09528d2f7df 100644 --- a/modules/mobkoiBidAdapter.js +++ b/modules/mobkoiBidAdapter.js @@ -39,6 +39,7 @@ export const converter = ortbConverter({ deepSetValue(ortbRequest, 'site.publisher.ext.adServerBaseUrl', utils.getAdServerEndpointBaseUrl(prebidBidRequest)); // We only support one impression per request. deepSetValue(ortbRequest, 'imp.0.tagid', utils.getPlacementId(prebidBidRequest)); + deepSetValue(ortbRequest, 'user.id', context.bidRequests[0].userId?.mobkoiId || null); return ortbRequest; }, @@ -267,7 +268,7 @@ export const utils = { CREATIVE_ID: ortbBidResponse.crid, CAMPAIGN_ID: ortbBidResponse.cid, ORTB_ID: ortbBidResponse.id, - PUBLISHER_ID: deepAccess(context, 'bidRequest.ortb2.site.publisher.id') || deepAccess(context, 'bidderRequest.ortb2.site.publisher.id') + PUBLISHER_ID: utils.getPublisherId(context.bidderRequest), }; _each(ORTB_RESPONSE_FIELDS_SUPPORT_MACROS, ortbField => { diff --git a/modules/mobkoiIdSystem.js b/modules/mobkoiIdSystem.js new file mode 100644 index 00000000000..47ab4279b1a --- /dev/null +++ b/modules/mobkoiIdSystem.js @@ -0,0 +1,145 @@ +/** + * This module adds mobkoiId support to the User ID module + * The {@link module:modules/userId} module is required. + * @module modules/mobkoiIdSystem + * @requires module:modules/userId + */ + +import { submodule } from '../src/hook.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { MODULE_TYPE_UID } from '../src/activities/modules.js'; +import { logError, logInfo, deepAccess, insertUserSyncIframe } from '../src/utils.js'; + +const GVL_ID = 898; +const MODULE_NAME = 'mobkoiId'; +export const PROD_AD_SERVER_BASE_URL = 'https://adserver.maximus.mobkoi.com'; +export const EQUATIV_BASE_URL = 'https://sync.smartadserver.com'; +export const EQUATIV_NETWORK_ID = '5290'; +/** + * !IMPORTANT: This value must match the value in mobkoiAnalyticsAdapter.js + * The name of the parameter that the publisher can use to specify the ad server endpoint. + */ +const PARAM_NAME_AD_SERVER_BASE_URL = 'adServerBaseUrl'; + +export const storage = getStorageManager({ moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME }); + +export const mobkoiIdSubmodule = { + name: MODULE_NAME, + + decode(value) { + return value ? { [MODULE_NAME]: value } : undefined; + }, + + gvlid: GVL_ID, + + getId(userSyncOptions, gdprConsent) { + logInfo('Getting Equativ SAS ID.'); + + if (!storage.cookiesAreEnabled()) { + logError('Cookies are not enabled. Module will not work.'); + return { + id: null + }; + } + + const storageName = userSyncOptions && userSyncOptions.storage && userSyncOptions.storage.name; + if (!storageName) { + logError('Storage name is not defined. Module will not work.'); + return { + id: null + }; + } + + const existingId = storage.getCookie(storageName); + + if (existingId) { + logInfo(`Found "${storageName}" from local cookie: "${existingId}"`); + return { id: existingId }; + } + + logInfo(`Cannot found "${storageName}" in local cookie with name.`); + return { + callback: () => { + return new Promise((resolve, _reject) => { + utils.requestEquativSasId( + userSyncOptions, + gdprConsent, + (sasId) => { + if (!sasId) { + logError('Equativ SAS ID is empty'); + resolve({ id: null }); + return; + } + + logInfo(`Fetched Equativ SAS ID: "${sasId}"`); + storage.setCookie(storageName, sasId, userSyncOptions.storage.expires); + logInfo(`Stored Equativ SAS ID in local cookie with name: "${storageName}"`); + resolve({ id: sasId }); + } + ); + }); + } + }; + }, +}; + +submodule('userId', mobkoiIdSubmodule); + +export const utils = { + requestEquativSasId(syncUserOptions, gdprConsent, onCompleteCallback) { + logInfo('Start requesting Equativ SAS ID'); + const adServerBaseUrl = deepAccess( + syncUserOptions, + `params.${PARAM_NAME_AD_SERVER_BASE_URL}`) || PROD_AD_SERVER_BASE_URL; + + const equativPixelUrl = utils.buildEquativPixelUrl(syncUserOptions, gdprConsent); + logInfo('Equativ SAS ID request URL:', equativPixelUrl); + + const url = adServerBaseUrl + '/pixeliframe?' + + 'pixelUrl=' + encodeURIComponent(equativPixelUrl) + + '&cookieName=sas_uid'; + + /** + * Listen for messages from the iframe + */ + window.addEventListener('message', function(event) { + switch (event.data.type) { + case 'MOBKOI_PIXEL_SYNC_COMPLETE': + const sasUid = event.data.syncData; + logInfo('Parent window Sync completed. SAS ID:', sasUid); + onCompleteCallback(sasUid); + break; + case 'MOBKOI_PIXEL_SYNC_ERROR': + logError('Parent window Sync failed:', event.data.error); + onCompleteCallback(null); + break; + } + }); + + insertUserSyncIframe(url, () => { + logInfo('insertUserSyncIframe loaded'); + }); + + // Return the URL for testing purposes + return url; + }, + + /** + * Build a pixel URL that will be placed in an iframe to fetch the Equativ SAS ID + */ + buildEquativPixelUrl(syncUserOptions, gdprConsent) { + logInfo('Generating Equativ SAS ID request URL'); + const adServerBaseUrl = + deepAccess( + syncUserOptions, + `params.${PARAM_NAME_AD_SERVER_BASE_URL}`) || PROD_AD_SERVER_BASE_URL; + + const gdprConsentString = gdprConsent && gdprConsent.gdprApplies ? gdprConsent.consentString : ''; + const smartServerUrl = EQUATIV_BASE_URL + '/getuid?' + + `url=` + encodeURIComponent(`${adServerBaseUrl}/getPixel?value=`) + '[sas_uid]' + + `&gdpr_consent=${gdprConsentString}` + + `&nwid=${EQUATIV_NETWORK_ID}`; + + return smartServerUrl; + } +}; diff --git a/modules/mobkoiIdSystem.md b/modules/mobkoiIdSystem.md new file mode 100644 index 00000000000..b122cad213e --- /dev/null +++ b/modules/mobkoiIdSystem.md @@ -0,0 +1,38 @@ +## Mobkoi User ID Submodule + +For assistance setting up your module please contact us at platformteam@mobkoi.com. + +### Prebid Params + +Individual params may be set for the IDx Submodule. +``` +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'mobkoiId', + storage: { + name : 'mobkoi_uid', + type : 'cookie', + expires : 30 + } + }] + } +}); +``` +## Parameter Descriptions for the `userSync` Configuration Section +The below parameters apply only to the Mobkoi integration. + +| Param under usersync.userIds[] | Scope | Type | Description | Example | +| --- | --- | --- | --- | --- | +| name | Required | String | ID of the module - `"mobkoiId"` | `"mobkoiId"` | +| storage.name | Required | String | The name of the cookie local storage where the user ID will be stored. | `"mobkoi_uid"` | +| storage.type | Required | String | Must be "`cookie`". This is where the results of the user ID will be stored. | `"cookie"` | +| storage.expires | Required | Integer | How long (in days) the user ID information will be stored. | `30` | + +## Serving the Custom Build Locally + +To serve the custom build locally, use the following command: + +```sh +gulp serve-fast --modules=consentManagementTcf,tcfControl,mobkoiBidAdapter,mobkoiIdSystem,userId +``` diff --git a/test/spec/modules/mobkoiIdSystem_spec.js b/test/spec/modules/mobkoiIdSystem_spec.js new file mode 100644 index 00000000000..4ca5acec686 --- /dev/null +++ b/test/spec/modules/mobkoiIdSystem_spec.js @@ -0,0 +1,224 @@ +import sinon from 'sinon'; +import { + mobkoiIdSubmodule, + storage, + PROD_AD_SERVER_BASE_URL, + EQUATIV_NETWORK_ID, + utils as mobkoiUtils +} from 'modules/mobkoiIdSystem'; +import * as prebidUtils from 'src/utils'; + +const TEST_SAS_ID = 'test-sas-id'; +const TEST_AD_SERVER_BASE_URL = 'https://mocha.test.adserver.com'; +const TEST_CONSENT_STRING = 'test-consent-string'; + +function decodeFullUrl(url) { + return decodeURIComponent(url); +} + +describe('mobkoiIdSystem', function () { + let sandbox, + getCookieStub, + setCookieStub, + cookiesAreEnabledStub, + insertUserSyncIframeStub; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(prebidUtils, 'logInfo'); + sandbox.stub(prebidUtils, 'logError'); + + insertUserSyncIframeStub = sandbox.stub(prebidUtils, 'insertUserSyncIframe'); + getCookieStub = sandbox.stub(storage, 'getCookie'); + setCookieStub = sandbox.stub(storage, 'setCookie'); + cookiesAreEnabledStub = sandbox.stub(storage, 'cookiesAreEnabled'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('decode', function () { + it('should return undefined if value is empty', function () { + expect(mobkoiIdSubmodule.decode()).to.be.undefined; + }); + + it('should return an object with the module name as key if value is provided', function () { + const value = 'test-value'; + expect(mobkoiIdSubmodule.decode(value)).to.deep.equal({ mobkoiId: value }); + }); + }); + + describe('getId', function () { + const userSyncOptions = { + storage: { + type: 'cookie', + name: '_mobkoi_Id', + expires: 30, // days + } + }; + + it('should return null id if cookies are not enabled', function () { + cookiesAreEnabledStub.returns(false); + const result = mobkoiIdSubmodule.getId(userSyncOptions); + expect(result).to.deep.equal({ id: null }); + }); + + it('should return existing id from cookie if available in cookie', function () { + const testId = 'existing-id'; + cookiesAreEnabledStub.returns(true); + getCookieStub.returns(testId); + const result = mobkoiIdSubmodule.getId(userSyncOptions); + + expect(result).to.deep.equal({ id: testId }); + }); + + it('should return a callback function if id is not available in cookie', function () { + cookiesAreEnabledStub.returns(true); + getCookieStub.returns(null); + const result = mobkoiIdSubmodule.getId(userSyncOptions); + + expect(result).to.have.property('callback').that.is.a('function'); + }); + + it('should the callback function should return a SAS ID', function () { + cookiesAreEnabledStub.returns(true); + getCookieStub.returns(null); + + const requestEquativSasIdStub = sandbox.stub(mobkoiUtils, 'requestEquativSasId') + .callsFake((_syncUserOptions, _gdprConsent, onCompleteCallback) => { + onCompleteCallback(TEST_SAS_ID); + return TEST_SAS_ID; + }); + + const callback = mobkoiIdSubmodule.getId(userSyncOptions).callback; + return callback().then(result => { + expect(setCookieStub.calledOnce).to.be.true; + expect(result).to.deep.equal({ id: TEST_SAS_ID }); + expect(requestEquativSasIdStub.calledOnce).to.be.true; + }); + }); + }); + + describe('utils.requestEquativSasId', function () { + let buildEquativPixelUrlStub; + + beforeEach(function () { + buildEquativPixelUrlStub = sandbox.stub(mobkoiUtils, 'buildEquativPixelUrl'); + }); + + it('should call insertUserSyncIframe with the correctly encoded URL', function () { + const syncUserOptions = {}; + const gdprConsent = {}; + const onCompleteCallback = sinon.spy(); + const testPixelUrl = 'https://equativ.test.pixel.url?uid=[sas_uid]'; + buildEquativPixelUrlStub.returns(testPixelUrl); + + mobkoiUtils.requestEquativSasId(syncUserOptions, gdprConsent, onCompleteCallback); + + const expectedEncodedUrl = encodeURIComponent(testPixelUrl); + expect(insertUserSyncIframeStub.calledOnce).to.be.true; + expect(insertUserSyncIframeStub.firstCall.args[0]).to.include('pixelUrl=' + expectedEncodedUrl); + }); + }); + + describe('utils.buildEquativPixelUrl', function () { + it('should use the provided adServerBaseUrl URL from syncUserOptions', function () { + const gdprConsent = { + gdprApplies: true, + consentString: TEST_CONSENT_STRING + }; + const syncUserOptions = { + params: { + adServerBaseUrl: TEST_AD_SERVER_BASE_URL + } + }; + + const url = mobkoiUtils.buildEquativPixelUrl(syncUserOptions, gdprConsent); + const decodedUrl = decodeFullUrl(url); + + expect(decodedUrl).to.include(TEST_AD_SERVER_BASE_URL); + }); + + it('should use the PROD ad server endpoint if adServerBaseUrl is not provided', function () { + const syncUserOptions = {}; + const gdprConsent = { + gdprApplies: true, + consentString: TEST_CONSENT_STRING + }; + + const url = mobkoiUtils.buildEquativPixelUrl(syncUserOptions, gdprConsent); + const decodedUrl = decodeFullUrl(url); + + expect(decodedUrl).to.include(PROD_AD_SERVER_BASE_URL); + }); + + it('should contains the Equativ network ID', function () { + const syncUserOptions = {}; + const gdprConsent = { + gdprApplies: true, + consentString: TEST_CONSENT_STRING + }; + + const url = mobkoiUtils.buildEquativPixelUrl(syncUserOptions, gdprConsent); + + expect(url).to.include(`nwid=${EQUATIV_NETWORK_ID}`); + }); + + it('should contain a consent string', function () { + const syncUserOptions = { + params: { + adServerBaseUrl: TEST_AD_SERVER_BASE_URL + } + }; + const gdprConsent = { + gdprApplies: true, + consentString: TEST_CONSENT_STRING + }; + + const url = mobkoiUtils.buildEquativPixelUrl(syncUserOptions, gdprConsent); + const decodedUrl = decodeFullUrl(url); + + expect(decodedUrl).to.include( + `gdpr_consent=${TEST_CONSENT_STRING}` + ); + }); + + it('should set empty string to gdpr_consent when GDPR is not applies', function () { + const syncUserOptions = { + params: { + adServerBaseUrl: TEST_AD_SERVER_BASE_URL + } + }; + const gdprConsent = { + gdprApplies: true, + consentString: TEST_CONSENT_STRING + }; + + const url = mobkoiUtils.buildEquativPixelUrl(syncUserOptions, gdprConsent); + + expect(url).to.include( + 'gdpr_consent=' // no value + ); + }); + + it('should contain SAS ID marco', function () { + const syncUserOptions = { + params: { + adServerBaseUrl: TEST_AD_SERVER_BASE_URL + } + }; + const gdprConsent = { + gdprApplies: true, + consentString: TEST_CONSENT_STRING + }; + + const url = mobkoiUtils.buildEquativPixelUrl(syncUserOptions, gdprConsent); + const decodedUrl = decodeFullUrl(url); + + expect(decodedUrl).to.include( + 'value=[sas_uid]' + ); + }); + }); +}); From da3eff70182204957f8f3710394350632d0036eb Mon Sep 17 00:00:00 2001 From: zhihuiye Date: Tue, 11 Feb 2025 15:20:27 +0000 Subject: [PATCH 12/14] removed publisher ID from bid adapter --- modules/mobkoiBidAdapter.js | 50 ------------------------------------- 1 file changed, 50 deletions(-) diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js index 09528d2f7df..6cf576c8c3e 100644 --- a/modules/mobkoiBidAdapter.js +++ b/modules/mobkoiBidAdapter.js @@ -13,7 +13,6 @@ const PUBLISHER_PARAMS = { * The name of the parameter that the publisher can use to specify the ad server endpoint. */ PARAM_NAME_AD_SERVER_BASE_URL: 'adServerBaseUrl', - PARAM_NAME_PUBLISHER_ID: 'publisherId', PARAM_NAME_PLACEMENT_ID: 'placementId', } @@ -35,7 +34,6 @@ export const converter = ortbConverter({ const prebidBidRequest = context.bidRequests[0]; ortbRequest.id = utils.getOrtbId(prebidBidRequest); - deepSetValue(ortbRequest, 'site.publisher.id', utils.getPublisherId(prebidBidRequest)); deepSetValue(ortbRequest, 'site.publisher.ext.adServerBaseUrl', utils.getAdServerEndpointBaseUrl(prebidBidRequest)); // We only support one impression per request. deepSetValue(ortbRequest, 'imp.0.tagid', utils.getPlacementId(prebidBidRequest)); @@ -61,16 +59,6 @@ export const spec = { * Determines whether or not the given bid request is valid. */ isBidRequestValid(bid) { - if ( - !deepAccess(bid, `params.${PUBLISHER_PARAMS.PARAM_NAME_PUBLISHER_ID}`) && - !deepAccess(bid, 'ortb2.site.publisher.id') - ) { - logError(`The ${PUBLISHER_PARAMS.PARAM_NAME_PUBLISHER_ID} field is required in the bid request. ` + - 'Please follow the setup guideline to set the publisher ID field.' - ); - return false; - } - if ( !deepAccess(bid, `params.${PUBLISHER_PARAMS.PARAM_NAME_PLACEMENT_ID}`) ) { @@ -176,43 +164,6 @@ export const utils = { return placementId; }, - /** - * !IMPORTANT: Make sure the implementation of this function matches utils.getPublisherId in - * both adapters. - * Extract the publisher ID from the given object. - * @param {*} prebidBidRequestOrOrtbBidRequest - * @returns string - * @throws {Error} If the publisher ID is not found in the given object. - */ - getPublisherId: function (prebidBidRequestOrOrtbBidRequest) { - // Fields that would be automatically set if the publisher set it - // via pbjs.setBidderConfig. - const ortbPath = 'site.publisher.id'; - const prebidPath = `ortb2.${ortbPath}`; - - // Fields that would be set by the publisher in the bid - // configuration object in ad unit. - const paramPath = 'params.publisherId'; - const bidRequestFirstBidParam = `bids.0.${paramPath}`; - - const publisherId = - deepAccess(prebidBidRequestOrOrtbBidRequest, paramPath) || - deepAccess(prebidBidRequestOrOrtbBidRequest, bidRequestFirstBidParam) || - deepAccess(prebidBidRequestOrOrtbBidRequest, prebidPath) || - deepAccess(prebidBidRequestOrOrtbBidRequest, ortbPath); - - if (!publisherId) { - throw new Error( - 'Failed to obtain publisher ID from the given object. ' + - `Please set it via the "${prebidPath}" field with pbjs.setBidderConfig.\n` + - 'Given object:\n' + - JSON.stringify({functionParam: prebidBidRequestOrOrtbBidRequest}, null, 3) - ); - } - - return publisherId; - }, - /** * !IMPORTANT: Make sure the implementation of this function matches utils.getOrtbId in * mobkoiAnalyticsAdapter.js. @@ -268,7 +219,6 @@ export const utils = { CREATIVE_ID: ortbBidResponse.crid, CAMPAIGN_ID: ortbBidResponse.cid, ORTB_ID: ortbBidResponse.id, - PUBLISHER_ID: utils.getPublisherId(context.bidderRequest), }; _each(ORTB_RESPONSE_FIELDS_SUPPORT_MACROS, ortbField => { From 4291d3001a0182b47f1ba37a6dead732cfc4e227 Mon Sep 17 00:00:00 2001 From: zeeye <56828723+zeeye@users.noreply.github.com> Date: Fri, 14 Mar 2025 16:45:53 +0000 Subject: [PATCH 13/14] refractory: removed macro replacement (#11) --- modules/mobkoiBidAdapter.js | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js index 6cf576c8c3e..ec5337447ed 100644 --- a/modules/mobkoiBidAdapter.js +++ b/modules/mobkoiBidAdapter.js @@ -1,7 +1,7 @@ import { ortbConverter } from '../libraries/ortbConverter/converter.js'; import { registerBidder } from '../src/adapters/bidderFactory.js'; import { BANNER } from '../src/mediaTypes.js'; -import { _each, replaceMacros, deepAccess, deepSetValue, logError } from '../src/utils.js'; +import { deepAccess, deepSetValue, logError } from '../src/utils.js'; const BIDDER_CODE = 'mobkoi'; const GVL_ID = 898; @@ -16,14 +16,6 @@ const PUBLISHER_PARAMS = { PARAM_NAME_PLACEMENT_ID: 'placementId', } -/** - * The list of ORTB response fields that are used in the macros. Field - * replacement is self-implemented in the adapter. Use dot-notated path for - * nested fields. For example, 'ad.ext.adomain'. For more information, visit - * https://www.npmjs.com/package/dset and https://www.npmjs.com/package/dlv. - */ -const ORTB_RESPONSE_FIELDS_SUPPORT_MACROS = ['adm', 'nurl', 'lurl']; - export const converter = ortbConverter({ context: { netRevenue: true, @@ -42,8 +34,6 @@ export const converter = ortbConverter({ return ortbRequest; }, bidResponse(buildPrebidBidResponse, ortbBidResponse, context) { - utils.replaceAllMacrosInPlace(ortbBidResponse, context); - const prebidBid = buildPrebidBidResponse(ortbBidResponse, context); utils.addCustomFieldsToPrebidBidResponse(prebidBid, ortbBidResponse); return prebidBid; @@ -205,28 +195,4 @@ export const utils = { prebidBidResponse.ortbBidResponse = ortbBidResponse; prebidBidResponse.ortbId = ortbBidResponse.id; }, - - replaceAllMacrosInPlace(ortbBidResponse, context) { - const macros = { - // ORTB macros - AUCTION_PRICE: ortbBidResponse.price, - AUCTION_IMP_ID: ortbBidResponse.impid, - AUCTION_CURRENCY: ortbBidResponse.cur, - AUCTION_BID_ID: context.bidderRequest.auctionId, - - // Custom macros - BIDDING_API_BASE_URL: utils.getAdServerEndpointBaseUrl(context.bidderRequest), - CREATIVE_ID: ortbBidResponse.crid, - CAMPAIGN_ID: ortbBidResponse.cid, - ORTB_ID: ortbBidResponse.id, - }; - - _each(ORTB_RESPONSE_FIELDS_SUPPORT_MACROS, ortbField => { - deepSetValue( - ortbBidResponse, - ortbField, - replaceMacros(deepAccess(ortbBidResponse, ortbField), macros) - ); - }); - }, } From 16951a5b912bd7064216eaed08b9d87f4b84f3ed Mon Sep 17 00:00:00 2001 From: zhihuiye Date: Wed, 19 Mar 2025 17:27:19 +0000 Subject: [PATCH 14/14] fixed tests --- test/spec/modules/mobkoiBidAdapter_spec.js | 93 +--------------------- 1 file changed, 2 insertions(+), 91 deletions(-) diff --git a/test/spec/modules/mobkoiBidAdapter_spec.js b/test/spec/modules/mobkoiBidAdapter_spec.js index 31ce715992a..6d040e97bd9 100644 --- a/test/spec/modules/mobkoiBidAdapter_spec.js +++ b/test/spec/modules/mobkoiBidAdapter_spec.js @@ -7,7 +7,6 @@ import { describe('Mobkoi bidding Adapter', function () { const testAdServerBaseUrl = 'http://test.adServerBaseUrl.com'; const testRequestId = 'test-request-id'; - const testPublisherId = 'mobkoiPublisherId'; const testPlacementId = 'mobkoiPlacementId'; const testBidId = 'test-bid-id'; const bidderCode = 'mobkoi'; @@ -18,7 +17,6 @@ describe('Mobkoi bidding Adapter', function () { const getOrtb2 = () => ({ site: { publisher: { - id: testPublisherId, ext: { adServerBaseUrl: testAdServerBaseUrl } } } @@ -34,7 +32,6 @@ describe('Mobkoi bidding Adapter', function () { auctionId: testAuctionId, ortb2: getOrtb2(), params: { - publisherId: testPublisherId, adServerBaseUrl: testAdServerBaseUrl, placementId: testPlacementId } @@ -102,36 +99,14 @@ describe('Mobkoi bidding Adapter', function () { bid = getBidderRequest().bids[0]; }); - it('should return true when publisher id only exists in ortb2', function () { - delete bid.params.publisherId; - expect(spec.isBidRequestValid(bid)).to.equal(true); - }); - it('should return true when placement id exist in ad unit params', function () { expect(spec.isBidRequestValid(bid)).to.equal(true); }); - it('should return true when publisher ID only exists in ad unit params', function () { - delete bid.ortb2.site.publisher.id; - expect(spec.isBidRequestValid(bid)).to.equal(true); - }); - - it('should return false when publisher id is missing both in ad unit params and ortb2', function () { - delete bid.ortb2.site.publisher.id; - delete bid.params.publisherId; - expect(spec.isBidRequestValid(bid)).to.equal(false); - }); - it('should return false when placement id is missing in ad unit params', function () { delete bid.params.placementId; expect(spec.isBidRequestValid(bid)).to.equal(false); }); - - it('should return false when publisher id is empty in ad unit params and ortb2', function () { - bid.ortb2.site.publisher.id = ''; - bid.params.publisherId = ''; - expect(spec.isBidRequestValid(bid)).to.equal(false); - }); }); describe('buildRequests', function () { @@ -147,18 +122,9 @@ describe('Mobkoi bidding Adapter', function () { const ortbData = request.data; expect(ortbData.id).to.equal(bidderRequest.bidderRequestId); - expect(ortbData.site.publisher.id).to.equal(bidderRequest.ortb2.site.publisher.id); }); - it('should obtain publisher ID from ad unit params if the value does not exist in ortb2.', function () { - delete bidderRequest.ortb2.site.publisher.id; - const request = spec.buildRequests(bidderRequest.bids, bidderRequest); - const ortbData = request.data; - - expect(ortbData.site.publisher.id).to.equal(bidderRequest.bids[0].params.publisherId); - }); - - it('should obtain adServerBaseUrl from ad unit params if the value does not exist in ortb2.', function () { + it('should obtain adServerBaseUrl from ad unit params if the value does not exist in ortb2', function () { delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; const request = spec.buildRequests(bidderRequest.bids, bidderRequest); const ortbData = request.data; @@ -226,20 +192,6 @@ describe('Mobkoi bidding Adapter', function () { }); }) - describe('getPublisherId', function () { - it('should return the publisherId from the given object', function () { - expect(utils.getPublisherId(bidderRequest)).to.equal(bidderRequest.ortb2.site.publisher.id); - }); - - it('should throw error when publisherId is missing', function () { - delete bidderRequest.ortb2.site.publisher.id; - delete bidderRequest.bids[0].params.publisherId; - expect(() => { - utils.getPublisherId(bidderRequest); - }).to.throw(); - }); - }) - describe('getOrtbId', function () { it('should return the ortbId from the prebid request object (i.e bidderRequestId)', function () { expect(utils.getOrtbId(bidderRequest)).to.equal(bidderRequest.bidderRequestId); @@ -270,46 +222,5 @@ describe('Mobkoi bidding Adapter', function () { }).to.throw(); }); }) - - describe('replaceAllMacrosInPlace', function () { - let bidderResponse, bidRequest, bidderRequest; - - beforeEach(function () { - bidderRequest = getBidderRequest(); - bidRequest = spec.buildRequests(bidderRequest.bids, bidderRequest); - bidderResponse = getBidderResponse(); - }); - - it('should replace all macros in adm, nurl, and lurl fields', function () { - const bid = bidderResponse.body.seatbid[0].bid[0]; - bid.nurl = '${BIDDING_API_BASE_URL}/win?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}'; - bid.lurl = '${BIDDING_API_BASE_URL}/loss?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}'; - bid.adm = '
${AUCTION_PRICE}${AUCTION_CURRENCY}${AUCTION_IMP_ID}${AUCTION_BID_ID}${CAMPAIGN_ID}${CREATIVE_ID}${PUBLISHER_ID}${ORTB_ID}${BIDDING_API_BASE_URL}
'; - - const BIDDING_API_BASE_URL = testAdServerBaseUrl; - const AUCTION_CURRENCY = bidderResponse.body.cur; - const AUCTION_BID_ID = bidderRequest.auctionId; - const AUCTION_PRICE = bid.price; - const AUCTION_IMP_ID = bid.impid; - const CREATIVE_ID = bid.crid; - const CAMPAIGN_ID = bid.cid; - const PUBLISHER_ID = bidderRequest.ortb2.site.publisher.id; - const ORTB_ID = bidderResponse.body.id; - - const context = { - bidRequest, - bidderRequest - } - utils.replaceAllMacrosInPlace(bid, context); - - expect(bid.adm).to.equal(`
${AUCTION_PRICE}${AUCTION_CURRENCY}${AUCTION_IMP_ID}${AUCTION_BID_ID}${CAMPAIGN_ID}${CREATIVE_ID}${PUBLISHER_ID}${ORTB_ID}${BIDDING_API_BASE_URL}
`); - expect(bid.lurl).to.equal( - `${BIDDING_API_BASE_URL}/loss?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}` - ); - expect(bid.nurl).to.equal( - `${BIDDING_API_BASE_URL}/win?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}` - ); - }); - }) }) -}) +}) \ No newline at end of file