diff --git a/modules/gridBidAdapter.js b/modules/gridBidAdapter.js index 46989610baf..e894154e6d1 100644 --- a/modules/gridBidAdapter.js +++ b/modules/gridBidAdapter.js @@ -47,13 +47,6 @@ const LOG_ERROR_MESS = { }; const ALIAS_CONFIG = { - 'trustx': { - endpoint: 'https://grid.bidswitch.net/hbjson?sp=trustx', - syncurl: 'https://x.bidswitch.net/sync?ssp=themediagrid', - bidResponseExternal: { - netRevenue: false - } - }, 'gridNM': { defaultParams: { multiRequest: true @@ -66,7 +59,7 @@ let hasSynced = false; export const spec = { code: BIDDER_CODE, gvlid: GVLID, - aliases: ['playwire', 'adlivetech', 'gridNM', { code: 'trustx', skipPbsAliasing: true }], + aliases: ['playwire', 'adlivetech', 'gridNM'], supportedMediaTypes: [ BANNER, VIDEO ], /** * Determines whether or not the given bid request is valid. diff --git a/modules/trustxBidAdapter.js b/modules/trustxBidAdapter.js new file mode 100644 index 00000000000..2b0f2c80331 --- /dev/null +++ b/modules/trustxBidAdapter.js @@ -0,0 +1,456 @@ +import { + logInfo, + logError, + logMessage, + deepAccess, + deepSetValue, + mergeDeep +} from '../src/utils.js'; +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import {BANNER, VIDEO} from '../src/mediaTypes.js'; +import {Renderer} from '../src/Renderer.js'; +import {ortbConverter} from '../libraries/ortbConverter/converter.js'; +import {config} from '../src/config.js'; + +/** + * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest + * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid + * @typedef {import('../src/adapters/bidderFactory.js').ServerResponse} ServerResponse + * @typedef {import('../src/adapters/bidderFactory.js').SyncOptions} SyncOptions + */ + +const BIDDER_CODE = 'trustx'; +const BID_TTL = 360; +const NET_REVENUE = false; +const SUPPORTED_CURRENCY = 'USD'; +const OUTSTREAM_PLAYER_URL = 'https://acdn.adnxs.com/video/outstream/ANOutstreamVideo.js'; +const ADAPTER_VERSION = '1.0'; + +const ortbAdapterConverter = ortbConverter({ + context: { + netRevenue: NET_REVENUE, + ttl: BID_TTL + }, + imp(buildImp, bidRequest, context) { + const impression = buildImp(bidRequest, context); + const params = bidRequest.params || {}; + + if (!impression.bidfloor) { + let floor = parseFloat(params.bidfloor || params.bidFloor || params.floorcpm || 0) || 0; + + if (typeof bidRequest.getFloor === 'function') { + const mediaTypes = bidRequest.mediaTypes || {}; + const curMediaType = mediaTypes.video ? 'video' : 'banner'; + const floorInfo = bidRequest.getFloor({ + currency: SUPPORTED_CURRENCY, + mediaType: curMediaType, + size: bidRequest.sizes ? bidRequest.sizes.map(([w, h]) => ({w, h})) : '*' + }); + + if (floorInfo && typeof floorInfo === 'object' && + floorInfo.currency === SUPPORTED_CURRENCY && + !isNaN(parseFloat(floorInfo.floor))) { + floor = Math.max(floor, parseFloat(floorInfo.floor)); + } + } + + impression.bidfloor = floor; + impression.bidfloorcur = params.currency || SUPPORTED_CURRENCY; + } + + const tagId = params.uid || params.secid; + if (tagId) { + impression.tagid = tagId.toString(); + } + + if (bidRequest.adUnitCode) { + if (!impression.ext) { + impression.ext = {}; + } + impression.ext.divid = bidRequest.adUnitCode.toString(); + } + + if (impression.banner && impression.banner.format && impression.banner.format.length > 0) { + const firstFormat = impression.banner.format[0]; + if (firstFormat.w && firstFormat.h && (impression.banner.w == null || impression.banner.h == null)) { + impression.banner.w = firstFormat.w; + impression.banner.h = firstFormat.h; + } + } + + return impression; + }, + request(buildRequest, imps, bidderRequest, context) { + const requestObj = buildRequest(imps, bidderRequest, context); + mergeDeep(requestObj, { + ext: { + hb: 1, + prebidver: '$prebid.version$', + adapterver: ADAPTER_VERSION, + } + }); + + if (!requestObj.source) { + requestObj.source = {}; + } + + if (!requestObj.source.tid && bidderRequest.ortb2?.source?.tid) { + requestObj.source.tid = bidderRequest.ortb2.source.tid.toString(); + } + + if (!requestObj.source.ext) { + requestObj.source.ext = {}; + } + requestObj.source.ext.wrapper = 'Prebid_js'; + requestObj.source.ext.wrapper_version = '$prebid.version$'; + + // CCPA + if (bidderRequest.uspConsent) { + deepSetValue(requestObj, 'regs.ext.us_privacy', bidderRequest.uspConsent); + } + + // GPP + if (bidderRequest.gppConsent?.gppString) { + deepSetValue(requestObj, 'regs.gpp', bidderRequest.gppConsent.gppString); + deepSetValue(requestObj, 'regs.gpp_sid', bidderRequest.gppConsent.applicableSections); + } else if (bidderRequest.ortb2?.regs?.gpp) { + deepSetValue(requestObj, 'regs.gpp', bidderRequest.ortb2.regs.gpp); + deepSetValue(requestObj, 'regs.gpp_sid', bidderRequest.ortb2.regs.gpp_sid); + } + + // COPPA + if (config.getConfig('coppa') === true) { + deepSetValue(requestObj, 'regs.coppa', 1); + } + + // User IDs (eIDs) + if (bidderRequest.bidRequests && bidderRequest.bidRequests.length > 0) { + const bidRequest = bidderRequest.bidRequests[0]; + if (bidRequest.userIdAsEids && bidRequest.userIdAsEids.length > 0) { + deepSetValue(requestObj, 'user.ext.eids', bidRequest.userIdAsEids); + } + + // Supply Chain (schain) + if (bidRequest.schain) { + deepSetValue(requestObj, 'source.ext.schain', bidRequest.schain); + } + } + + if (requestObj.tmax == null && bidderRequest.timeout) { + const timeout = parseInt(bidderRequest.timeout, 10); + if (!isNaN(timeout)) { + requestObj.tmax = timeout; + } + } + + return requestObj; + }, + bidResponse(buildBidResponse, bid, context) { + const {bidRequest} = context; + let responseMediaType; + + if (bid.mtype === 2) { + responseMediaType = VIDEO; + } else if (bid.mtype === 1) { + responseMediaType = BANNER; + } else { + responseMediaType = BANNER; + } + + context.mediaType = responseMediaType; + context.currency = SUPPORTED_CURRENCY; + + if (responseMediaType === VIDEO) { + context.vastXml = bid.adm; + } + + const bidResponseObj = buildBidResponse(bid, context); + + if (bid.ext?.bidder?.trustx?.networkName) { + bidResponseObj.adserverTargeting = { 'hb_ds': bid.ext.bidder.trustx.networkName }; + if (!bidResponseObj.meta) { + bidResponseObj.meta = {}; + } + bidResponseObj.meta.demandSource = bid.ext.bidder.trustx.networkName; + } + + if (responseMediaType === VIDEO && bidRequest.mediaTypes.video.context === 'outstream') { + bidResponseObj.renderer = setupOutstreamRenderer(bidResponseObj); + bidResponseObj.adResponse = { + content: bidResponseObj.vastXml, + width: bidResponseObj.width, + height: bidResponseObj.height + }; + } + + return bidResponseObj; + } +}); + +export const spec = { + code: BIDDER_CODE, + VERSION: ADAPTER_VERSION, + supportedMediaTypes: [BANNER, VIDEO], + ENDPOINT: 'https://ads.trustx.org/pbhb', + + /** + * Determines whether or not the given bid request is valid. + * + * @param {BidRequest} bid The bid params to validate. + * @return boolean True if this is a valid bid, and false otherwise. + */ + isBidRequestValid: function(bid) { + return ( + isParamsValid(bid) && + isBannerValid(bid) && + isVideoValid(bid) + ); + }, + + /** + * Make a server request from the list of BidRequests. + * + * @param {BidRequest[]} validBidRequests A non-empty list of valid bid requests + * @param {object} bidderRequest bidder request object + * @return ServerRequest Info describing the request to the server. + */ + buildRequests: function(validBidRequests, bidderRequest) { + const adType = containsVideoRequest(validBidRequests) ? VIDEO : BANNER; + const requestData = ortbAdapterConverter.toORTB({ + bidRequests: validBidRequests, + bidderRequest, + context: {contextMediaType: adType} + }); + + if (validBidRequests[0].params.test) { + logMessage('trustx test mode enabled'); + } + + return { + method: 'POST', + url: spec.ENDPOINT, + data: requestData + }; + }, + + /** + * Unpack the response from the server into a list of bids. + * + * @param {ServerResponse} serverResponse A successful response from the server. + * @param {object} bidRequest The bid request object. + * @return {Bid[]} An array of bids which were nested inside the server. + */ + interpretResponse: function(serverResponse, bidRequest) { + return ortbAdapterConverter.fromORTB({ + response: serverResponse.body, + request: bidRequest.data + }).bids; + }, + + /** + * Register the user sync pixels which should be dropped after the auction. + * + * @param {SyncOptions} syncOptions Which user syncs are allowed? + * @param {ServerResponse[]} serverResponses List of server's responses. + * @return {object[]} The user syncs which should be dropped. + */ + getUserSyncs: function(syncOptions, serverResponses) { + logInfo('trustx.getUserSyncs', 'syncOptions', syncOptions, 'serverResponses', serverResponses); + let syncElements = []; + + // Early return if sync is completely disabled + if (!syncOptions.iframeEnabled && !syncOptions.pixelEnabled) { + return syncElements; + } + + // Server always returns ext.usersync, so we extract sync URLs from server response + if (serverResponses && Array.isArray(serverResponses) && serverResponses.length > 0) { + serverResponses.forEach(resp => { + const userSync = deepAccess(resp, 'body.ext.usersync'); + if (userSync) { + Object.keys(userSync).forEach(key => { + const value = userSync[key]; + if (value.syncs && value.syncs.length) { + value.syncs.forEach(syncItem => { + syncElements.push({ + type: syncItem.type === 'iframe' ? 'iframe' : 'image', + url: syncItem.url + }); + }); + } + }); + } + }); + + // Per requirement: If iframeEnabled, return only iframes; + // if not iframeEnabled but pixelEnabled, return only pixels + if (syncOptions.iframeEnabled) { + syncElements = syncElements.filter(s => s.type === 'iframe'); + } else if (syncOptions.pixelEnabled) { + syncElements = syncElements.filter(s => s.type === 'image'); + } + } + + logInfo('trustx.getUserSyncs result=%o', syncElements); + return syncElements; + } +}; + +/** + * Creates an outstream renderer for video ads + * @param {Bid} bid The bid response + * @return {Renderer} A renderer for outstream video + */ +function setupOutstreamRenderer(bid) { + const renderer = Renderer.install({ + id: bid.adId, + url: OUTSTREAM_PLAYER_URL, + loaded: false + }); + + renderer.setRender(outstreamRender); + + return renderer; +} + +/** + * Outstream renderer function called by the renderer + * @param {Object} bid The bid object + */ +function outstreamRender(bid) { + bid.renderer.push(() => { + window.ANOutstreamVideo.renderAd({ + sizes: [bid.width, bid.height], + targetId: bid.adUnitCode, + adResponse: bid.adResponse + }); + }); +} + +/* ======== + * Helpers + *========= + */ + +/** + * Checks if the bid request has banner media type + * @param {BidRequest} bidRequest + * @return {boolean} True if has banner media type + */ +function hasBannerFormat(bidRequest) { + return !!deepAccess(bidRequest, 'mediaTypes.banner'); +} + +/** + * Checks if the bid request has video media type + * @param {BidRequest|BidRequest[]} bidRequest bid request or array of bid requests + * @return {boolean} True if has video media type + */ +function containsVideoRequest(bidRequest) { + if (Array.isArray(bidRequest)) { + return bidRequest.some(bid => !!deepAccess(bid, 'mediaTypes.video')); + } + return !!deepAccess(bidRequest, 'mediaTypes.video'); +} + +/** + * Validates basic bid parameters + * @param {BidRequest} bidRequest + * @return {boolean} True if parameters are valid + */ +function isParamsValid(bidRequest) { + if (!bidRequest.params) { + return false; + } + + if (bidRequest.params.test) { + return true; + } + + const hasTagId = bidRequest.params.uid || bidRequest.params.secid; + + if (!hasTagId) { + logError('trustx validation failed: Placement ID (uid or secid) not declared'); + return false; + } + + const hasMediaType = containsVideoRequest(bidRequest) || hasBannerFormat(bidRequest); + if (!hasMediaType) { + return false; + } + + return true; +} + +/** + * Validates banner bid request + * @param {BidRequest} bidRequest + * @return {boolean} True if valid banner bid request + */ +function isBannerValid(bidRequest) { + // If there's no banner no need to validate + if (!hasBannerFormat(bidRequest)) { + return true; + } + + const banner = deepAccess(bidRequest, 'mediaTypes.banner'); + if (!Array.isArray(banner.sizes)) { + return false; + } + + return true; +} + +/** + * Validates video bid request + * @param {BidRequest} bidRequest + * @return {boolean} True if valid video bid request + */ +function isVideoValid(bidRequest) { + // If there's no video no need to validate + if (!containsVideoRequest(bidRequest)) { + return true; + } + + const videoConfig = deepAccess(bidRequest, 'mediaTypes.video', {}); + const videoBidParams = deepAccess(bidRequest, 'params.video', {}); + const params = deepAccess(bidRequest, 'params', {}); + + if (params && params.test) { + return true; + } + + const consolidatedVideoParams = { + ...videoConfig, + ...videoBidParams // Bidder Specific overrides + }; + + if (!Array.isArray(consolidatedVideoParams.mimes) || consolidatedVideoParams.mimes.length === 0) { + logError('trustx validation failed: mimes are invalid'); + return false; + } + + if (!Array.isArray(consolidatedVideoParams.protocols) || consolidatedVideoParams.protocols.length === 0) { + logError('trustx validation failed: protocols are invalid'); + return false; + } + + if (!consolidatedVideoParams.context) { + logError('trustx validation failed: context id not declared'); + return false; + } + + if (consolidatedVideoParams.context !== 'instream' && consolidatedVideoParams.context !== 'outstream') { + logError('trustx validation failed: only context instream or outstream is supported'); + return false; + } + + if (typeof consolidatedVideoParams.playerSize === 'undefined' || !Array.isArray(consolidatedVideoParams.playerSize) || !Array.isArray(consolidatedVideoParams.playerSize[0])) { + logError('trustx validation failed: player size not declared or is not in format [[w,h]]'); + return false; + } + + return true; +} + +registerBidder(spec); diff --git a/modules/trustxBidAdapter.md b/modules/trustxBidAdapter.md new file mode 100644 index 00000000000..c9634201cb1 --- /dev/null +++ b/modules/trustxBidAdapter.md @@ -0,0 +1,298 @@ +# Overview + +``` +Module Name: TRUSTX Bid Adapter +Module Type: Bidder Adapter +Maintainer: prebid@trustx.org +``` + +# Description + +Module that connects to TRUSTX's premium demand sources. +TRUSTX bid adapter supports Banner and Video ad formats with advanced targeting capabilities. + +# Required Parameters + +## Banner + +- `uid` or `secid` (required) - Placement ID / Tag ID +- `mediaTypes.banner.sizes` (required) - Array of banner sizes + +## Video + +- `uid` or `secid` (required) - Placement ID / Tag ID +- `mediaTypes.video.context` (required) - Must be 'instream' or 'outstream' +- `mediaTypes.video.playerSize` (required) - Array format [[w, h]] +- `mediaTypes.video.mimes` (required) - Array of MIME types +- `mediaTypes.video.protocols` (required) - Array of protocol numbers + +# Test Parameters + +## Banner + +``` +var adUnits = [ + { + code: 'trustx-banner-container', + mediaTypes: { + banner: { + sizes: [[300, 250], [300, 600], [728, 90]] + } + }, + bids: [{ + bidder: 'trustx', + params: { + uid: 123456, + bidFloor: 0.5 + } + }] + } +]; +``` + +## Video + +We support the following OpenRTB params that can be specified in `mediaTypes.video` or in `bids[].params.video`: +- 'mimes' +- 'minduration' +- 'maxduration' +- 'plcmt' +- 'protocols' +- 'startdelay' +- 'skip' +- 'skipafter' +- 'minbitrate' +- 'maxbitrate' +- 'delivery' +- 'playbackmethod' +- 'api' +- 'linearity' + +## Instream Video adUnit using mediaTypes.video + +*Note:* By default, the adapter will read the mandatory parameters from mediaTypes.video. +*Note:* The TRUSTX ad server will respond with a VAST XML to load into your defined player. + +``` +var adUnits = [ + { + code: 'trustx-video-container', + mediaTypes: { + video: { + context: 'instream', + playerSize: [[640, 480]], + mimes: ['video/mp4', 'video/webm'], + protocols: [2, 3, 5, 6], + api: [2, 7], + position: 1, + delivery: [2], + minduration: 5, + maxduration: 60, + plcmt: 1, + playbackmethod: [1, 3, 5], + } + }, + bids: [ + { + bidder: 'trustx', + params: { + uid: 123456, + bidFloor: 5.0 + } + } + ] + } +] +``` + +## Outstream Video + +TRUSTX also supports outstream video format that can be displayed in non-video placements. + +``` +var adUnits = [ + { + code: 'trustx-outstream-container', + mediaTypes: { + video: { + context: 'outstream', + playerSize: [[640, 360]], + mimes: ['video/mp4', 'video/webm'], + protocols: [2, 3, 5, 6], + api: [2, 7], + placement: 3, + minduration: 5, + maxduration: 30, + } + }, + bids: [ + { + bidder: 'trustx', + params: { + uid: 123456, + bidFloor: 6.0 + } + } + ] + } +] +``` + +# Test Mode + +By passing `bid.params.test = true` you will be able to receive a test creative without needing to set up real placements. + +## Banner + +``` +var adUnits = [ + { + code: 'trustx-test-banner', + mediaTypes: { + banner: { + sizes: [[300, 250], [300, 600], [728, 90]] + } + }, + bids: [{ + bidder: 'trustx', + params: { + test: true + } + }] + } +]; +``` + +## Video + +``` +var adUnits = [ + { + code: 'trustx-test-video', + mediaTypes: { + video: { + context: "instream", + playerSize: [[640, 480]], + mimes: ['video/mp4', 'video/webm'], + protocols: [2, 3, 5, 6], + } + }, + bids: [ + { + bidder: 'trustx', + params: { + test: true + } + } + ] + } +] +``` + +# Optional Parameters + +## Bid Floor + +You can specify bid floor using any of these parameter names: +- `bidFloor` (camelCase) +- `bidfloor` (lowercase) +- `floorcpm` (alternative name) + +The adapter also supports Prebid's Floor Module via `getFloor()` function. The highest value between params and Floor Module will be used. + +``` +params: { + uid: 455069, + bidFloor: 0.5, // or bidfloor or floorcpm + currency: 'USD' // optional, defaults to USD +} +``` + +## Backward Compatibility with Grid Adapter + +The TRUSTX adapter is fully compatible with Grid adapter parameters for seamless migration: + +- `uid` - Same as Grid adapter (required) +- `secid` - Alternative to uid (required if uid not provided) +- `bidFloor` / `bidfloor` / `floorcpm` - Bid floor (optional) + +# First Party Data (FPD) Support + +The adapter automatically includes First Party Data from `ortb2` configuration: + +## Site FPD + +``` +pbjs.setConfig({ + ortb2: { + site: { + name: 'Example Site', + domain: 'example.com', + page: 'https://example.com/page', + cat: ['IAB12-1'], + content: { + data: [{ + name: 'reuters.com', + segment: [{id: '391'}, {id: '52'}] + }] + } + } + } +}); +``` + +## User FPD + +User IDs are passed through Prebid's User ID modules (e.g., SharedId) via `user.ext.eids`. + +## Device FPD + +Device data from `ortb2.device` is automatically included in requests. + +# User Sync + +The adapter supports server-side user sync. Sync URLs are extracted from server response (`ext.usersync`) and automatically registered with Prebid.js. + +``` +pbjs.setConfig({ + userSync: { + syncEnabled: true, + iframeEnabled: true, + pixelEnabled: true + } +}); +``` + +# Additional Configuration Options + +## GPP Support + +TRUSTX fully supports Global Privacy Platform (GPP) standards. GPP data is automatically passed from `bidderRequest.gppConsent` or `ortb2.regs.gpp`. + +## CCPA Support + +CCPA (US Privacy) data is automatically passed from `bidderRequest.uspConsent` or `ortb2.regs.ext.us_privacy`. + +## COPPA Support + +COPPA compliance is automatically handled when `config.getConfig('coppa') === true`. + +## DSA Support + +Digital Services Act (DSA) data is automatically passed from `ortb2.regs.ext.dsa`. + +## Supply Chain (schain) + +Supply chain data is automatically passed from `ortb2.source.ext.schain` or `bidRequest.schain`. + +## Source Transaction ID (tid) + +Transaction ID is automatically passed from `ortb2.source.tid`. + +# Response Format + +The adapter returns bids in standard Prebid.js format with the following additional fields: + +- `adserverTargeting.hb_ds` - Network name from server response (`ext.bidder.trustx.networkName`) +- `meta.demandSource` - Network name metadata (same as `networkName` from server) +- `netRevenue: false` - Revenue model \ No newline at end of file diff --git a/test/spec/modules/trustxBidAdapter_spec.js b/test/spec/modules/trustxBidAdapter_spec.js new file mode 100644 index 00000000000..ac572027900 --- /dev/null +++ b/test/spec/modules/trustxBidAdapter_spec.js @@ -0,0 +1,1112 @@ +import {expect} from 'chai'; +import {spec} from 'modules/trustxBidAdapter.js'; +import {BANNER, VIDEO} from 'src/mediaTypes.js'; +import sinon from 'sinon'; +import {config} from 'src/config.js'; + +const getBannerRequest = () => { + return { + bidderCode: 'trustx', + auctionId: 'ca09c8cd-3824-4322-9dfe-d5b62b51c81c', + bidderRequestId: 'trustx-request-1', + bids: [ + { + bidder: 'trustx', + params: { + uid: '987654', + bidfloor: 5.25, + }, + auctionId: 'auction-id-45fe-9823-123456789abc', + placementCode: 'div-gpt-ad-trustx-test', + mediaTypes: { + banner: { + sizes: [ + [ 300, 250 ], + ] + } + }, + bidId: 'trustx-bid-12345', + bidderRequestId: 'trustx-request-1', + } + ], + start: 1615982436070, + auctionStart: 1615982436069, + timeout: 2000 + } +}; + +const getVideoRequest = () => { + return { + bidderCode: 'trustx', + auctionId: 'd2b62784-f134-4896-a87e-a233c3371413', + bidderRequestId: 'trustx-video-request-1', + bids: [{ + mediaTypes: { + video: { + context: 'instream', + playerSize: [[640, 480]], + } + }, + bidder: 'trustx', + sizes: [640, 480], + bidId: 'trustx-video-bid-1', + adUnitCode: 'video-placement-1', + params: { + video: { + playerWidth: 640, + playerHeight: 480, + mimes: ['video/mp4', 'application/javascript'], + protocols: [2, 5], + api: [2], + position: 1, + delivery: [2], + sid: 789, + rewarded: 0, + placement: 1, + plcmt: 1, + hp: 1, + inventoryid: 456 + }, + site: { + id: 1234, + page: 'https://trustx-test.com', + referrer: 'http://trustx-referrer.com' + }, + publisher_id: 'trustx-publisher-id', + bidfloor: 7.25, + } + }, { + mediaTypes: { + video: { + context: 'instream', + playerSize: [[640, 480]], + } + }, + bidder: 'trustx', + sizes: [640, 480], + bidId: 'trustx-video-bid-2', + adUnitCode: 'video-placement-2', + params: { + video: { + playerWidth: 640, + playerHeight: 480, + mimes: ['video/mp4', 'application/javascript'], + protocols: [2, 5], + api: [2], + position: 1, + delivery: [2], + sid: 790, + rewarded: 0, + placement: 1, + plcmt: 1, + hp: 1, + inventoryid: 457 + }, + site: { + id: 1235, + page: 'https://trustx-test2.com', + referrer: 'http://trustx-referrer2.com' + }, + publisher_id: 'trustx-publisher-id', + bidfloor: 8.50, + } + }], + auctionStart: 1615982456880, + timeout: 3500, + start: 1615982456884, + doneCbCallCount: 0, + refererInfo: { + numIframes: 1, + reachedTop: true, + referer: 'trustx-test.com' + } + }; +}; + +const getBidderResponse = () => { + return { + headers: null, + body: { + id: 'trustx-response-id-1', + seatbid: [ + { + bid: [ + { + id: 'trustx-bid-12345', + impid: 'trustx-bid-12345', + price: 3.22, + adm: '', + adid: '987654321', + adomain: [ + 'https://trustx-advertiser.com' + ], + iurl: 'https://trustx-campaign.com/creative.jpg', + cid: '12345', + crid: 'trustx-creative-234', + cat: [], + w: 300, + h: 250, + ext: { + prebid: { + type: 'banner' + }, + bidder: { + trustx: { + brand_id: 123456, + auction_id: 987654321098765, + bidder_id: 5, + bid_ad_type: 0 + } + } + } + } + ], + seat: 'trustx' + } + ], + ext: { + usersync: { + sync1: { + status: 'none', + syncs: [ + { + url: 'https://sync1.trustx.org/sync', + type: 'iframe' + } + ] + }, + sync2: { + status: 'none', + syncs: [ + { + url: 'https://sync2.trustx.org/sync', + type: 'pixel' + } + ] + } + }, + responsetimemillis: { + trustx: 95 + } + } + } + }; +} + +describe('trustxBidAdapter', function() { + let videoBidRequest; + + const VIDEO_REQUEST = { + 'bidderCode': 'trustx', + 'auctionId': 'd2b62784-f134-4896-a87e-a233c3371413', + 'bidderRequestId': 'trustx-video-request-1', + 'bids': videoBidRequest, + 'auctionStart': 1615982456880, + 'timeout': 3000, + 'start': 1615982456884, + 'doneCbCallCount': 0, + 'refererInfo': { + 'numIframes': 1, + 'reachedTop': true, + 'referer': 'trustx-test.com' + } + }; + + beforeEach(function () { + videoBidRequest = { + mediaTypes: { + video: { + context: 'instream', + playerSize: [[640, 480]], + } + }, + bidder: 'trustx', + sizes: [640, 480], + bidId: 'trustx-video-bid-1', + adUnitCode: 'video-placement-1', + params: { + video: { + playerWidth: 640, + playerHeight: 480, + mimes: ['video/mp4', 'application/javascript'], + protocols: [2, 5], + api: [2], + position: 1, + delivery: [2], + sid: 789, + rewarded: 0, + placement: 1, + plcmt: 1, + hp: 1, + inventoryid: 456 + }, + site: { + id: 1234, + page: 'https://trustx-test.com', + referrer: 'http://trustx-referrer.com' + }, + publisher_id: 'trustx-publisher-id', + bidfloor: 0 + } + }; + }); + + describe('isValidRequest', function() { + let bidderRequest; + + beforeEach(function() { + bidderRequest = getBannerRequest(); + }); + + it('should accept request with uid/secid', function () { + bidderRequest.bids[0].params = { + uid: '123', + mediaTypes: { banner: { sizes: [[300, 250]] } } + }; + expect(spec.isBidRequestValid(bidderRequest.bids[0])).to.be.true; + }); + + it('should accept request with secid', function () { + bidderRequest.bids[0].params = { + secid: '456', + publisher_id: 'pub-1', + mediaTypes: { banner: { sizes: [[300, 250]] } } + }; + expect(spec.isBidRequestValid(bidderRequest.bids[0])).to.be.true; + }); + + it('reject requests without params', function () { + bidderRequest.bids[0].params = {}; + expect(spec.isBidRequestValid(bidderRequest.bids[0])).to.be.false; + }); + + it('returns false when banner mediaType does not exist', function () { + bidderRequest.bids[0].mediaTypes = {} + expect(spec.isBidRequestValid(bidderRequest.bids[0])).to.be.false; + }); + }); + + describe('buildRequests', function() { + let bidderRequest; + + beforeEach(function() { + bidderRequest = getBannerRequest(); + }); + + it('should return expected request object', function() { + const bidRequest = spec.buildRequests(bidderRequest.bids, bidderRequest); + expect(bidRequest.url).equal('https://ads.trustx.org/pbhb'); + expect(bidRequest.method).equal('POST'); + }); + }); + + context('banner validation', function () { + let bidderRequest; + + beforeEach(function() { + bidderRequest = getBannerRequest(); + }); + + it('returns true when banner sizes are defined', function () { + const bid = { + bidder: 'trustx', + mediaTypes: { + banner: { + sizes: [[250, 300]] + } + }, + params: { + uid: 'trustx-placement-1', + } + }; + + expect(spec.isBidRequestValid(bidderRequest.bids[0])).to.be.true; + }); + + it('returns false when banner sizes are invalid', function () { + const invalidSizes = [ + undefined, + '3:2', + 456, + 'invalid' + ]; + + invalidSizes.forEach((sizes) => { + const bid = { + bidder: 'trustx', + mediaTypes: { + banner: { + sizes + } + }, + params: { + uid: 'trustx-placement-1', + } + }; + + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + }); + }); + + context('video validation', function () { + beforeEach(function () { + // Basic Valid BidRequest + this.bid = { + bidder: 'trustx', + mediaTypes: { + video: { + playerSize: [[300, 250]], + context: 'instream', + mimes: ['video/mp4', 'video/webm'], + protocols: [2, 3] + } + }, + params: { + uid: 'trustx-placement-1', + } + }; + }); + + it('should return true (skip validations) when test = true', function () { + this.bid.params = { + test: true + }; + expect(spec.isBidRequestValid(this.bid)).to.equal(true); + }); + + it('returns false when video context is not defined', function () { + delete this.bid.mediaTypes.video.context; + + expect(spec.isBidRequestValid(this.bid)).to.be.false; + }); + + it('returns false when video playserSize is invalid', function () { + const invalidSizes = [ + undefined, + '1:1', + 456, + 'invalid' + ]; + + invalidSizes.forEach((playerSize) => { + this.bid.mediaTypes.video.playerSize = playerSize; + expect(spec.isBidRequestValid(this.bid)).to.be.false; + }); + }); + + it('returns false when video mimes is invalid', function () { + const invalidMimes = [ + undefined, + 'invalid', + 1, + [] + ] + + invalidMimes.forEach((mimes) => { + this.bid.mediaTypes.video.mimes = mimes; + expect(spec.isBidRequestValid(this.bid)).to.be.false; + }) + }); + + it('returns false when video protocols is invalid', function () { + const invalidProtocols = [ + undefined, + 'invalid', + 1, + [] + ] + + invalidProtocols.forEach((protocols) => { + this.bid.mediaTypes.video.protocols = protocols; + expect(spec.isBidRequestValid(this.bid)).to.be.false; + }) + }); + + it('should accept outstream context', function () { + this.bid.mediaTypes.video.context = 'outstream'; + expect(spec.isBidRequestValid(this.bid)).to.be.true; + }); + }); + + describe('buildRequests', function () { + let bidderBannerRequest; + let bidRequestsWithMediaTypes; + let mockBidderRequest; + + beforeEach(function() { + bidderBannerRequest = getBannerRequest(); + + mockBidderRequest = {refererInfo: {}}; + + bidRequestsWithMediaTypes = [{ + bidder: 'trustx', + params: { + publisher_id: 'trustx-publisher-id', + }, + adUnitCode: '/adunit-test/trustx-path', + mediaTypes: { + banner: { + sizes: [[300, 250], [300, 600]] + } + }, + bidId: 'trustx-test-bid-1', + bidderRequestId: 'trustx-test-request-1', + auctionId: 'trustx-test-auction-1', + transactionId: 'trustx-test-transaction-1', + ortb2Imp: { + ext: { + ae: 3 + } + } + }, { + bidder: 'trustx', + params: { + publisher_id: 'trustx-publisher-id', + }, + adUnitCode: 'trustx-adunit', + mediaTypes: { + video: { + playerSize: [640, 480], + placement: 1, + plcmt: 1, + } + }, + bidId: 'trustx-test-bid-2', + bidderRequestId: 'trustx-test-request-2', + auctionId: 'trustx-test-auction-2', + transactionId: 'trustx-test-transaction-2' + }]; + }); + + context('when mediaType is banner', function () { + it('creates request data', function () { + let request = spec.buildRequests(bidderBannerRequest.bids, bidderBannerRequest) + + expect(request).to.exist.and.to.be.a('object'); + const payload = request.data; + expect(payload.imp[0]).to.have.property('id', bidderBannerRequest.bids[0].bidId); + }); + + it('should combine multiple bid requests into a single request', function () { + // NOTE: This test verifies that trustx adapter does NOT use multi-request logic. + // Trustx adapter = returns single request with all imp objects (standard OpenRTB) + // + // IMPORTANT: Trustx adapter DOES support multi-bid (multiple bids in response for one imp), + // but does NOT support multi-request (multiple requests instead of one). + const multipleBidRequests = [ + { + bidder: 'trustx', + params: { uid: 'uid-1' }, + adUnitCode: 'adunit-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bidId: 'bid-1', + bidderRequestId: 'request-1', + auctionId: 'auction-1' + }, + { + bidder: 'trustx', + params: { uid: 'uid-2' }, + adUnitCode: 'adunit-2', + mediaTypes: { banner: { sizes: [[728, 90]] } }, + bidId: 'bid-2', + bidderRequestId: 'request-1', + auctionId: 'auction-1' + }, + { + bidder: 'trustx', + params: { uid: 'uid-3' }, + adUnitCode: 'adunit-3', + mediaTypes: { banner: { sizes: [[970, 250]] } }, + bidId: 'bid-3', + bidderRequestId: 'request-1', + auctionId: 'auction-1' + } + ]; + + const request = spec.buildRequests(multipleBidRequests, mockBidderRequest); + + // Trustx adapter should return a SINGLE request object + expect(request).to.be.an('object'); + expect(request).to.not.be.an('array'); + expect(request.method).to.equal('POST'); + expect(request.url).to.equal('https://ads.trustx.org/pbhb'); // No placement_id in URL + + // All imp objects should be in the same request + const payload = request.data; + expect(payload.imp).to.be.an('array'); + expect(payload.imp).to.have.length(3); + expect(payload.imp[0].id).to.equal('bid-1'); + expect(payload.imp[1].id).to.equal('bid-2'); + expect(payload.imp[2].id).to.equal('bid-3'); + expect(payload.imp[0].tagid).to.equal('uid-1'); + expect(payload.imp[1].tagid).to.equal('uid-2'); + expect(payload.imp[2].tagid).to.equal('uid-3'); + }); + + it('should determine media type from mtype field for banner', function () { + const customBidderResponse = Object.assign({}, getBidderResponse()); + customBidderResponse.body = Object.assign({}, getBidderResponse().body); + + if (customBidderResponse.body.seatbid && + customBidderResponse.body.seatbid[0] && + customBidderResponse.body.seatbid[0].bid && + customBidderResponse.body.seatbid[0].bid[0]) { + // Add mtype to the bid + customBidderResponse.body.seatbid[0].bid[0].mtype = 1; // Banner type + } + + const bidRequest = spec.buildRequests(bidderBannerRequest.bids, bidderBannerRequest); + const bids = spec.interpretResponse(customBidderResponse, bidRequest); + expect(bids[0].mediaType).to.equal('banner'); + }); + }); + + if (FEATURES.VIDEO) { + context('video', function () { + it('should create a POST request for every bid', function () { + const requests = spec.buildRequests(bidRequestsWithMediaTypes, mockBidderRequest); + expect(requests.method).to.equal('POST'); + expect(requests.url.trim()).to.equal(spec.ENDPOINT); + }); + + it('should attach request data', function () { + const requests = spec.buildRequests(bidRequestsWithMediaTypes, mockBidderRequest); + const data = requests.data; + const [width, height] = videoBidRequest.sizes; + const VERSION = '1.0.0'; + + expect(data.imp[1].video.w).to.equal(width); + expect(data.imp[1].video.h).to.equal(height); + expect(data.imp[1].bidfloor).to.equal(videoBidRequest.params.bidfloor); + expect(data.imp[1]['video']['placement']).to.equal(videoBidRequest.params.video['placement']); + expect(data.imp[1]['video']['plcmt']).to.equal(videoBidRequest.params.video['plcmt']); + expect(data.ext.prebidver).to.equal('$prebid.version$'); + expect(data.ext.adapterver).to.equal(spec.VERSION); + }); + + it('should attach End 2 End test data', function () { + bidRequestsWithMediaTypes[1].params.test = true; + const requests = spec.buildRequests(bidRequestsWithMediaTypes, mockBidderRequest); + const data = requests.data; + expect(data.imp[1].bidfloor).to.equal(0); + expect(data.imp[1].video.w).to.equal(640); + expect(data.imp[1].video.h).to.equal(480); + }); + }); + } + + context('privacy regulations', function() { + it('should include USP consent data in request', function() { + const uspConsent = '1YNN'; + const bidderRequestWithUsp = Object.assign({}, mockBidderRequest, { uspConsent }); + const requests = spec.buildRequests(bidRequestsWithMediaTypes, bidderRequestWithUsp); + const data = requests.data; + + expect(data.regs.ext).to.have.property('us_privacy', '1YNN'); + }); + + it('should include GPP consent data from gppConsent in request', function() { + const gppConsent = { + gppString: 'GPP_CONSENT_STRING', + applicableSections: [1, 2, 3] + }; + + const bidderRequestWithGpp = Object.assign({}, mockBidderRequest, { gppConsent }); + const requests = spec.buildRequests(bidRequestsWithMediaTypes, bidderRequestWithGpp); + const data = requests.data; + + expect(data.regs).to.have.property('gpp', 'GPP_CONSENT_STRING'); + expect(data.regs.gpp_sid).to.deep.equal([1, 2, 3]); + }); + + it('should include GPP consent data from ortb2 in request', function() { + const ortb2 = { + regs: { + gpp: 'GPP_STRING_FROM_ORTB2', + gpp_sid: [1, 2] + } + }; + + const bidderRequestWithOrtb2Gpp = Object.assign({}, mockBidderRequest, { ortb2 }); + const requests = spec.buildRequests(bidRequestsWithMediaTypes, bidderRequestWithOrtb2Gpp); + const data = requests.data; + + expect(data.regs).to.have.property('gpp', 'GPP_STRING_FROM_ORTB2'); + expect(data.regs.gpp_sid).to.deep.equal([1, 2]); + }); + + it('should prioritize gppConsent over ortb2 for GPP consent data', function() { + const gppConsent = { + gppString: 'GPP_CONSENT_STRING', + applicableSections: [1, 2, 3] + }; + + const ortb2 = { + regs: { + gpp: 'GPP_STRING_FROM_ORTB2', + gpp_sid: [1, 2] + } + }; + + const bidderRequestWithBothGpp = Object.assign({}, mockBidderRequest, { gppConsent, ortb2 }); + const requests = spec.buildRequests(bidRequestsWithMediaTypes, bidderRequestWithBothGpp); + const data = requests.data; + + expect(data.regs).to.have.property('gpp', 'GPP_CONSENT_STRING'); + expect(data.regs.gpp_sid).to.deep.equal([1, 2, 3]); + }); + + it('should include COPPA flag in request when set to true', function() { + // Mock the config.getConfig function to return true for coppa + sinon.stub(config, 'getConfig').withArgs('coppa').returns(true); + + const requests = spec.buildRequests(bidRequestsWithMediaTypes, mockBidderRequest); + const data = requests.data; + + expect(data.regs).to.have.property('coppa', 1); + + // Restore the stub + config.getConfig.restore(); + }); + }); + }); + + describe('interpretResponse', function() { + context('when mediaType is banner', function() { + let bidRequest, bidderResponse; + beforeEach(function() { + const bidderRequest = getBannerRequest(); + bidRequest = spec.buildRequests(bidderRequest.bids, bidderRequest); + bidderResponse = getBidderResponse(); + }); + + it('handles empty response', function () { + const EMPTY_RESP = Object.assign({}, bidderResponse, {'body': {}}); + const bids = spec.interpretResponse(EMPTY_RESP, bidRequest); + + expect(bids).to.be.empty; + }); + + it('have bids', function () { + let bids = spec.interpretResponse(bidderResponse, bidRequest); + expect(bids).to.be.an('array').that.is.not.empty; + validateBidOnIndex(0); + + function validateBidOnIndex(index) { + expect(bids[index]).to.have.property('currency', 'USD'); + expect(bids[index]).to.have.property('requestId', getBidderResponse().body.seatbid[0].bid[index].impid); + expect(bids[index]).to.have.property('cpm', getBidderResponse().body.seatbid[0].bid[index].price); + expect(bids[index]).to.have.property('width', getBidderResponse().body.seatbid[0].bid[index].w); + expect(bids[index]).to.have.property('height', getBidderResponse().body.seatbid[0].bid[index].h); + expect(bids[index]).to.have.property('ad', getBidderResponse().body.seatbid[0].bid[index].adm); + expect(bids[index]).to.have.property('creativeId', getBidderResponse().body.seatbid[0].bid[index].crid); + expect(bids[index].meta).to.have.property('advertiserDomains'); + expect(bids[index]).to.have.property('ttl', 360); + expect(bids[index]).to.have.property('netRevenue', false); + } + }); + + it('should determine media type from mtype field for banner', function () { + const customBidderResponse = Object.assign({}, getBidderResponse()); + customBidderResponse.body = Object.assign({}, getBidderResponse().body); + + if (customBidderResponse.body.seatbid && + customBidderResponse.body.seatbid[0] && + customBidderResponse.body.seatbid[0].bid && + customBidderResponse.body.seatbid[0].bid[0]) { + // Add mtype to the bid + customBidderResponse.body.seatbid[0].bid[0].mtype = 1; // Banner type + } + + const bids = spec.interpretResponse(customBidderResponse, bidRequest); + expect(bids[0].mediaType).to.equal('banner'); + }); + + it('should support multi-bid (multiple bids for one imp object) - Prebid v10 feature', function () { + // Multi-bid: Server can return multiple bids for a single imp object + // This is supported by ortbConverter which trustx adapter uses + const multiBidResponse = { + headers: null, + body: { + id: 'trustx-response-multi-bid', + seatbid: [ + { + bid: [ + { + id: 'bid-1', + impid: 'trustx-bid-12345', // Same impid - multiple bids for one imp + price: 2.50, + adm: '