diff --git a/src/banner.js b/src/banner.js new file mode 100644 index 00000000000..25da06b6669 --- /dev/null +++ b/src/banner.js @@ -0,0 +1,21 @@ +import { isArrayOfNums, isInteger, isStr } from './utils.js'; + +/** + * List of OpenRTB 2.x banner object properties with simple validators. + * Not included: `ext` + * reference: https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md + */ +export const ORTB_BANNER_PARAMS = new Map([ + [ 'format', value => Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'object') ], + [ 'w', isInteger ], + [ 'h', isInteger ], + [ 'btype', isArrayOfNums ], + [ 'battr', isArrayOfNums ], + [ 'pos', isInteger ], + [ 'mimes', value => Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'string') ], + [ 'topframe', value => [1, 0].includes(value) ], + [ 'expdir', isArrayOfNums ], + [ 'api', isArrayOfNums ], + [ 'id', isStr ], + [ 'vcm', value => [1, 0].includes(value) ] +]); diff --git a/src/prebid.js b/src/prebid.js index 37d37acfee7..6da2504dca1 100644 --- a/src/prebid.js +++ b/src/prebid.js @@ -46,7 +46,9 @@ import { renderIfDeferred } from './adRendering.js'; import {getHighestCpm} from './utils/reducers.js'; -import {fillVideoDefaults, validateOrtbVideoFields} from './video.js'; +import {ORTB_VIDEO_PARAMS, fillVideoDefaults, validateOrtbVideoFields} from './video.js'; +import { ORTB_BANNER_PARAMS } from './banner.js'; +import { BANNER, VIDEO } from './mediaTypes.js'; const pbjsInstance = getGlobal(); const { triggerUserSyncs } = userSync; @@ -105,20 +107,40 @@ function validateSizes(sizes, targLength) { return cleanSizes; } -export function setBattrForAdUnit(adUnit, mediaType) { - const ortb2Imp = adUnit.ortb2Imp || {}; - const mediaTypes = adUnit.mediaTypes || {}; +// synchronize fields between mediaTypes[mediaType] and ortb2Imp[mediaType] +export function syncOrtb2(adUnit, mediaType) { + const ortb2Imp = deepAccess(adUnit, `ortb2Imp.${mediaType}`); + const mediaTypes = deepAccess(adUnit, `mediaTypes.${mediaType}`); - if (ortb2Imp[mediaType]?.battr && mediaTypes[mediaType]?.battr && (ortb2Imp[mediaType]?.battr !== mediaTypes[mediaType]?.battr)) { - logWarn(`Ad unit ${adUnit.code} specifies conflicting ortb2Imp.${mediaType}.battr and mediaTypes.${mediaType}.battr, the latter will be ignored`, adUnit); + if (!ortb2Imp && !mediaTypes) { + // omitting sync due to not present mediaType + return; } - const battr = ortb2Imp[mediaType]?.battr || mediaTypes[mediaType]?.battr; + const fields = { + [VIDEO]: FEATURES.VIDEO && ORTB_VIDEO_PARAMS, + [BANNER]: ORTB_BANNER_PARAMS + }[mediaType]; - if (battr != null) { - deepSetValue(adUnit, `ortb2Imp.${mediaType}.battr`, battr); - deepSetValue(adUnit, `mediaTypes.${mediaType}.battr`, battr); + if (!fields) { + return; } + + [...fields].forEach(([key, validator]) => { + const mediaTypesFieldValue = deepAccess(adUnit, `mediaTypes.${mediaType}.${key}`); + const ortbFieldValue = deepAccess(adUnit, `ortb2Imp.${mediaType}.${key}`); + + if (mediaTypesFieldValue == undefined && ortbFieldValue == undefined) { + // omitting the params if it's not defined on either of sides + } else if (mediaTypesFieldValue == undefined) { + deepSetValue(adUnit, `mediaTypes.${mediaType}.${key}`, ortbFieldValue); + } else if (ortbFieldValue == undefined) { + deepSetValue(adUnit, `ortb2Imp.${mediaType}.${key}`, mediaTypesFieldValue); + } else { + logWarn(`adUnit ${adUnit.code}: specifies conflicting ortb2Imp.${mediaType}.${key} and mediaTypes.${mediaType}.${key}, the latter will be ignored`, adUnit); + deepSetValue(adUnit, `mediaTypes.${mediaType}.${key}`, ortbFieldValue); + } + }); } function validateBannerMediaType(adUnit) { @@ -133,7 +155,7 @@ function validateBannerMediaType(adUnit) { logError('Detected a mediaTypes.banner object without a proper sizes field. Please ensure the sizes are listed like: [[300, 250], ...]. Removing invalid mediaTypes.banner object from request.'); delete validatedAdUnit.mediaTypes.banner } - setBattrForAdUnit(validatedAdUnit, 'banner'); + syncOrtb2(validatedAdUnit, 'banner') return validatedAdUnit; } @@ -157,7 +179,7 @@ function validateVideoMediaType(adUnit) { } } validateOrtbVideoFields(validatedAdUnit); - setBattrForAdUnit(validatedAdUnit, 'video'); + syncOrtb2(validatedAdUnit, 'video'); return validatedAdUnit; } @@ -207,7 +229,6 @@ function validateNativeMediaType(adUnit) { logError('Please use an array of sizes for native.icon.sizes field. Removing invalid mediaTypes.native.icon.sizes property from request.'); delete validatedAdUnit.mediaTypes.native.icon.sizes; } - setBattrForAdUnit(validatedAdUnit, 'native'); return validatedAdUnit; } diff --git a/test/spec/banner_spec.js b/test/spec/banner_spec.js new file mode 100644 index 00000000000..80273686ff3 --- /dev/null +++ b/test/spec/banner_spec.js @@ -0,0 +1,182 @@ +import * as utils from '../../src/utils.js'; +import { syncOrtb2 } from '../../src/prebid.js'; + +describe('banner', () => { + describe('syncOrtb2', () => { + let logWarnSpy; + + beforeEach(function () { + logWarnSpy = sinon.spy(utils, 'logWarn'); + }); + + afterEach(function () { + utils.logWarn.restore(); + }); + + it('should properly sync fields if both present', () => { + const adUnit = { + mediaTypes: { + banner: { + format: [{w: 100, h: 100}], + btype: [1, 2, 34], // should be overwritten with value from ortb2Imp + battr: [3, 4], + maxduration: 'omitted_value' // should be omitted during copying - not part of banner obj spec + } + }, + ortb2Imp: { + banner: { + request: '{payload: true}', + pos: 5, + btype: [999, 999], + vcm: 0, + foobar: 'omitted_value' // should be omitted during copying - not part of banner obj spec + } + } + }; + + const expected = { + mediaTypes: { + banner: { + format: [{w: 100, h: 100}], + btype: [999, 999], + pos: 5, + battr: [3, 4], + vcm: 0, + maxduration: 'omitted_value' + } + }, + ortb2Imp: { + banner: { + format: [{w: 100, h: 100}], + request: '{payload: true}', + pos: 5, + btype: [999, 999], + battr: [3, 4], + vcm: 0, + foobar: 'omitted_value' + } + } + }; + + syncOrtb2(adUnit, 'banner'); + expect(adUnit).to.deep.eql(expected); + + assert.ok(logWarnSpy.calledOnce, 'expected warning was logged due to conflicting btype'); + }); + + it('should omit sync if mediaType not present on adUnit', () => { + const adUnit = { + mediaTypes: { + video: { + fieldToOmit: 'omitted_value' + } + }, + ortb2Imp: { + video: { + fieldToOmit2: 'omitted_value' + } + } + }; + + syncOrtb2(adUnit, 'banner'); + + expect(adUnit.ortb2Imp.banner).to.be.undefined; + expect(adUnit.mediaTypes.banner).to.be.undefined; + }); + + it('should properly sync if mediaTypes is not present on any of side', () => { + const adUnit = { + mediaTypes: { + banner: { + format: [{w: 100, h: 100}], + btype: [999, 999], + pos: 5, + battr: [3, 4], + vcm: 0, + maxduration: 'omitted_value' + } + }, + }; + + const expected1 = { + mediaTypes: { + banner: { + format: [{w: 100, h: 100}], + btype: [999, 999], + pos: 5, + battr: [3, 4], + vcm: 0, + maxduration: 'omitted_value' + } + }, + ortb2Imp: { + banner: { + format: [{w: 100, h: 100}], + btype: [999, 999], + pos: 5, + battr: [3, 4], + vcm: 0, + } + } + }; + + syncOrtb2(adUnit, 'banner'); + expect(adUnit).to.deep.eql(expected1); + + const adUnit2 = { + mediaTypes: {}, + ortb2Imp: { + banner: { + format: [{w: 100, h: 100}], + btype: [999, 999], + pos: 5, + battr: [3, 4], + vcm: 0, + maxduration: 'omitted_value' + } + } + }; + + const expected2 = { + mediaTypes: { + banner: { + format: [{w: 100, h: 100}], + btype: [999, 999], + pos: 5, + battr: [3, 4], + vcm: 0, + } + }, + ortb2Imp: { + banner: { + format: [{w: 100, h: 100}], + btype: [999, 999], + pos: 5, + battr: [3, 4], + vcm: 0, + maxduration: 'omitted_value' + } + } + }; + + syncOrtb2(adUnit2, 'banner'); + expect(adUnit2).to.deep.eql(expected2); + }); + + it('should not create empty banner object on ortb2Imp if there was nothing to copy', () => { + const adUnit2 = { + mediaTypes: { + banner: { + noOrtbBannerField1: 'value', + noOrtbBannerField2: 'value' + } + }, + ortb2Imp: { + // lack of banner field + } + }; + syncOrtb2(adUnit2, 'banner'); + expect(adUnit2.ortb2Imp.banner).to.be.undefined + }); + }); +}) diff --git a/test/spec/native_spec.js b/test/spec/native_spec.js index 877306c1c04..01214cdb3ae 100644 --- a/test/spec/native_spec.js +++ b/test/spec/native_spec.js @@ -22,7 +22,7 @@ import { convertOrtbRequestToProprietaryNative, fromOrtbNativeRequest } from '.. import {auctionManager} from '../../src/auctionManager.js'; import {getRenderingData} from '../../src/adRendering.js'; import {getCreativeRendererSource} from '../../src/creativeRenderers.js'; -import {deepClone, deepSetValue} from '../../src/utils.js'; +import {deepSetValue} from '../../src/utils.js'; const utils = require('src/utils'); const bid = { diff --git a/test/spec/unit/pbjs_api_spec.js b/test/spec/unit/pbjs_api_spec.js index e1f5b3b5b88..0a9de8e976c 100644 --- a/test/spec/unit/pbjs_api_spec.js +++ b/test/spec/unit/pbjs_api_spec.js @@ -3786,71 +3786,4 @@ describe('Unit: Prebid Module', function () { expect(auctionManager.getBidsReceived().length).to.equal(0); }); }); - - describe('setBattrForAdUnit', () => { - it('should set copy battr to both places', () => { - const adUnit = { - ortb2Imp: { - video: { - battr: 'banned attribute' - } - }, - mediaTypes: { - video: {} - } - } - - setBattrForAdUnit(adUnit, 'video'); - - expect(adUnit.mediaTypes.video.battr).to.deep.equal('banned attribute'); - expect(adUnit.ortb2Imp.video.battr).to.deep.equal('banned attribute'); - - const adUnit2 = { - mediaTypes: { - video: { - battr: 'banned attribute' - } - }, - ortb2Imp: { - video: {} - } - } - - setBattrForAdUnit(adUnit2, 'video'); - - expect(adUnit2.ortb2Imp.video.battr).to.deep.equal('banned attribute'); - expect(adUnit2.mediaTypes.video.battr).to.deep.equal('banned attribute'); - }) - - it('should log warn if both are specified and differ from eachother', () => { - let spyLogWarn = sinon.spy(utils, 'logWarn'); - const adUnit = { - mediaTypes: { - native: { - battr: 'banned attribute' - } - }, - ortb2Imp: { - native: { - battr: 'banned attribute 2' - } - } - } - setBattrForAdUnit(adUnit, 'native'); - sinon.assert.calledOnce(spyLogWarn); - spyLogWarn.resetHistory(); - utils.logWarn.restore(); - }) - - it('should not copy for undefined battr', () => { - const adUnit = { - mediaTypes: { - native: {} - } - } - setBattrForAdUnit(adUnit, 'native'); - expect(adUnit.mediaTypes.native).to.deep.equal({}); - expect(adUnit.mediaTypes.ortb2Imp).to.not.exist; - }) - }) }); diff --git a/test/spec/video_spec.js b/test/spec/video_spec.js index 18955771049..0d2a32659e9 100644 --- a/test/spec/video_spec.js +++ b/test/spec/video_spec.js @@ -2,6 +2,7 @@ import {fillVideoDefaults, isValidVideoBid, validateOrtbVideoFields} from 'src/v import {hook} from '../../src/hook.js'; import {stubAuctionIndex} from '../helpers/indexStub.js'; import * as utils from '../../src/utils.js'; +import { syncOrtb2 } from '../../src/prebid.js'; describe('video.js', function () { let sandbox; @@ -274,4 +275,207 @@ describe('video.js', function () { expect(valid).to.equal(false); }); }) + + describe('syncOrtb2', () => { + if (!FEATURES.VIDEO) { + return; + } + + let logWarnSpy; + + beforeEach(function () { + logWarnSpy = sinon.spy(utils, 'logWarn'); + }); + + afterEach(function () { + utils.logWarn.restore(); + }); + + it('should properly sync fields if both present', () => { + const adUnit = { + mediaTypes: { + video: { + minduration: 500, + maxduration: 1000, + protocols: [1, 2, 3], + linearity: 5, // should be overwritten with value from ortb2Imp + w: 100, + h: 200, + foo: 'omitted_value' // should be omitted during copying - not part of video obj spec + } + }, + ortb2Imp: { + video: { + minbitrate: 10, + maxbitrate: 50, + delivery: [1, 2, 3, 4], + linearity: 10, + bar: 'omitted_value' // should be omitted during copying - not part of video obj spec + } + } + }; + + const expected = { + mediaTypes: { + video: { + minduration: 500, + maxduration: 1000, + protocols: [1, 2, 3], + w: 100, + h: 200, + minbitrate: 10, + maxbitrate: 50, + delivery: [1, 2, 3, 4], + linearity: 10, + foo: 'omitted_value' + } + }, + ortb2Imp: { + video: { + minduration: 500, + maxduration: 1000, + protocols: [1, 2, 3], + w: 100, + h: 200, + minbitrate: 10, + maxbitrate: 50, + delivery: [1, 2, 3, 4], + linearity: 10, + bar: 'omitted_value' + } + } + }; + + syncOrtb2(adUnit, 'video'); + expect(adUnit).to.deep.eql(expected); + + assert.ok(logWarnSpy.calledOnce, 'expected warning was logged due to conflicting linearity'); + }); + + it('should omit sync if video mediaType not present on adUnit', () => { + const adUnit = { + mediaTypes: { + native: { + fieldToOmit: 'omitted_value' + } + }, + ortb2Imp: { + native: { + fieldToOmit2: 'omitted_value' + } + } + }; + + syncOrtb2(adUnit, 'video'); + + expect(adUnit.mediaTypes.video).to.be.undefined; + expect(adUnit.ortb2Imp.video).to.be.undefined; + }); + + it('should properly sync if mediaTypes is not present on any of side', () => { + const adUnit = { + mediaTypes: { + video: { + minduration: 500, + maxduration: 1000, + protocols: [1, 2, 3], + linearity: 5, + w: 100, + h: 200, + foo: 'omitted_value' + } + }, + ortb2Imp: { + // lack of video field + } + }; + + const expected1 = { + mediaTypes: { + video: { + minduration: 500, + maxduration: 1000, + protocols: [1, 2, 3], + linearity: 5, + w: 100, + h: 200, + foo: 'omitted_value' + } + }, + ortb2Imp: { + video: { + minduration: 500, + maxduration: 1000, + protocols: [1, 2, 3], + linearity: 5, + w: 100, + h: 200, + } + } + }; + + syncOrtb2(adUnit, 'video'); + expect(adUnit).to.deep.eql(expected1); + + const adUnit2 = { + mediaTypes: { + // lack of video field + }, + ortb2Imp: { + video: { + minduration: 500, + maxduration: 1000, + protocols: [1, 2, 3], + linearity: 5, + w: 100, + h: 200, + foo: 'omitted_value' + } + } + }; + + const expected2 = { + mediaTypes: { + video: { + minduration: 500, + maxduration: 1000, + protocols: [1, 2, 3], + linearity: 5, + w: 100, + h: 200, + } + }, + ortb2Imp: { + video: { + minduration: 500, + maxduration: 1000, + protocols: [1, 2, 3], + linearity: 5, + w: 100, + h: 200, + foo: 'omitted_value', + } + } + }; + + syncOrtb2(adUnit2, 'video'); + expect(adUnit2).to.deep.eql(expected2); + }); + + it('should not create empty video object on ortb2Imp if there was nothing to copy', () => { + const adUnit2 = { + mediaTypes: { + video: { + noOrtbVideoField1: 'value', + noOrtbVideoField2: 'value' + } + }, + ortb2Imp: { + // lack of video field + } + }; + syncOrtb2(adUnit2, 'video'); + expect(adUnit2.ortb2Imp.video).to.be.undefined + }); + }); });