From 196730b425818bd236608b88e9fde8df8594a449 Mon Sep 17 00:00:00 2001 From: Robert Ray Martinez III Date: Thu, 5 Mar 2020 00:04:21 -0800 Subject: [PATCH 01/10] Price Floors (#52) * Initial Commit Code cleanup + PBS Changes + tests still needed * Refactor to remove event * forgot to remove FLOOR_NOT_MET * Clean up code * broke out req params * floorNotMet is not bidRejected * clean up module, more requirements on bid and analytics adapters moved gpt matching into a util function * test updates + currency conversion now on global * asdf * floor data object schema changed updated to object not array * Selection function now needs to sort by number of *'s in the rule Rubicon get video floor use actual player size * Updated json schema * tests finished * domain is more sophisticated now * Currency floors refactored a little Added log for when bid is floored * update tests after additional data passed to analytics * analytics reqs updated * test update * update to cache lookups Analytics adapter update tests updated for cache lookup * reverting temp stuff * Pulling latest from master = few probs Small currency bug * revert newline * improved rule selection for getFloor * new api for adding new fields overrides if debug on, resp hook after debug hook small bug in overwriting data object * handle adjustment factor of 0 * removed size and mt specific logic and pushed to matching func * adding comments for all funcs * Added tests for rubicon adapters handle matchign keys with dot's * undefined data should be ignored * remove unnecessary -1 plus semi colon * md file * removing temp test file --- gulpfile.js | 14 +- modules/currency.js | 4 + modules/priceFloors.js | 642 ++++++++++++ modules/priceFloors.md | 62 ++ modules/rubiconAnalyticsAdapter.js | 67 +- modules/rubiconBidAdapter.js | 22 +- src/constants.json | 3 +- src/utils.js | 18 + test/spec/modules/currency_spec.js | 40 +- test/spec/modules/priceFloorsSchema.json | 86 ++ test/spec/modules/priceFloors_spec.js | 972 ++++++++++++++++++ .../modules/rubiconAnalyticsAdapter_spec.js | 95 ++ test/spec/modules/rubiconAnalyticsSchema.json | 3 +- test/spec/modules/rubiconBidAdapter_spec.js | 75 +- 14 files changed, 2080 insertions(+), 23 deletions(-) create mode 100644 modules/priceFloors.js create mode 100644 modules/priceFloors.md create mode 100644 test/spec/modules/priceFloorsSchema.json create mode 100644 test/spec/modules/priceFloors_spec.js diff --git a/gulpfile.js b/gulpfile.js index 7c4ca306886..8382b1d1ad7 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -238,8 +238,8 @@ function test(done) { ]; } - //run mock-server - const mockServer = spawn('node', ['./test/mock-server/index.js', '--port='+mockServerPort]); + // run mock-server + const mockServer = spawn('node', ['./test/mock-server/index.js', '--port=' + mockServerPort]); mockServer.stdout.on('data', (data) => { console.log(`stdout: ${data}`); }); @@ -249,13 +249,13 @@ function test(done) { execa(wdioCmd, wdioOpts, { stdio: 'inherit' }) .then(stdout => { - //kill mock server + // kill mock server mockServer.kill('SIGINT'); done(); process.exit(0); }) .catch(err => { - //kill mock server + // kill mock server mockServer.kill('SIGINT'); done(new Error(`Tests failed with error: ${err}`)); process.exit(1); @@ -326,10 +326,10 @@ function setupE2e(done) { done(); } -gulp.task('updatepath', function(){ +gulp.task('updatepath', function() { return gulp.src(['build/dist/*.js']) - .pipe(replace('ib.adnxs.com/ut/v3/prebid', host + ':' + mockServerPort + '/')) - .pipe(gulp.dest('build/dist')); + .pipe(replace('ib.adnxs.com/ut/v3/prebid', host + ':' + mockServerPort + '/')) + .pipe(gulp.dest('build/dist')); }); // support tasks diff --git a/modules/currency.js b/modules/currency.js index 84ca5e42783..0464d9b5cdb 100644 --- a/modules/currency.js +++ b/modules/currency.js @@ -1,3 +1,4 @@ +import { getGlobal } from '../src/prebidGlobal.js'; import { createBid } from '../src/bidfactory.js'; import { STATUS } from '../src/constants.json'; import { ajax } from '../src/ajax.js'; @@ -122,6 +123,8 @@ function initCurrency(url) { utils.logInfo('Installing addBidResponse decorator for currency module', arguments); + // Adding conversion function to prebid global for external module and on page use + getGlobal().convertCurrency = (cpm, fromCurrency, toCurrency) => parseFloat(cpm) * getCurrencyConversion(fromCurrency, toCurrency); getHook('addBidResponse').before(addBidResponseHook, 100); // call for the file if we haven't already @@ -149,6 +152,7 @@ function resetCurrency() { utils.logInfo('Uninstalling addBidResponse decorator for currency module', arguments); getHook('addBidResponse').getHooks({hook: addBidResponseHook}).remove(); + delete getGlobal().convertCurrency; adServerCurrency = 'USD'; conversionCache = {}; diff --git a/modules/priceFloors.js b/modules/priceFloors.js new file mode 100644 index 00000000000..e41ca70d41e --- /dev/null +++ b/modules/priceFloors.js @@ -0,0 +1,642 @@ +import { getGlobal } from '../src/prebidGlobal.js'; +import { config } from '../src/config.js'; +import * as utils from '../src/utils.js'; +import { ajaxBuilder } from '../src/ajax.js'; +import events from '../src/events.js'; +import CONSTANTS from '../src/constants.json'; +import { getHook } from '../src/hook.js'; +import { createBid } from '../src/bidfactory.js'; +import find from 'core-js/library/fn/array/find.js'; +import { parse as urlParse } from '../src/url.js'; +import { getRefererInfo } from '../src/refererDetection.js'; + +/** + * @summary This Module is intended to provide users with the ability to dynamically set and enforce price floors on a per auction basis. + */ +const MODULE_NAME = 'Price Floors'; + +/** + * @summary Instantiate Ajax so we control the timeout + */ +const ajax = ajaxBuilder(10000); + +/** + * @summary Allowed fields for rules to have + */ +export let allowedFields = ['gptSlot', 'adUnitCode', 'size', 'domain', 'mediaType']; + +/** + * @summary This is a flag to indicate if a AJAX call is processing for a floors request +*/ +let fetching = false; + +/** + * @summary so we only register for our hooks once +*/ +let addedFloorsHook = false; + +/** + * @summary The config to be used. Can be updated via: setConfig or a real time fetch + */ +let _floorsConfig = {}; + +/** + * @summary If a auction is to be delayed by an ongoing fetch we hold it here until it can be resumed + */ +let _delayedAuctions = []; + +/** + * @summary Each auction can have differing floors data depending on execution time or per adunit setup + * So we will be saving each auction offset by it's auctionId in order to make sure data is not changed + * Once the auction commences + */ +export let _floorDataForAuction = {}; + +/** + * @summary Simple function to round up to a certain decimal degree + */ +function roundUp(number, precision) { + return Math.ceil(parseFloat(number) * Math.pow(10, precision)) / Math.pow(10, precision); +} + +let referrerHostname; +function getHostNameFromReferer(referer) { + referrerHostname = urlParse(referer).hostname; + return referrerHostname; +} + +/** + * @summary floor field types with their matching functions to resolve the actual matched value + */ +export let fieldMatchingFunctions = { + 'size': (bidRequest, bidResponse) => utils.parseGPTSingleSizeArray(bidResponse.size) || '*', + 'mediaType': (bidRequest, bidResponse) => bidResponse.mediaType || 'banner', + 'gptSlot': (bidRequest, bidResponse) => utils.getGptSlotInfoForAdUnitCode(bidRequest.adUnitCode).gptSlot, + 'domain': (bidRequest, bidResponse) => referrerHostname || getHostNameFromReferer(getRefererInfo().referer), + 'adUnitCode': (bidRequest, bidResponse) => bidRequest.adUnitCode +} + +/** + * @summary Based on the fields array in floors data, it enumerates all possible matches based on exact match coupled with + * a "*" catch-all match + * Returns array of Tuple [exact match, catch all] for each field in rules file + */ +function enumeratePossibleFieldValues(floorFields, bidObject, responseObject) { + // generate combination of all exact matches and catch all for each field type + return floorFields.reduce((accum, field) => { + let exactMatch = fieldMatchingFunctions[field](bidObject, responseObject); + // storing exact matches as lowerCase since we want to compare case insensitively + accum.push(exactMatch === '*' ? ['*'] : [exactMatch.toLowerCase(), '*']); + return accum; + }, []); +} + +/** + * @summary get's the first matching floor based on context provided. + * Generates all possible rule matches and picks the first matching one. + */ +export function getFirstMatchingFloor(floorData, bidObject, responseObject = {}) { + let fieldValues = enumeratePossibleFieldValues(utils.deepAccess(floorData, 'schema.fields') || [], bidObject, responseObject); + if (!fieldValues.length) return { matchingFloor: floorData.default }; + + // look to see iof a request for this context was made already + let matchingInput = fieldValues.map(field => field[0]).join('-'); + // if we already have gotten the matching rule from this matching input then use it! No need to look again + let previousMatch = utils.deepAccess(floorData, `matchingInputs.${matchingInput}`); + if (previousMatch) { + return previousMatch; + } + let allPossibleMatches = generatePossibleEnumerations(fieldValues, utils.deepAccess(floorData, 'schema.delimiter') || '|'); + let matchingRule = find(allPossibleMatches, hashValue => floorData.values.hasOwnProperty(hashValue)); + + let matchingData = { + matchingFloor: floorData.values[matchingRule] || floorData.default, + matchingData: allPossibleMatches[0], // the first possible match is an "exact" so contains all data relevant for anlaytics adapters + matchingRule + }; + // save for later lookup if needed + utils.deepSetValue(floorData, `matchingInputs.${matchingInput}`, {...matchingData}); + return matchingData; +} + +/** + * @summary Generates all possible rule hash's based on input array of array's + * The generated list is of all possible key matches based on fields input + * The list is sorted by least amount of * in rule to most with left most fields taking precedence + */ +function generatePossibleEnumerations(arrayOfFields, delimiter) { + return arrayOfFields.reduce((accum, currentVal) => { + let ret = []; + accum.map(obj => { + currentVal.map(obj1 => { + ret.push(obj + delimiter + obj1) + }); + }); + return ret; + }).sort((left, right) => left.split('*').length - right.split('*').length); +} + +/** + * @summary If a the input bidder has a registered cpmadjustment it returns the input CPM after being adjusted + */ +export function getBiddersCpmAdjustment(bidderName, inputCpm) { + const adjustmentFunction = utils.deepAccess(getGlobal(), `bidderSettings.${bidderName}.bidCpmAdjustment`); + if (adjustmentFunction) { + return parseFloat(adjustmentFunction(inputCpm)); + } + return parseFloat(inputCpm); +} + +/** + * @summary This function takes the original floor and the adjusted floor in order to determine the bidders actual floor + */ +export function calculateAdjustedFloor(oldFloor, newFloor) { + return oldFloor / newFloor * oldFloor; +} + +/** + * @summary gets the prebid set sizes depending on the input mediaType + */ +const getMediaTypesSizes = { + banner: (bid) => utils.deepAccess(bid, 'mediaTypes.banner.sizes') || [], + video: (bid) => utils.deepAccess(bid, 'mediaTypes.video.playerSize') || [], + native: (bid) => [utils.deepAccess(bid, 'mediaTypes.native.image.sizes')] || [], +} + +/** + * @summary for getFloor only, before selecting a rule, if a bidAdapter asks for * in their getFloor params + * Then we may be able to get a better rule than the * ones depending on context of the adUnit + */ +function updateRequestParamsFromContext(bidRequest, requestParams) { + // if adapter asks for *'s then we can do some logic to infer if we can get a more specific rule based on context of bid + let mediaTypesOnBid = Object.keys(bidRequest.mediaTypes || {}); + // if there is only one mediaType then we can just use it + if (requestParams.mediaType === '*' && mediaTypesOnBid.length === 1) { + requestParams.mediaType = mediaTypesOnBid[0]; + } + // if they asked for * size, but for the given mediaType there is only one size, we can just use it + if (requestParams.size === '*' && mediaTypesOnBid.indexOf(requestParams.mediaType) !== -1 && getMediaTypesSizes[requestParams.mediaType] && getMediaTypesSizes[requestParams.mediaType](bidRequest).length === 1) { + requestParams.size = getMediaTypesSizes[requestParams.mediaType](bidRequest)[0]; + } + return requestParams; +} + +/** + * @summary This is the function which will return a single floor based on the input requests + * and matching it to a rule for the current auction + */ +export function getFloor(requestParams = {currency: 'USD', mediaType: '*', size: '*'}) { + let bidRequest = this; + let floorData = _floorDataForAuction[bidRequest.auctionId]; + if (!floorData || floorData.skipped) return {}; + + requestParams = updateRequestParamsFromContext(bidRequest, requestParams); + let floorInfo = getFirstMatchingFloor(floorData.data, {...bidRequest}, {mediaType: requestParams.mediaType, size: requestParams.size}); + let currency = requestParams.currency || floorData.data.currency; + + // if bidder asked for a currency which is not what floors are set in convert + if (floorInfo.matchingFloor && currency !== floorData.data.currency) { + try { + floorInfo.matchingFloor = getGlobal().convertCurrency(floorInfo.matchingFloor, floorData.data.currency, currency); + } catch (err) { + utils.logWarn(`${MODULE_NAME}: Unable to get currency conversion for getFloor for bidder ${bidRequest.bidder}. You must have currency module enabled with defaultRates in your currency config`); + // since we were unable to convert to the bidders requested currency, we send back just the actual floors currency to them + currency = floorData.data.currency; + } + } + + // if cpmAdjustment flag is true and we have a valid floor then run the adjustment on it + if (floorData.enforcement.bidAdjustment && floorInfo.matchingFloor) { + let cpmAdjustment = getBiddersCpmAdjustment(bidRequest.bidder, floorInfo.matchingFloor); + floorInfo.matchingFloor = cpmAdjustment ? calculateAdjustedFloor(floorInfo.matchingFloor, cpmAdjustment) : floorInfo.matchingFloor; + } + + if (floorInfo.matchingFloor) { + return { + floor: roundUp(floorInfo.matchingFloor, 4), + currency, + }; + } + return {}; +} + +/** + * @summary Takes a floorsData object and converts it into a hash map with appropriate keys + */ +export function getFloorsDataForAuction(floorData, adUnitCode) { + let auctionFloorData = utils.deepClone(floorData); + auctionFloorData.schema.delimiter = floorData.schema.delimiter || '|'; + auctionFloorData.values = normalizeRulesForAuction(auctionFloorData, adUnitCode); + // default the currency to USD if not passed in + auctionFloorData.currency = auctionFloorData.currency || 'USD'; + return auctionFloorData; +} + +/** + * @summary if adUnitCode needs to be added to the offset then it will add it else just return the values + */ +function normalizeRulesForAuction(floorData, adUnitCode) { + let fields = floorData.schema.fields; + let delimiter = floorData.schema.delimiter + + // if we are building the floor data form an ad unit, we need to append adUnit code as to not cause collisions + let prependAdUnitCode = adUnitCode && !fields.includes('adUnitCode') && fields.unshift('adUnitCode'); + return Object.keys(floorData.values).reduce((rulesHash, oldKey) => { + let newKey = prependAdUnitCode ? `${adUnitCode}${delimiter}${oldKey}` : oldKey + // we store the rule keys as lower case for case insensitive compare + rulesHash[newKey.toLowerCase()] = floorData.values[oldKey]; + return rulesHash; + }, {}); +} + +/** + * @summary This function will take the adUnits and generate a floor data object to be used during the auction + * Only called if no set config or fetch level data has returned + */ +export function getFloorDataFromAdUnits(adUnits) { + return adUnits.reduce((accum, adUnit) => { + if (isFloorsDataValid(adUnit.floors)) { + // if values already exist we want to not overwrite them + if (!accum.values) { + accum = getFloorsDataForAuction(adUnit.floors, adUnit.code); + accum.location = 'adUnit'; + } else { + let newRules = getFloorsDataForAuction(adUnit.floors, adUnit.code).values; + // copy over the new rules into our values object + Object.assign(accum.values, newRules); + } + } + return accum; + }, {}); +} + +/** + * @summary This function takes the adUnits for the auction and update them accordingly as well as returns the rules hashmap for the auction + */ +export function updateAdUnitsForAuction(adUnits, floorData, skipped) { + adUnits.forEach((adUnit) => { + adUnit.bids.forEach(bid => { + if (skipped) { + delete bid.getFloor; + } else { + bid.getFloor = getFloor; + } + // information for bid and analytics adapters + bid.floorData = { + skipped, + modelVersion: utils.deepAccess(floorData, 'data.modelVersion') || '', + location: floorData.data.location, + } + }); + }); +} + +/** + * @summary Updates the adUnits accordingly and returns the necessary floorsData for the current auction + */ +export function createFloorsDataForAuction(adUnits) { + let resolvedFloorsData = utils.deepClone(_floorsConfig); + + // if we do not have a floors data set, we will try to use data set on adUnits + let useAdUnitData = Object.keys(utils.deepAccess(resolvedFloorsData, 'data.values') || {}).length === 0; + if (useAdUnitData) { + resolvedFloorsData.data = getFloorDataFromAdUnits(adUnits); + } else { + resolvedFloorsData.data = getFloorsDataForAuction(resolvedFloorsData.data); + } + // if we still do not have a valid floor data then floors is not on for this auction + if (Object.keys(utils.deepAccess(resolvedFloorsData, 'data.values') || {}).length === 0) { + return; + } + // determine the skip rate now + const isSkipped = Math.random() * 100 < parseFloat(utils.deepAccess(resolvedFloorsData, 'data.skipRate') || 0); + resolvedFloorsData.skipped = isSkipped; + updateAdUnitsForAuction(adUnits, resolvedFloorsData, isSkipped); + return resolvedFloorsData; +} + +/** + * @summary This is the function which will be called to exit our module and continue the auction. + */ +export function continueAuction(hookConfig) { + // only run if hasExited + if (!hookConfig.hasExited) { + // if this current auction is still fetching, remove it from the _delayedAuctions + _delayedAuctions = _delayedAuctions.filter(auctionConfig => auctionConfig.timer !== hookConfig.timer); + + // We need to know the auctionId at this time. So we will use the passed in one or generate and set it ourselves + hookConfig.reqBidsConfigObj.auctionId = hookConfig.reqBidsConfigObj.auctionId || utils.generateUUID(); + + // now we do what we need to with adUnits and save the data object to be used for getFloor and enforcement calls + _floorDataForAuction[hookConfig.reqBidsConfigObj.auctionId] = createFloorsDataForAuction(hookConfig.reqBidsConfigObj.adUnits || getGlobal().adUnits); + + hookConfig.nextFn.apply(hookConfig.context, [hookConfig.reqBidsConfigObj]); + hookConfig.hasExited = true; + } +} + +function validateSchemaFields(fields) { + if (Array.isArray(fields) && fields.length > 0 && fields.every(field => allowedFields.includes(field))) { + return true; + } + utils.logError(`${MODULE_NAME}: Fields recieved do not match allowed fields`); + return false; +} + +function isValidRule(key, floor, numFields, delimiter) { + if (typeof key !== 'string' || key.split(delimiter).length !== numFields) { + return false; + } + return typeof floor === 'number'; +} + +function validateRules(floorsData, numFields, delimiter) { + if (typeof floorsData.values !== 'object') { + return false; + } + // if an invalid rule exists we remove it + floorsData.values = Object.keys(floorsData.values).reduce((filteredRules, key) => { + if (isValidRule(key, floorsData.values[key], numFields, delimiter)) { + filteredRules[key] = floorsData.values[key]; + } + return filteredRules + }, {}); + // rules is only valid if at least one rule remains + return Object.keys(floorsData.values).length > 0; +} + +/** + * @summary Fields array should have at least one entry and all should match allowed fields + * Each rule in the values array should have a 'key' and 'floor' param + * And each 'key' should have the correct number of 'fields' after splitting + * on the delim. If rule does not match remove it. return if still at least 1 rule + */ +export function isFloorsDataValid(floorsData) { + if (typeof floorsData !== 'object') { + return false; + } + // schema.fields has only allowed attributes + if (!validateSchemaFields(utils.deepAccess(floorsData, 'schema.fields'))) { + return false; + } + return validateRules(floorsData, floorsData.schema.fields.length, floorsData.schema.delimiter || '|') +} + +/** + * @summary This function updates the global Floors Data field based on the new one passed in if it is valid + */ +export function parseFloorData(floorsData, location) { + if (floorsData && typeof floorsData === 'object' && isFloorsDataValid(floorsData)) { + return { + ...floorsData, + location + }; + } + utils.logError(`${MODULE_NAME}: The floors data did not contain correct values`, floorsData); +} + +/** + * + * @param {Object} reqBidsConfigObj required; This is the same param that's used in pbjs.requestBids. + * @param {function} fn required; The next function in the chain, used by hook.js + */ +export function requestBidsHook(fn, reqBidsConfigObj) { + // preserves all module related variables for the current auction instance (used primiarily for concurrent auctions) + const hookConfig = { + reqBidsConfigObj, + context: this, + nextFn: fn, + haveExited: false, + timer: null + }; + + // If auction delay > 0 AND we are fetching -> Then wait until it finishes + if (_floorsConfig.auctionDelay > 0 && fetching) { + hookConfig.timer = setTimeout(() => { + utils.logWarn(`${MODULE_NAME}: Fetch attempt did not return in time for auction`); + continueAuction(hookConfig); + }, _floorsConfig.auctionDelay); + _delayedAuctions.push(hookConfig); + } else { + continueAuction(hookConfig); + } +} + +/** + * @summary If an auction was queued to be delayed (waiting for a fetch) then this function will resume + * those delayed auctions when delay is hit or success return or fail return + */ +function resumeDelayedAuctions() { + _delayedAuctions.forEach(auctionConfig => { + // clear the timeout + clearTimeout(auctionConfig.timer); + continueAuction(auctionConfig); + }); + _delayedAuctions = []; +} + +/** + * This function handles the ajax response which comes from the user set URL to fetch floors data from + * @param {object} fetchResponse The floors data response which came back from the url configured in config.floors + */ +export function handleFetchResponse(fetchResponse) { + fetching = false; + let floorResponse; + try { + floorResponse = JSON.parse(fetchResponse); + } catch (ex) { + floorResponse = fetchResponse; + } + // Update the global floors object according to the fetched data + _floorsConfig.data = parseFloorData(floorResponse, 'fetch') || _floorsConfig.data; + + // if any auctions are waiting for fetch to finish, we need to continue them! + resumeDelayedAuctions(); +} + +function handleFetchError(status) { + fetching = false; + utils.logError(`${MODULE_NAME}: Fetch errored with: ${status}`); + + // if any auctions are waiting for fetch to finish, we need to continue them! + resumeDelayedAuctions(); +} + +/** + * This function handles sending and recieving the AJAX call for a floors fetch + * @param {object} floorsConfig the floors config coming from setConfig + */ +export function generateAndHandleFetch(floorEndpoint) { + // if a fetch url is defined and one is not already occuring, fire it! + if (floorEndpoint.url && !fetching) { + // default to GET and we only support GET for now + let requestMethod = floorEndpoint.method || 'GET'; + if (requestMethod !== 'GET') { + utils.logError(`${MODULE_NAME}: 'GET' is the only request method supported at this time!`); + } else { + ajax(floorEndpoint.url, { success: handleFetchResponse, error: handleFetchError }, null, { method: 'GET' }); + fetching = true; + } + } else if (fetching) { + utils.logWarn(`${MODULE_NAME}: A fetch is already occuring. Skipping.`); + } +} + +/** + * @summary Updates our allowedFields and fieldMatchingFunctions with the publisher defined new ones + */ +function addFieldOverrides(overrides) { + Object.keys(overrides).forEach(override => { + // we only add it if it is not already in the allowed fields and if the passed in value is a function + if (allowedFields.indexOf(override) === -1 && typeof overrides[override] === 'function') { + allowedFields.push(override); + fieldMatchingFunctions[override] = overrides[override]; + } + }); +} + +/** + * @summary This is the function which controls what happens during a pbjs.setConfig({...floors: {}}) is called + */ +export function handleSetFloorsConfig(config) { + _floorsConfig = utils.pick(config, [ + 'enabled', enabled => enabled !== false, // defaults to true + 'auctionDelay', auctionDelay => auctionDelay || 0, + 'endpoint', endpoint => endpoint || {}, + 'enforcement', enforcement => utils.pick(enforcement, [ + 'enforceJS', enforceJS => enforceJS !== false, // defaults to true + 'enforcePBS', enforcePBS => enforcePBS === true, // defaults to false + 'floorDeals', floorDeals => floorDeals === true, // defaults to false + 'bidAdjustment', bidAdjustment => bidAdjustment !== false, // defaults to true + ]), + 'additionalSchemaFields', additionalSchemaFields => typeof additionalSchemaFields === 'object' && Object.keys(additionalSchemaFields).length > 0 ? addFieldOverrides(additionalSchemaFields) : undefined, + 'data', data => (data && parseFloorData(data, 'setConfig')) || _floorsConfig.data // do not overwrite if passed in data not valid + ]); + + // if enabled then do some stuff + if (_floorsConfig.enabled) { + // handle the floors fetch + generateAndHandleFetch(_floorsConfig.endpoint); + + if (!addedFloorsHook) { + // register hooks / listening events + // when auction finishes remove it's associated floor data + events.on(CONSTANTS.EVENTS.AUCTION_END, (args) => delete _floorDataForAuction[args.auctionId]); + + // we want our hooks to run after the currency hooks + getGlobal().requestBids.before(requestBidsHook, 50); + // if user has debug on then we want to allow the debugging module to run before this, assuming they are testing priceFloors + // debugging is currently set at 5 priority + getHook('addBidResponse').before(addBidResponseHook, utils.debugTurnedOn() ? 4 : 50); + addedFloorsHook = true; + } + } else { + utils.logInfo(`${MODULE_NAME}: Turning off module`); + + _floorsConfig = {}; + _floorDataForAuction = {}; + + getHook('addBidResponse').getHooks({hook: addBidResponseHook}).remove(); + getGlobal().requestBids.getHooks({hook: requestBidsHook}).remove(); + + addedFloorsHook = false; + } +} + +/** + * @summary Analytics adapters especially need context of what the floors module is doing in order + * to best create informed models. This function attaches necessary information to the bidResponse object for processing + */ +function addFloorDataToBid(floorData, floorInfo, bid, adjustedCpm) { + bid.floorData = { + floorValue: floorInfo.matchingFloor, + floorRule: floorInfo.matchingRule, + floorCurrency: floorData.data.currency, + cpmAfterAdjustments: adjustedCpm, + enforcements: {...floorData.enforcement}, + matchedFields: {} + }; + floorData.data.schema.fields.forEach((field, index) => { + let matchedValue = floorInfo.matchingData.split(floorData.data.schema.delimiter)[index]; + bid.floorData.matchedFields[field] = matchedValue; + }); +} + +/** + * @summary takes the enforcement flags and the bid itself and determines if it should be floored + */ +function shouldFloorBid(floorData, floorInfo, bid) { + let enforceJS = utils.deepAccess(floorData, 'enforcement.enforceJS') !== false; + let shouldFloorDeal = utils.deepAccess(floorData, 'enforcement.floorDeals') === true || !bid.dealId; + let bidBelowFloor = bid.floorData.cpmAfterAdjustments < floorInfo.matchingFloor; + return enforceJS && (bidBelowFloor && shouldFloorDeal); +} + +/** + * @summary The main driving force of floors. On bidResponse we hook in and intercept bidResponses. + * And if the rule we find determines a bid should be floored we will do so. + */ +export function addBidResponseHook(fn, adUnitCode, bid) { + let floorData = _floorDataForAuction[this.bidderRequest.auctionId]; + // if no floor data or associated bidRequest then bail + const matchingBidRequest = find(this.bidderRequest.bids, bidRequest => bidRequest.bidId && bidRequest.bidId === bid.requestId); + if (!floorData || !bid || floorData.skipped || !matchingBidRequest) { + return fn.call(this, adUnitCode, bid); + } + + // get the matching rule + let floorInfo = getFirstMatchingFloor(floorData.data, {...matchingBidRequest}, {...bid, size: [bid.width, bid.height]}); + + if (!floorInfo.matchingFloor) { + utils.logWarn(`${MODULE_NAME}: unable to determine a matching price floor for bidResponse`, bid); + return fn.call(this, adUnitCode, bid); + } + + // determine the base cpm to use based on if the currency matches the floor currency + let adjustedCpm; + let floorCurrency = floorData.data.currency.toUpperCase(); + let bidResponseCurrency = bid.currency || 'USD'; // if an adapter does not set a bid currency and currency module not on it may come in as undefined + if (floorCurrency === bidResponseCurrency.toUpperCase()) { + adjustedCpm = bid.cpm; + } else if (bid.originalCurrency && floorCurrency === bid.originalCurrency.toUpperCase()) { + adjustedCpm = bid.originalCpm; + } else { + try { + adjustedCpm = getGlobal().convertCurrency(bid.cpm, bidResponseCurrency.toUpperCase(), floorCurrency); + } catch (err) { + utils.logError(`${MODULE_NAME}: Unable do get currency conversion for bidResponse to Floor Currency. Do you have Currency module enabled? ${bid}`); + return fn.call(this, adUnitCode, bid); + } + } + + // ok we got the bid response cpm in our desired currency. Now we need to run the bidders CPMAdjustment function if it exists + adjustedCpm = getBiddersCpmAdjustment(bid.bidderCode, adjustedCpm); + + // add necessary data information for analytics adapters / floor providers would possibly need + addFloorDataToBid(floorData, floorInfo, bid, adjustedCpm); + + // now do the compare! + if (shouldFloorBid(floorData, floorInfo, bid)) { + // bid fails floor -> throw it out + // create basic bid no-bid with necessary data fro analytics adapters + let flooredBid = createBid(CONSTANTS.STATUS.NO_BID, matchingBidRequest); + Object.assign(flooredBid, utils.pick(bid, [ + 'floorData', + 'width', + 'height', + 'mediaType', + 'currency', + 'originalCpm', + 'originalCurrency', + 'getCpmInNewCurrency', + ])); + flooredBid.status = CONSTANTS.BID_STATUS.BID_REJECTED; + // if floor not met update bid with 0 cpm so it is not included downstream and marked as no-bid + flooredBid.cpm = 0; + utils.logWarn(`${MODULE_NAME}: ${flooredBid.bidderCode}'s Bid Response for ${adUnitCode} was rejected due to floor not met`, bid); + return fn.call(this, adUnitCode, flooredBid); + } + return fn.call(this, adUnitCode, bid); +} + +config.getConfig('floors', config => handleSetFloorsConfig(config.floors)); diff --git a/modules/priceFloors.md b/modules/priceFloors.md new file mode 100644 index 00000000000..d09be78c620 --- /dev/null +++ b/modules/priceFloors.md @@ -0,0 +1,62 @@ +## Dynamic Price Floors + +### Setup +```javascript +pbjs.setConfig({ + floors: { + enabled: true, //defaults to true + enforcement: { + floorDeals: false, //defaults to false + bidAdjustment: true, //defaults to true + enforceJS: true //defaults to true + }, + auctionDelay: 150, // in milliseconds defaults to 0 + endpoint: { + url: 'http://localhost:1500/floor-domains', + method: 'GET' // Only get supported for now + }, + data: { + schema: { + fields: ['mediaType', 'size'] + }, + values: { + 'banner|300x250': 0.86, + 'banner|300x600': 0.97, + 'banner|728x90': 1.12, + 'banner|*': 0.54, + 'video|640x480': 6.76, + 'video|1152x648': 11.76, + 'video|*': 4.55, + '*|*': 0.30 + }, + default: 0.01 + } + } +}); +``` + +| Parameter | Description | +|------------------------|---------------------------------------------------------------------------------------------------------------------| +| enabled | Wether to turn off or on the floors module | +| enforcement | object of booleans which control certain features of the module | +| auctionDelay | The time to suspend and auction while waiting for a real time price floors fetch to come back | +| endpoint | An object describing the endpoint to retrieve floor data from. GET only | +| data | The data to be used to select appropriate floors. See schema for more detail | +| additionalSchemaFields | An object of additional fields to be used in a floor data object. The schema is KEY: function to retrieve the match | + +### Passing floors to Bid Adapters +Because it is possible for many rules to match any given bidRequest, (wether it be due to more than one size or more than one mediaType), an encapsolated function is to be passed to bidders which will allow bidders to have insight as to what the floor could be. + +The function `getFloor` will be attached to every bidRequestObject passed to bid adapters if the price floors are enabled for a given auction. + +This function can takes in an object with the following optional parameters: + +| Parameter | Description | Example | Default | +|-----------|------------------------------------------------------------------------|------------|---------| +| currency | The 3 character currency code which the bid adapter wants the floor in | "JPY" | "USD" | +| mediaType | The specific mediaType to get a floor for | "banner" | "*" | +| size | The specific size to get a matching floor on | [300, 250] | "*" | + +If a bid adapter passes in `*` as an attribute, then the `priceFloors` module will attempt to select the best rule based on context. + +For example, if an adapter passes in a `*`, but the bidRequest only has a single size and a single mediaType, then the `getFloor` function will attempt to get a rule for that size before matching with the `*` catch-all. Similarily, if mediaType can be inferred on the bidRequest, it will use it. \ No newline at end of file diff --git a/modules/rubiconAnalyticsAdapter.js b/modules/rubiconAnalyticsAdapter.js index 939afc42f6d..a2f99d1d847 100644 --- a/modules/rubiconAnalyticsAdapter.js +++ b/modules/rubiconAnalyticsAdapter.js @@ -5,6 +5,7 @@ import { ajax } from '../src/ajax.js'; import { config } from '../src/config.js'; import * as utils from '../src/utils.js'; import * as urlLib from '../src/url.js' +import { getGlobal } from '../src/prebidGlobal.js'; const { EVENTS: { @@ -20,6 +21,9 @@ const { STATUS: { GOOD, NO_BID + }, + BID_STATUS: { + BID_REJECTED } } = CONSTANTS; @@ -98,7 +102,8 @@ function sendMessage(auctionId, bidWonId) { 'bidPriceUSD', 'dealId', 'dimensions', - 'mediaType' + 'mediaType', + 'floorValue' ]) : undefined ]); } @@ -140,9 +145,11 @@ function sendMessage(auctionId, bidWonId) { 'transactionId', 'mediaTypes', 'dimensions', - 'adserverTargeting', () => stringProperties(cache.targeting[bid.adUnit.adUnitCode] || {}) + 'adserverTargeting', () => stringProperties(cache.targeting[bid.adUnit.adUnitCode] || {}), + 'adSlot' ]); adUnit.bids = []; + adUnit.status = 'no-bid'; // default it to be no bid } // Add site and zone id if not there and if we found a rubicon bidder @@ -183,6 +190,16 @@ function sendMessage(auctionId, bidWonId) { adUnits: Object.keys(adUnitMap).map(i => adUnitMap[i]) }; + // pick our of top level floor data we want to send! + if (auctionCache.floorData) { + auction.floors = utils.pick(auctionCache.floorData, [ + 'location', + 'modelName', () => auctionCache.floorData.modelVersion || '', + 'skipped', + 'enforcement', () => utils.deepAccess(auctionCache.floorData, 'enforcements.enforceJS') + ]); + } + if (serverConfig) { auction.serverTimeoutMillis = serverConfig.timeout; } @@ -219,14 +236,31 @@ function sendMessage(auctionId, bidWonId) { } function getBidPrice(bid) { - if (typeof bid.currency === 'string' && bid.currency.toUpperCase() === 'USD') { + // get the cpm from bidResponse + let cpm; + let currency; + if (bid.status === BID_REJECTED && utils.deepAccess(bid, 'floorData.cpmAfterAdjustments')) { + // if bid was rejected and bid.floorData.cpmAfterAdjustments use it + cpm = bid.floorData.cpmAfterAdjustments; + currency = bid.floorData.floorCurrency; + } else if (typeof bid.currency === 'string' && bid.currency.toUpperCase() === 'USD') { + // bid is in USD use it return Number(bid.cpm); + } else { + // else grab cpm + cpm = bid.cpm; + currency = bid.currency; + } + // if after this it is still going and is USD then return it. + if (currency === 'USD') { + return Number(cpm); } - // use currency conversion function if present - if (typeof bid.getCpmInNewCurrency === 'function') { - return Number(bid.getCpmInNewCurrency('USD')); + // otherwise we convert and return + try { + return Number(getGlobal().convertCurrency(cpm, currency, 'USD')); + } catch (err) { + utils.logWarn('Rubicon Analytics Adapter: Could not determine the bidPriceUSD of the bid ', bid); } - utils.logWarn('Rubicon Analytics Adapter: Could not determine the bidPriceUSD of the bid ', bid); } export function parseBidResponse(bid, previousBidResponse) { @@ -246,6 +280,7 @@ export function parseBidResponse(bid, previousBidResponse) { 'height' ]), 'seatBidId', + 'floorValue', () => utils.deepAccess(bid, 'floorData.floorValue') ]); } @@ -325,6 +360,9 @@ let rubiconAdapter = Object.assign({}, baseAdapter, { cacheEntry.bids = {}; cacheEntry.bidsWon = {}; cacheEntry.referrer = args.bidderRequests[0].refererInfo.referer; + if (utils.deepAccess(args, 'bidderRequests.0.bids.0.floorData')) { + cacheEntry.floorData = {...utils.deepAccess(args, 'bidderRequests.0.bids.0.floorData')}; + } cache.auctions[args.auctionId] = cacheEntry; break; case BID_REQUESTED: @@ -395,14 +433,23 @@ let rubiconAdapter = Object.assign({}, baseAdapter, { return Object.keys(types).filter(validMediaType); } return ['banner']; - } + }, ]) ]); return memo; }, {})); break; case BID_RESPONSE: - let bid = cache.auctions[args.auctionId].bids[args.requestId]; + let auctionEntry = cache.auctions[args.auctionId]; + let bid = auctionEntry.bids[args.requestId]; + // If floor resolved gptSlot but we have not yet, then update the adUnit to have the adSlot name + if (!utils.deepAccess(bid, 'adUnit.adSlot') && utils.deepAccess(args, 'floorData.matchedFields.gptSlot')) { + bid.adUnit.adSlot = args.floorData.matchedFields.gptSlot; + } + // if we have not set enforcements yet set it + if (!utils.deepAccess(auctionEntry, 'floorData.enforcements') && utils.deepAccess(args, 'floorData.enforcements')) { + auctionEntry.floorData.enforcements = {...args.floorData.enforcements}; + } if (!bid) { utils.logError('Rubicon Anlytics Adapter Error: Could not find associated bid request for bid response with requestId: ', args.requestId); break; @@ -414,7 +461,7 @@ let rubiconAdapter = Object.assign({}, baseAdapter, { delete bid.error; // it's possible for this to be set by a previous timeout break; case NO_BID: - bid.status = 'no-bid'; + bid.status = args.status === BID_REJECTED ? 'rejected' : 'no-bid'; delete bid.error; break; default: diff --git a/modules/rubiconBidAdapter.js b/modules/rubiconBidAdapter.js index f8288503493..b943acd487c 100644 --- a/modules/rubiconBidAdapter.js +++ b/modules/rubiconBidAdapter.js @@ -185,7 +185,17 @@ export const spec = { } } - const bidFloor = parseFloat(utils.deepAccess(bidRequest, 'params.floor')); + let bidFloor; + if (typeof bidRequest.getFloor === 'function' && !config.getConfig('rubicon.disableFloors')) { + let floorInfo = bidRequest.getFloor({ + currency: 'USD', + mediaType: 'video', + size: parseSizes(bidRequest, 'video') + }); + bidFloor = typeof floorInfo === 'object' && floorInfo.currency === 'USD' && !isNaN(floorInfo.floor) ? parseFloat(floorInfo.floor) : undefined; + } else { + bidFloor = parseFloat(utils.deepAccess(bidRequest, 'params.floor')); + } if (!isNaN(bidFloor)) { data.imp[0].bidfloor = bidFloor; } @@ -487,6 +497,16 @@ export const spec = { 'rf': _getPageUrl(bidRequest, bidderRequest) }; + // If floors module is enabled and we get USD floor back, send it in rp_hard_floor else undfined + if (typeof bidRequest.getFloor === 'function' && !config.getConfig('rubicon.disableFloors')) { + let floorInfo = bidRequest.getFloor({ + currency: 'USD', + mediaType: 'banner', + size: '*' + }); + data['rp_hard_floor'] = typeof floorInfo === 'object' && floorInfo.currency === 'USD' && !isNaN(floorInfo.floor) ? floorInfo.floor : undefined; + } + // add p_pos only if specified and valid // For SRA we need to explicitly put empty semi colons so AE treats it as empty, instead of copying the latter value data['p_pos'] = (params.position === 'atf' || params.position === 'btf') ? params.position : ''; diff --git a/src/constants.json b/src/constants.json index 0b6b144a336..7ffef9db1aa 100644 --- a/src/constants.json +++ b/src/constants.json @@ -95,7 +95,8 @@ }, "BID_STATUS" : { "BID_TARGETING_SET": "targetingSet", - "RENDERED": "rendered" + "RENDERED": "rendered", + "BID_REJECTED": "bidRejected" }, "SUBMODULES_THAT_ALWAYS_REFRESH_ID": { "parrableId": true diff --git a/src/utils.js b/src/utils.js index f2b4a986ca7..f3664845080 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1014,6 +1014,24 @@ export function isSlotMatchingAdUnitCode(adUnitCode) { return (slot) => compareCodeAndSlot(slot, adUnitCode); } +/** + * @summary Uses the adUnit's code in order to find a matching gptSlot on the page + */ +export function getGptSlotInfoForAdUnitCode(adUnitCode) { + let matchingSlot; + if (isGptPubadsDefined()) { + // find the first matching gpt slot on the page + matchingSlot = find(window.googletag.pubads().getSlots(), isSlotMatchingAdUnitCode(adUnitCode)); + } + if (matchingSlot) { + return { + gptSlot: matchingSlot.getAdUnitPath(), + divId: matchingSlot.getSlotElementId() + } + } + return {}; +}; + /** * Constructs warning message for when unsupported bidders are dropped from an adunit * @param {Object} adUnit ad unit from which the bidder is being dropped diff --git a/test/spec/modules/currency_spec.js b/test/spec/modules/currency_spec.js index 577d36e9215..ccd205964a9 100644 --- a/test/spec/modules/currency_spec.js +++ b/test/spec/modules/currency_spec.js @@ -3,6 +3,8 @@ import { getCurrencyRates } from 'test/fixtures/fixtures.js'; +import { getGlobal } from 'src/prebidGlobal.js'; + import { setConfig, addBidResponseHook, @@ -43,8 +45,12 @@ describe('currency', function () { it('results in currencySupportEnabled = false when currency not configured', function () { setConfig({}); expect(currencySupportEnabled).to.equal(false); + expect(getGlobal().convertCurrency).to.be.undefined; + }); + it('adds conversion function onto pbjs global var once initiated', function () { + setConfig({ 'adServerCurrency': 'JPY' }); + expect(getGlobal().convertCurrency).to.be.a('function'); }); - it('results in currencySupportEnabled = true and currencyRates being loaded when configured', function () { fakeCurrencyFileServer.respondWith(JSON.stringify(getCurrencyRates())); setConfig({ 'adServerCurrency': 'JPY' }); @@ -124,6 +130,38 @@ describe('currency', function () { }); }); + describe('global currency function', function () { + it('still returns conversion if default rates present with fetch not returned yet', function () { + setConfig({ + 'adServerCurrency': 'USD', + 'defaultRates': { + 'USD': { EUR: 2, JPY: 100 } + } + }); + // first conversion should use default rates + expect(getGlobal().convertCurrency(1.0, 'USD', 'EUR')).to.equal(2); + expect(getGlobal().convertCurrency(1.0, 'USD', 'JPY')).to.equal(100); + fakeCurrencyFileServer.respond(); + }); + it('uses fetch rates if returned', function () { + fakeCurrencyFileServer.respondWith(JSON.stringify({ + 'dataAsOf': '2017-04-25', + 'conversions': { + 'USD': { EUR: 4, JPY: 200 } + } + })); + setConfig({ + 'adServerCurrency': 'USD', + 'defaultRates': { + 'USD': { EUR: 2, JPY: 100 } + } + }); + // now respond and should use updates rates + fakeCurrencyFileServer.respond(); + expect(getGlobal().convertCurrency(1.0, 'USD', 'EUR')).to.equal(4); + expect(getGlobal().convertCurrency(1.0, 'USD', 'JPY')).to.equal(200); + }); + }); describe('bidder override', function () { it('allows setConfig to set bidder currency', function () { setConfig({}); diff --git a/test/spec/modules/priceFloorsSchema.json b/test/spec/modules/priceFloorsSchema.json new file mode 100644 index 00000000000..7b524da381e --- /dev/null +++ b/test/spec/modules/priceFloorsSchema.json @@ -0,0 +1,86 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "description": "A Price Floors object which is parsed and used to enforce dynamic floors depending on the properties of the json", + "type": "object", + "title": "Price Floors Enforcement", + "required": [ + "schema", + "values" + ], + "properties": { + "schema": { + "type": "object", + "description": "Defines the schema of the rules", + "required": [ + "fields" + ], + "properties": { + "fields": { + "type": "array", + "description": "The list of fields to be used for matching criteria of a bid response with a price floor", + "minItems": 1, + "uniqueItems": true, + "additionalItems": false, + "items": { + "type": "string" + } + }, + "delimiter": { + "type": "string", + "description": "The character used to differentiate the fields inside a single rule", + "examples": [ + "|", + "^", + "~" + ] + } + } + }, + "values": { + "type": "object", + "description": "A object with key : value pairs which constitutes a rule and floor", + "additionalProperties": { + "type": "number" + }, + "minProperties": 1, + "examples": [ + { + "123456/someSlot|300x250|www.prebid.org": 1.5, + "123456/someSlot|300x600|www.prebid.org": 2.5, + "123456/someSlot|300x600|*": 1.2, + "123456/someSlot|*|*": 0.8 + } + ] + }, + "currency": { + "type": "string", + "description": "The three digit Currency Code which the floors are provided in", + "examples": [ + "USD", + "EUR", + "JPY" + ], + "pattern": "^[a-zA-Z]{3}$" + }, + "modelVersion": { + "type": "string", + "description": "The floor modeling name to be used for tracking", + "examples": [ + "Prebid-Floor-Model-1.2" + ] + }, + "skipRate": { + "type": "integer", + "description": "The skip rate as to which flooring will be 'turned off' for a given auction", + "minimum": 0, + "maximum": 100 + }, + "default": { + "type": "number", + "description": "The default floor to use if no entry in the value matches a bid response", + "examples": [ + 0.75 + ] + } + } +} \ No newline at end of file diff --git a/test/spec/modules/priceFloors_spec.js b/test/spec/modules/priceFloors_spec.js new file mode 100644 index 00000000000..55aa7900252 --- /dev/null +++ b/test/spec/modules/priceFloors_spec.js @@ -0,0 +1,972 @@ +import {expect} from 'chai'; +import * as utils from 'src/utils.js'; +import { getGlobal } from 'src/prebidGlobal.js'; +import CONSTANTS from 'src/constants.json'; +import { + _floorDataForAuction, + getFloorsDataForAuction, + getFirstMatchingFloor, + getFloor, + handleSetFloorsConfig, + requestBidsHook, + isFloorsDataValid, + addBidResponseHook, + fieldMatchingFunctions, + allowedFields +} from 'modules/priceFloors.js'; + +describe('the price floors module', function () { + let logErrorSpy; + let logWarnSpy; + let sandbox; + const basicFloorData = { + modelVersion: 'basic model', + currency: 'USD', + schema: { + delimiter: '|', + fields: ['mediaType'] + }, + values: { + 'banner': 1.0, + 'video': 5.0, + '*': 2.5 + } + }; + const basicFloorConfig = { + enabled: true, + auctionDelay: 0, + endpoint: {}, + enforcement: { + enforceJS: true, + enforcePBS: false, + floorDeals: false, + bidAdjustment: true + }, + data: basicFloorData + } + const basicBidRequest = { + bidder: 'rubicon', + adUnitCode: 'test_div_1', + auctionId: '1234-56-789', + }; + + function getAdUnitMock(code = 'adUnit-code') { + return { + code, + mediaTypes: {banner: { sizes: [[300, 200], [300, 600]] }, native: {}}, + bids: [{bidder: 'someBidder'}, {bidder: 'someOtherBidder'}] + }; + } + beforeEach(function() { + sandbox = sinon.sandbox.create(); + logErrorSpy = sinon.spy(utils, 'logError'); + logWarnSpy = sinon.spy(utils, 'logWarn'); + }); + + afterEach(function() { + handleSetFloorsConfig({enabled: false}); + sandbox.restore(); + utils.logError.restore(); + utils.logWarn.restore(); + // reset global bidder settings so no weird test side effects + getGlobal().bidderSettings = {}; + }); + + describe('getFloorsDataForAuction', function () { + it('converts basic input floor data into a floorData map for the auction correctly', function () { + // basic input where nothing needs to be updated + expect(getFloorsDataForAuction(basicFloorData)).to.deep.equal(basicFloorData); + + // if cur and delim not defined then default to correct ones (usd and |) + let inputFloorData = utils.deepClone(basicFloorData); + delete inputFloorData.currency; + delete inputFloorData.schema.delimiter; + expect(getFloorsDataForAuction(inputFloorData)).to.deep.equal(basicFloorData); + + // should not use defaults if differing values + inputFloorData.currency = 'EUR' + inputFloorData.schema.delimiter = '^' + let resultingData = getFloorsDataForAuction(inputFloorData); + expect(resultingData.currency).to.equal('EUR'); + expect(resultingData.schema.delimiter).to.equal('^'); + }); + + it('converts more complex floor data correctly', function () { + let inputFloorData = { + schema: { + fields: ['mediaType', 'size', 'domain'] + }, + values: { + 'banner|300x250|prebid.org': 1.0, + 'video|640x480|prebid.org': 5.0, + 'banner|728x90|rubicon.com': 3.5, + 'video|600x300|appnexus.com': 3.5, + '*|*|prebid.org': 3.5, + } + }; + let resultingData = getFloorsDataForAuction(inputFloorData); + expect(resultingData).to.deep.equal({ + currency: 'USD', + schema: { + delimiter: '|', + fields: ['mediaType', 'size', 'domain'] + }, + values: { + 'banner|300x250|prebid.org': 1.0, + 'video|640x480|prebid.org': 5.0, + 'banner|728x90|rubicon.com': 3.5, + 'video|600x300|appnexus.com': 3.5, + '*|*|prebid.org': 3.5, + } + }); + }); + + it('adds adUnitCode to the schema if the floorData comes from adUnit level to maintain scope', function () { + let inputFloorData = utils.deepClone(basicFloorData); + let resultingData = getFloorsDataForAuction(inputFloorData, 'test_div_1'); + expect(resultingData).to.deep.equal({ + modelVersion: 'basic model', + currency: 'USD', + schema: { + delimiter: '|', + fields: ['adUnitCode', 'mediaType'] + }, + values: { + 'test_div_1|banner': 1.0, + 'test_div_1|video': 5.0, + 'test_div_1|*': 2.5 + } + }); + + // uses the right delim if not | + inputFloorData.schema.delimiter = '^'; + resultingData = getFloorsDataForAuction(inputFloorData, 'this_is_a_div'); + expect(resultingData).to.deep.equal({ + modelVersion: 'basic model', + currency: 'USD', + schema: { + delimiter: '^', + fields: ['adUnitCode', 'mediaType'] + }, + values: { + 'this_is_a_div^banner': 1.0, + 'this_is_a_div^video': 5.0, + 'this_is_a_div^*': 2.5 + } + }); + }); + }); + + describe('getFirstMatchingFloor', function () { + it('selects the right floor for different mediaTypes', function () { + // banner with * size (not in rule file so does not do anything) + expect(getFirstMatchingFloor({...basicFloorData}, basicBidRequest, {mediaType: 'banner', size: '*'})).to.deep.equal({ + matchingFloor: 1.0, + matchingData: 'banner', + matchingRule: 'banner' + }); + // video with * size (not in rule file so does not do anything) + expect(getFirstMatchingFloor({...basicFloorData}, basicBidRequest, {mediaType: 'video', size: '*'})).to.deep.equal({ + matchingFloor: 5.0, + matchingData: 'video', + matchingRule: 'video' + }); + // native (not in the rule list) with * size (not in rule file so does not do anything) + expect(getFirstMatchingFloor({...basicFloorData}, basicBidRequest, {mediaType: 'native', size: '*'})).to.deep.equal({ + matchingFloor: 2.5, + matchingData: 'native', + matchingRule: '*' + }); + }); + it('selects the right floor for different sizes', function () { + let inputFloorData = { + currency: 'USD', + schema: { + delimiter: '|', + fields: ['size'] + }, + values: { + '300x250': 1.1, + '640x480': 2.2, + '728x90': 3.3, + '600x300': 4.4, + '*': 5.5, + } + } + // banner with 300x250 size + expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'banner', size: [300, 250]})).to.deep.equal({ + matchingFloor: 1.1, + matchingData: '300x250', + matchingRule: '300x250' + }); + // video with 300x250 size + expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'video', size: [300, 250]})).to.deep.equal({ + matchingFloor: 1.1, + matchingData: '300x250', + matchingRule: '300x250' + }); + // native (not in the rule list) with 300x600 size + expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'native', size: [600, 300]})).to.deep.equal({ + matchingFloor: 4.4, + matchingData: '600x300', + matchingRule: '600x300' + }); + // n/a mediaType with a size not in file should go to catch all + expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: undefined, size: [1, 1]})).to.deep.equal({ + matchingFloor: 5.5, + matchingData: '1x1', + matchingRule: '*' + }); + }); + it('selects the right floor for more complex rules', function () { + let inputFloorData = { + currency: 'USD', + schema: { + delimiter: '^', + fields: ['adUnitCode', 'mediaType', 'size'] + }, + values: { + 'test_div_1^banner^300x250': 1.1, + 'test_div_1^video^640x480': 2.2, + 'test_div_2^*^*': 3.3, + '*^banner^300x250': 4.4, + 'weird_div^*^300x250': 5.5 + }, + default: 0.5 + }; + // banner with 300x250 size + expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'banner', size: [300, 250]})).to.deep.equal({ + matchingFloor: 1.1, + matchingData: 'test_div_1^banner^300x250', + matchingRule: 'test_div_1^banner^300x250' + }); + // video with 300x250 size -> No matching rule so should use default + expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'video', size: [300, 250]})).to.deep.equal({ + matchingFloor: 0.5, + matchingData: 'test_div_1^video^300x250', + matchingRule: undefined + }); + // remove default and should still return the same floor as above since matches are cached + delete inputFloorData.default; + expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'video', size: [300, 250]})).to.deep.equal({ + matchingFloor: 0.5, + matchingData: 'test_div_1^video^300x250', + matchingRule: undefined + }); + // update adUnitCode to test_div_2 with weird other params + let newBidRequest = { ...basicBidRequest, adUnitCode: 'test_div_2' } + expect(getFirstMatchingFloor(inputFloorData, newBidRequest, {mediaType: 'badmediatype', size: [900, 900]})).to.deep.equal({ + matchingFloor: 3.3, + matchingData: 'test_div_2^badmediatype^900x900', + matchingRule: 'test_div_2^*^*' + }); + }); + it('it does not break if floorData has bad values', function () { + let inputFloorData = {}; + expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'banner', size: '*'})).to.deep.equal({ + matchingFloor: undefined + }); + // if default is there use it + inputFloorData = { default: 5.0 }; + expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'banner', size: '*'})).to.deep.equal({ + matchingFloor: 5.0 + }); + }); + }); + describe('pre-auction tests', function () { + let exposedAdUnits; + const validateBidRequests = (getFloorExpected, FloorDataExpected) => { + exposedAdUnits.forEach(adUnit => adUnit.bids.forEach(bid => { + expect(bid.hasOwnProperty('getFloor')).to.equal(getFloorExpected); + expect(bid.floorData).to.deep.equal(FloorDataExpected); + })); + }; + const runStandardAuction = (adUnits = [getAdUnitMock('test_div_1')]) => { + requestBidsHook(config => exposedAdUnits = config.adUnits, { + auctionId: basicBidRequest.auctionId, + adUnits, + }); + }; + let fakeFloorProvider; + let clock; + let actualAllowedFields = allowedFields; + let actualFieldMatchingFunctions = fieldMatchingFunctions; + const defaultAllowedFields = [...allowedFields]; + const defaultMatchingFunctions = {...fieldMatchingFunctions}; + before(function () { + clock = sinon.useFakeTimers(); + }); + after(function () { + clock.restore(); + }); + beforeEach(function() { + fakeFloorProvider = sinon.fakeServer.create(); + }); + afterEach(function() { + fakeFloorProvider.restore(); + exposedAdUnits = undefined; + actualAllowedFields = [...defaultAllowedFields]; + actualFieldMatchingFunctions = {...defaultMatchingFunctions}; + }); + it('should not do floor stuff if no resulting floor object can be resolved for auciton', function () { + handleSetFloorsConfig({ + ...basicFloorConfig, + data: undefined + }); + runStandardAuction(); + validateBidRequests(false, undefined); + }); + it('should use adUnit level data if not setConfig or fetch has occured', function () { + handleSetFloorsConfig({ + ...basicFloorConfig, + data: undefined + }); + // attach floor data onto an adUnit and run an auction + let adUnitWithFloors1 = { + ...getAdUnitMock('adUnit-Div-1'), + floors: { + ...basicFloorData, + modelVersion: 'adUnit Model Version', // change the model name + } + }; + let adUnitWithFloors2 = { + ...getAdUnitMock('adUnit-Div-2'), + floors: { + ...basicFloorData, + values: { + 'banner': 5.0, + '*': 10.4 + } + } + }; + runStandardAuction([adUnitWithFloors1, adUnitWithFloors2]); + validateBidRequests(true, { + skipped: false, + modelVersion: 'adUnit Model Version', + location: 'adUnit', + }); + }); + it('bidRequests should have getFloor function and flooring meta data when setConfig occurs', function () { + handleSetFloorsConfig({...basicFloorConfig}); + runStandardAuction(); + validateBidRequests(true, { + skipped: false, + modelVersion: 'basic model', + location: 'setConfig', + }); + }); + it('should not overwrite previous data object if the new one is bad', function () { + handleSetFloorsConfig({...basicFloorConfig}); + handleSetFloorsConfig({ + ...basicFloorConfig, + data: undefined + }); + handleSetFloorsConfig({ + ...basicFloorConfig, + data: 5 + }); + handleSetFloorsConfig({ + ...basicFloorConfig, + data: { + schema: {fields: ['thisIsNotAllowedSoShouldFail']}, + values: {'*': 1.2}, + modelVersion: 'FAIL' + } + }); + runStandardAuction(); + validateBidRequests(true, { + skipped: false, + modelVersion: 'basic model', + location: 'setConfig', + }); + }); + it('should dynamically add new schema fileds and functions if added via setConfig', function () { + let deviceSpoof; + handleSetFloorsConfig({ + ...basicFloorConfig, + data: { + schema: {fields: ['deviceType']}, + values: { + 'mobile': 1.0, + 'desktop': 2.0, + 'tablet': 3.0, + '*': 4.0 + } + }, + additionalSchemaFields: { + deviceType: () => deviceSpoof + } + }); + expect(allowedFields).to.contain('deviceType'); + expect(fieldMatchingFunctions['deviceType']).to.be.a('function'); + + // run getFloor to make sure it selcts right stuff! (other params do not matter since we only are testing deviceType) + runStandardAuction(); + + // set deviceType to mobile; + deviceSpoof = 'mobile'; + exposedAdUnits[0].bids[0].auctionId = basicBidRequest.auctionId + expect(exposedAdUnits[0].bids[0].getFloor()).to.deep.equal({ + currency: 'USD', + floor: 1.0 // 'mobile': 1.0, + }); + + // set deviceType to desktop; + deviceSpoof = 'desktop'; + expect(exposedAdUnits[0].bids[0].getFloor()).to.deep.equal({ + currency: 'USD', + floor: 2.0 // 'desktop': 2.0, + }); + + // set deviceType to tablet; + deviceSpoof = 'tablet'; + expect(exposedAdUnits[0].bids[0].getFloor()).to.deep.equal({ + currency: 'USD', + floor: 3.0 // 'tablet': 3.0 + }); + + // set deviceType to unknown; + deviceSpoof = 'unknown'; + expect(exposedAdUnits[0].bids[0].getFloor()).to.deep.equal({ + currency: 'USD', + floor: 4.0 // '*': 4.0 + }); + }); + it('Should continue auction of delay is hit without a response from floor provider', function () { + handleSetFloorsConfig({...basicFloorConfig, auctionDelay: 250, endpoint: {url: 'http://www.fakeFloorProvider.json'}}); + + // start the auction it should delay and not immediately call `continueAuction` + runStandardAuction(); + + // exposedAdUnits should be undefined if the auction has not continued + expect(exposedAdUnits).to.be.undefined; + + // hit the delay + clock.tick(250); + + // log warn should be called and adUnits not undefined + expect(logWarnSpy.calledOnce).to.equal(true); + expect(exposedAdUnits).to.not.be.undefined; + + // the exposedAdUnits should be from the fetch not setConfig level data + validateBidRequests(true, { + skipped: false, + modelVersion: 'basic model', + location: 'setConfig', + }); + fakeFloorProvider.respond(); + }); + it('It should fetch if config has url and bidRequests have fetch level flooring meta data', function () { + // init the fake server with response stuff + let fetchFloorData = { + ...basicFloorData, + modelVersion: 'fetch model name', // change the model name + }; + fakeFloorProvider.respondWith(JSON.stringify(fetchFloorData)); + + // run setConfig indicating fetch + handleSetFloorsConfig({...basicFloorConfig, auctionDelay: 250, endpoint: {url: 'http://www.fakeFloorProvider.json'}}); + + // floor provider should be called + expect(fakeFloorProvider.requests.length).to.equal(1); + expect(fakeFloorProvider.requests[0].url).to.equal('http://www.fakeFloorProvider.json'); + + // start the auction it should delay and not immediately call `continueAuction` + runStandardAuction(); + + // exposedAdUnits should be undefined if the auction has not continued + expect(exposedAdUnits).to.be.undefined; + + // make the fetch respond + fakeFloorProvider.respond(); + expect(exposedAdUnits).to.not.be.undefined; + + // the exposedAdUnits should be from the fetch not setConfig level data + validateBidRequests(true, { + skipped: false, + modelVersion: 'fetch model name', + location: 'fetch', + }); + }); + it('Should not break if floor provider returns non json', function () { + fakeFloorProvider.respondWith('Not valid response'); + + // run setConfig indicating fetch + handleSetFloorsConfig({...basicFloorConfig, auctionDelay: 250, endpoint: {url: 'http://www.fakeFloorProvider.json'}}); + + // run the auction and make server respond + fakeFloorProvider.respond(); + runStandardAuction(); + + // should have caught the response error and still used setConfig data + validateBidRequests(true, { + skipped: false, + modelVersion: 'basic model', + location: 'setConfig', + }); + }); + it('should handle not using fetch correctly', function () { + // run setConfig twice indicating fetch + fakeFloorProvider.respondWith(JSON.stringify(basicFloorData)); + handleSetFloorsConfig({...basicFloorConfig, auctionDelay: 250, endpoint: {url: 'http://www.fakeFloorProvider.json'}}); + handleSetFloorsConfig({...basicFloorConfig, auctionDelay: 250, endpoint: {url: 'http://www.fakeFloorProvider.json'}}); + + // log warn should be called and server only should have one request + expect(logWarnSpy.calledOnce).to.equal(true); + expect(fakeFloorProvider.requests.length).to.equal(1); + expect(fakeFloorProvider.requests[0].url).to.equal('http://www.fakeFloorProvider.json'); + + // now we respond and then run again it should work and make another request + fakeFloorProvider.respond(); + handleSetFloorsConfig({...basicFloorConfig, auctionDelay: 250, endpoint: {url: 'http://www.fakeFloorProvider.json'}}); + fakeFloorProvider.respond(); + + // now warn still only called once and server called twice + expect(logWarnSpy.calledOnce).to.equal(true); + expect(fakeFloorProvider.requests.length).to.equal(2); + + // should log error if method is not GET for now + expect(logErrorSpy.calledOnce).to.equal(false); + handleSetFloorsConfig({...basicFloorConfig, endpoint: {url: 'http://www.fakeFloorProvider.json', method: 'POST'}}); + expect(logErrorSpy.calledOnce).to.equal(true); + }); + describe('isFloorsDataValid', function () { + it('should work correctly for fields array', function () { + let inputFloorData = utils.deepClone(basicFloorData); + expect(isFloorsDataValid(inputFloorData)).to.to.equal(true); + + // no fields array + delete inputFloorData.schema.fields; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(false); + + // Fields is not an array + inputFloorData.schema.fields = {}; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(false); + inputFloorData.schema.fields = undefined; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(false); + inputFloorData.schema.fields = 'adUnitCode'; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(false); + + // fields has a value that is not one of the "allowed" fields + inputFloorData.schema.fields = ['adUnitCode', 'notValidMapping']; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(false); + }); + it('should work correctly for values object', function () { + let inputFloorData = utils.deepClone(basicFloorData); + expect(isFloorsDataValid(inputFloorData)).to.to.equal(true); + + // no values object + delete inputFloorData.values; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(false); + + // values is not correct type + inputFloorData.values = []; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(false); + inputFloorData.values = '123455/slot'; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(false); + + // is an object but structure is wrong + inputFloorData.values = { + 'banner': 'not a floor value' + }; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(false); + inputFloorData.values = { + 'banner': undefined + }; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(false); + + // should be true if at least one rule is valid + inputFloorData.schema.fields = ['adUnitCode', 'mediaType']; + inputFloorData.values = { + 'banner': 1.0, + 'test-div-1|native': 1.0, // only valid rule should still work and delete the other rules + 'video': 1.0, + '*': 1.0 + }; + expect(isFloorsDataValid(inputFloorData)).to.to.equal(true); + expect(inputFloorData.values).to.deep.equal({ 'test-div-1|native': 1.0 }); + }); + }); + describe('getFloor', function () { + let bidRequest = { + ...basicBidRequest, + getFloor + }; + it('returns empty if no matching data for auction is found', function () { + expect(bidRequest.getFloor({})).to.deep.equal({}); + }); + it('picks the right rule depending on input', function () { + _floorDataForAuction[bidRequest.auctionId] = utils.deepClone(basicFloorConfig); + + // empty params into getFloor should use default of banner * FloorData Curr + let inputParams = {}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 1.0 + }); + + // ask for banner + inputParams = {mediaType: 'banner'}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 1.0 + }); + + // ask for video + inputParams = {mediaType: 'video'}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 5.0 + }); + + // ask for * + inputParams = {mediaType: '*'}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 2.5 + }); + }); + it('picks the right rule with more complex rules', function () { + _floorDataForAuction[bidRequest.auctionId] = { + ...basicFloorConfig, + data: { + currency: 'USD', + schema: { fields: ['mediaType', 'size'], delimiter: '|' }, + values: { + 'banner|300x250': 0.5, + 'banner|300x600': 1.5, + 'banner|728x90': 2.5, + 'banner|*': 3.5, + 'video|640x480': 4.5, + 'video|*': 5.5 + }, + default: 10.0 + } + }; + + // assumes banner * + let inputParams = {}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 3.5 + }); + + // ask for banner with a size + inputParams = {mediaType: 'banner', size: [300, 600]}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 1.5 + }); + + // ask for video with a size + inputParams = {mediaType: 'video', size: [640, 480]}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 4.5 + }); + + // ask for video with a size not in rules (should pick rule which has video and *) + inputParams = {mediaType: 'video', size: [111, 222]}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 5.5 + }); + + // ask for native * but no native rule so should use default value if there + inputParams = {mediaType: 'native', size: '*'}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 10.0 + }); + }); + it('should round up to 4 decimal places', function () { + _floorDataForAuction[bidRequest.auctionId] = utils.deepClone(basicFloorConfig); + _floorDataForAuction[bidRequest.auctionId].data.values = { + 'banner': 1.777777, + 'video': 1.1111111, + }; + + // assumes banner * + let inputParams = {mediaType: 'banner'}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 1.7778 + }); + + // assumes banner * + inputParams = {mediaType: 'video'}; + expect(bidRequest.getFloor(inputParams)).to.deep.equal({ + currency: 'USD', + floor: 1.1112 + }); + }); + it('should return the adjusted floor if bidder has cpm adjustment function', function () { + getGlobal().bidderSettings = { + rubicon: { + bidCpmAdjustment: function (bidCpm) { + return bidCpm * 0.5; + }, + }, + appnexus: { + bidCpmAdjustment: function (bidCpm) { + return bidCpm * 0.75; + }, + } + }; + _floorDataForAuction[bidRequest.auctionId] = utils.deepClone(basicFloorConfig); + _floorDataForAuction[bidRequest.auctionId].data.values = { '*': 1.0 }; + let appnexusBid = { + ...bidRequest, + bidder: 'appnexus' + }; + + // the conversion should be what the bidder would need to return in order to match the actual floor + // rubicon + expect(bidRequest.getFloor()).to.deep.equal({ + currency: 'USD', + floor: 2.0 // a 2.0 bid after rubicons cpm adjustment would be 1.0 and thus is the floor after adjust + }); + + // appnexus + expect(appnexusBid.getFloor()).to.deep.equal({ + currency: 'USD', + floor: 1.3334 // 1.3334 * 0.75 = 1.000005 which is the floor (we cut off getFloor at 4 decimal points) + }); + }); + it('should correctly pick the right attributes if * is passed in and context can be assumed', function () { + let inputBidReq = { + bidder: 'rubicon', + adUnitCode: 'test_div_2', + auctionId: '987654321', + mediaTypes: { + video: {} + }, + getFloor + }; + _floorDataForAuction[inputBidReq.auctionId] = utils.deepClone(basicFloorConfig); + _floorDataForAuction[inputBidReq.auctionId].data.values = { + '*': 1.0, + 'banner': 3.0, + 'video': 5.0 + }; + + // because bid req only has video, if a bidder asks for a floor for * we can actually give them the right mediaType + expect(inputBidReq.getFloor({mediaType: '*'})).to.deep.equal({ + currency: 'USD', + floor: 5.0 // 'video': 5.0 + }); + delete _floorDataForAuction[inputBidReq.auctionId].data.matchingInputs; + + // Same for if only banner is in the input bid + inputBidReq.mediaTypes = {banner: {}}; + expect(inputBidReq.getFloor({mediaType: '*'})).to.deep.equal({ + currency: 'USD', + floor: 3.0 // 'banner': 3.0, + }); + delete _floorDataForAuction[inputBidReq.auctionId].data.matchingInputs; + + // if both are present then it will really use the * + inputBidReq.mediaTypes = {banner: {}, video: {}}; + expect(inputBidReq.getFloor({mediaType: '*'})).to.deep.equal({ + currency: 'USD', + floor: 1.0 // '*': 1.0, + }); + delete _floorDataForAuction[inputBidReq.auctionId].data.matchingInputs; + + // now if size can be inferred (meaning only one size is in the specified mediaType, it will use it) + _floorDataForAuction[inputBidReq.auctionId].data.schema.fields = ['mediaType', 'size']; + _floorDataForAuction[inputBidReq.auctionId].data.values = { + '*|*': 1.0, + 'banner|300x250': 2.0, + 'banner|728x90': 3.0, + 'banner|*': 4.0, + 'video|300x250': 5.0, + 'video|728x90': 6.0, + 'video|*': 7.0 + }; + // mediaType is banner and only one size, so if someone asks for banner * we should give them banner 300x250 + // instead of banner|* + inputBidReq.mediaTypes = {banner: {sizes: [[300, 250]]}}; + expect(inputBidReq.getFloor({mediaType: 'banner', size: '*'})).to.deep.equal({ + currency: 'USD', + floor: 2.0 // 'banner|300x250': 2.0, + }); + delete _floorDataForAuction[inputBidReq.auctionId].data.matchingInputs; + + // now for video it should look at playersize (prebid core translates playersize into typical array of size arrays) + inputBidReq.mediaTypes = {video: {playerSize: [[728, 90]]}}; + expect(inputBidReq.getFloor({mediaType: 'video', size: '*'})).to.deep.equal({ + currency: 'USD', + floor: 6.0 // 'video|728x90': 6.0, + }); + delete _floorDataForAuction[inputBidReq.auctionId].data.matchingInputs; + + // Now if multiple sizes are there, it will actually use * since can't infer + inputBidReq.mediaTypes = {banner: {sizes: [[300, 250], [728, 90]]}}; + expect(inputBidReq.getFloor({mediaType: 'banner', size: '*'})).to.deep.equal({ + currency: 'USD', + floor: 4.0 // 'banner|*': 4.0, + }); + delete _floorDataForAuction[inputBidReq.auctionId].data.matchingInputs; + + // lastly, if you pass in * mediaType and * size it should resolve both if possble + inputBidReq.mediaTypes = {banner: {sizes: [[300, 250]]}}; + expect(inputBidReq.getFloor({mediaType: '*', size: '*'})).to.deep.equal({ + currency: 'USD', + floor: 2.0 // 'banner|300x250': 2.0, + }); + delete _floorDataForAuction[inputBidReq.auctionId].data.matchingInputs; + + inputBidReq.mediaTypes = {video: {playerSize: [[300, 250]]}}; + expect(inputBidReq.getFloor({mediaType: '*', size: '*'})).to.deep.equal({ + currency: 'USD', + floor: 5.0 // 'video|300x250': 5.0, + }); + delete _floorDataForAuction[inputBidReq.auctionId].data.matchingInputs; + + // now it has both mediaTypes so will use * mediaType and thus not use sizes either + inputBidReq.mediaTypes = {video: {playerSize: [[300, 250]]}, banner: {sizes: [[300, 250]]}}; + expect(inputBidReq.getFloor({mediaType: '*', size: '*'})).to.deep.equal({ + currency: 'USD', + floor: 1.0 // '*|*': 1.0, + }); + delete _floorDataForAuction[inputBidReq.auctionId].data.matchingInputs; + }); + }); + }); + describe('bidResponseHook tests', function () { + let returnedBidResponse; + let bidderRequest = { + bidderCode: 'appnexus', + auctionId: '123456', + bids: [{ + bidder: 'appnexus', + adUnitCode: 'test_div_1', + auctionId: '123456', + bidId: '1111' + }] + }; + let basicBidResponse = { + bidderCode: 'appnexus', + width: 300, + height: 250, + cpm: 0.5, + mediaType: 'banner', + requestId: '1111', + }; + beforeEach(function () { + returnedBidResponse = {}; + }); + function runBidResponse(bidResp = basicBidResponse) { + let next = (adUnitCode, bid) => { + returnedBidResponse = bid; + }; + addBidResponseHook.bind({ bidderRequest })(next, bidResp.adUnitCode, bidResp); + }; + it('continues with the auction if not floors data is present without any flooring', function () { + runBidResponse(); + expect(returnedBidResponse).to.not.haveOwnProperty('floorData'); + }); + it('if no matching rule it should not floor and should call log warn', function () { + _floorDataForAuction[bidderRequest.auctionId] = utils.deepClone(basicFloorConfig); + _floorDataForAuction[bidderRequest.auctionId].data.values = { 'video': 1.0 }; + runBidResponse(); + expect(returnedBidResponse).to.not.haveOwnProperty('floorData'); + expect(logWarnSpy.calledOnce).to.equal(true); + }); + it('if it finds a rule and floors should update the bid accordingly', function () { + _floorDataForAuction[bidderRequest.auctionId] = utils.deepClone(basicFloorConfig); + _floorDataForAuction[bidderRequest.auctionId].data.values = { 'banner': 1.0 }; + runBidResponse(); + expect(returnedBidResponse).to.haveOwnProperty('floorData'); + expect(returnedBidResponse.status).to.equal(CONSTANTS.BID_STATUS.BID_REJECTED); + expect(returnedBidResponse.cpm).to.equal(0); + }); + it('if it finds a rule and does not floor should update the bid accordingly', function () { + _floorDataForAuction[bidderRequest.auctionId] = utils.deepClone(basicFloorConfig); + _floorDataForAuction[bidderRequest.auctionId].data.values = { 'banner': 0.3 }; + runBidResponse(); + expect(returnedBidResponse).to.haveOwnProperty('floorData'); + expect(returnedBidResponse.floorData).to.deep.equal({ + floorValue: 0.3, + floorCurrency: 'USD', + floorRule: 'banner', + cpmAfterAdjustments: 0.5, + enforcements: { + bidAdjustment: true, + enforceJS: true, + enforcePBS: false, + floorDeals: false + }, + matchedFields: { + mediaType: 'banner' + } + }); + expect(returnedBidResponse.cpm).to.equal(0.5); + }); + it('if should work with more complex rules and update accordingly', function () { + _floorDataForAuction[bidderRequest.auctionId] = { + ...basicFloorConfig, + data: { + currency: 'USD', + schema: { fields: ['mediaType', 'size'], delimiter: '|' }, + values: { + 'banner|300x250': 0.5, + 'banner|300x600': 1.5, + 'banner|728x90': 2.5, + 'banner|*': 3.5, + 'video|640x480': 4.5, + 'video|*': 5.5 + }, + default: 10.0 + } + }; + runBidResponse(); + expect(returnedBidResponse).to.haveOwnProperty('floorData'); + expect(returnedBidResponse.floorData).to.deep.equal({ + floorValue: 0.5, + floorCurrency: 'USD', + floorRule: 'banner|300x250', + cpmAfterAdjustments: 0.5, + enforcements: { + bidAdjustment: true, + enforceJS: true, + enforcePBS: false, + floorDeals: false + }, + matchedFields: { + mediaType: 'banner', + size: '300x250' + } + }); + expect(returnedBidResponse.cpm).to.equal(0.5); + + // update bidResponse to have different combinations (should pick video|*) + runBidResponse({ + width: 300, + height: 250, + cpm: 7.5, + mediaType: 'video', + requestId: '1111', + }); + expect(returnedBidResponse).to.haveOwnProperty('floorData'); + expect(returnedBidResponse.floorData).to.deep.equal({ + floorValue: 5.5, + floorCurrency: 'USD', + floorRule: 'video|*', + cpmAfterAdjustments: 7.5, + enforcements: { + bidAdjustment: true, + enforceJS: true, + enforcePBS: false, + floorDeals: false + }, + matchedFields: { + mediaType: 'video', + size: '300x250' + } + }); + expect(returnedBidResponse.cpm).to.equal(7.5); + }); + }); +}); diff --git a/test/spec/modules/rubiconAnalyticsAdapter_spec.js b/test/spec/modules/rubiconAnalyticsAdapter_spec.js index 2ad828ef3e0..2d444d2c15c 100644 --- a/test/spec/modules/rubiconAnalyticsAdapter_spec.js +++ b/test/spec/modules/rubiconAnalyticsAdapter_spec.js @@ -655,6 +655,101 @@ describe('rubicon analytics adapter', function () { expect(message).to.deep.equal(ANALYTICS_MESSAGE); }); + it('should capture price floor information correctly', function () { + let auctionInit = utils.deepClone(MOCK.AUCTION_INIT); + auctionInit.bidderRequests[0].bids[0].floorData = { + skipped: false, + modelVersion: 'someModelName', + location: 'setConfig' + }; + let flooredResponse = { + ...BID, + floorData: { + floorValue: 4, + floorRule: '12345/sports|video', + floorCurrency: 'USD', + cpmAfterAdjustments: 2.1, + enforcements: { + enforceJS: true, + enforcePBS: false, + floorDeals: false, + bidAdjustment: true + }, + matchedFields: { + gptSlot: '12345/sports', + mediaType: 'video' + } + }, + status: 'bidRejected', + cpm: 0, + getStatusCode() { + return 2; + } + }; + + let notFlooredResponse = { + ...BID2, + floorData: { + floorValue: 1, + floorRule: '12345/news|banner', + floorCurrency: 'USD', + cpmAfterAdjustments: 1.55, + enforcements: { + enforceJS: true, + enforcePBS: false, + floorDeals: false, + bidAdjustment: true + }, + matchedFields: { + gptSlot: '12345/news', + mediaType: 'banner' + } + } + }; + + // spoof the auction with just our duplicates + events.emit(AUCTION_INIT, auctionInit); + events.emit(BID_REQUESTED, MOCK.BID_REQUESTED); + events.emit(BID_RESPONSE, flooredResponse); + events.emit(BID_RESPONSE, notFlooredResponse); + events.emit(AUCTION_END, MOCK.AUCTION_END); + events.emit(SET_TARGETING, MOCK.SET_TARGETING); + events.emit(BID_WON, MOCK.BID_WON[1]); + clock.tick(SEND_TIMEOUT + 1000); + + expect(server.requests.length).to.equal(1); + + let message = JSON.parse(server.requests[0].requestBody); + validate(message); + + // verify our floor stuff is passed + // top level floor info + expect(message.auctions[0].floors).to.deep.equal({ + location: 'setConfig', + modelName: 'someModelName', + skipped: false, + enforcement: true + }); + // first adUnit's adSlot + expect(message.auctions[0].adUnits[0].adSlot).to.equal('12345/sports'); + // since no other bids, we set adUnit status to no-bid + expect(message.auctions[0].adUnits[0].status).to.equal('no-bid'); + // first adUnits bid is rejected + expect(message.auctions[0].adUnits[0].bids[0].status).to.equal('rejected'); + expect(message.auctions[0].adUnits[0].bids[0].bidResponse.floorValue).to.equal(4); + // if bid rejected should take cpmAfterAdjustments val + expect(message.auctions[0].adUnits[0].bids[0].bidResponse.bidPriceUSD).to.equal(2.1); + + // second adUnit's adSlot + expect(message.auctions[0].adUnits[1].adSlot).to.equal('12345/news'); + // top level adUnit status is success + expect(message.auctions[0].adUnits[1].status).to.equal('success'); + // second adUnits bid is success + expect(message.auctions[0].adUnits[1].bids[0].status).to.equal('success'); + expect(message.auctions[0].adUnits[1].bids[0].bidResponse.floorValue).to.equal(1); + expect(message.auctions[0].adUnits[1].bids[0].bidResponse.bidPriceUSD).to.equal(1.52); + }); + it('should correctly overwrite bidId if seatBidId is on the bidResponse', function () { // Only want one bid request in our mock auction let bidRequested = utils.deepClone(MOCK.BID_REQUESTED); diff --git a/test/spec/modules/rubiconAnalyticsSchema.json b/test/spec/modules/rubiconAnalyticsSchema.json index 686aced840f..16cca629d8c 100644 --- a/test/spec/modules/rubiconAnalyticsSchema.json +++ b/test/spec/modules/rubiconAnalyticsSchema.json @@ -292,7 +292,8 @@ "enum": [ "success", "no-bid", - "error" + "error", + "rejected" ] }, "error": { diff --git a/test/spec/modules/rubiconBidAdapter_spec.js b/test/spec/modules/rubiconBidAdapter_spec.js index 7adedbcfdde..4e999f4bdab 100644 --- a/test/spec/modules/rubiconBidAdapter_spec.js +++ b/test/spec/modules/rubiconBidAdapter_spec.js @@ -15,7 +15,8 @@ const INTEGRATION = `pbjs_lite_v$prebid.version$`; // $prebid.version$ will be s describe('the rubicon adapter', function () { let sandbox, bidderRequest, - sizeMap; + sizeMap, + getFloorResponse; /** * @typedef {Object} sizeMapConverted @@ -274,7 +275,7 @@ describe('the rubicon adapter', function () { beforeEach(function () { sandbox = sinon.sandbox.create(); - + getFloorResponse = {}; bidderRequest = { bidderCode: 'rubicon', auctionId: 'c45dd708-a418-42ec-b8a7-b70a6c6fab0a', @@ -411,6 +412,43 @@ describe('the rubicon adapter', function () { }); }); + it('should correctly send hard floors when getFloor function is present and returns valid floor', function () { + // default getFloor response is empty object so should not break and not send hard_floor + bidderRequest.bids[0].getFloor = () => getFloorResponse; + let [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + let data = parseQuery(request.data); + expect(data.rp_hard_floor).to.be.undefined; + + // not an object should work and not send + getFloorResponse = undefined; + [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + data = parseQuery(request.data); + expect(data.rp_hard_floor).to.be.undefined; + + // make it respond with a non USD floor should not send it + getFloorResponse = {currency: 'EUR', floor: 1.0}; + [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + data = parseQuery(request.data); + expect(data.rp_hard_floor).to.be.undefined; + + // make it respond with a non USD floor should not send it + getFloorResponse = {currency: 'EUR'}; + [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + data = parseQuery(request.data); + expect(data.rp_hard_floor).to.be.undefined; + + // make it respond with USD floor and string floor + getFloorResponse = {currency: 'USD', floor: '1.23'}; + [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + data = parseQuery(request.data); + expect(data.rp_hard_floor).to.equal('1.23'); + + // make it respond with USD floor and num floor + getFloorResponse = {currency: 'USD', floor: 1.23}; + [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + data = parseQuery(request.data); + expect(data.rp_hard_floor).to.equal('1.23'); + }); it('should not send p_pos to AE if not params.position specified', function() { var noposRequest = utils.deepClone(bidderRequest); delete noposRequest.bids[0].params.position; @@ -1450,6 +1488,39 @@ describe('the rubicon adapter', function () { expect(post.ext.prebid.cache.vastxml.returnCreative).to.equal(false) }); + it('should correctly set bidfloor on imp when getfloor in scope', function () { + createVideoBidderRequest(); + // default getFloor response is empty object so should not break and not send hard_floor + bidderRequest.bids[0].getFloor = () => getFloorResponse; + sandbox.stub(Date, 'now').callsFake(() => + bidderRequest.auctionStart + 100 + ); + + let [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + + // not an object should work and not send + expect(request.data.imp[0].bidfloor).to.be.undefined; + + // make it respond with a non USD floor should not send it + getFloorResponse = {currency: 'EUR', floor: 1.0}; + [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + expect(request.data.imp[0].bidfloor).to.be.undefined; + + // make it respond with a non USD floor should not send it + getFloorResponse = {currency: 'EUR'}; + [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + expect(request.data.imp[0].bidfloor).to.be.undefined; + + // make it respond with USD floor and string floor + getFloorResponse = {currency: 'USD', floor: '1.23'}; + [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + expect(request.data.imp[0].bidfloor).to.equal(1.23); + + // make it respond with USD floor and num floor + getFloorResponse = {currency: 'USD', floor: 1.23}; + [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + expect(request.data.imp[0].bidfloor).to.equal(1.23); + }); it('should add alias name to PBS Request', function() { createVideoBidderRequest(); From 1aadce9fe443684ef07022c85a3dcfd0001d1c9c Mon Sep 17 00:00:00 2001 From: rmartinez Date: Thu, 5 Mar 2020 00:21:48 -0800 Subject: [PATCH 02/10] prebid uses native image size as a single array so we wrap it --- modules/priceFloors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/priceFloors.js b/modules/priceFloors.js index e41ca70d41e..cc2a0534eec 100644 --- a/modules/priceFloors.js +++ b/modules/priceFloors.js @@ -160,7 +160,7 @@ export function calculateAdjustedFloor(oldFloor, newFloor) { const getMediaTypesSizes = { banner: (bid) => utils.deepAccess(bid, 'mediaTypes.banner.sizes') || [], video: (bid) => utils.deepAccess(bid, 'mediaTypes.video.playerSize') || [], - native: (bid) => [utils.deepAccess(bid, 'mediaTypes.native.image.sizes')] || [], + native: (bid) => utils.deepAccess(bid, 'mediaTypes.native.image.sizes') ? [utils.deepAccess(bid, 'mediaTypes.native.image.sizes')] : [] } /** From 7acfbd841708b72230bd3842a5e7ce33b7a59e3d Mon Sep 17 00:00:00 2001 From: rmartinez Date: Thu, 5 Mar 2020 09:24:22 -0800 Subject: [PATCH 03/10] Forgot IE 11 no have includes! --- modules/priceFloors.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/priceFloors.js b/modules/priceFloors.js index cc2a0534eec..1f30e5c35d8 100644 --- a/modules/priceFloors.js +++ b/modules/priceFloors.js @@ -240,7 +240,7 @@ function normalizeRulesForAuction(floorData, adUnitCode) { let delimiter = floorData.schema.delimiter // if we are building the floor data form an ad unit, we need to append adUnit code as to not cause collisions - let prependAdUnitCode = adUnitCode && !fields.includes('adUnitCode') && fields.unshift('adUnitCode'); + let prependAdUnitCode = adUnitCode && fields.indexOf('adUnitCode') === -1 && fields.unshift('adUnitCode'); return Object.keys(floorData.values).reduce((rulesHash, oldKey) => { let newKey = prependAdUnitCode ? `${adUnitCode}${delimiter}${oldKey}` : oldKey // we store the rule keys as lower case for case insensitive compare @@ -336,7 +336,7 @@ export function continueAuction(hookConfig) { } function validateSchemaFields(fields) { - if (Array.isArray(fields) && fields.length > 0 && fields.every(field => allowedFields.includes(field))) { + if (Array.isArray(fields) && fields.length > 0 && fields.every(field => allowedFields.indexOf(field) !== -1)) { return true; } utils.logError(`${MODULE_NAME}: Fields recieved do not match allowed fields`); From 33963209ccd19f4d9c6fb230dbfc545a89be8941 Mon Sep 17 00:00:00 2001 From: rmartinez Date: Thu, 5 Mar 2020 15:07:23 -0800 Subject: [PATCH 04/10] if enforcement not passed in default it Exact match not found, then * --- modules/priceFloors.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/priceFloors.js b/modules/priceFloors.js index 1f30e5c35d8..52d7c6b4803 100644 --- a/modules/priceFloors.js +++ b/modules/priceFloors.js @@ -84,7 +84,7 @@ export let fieldMatchingFunctions = { function enumeratePossibleFieldValues(floorFields, bidObject, responseObject) { // generate combination of all exact matches and catch all for each field type return floorFields.reduce((accum, field) => { - let exactMatch = fieldMatchingFunctions[field](bidObject, responseObject); + let exactMatch = fieldMatchingFunctions[field](bidObject, responseObject) || '*'; // storing exact matches as lowerCase since we want to compare case insensitively accum.push(exactMatch === '*' ? ['*'] : [exactMatch.toLowerCase(), '*']); return accum; @@ -503,7 +503,7 @@ export function handleSetFloorsConfig(config) { 'enabled', enabled => enabled !== false, // defaults to true 'auctionDelay', auctionDelay => auctionDelay || 0, 'endpoint', endpoint => endpoint || {}, - 'enforcement', enforcement => utils.pick(enforcement, [ + 'enforcement', enforcement => utils.pick(enforcement || {}, [ 'enforceJS', enforceJS => enforceJS !== false, // defaults to true 'enforcePBS', enforcePBS => enforcePBS === true, // defaults to false 'floorDeals', floorDeals => floorDeals === true, // defaults to false From 859bc883a83cd327ad556c074fcfa3e981404fc7 Mon Sep 17 00:00:00 2001 From: rmartinez Date: Thu, 5 Mar 2020 15:55:38 -0800 Subject: [PATCH 05/10] no need to decode uri entire url when just wanting hostname --- modules/priceFloors.js | 2 +- modules/rubiconAnalyticsAdapter.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/priceFloors.js b/modules/priceFloors.js index 52d7c6b4803..b1ad1896e4a 100644 --- a/modules/priceFloors.js +++ b/modules/priceFloors.js @@ -61,7 +61,7 @@ function roundUp(number, precision) { let referrerHostname; function getHostNameFromReferer(referer) { - referrerHostname = urlParse(referer).hostname; + referrerHostname = urlParse(referer, {noDecodeWholeURL: true}).hostname; return referrerHostname; } diff --git a/modules/rubiconAnalyticsAdapter.js b/modules/rubiconAnalyticsAdapter.js index a2f99d1d847..ed1a7c54560 100644 --- a/modules/rubiconAnalyticsAdapter.js +++ b/modules/rubiconAnalyticsAdapter.js @@ -44,7 +44,7 @@ const cache = { let referrerHostname; function getHostNameFromReferer(referer) { - referrerHostname = urlLib.parse(referer).hostname; + referrerHostname = urlLib.parse(referer, {noDecodeWholeURL: true}).hostname; return referrerHostname; }; From c2ceeee4d8fbe982289f8af48223a6b6f7fc470e Mon Sep 17 00:00:00 2001 From: rmartinez Date: Mon, 9 Mar 2020 11:51:19 -0700 Subject: [PATCH 06/10] setting auctionId on bidRequests so `onBeforeRequestBids` has context for getFloor() --- modules/priceFloors.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/modules/priceFloors.js b/modules/priceFloors.js index b1ad1896e4a..7305dcf178d 100644 --- a/modules/priceFloors.js +++ b/modules/priceFloors.js @@ -273,7 +273,7 @@ export function getFloorDataFromAdUnits(adUnits) { /** * @summary This function takes the adUnits for the auction and update them accordingly as well as returns the rules hashmap for the auction */ -export function updateAdUnitsForAuction(adUnits, floorData, skipped) { +export function updateAdUnitsForAuction(adUnits, floorData, skipped, auctionId) { adUnits.forEach((adUnit) => { adUnit.bids.forEach(bid => { if (skipped) { @@ -282,6 +282,7 @@ export function updateAdUnitsForAuction(adUnits, floorData, skipped) { bid.getFloor = getFloor; } // information for bid and analytics adapters + bid.auctionId = auctionId; bid.floorData = { skipped, modelVersion: utils.deepAccess(floorData, 'data.modelVersion') || '', @@ -294,7 +295,7 @@ export function updateAdUnitsForAuction(adUnits, floorData, skipped) { /** * @summary Updates the adUnits accordingly and returns the necessary floorsData for the current auction */ -export function createFloorsDataForAuction(adUnits) { +export function createFloorsDataForAuction(adUnits, auctionId) { let resolvedFloorsData = utils.deepClone(_floorsConfig); // if we do not have a floors data set, we will try to use data set on adUnits @@ -311,7 +312,7 @@ export function createFloorsDataForAuction(adUnits) { // determine the skip rate now const isSkipped = Math.random() * 100 < parseFloat(utils.deepAccess(resolvedFloorsData, 'data.skipRate') || 0); resolvedFloorsData.skipped = isSkipped; - updateAdUnitsForAuction(adUnits, resolvedFloorsData, isSkipped); + updateAdUnitsForAuction(adUnits, resolvedFloorsData, isSkipped, auctionId); return resolvedFloorsData; } @@ -328,7 +329,7 @@ export function continueAuction(hookConfig) { hookConfig.reqBidsConfigObj.auctionId = hookConfig.reqBidsConfigObj.auctionId || utils.generateUUID(); // now we do what we need to with adUnits and save the data object to be used for getFloor and enforcement calls - _floorDataForAuction[hookConfig.reqBidsConfigObj.auctionId] = createFloorsDataForAuction(hookConfig.reqBidsConfigObj.adUnits || getGlobal().adUnits); + _floorDataForAuction[hookConfig.reqBidsConfigObj.auctionId] = createFloorsDataForAuction(hookConfig.reqBidsConfigObj.adUnits || getGlobal().adUnits, hookConfig.reqBidsConfigObj.auctionId); hookConfig.nextFn.apply(hookConfig.context, [hookConfig.reqBidsConfigObj]); hookConfig.hasExited = true; From cdae951e17e73ad12ae6beed7ea966077fe7f503 Mon Sep 17 00:00:00 2001 From: rmartinez Date: Mon, 23 Mar 2020 09:46:45 -0700 Subject: [PATCH 07/10] rubi analytics new values --- modules/rubiconAnalyticsAdapter.js | 9 ++++++--- test/spec/modules/rubiconAnalyticsAdapter_spec.js | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/rubiconAnalyticsAdapter.js b/modules/rubiconAnalyticsAdapter.js index ed1a7c54560..dccd8d9c8fd 100644 --- a/modules/rubiconAnalyticsAdapter.js +++ b/modules/rubiconAnalyticsAdapter.js @@ -103,7 +103,8 @@ function sendMessage(auctionId, bidWonId) { 'dealId', 'dimensions', 'mediaType', - 'floorValue' + 'floorValue', + 'floorRule' ]) : undefined ]); } @@ -196,7 +197,8 @@ function sendMessage(auctionId, bidWonId) { 'location', 'modelName', () => auctionCache.floorData.modelVersion || '', 'skipped', - 'enforcement', () => utils.deepAccess(auctionCache.floorData, 'enforcements.enforceJS') + 'enforcement', () => utils.deepAccess(auctionCache.floorData, 'enforcements.enforceJS'), + 'dealsEnforced', () => utils.deepAccess(auctionCache.floorData, 'enforcements.floorDeals') ]); } @@ -280,7 +282,8 @@ export function parseBidResponse(bid, previousBidResponse) { 'height' ]), 'seatBidId', - 'floorValue', () => utils.deepAccess(bid, 'floorData.floorValue') + 'floorValue', () => utils.deepAccess(bid, 'floorData.floorValue'), + 'floorRule', () => utils.debugTurnedOn() ? utils.deepAccess(bid, 'floorData.floorRule') : undefined ]); } diff --git a/test/spec/modules/rubiconAnalyticsAdapter_spec.js b/test/spec/modules/rubiconAnalyticsAdapter_spec.js index 2d444d2c15c..1de36625958 100644 --- a/test/spec/modules/rubiconAnalyticsAdapter_spec.js +++ b/test/spec/modules/rubiconAnalyticsAdapter_spec.js @@ -728,7 +728,8 @@ describe('rubicon analytics adapter', function () { location: 'setConfig', modelName: 'someModelName', skipped: false, - enforcement: true + enforcement: true, + dealsEnforced: false }); // first adUnit's adSlot expect(message.auctions[0].adUnits[0].adSlot).to.equal('12345/sports'); From 9eb2fc1e01cd047e3ff9ba90e1ea90aca24c6011 Mon Sep 17 00:00:00 2001 From: rmartinez Date: Mon, 30 Mar 2020 10:32:11 -0700 Subject: [PATCH 08/10] js rounding fix + isNaN handling --- modules/priceFloors.js | 4 +++- modules/rubiconBidAdapter.js | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/priceFloors.js b/modules/priceFloors.js index 7305dcf178d..dfcad4507bf 100644 --- a/modules/priceFloors.js +++ b/modules/priceFloors.js @@ -149,9 +149,11 @@ export function getBiddersCpmAdjustment(bidderName, inputCpm) { /** * @summary This function takes the original floor and the adjusted floor in order to determine the bidders actual floor + * With js rounding errors with decimal division we utilize similar method as shown in cpmBucketManager.js */ export function calculateAdjustedFloor(oldFloor, newFloor) { - return oldFloor / newFloor * oldFloor; + const pow = Math.pow(10, 10); + return ( oldFloor * pow / newFloor * oldFloor) / pow; } /** diff --git a/modules/rubiconBidAdapter.js b/modules/rubiconBidAdapter.js index b943acd487c..291a81ac345 100644 --- a/modules/rubiconBidAdapter.js +++ b/modules/rubiconBidAdapter.js @@ -192,7 +192,7 @@ export const spec = { mediaType: 'video', size: parseSizes(bidRequest, 'video') }); - bidFloor = typeof floorInfo === 'object' && floorInfo.currency === 'USD' && !isNaN(floorInfo.floor) ? parseFloat(floorInfo.floor) : undefined; + bidFloor = typeof floorInfo === 'object' && floorInfo.currency === 'USD' && !isNaN(parseInt(floorInfo.floor)) ? parseFloat(floorInfo.floor) : undefined; } else { bidFloor = parseFloat(utils.deepAccess(bidRequest, 'params.floor')); } @@ -504,7 +504,7 @@ export const spec = { mediaType: 'banner', size: '*' }); - data['rp_hard_floor'] = typeof floorInfo === 'object' && floorInfo.currency === 'USD' && !isNaN(floorInfo.floor) ? floorInfo.floor : undefined; + data['rp_hard_floor'] = typeof floorInfo === 'object' && floorInfo.currency === 'USD' && !isNaN(parseInt(floorInfo.floor)) ? floorInfo.floor : undefined; } // add p_pos only if specified and valid From ef439db6e210d7986c3f3103a7cfc6d4ea0f0290 Mon Sep 17 00:00:00 2001 From: rmartinez Date: Mon, 30 Mar 2020 10:47:29 -0700 Subject: [PATCH 09/10] random parenthesis --- modules/priceFloors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/priceFloors.js b/modules/priceFloors.js index dfcad4507bf..ab3b1aeee09 100644 --- a/modules/priceFloors.js +++ b/modules/priceFloors.js @@ -153,7 +153,7 @@ export function getBiddersCpmAdjustment(bidderName, inputCpm) { */ export function calculateAdjustedFloor(oldFloor, newFloor) { const pow = Math.pow(10, 10); - return ( oldFloor * pow / newFloor * oldFloor) / pow; + return (oldFloor * pow / newFloor * oldFloor) / pow; } /** From 91ad99f88e8e97caa7e5beb7a10209f10e5e1025 Mon Sep 17 00:00:00 2001 From: rmartinez Date: Mon, 30 Mar 2020 15:00:53 -0700 Subject: [PATCH 10/10] fixing formula --- modules/priceFloors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/priceFloors.js b/modules/priceFloors.js index ab3b1aeee09..2ee5fb3b0fd 100644 --- a/modules/priceFloors.js +++ b/modules/priceFloors.js @@ -153,7 +153,7 @@ export function getBiddersCpmAdjustment(bidderName, inputCpm) { */ export function calculateAdjustedFloor(oldFloor, newFloor) { const pow = Math.pow(10, 10); - return (oldFloor * pow / newFloor * oldFloor) / pow; + return ((oldFloor * pow) / (newFloor * pow) * (oldFloor * pow)) / pow; } /**