diff --git a/modules/mediafuseBidAdapter.js b/modules/mediafuseBidAdapter.js index da98d88fa81..135ee52a880 100644 --- a/modules/mediafuseBidAdapter.js +++ b/modules/mediafuseBidAdapter.js @@ -1,8 +1,13 @@ +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { Renderer } from '../src/Renderer.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { hasPurpose1Consent } from '../src/utils/gdpr.js'; import { createTrackPixelHtml, deepAccess, - deepClone, - getBidRequest, + deepSetValue, getParameterByName, isArray, isArrayOfNums, @@ -16,1087 +21,982 @@ import { logMessage, logWarn } from '../src/utils.js'; -import { Renderer } from '../src/Renderer.js'; import { config } from '../src/config.js'; -import { registerBidder } from '../src/adapters/bidderFactory.js'; -import { ADPOD, BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; -import { INSTREAM, OUTSTREAM } from '../src/video.js'; -import { getStorageManager } from '../src/storageManager.js'; -import { bidderSettings } from '../src/bidderSettings.js'; -import { hasPurpose1Consent } from '../src/utils/gdpr.js'; -import { convertOrtbRequestToProprietaryNative } from '../src/native.js'; -import { APPNEXUS_CATEGORY_MAPPING } from '../libraries/categoryTranslationMapping/index.js'; import { getANKewyordParamFromMaps, getANKeywordParam } from '../libraries/appnexusUtils/anKeywords.js'; -import { convertCamelToUnderscore, fill } from '../libraries/appnexusUtils/anUtils.js'; +import { convertCamelToUnderscore } from '../libraries/appnexusUtils/anUtils.js'; import { chunk } from '../libraries/chunk/chunk.js'; - -/** - * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest - * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid - */ - const BIDDER_CODE = 'mediafuse'; -const URL = 'https://ib.adnxs.com/ut/v3/prebid'; -const URL_SIMPLE = 'https://ib.adnxs-simple.com/ut/v3/prebid'; -const VIDEO_TARGETING = ['id', 'minduration', 'maxduration', - 'skippable', 'playback_method', 'frameworks', 'context', 'skipoffset']; -const VIDEO_RTB_TARGETING = ['minduration', 'maxduration', 'skip', 'skipafter', 'playbackmethod', 'api']; -const USER_PARAMS = ['age', 'externalUid', 'segments', 'gender', 'dnt', 'language']; -const APP_DEVICE_PARAMS = ['device_id']; // appid is collected separately +const GVLID = 32; +const ENDPOINT_URL_NORMAL = 'https://ib.adnxs.com/openrtb2/prebidjs'; +const ENDPOINT_URL_SIMPLE = 'https://ib.adnxs-simple.com/openrtb2/prebidjs'; +const SOURCE = 'pbjs'; +const MAX_IMPS_PER_REQUEST = 15; const DEBUG_PARAMS = ['enabled', 'dongle', 'member_id', 'debug_timeout']; -const VIDEO_MAPPING = { - playback_method: { - 'unknown': 0, - 'auto_play_sound_on': 1, - 'auto_play_sound_off': 2, - 'click_to_play': 3, - 'mouse_over': 4, - 'auto_play_sound_unknown': 5 - }, - context: { - 'unknown': 0, - 'pre_roll': 1, - 'mid_roll': 2, - 'post_roll': 3, - 'outstream': 4, - 'in-banner': 5 - } +const DEBUG_QUERY_PARAM_MAP = { + 'apn_debug_enabled': 'enabled', + 'apn_debug_dongle': 'dongle', + 'apn_debug_member_id': 'member_id', + 'apn_debug_timeout': 'debug_timeout' }; -const NATIVE_MAPPING = { - body: 'description', - body2: 'desc2', - cta: 'ctatext', - image: { - serverName: 'main_image', - requiredParams: { required: true } - }, - icon: { - serverName: 'icon', - requiredParams: { required: true } - }, - sponsoredBy: 'sponsored_by', - privacyLink: 'privacy_link', - salePrice: 'saleprice', - displayUrl: 'displayurl' +const RESPONSE_MEDIA_TYPE_MAP = { + 0: BANNER, + 1: VIDEO, + 3: NATIVE }; -const SOURCE = 'pbjs'; -const MAX_IMPS_PER_REQUEST = 15; -const SCRIPT_TAG_START = '' } + } + } + }] + }] + } + }; - const request = spec.buildRequests([bidRequest1, bidRequest2]); - const payload = JSON.parse(request.data); - expect(payload.tags[0].video).to.deep.equal({ - skippable: true, - playback_method: 2, - custom_renderer_present: true + const bids = spec.interpretResponse(serverResponse, req); + const trackers = bids[0].native.javascriptTrackers; + expect(trackers).to.be.an('array'); + expect(trackers[0]).to.include('data-src='); }); - expect(payload.tags[1].video).to.deep.equal({ - skippable: true, - playback_method: 2 + + it('should handle malformed native adm gracefully', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { native: { title: { required: true } } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + const logErrorStub = sandbox.stub(utils, 'logError'); + + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + adm: 'NOT_VALID_JSON', + ext: { appnexus: { bid_ad_type: 3 } } + }] + }] + } + }; + + // Should not throw + expect(() => spec.interpretResponse(serverResponse, req)).to.not.throw(); + expect(logErrorStub.calledOnce).to.be.true; }); }); + } // FEATURES.NATIVE + + // ------------------------------------------------------------------------- + // getUserSyncs — gdprApplies not a boolean + // ------------------------------------------------------------------------- + describe('getUserSyncs - gdprApplies undefined', function () { + it('should use only gdpr_consent param when gdprApplies is not a boolean', function () { + const syncOptions = { pixelEnabled: true }; + const serverResponses = [{ + body: { ext: { appnexus: { userSync: { url: 'https://sync.example.com/px' } } } } + }]; + const gdprConsent = { consentString: 'abc123' }; // gdprApplies is undefined - it('should attach valid user params to the tag', function () { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { - placementId: '10433394', - user: { - externalUid: '123', - segments: [123, { id: 987, value: 876 }], - foobar: 'invalid' - } - } - } - ); + const syncs = spec.getUserSyncs(syncOptions, serverResponses, gdprConsent); + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].url).to.include('gdpr_consent=abc123'); + expect(syncs[0].url).to.not.include('gdpr='); + }); + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + // ------------------------------------------------------------------------- + // lifecycle — onBidWon + // ------------------------------------------------------------------------- + + // ------------------------------------------------------------------------- + // interpretResponse — dchain from buyer_member_id + // ------------------------------------------------------------------------- + describe('interpretResponse - dchain', function () { + it('should set meta.dchain when buyer_member_id is present', function () { + const [req] = spec.buildRequests([deepClone(BASE_BID)], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + ext: { appnexus: { bid_ad_type: 0, buyer_member_id: 77, advertiser_id: 99 } } + }] + }] + } + }; - expect(payload.user).to.exist; - expect(payload.user).to.deep.equal({ - external_uid: '123', - segments: [{ id: 123 }, { id: 987, value: 876 }] + const bids = spec.interpretResponse(serverResponse, req); + expect(bids[0].meta.dchain).to.deep.equal({ + ver: '1.0', + complete: 0, + nodes: [{ bsid: '77' }] }); + expect(bids[0].meta.advertiserId).to.equal(99); }); + }); - it('should attach reserve param when either bid param or getFloor function exists', function () { - const getFloorResponse = { currency: 'USD', floor: 3 }; - let request; let payload = null; - const bidRequest = deepClone(bidRequests[0]); + // ------------------------------------------------------------------------- + // buildRequests — optional params map (allowSmallerSizes, usePaymentRule, etc.) + // ------------------------------------------------------------------------- + describe('buildRequests - optional params', function () { + it('should map allowSmallerSizes, usePaymentRule, trafficSourceCode', function () { + const bid = deepClone(BASE_BID); + bid.params.allowSmallerSizes = true; + bid.params.usePaymentRule = true; + bid.params.trafficSourceCode = 'my-source'; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const extAN = req.data.imp[0].ext.appnexus; + expect(extAN.allow_smaller_sizes).to.be.true; + expect(extAN.use_pmt_rule).to.be.true; + expect(extAN.traffic_source_code).to.equal('my-source'); + }); - // 1 -> reserve not defined, getFloor not defined > empty - request = spec.buildRequests([bidRequest]); - payload = JSON.parse(request.data); + it('should map externalImpId to ext.appnexus.ext_imp_id', function () { + const bid = deepClone(BASE_BID); + bid.params.externalImpId = 'ext-imp-123'; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.ext_imp_id).to.equal('ext-imp-123'); + }); + }); - expect(payload.tags[0].reserve).to.not.exist; + // ------------------------------------------------------------------------- + // isBidRequestValid + // ------------------------------------------------------------------------- + describe('isBidRequestValid', function () { + it('should return true for placement_id (snake_case)', function () { + expect(spec.isBidRequestValid({ params: { placement_id: 12345 } })).to.be.true; + }); - // 2 -> reserve is defined, getFloor not defined > reserve is used - bidRequest.params = { - 'placementId': '10433394', - 'reserve': 0.5 - }; - request = spec.buildRequests([bidRequest]); - payload = JSON.parse(request.data); - - expect(payload.tags[0].reserve).to.exist.and.to.equal(0.5); - - // 3 -> reserve is defined, getFloor is defined > getFloor is used - bidRequest.getFloor = () => getFloorResponse; - - request = spec.buildRequests([bidRequest]); - payload = JSON.parse(request.data); - - expect(payload.tags[0].reserve).to.exist.and.to.equal(3); - }); - - it('should duplicate adpod placements into batches and set correct maxduration', function() { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { placementId: '14542875' } - }, - { - mediaTypes: { - video: { - context: 'adpod', - playerSize: [640, 480], - adPodDurationSec: 300, - durationRangeSec: [15, 30], - } - } - } - ); - - const request = spec.buildRequests([bidRequest]); - const payload1 = JSON.parse(request[0].data); - const payload2 = JSON.parse(request[1].data); - - // 300 / 15 = 20 total - expect(payload1.tags.length).to.equal(15); - expect(payload2.tags.length).to.equal(5); - - expect(payload1.tags[0]).to.deep.equal(payload1.tags[1]); - expect(payload1.tags[0].video.maxduration).to.equal(30); - - expect(payload2.tags[0]).to.deep.equal(payload1.tags[1]); - expect(payload2.tags[0].video.maxduration).to.equal(30); - }); - - it('should round down adpod placements when numbers are uneven', function() { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { placementId: '14542875' } - }, - { - mediaTypes: { - video: { - context: 'adpod', - playerSize: [640, 480], - adPodDurationSec: 123, - durationRangeSec: [45], - } - } - } - ); - - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - expect(payload.tags.length).to.equal(2); - }); - - it('should duplicate adpod placements when requireExactDuration is set', function() { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { placementId: '14542875' } - }, - { - mediaTypes: { - video: { - context: 'adpod', - playerSize: [640, 480], - adPodDurationSec: 300, - durationRangeSec: [15, 30], - requireExactDuration: true, - } - } - } - ); - - // 20 total placements with 15 max impressions = 2 requests - const request = spec.buildRequests([bidRequest]); - expect(request.length).to.equal(2); - - // 20 spread over 2 requests = 15 in first request, 5 in second - const payload1 = JSON.parse(request[0].data); - const payload2 = JSON.parse(request[1].data); - expect(payload1.tags.length).to.equal(15); - expect(payload2.tags.length).to.equal(5); - - // 10 placements should have max/min at 15 - // 10 placemenst should have max/min at 30 - const payload1tagsWith15 = payload1.tags.filter(tag => tag.video.maxduration === 15); - const payload1tagsWith30 = payload1.tags.filter(tag => tag.video.maxduration === 30); - expect(payload1tagsWith15.length).to.equal(10); - expect(payload1tagsWith30.length).to.equal(5); - - // 5 placemenst with min/max at 30 were in the first request - // so 5 remaining should be in the second - const payload2tagsWith30 = payload2.tags.filter(tag => tag.video.maxduration === 30); - expect(payload2tagsWith30.length).to.equal(5); - }); - - it('should set durations for placements when requireExactDuration is set and numbers are uneven', function() { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { placementId: '14542875' } - }, - { - mediaTypes: { - video: { - context: 'adpod', - playerSize: [640, 480], - adPodDurationSec: 105, - durationRangeSec: [15, 30, 60], - requireExactDuration: true, - } - } - } - ); - - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - expect(payload.tags.length).to.equal(7); - - const tagsWith15 = payload.tags.filter(tag => tag.video.maxduration === 15); - const tagsWith30 = payload.tags.filter(tag => tag.video.maxduration === 30); - const tagsWith60 = payload.tags.filter(tag => tag.video.maxduration === 60); - expect(tagsWith15.length).to.equal(3); - expect(tagsWith30.length).to.equal(3); - expect(tagsWith60.length).to.equal(1); - }); - - it('should break adpod request into batches', function() { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { placementId: '14542875' } - }, - { - mediaTypes: { - video: { - context: 'adpod', - playerSize: [640, 480], - adPodDurationSec: 225, - durationRangeSec: [5], - } - } - } - ); - - const request = spec.buildRequests([bidRequest]); - const payload1 = JSON.parse(request[0].data); - const payload2 = JSON.parse(request[1].data); - const payload3 = JSON.parse(request[2].data); - - expect(payload1.tags.length).to.equal(15); - expect(payload2.tags.length).to.equal(15); - expect(payload3.tags.length).to.equal(15); - }); - - it('should contain hb_source value for adpod', function() { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { placementId: '14542875' } - }, - { - mediaTypes: { - video: { - context: 'adpod', - playerSize: [640, 480], - adPodDurationSec: 300, - durationRangeSec: [15, 30], - } - } - } - ); - const request = spec.buildRequests([bidRequest])[0]; - const payload = JSON.parse(request.data); - expect(payload.tags[0].hb_source).to.deep.equal(7); - }); - - it('should contain hb_source value for other media', function() { - const bidRequest = Object.assign({}, - bidRequests[0], - { - mediaType: 'banner', - params: { - sizes: [[300, 250], [300, 600]], - placementId: 13144370 - } - } - ); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - expect(payload.tags[0].hb_source).to.deep.equal(1); - }); - - it('adds brand_category_exclusion to request when set', function() { - const bidRequest = Object.assign({}, bidRequests[0]); - sinon - .stub(config, 'getConfig') - .withArgs('adpod.brandCategoryExclusion') - .returns(true); - - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - - expect(payload.brand_category_uniqueness).to.equal(true); - - config.getConfig.restore(); - }); - - it('adds auction level keywords to request when set', function() { - const bidRequest = Object.assign({}, bidRequests[0]); - sinon - .stub(config, 'getConfig') - .withArgs('mediafuseAuctionKeywords') - .returns({ - gender: 'm', - music: ['rock', 'pop'], - test: '' - }); - - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - - expect(payload.keywords).to.deep.equal([{ - 'key': 'gender', - 'value': ['m'] - }, { - 'key': 'music', - 'value': ['rock', 'pop'] - }, { - 'key': 'test' - }]); - - config.getConfig.restore(); - }); - - it('should attach native params to the request', function () { - const bidRequest = Object.assign({}, - bidRequests[0], - { - mediaType: 'native', - nativeParams: { - title: { required: true }, - body: { required: true }, - body2: { required: true }, - image: { required: true, sizes: [100, 100] }, - icon: { required: true }, - cta: { required: false }, - rating: { required: true }, - sponsoredBy: { required: true }, - privacyLink: { required: true }, - displayUrl: { required: true }, - address: { required: true }, - downloads: { required: true }, - likes: { required: true }, - phone: { required: true }, - price: { required: true }, - salePrice: { required: true } - } - } - ); - - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - - expect(payload.tags[0].native.layouts[0]).to.deep.equal({ - title: { required: true }, - description: { required: true }, - desc2: { required: true }, - main_image: { required: true, sizes: [{ width: 100, height: 100 }] }, - icon: { required: true }, - ctatext: { required: false }, - rating: { required: true }, - sponsored_by: { required: true }, - privacy_link: { required: true }, - displayurl: { required: true }, - address: { required: true }, - downloads: { required: true }, - likes: { required: true }, - phone: { required: true }, - price: { required: true }, - saleprice: { required: true }, - privacy_supported: true - }); - expect(payload.tags[0].hb_source).to.equal(1); + it('should return true for member + invCode', function () { + expect(spec.isBidRequestValid({ params: { member: '123', invCode: 'inv' } })).to.be.true; }); - it('should always populated tags[].sizes with 1,1 for native if otherwise not defined', function () { - const bidRequest = Object.assign({}, - bidRequests[0], - { - mediaType: 'native', - nativeParams: { - image: { required: true } - } - } - ); - bidRequest.sizes = [[150, 100], [300, 250]]; - - let request = spec.buildRequests([bidRequest]); - let payload = JSON.parse(request.data); - expect(payload.tags[0].sizes).to.deep.equal([{ width: 150, height: 100 }, { width: 300, height: 250 }]); - - delete bidRequest.sizes; - - request = spec.buildRequests([bidRequest]); - payload = JSON.parse(request.data); - - expect(payload.tags[0].sizes).to.deep.equal([{ width: 1, height: 1 }]); - }); - - it('should convert keyword params to proper form and attaches to request', function () { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { - placementId: '10433394', - keywords: { - single: 'val', - singleArr: ['val'], - singleArrNum: [5], - multiValMixed: ['value1', 2, 'value3'], - singleValNum: 123, - emptyStr: '', - emptyArr: [''], - badValue: { 'foo': 'bar' } // should be dropped - } - } - } - ); - - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - - expect(payload.tags[0].keywords).to.deep.equal([{ - 'key': 'single', - 'value': ['val'] - }, { - 'key': 'singleArr', - 'value': ['val'] - }, { - 'key': 'singleArrNum', - 'value': ['5'] - }, { - 'key': 'multiValMixed', - 'value': ['value1', '2', 'value3'] - }, { - 'key': 'singleValNum', - 'value': ['123'] - }, { - 'key': 'emptyStr' - }, { - 'key': 'emptyArr' - }]); - }); - - it('should add payment rules to the request', function () { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { - placementId: '10433394', - usePaymentRule: true - } - } - ); + it('should return true for member + inv_code', function () { + expect(spec.isBidRequestValid({ params: { member: '123', inv_code: 'inv' } })).to.be.true; + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + it('should return false when no params', function () { + expect(spec.isBidRequestValid({})).to.be.false; + }); - expect(payload.tags[0].use_pmt_rule).to.equal(true); + it('should return false for member without invCode or inv_code', function () { + expect(spec.isBidRequestValid({ params: { member: '123' } })).to.be.false; }); + }); - it('should add gpid to the request', function () { - const testGpid = '/12345/my-gpt-tag-0'; - const bidRequest = deepClone(bidRequests[0]); - bidRequest.ortb2Imp = { ext: { data: {}, gpid: testGpid } }; + // ------------------------------------------------------------------------- + // getBidFloor + // ------------------------------------------------------------------------- + describe('buildRequests - getBidFloor', function () { + it('should use getFloor function result when available and currency matches', function () { + const bid = deepClone(BASE_BID); + bid.getFloor = () => ({ currency: 'USD', floor: 1.5 }); + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].bidfloor).to.equal(1.5); + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + it('should return null when getFloor returns wrong currency', function () { + const bid = deepClone(BASE_BID); + bid.getFloor = () => ({ currency: 'EUR', floor: 1.5 }); + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].bidfloor).to.be.undefined; + }); - expect(payload.tags[0].gpid).to.exist.and.equal(testGpid) + it('should use params.reserve when no getFloor function', function () { + const bid = deepClone(BASE_BID); + bid.params.reserve = 2.0; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].bidfloor).to.equal(2.0); }); + }); - it('should add gdpr consent information to the request', function () { - const consentString = 'BOJ8RZsOJ8RZsABAB8AAAAAZ+A=='; - const bidderRequest = { - 'bidderCode': 'mediafuse', - 'auctionId': '1d1a030790a475', - 'bidderRequestId': '22edbae2733bf6', - 'timeout': 3000, - 'gdprConsent': { - consentString: consentString, - gdprApplies: true, - addtlConsent: '1~7.12.35.62.66.70.89.93.108' - } - }; - bidderRequest.bids = bidRequests; - - const request = spec.buildRequests(bidRequests, bidderRequest); - expect(request.options).to.deep.equal({ withCredentials: true }); - const payload = JSON.parse(request.data); - - expect(payload.gdpr_consent).to.exist; - expect(payload.gdpr_consent.consent_string).to.exist.and.to.equal(consentString); - expect(payload.gdpr_consent.consent_required).to.exist.and.to.be.true; - expect(payload.gdpr_consent.addtl_consent).to.exist.and.to.deep.equal([7, 12, 35, 62, 66, 70, 89, 93, 108]); - }); - - it('should add us privacy string to payload', function() { - const consentString = '1YA-'; - const bidderRequest = { - 'bidderCode': 'mediafuse', - 'auctionId': '1d1a030790a475', - 'bidderRequestId': '22edbae2733bf6', - 'timeout': 3000, - 'uspConsent': consentString - }; - bidderRequest.bids = bidRequests; - - const request = spec.buildRequests(bidRequests, bidderRequest); - const payload = JSON.parse(request.data); - - expect(payload.us_privacy).to.exist; - expect(payload.us_privacy).to.exist.and.to.equal(consentString); - }); - - it('supports sending hybrid mobile app parameters', function () { - const appRequest = Object.assign({}, - bidRequests[0], - { - params: { - placementId: '10433394', - app: { - id: 'B1O2W3M4AN.com.prebid.webview', - geo: { - lat: 40.0964439, - lng: -75.3009142 - }, - device_id: { - idfa: '4D12078D-3246-4DA4-AD5E-7610481E7AE', // Apple advertising identifier - aaid: '38400000-8cf0-11bd-b23e-10b96e40000d', // Android advertising identifier - md5udid: '5756ae9022b2ea1e47d84fead75220c8', // MD5 hash of the ANDROID_ID - sha1udid: '4DFAA92388699AC6539885AEF1719293879985BF', // SHA1 hash of the ANDROID_ID - windowsadid: '750c6be243f1c4b5c9912b95a5742fc5' // Windows advertising identifier - } - } - } - } - ); - const request = spec.buildRequests([appRequest]); - const payload = JSON.parse(request.data); - expect(payload.app).to.exist; - expect(payload.app).to.deep.equal({ - appid: 'B1O2W3M4AN.com.prebid.webview' - }); - expect(payload.device.device_id).to.exist; - expect(payload.device.device_id).to.deep.equal({ - aaid: '38400000-8cf0-11bd-b23e-10b96e40000d', - idfa: '4D12078D-3246-4DA4-AD5E-7610481E7AE', - md5udid: '5756ae9022b2ea1e47d84fead75220c8', - sha1udid: '4DFAA92388699AC6539885AEF1719293879985BF', - windowsadid: '750c6be243f1c4b5c9912b95a5742fc5' - }); - expect(payload.device.geo).to.not.exist; - expect(payload.device.geo).to.not.deep.equal({ - lat: 40.0964439, - lng: -75.3009142 - }); + // ------------------------------------------------------------------------- + // buildRequests — inv_code + // ------------------------------------------------------------------------- + describe('buildRequests - inv_code', function () { + it('should set tagid from invCode when no placementId', function () { + const bid = { bidder: 'mediafuse', adUnitCode: 'au', bidId: 'b1', params: { invCode: 'my-inv-code', member: '123' } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].tagid).to.equal('my-inv-code'); }); - it('should add referer info to payload', function () { - const bidRequest = Object.assign({}, bidRequests[0]) - const bidderRequest = { - refererInfo: { - topmostLocation: 'https://example.com/page.html', - reachedTop: true, - numIframes: 2, - stack: [ - 'https://example.com/page.html', - 'https://example.com/iframe1.html', - 'https://example.com/iframe2.html' - ] - } - } - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); - - expect(payload.referrer_detection).to.exist; - expect(payload.referrer_detection).to.deep.equal({ - rd_ref: 'https%3A%2F%2Fexample.com%2Fpage.html', - rd_top: true, - rd_ifs: 2, - rd_stk: bidderRequest.refererInfo.stack.map((url) => encodeURIComponent(url)).join(',') - }); + it('should set tagid from inv_code when no placementId', function () { + const bid = { bidder: 'mediafuse', adUnitCode: 'au', bidId: 'b1', params: { inv_code: 'my-inv-code-snake', member: '123' } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].tagid).to.equal('my-inv-code-snake'); }); + }); - it('should populate schain if available', function () { - const bidRequest = Object.assign({}, bidRequests[0], { - ortb2: { - source: { - ext: { - schain: { - ver: '1.0', - complete: 1, - nodes: [ - { - 'asi': 'blob.com', - 'sid': '001', - 'hp': 1 - } - ] - } - } - } - } - }); + // ------------------------------------------------------------------------- + // buildRequests — banner_frameworks + // ------------------------------------------------------------------------- + describe('buildRequests - banner_frameworks', function () { + it('should set banner_frameworks from bid.params.banner_frameworks when no banner.api', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { banner: { sizes: [[300, 250]] } }; + bid.params.banner_frameworks = [1, 2, 3]; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.banner_frameworks).to.deep.equal([1, 2, 3]); + }); + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - expect(payload.schain).to.deep.equal({ - ver: '1.0', - complete: 1, - nodes: [ - { - 'asi': 'blob.com', - 'sid': '001', - 'hp': 1 - } - ] + // ------------------------------------------------------------------------- + // buildRequests — custom_renderer_present via bid.renderer + // ------------------------------------------------------------------------- + describe('buildRequests - custom renderer present', function () { + if (FEATURES.VIDEO) { + it('should set custom_renderer_present when bid.renderer is set for video imp', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'outstream', playerSize: [640, 480] } }; + bid.renderer = { id: 'custom', url: 'https://renderer.example.com/r.js' }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.custom_renderer_present).to.be.true; }); + } + }); + + // ------------------------------------------------------------------------- + // buildRequests — catch-all unknown camelCase params + // ------------------------------------------------------------------------- + describe('buildRequests - catch-all unknown params', function () { + it('should convert unknown camelCase params to snake_case in extAN', function () { + const bid = deepClone(BASE_BID); + bid.params.unknownCamelCaseParam = 'value123'; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.unknown_camel_case_param).to.equal('value123'); + }); + }); + + // ------------------------------------------------------------------------- + // buildRequests — bid-level keywords + // ------------------------------------------------------------------------- + describe('buildRequests - bid keywords', function () { + it('should map bid.params.keywords to extAN.keywords string', function () { + const bid = deepClone(BASE_BID); + bid.params.keywords = { genre: ['rock', 'pop'] }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.keywords).to.be.a('string'); + expect(req.data.imp[0].ext.appnexus.keywords).to.include('genre=rock,pop'); }); + }); + + // ------------------------------------------------------------------------- + // buildRequests — canonicalUrl in referer detection + // ------------------------------------------------------------------------- + describe('buildRequests - canonicalUrl', function () { + it('should set rd_can in referrer_detection when canonicalUrl is present', function () { + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.refererInfo.canonicalUrl = 'https://canonical.example.com/page'; + const [req] = spec.buildRequests([deepClone(BASE_BID)], bidderRequest); + expect(req.data.ext.appnexus.referrer_detection.rd_can).to.equal('https://canonical.example.com/page'); + }); + }); - it('should populate coppa if set in config', function () { - const bidRequest = Object.assign({}, bidRequests[0]); - sinon.stub(config, 'getConfig') - .withArgs('coppa') - .returns(true); + // ------------------------------------------------------------------------- + // buildRequests — publisherId → site.publisher.id + // ------------------------------------------------------------------------- + describe('buildRequests - publisherId', function () { + it('should set site.publisher.id from bid.params.publisherId', function () { + const bid = deepClone(BASE_BID); + bid.params.publisherId = 67890; + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.bids = [bid]; + const [req] = spec.buildRequests([bid], bidderRequest); + expect(req.data.site.publisher.id).to.equal('67890'); + }); + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + // ------------------------------------------------------------------------- + // buildRequests — member appended to endpoint URL + // ------------------------------------------------------------------------- + describe('buildRequests - member URL param', function () { + it('should append member_id to endpoint URL when bid.params.member is set', function () { + const bid = deepClone(BASE_BID); + bid.params.member = '456'; + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.bids = [bid]; + const [req] = spec.buildRequests([bid], bidderRequest); + expect(req.url).to.include('member_id=456'); + }); + }); - expect(payload.user.coppa).to.equal(true); + // ------------------------------------------------------------------------- + // buildRequests — gppConsent + // ------------------------------------------------------------------------- + describe('buildRequests - gppConsent', function () { + it('should set regs.gpp and regs.gpp_sid from gppConsent', function () { + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.gppConsent = { gppString: 'DBACMYA', applicableSections: [7] }; + const [req] = spec.buildRequests([deepClone(BASE_BID)], bidderRequest); + expect(req.data.regs.gpp).to.equal('DBACMYA'); + expect(req.data.regs.gpp_sid).to.deep.equal([7]); + }); + }); - config.getConfig.restore(); + // ------------------------------------------------------------------------- + // buildRequests — gdprApplies=false + // ------------------------------------------------------------------------- + describe('buildRequests - gdprApplies false', function () { + it('should set regs.ext.gdpr=0 when gdprApplies is false', function () { + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.gdprConsent = { gdprApplies: false, consentString: 'cs' }; + const [req] = spec.buildRequests([deepClone(BASE_BID)], bidderRequest); + expect(req.data.regs.ext.gdpr).to.equal(0); }); + }); - it('should set the X-Is-Test customHeader if test flag is enabled', function () { - const bidRequest = Object.assign({}, bidRequests[0]); - sinon.stub(config, 'getConfig') - .withArgs('apn_test') - .returns(true); + // ------------------------------------------------------------------------- + // buildRequests — user.externalUid + // ------------------------------------------------------------------------- + describe('buildRequests - user externalUid', function () { + it('should map externalUid to user.external_uid', function () { + const bid = deepClone(BASE_BID); + bid.params.user = { externalUid: 'uid-abc-123' }; + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.bids = [bid]; + const [req] = spec.buildRequests([bid], bidderRequest); + expect(req.data.user.external_uid).to.equal('uid-abc-123'); + }); + }); - const request = spec.buildRequests([bidRequest]); - expect(request.options.customHeaders).to.deep.equal({ 'X-Is-Test': 1 }); + // ------------------------------------------------------------------------- + // buildRequests — EID rtiPartner mapping (TDID / UID2) + // ------------------------------------------------------------------------- + describe('buildRequests - EID rtiPartner mapping', function () { + it('should set rtiPartner=TDID inside uids[0].ext for adserver.org EID', function () { + const bid = deepClone(BASE_BID); + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.ortb2.user = { ext: { eids: [{ source: 'adserver.org', uids: [{ id: 'tdid-value', atype: 1 }] }] } }; + const [req] = spec.buildRequests([bid], bidderRequest); + const eid = req.data.user?.ext?.eids?.find(e => e.source === 'adserver.org'); + expect(eid).to.exist; + expect(eid.uids[0].ext.rtiPartner).to.equal('TDID'); + expect(eid.rti_partner).to.be.undefined; + }); - config.getConfig.restore(); + it('should set rtiPartner=UID2 inside uids[0].ext for uidapi.com EID', function () { + const bid = deepClone(BASE_BID); + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.ortb2.user = { ext: { eids: [{ source: 'uidapi.com', uids: [{ id: 'uid2-value', atype: 3 }] }] } }; + const [req] = spec.buildRequests([bid], bidderRequest); + const eid = req.data.user?.ext?.eids?.find(e => e.source === 'uidapi.com'); + expect(eid).to.exist; + expect(eid.uids[0].ext.rtiPartner).to.equal('UID2'); + expect(eid.rti_partner).to.be.undefined; }); - it('should always set withCredentials: true on the request.options', function () { - const bidRequest = Object.assign({}, bidRequests[0]); - const request = spec.buildRequests([bidRequest]); - expect(request.options.withCredentials).to.equal(true); + it('should preserve existing uid.ext fields when adding rtiPartner', function () { + const bid = deepClone(BASE_BID); + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.ortb2.user = { ext: { eids: [{ source: 'adserver.org', uids: [{ id: 'tdid-value', atype: 1, ext: { existing: true } }] }] } }; + const [req] = spec.buildRequests([bid], bidderRequest); + const eid = req.data.user?.ext?.eids?.find(e => e.source === 'adserver.org'); + expect(eid).to.exist; + expect(eid.uids[0].ext.rtiPartner).to.equal('TDID'); + expect(eid.uids[0].ext.existing).to.be.true; }); + }); - it('should set simple domain variant if purpose 1 consent is not given', function () { - const consentString = 'BOJ8RZsOJ8RZsABAB8AAAAAZ+A=='; - const bidderRequest = { - 'bidderCode': 'mediafuse', - 'auctionId': '1d1a030790a475', - 'bidderRequestId': '22edbae2733bf6', - 'timeout': 3000, - 'gdprConsent': { - consentString: consentString, - gdprApplies: true, - apiVersion: 2, - vendorData: { - purpose: { - consents: { - 1: false - } - } - } - } - }; - bidderRequest.bids = bidRequests; - - const request = spec.buildRequests(bidRequests, bidderRequest); - expect(request.url).to.equal('https://ib.adnxs-simple.com/ut/v3/prebid'); - }); - - it('should populate eids when supported userIds are available', function () { - const bidRequest = Object.assign({}, bidRequests[0], { - userIdAsEids: [{ - source: 'adserver.org', - uids: [{ id: 'sample-userid' }] - }, { - source: 'criteo.com', - uids: [{ id: 'sample-criteo-userid' }] - }, { - source: 'netid.de', - uids: [{ id: 'sample-netId-userid' }] - }, { - source: 'liveramp.com', - uids: [{ id: 'sample-idl-userid' }] - }, { - source: 'uidapi.com', - uids: [{ id: 'sample-uid2-value' }] - }, { - source: 'puburl.com', - uids: [{ id: 'pubid1' }] - }, { - source: 'puburl2.com', - uids: [{ id: 'pubid2' }, { id: 'pubid2-123' }] - }] + // ------------------------------------------------------------------------- + // buildRequests — apn_test config → X-Is-Test header + // ------------------------------------------------------------------------- + describe('buildRequests - apn_test config header', function () { + it('should set X-Is-Test:1 custom header when config apn_test=true', function () { + sandbox.stub(config, 'getConfig').callsFake((key) => { + if (key === 'apn_test') return true; + return undefined; }); + const [req] = spec.buildRequests([deepClone(BASE_BID)], deepClone(BASE_BIDDER_REQUEST)); + expect(req.options.customHeaders).to.deep.equal({ 'X-Is-Test': 1 }); + }); + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - expect(payload.eids).to.deep.include({ - source: 'adserver.org', - id: 'sample-userid', - rti_partner: 'TDID' + // ------------------------------------------------------------------------- + // buildRequests — video minduration already set (skip overwrite) + // ------------------------------------------------------------------------- + describe('buildRequests - video minduration skip overwrite', function () { + if (FEATURES.VIDEO) { + it('should not overwrite minduration set by params.video when mediaTypes.video.minduration also present', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480], minduration: 10 } }; + bid.params.video = { minduration: 5 }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + // params.video sets minduration=5 first; mediaTypes check sees it's already a number → skips + expect(req.data.imp[0].video.minduration).to.equal(5); }); + } + }); - expect(payload.eids).to.deep.include({ - source: 'criteo.com', - id: 'sample-criteo-userid', + // ------------------------------------------------------------------------- + // buildRequests — playbackmethod out of range (>4) + // ------------------------------------------------------------------------- + describe('buildRequests - video playbackmethod out of range', function () { + if (FEATURES.VIDEO) { + it('should not set playback_method when playbackmethod[0] > 4', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480], playbackmethod: [5] } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].video.playback_method).to.be.undefined; }); + } + }); - expect(payload.eids).to.deep.include({ - source: 'netid.de', - id: 'sample-netId-userid', + // ------------------------------------------------------------------------- + // buildRequests — video api val=6 filtered out + // ------------------------------------------------------------------------- + describe('buildRequests - video api val=6 filtered', function () { + if (FEATURES.VIDEO) { + it('should produce empty video_frameworks when api=[6] since 6 is out of 1-5 range', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480], api: [6] } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.video_frameworks).to.deep.equal([]); }); + } + }); - expect(payload.eids).to.deep.include({ - source: 'liveramp.com', - id: 'sample-idl-userid' + // ------------------------------------------------------------------------- + // buildRequests — video_frameworks already set; api should not override + // ------------------------------------------------------------------------- + describe('buildRequests - video_frameworks not overridden by api', function () { + if (FEATURES.VIDEO) { + it('should keep frameworks from params.video when mediaTypes.video.api is also present', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480], api: [4] } }; + bid.params.video = { frameworks: [1, 2] }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.video_frameworks).to.deep.equal([1, 2]); }); + } + }); - expect(payload.eids).to.deep.include({ - source: 'uidapi.com', - id: 'sample-uid2-value', - rti_partner: 'UID2' - }); + // ------------------------------------------------------------------------- + // interpretResponse — adomain string vs empty array + // ------------------------------------------------------------------------- + describe('interpretResponse - adomain handling', function () { + it('should wrap string adomain in an array for advertiserDomains', function () { + const [req] = spec.buildRequests([deepClone(BASE_BID)], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + adomain: 'example.com', + ext: { appnexus: { bid_ad_type: 0 } } + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, req); + expect(bids[0].meta.advertiserDomains).to.deep.equal(['example.com']); }); - it('should populate iab_support object at the root level if omid support is detected', function () { - // with bid.params.frameworks - const bidRequest_A = Object.assign({}, bidRequests[0], { - params: { - frameworks: [1, 2, 5, 6], - video: { - frameworks: [1, 2, 5, 6] - } + it('should not set non-empty advertiserDomains when adomain is an empty array', function () { + const [req] = spec.buildRequests([deepClone(BASE_BID)], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + adomain: [], + ext: { appnexus: { bid_ad_type: 0 } } + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, req); + // adapter's guard skips setting advertiserDomains for empty arrays; + // ortbConverter may set it to [] — either way it must not be a non-empty array + const domains = bids[0].meta && bids[0].meta.advertiserDomains; + expect(!domains || domains.length === 0).to.be.true; + }); + }); + + // ------------------------------------------------------------------------- + // interpretResponse — banner impression_urls trackers + // ------------------------------------------------------------------------- + describe('interpretResponse - banner trackers', function () { + it('should append tracker pixel HTML to bid.ad when trackers.impression_urls is present', function () { + const [req] = spec.buildRequests([deepClone(BASE_BID)], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + adm: '