Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

priceFloors: fix bug where default does not work on adUnit-level floors #10475

Merged
merged 3 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions modules/priceFloors.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
mergeDeep,
parseGPTSingleSizeArray,
parseUrl,
pick
pick,
deepEqual
} from '../src/utils.js';
import {getGlobal} from '../src/prebidGlobal.js';
import {config} from '../src/config.js';
Expand All @@ -40,10 +41,13 @@ const MODULE_NAME = 'Price Floors';
*/
const ajax = ajaxBuilder(10000);

// eslint-disable-next-line symbol-description
const SYN_FIELD = Symbol();

/**
* @summary Allowed fields for rules to have
*/
export let allowedFields = ['gptSlot', 'adUnitCode', 'size', 'domain', 'mediaType'];
export let allowedFields = [SYN_FIELD, 'gptSlot', 'adUnitCode', 'size', 'domain', 'mediaType'];

/**
* @summary This is a flag to indicate if a AJAX call is processing for a floors request
Expand Down Expand Up @@ -104,6 +108,7 @@ function getAdUnitCode(request, response, {index = auctionManager.index} = {}) {
* @summary floor field types with their matching functions to resolve the actual matched value
*/
export let fieldMatchingFunctions = {
[SYN_FIELD]: () => '*',
'size': (bidRequest, bidResponse) => parseGPTSingleSizeArray(bidResponse.size) || '*',
'mediaType': (bidRequest, bidResponse) => bidResponse.mediaType || 'banner',
'gptSlot': (bidRequest, bidResponse) => getGptSlotFromAdUnit((bidRequest || bidResponse).transactionId) || getGptSlotInfoForAdUnitCode(getAdUnitCode(bidRequest, bidResponse)).gptSlot,
Expand All @@ -117,6 +122,7 @@ export let fieldMatchingFunctions = {
* Returns array of Tuple [exact match, catch all] for each field in rules file
*/
function enumeratePossibleFieldValues(floorFields, bidObject, responseObject) {
if (!floorFields.length) return [];
// generate combination of all exact matches and catch all for each field type
return floorFields.reduce((accum, field) => {
let exactMatch = fieldMatchingFunctions[field](bidObject, responseObject) || '*';
Expand All @@ -132,7 +138,9 @@ function enumeratePossibleFieldValues(floorFields, bidObject, responseObject) {
*/
export function getFirstMatchingFloor(floorData, bidObject, responseObject = {}) {
let fieldValues = enumeratePossibleFieldValues(deepAccess(floorData, 'schema.fields') || [], bidObject, responseObject);
if (!fieldValues.length) return { matchingFloor: floorData.default };
if (!fieldValues.length) {
return {matchingFloor: undefined}
}

// look to see if a request for this context was made already
let matchingInput = fieldValues.map(field => field[0]).join('-');
Expand All @@ -146,9 +154,9 @@ export function getFirstMatchingFloor(floorData, bidObject, responseObject = {})

let matchingData = {
floorMin: floorData.floorMin || 0,
floorRuleValue: isNaN(floorData.values[matchingRule]) ? floorData.default : floorData.values[matchingRule],
floorRuleValue: floorData.values[matchingRule],
matchingData: allPossibleMatches[0], // the first possible match is an "exact" so contains all data relevant for anlaytics adapters
matchingRule
matchingRule: matchingRule === floorData.meta?.defaultRule ? undefined : matchingRule
};
// use adUnit floorMin as priority!
const floorMin = deepAccess(bidObject, 'ortb2Imp.ext.prebid.floors.floorMin');
Expand Down Expand Up @@ -300,14 +308,20 @@ function normalizeRulesForAuction(floorData, adUnitCode) {
* Only called if no set config or fetch level data has returned
*/
export function getFloorDataFromAdUnits(adUnits) {
const schemaAu = adUnits.find(au => au.floors?.schema != null);
return adUnits.reduce((accum, adUnit) => {
if (isFloorsDataValid(adUnit.floors)) {
if (adUnit.floors?.schema != null && !deepEqual(adUnit.floors.schema, schemaAu?.floors?.schema)) {
logError(`${MODULE_NAME}: adUnit '${adUnit.code}' declares a different schema from one previously declared by adUnit '${schemaAu.code}'. Floor config for '${adUnit.code}' will be ignored.`)
return accum;
}
const floors = Object.assign({}, schemaAu?.floors, {values: undefined}, adUnit.floors)
if (isFloorsDataValid(floors)) {
// if values already exist we want to not overwrite them
if (!accum.values) {
accum = getFloorsDataForAuction(adUnit.floors, adUnit.code);
accum = getFloorsDataForAuction(floors, adUnit.code);
accum.location = 'adUnit';
} else {
let newRules = getFloorsDataForAuction(adUnit.floors, adUnit.code).values;
let newRules = getFloorsDataForAuction(floors, adUnit.code).values;
// copy over the new rules into our values object
Object.assign(accum.values, newRules);
}
Expand Down Expand Up @@ -443,7 +457,26 @@ function validateRules(floorsData, numFields, delimiter) {
return Object.keys(floorsData.values).length > 0;
}

export function normalizeDefault(model) {
if (isNumber(model.default)) {
let defaultRule = '*';
const numFields = (model.schema?.fields || []).length;
if (!numFields) {
deepSetValue(model, 'schema.fields', [SYN_FIELD]);
} else {
defaultRule = Array(numFields).fill('*').join(model.schema?.delimiter || '|');
}
model.values = model.values || {};
if (model.values[defaultRule] == null) {
model.values[defaultRule] = model.default;
model.meta = {defaultRule};
}
}
return model;
}

function modelIsValid(model) {
model = normalizeDefault(model);
// schema.fields has only allowed attributes
if (!validateSchemaFields(deepAccess(model, 'schema.fields'))) {
return false;
Expand Down
183 changes: 170 additions & 13 deletions test/spec/modules/priceFloors_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
isFloorsDataValid,
addBidResponseHook,
fieldMatchingFunctions,
allowedFields
allowedFields, parseFloorData, normalizeDefault, getFloorDataFromAdUnits
} from 'modules/priceFloors.js';
import * as events from 'src/events.js';
import * as mockGpt from '../integration/faker/googletag.js';
Expand Down Expand Up @@ -123,7 +123,7 @@ describe('the price floors module', function () {
return {
code,
mediaTypes: {banner: { sizes: [[300, 200], [300, 600]] }, native: {}},
bids: [{bidder: 'someBidder'}, {bidder: 'someOtherBidder'}]
bids: [{bidder: 'someBidder', adUnitCode: code}, {bidder: 'someOtherBidder', adUnitCode: code}]
};
}
beforeEach(function() {
Expand All @@ -143,6 +143,76 @@ describe('the price floors module', function () {
getGlobal().bidderSettings = {};
});

describe('parseFloorData', () => {
it('should accept just a default floor', () => {
const fd = parseFloorData({
default: 1.23
});
expect(getFirstMatchingFloor(fd, {}, {}).matchingFloor).to.eql(1.23);
});
});

describe('getFloorDataFromAdUnits', () => {
let adUnits;

function setFloorValues(rule) {
adUnits.forEach((au, i) => {
au.floors = {
values: {
[rule]: i + 1
}
}
})
}

beforeEach(() => {
adUnits = ['au1', 'au2', 'au3'].map(getAdUnitMock);
})

it('should use one schema for all adUnits', () => {
setFloorValues('*;*')
adUnits[1].floors.schema = {
fields: ['mediaType', 'gptSlot'],
delimiter: ';'
}
sinon.assert.match(getFloorDataFromAdUnits(adUnits), {
schema: {
fields: ['adUnitCode', 'mediaType', 'gptSlot'],
delimiter: ';'
},
values: {
'au1;*;*': 1,
'au2;*;*': 2,
'au3;*;*': 3
}
})
});
it('should ignore adUnits that declare different schema', () => {
setFloorValues('*|*');
adUnits[0].floors.schema = {
fields: ['mediaType', 'gptSlot']
};
adUnits[2].floors.schema = {
fields: ['gptSlot', 'mediaType']
};
expect(getFloorDataFromAdUnits(adUnits).values).to.eql({
'au1|*|*': 1,
'au2|*|*': 2
})
});
it('should ignore adUnits that declare no values', () => {
setFloorValues('*');
adUnits[0].floors.schema = {
fields: ['mediaType']
};
delete adUnits[2].floors.values;
expect(getFloorDataFromAdUnits(adUnits).values).to.eql({
'au1|*': 1,
'au2|*': 2,
})
})
})

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
Expand Down Expand Up @@ -233,8 +303,8 @@ describe('the price floors module', function () {
});

describe('getFirstMatchingFloor', function () {
it('uses a 0 floor as overrite', function () {
let inputFloorData = {
it('uses a 0 floor as override', function () {
let inputFloorData = normalizeDefault({
currency: 'USD',
schema: {
delimiter: '|',
Expand All @@ -245,7 +315,7 @@ describe('the price floors module', function () {
'test_div_2': 2
},
default: 0.5
};
});

expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'banner', size: '*'})).to.deep.equal({
floorMin: 0,
Expand Down Expand Up @@ -434,7 +504,7 @@ describe('the price floors module', function () {
});
});
it('selects the right floor for more complex rules', function () {
let inputFloorData = {
let inputFloorData = normalizeDefault({
currency: 'USD',
schema: {
delimiter: '^',
Expand All @@ -448,7 +518,7 @@ describe('the price floors module', function () {
'weird_div^*^300x250': 5.5
},
default: 0.5
};
});
// banner with 300x250 size
expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'banner', size: [300, 250]})).to.deep.equal({
floorMin: 0,
Expand Down Expand Up @@ -490,10 +560,8 @@ describe('the price floors module', function () {
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
});
inputFloorData = normalizeDefault({ default: 5.0 });
expect(getFirstMatchingFloor(inputFloorData, basicBidRequest, {mediaType: 'banner', size: '*'}).matchingFloor).to.equal(5.0);
});
describe('with gpt enabled', function () {
let gptFloorData;
Expand Down Expand Up @@ -693,6 +761,95 @@ describe('the price floors module', function () {
floorProvider: undefined
});
});
describe('default floor', () => {
let adUnits;
beforeEach(() => {
adUnits = ['au1', 'au2'].map(getAdUnitMock);
})
function expectFloors(floors) {
runStandardAuction(adUnits);
adUnits.forEach((au, i) => {
au.bids.forEach(bid => {
expect(bid.getFloor().floor).to.eql(floors[i]);
})
})
}
describe('should be sufficient by itself', () => {
it('globally', () => {
handleSetFloorsConfig({
...basicFloorConfig,
data: {
default: 1.23
}
});
expectFloors([1.23, 1.23])
});
it('on adUnits', () => {
handleSetFloorsConfig({
...basicFloorConfig,
data: undefined
});
adUnits[0].floors = {default: 1};
adUnits[1].floors = {default: 2};
expectFloors([1, 2])
});
it('on an adUnit with hidden schema', () => {
handleSetFloorsConfig({
...basicFloorConfig,
data: undefined
});
adUnits[0].floors = {
schema: {
fields: ['mediaType', 'gptSlot'],
},
default: 1
}
adUnits[1].floors = {
default: 2
}
expectFloors([1, 2]);
})
});
describe('should NOT be used when a star rule exists', () => {
it('globally', () => {
handleSetFloorsConfig({
...basicFloorConfig,
data: {
schema: {
fields: ['mediaType', 'gptSlot'],
},
values: {
'*|*': 2
},
default: 3,
}
});
expectFloors([2, 2]);
});
it('on adUnits', () => {
handleSetFloorsConfig({
...basicFloorConfig,
data: undefined
});
adUnits[0].floors = {
schema: {
fields: ['mediaType', 'gptSlot'],
},
values: {
'*|*': 1
},
default: 3
};
adUnits[1].floors = {
values: {
'*|*': 2
},
default: 4
}
expectFloors([1, 2]);
})
});
})
it('bidRequests should have getFloor function and flooring meta data when setConfig occurs', function () {
handleSetFloorsConfig({...basicFloorConfig, floorProvider: 'floorprovider'});
runStandardAuction();
Expand Down Expand Up @@ -1382,7 +1539,7 @@ describe('the price floors module', function () {
it('picks the right rule with more complex rules', function () {
_floorDataForAuction[bidRequest.auctionId] = {
...basicFloorConfig,
data: {
data: normalizeDefault({
currency: 'USD',
schema: { fields: ['mediaType', 'size'], delimiter: '|' },
values: {
Expand All @@ -1394,7 +1551,7 @@ describe('the price floors module', function () {
'video|*': 5.5
},
default: 10.0
}
})
};

// assumes banner *
Expand Down