diff --git a/modules/bluebillywigBidAdapter.js b/modules/bluebillywigBidAdapter.js new file mode 100644 index 00000000000..9e4f5b62e48 --- /dev/null +++ b/modules/bluebillywigBidAdapter.js @@ -0,0 +1,386 @@ +import * as utils from '../src/utils.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { VIDEO } from '../src/mediaTypes.js'; +import { config } from '../src/config.js'; +import { Renderer } from '../src/Renderer.js'; +import { createEidsArray } from './userId/eids.js'; + +const DEV_MODE = window.location.search.match(/bbpbs_debug=true/); + +// Blue Billywig Constants +const BB_CONSTANTS = { + BIDDER_CODE: 'bluebillywig', + AUCTION_URL: '$$URL_STARTpbs.bluebillywig.com/openrtb2/auction?pub=$$PUBLICATION', + SYNC_URL: '$$URL_STARTpbs.bluebillywig.com/static/cookie-sync.html?pub=$$PUBLICATION', + RENDERER_URL: 'https://$$PUBLICATION.bbvms.com/r/$$RENDERER.js', + DEFAULT_TIMEOUT: 5000, + DEFAULT_TTL: 300, + DEFAULT_WIDTH: 768, + DEFAULT_HEIGHT: 432, + DEFAULT_NET_REVENUE: true +}; + +// Aliasing +const getConfig = config.getConfig; + +// Helper Functions +export const BB_HELPERS = { + addSiteAppDevice: function(request, pageUrl) { + if (!request) return; + + if (typeof getConfig('app') === 'object') request.app = getConfig('app'); + else if (pageUrl) request.site = { page: pageUrl }; + + if (typeof getConfig('device') === 'object') request.device = getConfig('device'); + if (!request.device) request.device = {}; + if (!request.device.w) request.device.w = window.innerWidth; + if (!request.device.h) request.device.h = window.innerHeight; + }, + addSchain: function(request, validBidRequests) { + if (!request) return; + + const schain = utils.deepAccess(validBidRequests, '0.schain'); + if (schain) request.source.ext = { schain: schain }; + }, + addCurrency: function(request) { + if (!request) return; + + const adServerCur = getConfig('currency.adServerCurrency'); + if (adServerCur && typeof adServerCur === 'string') request.cur = [adServerCur]; + else if (Array.isArray(adServerCur) && adServerCur.length) request.cur = [adServerCur[0]]; + }, + addUserIds: function(request, validBidRequests) { + if (!request) return; + + const bidUserId = utils.deepAccess(validBidRequests, '0.userId'); + const eids = createEidsArray(bidUserId); + + if (eids.length) { + utils.deepSetValue(request, 'user.ext.eids', eids); + } + }, + addDigiTrust: function(request, bidRequests) { + const digiTrust = BB_HELPERS.getDigiTrustParams(bidRequests && bidRequests[0]); + if (digiTrust) utils.deepSetValue(request, 'user.ext.digitrust', digiTrust); + }, + substituteUrl: function (url, publication, renderer) { + return url.replace('$$URL_START', (DEV_MODE) ? 'https://dev.' : 'https://').replace('$$PUBLICATION', publication).replace('$$RENDERER', renderer); + }, + getAuctionUrl: function(publication) { + return BB_HELPERS.substituteUrl(BB_CONSTANTS.AUCTION_URL, publication); + }, + getSyncUrl: function(publication) { + return BB_HELPERS.substituteUrl(BB_CONSTANTS.SYNC_URL, publication); + }, + getRendererUrl: function(publication, renderer) { + return BB_HELPERS.substituteUrl(BB_CONSTANTS.RENDERER_URL, publication, renderer); + }, + getDigiTrustParams: function(bidRequest) { + const digiTrustId = BB_HELPERS.getDigiTrustId(bidRequest); + + if (!digiTrustId || (digiTrustId.privacy && digiTrustId.privacy.optout)) return null; + return { + id: digiTrustId.id, + keyv: digiTrustId.keyv + } + }, + getDigiTrustId: function(bidRequest) { + const bidRequestDigiTrust = utils.deepAccess(bidRequest, 'userId.digitrustid.data'); + if (bidRequestDigiTrust) return bidRequestDigiTrust; + + const digiTrustUser = getConfig('digiTrustId'); + return (digiTrustUser && digiTrustUser.success && digiTrustUser.identity) || null; + }, + transformRTBToPrebidProps: function(bid, serverResponse) { + bid.cpm = bid.price; delete bid.price; + bid.bidId = bid.impid; + bid.requestId = bid.impid; delete bid.impid; + bid.width = bid.w || BB_CONSTANTS.DEFAULT_WIDTH; + bid.height = bid.h || BB_CONSTANTS.DEFAULT_HEIGHT; + if (bid.adm) { + bid.ad = bid.adm; + bid.vastXml = bid.adm; + delete bid.adm; + } + if (bid.nurl && !bid.adm) { // ad markup is on win notice url, and adm is ommited according to OpenRTB 2.5 + bid.vastUrl = bid.nurl; + delete bid.nurl; + } + bid.netRevenue = BB_CONSTANTS.DEFAULT_NET_REVENUE; + bid.creativeId = bid.crid; delete bid.crid; + bid.currency = serverResponse.cur; + bid.ttl = BB_CONSTANTS.DEFAULT_TTL; + }, +}; + +// Renderer Functions +const BB_RENDERER = { + bootstrapPlayer: function(bid) { + const config = { + code: bid.adUnitCode, + }; + + if (bid.vastXml) config.vastXml = bid.vastXml; + else if (bid.vastUrl) config.vastUrl = bid.vastUrl; + + if (!bid.vastXml && !bid.vastUrl) { + utils.logWarn(`${BB_CONSTANTS.BIDDER_CODE}: No vastXml or vastUrl on bid, bailing...`); + return; + } + + const rendererId = BB_RENDERER.getRendererId(bid.publicationName, bid.rendererCode); + + const ele = document.getElementById(bid.adUnitCode); // NB convention + + let renderer; + + for (let rendererIndex = 0; rendererIndex < window.bluebillywig.renderers.length; rendererIndex++) { + if (window.bluebillywig.renderers[rendererIndex]._id === rendererId) { + renderer = window.bluebillywig.renderers[rendererIndex]; + break; + } + } + + if (renderer) renderer.bootstrap(config, ele); + else utils.logWarn(`${BB_CONSTANTS.BIDDER_CODE}: Couldn't find a renderer with ${rendererId}`); + }, + newRenderer: function(rendererUrl, adUnitCode) { + const renderer = Renderer.install({ + url: rendererUrl, + loaded: false, + adUnitCode + }); + + try { + renderer.setRender(BB_RENDERER.outstreamRender); + } catch (err) { + utils.logWarn(`${BB_CONSTANTS.BIDDER_CODE}: Error tying to setRender on renderer`, err); + } + + return renderer; + }, + outstreamRender: function(bid) { + bid.renderer.push(function() { BB_RENDERER.bootstrapPlayer(bid) }); + }, + getRendererId: function(pub, renderer) { + return `${pub}-${renderer}`; // NB convention! + } +}; + +// Spec Functions +// These functions are used to construct the core spec for the adapter +export const spec = { + code: BB_CONSTANTS.BIDDER_CODE, + supportedMediaTypes: [VIDEO], + syncStore: { bidders: [], }, + isBidRequestValid(bid) { + const publicationNameRegex = /^\w+\.?\w+$/; + const rendererRegex = /^[\w+_]+$/; + + if (!bid.params) { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: no params set on bid. Rejecting bid: `, bid); + return false; + } + + if (!bid.params.hasOwnProperty('publicationName') || typeof bid.params.publicationName !== 'string') { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: no publicationName specified in bid params, or it's not a string. Rejecting bid: `, bid); + return false; + } else if (!publicationNameRegex.test(bid.params.publicationName)) { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: publicationName must be in format 'publication' or 'publication.environment'. Rejecting bid: `, bid); + return false; + } + + if ((!bid.params.hasOwnProperty('rendererCode') || typeof bid.params.rendererCode !== 'string')) { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: no rendererCode was specified in bid params. Rejecting bid: `, bid); + return false; + } else if (!rendererRegex.test(bid.params.rendererCode)) { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: rendererCode must be alphanumeric, including underscores. Rejecting bid: `, bid); + return false; + } + + if (!bid.params.accountId) { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: no accountId specified in bid params. Rejecting bid: `, bid); + return false; + } + + if (bid.params.hasOwnProperty('connections')) { + if (!Array.isArray(bid.params.connections)) { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: connections is not of type array. Rejecting bid: `, bid); + return false; + } else { + for (let connectionIndex = 0; connectionIndex < bid.params.connections.length; connectionIndex++) { + const connection = bid.params.connections[connectionIndex]; + if (!bid.params.hasOwnProperty(connection)) { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: connection specified in params.connections, but not configured in params. Rejecting bid: `, bid); + return false; + } + } + } + } else { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: no connections specified in bid. Rejecting bid: `, bid); + return false; + } + + if (bid.hasOwnProperty('mediaTypes') && bid.mediaTypes.hasOwnProperty(VIDEO)) { + if (!bid.mediaTypes[VIDEO].hasOwnProperty('context')) { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: no context specified in bid. Rejecting bid: `, bid); + return false; + } + + if (bid.mediaTypes[VIDEO].context !== 'outstream') { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: video.context is invalid, must be "outstream". Rejecting bid: `, bid); + return false; + } + } else { + utils.logError(`${BB_CONSTANTS.BIDDER_CODE}: mediaTypes or mediaTypes.video is not specified. Rejecting bid: `, bid); + return false; + } + + return true; + }, + buildRequests(validBidRequests, bidderRequest) { + const imps = []; + + for (let validBidRequestIndex = 0; validBidRequestIndex < validBidRequests.length; validBidRequestIndex++) { + const validBidRequest = validBidRequests[validBidRequestIndex]; + const _this = this; + + const ext = validBidRequest.params.connections.reduce(function(extBuilder, connection) { + extBuilder[connection] = validBidRequest.params[connection]; + + if (_this.syncStore.bidders.indexOf(connection) === -1) _this.syncStore.bidders.push(connection); + + return extBuilder; + }, {}); + + imps.push({ id: validBidRequest.bidId, ext, secure: window.location.protocol === 'https' ? 1 : 0, video: utils.deepAccess(validBidRequest, 'mediaTypes.video') }); + } + + const request = { + id: bidderRequest.auctionId, + source: {tid: bidderRequest.auctionId}, + tmax: BB_CONSTANTS.DEFAULT_TIMEOUT, + imp: imps, + test: DEV_MODE ? 1 : 0, + ext: { + prebid: { + targeting: { includewinners: true, includebidderkeys: false } + } + } + }; + + // handle privacy settings for GDPR/CCPA/COPPA + if (bidderRequest.gdprConsent) { + let gdprApplies = 0; + if (typeof bidderRequest.gdprConsent.gdprApplies === 'boolean') gdprApplies = bidderRequest.gdprConsent.gdprApplies ? 1 : 0; + utils.deepSetValue(request, 'regs.ext.gdpr', gdprApplies); + utils.deepSetValue(request, 'user.ext.consent', bidderRequest.gdprConsent.consentString); + } + + if (bidderRequest.uspConsent) { + utils.deepSetValue(request, 'regs.ext.us_privacy', bidderRequest.uspConsent); + this.syncStore.uspConsent = bidderRequest.uspConsent; + } + + if (getConfig('coppa') == true) utils.deepSetValue(request, 'regs.coppa', 1); + + // Enrich the request with any external data we may have + BB_HELPERS.addSiteAppDevice(request, bidderRequest.refererInfo && bidderRequest.refererInfo.referer); + BB_HELPERS.addSchain(request, validBidRequests); + BB_HELPERS.addCurrency(request); + BB_HELPERS.addUserIds(request, validBidRequests); + BB_HELPERS.addDigiTrust(request, validBidRequests); + + return { + method: 'POST', + url: BB_HELPERS.getAuctionUrl(validBidRequests[0].params.publicationName), + data: JSON.stringify(request), + bidderRequest: bidderRequest + }; + }, + interpretResponse(serverResponse, request) { + serverResponse = serverResponse.body || {}; + + if (!serverResponse.hasOwnProperty('seatbid') || !Array.isArray(serverResponse.seatbid)) { + return []; + } + + const bids = []; + + for (let seatbidIndex = 0; seatbidIndex < serverResponse.seatbid.length; seatbidIndex++) { + const seatbid = serverResponse.seatbid[seatbidIndex]; + if (!seatbid.bid || !Array.isArray(seatbid.bid)) continue; + for (let bidIndex = 0; bidIndex < seatbid.bid.length; bidIndex++) { + const bid = seatbid.bid[bidIndex]; + BB_HELPERS.transformRTBToPrebidProps(bid, serverResponse); + + let bidParams; + for (let bidderRequestBidsIndex = 0; bidderRequestBidsIndex < request.bidderRequest.bids.length; bidderRequestBidsIndex++) { + if (request.bidderRequest.bids[bidderRequestBidsIndex].bidId === bid.bidId) { + bidParams = request.bidderRequest.bids[bidderRequestBidsIndex].params; + } + } + + if (bidParams) { + bid.publicationName = bidParams.publicationName; + bid.rendererCode = bidParams.rendererCode; + bid.accountId = bidParams.accountId; + } + + const rendererUrl = BB_HELPERS.getRendererUrl(bid.publicationName, bid.rendererCode); + + bid.renderer = BB_RENDERER.newRenderer(rendererUrl, bid.adUnitCode); + + bids.push(bid); + } + } + + return bids; + }, + getUserSyncs(syncOptions, serverResponses, gdpr) { + if (!serverResponses || !serverResponses.length) return []; + if (!syncOptions.iframeEnabled) return []; + + const queryString = []; + let accountId; + let publication; + + const serverResponse = serverResponses[0]; + if (!serverResponse.body || !serverResponse.body.seatbid) return []; + + for (let seatbidIndex = 0; seatbidIndex < serverResponse.body.seatbid.length; seatbidIndex++) { + const seatbid = serverResponse.body.seatbid[seatbidIndex]; + for (let bidIndex = 0; bidIndex < seatbid.bid.length; bidIndex++) { + const bid = seatbid.bid[bidIndex]; + accountId = bid.accountId || null; + publication = bid.publicationName || null; + + if (publication && accountId) break; + } + if (publication && accountId) break; + } + + if (!publication || !accountId) return []; + + if (gdpr.gdprApplies) queryString.push(`gdpr=${gdpr.gdprApplies ? 1 : 0}`); + if (gdpr.gdprApplies && gdpr.consentString) queryString.push(`gdpr_consent=${gdpr.consentString}`); + + if (this.syncStore.uspConsent) queryString.push(`usp_consent=${this.syncStore.uspConsent}`); + + queryString.push(`accountId=${accountId}`); + queryString.push(`bidders=${btoa(JSON.stringify(this.syncStore.bidders))}`); + queryString.push(`cb=${Date.now()}-${Math.random().toString().replace('.', '')}`); + + if (DEV_MODE) queryString.push('bbpbs_debug=true'); + + // NB syncUrl by default starts with ?pub=$$PUBLICATION + const syncUrl = `${BB_HELPERS.getSyncUrl(publication)}&${queryString.join('&')}`; + + return [{ + type: 'iframe', + url: syncUrl + }]; + } +}; + +registerBidder(spec); diff --git a/modules/bluebillywigBidAdapter.md b/modules/bluebillywigBidAdapter.md new file mode 100644 index 00000000000..7879697baf5 --- /dev/null +++ b/modules/bluebillywigBidAdapter.md @@ -0,0 +1,38 @@ +# Overview + +``` +Module Name: Blue Billywig Adapter +Module Type: Bidder Adapter +Maintainer: dev+prebid@bluebillywig.com +``` + +# Description + +Prebid Blue Billywig Bidder Adapter + +# Test Parameters + +``` + const adUnits = [{ + code: 'ad-unit', + sizes: [[[768,432],[640,480],[640,360]]], + mediaTypes: { + video: { + playerSize: [768, 432], + context: 'outstream', + mimes: ['video/mp4'], + protocols: [ 2,3,5,6] + } + }, + bids: [{ + bidder: 'bluebillywig', + params: { + publicationName: "bbprebid", + rendererCode: "renderer", + accountId: 642, + connections: [ 'bluebillywig' ], + bluebillywig: {} + } + }] + }]; +``` diff --git a/test/spec/modules/bluebillywigBidAdapter_spec.js b/test/spec/modules/bluebillywigBidAdapter_spec.js new file mode 100644 index 00000000000..fcf3e16ad8b --- /dev/null +++ b/test/spec/modules/bluebillywigBidAdapter_spec.js @@ -0,0 +1,898 @@ +import { expect } from 'chai'; +import { spec } from 'modules/bluebillywigBidAdapter.js'; +import * as bidderFactory from 'src/adapters/bidderFactory.js'; +import { auctionManager } from 'src/auctionManager.js'; +import { deepClone, deepAccess } from 'src/utils.js'; +import { config } from 'src/config.js'; +import { VIDEO } from 'src/mediaTypes.js'; + +const BB_CONSTANTS = { + BIDDER_CODE: 'bluebillywig', + AUCTION_URL: '$$URL_STARTpbs.bluebillywig.com/openrtb2/auction?pub=$$PUBLICATION', + SYNC_URL: '$$URL_STARTpbs.bluebillywig.com/static/cookie-sync.html?pub=$$PUBLICATION', + RENDERER_URL: 'https://$$PUBLICATION.bbvms.com/r/$$RENDERER.js', + DEFAULT_TIMEOUT: 5000, + DEFAULT_TTL: 300, + DEFAULT_WIDTH: 768, + DEFAULT_HEIGHT: 432, + DEFAULT_NET_REVENUE: true +}; + +describe('BlueBillywigAdapter', () => { + describe('isBidRequestValid', () => { + const baseValidBid = { + bidder: BB_CONSTANTS.BIDDER_CODE, + params: { + accountId: 123, + publicationName: 'bbprebid.dev', + rendererCode: 'glorious_renderer', + connections: [ BB_CONSTANTS.BIDDER_CODE ], + bluebillywig: {} + }, + mediaTypes: { + video: { + context: 'outstream' + } + } + }; + + it('should return true when required params found', () => { + expect(spec.isBidRequestValid(baseValidBid)).to.equal(true); + }); + + it('should return false when publicationName is missing', () => { + const bid = deepClone(baseValidBid); + delete bid.params.publicationName; + + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when publicationName is not a string', () => { + const bid = deepClone(baseValidBid); + + bid.params.publicationName = 123; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.publicationName = false; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.publicationName = void (0); + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.publicationName = {}; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when publicationName is formatted poorly', () => { + const bid = deepClone(baseValidBid); + + bid.params.publicationName = 'bb.'; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.publicationName = 'bb-test'; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.publicationName = '?'; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when renderer is not specified', () => { + const bid = deepClone(baseValidBid); + + delete bid.params.rendererCode; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when renderer is not a string', () => { + const bid = deepClone(baseValidBid); + + bid.params.rendererCode = 123; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.rendererCode = false; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.rendererCode = void (0); + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.rendererCode = {}; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when renderer is formatted poorly', () => { + const bid = deepClone(baseValidBid); + + bid.params.rendererCode = 'bb.'; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.rendererCode = 'bb-test'; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.rendererCode = '?'; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when accountId is not specified', () => { + const bid = deepClone(baseValidBid); + + delete bid.params.accountId; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when connections is not specified', () => { + const bid = deepClone(baseValidBid); + + delete bid.params.connections; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when connections is not an array', () => { + const bid = deepClone(baseValidBid); + + bid.params.connections = 123; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.connections = false; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.connections = void (0); + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.connections = {}; + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.connections = 'string'; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when a connection is missing', () => { + const bid = deepClone(baseValidBid); + + bid.params.connections.push('potatoes'); + expect(spec.isBidRequestValid(bid)).to.equal(false); + + bid.params.connections.pop(); + + delete bid.params[BB_CONSTANTS.BIDDER_CODE]; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should fail if bid has no mediaTypes', () => { + const bid = deepClone(baseValidBid); + + delete bid.mediaTypes; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should fail if bid has no mediaTypes.video', () => { + const bid = deepClone(baseValidBid); + + delete bid.mediaTypes[VIDEO]; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should fail if bid has no mediaTypes.video.context', () => { + const bid = deepClone(baseValidBid); + + delete bid.mediaTypes[VIDEO].context; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should fail if mediaTypes.video.context is not "outstream"', () => { + const bid = deepClone(baseValidBid); + + bid.mediaTypes[VIDEO].context = 'instream'; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + }); + + describe('buildRequests', () => { + const publicationName = 'bbprebid.dev'; + const rendererCode = 'glorious_renderer'; + + const baseValidBid = { + bidder: BB_CONSTANTS.BIDDER_CODE, + params: { + accountId: 123, + publicationName: publicationName, + rendererCode: rendererCode, + connections: [ BB_CONSTANTS.BIDDER_CODE ], + bluebillywig: {} + }, + mediaTypes: { + video: { + context: 'outstream' + } + } + }; + + const baseValidBidRequests = [baseValidBid]; + + const validBidderRequest = { + auctionId: '12abc345-67d8-9012-e345-6f78901a2b34', + auctionStart: 1585918458868, + bidderCode: BB_CONSTANTS.BIDDER_CODE, + bidderRequestId: '1a2345b67c8d9e0', + bids: [{ + adUnitCode: 'ad-unit-test', + auctionId: '12abc345-67d8-9012-e345-6f78901a2b34', + bidId: '1234ab567c89de0', + bidRequestsCount: 1, + bidder: BB_CONSTANTS.BIDDER_CODE, + bidderRequestId: '1a2345b67c8d9e0', + params: baseValidBid.params, + sizes: [[768, 432], [640, 480], [630, 360]], + transactionId: '2b34c5de-f67a-8901-bcd2-34567efabc89' + }], + start: 11585918458869, + timeout: 3000 + }; + + it('sends bid request to AUCTION_URL via POST', () => { + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + expect(request.url).to.equal(`https://pbs.bluebillywig.com/openrtb2/auction?pub=${publicationName}`); + expect(request.method).to.equal('POST'); + }); + + it('sends data as a string', () => { + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + expect(request.data).to.be.a('string'); + }); + + it('sends all bid parameters', () => { + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + expect(request).to.have.all.keys(['bidderRequest', 'data', 'method', 'url']); + }); + + it('builds the base request properly', () => { + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload.id).to.equal(validBidderRequest.auctionId); + expect(payload.source).to.be.an('object'); + expect(payload.source.tid).to.equal(validBidderRequest.auctionId); + expect(payload.tmax).to.equal(BB_CONSTANTS.DEFAULT_TIMEOUT); + expect(payload.imp).to.be.an('array'); + expect(payload.test).to.be.a('number'); + expect(payload).to.have.nested.property('ext.prebid.targeting'); + expect(payload.ext.prebid.targeting).to.be.an('object'); + expect(payload.ext.prebid.targeting.includewinners).to.equal(true); + expect(payload.ext.prebid.targeting.includebidderkeys).to.equal(false); + }); + + it('adds an impression to the payload', () => { + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload.imp.length).to.equal(1); + }); + + it('adds connections to ext', () => { + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload.imp[0].ext).to.have.all.keys(['bluebillywig']); + }); + + it('adds gdpr when present', () => { + const newValidBidderRequest = deepClone(validBidderRequest); + newValidBidderRequest.gdprConsent = { + consentString: 'BOh7mtYOh7mtYAcABBENCU-AAAAncgPIXJiiAoao0PxBFkgCAC8ACIAAQAQQAAIAAAIAAAhBGAAAQAQAEQgAAAAAAABAAAAAAAAAAAAAAACAAAAAAAACgAAAAABAAAAQAAAAAAA', + gdprApplies: true + }; + + const request = spec.buildRequests(baseValidBidRequests, newValidBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.nested.property('regs.ext.gdpr'); + expect(payload.regs.ext.gdpr).to.be.a('number'); + expect(payload.regs.ext.gdpr).to.equal(1); + expect(payload).to.have.nested.property('user.ext.consent'); + expect(payload.user.ext.consent).to.equal(newValidBidderRequest.gdprConsent.consentString); + }); + + it('sets gdpr to 0 when explicitly gdprApplies: false', () => { + const newValidBidderRequest = deepClone(validBidderRequest); + newValidBidderRequest.gdprConsent = { + gdprApplies: false + }; + + const request = spec.buildRequests(baseValidBidRequests, newValidBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.nested.property('regs.ext.gdpr'); + expect(payload.regs.ext.gdpr).to.be.a('number'); + expect(payload.regs.ext.gdpr).to.equal(0); + }); + + it('adds usp_consent when present', () => { + const newValidBidderRequest = deepClone(validBidderRequest); + newValidBidderRequest.uspConsent = '1YYY'; + + const request = spec.buildRequests(baseValidBidRequests, newValidBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.nested.property('regs.ext.us_privacy'); + expect(payload.regs.ext.us_privacy).to.equal(newValidBidderRequest.uspConsent); + }); + + it('sets coppa to 1 when specified in config', () => { + config.setConfig({'coppa': true}); + + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.nested.property('regs.coppa'); + expect(payload.regs.coppa).to.equal(1); + + config.resetConfig(); + }); + + it('does not set coppa when disabled in the config', () => { + config.setConfig({'coppa': false}); + + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(deepAccess(payload, 'regs.coppa')).to.be.undefined; + + config.resetConfig(); + }); + + it('does not set coppa when not specified in config', () => { + config.resetConfig(); + + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(deepAccess(payload, 'regs.coppa')).to.be.undefined; + }); + + it('should add window size to request by default', () => { + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.nested.property('device.w'); + expect(payload).to.have.nested.property('device.h'); + expect(payload.device.w).to.be.a('number'); + expect(payload.device.h).to.be.a('number'); + }); + + it('should add app when specified in config', () => { + config.setConfig({ app: { bundle: 'org.prebid.mobile.demoapp', domain: 'prebid.org' } }); + + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.property('app'); + expect(payload).to.have.nested.property('app.bundle'); + expect(payload).to.have.nested.property('app.domain'); + expect(payload.app.bundle).to.equal('org.prebid.mobile.demoapp'); + expect(payload.app.domain).to.equal('prebid.org'); + + config.resetConfig(); + }); + + it('should add referrerInfo as site when no app is set', () => { + const newValidBidderRequest = deepClone(validBidderRequest); + + newValidBidderRequest.refererInfo = { referer: 'https://www.bluebillywig.com' }; + + const request = spec.buildRequests(baseValidBidRequests, newValidBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.nested.property('site.page'); + expect(payload.site.page).to.equal('https://www.bluebillywig.com'); + }); + + it('should not add referrerInfo as site when app is set', () => { + config.setConfig({ app: { bundle: 'org.prebid.mobile.demoapp', domain: 'prebid.org' } }); + + const newValidBidderRequest = deepClone(validBidderRequest); + newValidBidderRequest.refererInfo = { referer: 'https://www.bluebillywig.com' }; + + const request = spec.buildRequests(baseValidBidRequests, newValidBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload.site).to.be.undefined; + config.resetConfig(); + }); + + it('should add device size to request when specified in config', () => { + config.setConfig({ device: { w: 1, h: 1 } }); + + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.nested.property('device.w'); + expect(payload).to.have.nested.property('device.h'); + expect(payload.device.w).to.be.a('number'); + expect(payload.device.h).to.be.a('number'); + expect(payload.device.w).to.equal(1); + expect(payload.device.h).to.equal(1); + + config.resetConfig(); + }); + + it('should set schain on the request when set on config', () => { + const schain = { + validation: 'lax', + config: { + ver: '1.0', + complete: 1, + nodes: [ + { + asi: 'indirectseller.com', + sid: '00001', + hp: 1 + } + ] + } + }; + + const newBaseValidBidRequests = deepClone(baseValidBidRequests); + newBaseValidBidRequests[0].schain = schain; + + const request = spec.buildRequests(newBaseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.nested.property('source.ext.schain'); + expect(payload.source.ext.schain).to.deep.equal(schain); + }); + + it('should add currency when specified on the config', () => { + config.setConfig({ currency: { adServerCurrency: 'USD' } }); + + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.property('cur'); + expect(payload.cur).to.eql(['USD']); // NB not equal, eql to check for same array because [1] === [1] fails normally + + config.resetConfig(); + }); + + it('should also take in array for currency on the config', () => { + config.setConfig({ currency: { adServerCurrency: ['USD', 'PHP'] } }); + + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.property('cur'); + expect(payload.cur).to.eql(['USD']); // NB not equal, eql to check for same array because [1] === [1] fails normally + + config.resetConfig(); + }); + + it('should not set cur when currency is not specified on the config', () => { + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload.cur).to.be.undefined; + }); + + it('should set user ids when present', () => { + const userId = { tdid: 123 }; + + const newBaseValidBidRequests = deepClone(baseValidBidRequests); + newBaseValidBidRequests[0].userId = { criteoId: 'sample-userid' }; + + const request = spec.buildRequests(newBaseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.nested.property('user.ext.eids'); + expect(payload.user.ext.eids).to.be.an('array'); + expect(payload.user.ext.eids.length).to.equal(1); + }); + + it('should not set user ids when none present', () => { + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(deepAccess(payload, 'user.ext.eids')).to.be.undefined; + }); + + it('should set digitrust when present on bid', () => { + const digiTrust = {data: {id: 'DTID', keyv: 4, privacy: {optout: false}, producer: 'ABC', version: 2}}; + + const newBaseValidBidRequests = deepClone(baseValidBidRequests); + newBaseValidBidRequests[0].userId = { digitrustid: digiTrust }; + + const request = spec.buildRequests(newBaseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(payload).to.have.nested.property('user.ext.digitrust'); + expect(payload.user.ext.digitrust.id).to.equal(digiTrust.data.id); + expect(payload.user.ext.digitrust.keyv).to.equal(digiTrust.data.keyv); + }); + + it('should not set digitrust when opted out', () => { + const digiTrust = {data: {id: 'DTID', keyv: 4, privacy: {optout: true}, producer: 'ABC', version: 2}}; + + const newBaseValidBidRequests = deepClone(baseValidBidRequests); + newBaseValidBidRequests[0].userId = { digitrustid: digiTrust }; + + const request = spec.buildRequests(newBaseValidBidRequests, validBidderRequest); + const payload = JSON.parse(request.data); + + expect(deepAccess(payload, 'user.ext.digitrust')).to.be.undefined; + }); + }); + describe('interpretResponse', () => { + const publicationName = 'bbprebid.dev'; + const rendererCode = 'glorious_renderer'; + + const baseValidBid = { + bidder: BB_CONSTANTS.BIDDER_CODE, + params: { + accountId: 123, + publicationName: publicationName, + rendererCode: rendererCode, + connections: [ BB_CONSTANTS.BIDDER_CODE ], + bluebillywig: {} + }, + mediaTypes: { + video: { + context: 'outstream' + } + } + }; + + const baseValidBidRequests = [baseValidBid]; + + const validBidderRequest = { + auctionId: '12abc345-67d8-9012-e345-6f78901a2b34', + auctionStart: 1585918458868, + bidderCode: BB_CONSTANTS.BIDDER_CODE, + bidderRequestId: '1a2345b67c8d9e0', + bids: [{ + adUnitCode: 'ad-unit-test', + auctionId: '12abc345-67d8-9012-e345-6f78901a2b34', + bidId: '1234ab567c89de0', + bidRequestsCount: 1, + bidder: BB_CONSTANTS.BIDDER_CODE, + bidderRequestId: '1a2345b67c8d9e0', + params: baseValidBid.params, + sizes: [[768, 432], [640, 480], [630, 360]], + transactionId: '2b34c5de-f67a-8901-bcd2-34567efabc89' + }], + start: 11585918458869, + timeout: 3000 + }; + + const validResponse = { + id: 'a12abc345-67d8-9012-e345-6f78901a2b34', + seatbid: [ + { + bid: [ + { + id: '1', + impid: '1234ab567c89de0', + price: 1, + adm: '\r\nBB Adserver00:00:51', + adid: '67069817', + adomain: [ + 'bluebillywig.com' + ], + cid: '3535', + crid: '67069817', + w: 1, + h: 1, + publicationName: 'bbprebid', + accountId: 123, + ext: { + prebid: { + targeting: { + hb_bidder: 'bluebillywig', + hb_pb: '1.00', + hb_size: '1x1' + }, + type: 'video' + }, + bidder: { + prebid: { + targeting: { + hb_bidder: 'bluebillywig', + hb_pb: '10.00', + hb_size: '1x1' + }, + type: 'video', + video: { + duration: 51, + primary_category: '' + } + }, + bidder: { + bluebillywig: { + brand_id: 1, + auction_id: 1, + bid_ad_type: 1, + creative_info: { + video: { + duration: 51, + mimes: [ + 'video/x-flv', + 'video/mp4', + 'video/webm' + ] + } + } + } + } + } + } + } + ], + seat: 'bluebillywig' + } + ], + cur: 'USD', + ext: { + responsetimemillis: { + bluebillywig: 0 + }, + tmaxrequest: 5000 + } + }; + + const serverResponse = { body: validResponse }; + + it('should build bid array', () => { + const response = deepClone(serverResponse); + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const result = spec.interpretResponse(response, request); + + expect(result.length).to.equal(1); + }); + + it('should have all relevant fields', () => { + const response = deepClone(serverResponse); + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const result = spec.interpretResponse(response, request); + const bid = result[0]; + + // BB_HELPERS.transformRTBToPrebidProps + expect(bid.cpm).to.equal(serverResponse.body.seatbid[0].bid[0].price); + expect(bid.bidId).to.equal(serverResponse.body.seatbid[0].bid[0].impid); + expect(bid.requestId).to.equal(serverResponse.body.seatbid[0].bid[0].impid); + expect(bid.width).to.equal(serverResponse.body.seatbid[0].bid[0].w || BB_CONSTANTS.DEFAULT_WIDTH); + expect(bid.height).to.equal(serverResponse.body.seatbid[0].bid[0].h || BB_CONSTANTS.DEFAULT_HEIGHT); + expect(bid.ad).to.equal(serverResponse.body.seatbid[0].bid[0].adm); + expect(bid.netRevenue).to.equal(BB_CONSTANTS.DEFAULT_NET_REVENUE); + expect(bid.creativeId).to.equal(serverResponse.body.seatbid[0].bid[0].crid); + expect(bid.currency).to.equal(serverResponse.body.cur); + expect(bid.ttl).to.equal(BB_CONSTANTS.DEFAULT_TTL); + + expect(bid.publicationName).to.equal(validBidderRequest.bids[0].params.publicationName); + expect(bid.rendererCode).to.equal(validBidderRequest.bids[0].params.rendererCode); + expect(bid.accountId).to.equal(validBidderRequest.bids[0].params.accountId); + }); + + it('should not give anything when seatbid is an empty array', () => { + const seatbidEmptyArray = deepClone(serverResponse); + seatbidEmptyArray.body.seatbid = []; + + const response = seatbidEmptyArray; + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const result = spec.interpretResponse(response, request); + + expect(result.length).to.equal(0); + }); + + it('should not give anything when seatbid is missing', () => { + const seatbidMissing = deepClone(serverResponse); + delete seatbidMissing.body.seatbid; + + const response = seatbidMissing; + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const result = spec.interpretResponse(response, request); + + expect(result.length).to.equal(0); + }); + + const seatbidNotArrayResponse = deepClone(serverResponse); + it('should not give anything when seatbid is not an array', () => { + const invalidValues = [ false, null, {}, void (0), 123, 'string' ]; + + for (const invalidValue of invalidValues) { + seatbidNotArrayResponse.body.seatbid = invalidValue + const response = deepClone(seatbidNotArrayResponse); // interpretResponse is destructive + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const result = spec.interpretResponse(response, request); + + expect(result.length).to.equal(0); + } + }); + + it('should not give anything when seatbid.bid is an empty array', () => { + const seatbidBidEmpty = deepClone(serverResponse); + seatbidBidEmpty.body.seatbid[0].bid = []; + + const response = seatbidBidEmpty; + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const result = spec.interpretResponse(response, request); + + expect(result.length).to.equal(0); + }); + + it('should not give anything when seatbid.bid is missing', () => { + const seatbidBidMissing = deepClone(serverResponse); + delete seatbidBidMissing.body.seatbid[0].bid; + + const response = seatbidBidMissing; + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const result = spec.interpretResponse(response, request); + + expect(result.length).to.equal(0); + }); + + it('should not give anything when seatbid.bid is not an array', () => { + const seatbidBidNotArray = deepClone(serverResponse); + + const invalidValues = [ false, null, {}, void (0), 123, 'string' ]; + + for (const invalidValue of invalidValues) { + seatbidBidNotArray.body.seatbid[0].bid = invalidValue; + + const response = deepClone(seatbidBidNotArray); // interpretResponse is destructive + const request = spec.buildRequests(baseValidBidRequests, validBidderRequest); + const result = spec.interpretResponse(response, request); + + expect(result.length).to.equal(0); + } + }); + }); + describe('getUserSyncs', () => { + const publicationName = 'bbprebid.dev'; + const rendererCode = 'glorious_renderer'; + + const baseValidBid = { + bidder: BB_CONSTANTS.BIDDER_CODE, + params: { + accountId: 123, + publicationName: publicationName, + rendererCode: rendererCode, + connections: [ BB_CONSTANTS.BIDDER_CODE ], + bluebillywig: {} + }, + mediaTypes: { + video: { + context: 'outstream' + } + } + }; + + const validBidRequests = [baseValidBid]; + + const validBidderRequest = { + auctionId: '12abc345-67d8-9012-e345-6f78901a2b34', + auctionStart: 1585918458868, + bidderCode: BB_CONSTANTS.BIDDER_CODE, + bidderRequestId: '1a2345b67c8d9e0', + bids: [{ + adUnitCode: 'ad-unit-test', + auctionId: '12abc345-67d8-9012-e345-6f78901a2b34', + bidId: '1234ab567c89de0', + bidRequestsCount: 1, + bidder: BB_CONSTANTS.BIDDER_CODE, + bidderRequestId: '1a2345b67c8d9e0', + params: baseValidBid.params, + sizes: [[768, 432], [640, 480], [630, 360]], + transactionId: '2b34c5de-f67a-8901-bcd2-34567efabc89' + }], + start: 11585918458869, + timeout: 3000 + }; + const validResponse = { + id: 'a12abc345-67d8-9012-e345-6f78901a2b34', + seatbid: [ + { + bid: [ + { + id: '1', + impid: '1234ab567c89de0', + price: 1, + adm: '\r\nBB Adserver00:00:51', + adid: '67069817', + adomain: [ + 'bluebillywig.com' + ], + cid: '3535', + crid: '67069817', + w: 1, + h: 1, + publicationName: 'bbprebid', + accountId: 123, + ext: { + prebid: { + targeting: { + hb_bidder: 'bluebillywig', + hb_pb: '1.00', + hb_size: '1x1' + }, + type: 'video' + }, + bidder: { + prebid: { + targeting: { + hb_bidder: 'bluebillywig', + hb_pb: '10.00', + hb_size: '1x1' + }, + type: 'video', + video: { + duration: 51, + primary_category: '' + } + }, + bidder: { + bluebillywig: { + brand_id: 1, + auction_id: 1, + bid_ad_type: 1, + creative_info: { + video: { + duration: 51, + mimes: [ + 'video/x-flv', + 'video/mp4', + 'video/webm' + ] + } + } + } + } + } + } + } + ], + seat: 'bluebillywig' + } + ], + cur: 'USD', + ext: { + responsetimemillis: { + bluebillywig: 0 + }, + tmaxrequest: 5000 + } + }; + + const serverResponse = { body: validResponse }; + + const gdpr = { + consentString: 'BOh7mtYOh7mtYAcABBENCU-AAAAncgPIXJiiAoao0PxBFkgCAC8ACIAAQAQQAAIAAAIAAAhBGAAAQAQAEQgAAAAAAABAAAAAAAAA AAAAAACAAAAAAAACgAAAAABAAAAQAAAAAAA', + gdprApplies: true + }; + + it('should return empty if no server response', function () { + const result = spec.getUserSyncs({}, false, gdpr); + expect(result).to.be.empty; + }); + + it('should return empty if server response is empty', function () { + const result = spec.getUserSyncs({}, [], gdpr); + expect(result).to.be.empty; + }); + + it('should return empty if iframeEnabled is not true', () => { + const result = spec.getUserSyncs({iframeEnabled: false}, [serverResponse], gdpr); + expect(result).to.be.empty; + }); + + it('should append the various values if they exist', function() { + // push data to syncStore + spec.buildRequests(validBidRequests, validBidderRequest); + + const result = spec.getUserSyncs({iframeEnabled: true}, [serverResponse], gdpr); + + expect(result).to.not.be.empty; + + expect(result[0].url).to.include('gdpr=1'); + expect(result[0].url).to.include(gdpr.consentString); + expect(result[0].url).to.include('accountId=123'); + expect(result[0].url).to.include(`bidders=${btoa(JSON.stringify(validBidRequests[0].params.connections))}`); + expect(result[0].url).to.include('cb='); + }); + }); +});