diff --git a/modules/mediafuseBidAdapter.js b/modules/mediafuseBidAdapter.js index 0bffb9219c..bf4b52f894 100644 --- a/modules/mediafuseBidAdapter.js +++ b/modules/mediafuseBidAdapter.js @@ -1,501 +1,566 @@ +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 { bidderSettings } from '../src/bidderSettings.js'; import { - createTrackPixelHtml, deepAccess, - deepClone, - getBidRequest, + deepSetValue, getParameterByName, isArray, isArrayOfNums, - isEmpty, isFn, isNumber, isPlainObject, isStr, + isEmpty, logError, logInfo, logMessage, - logWarn + logWarn, + createTrackPixelHtml } 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 { config } from '../src/config.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 {chunk} from '../libraries/chunk/chunk.js'; - -/** - * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest - * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid - */ +import { convertCamelToUnderscore, fill } from '../libraries/appnexusUtils/anUtils.js'; +import { chunk } from '../libraries/chunk/chunk.js'; 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 = ' USER_PARAMS.includes(param)) - .forEach((param) => { - const uparam = convertCamelToUnderscore(param); - if (param === 'segments' && isArray(userObjBid.params.user[param])) { - const segs = []; - userObjBid.params.user[param].forEach(val => { - if (isNumber(val)) { - segs.push({'id': val}); - } else if (isPlainObject(val)) { - segs.push(val); - } - }); - userObj[uparam] = segs; - } else if (param !== 'segments') { - userObj[uparam] = userObjBid.params.user[param]; - } - }); + // Legacy support for placement_id vs placementId + const placementId = bidderParams.placement_id || bidderParams.placementId; + if (placementId) { + extANData.placement_id = parseInt(placementId, 10); + } else { + const invCode = bidderParams.inv_code || bidderParams.invCode; + if (invCode) { + deepSetValue(imp, 'tagid', invCode); + } } - const appDeviceObjBid = ((bidRequests) || []).find(hasAppDeviceInfo); - let appDeviceObj; - if (appDeviceObjBid && appDeviceObjBid.params && appDeviceObjBid.params.app) { - appDeviceObj = {}; - Object.keys(appDeviceObjBid.params.app) - .filter(param => APP_DEVICE_PARAMS.includes(param)) - .forEach(param => { - appDeviceObj[param] = appDeviceObjBid.params.app[param]; - }); + if (imp.banner) { + // primary_size for legacy support + const firstFormat = deepAccess(imp, 'banner.format.0'); + if (firstFormat) { + extANData.primary_size = firstFormat; + } + if (!imp.banner.api) { + const bannerFrameworks = bidderParams.banner_frameworks || bidderParams.frameworks; + if (isArrayOfNums(bannerFrameworks)) { + extANData.banner_frameworks = bannerFrameworks; + } + } } - const appIdObjBid = ((bidRequests) || []).find(hasAppId); - let appIdObj; - if (appIdObjBid && appIdObjBid.params && appDeviceObjBid.params.app && appDeviceObjBid.params.app.id) { - appIdObj = { - appid: appIdObjBid.params.app.id - }; + const gpid = deepAccess(bidRequest, 'ortb2Imp.ext.gpid'); + if (gpid) { + extANData.gpid = gpid; } - let debugObj = {}; - const debugObjParams = {}; - const debugCookieName = 'apn_prebid_debug'; - const debugCookie = storage.getCookie(debugCookieName) || null; + const isAdPod = deepAccess(bidRequest, 'mediaTypes.video.context') === 'adpod'; + extANData.hb_source = isAdPod ? 7 : 1; - if (debugCookie) { - try { - debugObj = JSON.parse(debugCookie); - } catch (e) { - logError('MediaFuse Debug Auction Cookie Error:\n\n' + e); + if (imp.video) { + if (deepAccess(bidRequest, 'mediaTypes.video.context') === 'instream') { + extANData.require_asset_url = true; } - } else { - const debugBidRequest = ((bidRequests) || []).find(hasDebug); - if (debugBidRequest && debugBidRequest.debug) { - debugObj = debugBidRequest.debug; + + const videoParams = bidderParams.video; + if (videoParams) { + Object.keys(videoParams) + .filter(param => VIDEO_TARGETING.includes(param)) + .forEach(param => { + if (param === 'frameworks') { + if (isArray(videoParams.frameworks)) { + extANData.video_frameworks = videoParams.frameworks; + } + } else { + imp.video[param] = videoParams[param]; + } + }); } - } - if (debugObj && debugObj.enabled) { - Object.keys(debugObj) - .filter(param => DEBUG_PARAMS.includes(param)) - .forEach(param => { - debugObjParams[param] = debugObj[param]; - }); + const videoMediaType = deepAccess(bidRequest, 'mediaTypes.video'); + if (videoMediaType) { + Object.keys(videoMediaType) + .filter(param => VIDEO_RTB_TARGETING.includes(param)) + .forEach(param => { + switch (param) { + case 'minduration': + case 'maxduration': + if (typeof imp.video[param] !== 'number') imp.video[param] = videoMediaType[param]; + break; + case 'skip': + if (typeof imp.video['skippable'] !== 'boolean') imp.video['skippable'] = (videoMediaType[param] === 1); + break; + case 'skipafter': + if (typeof imp.video['skipoffset'] !== 'number') imp.video['skipoffset'] = videoMediaType[param]; + break; + case 'playbackmethod': + if (typeof imp.video['playback_method'] !== 'number' && isArray(videoMediaType[param])) { + const type = videoMediaType[param][0]; + if (type >= 1 && type <= 4) { + imp.video['playback_method'] = type; + } + } + break; + case 'api': + if (!extANData.video_frameworks && isArray(videoMediaType[param])) { + const apiTmp = videoMediaType[param].map(val => { + const v = (val === 4) ? 5 : (val === 5) ? 4 : val; + return (v >= 1 && v <= 5) ? v : undefined; + }).filter(v => v !== undefined); + extANData.video_frameworks = apiTmp; + } + break; + } + }); + } + + if (deepAccess(bidRequest, 'mediaTypes.video.context') === 'outstream') { + imp.video.placement = imp.video.placement || 4; + } + + if (bidRequest.renderer) { + extANData.custom_renderer_present = true; + } } - const memberIdBid = ((bidRequests) || []).find(hasMemberId); - const member = memberIdBid ? parseInt(memberIdBid.params.member, 10) : 0; - const schain = bidRequests[0]?.ortb2?.source?.ext?.schain; - const omidSupport = ((bidRequests) || []).find(hasOmidSupport); + // Force creative override logic + const overrides = getParameterByName('ast_override_div'); + if (overrides) { + const adUnitOverride = decodeURIComponent(overrides).split(',').find((pair) => pair.startsWith(`${bidRequest.adUnitCode}:`)); + if (adUnitOverride) { + const forceCreativeId = adUnitOverride.split(':')[1]; + if (forceCreativeId) { + extANData.force_creative_id = parseInt(forceCreativeId, 10); + } + } + } - const payload = { - tags: [...tags], - user: userObj, - sdk: { - source: SOURCE, - version: '$prebid.version$' - }, - schain: schain + // Optional params map + const optionalParamsMap = { + 'allowSmallerSizes': 'allow_smaller_sizes', + 'usePaymentRule': 'use_pmt_rule', + 'trafficSourceCode': 'traffic_source_code', + 'pubClick': 'pubclick', + 'extInvCode': 'ext_inv_code', + 'externalImpId': 'ext_imp_id', + 'supplyType': 'supply_type' }; - if (omidSupport) { - payload['iab_support'] = { - omidpn: 'Mediafuse', - omidpv: '$prebid.version$' - }; + Object.entries(optionalParamsMap).forEach(([paramName, ortbName]) => { + if (bidderParams[paramName] !== undefined) { + if (ortbName === 'ext_imp_id') { + imp.id = bidderParams[paramName]; + } else { + extANData[ortbName] = bidderParams[paramName]; + } + } + }); + + // Snake-case catch-all + const knownParams = ['placementId', 'placement_id', 'invCode', 'inv_code', 'member', 'keywords', 'reserve', 'video', 'user', 'app', 'frameworks', 'position', 'publisherId', 'publisher_id', ...Object.keys(optionalParamsMap), ...Object.values(optionalParamsMap), 'banner_frameworks', 'video_frameworks']; + Object.keys(bidderParams) + .filter(param => !knownParams.includes(param)) + .forEach(param => { + extANData[convertCamelToUnderscore(param)] = bidderParams[param]; + }); + + // Keywords + if (!isEmpty(bidderParams.keywords)) { + const keywords = getANKewyordParamFromMaps(bidderParams.keywords); + if (keywords && keywords.length > 0) { + extANData.keywords = keywords.map(kw => kw.key + (kw.value ? '=' + kw.value.join(',') : '')).join(','); + } } - if (member > 0) { - payload.member_id = member; + // Floor + const bidFloor = getBidFloor(bidRequest); + if (bidFloor) { + imp.bidfloor = bidFloor; + imp.bidfloorcur = 'USD'; + } else { + delete imp.bidfloor; + delete imp.bidfloorcur; } - if (appDeviceObjBid) { - payload.device = appDeviceObj; + if (Object.keys(extANData).length > 0) { + deepSetValue(imp, 'ext.appnexus', extANData); } - if (appIdObjBid) { - payload.app = appIdObj; + + return imp; + }, + request(buildRequest, imps, bidderRequest, context) { + const request = buildRequest(imps, bidderRequest, context); + + if (request?.user?.ext?.eids?.length > 0) { + request.user.ext.eids = request.user.ext.eids.map(eid => { + if (eid.source === 'adserver.org') { + return Object.assign({}, eid, { rti_partner: 'TDID' }); + } else if (eid.source === 'uidapi.com') { + return Object.assign({}, eid, { rti_partner: 'UID2' }); + } + return eid; + }); } - const mfKeywords = config.getConfig('mediafuseAuctionKeywords'); - payload.keywords = getANKeywordParam(bidderRequest?.ortb2, mfKeywords); + const extANData = { + prebid: true, + hb_source: isAdPodRequest(imps) ? 7 : 1, + sdk: { + version: '$prebid.version$', + source: SOURCE + } + }; if (config.getConfig('adpod.brandCategoryExclusion')) { - payload.brand_category_uniqueness = true; + extANData.brand_category_uniqueness = true; } - if (debugObjParams.enabled) { - payload.debug = debugObjParams; - logInfo('MediaFuse Debug Auction Settings:\n\n' + JSON.stringify(debugObjParams, null, 4)); + if (bidderRequest?.refererInfo) { + const refererinfo = { + rd_ref: bidderRequest.refererInfo.topmostLocation ? encodeURIComponent(bidderRequest.refererInfo.topmostLocation) : '', + rd_top: bidderRequest.refererInfo.reachedTop, + rd_ifs: bidderRequest.refererInfo.numIframes, + rd_stk: bidderRequest.refererInfo.stack?.map((url) => encodeURIComponent(url)).join(',') + }; + if (bidderRequest.refererInfo.canonicalUrl) { + refererinfo.rd_can = bidderRequest.refererInfo.canonicalUrl; + } + extANData.referrer_detection = refererinfo; + } + + // App/Device parameters + const expandedBids = bidderRequest?.bids || []; + const memberBid = expandedBids.find(bid => bid.params && bid.params.member); + const commonBidderParams = memberBid ? memberBid.params : (expandedBids[0] && expandedBids[0].params); + + if (commonBidderParams) { + if (commonBidderParams.member) { + extANData.member_id = parseInt(commonBidderParams.member, 10); + } + if (commonBidderParams.publisherId) { + deepSetValue(request, 'site.publisher.id', commonBidderParams.publisherId.toString()); + } } - if (bidderRequest && bidderRequest.gdprConsent) { - // note - objects for impbus use underscore instead of camelCase - payload.gdpr_consent = { - consent_string: bidderRequest.gdprConsent.consentString, - consent_required: bidderRequest.gdprConsent.gdprApplies + if (hasOmidSupport(bidderRequest.bids?.[0])) { + extANData.iab_support = { + omidpn: 'Mediafuse', + omidpv: '$prebid.version$' }; + } + + deepSetValue(request, 'ext.appnexus', extANData); + + // GDPR / Consent + if (bidderRequest.gdprConsent) { + deepSetValue(request, 'regs.ext.gdpr', bidderRequest.gdprConsent.gdprApplies ? 1 : 0); + deepSetValue(request, 'user.ext.consent', bidderRequest.gdprConsent.consentString); if (bidderRequest.gdprConsent.addtlConsent && bidderRequest.gdprConsent.addtlConsent.indexOf('~') !== -1) { const ac = bidderRequest.gdprConsent.addtlConsent; - // pull only the ids from the string (after the ~) and convert them to an array of ints const acStr = ac.substring(ac.indexOf('~') + 1); - payload.gdpr_consent.addtl_consent = acStr.split('.').map(id => parseInt(id, 10)); + const addtlConsent = acStr.split('.').map(id => parseInt(id, 10)).filter(id => !isNaN(id)); + if (addtlConsent.length > 0) { + deepSetValue(request, 'user.ext.addtl_consent', addtlConsent); + } } } - if (bidderRequest && bidderRequest.uspConsent) { - payload.us_privacy = bidderRequest.uspConsent; + if (bidderRequest.uspConsent) { + deepSetValue(request, 'regs.ext.us_privacy', bidderRequest.uspConsent); } - if (bidderRequest && bidderRequest.refererInfo) { - const refererinfo = { - // TODO: this collects everything it finds, except for canonicalUrl - rd_ref: encodeURIComponent(bidderRequest.refererInfo.topmostLocation), - rd_top: bidderRequest.refererInfo.reachedTop, - rd_ifs: bidderRequest.refererInfo.numIframes, - rd_stk: bidderRequest.refererInfo.stack.map((url) => encodeURIComponent(url)).join(',') - }; - payload.referrer_detection = refererinfo; + if (bidderRequest.gppConsent) { + deepSetValue(request, 'regs.gpp', bidderRequest.gppConsent.gppString); + deepSetValue(request, 'regs.gpp_sid', bidderRequest.gppConsent.applicableSections); } - const hasAdPodBid = ((bidRequests) || []).find(hasAdPod); - if (hasAdPodBid) { - bidRequests.filter(hasAdPod).forEach(adPodBid => { - const adPodTags = createAdPodRequest(tags, adPodBid); - // don't need the original adpod placement because it's in adPodTags - const nonPodTags = payload.tags.filter(tag => tag.uuid !== adPodBid.bidId); - payload.tags = [...nonPodTags, ...adPodTags]; - }); + if (config.getConfig('coppa') === true) { + deepSetValue(request, 'regs.coppa', 1); } - if (bidRequests[0].userIdAsEids?.length > 0) { - const eids = []; - bidRequests[0].userIdAsEids.forEach(eid => { - if (!eid || !eid.uids || eid.uids.length < 1) { return; } - eid.uids.forEach(uid => { - const tmp = {'source': eid.source, 'id': uid.id}; - if (eid.source === 'adserver.org') { - tmp.rti_partner = 'TDID'; - } else if (eid.source === 'uidapi.com') { - tmp.rti_partner = 'UID2'; + // User Params + const userObjBid = ((bidderRequest?.bids) || []).find(bid => bid.params?.user); + if (userObjBid) { + const userObj = request.user || {}; + Object.keys(userObjBid.params.user) + .filter(param => USER_PARAMS.includes(param)) + .forEach((param) => { + const uparam = convertCamelToUnderscore(param); + if (param === 'segments' && isArray(userObjBid.params.user[param])) { + const segs = userObjBid.params.user[param].map(val => { + if (isNumber(val)) return { 'id': val }; + if (isPlainObject(val)) return val; + return undefined; + }).filter(s => s); + userObj.ext = userObj.ext || {}; + userObj.ext[uparam] = segs; + } else if (param !== 'segments') { + userObj[uparam] = userObjBid.params.user[param]; } - eids.push(tmp); }); - }); + request.user = userObj; + } - if (eids.length) { - payload.eids = eids; - } + // App Params + const appObjBid = ((bidderRequest?.bids) || []).find(bid => bid.params?.app); + if (appObjBid) { + request.app = Object.assign({}, request.app, appObjBid.params.app); } - if (tags[0].publisher_id) { - payload.publisher_id = tags[0].publisher_id; + // Global Keywords + const mfKeywords = config.getConfig('mediafuseAuctionKeywords'); + if (mfKeywords) { + const keywords = getANKeywordParam(bidderRequest?.ortb2, mfKeywords); + if (keywords && keywords.length > 0) { + const kwString = keywords.map(kw => kw.key + (kw.value ? '=' + kw.value.join(',') : '')).join(','); + deepSetValue(request, 'ext.appnexus.keywords', kwString); + } } - const request = formatRequest(payload, bidderRequest); return request; }, - - /** - * Unpack the response from the server into a list of bids. - * - * @param {*} serverResponse A successful response from the server. - * @return {Bid[]} An array of bids which were nested inside the server. - */ - interpretResponse: function (serverResponse, { bidderRequest }) { - serverResponse = serverResponse.body; - const bids = []; - if (!serverResponse || serverResponse.error) { - let errorMessage = `in response for ${bidderRequest.bidderCode} adapter`; - if (serverResponse && serverResponse.error) { errorMessage += `: ${serverResponse.error}`; } - logError(errorMessage); - return bids; + bidResponse(buildBidResponse, bid, context) { + const { bidRequest } = context; + let mediaType; + const bidAdType = bid?.ext?.appnexus?.bid_ad_type; + const extANData = deepAccess(bid, 'ext.appnexus'); + + if (isNumber(bidAdType) && bidAdType in RESPONSE_MEDIA_TYPE_MAP) { + mediaType = RESPONSE_MEDIA_TYPE_MAP[bidAdType]; + // Native Masquerading + if (mediaType === NATIVE) { + context.mediaType = BANNER; + } else { + context.mediaType = mediaType; + } } - if (serverResponse.tags) { - serverResponse.tags.forEach(serverBid => { - const rtbBid = getRtbBid(serverBid); - if (rtbBid) { - const cpmCheck = (bidderSettings.get(bidderRequest.bidderCode, 'allowZeroCpmBids') === true) ? rtbBid.cpm >= 0 : rtbBid.cpm > 0; - if (cpmCheck && this.supportedMediaTypes.includes(rtbBid.ad_type)) { - const bid = newBid(serverBid, rtbBid, bidderRequest); - bid.mediaType = parseMediaType(rtbBid); - bids.push(bid); - } - } - }); - } + const bidResponse = buildBidResponse(bid, context); - if (serverResponse.debug && serverResponse.debug.debug_info) { - const debugHeader = 'MediaFuse Debug Auction for Prebid\n\n' - let debugText = debugHeader + serverResponse.debug.debug_info - debugText = debugText - .replace(/(|)/gm, '\t') // Tables - .replace(/(<\/td>|<\/th>)/gm, '\n') // Tables - .replace(/^
/gm, '') // Remove leading
- .replace(/(
\n|
)/gm, '\n') //
- .replace(/

(.*)<\/h1>/gm, '\n\n===== $1 =====\n\n') // Header H1 - .replace(/(.*)<\/h[2-6]>/gm, '\n\n*** $1 ***\n\n') // Headers - .replace(/(<([^>]+)>)/igm, ''); // Remove any other tags - // logMessage('https://console.appnexus.com/docs/understanding-the-debug-auction'); - logMessage(debugText); + if (mediaType === NATIVE) { + bidResponse.mediaType = NATIVE; } - return bids; - }, + if (extANData) { + bidResponse.mediafuse = { + buyerMemberId: extANData.buyer_member_id, + dealPriority: extANData.deal_priority, + dealCode: extANData.deal_code + }; + bidResponse.meta = Object.assign({}, bidResponse.meta, { + advertiserId: extANData.advertiser_id, + brandId: extANData.brand_id + }); - getUserSyncs: function (syncOptions, responses, gdprConsent) { - if (syncOptions.iframeEnabled && hasPurpose1Consent({gdprConsent})) { - return [{ - type: 'iframe', - url: 'https://acdn.adnxs.com/dmp/async_usersync.html' - }]; + if (extANData.buyer_member_id) { + bidResponse.meta.dchain = { + ver: '1.0', + complete: 0, + nodes: [{ + bsid: extANData.buyer_member_id.toString() + }] + }; + } } - }, - /** - * Add element selector to javascript tracker to improve native viewability - * @param {Bid} bid - */ - onBidWon: function (bid) { - if (bid.native) { - reloadViewabilityScriptWithCorrectParameters(bid); + if (bid.adomain) { + const adomain = Array.isArray(bid.adomain) ? bid.adomain : [bid.adomain]; + if (adomain.length > 0) { + bidResponse.meta = bidResponse.meta || {}; + bidResponse.meta.advertiserDomains = adomain; + } } - } -}; - -function reloadViewabilityScriptWithCorrectParameters(bid) { - const viewJsPayload = getMediafuseViewabilityScriptFromJsTrackers(bid.native.javascriptTrackers); - if (viewJsPayload) { - const prebidParams = 'pbjs_adid=' + bid.adId + ';pbjs_auc=' + bid.adUnitCode; + // Video + if (mediaType === VIDEO) { + bidResponse.ttl = 3600; + if (bid.nurl) { + bidResponse.vastImpUrl = bid.nurl; + } - const jsTrackerSrc = getViewabilityScriptUrlFromPayload(viewJsPayload); + const videoContext = deepAccess(bidRequest, 'mediaTypes.video.context'); + if (videoContext === 'adpod') { + if (extANData?.brand_category_id && APPNEXUS_CATEGORY_MAPPING[extANData.brand_category_id]) { + bidResponse.meta.primaryCatId = APPNEXUS_CATEGORY_MAPPING[extANData.brand_category_id]; + } + bidResponse.video = { + context: 'adpod', + dealTier: extANData?.deal_priority + }; + } - const newJsTrackerSrc = jsTrackerSrc.replace('dom_id=%native_dom_id%', prebidParams); + if (extANData?.renderer_url && extANData?.renderer_id) { + const rendererOptions = deepAccess(bidRequest, 'mediaTypes.video.renderer.options') || deepAccess(bidRequest, 'renderer.options'); + bidResponse.adResponse = { + ad: { + notify_url: bid.nurl || '', + renderer_config: extANData.renderer_config || '', + }, + auction_id: extANData.auction_id, + content: bidResponse.vastXml, + tag_id: extANData.tag_id, + uuid: bidResponse.requestId + }; + bidResponse.renderer = newRenderer(bidResponse.adUnitCode, { + renderer_url: extANData.renderer_url, + renderer_id: extANData.renderer_id, + }, rendererOptions); + } else if (bid.nurl && extANData?.asset_url) { + bidResponse.vastUrl = bid.nurl + '&redir=' + encodeURIComponent(extANData.asset_url); + } + } - // find iframe containing script tag - const frameArray = document.getElementsByTagName('iframe'); + // Native Manual Mapping + if (mediaType === NATIVE) { + try { + const nativeAdm = JSON.parse(bid.adm); + const nativeAd = nativeAdm.native || nativeAdm; + bidResponse.native = { + title: nativeAd.title, + body: nativeAd.desc, + body2: nativeAd.desc2, + cta: nativeAd.ctatext, + rating: nativeAd.rating, + sponsoredBy: nativeAd.sponsored, + privacyLink: nativeAd.privacy_link, + address: nativeAd.address, + downloads: nativeAd.downloads, + likes: nativeAd.likes, + phone: nativeAd.phone, + price: nativeAd.price, + salePrice: nativeAd.saleprice, + clickUrl: nativeAd.link?.url, + displayUrl: nativeAd.displayurl, + clickTrackers: nativeAd.link?.click_trackers, + impressionTrackers: nativeAd.impression_trackers, + }; - // boolean var to modify only one script. That way if there are muliple scripts, - // they won't all point to the same creative. - let modifiedAScript = false; + if (nativeAd.main_img) { + bidResponse.native.image = { url: nativeAd.main_img.url, width: nativeAd.main_img.width, height: nativeAd.main_img.height }; + } + if (nativeAd.icon) { + bidResponse.native.icon = { url: nativeAd.icon.url, width: nativeAd.icon.width, height: nativeAd.icon.height }; + } - // first, loop on all ifames - for (let i = 0; i < frameArray.length && !modifiedAScript; i++) { - const currentFrame = frameArray[i]; - try { - // IE-compatible, see https://stackoverflow.com/a/3999191/2112089 - const nestedDoc = currentFrame.contentDocument || currentFrame.contentWindow.document; - - if (nestedDoc) { - // if the doc is present, we look for our jstracker - const scriptArray = nestedDoc.getElementsByTagName('script'); - for (let j = 0; j < scriptArray.length && !modifiedAScript; j++) { - const currentScript = scriptArray[j]; - if (currentScript.getAttribute('data-src') === jsTrackerSrc) { - currentScript.setAttribute('src', newJsTrackerSrc); - currentScript.setAttribute('data-src', ''); - if (currentScript.removeAttribute) { - currentScript.removeAttribute('data-src'); - } - modifiedAScript = true; + let jsTrackers = nativeAd.javascript_trackers; + const viewabilityConfig = deepAccess(bid, 'ext.appnexus.viewability.config'); + if (viewabilityConfig) { + const jsTrackerDisarmed = viewabilityConfig.replace('src=', 'data-src='); + if (jsTrackers === undefined || jsTrackers === null) { + jsTrackers = [jsTrackerDisarmed]; + } else if (isStr(jsTrackers)) { + jsTrackers = [jsTrackers, jsTrackerDisarmed]; + } else if (isArray(jsTrackers)) { + jsTrackers.push(jsTrackerDisarmed); + } + } else if (nativeAd.eventtrackers) { + nativeAd.eventtrackers.forEach(track => { + if (track.method === 1 && track.url.match(VIEWABILITY_URL_START) && track.url.indexOf(VIEWABILITY_FILE_NAME) > -1) { + track.url = track.url.replace('src=', 'data-src='); } + }); + const trackers = nativeAd.eventtrackers.filter(t => t.method === 1).map(t => t.url); + if (jsTrackers === undefined || jsTrackers === null) { + jsTrackers = trackers; + } else if (isStr(jsTrackers)) { + jsTrackers = [jsTrackers, ...trackers]; + } else if (isArray(jsTrackers)) { + jsTrackers.push(...trackers); } } - } catch (exception) { - // trying to access a cross-domain iframe raises a SecurityError - // this is expected and ignored - if (!(exception instanceof DOMException && exception.name === 'SecurityError')) { - // all other cases are raised again to be treated by the calling function - throw exception; - } + bidResponse.native.javascriptTrackers = jsTrackers; + } catch (e) { + logError('Mediafuse Native adm parse error', e); } } - } -} -function strIsMediafuseViewabilityScript(str) { - const regexMatchUrlStart = str.match(VIEWABILITY_URL_START); - const viewUrlStartInStr = regexMatchUrlStart != null && regexMatchUrlStart.length >= 1; - - const regexMatchFileName = str.match(VIEWABILITY_FILE_NAME); - const fileNameInStr = regexMatchFileName != null && regexMatchFileName.length >= 1; - - return str.startsWith(SCRIPT_TAG_START) && fileNameInStr && viewUrlStartInStr; -} - -function getMediafuseViewabilityScriptFromJsTrackers(jsTrackerArray) { - let viewJsPayload; - if (isStr(jsTrackerArray) && strIsMediafuseViewabilityScript(jsTrackerArray)) { - viewJsPayload = jsTrackerArray; - } else if (isArray(jsTrackerArray)) { - for (let i = 0; i < jsTrackerArray.length; i++) { - const currentJsTracker = jsTrackerArray[i]; - if (strIsMediafuseViewabilityScript(currentJsTracker)) { - viewJsPayload = currentJsTracker; - } + // Banner Trackers + if (mediaType === BANNER && extANData?.trackers) { + extANData.trackers.forEach(tracker => { + if (tracker.impression_urls) { + tracker.impression_urls.forEach(url => { + bidResponse.ad = (bidResponse.ad || '') + createTrackPixelHtml(url); + }); + } + }); } - } - return viewJsPayload; -} - -function getViewabilityScriptUrlFromPayload(viewJsPayload) { - // extracting the content of the src attribute - // -> substring between src=" and " - const indexOfFirstQuote = viewJsPayload.indexOf('src="') + 5; // offset of 5: the length of 'src=' + 1 - const indexOfSecondQuote = viewJsPayload.indexOf('"', indexOfFirstQuote); - const jsTrackerSrc = viewJsPayload.substring(indexOfFirstQuote, indexOfSecondQuote); - return jsTrackerSrc; -} - -function formatRequest(payload, bidderRequest) { - let request = []; - const options = { - withCredentials: true - }; - - let endpointUrl = URL; - if (!hasPurpose1Consent(bidderRequest?.gdprConsent)) { - endpointUrl = URL_SIMPLE; + return bidResponse; } +}); - if (getParameterByName('apn_test').toUpperCase() === 'TRUE' || config.getConfig('apn_test') === true) { - options.customHeaders = { - 'X-Is-Test': 1 - }; +function getBidFloor(bid) { + if (!isFn(bid.getFloor)) { + return (bid.params.reserve != null) ? bid.params.reserve : null; } - - if (payload.tags.length > MAX_IMPS_PER_REQUEST) { - const clonedPayload = deepClone(payload); - - chunk(payload.tags, MAX_IMPS_PER_REQUEST).forEach(tags => { - clonedPayload.tags = tags; - const payloadString = JSON.stringify(clonedPayload); - request.push({ - method: 'POST', - url: endpointUrl, - data: payloadString, - bidderRequest, - options - }); - }); - } else { - const payloadString = JSON.stringify(payload); - request = { - method: 'POST', - url: endpointUrl, - data: payloadString, - bidderRequest, - options - }; + // Mediafuse/AppNexus generally expects USD for its RTB endpoints + let floor = bid.getFloor({ + currency: 'USD', + mediaType: '*', + size: '*' + }); + if (isPlainObject(floor) && !isNaN(floor.floor) && floor.currency === 'USD') { + return floor.floor; } - - return request; + return null; } function newRenderer(adUnitCode, rtbBid, rendererOptions = {}) { @@ -504,7 +569,7 @@ function newRenderer(adUnitCode, rtbBid, rendererOptions = {}) { url: rtbBid.renderer_url, config: rendererOptions, loaded: false, - adUnitCode + adUnitCode, }); try { @@ -514,588 +579,347 @@ function newRenderer(adUnitCode, rtbBid, rendererOptions = {}) { } renderer.setEventHandlers({ - impression: () => logMessage('MediaFuse outstream video impression event'), - loaded: () => logMessage('MediaFuse outstream video loaded event'), + impression: () => logMessage('Mediafuse outstream video impression event'), + loaded: () => logMessage('Mediafuse outstream video loaded event'), ended: () => { - logMessage('MediaFuse outstream renderer video event'); - document.querySelector(`#${adUnitCode}`).style.display = 'none'; - } + logMessage('Mediafuse outstream renderer video event'); + const el = document.querySelector(`#${adUnitCode}`); + if (el) { + el.style.display = 'none'; + } + }, }); return renderer; } -/** - * Unpack the Server's Bid into a Prebid-compatible one. - * @param serverBid - * @param rtbBid - * @param bidderRequest - * @return Bid - */ -function newBid(serverBid, rtbBid, bidderRequest) { - const bidRequest = getBidRequest(serverBid.uuid, [bidderRequest]); - const bid = { - requestId: serverBid.uuid, - cpm: rtbBid.cpm, - creativeId: rtbBid.creative_id, - dealId: rtbBid.deal_id, - currency: 'USD', - netRevenue: true, - ttl: 300, - adUnitCode: bidRequest.adUnitCode, - mediafuse: { - buyerMemberId: rtbBid.buyer_member_id, - dealPriority: rtbBid.deal_priority, - dealCode: rtbBid.deal_code +function hidedfpContainer(elementId) { + try { + const el = document.getElementById(elementId).querySelectorAll("div[id^='google_ads']"); + if (el[0]) { + el[0].style.setProperty('display', 'none'); } - }; - - // WE DON'T FULLY SUPPORT THIS ATM - future spot for adomain code; creating a stub for 5.0 compliance - if (rtbBid.adomain) { - bid.meta = Object.assign({}, bid.meta, { advertiserDomains: [] }); - } - - if (rtbBid.advertiser_id) { - bid.meta = Object.assign({}, bid.meta, { advertiserId: rtbBid.advertiser_id }); + } catch (e) { + logWarn('Mediafuse: hidedfpContainer error', e); } +} - // temporary function; may remove at later date if/when adserver fully supports dchain - function setupDChain(rtbBid) { - const dchain = { - ver: '1.0', - complete: 0, - nodes: [{ - bsid: rtbBid.buyer_member_id.toString() - }]}; - - return dchain; - } - if (rtbBid.buyer_member_id) { - bid.meta = Object.assign({}, bid.meta, {dchain: setupDChain(rtbBid)}); +function hideSASIframe(elementId) { + try { + const el = document.getElementById(elementId).querySelectorAll("script[id^='sas_script']"); + if (el[0]?.nextSibling?.localName === 'iframe') { + el[0].nextSibling.style.setProperty('display', 'none'); + } + } catch (e) { + logWarn('Mediafuse: hideSASIframe error', e); } +} - if (rtbBid.brand_id) { - bid.meta = Object.assign({}, bid.meta, { brandId: rtbBid.brand_id }); +function handleOutstreamRendererEvents(bid, id, eventName) { + try { + bid.renderer.handleVideoEvent({ + id, + eventName, + }); + } catch (err) { + logWarn(`Mediafuse: handleOutstreamRendererEvents error for ${eventName}`, err); } +} - if (rtbBid.rtb.video) { - // shared video properties used for all 3 contexts - Object.assign(bid, { - width: rtbBid.rtb.video.player_width, - height: rtbBid.rtb.video.player_height, - vastImpUrl: rtbBid.notify_url, - ttl: 3600 - }); +function outstreamRender(bid, doc) { + hidedfpContainer(bid.adUnitCode); + hideSASIframe(bid.adUnitCode); + bid.renderer.push(() => { + const win = doc?.defaultView || window; + if (win.ANOutstreamVideo) { + let sizes = bid.getSize(); + if (typeof sizes === 'string' && sizes.indexOf('x') > -1) { + sizes = [sizes.split('x').map(Number)]; + } else if (!isArray(sizes) || (isArray(sizes) && !isArray(sizes[0]))) { + sizes = [sizes]; + } - const videoContext = deepAccess(bidRequest, 'mediaTypes.video.context'); - switch (videoContext) { - case ADPOD: - const primaryCatId = (APPNEXUS_CATEGORY_MAPPING[rtbBid.brand_category_id]) ? APPNEXUS_CATEGORY_MAPPING[rtbBid.brand_category_id] : null; - bid.meta = Object.assign({}, bid.meta, { primaryCatId }); - const dealTier = rtbBid.deal_priority; - bid.video = { - context: ADPOD, - durationSeconds: Math.floor(rtbBid.rtb.video.duration_ms / 1000), - dealTier - }; - bid.vastUrl = rtbBid.rtb.video.asset_url; - break; - case OUTSTREAM: - bid.adResponse = serverBid; - bid.adResponse.ad = bid.adResponse.ads[0]; - bid.adResponse.ad.video = bid.adResponse.ad.rtb.video; - bid.vastXml = rtbBid.rtb.video.content; - - if (rtbBid.renderer_url) { - const videoBid = ((bidderRequest.bids) || []).find(bid => bid.bidId === serverBid.uuid); - const rendererOptions = deepAccess(videoBid, 'renderer.options'); - bid.renderer = newRenderer(bid.adUnitCode, rtbBid, rendererOptions); - } - break; - case INSTREAM: - bid.vastUrl = rtbBid.notify_url + '&redir=' + encodeURIComponent(rtbBid.rtb.video.asset_url); - break; + win.ANOutstreamVideo.renderAd({ + tagId: bid.adResponse.tag_id, + sizes: sizes, + targetId: bid.adUnitCode, + uuid: bid.requestId, + adResponse: bid.adResponse, + rendererOptions: bid.renderer.getConfig(), + }, + handleOutstreamRendererEvents.bind(null, bid) + ); } - } else if (rtbBid.rtb[NATIVE]) { - const nativeAd = rtbBid.rtb[NATIVE]; + }); +} - // setting up the jsTracker: - // we put it as a data-src attribute so that the tracker isn't called - // until we have the adId (see onBidWon) - const jsTrackerDisarmed = rtbBid.viewability.config.replace('src=', 'data-src='); +function createAdPodRequest(bidRequest) { + const { durationRangeSec = [], requireExactDuration, adPodDurationSec = 0 } = bidRequest.mediaTypes.video || {}; + if (!isArray(durationRangeSec) || durationRangeSec.length === 0 || adPodDurationSec === 0) { + return [bidRequest]; + } + const minAllowedDuration = Math.min(...durationRangeSec); + const numberOfPlacements = requireExactDuration + ? Math.max(Math.floor(adPodDurationSec / minAllowedDuration), durationRangeSec.length) + : Math.floor(adPodDurationSec / minAllowedDuration); - let jsTrackers = nativeAd.javascript_trackers; + const requests = fill(bidRequest, numberOfPlacements); + requests.forEach((req, index) => { + req.bidId = `${bidRequest.bidId}_${index}`; + }); - if (jsTrackers === undefined || jsTrackers === null) { - jsTrackers = jsTrackerDisarmed; - } else if (isStr(jsTrackers)) { - jsTrackers = [jsTrackers, jsTrackerDisarmed]; - } else { - jsTrackers.push(jsTrackerDisarmed); - } + if (requireExactDuration) { + const divider = Math.ceil(numberOfPlacements / durationRangeSec.length); + const chunked = chunk(requests, divider); - bid[NATIVE] = { - title: nativeAd.title, - body: nativeAd.desc, - body2: nativeAd.desc2, - cta: nativeAd.ctatext, - rating: nativeAd.rating, - sponsoredBy: nativeAd.sponsored, - privacyLink: nativeAd.privacy_link, - address: nativeAd.address, - downloads: nativeAd.downloads, - likes: nativeAd.likes, - phone: nativeAd.phone, - price: nativeAd.price, - salePrice: nativeAd.saleprice, - clickUrl: nativeAd.link.url, - displayUrl: nativeAd.displayurl, - clickTrackers: nativeAd.link.click_trackers, - impressionTrackers: nativeAd.impression_trackers, - javascriptTrackers: jsTrackers - }; - if (nativeAd.main_img) { - bid['native'].image = { - url: nativeAd.main_img.url, - height: nativeAd.main_img.height, - width: nativeAd.main_img.width, - }; - } - if (nativeAd.icon) { - bid['native'].icon = { - url: nativeAd.icon.url, - height: nativeAd.icon.height, - width: nativeAd.icon.width, - }; - } + durationRangeSec.forEach((duration, index) => { + if (chunked[index]) { + chunked[index].forEach(req => { + deepSetValue(req, 'mediaTypes.video.minduration', duration); + deepSetValue(req, 'mediaTypes.video.maxduration', duration); + }); + } + }); } else { - Object.assign(bid, { - width: rtbBid.rtb.banner.width, - height: rtbBid.rtb.banner.height, - ad: rtbBid.rtb.banner.content + const maxDuration = Math.max(...durationRangeSec); + requests.forEach(req => { + deepSetValue(req, 'mediaTypes.video.maxduration', maxDuration); }); - try { - if (rtbBid.rtb.trackers) { - for (let i = 0; i < rtbBid.rtb.trackers[0].impression_urls.length; i++) { - const url = rtbBid.rtb.trackers[0].impression_urls[i]; - const tracker = createTrackPixelHtml(url); - bid.ad += tracker; - } - } - } catch (error) { - logError('Error appending tracking pixel', error); - } } - return bid; + return requests; } -function bidToTag(bid) { - const tag = {}; - tag.sizes = transformSizes(bid.sizes); - tag.primary_size = tag.sizes[0]; - tag.ad_types = []; - tag.uuid = bid.bidId; - if (bid.params.placementId) { - tag.id = parseInt(bid.params.placementId, 10); - } else { - tag.code = bid.params.invCode; - } - tag.allow_smaller_sizes = bid.params.allowSmallerSizes || false; - tag.use_pmt_rule = bid.params.usePaymentRule || false; - tag.prebid = true; - tag.disable_psa = true; - const bidFloor = getBidFloor(bid); - if (bidFloor) { - tag.reserve = bidFloor; - } - if (bid.params.position) { - tag.position = { 'above': 1, 'below': 2 }[bid.params.position] || 0; - } - if (bid.params.trafficSourceCode) { - tag.traffic_source_code = bid.params.trafficSourceCode; - } - if (bid.params.privateSizes) { - tag.private_sizes = transformSizes(bid.params.privateSizes); - } - if (bid.params.supplyType) { - tag.supply_type = bid.params.supplyType; - } - if (bid.params.pubClick) { - tag.pubclick = bid.params.pubClick; - } - if (bid.params.extInvCode) { - tag.ext_inv_code = bid.params.extInvCode; - } - if (bid.params.publisherId) { - tag.publisher_id = parseInt(bid.params.publisherId, 10); - } - if (bid.params.externalImpId) { - tag.external_imp_id = bid.params.externalImpId; - } - if (!isEmpty(bid.params.keywords)) { - tag.keywords = getANKewyordParamFromMaps(bid.params.keywords); +function isAdPodRequest(imps) { + return imps.some(imp => deepAccess(imp, 'ext.appnexus.hb_source') === 7); +} + +function hasOmidSupport(bid) { + let hasOmid = false; + const bidderParams = bid?.params; + const videoParams = bid?.mediaTypes?.video?.api; + if (bidderParams?.frameworks && isArray(bidderParams.frameworks)) { + hasOmid = bidderParams.frameworks.includes(OMID_FRAMEWORK); } - const gpid = deepAccess(bid, 'ortb2Imp.ext.gpid'); - if (gpid) { - tag.gpid = gpid; + if (!hasOmid && isArray(videoParams)) { + hasOmid = videoParams.includes(OMID_API); } + return hasOmid; +} - if (bid.mediaType === NATIVE || deepAccess(bid, `mediaTypes.${NATIVE}`)) { - tag.ad_types.push(NATIVE); - if (tag.sizes.length === 0) { - tag.sizes = transformSizes([1, 1]); - } +function reloadViewabilityScriptWithCorrectParameters(bid) { + const jsTrackers = bid.native?.javascriptTrackers; + if (!jsTrackers) return; - if (bid.nativeParams) { - const nativeRequest = buildNativeRequest(bid.nativeParams); - tag[NATIVE] = { layouts: [nativeRequest] }; - } - } + const prebidParams = 'pbjs_adid=' + bid.adId + ';pbjs_auc=' + bid.adUnitCode; - const videoMediaType = deepAccess(bid, `mediaTypes.${VIDEO}`); - const context = deepAccess(bid, 'mediaTypes.video.context'); + // Find the tracker that needs arming (contains trk.js and has dom_id macro) + const tracker = isArray(jsTrackers) ? jsTrackers.find(t => t.includes('trk.js')) : (isStr(jsTrackers) && jsTrackers.includes('trk.js') ? jsTrackers : null); + if (!tracker) return; - if (videoMediaType && context === 'adpod') { - tag.hb_source = 7; + let jsTrackerSrc; + if (tracker.indexOf('src="') > -1) { + // It's an HTML-like snippet (Bak style) + jsTrackerSrc = tracker.substring(tracker.indexOf('src="') + 5, tracker.indexOf('"', tracker.indexOf('src="') + 5)); } else { - tag.hb_source = 1; - } - if (bid.mediaType === VIDEO || videoMediaType) { - tag.ad_types.push(VIDEO); - } - - // instream gets vastUrl, outstream gets vastXml - if (bid.mediaType === VIDEO || (videoMediaType && context !== 'outstream')) { - tag.require_asset_url = true; - } - - if (bid.params.video) { - tag.video = {}; - // place any valid video params on the tag - Object.keys(bid.params.video) - .filter(param => VIDEO_TARGETING.includes(param)) - .forEach(param => { - switch (param) { - case 'context': - case 'playback_method': - let type = bid.params.video[param]; - type = (isArray(type)) ? type[0] : type; - tag.video[param] = VIDEO_MAPPING[param][type]; - break; - // Deprecating tags[].video.frameworks in favor of tags[].video_frameworks - case 'frameworks': - break; - default: - tag.video[param] = bid.params.video[param]; - } - }); - - if (bid.params.video.frameworks && isArray(bid.params.video.frameworks)) { - tag['video_frameworks'] = bid.params.video.frameworks; - } + // It's a raw URL (Modern style) + jsTrackerSrc = tracker; } - // use IAB ORTB values if the corresponding values weren't already set by bid.params.video - if (videoMediaType) { - tag.video = tag.video || {}; - Object.keys(videoMediaType) - .filter(param => VIDEO_RTB_TARGETING.includes(param)) - .forEach(param => { - switch (param) { - case 'minduration': - case 'maxduration': - if (typeof tag.video[param] !== 'number') tag.video[param] = videoMediaType[param]; - break; - case 'skip': - if (typeof tag.video['skippable'] !== 'boolean') tag.video['skippable'] = (videoMediaType[param] === 1); - break; - case 'skipafter': - if (typeof tag.video['skipoffset'] !== 'number') tag.video['skippoffset'] = videoMediaType[param]; - break; - case 'playbackmethod': - if (typeof tag.video['playback_method'] !== 'number') { - let type = videoMediaType[param]; - type = (isArray(type)) ? type[0] : type; - - // we only support iab's options 1-4 at this time. - if (type >= 1 && type <= 4) { - tag.video['playback_method'] = type; - } - } - break; - case 'api': - if (!tag['video_frameworks'] && isArray(videoMediaType[param])) { - // need to read thru array; remove 6 (we don't support it), swap 4 <> 5 if found (to match our adserver mapping for these specific values) - const apiTmp = videoMediaType[param].map(val => { - const v = (val === 4) ? 5 : (val === 5) ? 4 : val; - - if (v >= 1 && v <= 5) { - return v; - } - return undefined; - }).filter(v => v); - tag['video_frameworks'] = apiTmp; + try { + const frames = document.getElementsByTagName('iframe'); + let modifiedAScript = false; + for (let i = 0; i < frames.length && !modifiedAScript; i++) { + try { + const frameDoc = frames[i].contentDocument || frames[i].contentWindow?.document; + if (frameDoc) { + const scripts = frameDoc.getElementsByTagName('script'); + for (let j = 0; j < scripts.length && !modifiedAScript; j++) { + const dataSrc = scripts[j].getAttribute('data-src'); + if (dataSrc && dataSrc.includes(jsTrackerSrc.replace('data-src=', '').replace('src=', '').split('?')[0])) { + scripts[j].setAttribute('src', dataSrc.replace('dom_id=%native_dom_id%', prebidParams)); + scripts[j].removeAttribute('data-src'); + modifiedAScript = true; } - break; + } } - }); - } - - if (bid.renderer) { - tag.video = Object.assign({}, tag.video, { custom_renderer_present: true }); - } - - if (bid.params.frameworks && isArray(bid.params.frameworks)) { - tag['banner_frameworks'] = bid.params.frameworks; - } - - if (bid.mediaTypes?.banner) { - tag.ad_types.push(BANNER); - } - - if (tag.ad_types.length === 0) { - delete tag.ad_types; - } - - return tag; -} - -/* Turn bid request sizes into ut-compatible format */ -function transformSizes(requestSizes) { - const sizes = []; - let sizeObj = {}; - - if (isArray(requestSizes) && requestSizes.length === 2 && - !isArray(requestSizes[0])) { - sizeObj.width = parseInt(requestSizes[0], 10); - sizeObj.height = parseInt(requestSizes[1], 10); - sizes.push(sizeObj); - } else if (typeof requestSizes === 'object') { - for (let i = 0; i < requestSizes.length; i++) { - const size = requestSizes[i]; - sizeObj = {}; - sizeObj.width = parseInt(size[0], 10); - sizeObj.height = parseInt(size[1], 10); - sizes.push(sizeObj); + } catch (e) { + logWarn('Mediafuse: cannot access iframe due to cross-origin, skipping viewability script arming', e); + } } + } catch (e) { + logWarn('Mediafuse: reloadViewabilityScriptWithCorrectParameters error', e); } - - return sizes; } -function hasUserInfo(bid) { - return !!bid.params.user; -} +export const spec = { + code: BIDDER_CODE, + gvlid: GVLID, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + aliases: [{ code: 'mediafuseBidAdapter', gvlid: GVLID }], -function hasMemberId(bid) { - return !!parseInt(bid.params.member, 10); -} + isBidRequestValid: function (bid) { + const params = bid?.params; + if (!params) return false; + return !!(params.placementId || params.placement_id || (params.member && (params.invCode || params.inv_code))); + }, -function hasAppDeviceInfo(bid) { - if (bid.params) { - return !!bid.params.app - } -} + buildRequests: function (bidRequests, bidderRequest) { + // AdPod Expansion + let expandedBidRequests = []; + bidRequests.forEach(bid => { + if (deepAccess(bid, 'mediaTypes.video.context') === 'adpod') { + expandedBidRequests.push(...createAdPodRequest(bid)); + } else { + expandedBidRequests.push(bid); + } + }); -function hasAppId(bid) { - if (bid.params && bid.params.app) { - return !!bid.params.app.id - } - return !!bid.params.app -} + const options = { + withCredentials: true + }; -function hasDebug(bid) { - return !!bid.debug -} + if (getParameterByName('apn_test')?.toUpperCase() === 'TRUE' || config.getConfig('apn_test') === true) { + options.customHeaders = { 'X-Is-Test': 1 }; + } -function hasAdPod(bid) { - return ( - bid.mediaTypes && - bid.mediaTypes.video && - bid.mediaTypes.video.context === ADPOD - ); -} + const requests = []; + const chunkedRequests = chunk(expandedBidRequests, MAX_IMPS_PER_REQUEST); -function hasOmidSupport(bid) { - let hasOmid = false; - const bidderParams = bid.params; - const videoParams = bid.params.video; - if (bidderParams.frameworks && isArray(bidderParams.frameworks)) { - hasOmid = bid.params.frameworks.includes(6); - } - if (!hasOmid && videoParams && videoParams.frameworks && isArray(videoParams.frameworks)) { - hasOmid = bid.params.video.frameworks.includes(6); - } - return hasOmid; -} + chunkedRequests.forEach(batch => { + const data = converter.toORTB({ bidRequests: batch, bidderRequest }); -/** - * Expand an adpod placement into a set of request objects according to the - * total adpod duration and the range of duration seconds. Sets minduration/ - * maxduration video property according to requireExactDuration configuration - */ -function createAdPodRequest(tags, adPodBid) { - const { durationRangeSec, requireExactDuration } = adPodBid.mediaTypes.video; + let endpointUrl = ENDPOINT_URL_NORMAL; + if (!hasPurpose1Consent(bidderRequest.gdprConsent)) { + endpointUrl = ENDPOINT_URL_SIMPLE; + } - const numberOfPlacements = getAdPodPlacementNumber(adPodBid.mediaTypes.video); - const maxDuration = Math.max(...durationRangeSec); + // Debug logic + let debugObj = {}; + const debugCookie = storage.getCookie('apn_prebid_debug'); + if (debugCookie) { + try { + debugObj = JSON.parse(debugCookie); + } catch (e) { + logWarn('Mediafuse: failed to parse debug cookie', e); + } + } else { + Object.keys(DEBUG_QUERY_PARAM_MAP).forEach(qparam => { + const qval = getParameterByName(qparam); + if (qval) debugObj[DEBUG_QUERY_PARAM_MAP[qparam]] = qval; + }); + if (Object.keys(debugObj).length > 0 && !('enabled' in debugObj)) debugObj.enabled = true; + } - const tagToDuplicate = tags.filter(tag => tag.uuid === adPodBid.bidId); - const request = fill(...tagToDuplicate, numberOfPlacements); + if (debugObj.enabled) { + logInfo('MediaFuse Debug Auction Settings:\n\n' + JSON.stringify(debugObj, null, 4)); + endpointUrl += (endpointUrl.indexOf('?') === -1 ? '?' : '&') + + Object.keys(debugObj).filter(p => DEBUG_PARAMS.includes(p)) + .map(p => (p === 'enabled') ? `debug=1` : `${p}=${debugObj[p]}`).join('&'); + } - if (requireExactDuration) { - const divider = Math.ceil(numberOfPlacements / durationRangeSec.length); - const chunked = chunk(request, divider); + // member_id optimization + const memberBid = batch.find(bid => bid.params && bid.params.member); + const member = memberBid && memberBid.params.member; + if (member) { + endpointUrl += (endpointUrl.indexOf('?') === -1 ? '?' : '&') + 'member_id=' + member; + } - // each configured duration is set as min/maxduration for a subset of requests - durationRangeSec.forEach((duration, index) => { - chunked[index].forEach(tag => { - setVideoProperty(tag, 'minduration', duration); - setVideoProperty(tag, 'maxduration', duration); + requests.push({ + method: 'POST', + url: endpointUrl, + data, + bidderRequest, + options }); }); - } else { - // all maxdurations should be the same - request.forEach(tag => setVideoProperty(tag, 'maxduration', maxDuration)); - } - return request; -} + return requests; + }, -function getAdPodPlacementNumber(videoParams) { - const { adPodDurationSec, durationRangeSec, requireExactDuration } = videoParams; - const minAllowedDuration = Math.min(...durationRangeSec); - const numberOfPlacements = Math.floor(adPodDurationSec / minAllowedDuration); + interpretResponse: function (serverResponse, request) { + const bids = converter.fromORTB({ + response: serverResponse.body, + request: request.data + }).bids; - return requireExactDuration - ? Math.max(numberOfPlacements, durationRangeSec.length) - : numberOfPlacements; -} + // allowZeroCpmBids check + const allowZeroCpm = bidderSettings.get(BIDDER_CODE, 'allowZeroCpmBids') === true; + const filteredBids = bids.filter(bid => allowZeroCpm ? bid.cpm >= 0 : bid.cpm > 0); -function setVideoProperty(tag, key, value) { - if (isEmpty(tag.video)) { tag.video = {}; } - tag.video[key] = value; -} + // Debug logging + if (serverResponse.body?.debug?.debug_info) { + const debugHeader = 'MediaFuse Debug Auction for Prebid\n\n'; + let debugText = debugHeader + serverResponse.body.debug.debug_info; + debugText = debugText + .replace(/(|)/gm, '\t') + .replace(/(<\/td>|<\/th>)/gm, '\n') + .replace(/^
/gm, '') + .replace(/(
\n|
)/gm, '\n') + .replace(/

(.*)<\/h1>/gm, '\n\n===== $1 =====\n\n') + .replace(/(.*)<\/h[2-6]>/gm, '\n\n*** $1 ***\n\n') + .replace(/(<([^>]+)>)/igm, ''); + logMessage(debugText); + } -function getRtbBid(tag) { - return tag && tag.ads && tag.ads.length && ((tag.ads) || []).find(ad => ad.rtb); -} + return filteredBids; + }, -function buildNativeRequest(params) { - const request = {}; - - // map standard prebid native asset identifier to /ut parameters - // e.g., tag specifies `body` but /ut only knows `description`. - // mapping may be in form {tag: ''} or - // {tag: {serverName: '', requiredParams: {...}}} - Object.keys(params).forEach(key => { - // check if one of the forms is used, otherwise - // a mapping wasn't specified so pass the key straight through - const requestKey = - (NATIVE_MAPPING[key] && NATIVE_MAPPING[key].serverName) || - NATIVE_MAPPING[key] || - key; - - // required params are always passed on request - const requiredParams = NATIVE_MAPPING[key] && NATIVE_MAPPING[key].requiredParams; - request[requestKey] = Object.assign({}, requiredParams, params[key]); - - // convert the sizes of image/icon assets to proper format (if needed) - const isImageAsset = !!(requestKey === NATIVE_MAPPING.image.serverName || requestKey === NATIVE_MAPPING.icon.serverName); - if (isImageAsset && request[requestKey].sizes) { - const sizes = request[requestKey].sizes; - if (isArrayOfNums(sizes) || (isArray(sizes) && sizes.length > 0 && sizes.every(sz => isArrayOfNums(sz)))) { - request[requestKey].sizes = transformSizes(request[requestKey].sizes); + getUserSyncs: function (syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent) { + const syncs = []; + let gdprParams = ''; + + if (gdprConsent) { + if (typeof gdprConsent.gdprApplies === 'boolean') { + gdprParams = `?gdpr=${Number(gdprConsent.gdprApplies)}&gdpr_consent=${gdprConsent.consentString}`; + } else { + gdprParams = `?gdpr_consent=${gdprConsent.consentString}`; } } - if (requestKey === NATIVE_MAPPING.privacyLink) { - request.privacy_supported = true; + if (syncOptions.iframeEnabled && hasPurpose1Consent(gdprConsent)) { + syncs.push({ + type: 'iframe', + url: 'https://acdn.adnxs.com/dmp/async_usersync.html' + gdprParams + }); } - }); - - return request; -} - -/** - * This function hides google div container for outstream bids to remove unwanted space on page. Mediafuse renderer creates a new iframe outside of google iframe to render the outstream creative. - * @param {string} elementId element id - */ -function hidedfpContainer(elementId) { - var el = document.getElementById(elementId).querySelectorAll("div[id^='google_ads']"); - if (el[0]) { - el[0].style.setProperty('display', 'none'); - } -} -function hideSASIframe(elementId) { - try { - // find script tag with id 'sas_script'. This ensures it only works if you're using Smart Ad Server. - const el = document.getElementById(elementId).querySelectorAll("script[id^='sas_script']"); - if (el[0].nextSibling && el[0].nextSibling.localName === 'iframe') { - el[0].nextSibling.style.setProperty('display', 'none'); + if (syncOptions.pixelEnabled && serverResponses.length > 0) { + const userSync = deepAccess(serverResponses[0], 'body.ext.appnexus.userSync'); + if (userSync && userSync.url) { + let url = userSync.url; + if (gdprParams) { + url += (url.indexOf('?') === -1 ? '?' : '&') + gdprParams.substring(1); + } + syncs.push({ + type: 'image', + url: url + }); + } } - } catch (e) { - // element not found! - } -} - -function outstreamRender(bid) { - hidedfpContainer(bid.adUnitCode); - hideSASIframe(bid.adUnitCode); - // push to render queue because ANOutstreamVideo may not be loaded - bid.renderer.push(() => { - window.ANOutstreamVideo.renderAd({ - tagId: bid.adResponse.tag_id, - sizes: [bid.getSize().split('x')], - targetId: bid.adUnitCode, // target div id to render video - uuid: bid.adResponse.uuid, - adResponse: bid.adResponse, - rendererOptions: bid.renderer.getConfig() - }, handleOutstreamRendererEvents.bind(null, bid)); - }); -} + return syncs; + }, -function handleOutstreamRendererEvents(bid, id, eventName) { - bid.renderer.handleVideoEvent({ id, eventName }); -} + onBidWon: function (bid) { + if (bid.native) { + reloadViewabilityScriptWithCorrectParameters(bid); + } + }, -function parseMediaType(rtbBid) { - const adType = rtbBid.ad_type; - if (adType === VIDEO) { - return VIDEO; - } else if (adType === NATIVE) { - return NATIVE; - } else { - return BANNER; - } -} + onTimeout: function (data) { + // Bidder specific code + }, -/* function addUserId(eids, id, source, rti) { - if (id) { - if (rti) { - eids.push({ source, id, rti_partner: rti }); - } else { - eids.push({ source, id }); - } - } - return eids; -} */ + onSetTargeting: function (bid) { + // Bidder specific code + }, -function getBidFloor(bid) { - if (!isFn(bid.getFloor)) { - return (bid.params.reserve) ? bid.params.reserve : null; - } + onBidderError: function ({ error, bidderRequest }) { + logMessage(`Mediafuse Bidder Error: ${error}`, bidderRequest); + }, - const floor = bid.getFloor({ - currency: 'USD', - mediaType: '*', - size: '*' - }); - if (isPlainObject(floor) && !isNaN(floor.floor) && floor.currency === 'USD') { - return floor.floor; + onAdRenderSucceeded: function (bid) { + // Bidder specific code } - return null; -} +}; registerBidder(spec); diff --git a/modules/mediafuseBidAdapter.md b/modules/mediafuseBidAdapter.md index f9ed9835b9..948a4a1589 100644 --- a/modules/mediafuseBidAdapter.md +++ b/modules/mediafuseBidAdapter.md @@ -77,7 +77,7 @@ var adUnits = [ placementId: 13232361, video: { skippable: true, - playback_methods: ['auto_play_sound_off'] + playback_method: 'auto_play_sound_off' } } }] diff --git a/test/spec/modules/mediafuseBidAdapter_spec.js b/test/spec/modules/mediafuseBidAdapter_spec.js index ff806d91f2..e9c6cd0dfd 100644 --- a/test/spec/modules/mediafuseBidAdapter_spec.js +++ b/test/spec/modules/mediafuseBidAdapter_spec.js @@ -1,1451 +1,1687 @@ +/** + * mediafuseBidAdapter_spec.js — extended tests + * + * Tests for mediafuseBidAdapter.js covering buildRequests, interpretResponse, + * getUserSyncs, and lifecycle callbacks. + */ + import { expect } from 'chai'; import { spec } from 'modules/mediafuseBidAdapter.js'; -import { newBidder } from 'src/adapters/bidderFactory.js'; -import * as bidderFactory from 'src/adapters/bidderFactory.js'; -import { auctionManager } from 'src/auctionManager.js'; -import { deepClone } from 'src/utils.js'; -import { config } from 'src/config.js'; -import {getGlobal} from '../../../src/prebidGlobal.js'; +import { deepClone } from '../../../src/utils.js'; +import { config } from '../../../src/config.js'; +import * as utils from '../../../src/utils.js'; +import { bidderSettings } from '../../../src/bidderSettings.js'; +import sinon from 'sinon'; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- +const BASE_BID = { + bidder: 'mediafuse', + adUnitCode: 'adunit-code', + bidId: 'bid-id-1', + params: { placementId: 12345 } +}; + +const BASE_BIDDER_REQUEST = { + auctionId: 'auction-1', + ortb2: { + site: { page: 'http://example.com', domain: 'example.com' }, + user: {} + }, + refererInfo: { + topmostLocation: 'http://example.com', + reachedTop: true, + numIframes: 0, + stack: ['http://example.com'] + }, + bids: [BASE_BID] +}; + +// --------------------------------------------------------------------------- +describe('mediafuseBidAdapter', function () { + let sandbox; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); -const ENDPOINT = 'https://ib.adnxs.com/ut/v3/prebid'; + afterEach(function () { + sandbox.restore(); + }); -describe('MediaFuseAdapter', function () { - const adapter = newBidder(spec); + // ------------------------------------------------------------------------- + // buildRequests — endpoint selection + // ------------------------------------------------------------------------- + describe('buildRequests - endpoint selection', function () { + it('should use simple endpoint when GDPR purpose 1 consent is missing', function () { + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.gdprConsent = { + gdprApplies: true, + consentString: 'test-consent', + vendorData: { purpose: { consents: { 1: false } } } + }; + const [req] = spec.buildRequests([deepClone(BASE_BID)], bidderRequest); + expect(req.url).to.include('adnxs-simple.com'); + }); + }); - describe('inherited functions', function () { - it('exists and is a function', function () { - expect(adapter.callBids).to.exist.and.to.be.a('function'); + // ------------------------------------------------------------------------- + // buildRequests — GPID + // ------------------------------------------------------------------------- + describe('buildRequests - GPID', function () { + it('should map ortb2Imp.ext.gpid into imp.ext.appnexus.gpid', function () { + const bid = deepClone(BASE_BID); + bid.ortb2Imp = { ext: { gpid: '/1234/home#header' } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.gpid).to.equal('/1234/home#header'); }); }); - describe('isBidRequestValid', function () { - const bid = { - 'bidder': 'mediafuse', - 'params': { - 'placementId': '10433394' - }, - 'adUnitCode': 'adunit-code', - 'sizes': [[300, 250], [300, 600]], - 'bidId': '30b31c1838de1e', - 'bidderRequestId': '22edbae2733bf6', - 'auctionId': '1d1a030790a475', - }; - - it('should return true when required params found', function () { - expect(spec.isBidRequestValid(bid)).to.equal(true); - }); - - it('should return true when required params found', function () { - const invalidBid = Object.assign({}, bid); - delete invalidBid.params; - invalidBid.params = { - 'member': '1234', - 'invCode': 'ABCD' - }; + // ------------------------------------------------------------------------- + // buildRequests — global keywords + // ------------------------------------------------------------------------- + describe('buildRequests - global keywords', function () { + it('should include mediafuseAuctionKeywords in request ext', function () { + sandbox.stub(config, 'getConfig').callsFake((key) => { + if (key === 'mediafuseAuctionKeywords') return { section: ['news', 'sports'] }; + return undefined; + }); + const [req] = spec.buildRequests([deepClone(BASE_BID)], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.ext.appnexus.keywords).to.include('section=news,sports'); + }); + }); - expect(spec.isBidRequestValid(invalidBid)).to.equal(true); + // ------------------------------------------------------------------------- + // buildRequests — user params + // ------------------------------------------------------------------------- + describe('buildRequests - user params', function () { + it('should map age, gender, and numeric segments', function () { + const bid = deepClone(BASE_BID); + bid.params.user = { age: 35, gender: 'F', segments: [10, 20] }; + // bidderRequest.bids must contain the bid for the request() hook to find params.user + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.bids = [bid]; + const [req] = spec.buildRequests([bid], bidderRequest); + expect(req.data.user.age).to.equal(35); + expect(req.data.user.gender).to.equal('F'); + expect(req.data.user.ext.segments).to.deep.equal([{ id: 10 }, { id: 20 }]); }); - it('should return false when required params are not passed', function () { - const invalidBid = Object.assign({}, bid); - delete invalidBid.params; - invalidBid.params = { - 'placementId': 0 - }; - expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + it('should map object-style segments and ignore invalid ones', function () { + const bid = deepClone(BASE_BID); + bid.params.user = { segments: [{ id: 99 }, 'bad', null] }; + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.bids = [bid]; + const [req] = spec.buildRequests([bid], bidderRequest); + expect(req.data.user.ext.segments).to.deep.equal([{ id: 99 }]); }); }); - describe('buildRequests', function () { - let getAdUnitsStub; - const bidRequests = [ - { - 'bidder': 'mediafuse', - 'params': { - 'placementId': '10433394' - }, - 'adUnitCode': 'adunit-code', - 'sizes': [[300, 250], [300, 600]], - 'bidId': '30b31c1838de1e', - 'bidderRequestId': '22edbae2733bf6', - 'auctionId': '1d1a030790a475', - 'transactionId': '04f2659e-c005-4eb1-a57c-fa93145e3843' - } - ]; + // ------------------------------------------------------------------------- + // buildRequests — app params + // ------------------------------------------------------------------------- + describe('buildRequests - app params', function () { + it('should merge app params into request.app', function () { + const bid = deepClone(BASE_BID); + bid.params.app = { name: 'MyApp', bundle: 'com.myapp', ver: '1.0' }; + // bidderRequest.bids must contain the bid for the request() hook to find params.app + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.bids = [bid]; + const [req] = spec.buildRequests([bid], bidderRequest); + expect(req.data.app.name).to.equal('MyApp'); + expect(req.data.app.bundle).to.equal('com.myapp'); + }); + }); - beforeEach(function() { - getAdUnitsStub = sinon.stub(auctionManager, 'getAdUnits').callsFake(function() { - return []; - }); + // ------------------------------------------------------------------------- + // buildRequests — privacy: USP, addtlConsent, COPPA + // ------------------------------------------------------------------------- + describe('buildRequests - privacy', function () { + it('should set us_privacy from uspConsent', function () { + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.uspConsent = '1YNN'; + const [req] = spec.buildRequests([deepClone(BASE_BID)], bidderRequest); + expect(req.data.regs.ext.us_privacy).to.equal('1YNN'); }); - afterEach(function() { - getAdUnitsStub.restore(); + it('should parse addtlConsent into array of integers', function () { + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.gdprConsent = { + gdprApplies: true, + consentString: 'cs', + addtlConsent: '1~7.12.99' + }; + const [req] = spec.buildRequests([deepClone(BASE_BID)], bidderRequest); + expect(req.data.user.ext.addtl_consent).to.deep.equal([7, 12, 99]); }); - it('should parse out private sizes', function () { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { - placementId: '10433394', - privateSizes: [300, 250] - } + it('should not set addtl_consent when addtlConsent has no ~ separator', function () { + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.gdprConsent = { gdprApplies: true, consentString: 'cs', addtlConsent: 'no-tilde' }; + const [req] = spec.buildRequests([deepClone(BASE_BID)], bidderRequest); + expect(req.data.user?.ext?.addtl_consent).to.be.undefined; + }); + + it('should set regs.coppa=1 when coppa config is true', function () { + sandbox.stub(config, 'getConfig').callsFake((key) => { + if (key === 'coppa') return true; + return undefined; + }); + const [req] = spec.buildRequests([deepClone(BASE_BID)], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.regs.coppa).to.equal(1); + }); + }); + + // ------------------------------------------------------------------------- + // buildRequests — video RTB targeting + // ------------------------------------------------------------------------- + describe('buildRequests - video RTB targeting', function () { + it('should map skip, skipafter, playbackmethod, and api to AN fields', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { + video: { + context: 'instream', + playerSize: [640, 480], + skip: 1, + skipafter: 5, + playbackmethod: [2], + api: [4] } - ); + }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const video = req.data.imp[0].video; + const extAN = req.data.imp[0].ext.appnexus; + expect(video.skippable).to.be.true; + expect(video.skipoffset).to.equal(5); + expect(video.playback_method).to.equal(2); + // api [4] maps to video_frameworks [5] (4↔5 swap) + expect(extAN.video_frameworks).to.include(5); + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + it('should set outstream placement=4 for outstream context', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'outstream', playerSize: [640, 480] } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].video.placement).to.equal(4); + }); - expect(payload.tags[0].private_sizes).to.exist; - expect(payload.tags[0].private_sizes).to.deep.equal([{width: 300, height: 250}]); + it('should set require_asset_url for instream context', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480] } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.require_asset_url).to.be.true; }); - it('should add publisher_id in request', function() { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { - placementId: '10433394', - publisherId: '1231234' - } - }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - - expect(payload.tags[0].publisher_id).to.exist; - expect(payload.tags[0].publisher_id).to.deep.equal(1231234); - expect(payload.publisher_id).to.exist; - expect(payload.publisher_id).to.deep.equal(1231234); - }) - - it('should add source and verison to the tag', function () { - const request = spec.buildRequests(bidRequests); - const payload = JSON.parse(request.data); - expect(payload.sdk).to.exist; - expect(payload.sdk).to.deep.equal({ - source: 'pbjs', - version: '$prebid.version$' - }); + it('should map video params from bid.params.video (VIDEO_TARGETING fields)', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480] } }; + bid.params.video = { minduration: 5, maxduration: 30, frameworks: [1, 2] }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].video.minduration).to.equal(5); + expect(req.data.imp[0].ext.appnexus.video_frameworks).to.deep.equal([1, 2]); }); + }); - it('should populate the ad_types array on all requests', function () { - const adUnits = [{ - code: 'adunit-code', + // ------------------------------------------------------------------------- + // buildRequests — AdPod requireExactDuration with multiple durations + // ------------------------------------------------------------------------- + describe('buildRequests - AdPod requireExactDuration', function () { + it('should assign durations round-robin when requireExactDuration=true with multiple durations', function () { + const bid = { + bidder: 'mediafuse', + adUnitCode: 'pod', + bidId: 'pod-bid', mediaTypes: { - banner: { - sizes: [[300, 250], [300, 600]] + video: { + context: 'adpod', + adPodDurationSec: 60, + durationRangeSec: [15, 30], + requireExactDuration: true } }, - bids: [{ - bidder: 'mediafuse', - params: { - placementId: '10433394' - } - }], - transactionId: '04f2659e-c005-4eb1-a57c-fa93145e3843' - }]; + params: { placementId: 111 } + }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + // 60/15=4 placements, 2 durations → 2 per duration + expect(req.data.imp).to.have.lengthOf(4); + // First chunk gets duration 15 + expect(req.data.imp[0].video.minduration).to.equal(15); + expect(req.data.imp[0].video.maxduration).to.equal(15); + // Second chunk gets duration 30 + expect(req.data.imp[2].video.minduration).to.equal(30); + expect(req.data.imp[2].video.maxduration).to.equal(30); + }); - ['banner', 'video', 'native'].forEach(type => { - getAdUnitsStub.callsFake(function(...args) { - return adUnits; - }); + it('should set maxduration to max of range when requireExactDuration=false', function () { + const bid = { + bidder: 'mediafuse', + adUnitCode: 'pod', + bidId: 'pod-bid-2', + mediaTypes: { + video: { + context: 'adpod', + adPodDurationSec: 30, + durationRangeSec: [10, 20], + requireExactDuration: false + } + }, + params: { placementId: 111 } + }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + // 30/10=3 placements, all get maxduration=20 + expect(req.data.imp).to.have.lengthOf(3); + req.data.imp.forEach(imp => { + expect(imp.video.maxduration).to.equal(20); + }); + }); + }); - const bidRequest = Object.assign({}, bidRequests[0]); - bidRequest.mediaTypes = {}; - bidRequest.mediaTypes[type] = {}; + // ------------------------------------------------------------------------- + // buildRequests — OMID support + // ------------------------------------------------------------------------- + describe('buildRequests - OMID support', function () { + it('should set iab_support when bid.params.frameworks includes 6', function () { + const bid = deepClone(BASE_BID); + bid.params.frameworks = [6]; + // hasOmidSupport reads bidderRequest.bids[0], so bid must be there + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.bids = [bid]; + const [req] = spec.buildRequests([bid], bidderRequest); + expect(req.data.ext.appnexus.iab_support).to.deep.equal({ + omidpn: 'Mediafuse', + omidpv: '$prebid.version$' + }); + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + it('should set iab_support when mediaTypes.video.api includes 7', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480], api: [7] } }; + // hasOmidSupport reads bidderRequest.bids[0] + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.bids = [bid]; + const [req] = spec.buildRequests([bid], bidderRequest); + expect(req.data.ext.appnexus.iab_support).to.exist; + }); + }); - expect(payload.tags[0].ad_types).to.deep.equal([type]); + // ------------------------------------------------------------------------- + // buildRequests — brand_category_uniqueness + // ------------------------------------------------------------------------- + describe('buildRequests - brand_category_uniqueness', function () { + it('should set brand_category_uniqueness when adpod.brandCategoryExclusion is true', function () { + sandbox.stub(config, 'getConfig').callsFake((key) => { + if (key === 'adpod.brandCategoryExclusion') return true; + return undefined; + }); + const [req] = spec.buildRequests([deepClone(BASE_BID)], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.ext.appnexus.brand_category_uniqueness).to.be.true; + }); + }); - if (type === 'banner') { - delete adUnits[0].mediaTypes; + // ------------------------------------------------------------------------- + // interpretResponse — adpod video response + // ------------------------------------------------------------------------- + describe('interpretResponse - adpod video', function () { + it('should set video.context=adpod and map primaryCatId from brand_category_id', function () { + const bid = { + bidder: 'mediafuse', + adUnitCode: 'pod', + bidId: 'pod-bid', + mediaTypes: { video: { context: 'adpod', adPodDurationSec: 30, durationRangeSec: [15] } }, + params: { placementId: 111 } + }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 2.5, + ext: { + appnexus: { + bid_ad_type: 1, + brand_category_id: 1 // maps to 'IAB2' (Automotive) in APPNEXUS_CATEGORY_MAPPING + } + } + }] + }] } - }); + }; + + const bids = spec.interpretResponse(serverResponse, req); + expect(bids[0].video.context).to.equal('adpod'); + // brand_category_id:1 maps to 'IAB20-3' in APPNEXUS_CATEGORY_MAPPING + expect(bids[0].meta.primaryCatId).to.equal('IAB20-3'); }); + }); - it('should not populate the ad_types array when adUnit.mediaTypes is undefined', function() { - const bidRequest = Object.assign({}, bidRequests[0]); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + // ------------------------------------------------------------------------- + // interpretResponse — outstream renderer + // ------------------------------------------------------------------------- + describe('interpretResponse - outstream renderer', function () { + it('should create renderer when renderer_url and renderer_id are present', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'outstream', playerSize: [640, 480] } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 3.0, + ext: { + appnexus: { + bid_ad_type: 1, + renderer_url: 'https://cdn.adnxs.com/renderer.js', + renderer_id: 42, + renderer_config: '{"key":"val"}' + } + } + }] + }] + } + }; - expect(payload.tags[0].ad_types).to.not.exist; + const bids = spec.interpretResponse(serverResponse, req); + expect(bids[0].renderer).to.exist; + expect(bids[0].adResponse.ad.renderer_config).to.equal('{"key":"val"}'); }); - it('should populate the ad_types array on outstream requests', function () { - const bidRequest = Object.assign({}, bidRequests[0]); - bidRequest.mediaTypes = {}; - bidRequest.mediaTypes.video = {context: 'outstream'}; + it('should set vastUrl from nurl+asset_url when no renderer', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480] } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + nurl: 'https://notify.example.com/win', + ext: { + appnexus: { + bid_ad_type: 1, + asset_url: 'https://vast.example.com/vast.xml' + } + } + }] + }] + } + }; - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + const bids = spec.interpretResponse(serverResponse, req); + expect(bids[0].vastUrl).to.include('redir='); + expect(bids[0].vastUrl).to.include(encodeURIComponent('https://vast.example.com/vast.xml')); + }); + }); - expect(payload.tags[0].ad_types).to.deep.equal(['video']); - expect(payload.tags[0].hb_source).to.deep.equal(1); + // ------------------------------------------------------------------------- + // interpretResponse — zero CPM bids + // ------------------------------------------------------------------------- + describe('interpretResponse - zero CPM bids', function () { + it('should filter out zero CPM bids by default (allowZeroCpmBids=false)', 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: 0, ext: { appnexus: { bid_ad_type: 0 } } }] }] + } + }; + + const stub = sandbox.stub(bidderSettings, 'get'); + stub.withArgs('mediafuse', 'allowZeroCpmBids').returns(false); + expect(spec.interpretResponse(serverResponse, req)).to.have.lengthOf(0); }); - it('sends bid request to ENDPOINT via POST', function () { - const request = spec.buildRequests(bidRequests); - expect(request.url).to.equal(ENDPOINT); - expect(request.method).to.equal('POST'); + it('should include zero CPM bids when allowZeroCpmBids=true', 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: 0, ext: { appnexus: { bid_ad_type: 0 } } }] }] + } + }; + + const stub = sandbox.stub(bidderSettings, 'get'); + stub.withArgs('mediafuse', 'allowZeroCpmBids').returns(true); + expect(spec.interpretResponse(serverResponse, req)).to.have.lengthOf(1); }); + }); - it('should attach valid video params to the tag', function () { - const bidRequest = Object.assign({}, - bidRequests[0], - { - params: { - placementId: '10433394', - video: { - id: 123, - minduration: 100, - foobar: 'invalid' - } - } + // ------------------------------------------------------------------------- + // interpretResponse — debug info logging + // ------------------------------------------------------------------------- + describe('interpretResponse - debug info logging', function () { + it('should clean HTML and call logMessage when debug_info is present', function () { + const [req] = spec.buildRequests([deepClone(BASE_BID)], deepClone(BASE_BIDDER_REQUEST)); + const logStub = sandbox.stub(utils, 'logMessage'); + + spec.interpretResponse({ + body: { + seatbid: [], + debug: { debug_info: '

Auction Debug


Row' } } - ); + }, req); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - expect(payload.tags[0].video).to.deep.equal({ - id: 123, - minduration: 100 - }); - expect(payload.tags[0].hb_source).to.deep.equal(1); + expect(logStub.calledOnce).to.be.true; + expect(logStub.firstCall.args[0]).to.include('===== Auction Debug ====='); + expect(logStub.firstCall.args[0]).to.not.include('

'); }); + }); - it('should include ORTB video values when video params were not set', function() { - const bidRequest = deepClone(bidRequests[0]); - bidRequest.params = { - placementId: '1234235', - video: { - skippable: true, - playback_method: ['auto_play_sound_off', 'auto_play_sound_unknown'], - context: 'outstream' + // ------------------------------------------------------------------------- + // interpretResponse — native exhaustive assets + // ------------------------------------------------------------------------- + describe('interpretResponse - native exhaustive assets', function () { + it('should map all optional native fields', 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 nativeAdm = { + native: { + title: 'Title', + desc: 'Body', + desc2: 'Body2', + ctatext: 'Click', + rating: '4.5', + sponsored: 'Sponsor', + privacy_link: 'https://priv.example.com', + address: '123 Main St', + downloads: '1000', + likes: '500', + phone: '555-1234', + price: '$9.99', + saleprice: '$4.99', + displayurl: 'example.com', + link: { url: 'https://click.example.com', click_trackers: ['https://ct.example.com'] }, + main_img: { url: 'https://img.example.com/img.jpg', width: 300, height: 250 }, + icon: { url: 'https://img.example.com/icon.png', width: 50, height: 50 } } }; - bidRequest.mediaTypes = { - video: { - playerSize: [640, 480], - context: 'outstream', - mimes: ['video/mp4'], - skip: 0, - minduration: 5, - api: [1, 5, 6], - playbackmethod: [2, 4] + + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.5, + adm: JSON.stringify(nativeAdm), + ext: { appnexus: { bid_ad_type: 3 } } + }] + }] } }; - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - - expect(payload.tags[0].video).to.deep.equal({ - minduration: 5, - playback_method: 2, - skippable: true, - context: 4 - }); - expect(payload.tags[0].video_frameworks).to.deep.equal([1, 4]) + const bids = spec.interpretResponse(serverResponse, req); + const native = bids[0].native; + expect(native.title).to.equal('Title'); + expect(native.body).to.equal('Body'); + expect(native.body2).to.equal('Body2'); + expect(native.cta).to.equal('Click'); + expect(native.rating).to.equal('4.5'); + expect(native.sponsoredBy).to.equal('Sponsor'); + expect(native.privacyLink).to.equal('https://priv.example.com'); + expect(native.address).to.equal('123 Main St'); + expect(native.downloads).to.equal('1000'); + expect(native.likes).to.equal('500'); + expect(native.phone).to.equal('555-1234'); + expect(native.price).to.equal('$9.99'); + expect(native.salePrice).to.equal('$4.99'); + expect(native.displayUrl).to.equal('example.com'); + expect(native.clickUrl).to.equal('https://click.example.com'); + expect(native.clickTrackers).to.deep.equal(['https://ct.example.com']); + expect(native.image.url).to.equal('https://img.example.com/img.jpg'); + expect(native.image.width).to.equal(300); + expect(native.icon.url).to.equal('https://img.example.com/icon.png'); }); - it('should add video property when adUnit includes a renderer', function () { - const videoData = { - mediaTypes: { - video: { - context: 'outstream', - mimes: ['video/mp4'] - } - }, - params: { - placementId: '10433394', - video: { - skippable: true, - playback_method: ['auto_play_sound_off'] - } + it('should disarm eventtrackers (trk.js) by replacing src= with data-src=', 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 nativeAdm = { + native: { + title: 'T', + eventtrackers: [ + { method: 1, url: '//cdn.adnxs.com/v/trk.js?src=1&dom_id=%native_dom_id%' }, + { method: 1, url: 'https://other-tracker.com/pixel' } + ] } }; - let bidRequest1 = deepClone(bidRequests[0]); - bidRequest1 = Object.assign({}, bidRequest1, videoData, { - renderer: { - url: 'https://test.renderer.url', - render: function () {} + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + adm: JSON.stringify(nativeAdm), + ext: { appnexus: { bid_ad_type: 3 } } + }] + }] } - }); + }; - let bidRequest2 = deepClone(bidRequests[0]); - bidRequest2.adUnitCode = 'adUnit_code_2'; - bidRequest2 = Object.assign({}, bidRequest2, videoData); + const bids = spec.interpretResponse(serverResponse, req); + const trackers = bids[0].native.javascriptTrackers; + expect(trackers).to.be.an('array'); + // The trk.js tracker should be disarmed: 'src=' replaced with 'data-src=' + const trkTracker = trackers.find(t => t.includes('trk.js')); + expect(trkTracker).to.include('data-src='); + // Verify the original 'src=1' param is now 'data-src=1' (not a bare 'src=') + expect(trkTracker).to.not.match(/(?' } + } + } + }] + }] + } + }; + + 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='); }); - 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' - } - } + 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 } } + }] + }] } - ); + }; - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + // Should not throw + expect(() => spec.interpretResponse(serverResponse, req)).to.not.throw(); + expect(logErrorStub.calledOnce).to.be.true; + }); + }); - expect(payload.user).to.exist; - expect(payload.user).to.deep.equal({ - external_uid: '123', - segments: [{id: 123}, {id: 987, value: 876}] - }); + // ------------------------------------------------------------------------- + // 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 + + 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='); }); + }); - 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]); + // ------------------------------------------------------------------------- + // lifecycle — onBidWon + // ------------------------------------------------------------------------- + describe('onBidWon', function () { + it('should call reloadViewabilityScript for native bids without throwing', function () { + const bid = { + adId: 'ad-1', + adUnitCode: 'unit-1', + native: { javascriptTrackers: ['//cdn.adnxs.com/v/trk.js?dom_id=%native_dom_id%'] } + }; + expect(() => spec.onBidWon(bid)).to.not.throw(); + }); - // 1 -> reserve not defined, getFloor not defined > empty - request = spec.buildRequests([bidRequest]); - payload = JSON.parse(request.data); + it('should not throw for non-native bids', function () { + const bid = { adId: 'ad-2', adUnitCode: 'unit-2', ad: '
Banner
' }; + expect(() => spec.onBidWon(bid)).to.not.throw(); + }); + }); - expect(payload.tags[0].reserve).to.not.exist; + // ------------------------------------------------------------------------- + // lifecycle — onBidderError + // ------------------------------------------------------------------------- + describe('onBidderError', function () { + it('should call logMessage with error details', function () { + const logStub = sandbox.stub(utils, 'logMessage'); + const error = new Error('timeout'); + spec.onBidderError({ error, bidderRequest: { auctionId: 'x' } }); + expect(logStub.calledOnce).to.be.true; + expect(logStub.firstCall.args[0]).to.include('Mediafuse Bidder Error'); + }); + }); - // 2 -> reserve is defined, getFloor not defined > reserve is used - bidRequest.params = { - 'placementId': '10433394', - 'reserve': 0.5 + // ------------------------------------------------------------------------- + // 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 } } + }] + }] + } }; - 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; + 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); + }); + }); - request = spec.buildRequests([bidRequest]); - payload = JSON.parse(request.data); + // ------------------------------------------------------------------------- + // 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'); + }); - expect(payload.tags[0].reserve).to.exist.and.to.equal(3); + it('should map externalImpId to 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].id).to.equal('ext-imp-123'); }); + }); +}); - 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], - } - } - } - ); +describe('mediafuseBidAdapter', function () { + let sandbox; - const request = spec.buildRequests([bidRequest]); - const payload1 = JSON.parse(request[0].data); - const payload2 = JSON.parse(request[1].data); + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); - // 300 / 15 = 20 total - expect(payload1.tags.length).to.equal(15); - expect(payload2.tags.length).to.equal(5); + afterEach(function () { + sandbox.restore(); + }); - expect(payload1.tags[0]).to.deep.equal(payload1.tags[1]); - expect(payload1.tags[0].video.maxduration).to.equal(30); + // ------------------------------------------------------------------------- + // isBidRequestValid + // ------------------------------------------------------------------------- + describe('isBidRequestValid', function () { + it('should return true for placement_id (snake_case)', function () { + expect(spec.isBidRequestValid({ params: { placement_id: 12345 } })).to.be.true; + }); - expect(payload2.tags[0]).to.deep.equal(payload1.tags[1]); - expect(payload2.tags[0].video.maxduration).to.equal(30); + it('should return true for member + invCode', function () { + expect(spec.isBidRequestValid({ params: { member: '123', invCode: 'inv' } })).to.be.true; }); - 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], - } - } - } - ); + 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); - expect(payload.tags.length).to.equal(2); + it('should return false when no params', function () { + expect(spec.isBidRequestValid({})).to.be.false; }); - 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, - } - } - } - ); + it('should return false for member without invCode or inv_code', function () { + expect(spec.isBidRequestValid({ params: { member: '123' } })).to.be.false; + }); + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); - expect(payload.tags.length).to.equal(7); + // ------------------------------------------------------------------------- + // 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 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 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; }); - 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], - } - } - } - ); + 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); + }); + }); - 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); + // ------------------------------------------------------------------------- + // 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'); + }); - expect(payload1.tags.length).to.equal(15); - expect(payload2.tags.length).to.equal(15); - expect(payload3.tags.length).to.equal(15); + 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 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); + // ------------------------------------------------------------------------- + // 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]); }); + }); - 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 - } - } - ); + // ------------------------------------------------------------------------- + // buildRequests — custom_renderer_present via bid.renderer + // ------------------------------------------------------------------------- + describe('buildRequests - custom renderer present', function () { + 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'); + }); + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + // ------------------------------------------------------------------------- + // 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'); + }); + }); - expect(payload.tags[0].use_pmt_rule).to.equal(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'); }); + }); - 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 } }; + // ------------------------------------------------------------------------- + // 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'); + }); + }); - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + // ------------------------------------------------------------------------- + // 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]); + }); + }); - expect(payload.tags[0].gpid).to.exist.and.equal(testGpid) + // ------------------------------------------------------------------------- + // 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 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 — 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'); }); + }); - 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' - ] - } + // ------------------------------------------------------------------------- + // buildRequests — EID rti_partner mapping (TDID / UID2) + // ------------------------------------------------------------------------- + describe('buildRequests - EID rti_partner mapping', function () { + it('should add rti_partner=TDID to adserver.org EID', function () { + const bid = deepClone(BASE_BID); + bid.userIdAsEids = [{ source: 'adserver.org', uids: [{ id: 'tdid-value', atype: 1 }] }]; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const eid = req.data.user && req.data.user.ext && req.data.user.ext.eids && + req.data.user.ext.eids.find(e => e.source === 'adserver.org'); + if (eid) { + expect(eid.rti_partner).to.equal('TDID'); } - 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 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 - } - ] - } - } - } - } - }); + it('should add rti_partner=UID2 to uidapi.com EID', function () { + const bid = deepClone(BASE_BID); + bid.userIdAsEids = [{ source: 'uidapi.com', uids: [{ id: 'uid2-value', atype: 3 }] }]; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const eid = req.data.user && req.data.user.ext && req.data.user.ext.eids && + req.data.user.ext.eids.find(e => e.source === 'uidapi.com'); + if (eid) { + expect(eid.rti_partner).to.equal('UID2'); + } + }); + }); - 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 — 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 }); }); + }); - it('should populate coppa if set in config', function () { - const bidRequest = Object.assign({}, bidRequests[0]); - sinon.stub(config, 'getConfig') - .withArgs('coppa') - .returns(true); - - const request = spec.buildRequests([bidRequest]); - const payload = JSON.parse(request.data); + // ------------------------------------------------------------------------- + // buildRequests — adpod early return (no durationRangeSec) + // ------------------------------------------------------------------------- + describe('buildRequests - adpod early return', function () { + it('should return single imp when adpod has no durationRangeSec', function () { + const bid = { + bidder: 'mediafuse', + adUnitCode: 'pod', + bidId: 'pod-bid', + mediaTypes: { video: { context: 'adpod', adPodDurationSec: 30 } }, + params: { placementId: 111 } + }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp).to.have.lengthOf(1); + }); + }); - expect(payload.user.coppa).to.equal(true); + // ------------------------------------------------------------------------- + // buildRequests — video minduration already set (skip overwrite) + // ------------------------------------------------------------------------- + describe('buildRequests - video minduration skip overwrite', function () { + 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); + }); + }); - config.getConfig.restore(); + // ------------------------------------------------------------------------- + // buildRequests — playbackmethod out of range (>4) + // ------------------------------------------------------------------------- + describe('buildRequests - video playbackmethod out of range', function () { + 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; }); + }); - 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 — video api val=6 filtered out + // ------------------------------------------------------------------------- + describe('buildRequests - video api val=6 filtered', function () { + 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([]); + }); + }); - const request = spec.buildRequests([bidRequest]); - expect(request.options.customHeaders).to.deep.equal({'X-Is-Test': 1}); + // ------------------------------------------------------------------------- + // buildRequests — video_frameworks already set; api should not override + // ------------------------------------------------------------------------- + describe('buildRequests - video_frameworks not overridden by api', function () { + 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]); + }); + }); - config.getConfig.restore(); + // ------------------------------------------------------------------------- + // 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 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 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; }); + }); - 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 + // ------------------------------------------------------------------------- + // 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: '
ad
', + ext: { + appnexus: { + bid_ad_type: 0, + trackers: [{ impression_urls: ['https://tracker.example.com/impression'] }] + } } - } - } + }] + }] } }; - 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' }] - }] - }); - - 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' - }); - - expect(payload.eids).to.deep.include({ - source: 'criteo.com', - id: 'sample-criteo-userid', - }); - - expect(payload.eids).to.deep.include({ - source: 'netid.de', - id: 'sample-netId-userid', - }); - - expect(payload.eids).to.deep.include({ - source: 'liveramp.com', - id: 'sample-idl-userid' - }); - - expect(payload.eids).to.deep.include({ - source: 'uidapi.com', - id: 'sample-uid2-value', - rti_partner: 'UID2' - }); + const bids = spec.interpretResponse(serverResponse, req); + expect(bids[0].ad).to.include('tracker.example.com/impression'); }); + }); - 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] - } - } - }); - let request = spec.buildRequests([bidRequest_A]); - let payload = JSON.parse(request.data); - expect(payload.iab_support).to.be.an('object'); - expect(payload.iab_support).to.deep.equal({ - omidpn: 'Mediafuse', - omidpv: '$prebid.version$' - }); - expect(payload.tags[0].banner_frameworks).to.be.an('array'); - expect(payload.tags[0].banner_frameworks).to.deep.equal([1, 2, 5, 6]); - expect(payload.tags[0].video_frameworks).to.be.an('array'); - expect(payload.tags[0].video_frameworks).to.deep.equal([1, 2, 5, 6]); - expect(payload.tags[0].video.frameworks).to.not.exist; - - // without bid.params.frameworks - const bidRequest_B = Object.assign({}, bidRequests[0]); - request = spec.buildRequests([bidRequest_B]); - payload = JSON.parse(request.data); - expect(payload.iab_support).to.not.exist; - expect(payload.tags[0].banner_frameworks).to.not.exist; - expect(payload.tags[0].video_frameworks).to.not.exist; - - // with video.frameworks but it is not an array - const bidRequest_C = Object.assign({}, bidRequests[0], { - params: { - video: { - frameworks: "'1', '2', '3', '6'" - } + // ------------------------------------------------------------------------- + // interpretResponse — native jsTrackers combinations + // ------------------------------------------------------------------------- + describe('interpretResponse - native jsTrackers combinations', function () { + function buildNativeReq() { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { native: { title: { required: true } } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + return req; + } + + it('should combine string jsTracker with viewability.config into array [str, disarmed]', function () { + const req = buildNativeReq(); + const impId = req.data.imp[0].id; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + adm: JSON.stringify({ native: { title: 'T', javascript_trackers: 'https://existing-tracker.com/t.js' } }), + ext: { + appnexus: { + bid_ad_type: 3, + viewability: { config: '' } + } + } + }] + }] } - }); - request = spec.buildRequests([bidRequest_C]); - payload = JSON.parse(request.data); - expect(payload.iab_support).to.not.exist; - expect(payload.tags[0].banner_frameworks).to.not.exist; - expect(payload.tags[0].video_frameworks).to.not.exist; - }); - }) - - describe('interpretResponse', function () { - let bidderSettingsStorage; - - before(function() { - bidderSettingsStorage = getGlobal().bidderSettings; - }); - - after(function() { - getGlobal().bidderSettings = bidderSettingsStorage; - }); - - const response = { - 'version': '3.0.0', - 'tags': [ - { - 'uuid': '3db3773286ee59', - 'tag_id': 10433394, - 'auction_id': '4534722592064951574', - 'nobid': false, - 'no_ad_url': 'https://lax1-ib.adnxs.com/no-ad', - 'timeout_ms': 10000, - 'ad_profile_id': 27079, - 'ads': [ - { - 'content_source': 'rtb', - 'ad_type': 'banner', - 'buyer_member_id': 958, - 'creative_id': 29681110, - 'media_type_id': 1, - 'media_subtype_id': 1, - 'cpm': 0.5, - 'cpm_publisher_currency': 0.5, - 'publisher_currency_code': '$', - 'client_initiated_ad_counting': true, - 'viewability': { - 'config': '' - }, - 'rtb': { - 'banner': { - 'content': '', - 'width': 300, - 'height': 250 - }, - 'trackers': [ - { - 'impression_urls': [ - 'https://lax1-ib.adnxs.com/impression', - 'https://www.test.com/tracker' - ], - 'video_events': {} - } - ] + }; + const bids = spec.interpretResponse(serverResponse, req); + const trackers = bids[0].native.javascriptTrackers; + expect(trackers).to.be.an('array').with.lengthOf(2); + expect(trackers[0]).to.equal('https://existing-tracker.com/t.js'); + expect(trackers[1]).to.include('data-src='); + }); + + it('should push viewability.config into existing array jsTrackers', function () { + const req = buildNativeReq(); + const impId = req.data.imp[0].id; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + adm: JSON.stringify({ native: { title: 'T', javascript_trackers: ['https://tracker1.com/t.js'] } }), + ext: { + appnexus: { + bid_ad_type: 3, + viewability: { config: '' } + } } - } - ] + }] + }] } - ] - }; - - it('should get correct bid response', function () { - const expectedResponse = [ - { - 'requestId': '3db3773286ee59', - 'cpm': 0.5, - 'creativeId': 29681110, - 'dealId': undefined, - 'width': 300, - 'height': 250, - 'ad': '', - 'mediaType': 'banner', - 'currency': 'USD', - 'ttl': 300, - 'netRevenue': true, - 'adUnitCode': 'code', - 'mediafuse': { - 'buyerMemberId': 958 - }, - 'meta': { - 'dchain': { - 'ver': '1.0', - 'complete': 0, - 'nodes': [{ - 'bsid': '958' - }] - } - } + }; + const bids = spec.interpretResponse(serverResponse, req); + const trackers = bids[0].native.javascriptTrackers; + expect(trackers).to.be.an('array').with.lengthOf(2); + expect(trackers[0]).to.equal('https://tracker1.com/t.js'); + expect(trackers[1]).to.include('data-src='); + }); + + it('should combine string jsTracker with eventtrackers into array', function () { + const req = buildNativeReq(); + const impId = req.data.imp[0].id; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + adm: JSON.stringify({ + native: { + title: 'T', + javascript_trackers: 'https://existing-tracker.com/t.js', + eventtrackers: [{ method: 1, url: 'https://event-tracker.com/track' }] + } + }), + ext: { appnexus: { bid_ad_type: 3 } } + }] + }] } - ]; - const bidderRequest = { - bids: [{ - bidId: '3db3773286ee59', - adUnitCode: 'code' - }] }; - const result = spec.interpretResponse({ body: response }, {bidderRequest}); - expect(Object.keys(result[0])).to.have.members(Object.keys(expectedResponse[0])); + const bids = spec.interpretResponse(serverResponse, req); + const trackers = bids[0].native.javascriptTrackers; + expect(trackers).to.be.an('array').with.lengthOf(2); + expect(trackers[0]).to.equal('https://existing-tracker.com/t.js'); + expect(trackers[1]).to.equal('https://event-tracker.com/track'); }); - it('should reject 0 cpm bids', function () { - const zeroCpmResponse = deepClone(response); - zeroCpmResponse.tags[0].ads[0].cpm = 0; + it('should push eventtrackers into existing array jsTrackers', function () { + const req = buildNativeReq(); + const impId = req.data.imp[0].id; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + adm: JSON.stringify({ + native: { + title: 'T', + javascript_trackers: ['https://existing-tracker.com/t.js'], + eventtrackers: [{ method: 1, url: 'https://event-tracker.com/track' }] + } + }), + ext: { appnexus: { bid_ad_type: 3 } } + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, req); + const trackers = bids[0].native.javascriptTrackers; + expect(trackers).to.be.an('array').with.lengthOf(2); + expect(trackers[0]).to.equal('https://existing-tracker.com/t.js'); + expect(trackers[1]).to.equal('https://event-tracker.com/track'); + }); + }); - const bidderRequest = { - bidderCode: 'mediafuse' + // ------------------------------------------------------------------------- + // getUserSyncs — iframe and pixel syncing + // ------------------------------------------------------------------------- + describe('getUserSyncs - iframe and pixel syncing', function () { + it('should add iframe sync when iframeEnabled and purpose-1 consent is present', function () { + const syncOptions = { iframeEnabled: true }; + const gdprConsent = { + gdprApplies: true, + consentString: 'cs', + vendorData: { purpose: { consents: { 1: true } } } }; + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].type).to.equal('iframe'); + expect(syncs[0].url).to.include('gdpr=1'); + }); - const result = spec.interpretResponse({ body: zeroCpmResponse }, { bidderRequest }); - expect(result.length).to.equal(0); + it('should have no gdpr params in pixel url when gdprConsent is null', function () { + const syncOptions = { pixelEnabled: true }; + const serverResponses = [{ + body: { ext: { appnexus: { userSync: { url: 'https://sync.example.com/px' } } } } + }]; + const syncs = spec.getUserSyncs(syncOptions, serverResponses, null); + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].url).to.not.include('gdpr'); }); - it('should allow 0 cpm bids if allowZeroCpmBids setConfig is true', function () { - getGlobal().bidderSettings = { - mediafuse: { - allowZeroCpmBids: true - } - }; + it('should append gdpr params with & when pixel url already contains ?', function () { + const syncOptions = { pixelEnabled: true }; + const serverResponses = [{ + body: { ext: { appnexus: { userSync: { url: 'https://sync.example.com/px?existing=1' } } } } + }]; + const gdprConsent = { gdprApplies: true, consentString: 'cs' }; + const syncs = spec.getUserSyncs(syncOptions, serverResponses, gdprConsent); + expect(syncs[0].url).to.include('existing=1'); + expect(syncs[0].url).to.include('gdpr=1'); + expect(syncs[0].url).to.match(/\?existing=1&/); + }); + }); - const zeroCpmResponse = deepClone(response); - zeroCpmResponse.tags[0].ads[0].cpm = 0; + // ------------------------------------------------------------------------- + // Empty lifecycle methods — onTimeout, onSetTargeting, onAdRenderSucceeded + // ------------------------------------------------------------------------- + describe('empty lifecycle methods', function () { + it('onTimeout should not throw', function () { + expect(() => spec.onTimeout({ bidderCode: 'mediafuse', timeout: 3000 })).to.not.throw(); + }); - const bidderRequest = { - bidderCode: 'mediafuse', - bids: [{ - bidId: '3db3773286ee59', - adUnitCode: 'code' - }] - }; + it('onSetTargeting should not throw', function () { + expect(() => spec.onSetTargeting({ adUnitCode: 'au', params: {} })).to.not.throw(); + }); - const result = spec.interpretResponse({ body: zeroCpmResponse }, { bidderRequest }); - expect(result.length).to.equal(1); - expect(result[0].cpm).to.equal(0); + it('onAdRenderSucceeded should not throw', function () { + expect(() => spec.onAdRenderSucceeded({ adUnitCode: 'au' })).to.not.throw(); }); + }); - it('handles nobid responses', function () { - const response = { - 'version': '0.0.1', - 'tags': [{ - 'uuid': '84ab500420319d', - 'tag_id': 5976557, - 'auction_id': '297492697822162468', - 'nobid': true - }] + // ------------------------------------------------------------------------- + // onBidWon — string jsTracker + // ------------------------------------------------------------------------- + describe('onBidWon - string jsTracker', function () { + it('should handle jsTrackers as a plain string without throwing', function () { + const bid = { + adId: 'ad-str', + adUnitCode: 'unit-str', + native: { javascriptTrackers: '//cdn.adnxs.com/v/trk.js?dom_id=%native_dom_id%' } }; - let bidderRequest; - - const result = spec.interpretResponse({ body: response }, {bidderRequest}); - expect(result.length).to.equal(0); - }); - - it('handles outstream video responses', function () { - const response = { - 'tags': [{ - 'uuid': '84ab500420319d', - 'ads': [{ - 'ad_type': 'video', - 'cpm': 0.500000, - 'notify_url': 'imptracker.com', - 'rtb': { - 'video': { - 'content': '' - } - }, - 'javascriptTrackers': '' - }] - }] + expect(() => spec.onBidWon(bid)).to.not.throw(); + }); + }); + + // ------------------------------------------------------------------------- + // getUserSyncs — iframeEnabled but consent denied (no iframe added) + // ------------------------------------------------------------------------- + describe('getUserSyncs - iframeEnabled denied by consent', function () { + it('should not add iframe sync when iframeEnabled but purpose-1 consent is denied', function () { + const syncOptions = { iframeEnabled: true }; + const gdprConsent = { + gdprApplies: true, + consentString: 'cs', + vendorData: { purpose: { consents: { 1: false } } } }; - const bidderRequest = { - bids: [{ - bidId: '84ab500420319d', - adUnitCode: 'code', - mediaTypes: { - video: { - context: 'outstream' - } - } - }] - } + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + expect(syncs).to.have.lengthOf(0); + }); - const result = spec.interpretResponse({ body: response }, {bidderRequest}); - expect(result[0]).to.have.property('vastXml'); - expect(result[0]).to.have.property('vastImpUrl'); - expect(result[0]).to.have.property('mediaType', 'video'); - }); - - it('handles instream video responses', function () { - const response = { - 'tags': [{ - 'uuid': '84ab500420319d', - 'ads': [{ - 'ad_type': 'video', - 'cpm': 0.500000, - 'notify_url': 'imptracker.com', - 'rtb': { - 'video': { - 'asset_url': 'https://sample.vastURL.com/here/vid' - } - }, - 'javascriptTrackers': '' + it('should not add pixel sync when serverResponses is empty', function () { + const syncOptions = { pixelEnabled: true }; + const syncs = spec.getUserSyncs(syncOptions, [], null); + expect(syncs).to.have.lengthOf(0); + }); + + it('should not add pixel sync when response has no userSync url', function () { + const syncOptions = { pixelEnabled: true }; + const serverResponses = [{ body: { ext: { appnexus: {} } } }]; + const syncs = spec.getUserSyncs(syncOptions, serverResponses, null); + expect(syncs).to.have.lengthOf(0); + }); + }); + + // ------------------------------------------------------------------------- + // interpretResponse — bid_ad_type not in RESPONSE_MEDIA_TYPE_MAP + // ------------------------------------------------------------------------- + describe('interpretResponse - unknown bid_ad_type', function () { + it('should not throw when bid_ad_type=2 is not in the media type map', 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.5, + adm: '
creative
', + ext: { appnexus: { bid_ad_type: 2 } } + }] }] - }] + } }; - const bidderRequest = { - bids: [{ - bidId: '84ab500420319d', - adUnitCode: 'code', - mediaTypes: { - video: { - context: 'instream' - } - } - }] - } + expect(() => spec.interpretResponse(serverResponse, req)).to.not.throw(); + }); + }); - const result = spec.interpretResponse({ body: response }, {bidderRequest}); - expect(result[0]).to.have.property('vastUrl'); - expect(result[0]).to.have.property('vastImpUrl'); - expect(result[0]).to.have.property('mediaType', 'video'); - }); - - it('handles adpod responses', function () { - const response = { - 'tags': [{ - 'uuid': '84ab500420319d', - 'ads': [{ - 'ad_type': 'video', - 'brand_category_id': 10, - 'cpm': 0.500000, - 'notify_url': 'imptracker.com', - 'rtb': { - 'video': { - 'asset_url': 'https://sample.vastURL.com/here/adpod', - 'duration_ms': 30000, + // ------------------------------------------------------------------------- + // interpretResponse — adpod brand_category_id not in APPNEXUS_CATEGORY_MAPPING + // ------------------------------------------------------------------------- + describe('interpretResponse - adpod brand_category_id not in mapping', function () { + it('should not set primaryCatId when brand_category_id has no mapping entry', function () { + const bid = { + bidder: 'mediafuse', + adUnitCode: 'pod', + bidId: 'pod-bid', + mediaTypes: { video: { context: 'adpod', adPodDurationSec: 30, durationRangeSec: [15] } }, + params: { placementId: 111 } + }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 2.0, + ext: { + appnexus: { + bid_ad_type: 1, + brand_category_id: 99999 + } } - }, - 'viewability': { - 'config': '' - } + }] }] - }] + } }; + const bids = spec.interpretResponse(serverResponse, req); + expect(bids[0].meta && bids[0].meta.primaryCatId).to.be.undefined; + }); + }); - const bidderRequest = { - bids: [{ - bidId: '84ab500420319d', - adUnitCode: 'code', - mediaTypes: { - video: { - context: 'adpod' - } - } - }] + // ------------------------------------------------------------------------- + // buildRequests — topmostLocation falsy → rd_ref='' + // ------------------------------------------------------------------------- + describe('buildRequests - topmostLocation falsy', function () { + it('should set rd_ref to empty string when topmostLocation is not present', function () { + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.refererInfo = { + topmostLocation: null, + reachedTop: false, + numIframes: 0, + stack: [] }; + const [req] = spec.buildRequests([deepClone(BASE_BID)], bidderRequest); + expect(req.data.ext.appnexus.referrer_detection.rd_ref).to.equal(''); + }); + }); - const result = spec.interpretResponse({ body: response }, {bidderRequest}); - expect(result[0]).to.have.property('vastUrl'); - expect(result[0].video.context).to.equal('adpod'); - expect(result[0].video.durationSeconds).to.equal(30); - }); - - it('handles native responses', function () { - const response1 = deepClone(response); - response1.tags[0].ads[0].ad_type = 'native'; - response1.tags[0].ads[0].rtb.native = { - 'title': 'Native Creative', - 'desc': 'Cool description great stuff', - 'desc2': 'Additional body text', - 'ctatext': 'Do it', - 'sponsored': 'MediaFuse', - 'icon': { - 'width': 0, - 'height': 0, - 'url': 'https://cdn.adnxs.com/icon.png' - }, - 'main_img': { - 'width': 2352, - 'height': 1516, - 'url': 'https://cdn.adnxs.com/img.png' - }, - 'link': { - 'url': 'https://www.mediafuse.com', - 'fallback_url': '', - 'click_trackers': ['https://nym1-ib.adnxs.com/click'] - }, - 'impression_trackers': ['https://example.com'], - 'rating': '5', - 'displayurl': 'https://mediafuse.com/?url=display_url', - 'likes': '38908320', - 'downloads': '874983', - 'price': '9.99', - 'saleprice': 'FREE', - 'phone': '1234567890', - 'address': '28 W 23rd St, New York, NY 10010', - 'privacy_link': 'https://www.mediafuse.com/privacy-policy-agreement/', - 'javascriptTrackers': '' + // ------------------------------------------------------------------------- + // buildRequests — addtlConsent all-NaN → addtl_consent not set + // ------------------------------------------------------------------------- + describe('buildRequests - addtlConsent all-NaN values', function () { + it('should not set addtl_consent when all values after ~ are NaN', function () { + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.gdprConsent = { + gdprApplies: true, + consentString: 'cs', + addtlConsent: '1~abc.def.ghi' }; - const bidderRequest = { - bids: [{ - bidId: '3db3773286ee59', - adUnitCode: 'code' - }] + const [req] = spec.buildRequests([deepClone(BASE_BID)], bidderRequest); + expect(req.data.user && req.data.user.ext && req.data.user.ext.addtl_consent).to.be.undefined; + }); + }); + + // ------------------------------------------------------------------------- + // buildRequests — EID with unrecognized source passes through unchanged + // ------------------------------------------------------------------------- + describe('buildRequests - EID unrecognized source', function () { + it('should pass through EID unchanged when source is neither adserver.org nor uidapi.com', function () { + const bid = deepClone(BASE_BID); + bid.userIdAsEids = [{ source: 'unknown-id-provider.com', uids: [{ id: 'some-id', atype: 1 }] }]; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const eid = req.data.user && req.data.user.ext && req.data.user.ext.eids && + req.data.user.ext.eids.find(e => e.source === 'unknown-id-provider.com'); + if (eid) { + expect(eid.rti_partner).to.be.undefined; } + }); + }); - const result = spec.interpretResponse({ body: response1 }, {bidderRequest}); - expect(result[0].native.title).to.equal('Native Creative'); - expect(result[0].native.body).to.equal('Cool description great stuff'); - expect(result[0].native.cta).to.equal('Do it'); - expect(result[0].native.image.url).to.equal('https://cdn.adnxs.com/img.png'); - }); - - it('supports configuring outstream renderers', function () { - const outstreamResponse = deepClone(response); - outstreamResponse.tags[0].ads[0].rtb.video = {}; - outstreamResponse.tags[0].ads[0].renderer_url = 'renderer.js'; - - const bidderRequest = { - bids: [{ - bidId: '3db3773286ee59', - renderer: { - options: { - adText: 'configured' - } - }, - mediaTypes: { - video: { - context: 'outstream' - } - } - }] + // ------------------------------------------------------------------------- + // getBidFloor — edge cases + // ------------------------------------------------------------------------- + describe('buildRequests - getBidFloor edge cases', function () { + it('should return null when getFloor returns a non-plain-object (null)', function () { + const bid = deepClone(BASE_BID); + bid.getFloor = () => null; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].bidfloor).to.be.undefined; + }); + + it('should return null when getFloor returns a NaN floor value', function () { + const bid = deepClone(BASE_BID); + bid.getFloor = () => ({ currency: 'USD', floor: NaN }); + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].bidfloor).to.be.undefined; + }); + }); + + // ------------------------------------------------------------------------- + // buildRequests — banner_frameworks type guard + // ------------------------------------------------------------------------- + describe('buildRequests - banner_frameworks invalid type', function () { + it('should not set banner_frameworks when value is a string (not array of nums)', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { banner: { sizes: [[300, 250]] } }; + bid.params.banner_frameworks = 'not-an-array'; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.banner_frameworks).to.be.undefined; + }); + }); + + // ------------------------------------------------------------------------- + // onBidWon — no trk.js in jsTrackers + // ------------------------------------------------------------------------- + describe('onBidWon - jsTrackers with no trk.js URL', function () { + it('should return early when no trk.js tracker is found in jsTrackers array', function () { + const bid = { + adId: 'ad-no-trk', + adUnitCode: 'unit-no-trk', + native: { javascriptTrackers: ['https://other-tracker.com/track.js', 'https://another.com/t.js'] } }; + expect(() => spec.onBidWon(bid)).to.not.throw(); + }); + }); - const result = spec.interpretResponse({ body: outstreamResponse }, {bidderRequest}); - expect(result[0].renderer.config).to.deep.equal( - bidderRequest.bids[0].renderer.options - ); - }); - - it('should add deal_priority and deal_code', function() { - const responseWithDeal = deepClone(response); - responseWithDeal.tags[0].ads[0].ad_type = 'video'; - responseWithDeal.tags[0].ads[0].deal_priority = 5; - responseWithDeal.tags[0].ads[0].deal_code = '123'; - responseWithDeal.tags[0].ads[0].rtb.video = { - duration_ms: 1500, - player_width: 640, - player_height: 340, + // ------------------------------------------------------------------------- + // onBidWon — HTML-snippet tracker + // ------------------------------------------------------------------------- + describe('onBidWon - HTML-snippet style tracker', function () { + it('should extract src from HTML-snippet style tracker without throwing', function () { + const bid = { + adId: 'ad-html', + adUnitCode: 'unit-html', + native: { + javascriptTrackers: [''] + } }; + expect(() => spec.onBidWon(bid)).to.not.throw(); + }); + }); - const bidderRequest = { - bids: [{ - bidId: '3db3773286ee59', - adUnitCode: 'code', - mediaTypes: { - video: { - context: 'adpod' - } - } - }] - } - const result = spec.interpretResponse({ body: responseWithDeal }, {bidderRequest}); - expect(Object.keys(result[0].mediafuse)).to.include.members(['buyerMemberId', 'dealPriority', 'dealCode']); - expect(result[0].video.dealTier).to.equal(5); + // ------------------------------------------------------------------------- + // buildRequests — video params frameworks type guard + // ------------------------------------------------------------------------- + describe('buildRequests - video params frameworks', function () { + it('should not set video_frameworks when params.video.frameworks is not an array', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480] } }; + bid.params.video = { frameworks: 'not-an-array' }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.video_frameworks).to.be.undefined; }); + }); - it('should add advertiser id', function() { - const responseAdvertiserId = deepClone(response); - responseAdvertiserId.tags[0].ads[0].advertiser_id = '123'; + // ------------------------------------------------------------------------- + // buildRequests — banner_frameworks param fallback + // ------------------------------------------------------------------------- + describe('buildRequests - banner frameworks param fallback', function () { + it('should use bid.params.frameworks as fallback when banner_frameworks is absent', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { banner: { sizes: [[300, 250]] } }; + bid.params.frameworks = [1, 2]; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].ext.appnexus.banner_frameworks).to.deep.equal([1, 2]); + }); + }); - const bidderRequest = { - bids: [{ - bidId: '3db3773286ee59', - adUnitCode: 'code' - }] - } - const result = spec.interpretResponse({ body: responseAdvertiserId }, {bidderRequest}); - expect(Object.keys(result[0].meta)).to.include.members(['advertiserId']); + // ------------------------------------------------------------------------- + // buildRequests — refererInfo.stack absent + // ------------------------------------------------------------------------- + describe('buildRequests - refererInfo stack absent', function () { + it('should set rd_stk to undefined when stack is not present in refererInfo', function () { + const bidderRequest = deepClone(BASE_BIDDER_REQUEST); + bidderRequest.refererInfo = { + topmostLocation: 'http://example.com', + reachedTop: true, + numIframes: 0 + }; + const [req] = spec.buildRequests([deepClone(BASE_BID)], bidderRequest); + expect(req.data.ext.appnexus.referrer_detection.rd_stk).to.be.undefined; }); + }); - it('should add brand id', function() { - const responseBrandId = deepClone(response); - responseBrandId.tags[0].ads[0].brand_id = 123; + // ------------------------------------------------------------------------- + // interpretResponse — renderer options from mediaTypes.video.renderer.options + // ------------------------------------------------------------------------- + describe('interpretResponse - renderer options from mediaTypes.video.renderer', function () { + it('should use mediaTypes.video.renderer.options when available', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'outstream', playerSize: [640, 480], renderer: { options: { key: 'val' } } } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 3.0, + ext: { + appnexus: { + bid_ad_type: 1, + renderer_url: 'https://cdn.adnxs.com/renderer.js', + renderer_id: 42 + } + } + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, req); + expect(bids[0].renderer).to.exist; + }); + }); - const bidderRequest = { - bids: [{ - bidId: '3db3773286ee59', - adUnitCode: 'code' - }] - } - const result = spec.interpretResponse({ body: responseBrandId }, {bidderRequest}); - expect(Object.keys(result[0].meta)).to.include.members(['brandId']); + // ------------------------------------------------------------------------- + // buildRequests — video params.video takes priority over mediaTypes.video for maxduration + // ------------------------------------------------------------------------- + describe('buildRequests - video maxduration skip overwrite', function () { + it('should not overwrite maxduration set by params.video when mediaTypes.video.maxduration also present', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480], maxduration: 15 } }; + bid.params.video = { maxduration: 30 }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].video.maxduration).to.equal(30); + }); + }); + + // ------------------------------------------------------------------------- + // buildRequests — video params.video takes priority over mediaTypes.video for skippable + // ------------------------------------------------------------------------- + describe('buildRequests - video skippable skip overwrite', function () { + it('should not overwrite skippable set by params.video when mediaTypes.video.skip is also present', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480], skip: 1 } }; + bid.params.video = { skippable: false }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].video.skippable).to.be.false; }); + }); - it('should add advertiserDomains', function() { - const responseAdvertiserId = deepClone(response); - responseAdvertiserId.tags[0].ads[0].adomain = ['123']; + // ------------------------------------------------------------------------- + // buildRequests — video params.video takes priority over mediaTypes.video for skipoffset + // ------------------------------------------------------------------------- + describe('buildRequests - video skipoffset skip overwrite', function () { + it('should not overwrite skipoffset set by params.video when mediaTypes.video.skipafter is also present', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480], skipafter: 10 } }; + bid.params.video = { skipoffset: 5 }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].video.skipoffset).to.equal(5); + }); + }); - const bidderRequest = { - bids: [{ - bidId: '3db3773286ee59', - adUnitCode: 'code' - }] - } - const result = spec.interpretResponse({ body: responseAdvertiserId }, {bidderRequest}); - expect(Object.keys(result[0].meta)).to.include.members(['advertiserDomains']); - expect(Object.keys(result[0].meta.advertiserDomains)).to.deep.equal([]); + // ------------------------------------------------------------------------- + // buildRequests — video playbackmethod type guard + // ------------------------------------------------------------------------- + describe('buildRequests - video playbackmethod', function () { + it('should not set playback_method when mediaTypes.video.playbackmethod is not an array', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480], playbackmethod: 2 } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + expect(req.data.imp[0].video.playback_method).to.be.undefined; + }); + }); + + // ------------------------------------------------------------------------- + // interpretResponse — video nurl without asset_url + // ------------------------------------------------------------------------- + describe('interpretResponse - video nurl without asset_url', function () { + it('should set vastImpUrl but not vastUrl when nurl present but asset_url absent', function () { + const bid = deepClone(BASE_BID); + bid.mediaTypes = { video: { context: 'instream', playerSize: [640, 480] } }; + const [req] = spec.buildRequests([bid], deepClone(BASE_BIDDER_REQUEST)); + const impId = req.data.imp[0].id; + + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: impId, + price: 1.0, + nurl: 'https://notify.example.com/win', + ext: { + appnexus: { + bid_ad_type: 1 + // no asset_url, no renderer_url/renderer_id + } + } + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, req); + expect(bids[0].vastImpUrl).to.equal('https://notify.example.com/win'); + expect(bids[0].vastUrl).to.not.include('&redir='); }); }); });