diff --git a/modules/cwireBidAdapter.js b/modules/cwireBidAdapter.js index 46647b31c58..ac6ebbe7936 100644 --- a/modules/cwireBidAdapter.js +++ b/modules/cwireBidAdapter.js @@ -1,134 +1,34 @@ -import { registerBidder } from "../src/adapters/bidderFactory.js"; -import { getStorageManager } from "../src/storageManager.js"; -import { BANNER } from "../src/mediaTypes.js"; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { BANNER, VIDEO } from '../src/mediaTypes.js'; import { getParameterByName, isNumber, logError, logInfo, -} from "../src/utils.js"; -import { getBoundingClientRect } from "../libraries/boundingClientRect/boundingClientRect.js"; -import { hasPurpose1Consent } from "../src/utils/gdpr.js"; -import { sendBeacon } from "../src/ajax.js"; -import { isAutoplayEnabled } from "../libraries/autoplayDetection/autoplay.js"; +} from '../src/utils.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { getBoundingClientRect } from '../libraries/boundingClientRect/boundingClientRect.js'; +import { hasPurpose1Consent } from '../src/utils/gdpr.js'; +import { sendBeacon } from '../src/ajax.js'; +import { isAutoplayEnabled } from '../libraries/autoplayDetection/autoplay.js'; import { getAdUnitElement } from '../src/utils/adUnits.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 - */ +const BIDDER_CODE = 'cwire'; +const CWID_KEY = 'cw_cwid'; -// ------------------------------------ -const BIDDER_CODE = "cwire"; -const CWID_KEY = "cw_cwid"; - -export const BID_ENDPOINT = "https://prebid.cwi.re/v1/bid"; -export const EVENT_ENDPOINT = "https://prebid.cwi.re/v1/event"; +export const BID_ENDPOINT = 'https://ortb.cwi.re/v1/bid'; +export const EVENT_ENDPOINT = 'https://prebid.cwi.re/v1/event'; export const GVL_ID = 1081; export const storage = getStorageManager({ bidderCode: BIDDER_CODE }); -/** - * Retrieve dimensions and CSS max height/width from a given slot and attach the properties to the bidRequest. - * @param bid - * @returns {*&{cwExt: {dimensions: {width: number, height: number}, style: {maxWidth: number, maxHeight: number}}}} - */ -function slotDimensions(bid) { - const adUnitCode = bid.adUnitCode; - const slotEl = getAdUnitElement(bid); - - if (slotEl) { - logInfo(`Slot element found: ${adUnitCode}`); - const { width: slotW, height: slotH } = getBoundingClientRect(slotEl); - const cssMaxW = slotEl.style?.maxWidth; - const cssMaxH = slotEl.style?.maxHeight; - logInfo(`Slot dimensions (w/h): ${slotW} / ${slotH}`); - logInfo(`Slot Styles (maxW/maxH): ${cssMaxW} / ${cssMaxH}`); - - bid = { - ...bid, - cwExt: { - dimensions: { - width: slotW, - height: slotH, - }, - style: { - ...(cssMaxW && { - maxWidth: cssMaxW, - }), - ...(cssMaxH && { - maxHeight: cssMaxH, - }), - }, - }, - }; - } - return bid; -} - -/** - * Extracts feature flags from a comma-separated url parameter `cwfeatures`. - * - * @returns *[] - */ -function getFeatureFlags() { - const ffParam = getParameterByName("cwfeatures"); - if (ffParam) { - return ffParam.split(","); - } - return []; -} - -function getRefGroups() { - const groups = getParameterByName("cwgroups"); - if (groups) { - return groups.split(","); - } - return []; -} - -function getBidFloor(bid) { - if (typeof bid.getFloor !== "function") { - return {}; - } - - const floor = bid.getFloor({ - currency: "USD", - mediaType: "*", - size: "*", - }); - - return floor; -} - -/** - * Returns the downlink speed of the connection in Mbps or an empty string if not available. - */ -function getConnectionDownLink(nav) { - return nav && nav.connection && nav.connection.downlink >= 0 - ? nav.connection.downlink.toString() - : ""; -} - -/** - * Reads the CWID from local storage. - */ function getCwid() { return storage.localStorageIsEnabled() ? storage.getDataFromLocalStorage(CWID_KEY) : null; } -function hasCwid() { - return ( - storage.localStorageIsEnabled() && storage.getDataFromLocalStorage(CWID_KEY) - ); -} - -/** - * Store the CWID to local storage. - */ function updateCwid(cwid) { if (storage.localStorageIsEnabled()) { storage.setDataInLocalStorage(CWID_KEY, cwid); @@ -137,59 +37,110 @@ function updateCwid(cwid) { } } -/** - * Extract and collect cwire specific extensions. - */ -function getCwExtension() { - const cwId = getCwid(); - const cwCreative = getParameterByName("cwcreative"); - const cwGroups = getRefGroups(); - const cwFeatures = getFeatureFlags(); - // Enable debug flag by passing ?cwdebug=true as url parameter. - // Note: pbjs_debug=true enables it on prebid level - // More info: https://docs.prebid.org/troubleshooting/troubleshooting-guide.html#turn-on-prebidjs-debug-messages - const debug = getParameterByName("cwdebug"); +function getRefGroups() { + const groups = getParameterByName('cwgroups'); + return groups ? groups.split(',') : []; +} + +function getFeatureFlags() { + const ff = getParameterByName('cwfeatures'); + return ff ? ff.split(',') : []; +} +function getConnectionDownLink(nav) { + return nav?.connection?.downlink >= 0 ? nav.connection.downlink.toString() : ''; +} + +function getSlotSignals(bidRequest) { + const slotEl = getAdUnitElement(bidRequest); + if (!slotEl) return {}; + const { width, height } = getBoundingClientRect(slotEl); + const maxWidth = slotEl.style?.maxWidth; + const maxHeight = slotEl.style?.maxHeight; return { - ...(cwId && { - cwid: cwId, - }), - ...(cwGroups.length > 0 && { - refgroups: cwGroups, - }), - ...(cwFeatures.length > 0 && { - featureFlags: cwFeatures, - }), - ...(cwCreative && { - cwcreative: cwCreative, - }), - ...(debug && { - debug: true, - }), + dimensions: { width, height }, + style: { + ...(maxWidth && { maxWidth }), + ...(maxHeight && { maxHeight }), + }, }; } +const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: 360, + }, + imp(buildImp, bidRequest, context) { + const imp = buildImp(bidRequest, context); + + const bidderParams = {}; + if (bidRequest.params?.domainId != null) bidderParams.domainId = bidRequest.params.domainId; + if (bidRequest.params?.pageId != null) bidderParams.pageId = bidRequest.params.pageId; + if (bidRequest.params?.placementId != null) bidderParams.placementId = bidRequest.params.placementId; + if (Object.keys(bidderParams).length) { + imp.ext = imp.ext || {}; + imp.ext.bidder = { ...(imp.ext.bidder || {}), ...bidderParams }; + } + + const slotSignals = getSlotSignals(bidRequest); + const cwireExt = { + ...slotSignals, + autoplay: isAutoplayEnabled(), + }; + imp.ext = imp.ext || {}; + imp.ext.cwire = cwireExt; + + return imp; + }, + request(buildRequest, imps, bidderRequest, context) { + const request = buildRequest(imps, bidderRequest, context); + + const cwid = getCwid(); + const refgroups = getRefGroups(); + const featureFlags = getFeatureFlags(); + const cwcreative = getParameterByName('cwcreative'); + const debug = getParameterByName('cwdebug'); + + const cwExt = { + ...(cwid && { cwid }), + ...(refgroups.length && { refgroups }), + ...(featureFlags.length && { featureFlags }), + ...(cwcreative && { cwcreative }), + ...(debug && { debug: true }), + pageViewId: bidderRequest.pageViewId, + networkBandwidth: getConnectionDownLink(window.navigator), + sdk: { version: '$prebid.version$' }, + }; + + request.ext = request.ext || {}; + request.ext.cwire = cwExt; + return request; + }, + bidResponse(buildBidResponse, bid, context) { + if (!bid.mtype && context.bidRequest) { + const mt = context.bidRequest.mediaTypes; + if (mt?.video && !mt?.banner) context.mediaType = VIDEO; + else if (mt?.banner && !mt?.video) context.mediaType = BANNER; + } + return buildBidResponse(bid, context); + }, +}); + export const spec = { code: BIDDER_CODE, gvlid: GVL_ID, - supportedMediaTypes: [BANNER], + supportedMediaTypes: [BANNER, VIDEO], - /** - * Determines whether 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) { if (!bid.params?.domainId || !isNumber(bid.params.domainId)) { - logError("domainId not provided or not a number"); + logError('domainId not provided or not a number'); if (!bid.params?.placementId || !isNumber(bid.params.placementId)) { - logError("placementId not provided or not a number"); + logError('placementId not provided or not a number'); return false; } - if (!bid.params?.pageId || !isNumber(bid.params.pageId)) { - logError("pageId not provided or not a number"); + logError('pageId not provided or not a number'); return false; } return true; @@ -197,141 +148,68 @@ export const spec = { return true; }, - /** - * Make a server request from the list of BidRequests. - * - * @param {validBidRequests[]} validBidRequests An array of bids. - * @return ServerRequest Info describing the request to the server. - */ buildRequests: function (validBidRequests, bidderRequest) { - // There are more fields on the refererInfo object - const referrer = bidderRequest?.refererInfo?.page; - - // process bid requests - const processed = validBidRequests - .map((bid) => slotDimensions(bid)) - .map((bid) => { - const bidFloor = getBidFloor(bid); - return { - ...bid, - params: { - ...bid.params, - floor: bidFloor, - }, - }; - }) - .map((bid) => { - const autoplayEnabled = isAutoplayEnabled(); - return { - ...bid, - params: { - ...bid.params, - autoplay: autoplayEnabled, - }, - }; - }) - // Flattens the pageId, domainId and placement Id for backwards compatibility. - .map((bid) => ({ - ...bid, - pageId: bid.params?.pageId, - domainId: bid.params?.domainId, - placementId: bid.params?.placementId, - })); - - const extensions = getCwExtension(); - const payload = { - slots: processed, - httpRef: referrer, - // TODO: Verify whether the auctionId and the usage of pageViewId make sense. - pageViewId: bidderRequest.pageViewId, - networkBandwidth: getConnectionDownLink(window.navigator), - sdk: { - version: "$prebid.version$", - }, - ...extensions, - }; - const payloadString = JSON.stringify(payload); + const data = converter.toORTB({ bidRequests: validBidRequests, bidderRequest }); return { - method: "POST", + method: 'POST', url: BID_ENDPOINT, - data: payloadString, + data, + bids: validBidRequests, }; }, - /** - * Unpack the response from the server into a list of bids. - * - * @param {ServerResponse} serverResponse A successful response from the server. - * @return {Bid[]} An array of bids which were nested inside the server. - */ - interpretResponse: function (serverResponse, bidRequest) { - if (!hasCwid()) { - const cwid = serverResponse.body?.cwid; - if (cwid) { - updateCwid(cwid); - } + + interpretResponse: function (serverResponse, request) { + if (!serverResponse?.body) return []; + + const cwid = serverResponse.body?.ext?.cwire?.cwid; + if (cwid && !getCwid()) { + updateCwid(cwid); } - // Rename `html` response property to `ad` as used by prebid. - const bids = serverResponse.body?.bids.map(({ html, ...rest }) => ({ - ...rest, - ad: html, - })); - return bids || []; + return converter.fromORTB({ + response: serverResponse.body, + request: request.data, + }).bids; }, onBidWon: function (bid) { - logInfo(`Bid won.`); - const event = { - type: "BID_WON", - payload: { - bid: bid, - }, - }; + logInfo('Bid won.'); + const event = { type: 'BID_WON', payload: { bid } }; sendBeacon(EVENT_ENDPOINT, JSON.stringify(event)); }, - onBidderError: function (error, bidderRequest) { + onBidderError: function ({ error, bidderRequest }) { logInfo(`Bidder error: ${error}`); - const event = { - type: "BID_ERROR", - payload: { - error: error, - bidderRequest: bidderRequest, - }, - }; + const event = { type: 'BID_ERROR', payload: { error, bidderRequest } }; sendBeacon(EVENT_ENDPOINT, JSON.stringify(event)); }, - getUserSyncs: function ( - syncOptions, - serverResponses, - gdprConsent, - uspConsent - ) { + getUserSyncs: function (syncOptions, serverResponses, gdprConsent, uspConsent) { logInfo( - "Collecting user-syncs: ", + 'Collecting user-syncs: ', JSON.stringify({ syncOptions, gdprConsent, uspConsent, serverResponses }) ); const syncs = []; if (hasPurpose1Consent(gdprConsent) && gdprConsent.consentString) { - logInfo("GDPR purpose 1 consent was given, adding user-syncs"); + logInfo('GDPR purpose 1 consent was given, adding user-syncs'); const type = syncOptions.pixelEnabled - ? "image" - : null ?? syncOptions.iframeEnabled - ? "iframe" + ? 'image' + : syncOptions.iframeEnabled + ? 'iframe' : null; if (type) { syncs.push({ - type: type, + type, url: `https://ib.adnxs.com/getuid?https://prebid.cwi.re/v1/cookiesync?xandrId=$UID&gdpr=${ gdprConsent.gdprApplies ? 1 : 0 }&gdpr_consent=${gdprConsent.consentString}`, }); } } - logInfo("Collected user-syncs: ", JSON.stringify({ syncs })); + logInfo('Collected user-syncs: ', JSON.stringify({ syncs })); return syncs; }, }; + registerBidder(spec); diff --git a/modules/cwireBidAdapter.md b/modules/cwireBidAdapter.md index 1d4f3c039c8..bbb7c8979f8 100644 --- a/modules/cwireBidAdapter.md +++ b/modules/cwireBidAdapter.md @@ -8,7 +8,9 @@ Maintainer: devs@cwire.com ## Description -Prebid.js Adapter for C-Wire. +Prebid.js Adapter for C-Wire. Uses native OpenRTB 2.x request/response handling via Prebid's `ortbConverter` library. Supports banner and video. + +Bid requests are POSTed as OpenRTB 2.x JSON to `https://prebid.cwi.re/v2/bid`. Bidder params and cwire-specific signals are carried under `imp[].ext.bidder` and `request.ext.cwire` / `imp[].ext.cwire`. ## Configuration @@ -24,13 +26,14 @@ Below, the list of C-WIRE params and where they can be set. | cwdebug | x | | boolean | NO | | cwfeatures | x | | string | NO | - ### adUnit configuration +#### Banner + ```javascript var adUnits = [ { - code: 'target_div_id', // REQUIRED + code: 'target_div_id', // REQUIRED bids: [{ bidder: 'cwire', mediaTypes: { @@ -45,7 +48,7 @@ var adUnits = [ }] } ]; -// old version for the compatibility +// legacy configuration (still supported) var adUnits = [ { code: 'target_div_id', // REQUIRED @@ -65,9 +68,39 @@ var adUnits = [ ]; ``` +#### Video + +```javascript +var adUnits = [ + { + code: 'video_target_div_id', // REQUIRED + mediaTypes: { + video: { + context: 'instream', + playerSize: [640, 480], + mimes: ['video/mp4'], + protocols: [2, 3, 5, 6], + startdelay: 0, + placement: 1, + playbackmethod: [2], + api: [2], + linearity: 1 + } + }, + bids: [{ + bidder: 'cwire', + params: { + domainId: 1422, // required - number + placementId: 2211521, // optional - number + } + }] + } +]; +``` + ### URL parameters -For debugging and testing purposes url parameters can be set. +For debugging and testing purposes URL parameters can be set. **Example:** diff --git a/test/spec/modules/cwireBidAdapter_spec.js b/test/spec/modules/cwireBidAdapter_spec.js index 6943618cf51..d329b316304 100644 --- a/test/spec/modules/cwireBidAdapter_spec.js +++ b/test/spec/modules/cwireBidAdapter_spec.js @@ -1,58 +1,80 @@ -import { expect } from "chai"; -import { newBidder } from "../../../src/adapters/bidderFactory.js"; -import { BID_ENDPOINT, spec, storage } from "../../../modules/cwireBidAdapter.js"; -import { deepClone, logInfo } from "../../../src/utils.js"; -import * as utils from "src/utils.js"; -import sinon, { stub } from "sinon"; -import { config } from "../../../src/config.js"; -import * as autoplayLib from "../../../libraries/autoplayDetection/autoplay.js"; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { newBidder } from '../../../src/adapters/bidderFactory.js'; +import { + BID_ENDPOINT, + EVENT_ENDPOINT, + spec, + storage, +} from '../../../modules/cwireBidAdapter.js'; +import { deepClone } from '../../../src/utils.js'; +import * as utils from 'src/utils.js'; +import * as ajaxLib from 'src/ajax.js'; +import * as autoplayLib from '../../../libraries/autoplayDetection/autoplay.js'; import * as adUnits from 'src/utils/adUnits'; +import { BANNER, VIDEO } from '../../../src/mediaTypes.js'; +import 'modules/priceFloors.js'; +import 'modules/currency.js'; -describe("C-WIRE bid adapter", () => { - config.setConfig({ debug: true }); - let sandbox; - const adapter = newBidder(spec); - const bidRequests = [ +function makeBannerBid(overrides = {}) { + return Object.assign( { - bidder: "cwire", - params: { - pageId: "4057", - placementId: "ad-slot-bla", - }, - adUnitCode: "adunit-code", - sizes: [ - [300, 250], - [300, 600], - ], - bidId: "30b31c1838de1e", - bidderRequestId: "22edbae2733bf6", - auctionId: "1d1a030790a475", - transactionId: "04f2659e-c005-4eb1-a57c-fa93145e3843", + bidder: 'cwire', + params: { domainId: 1422, placementId: 2211521 }, + adUnitCode: 'adunit-code', + mediaTypes: { banner: { sizes: [[300, 250], [300, 600]] } }, + bidId: '30b31c1838de1e', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475', + transactionId: '04f2659e-c005-4eb1-a57c-fa93145e3843', }, - ]; - const bidderRequest = { - pageViewId: "326dca71-9ca0-4e8f-9e4d-6106161ac1ad" - } - const response = { - body: { - cwid: "2ef90743-7936-4a82-8acf-e73382a64e94", - hash: "17112D98BBF55D3A", - bids: [ - { - html: "

Hello world

", - cpm: 100, - currency: "CHF", - dimensions: [1, 1], - netRevenue: true, - creativeId: "3454", - requestId: "2c634d4ca5ccfb", - placementId: 177, - transactionId: "b4b32618-1350-4828-b6f0-fbb5c329e9a4", - ttl: 360, + overrides + ); +} + +function makeVideoBid(overrides = {}) { + return Object.assign( + { + bidder: 'cwire', + params: { domainId: 1422 }, + adUnitCode: 'video-adunit', + mediaTypes: { + video: { + context: 'instream', + playerSize: [640, 480], + mimes: ['video/mp4'], + protocols: [2, 3, 5, 6], + api: [2], + linearity: 1, }, - ], + }, + bidId: 'video-bid-id', + bidderRequestId: 'video-request-id', + auctionId: 'video-auction-id', + transactionId: 'video-transaction-id', + }, + overrides + ); +} + +function makeBidderRequest(overrides = {}) { + return Object.assign( + { + bidderCode: 'cwire', + auctionId: '1d1a030790a475', + bidderRequestId: '22edbae2733bf6', + pageViewId: '326dca71-9ca0-4e8f-9e4d-6106161ac1ad', + refererInfo: { page: 'https://example.com/article' }, + timeout: 1000, + ortb2: {}, }, - }; + overrides + ); +} + +describe('C-WIRE bid adapter (ORTB2)', () => { + const adapter = newBidder(spec); + let sandbox; beforeEach(function () { sandbox = sinon.createSandbox(); @@ -61,423 +83,419 @@ describe("C-WIRE bid adapter", () => { afterEach(function () { sandbox.restore(); }); - describe("inherited functions", function () { - it("exists and is a function", function () { - expect(adapter.callBids).to.exist.and.to.be.a("function"); - expect(spec.isBidRequestValid).to.exist.and.to.be.a("function"); - expect(spec.buildRequests).to.exist.and.to.be.a("function"); - expect(spec.interpretResponse).to.exist.and.to.be.a("function"); - }); - }); - describe("buildRequests", function () { - it("sends bid request to ENDPOINT via POST", function () { - const request = spec.buildRequests(bidRequests, bidderRequest); - expect(request.url).to.equal(BID_ENDPOINT); - expect(request.method).to.equal("POST"); - }); - }); - describe("buildRequests with given creative", function () { - let utilsStub; - beforeEach(function () { - utilsStub = stub(utils, "getParameterByName").callsFake(function () { - return "str-str"; - }); + describe('inherited functions', function () { + it('exposes the standard bidderFactory interface', function () { + expect(adapter.callBids).to.exist.and.to.be.a('function'); + expect(spec.isBidRequestValid).to.exist.and.to.be.a('function'); + expect(spec.buildRequests).to.exist.and.to.be.a('function'); + expect(spec.interpretResponse).to.exist.and.to.be.a('function'); }); - afterEach(function () { - utilsStub.restore(); + it('declares banner and video as supported media types', function () { + expect(spec.supportedMediaTypes).to.include.members([BANNER, VIDEO]); }); - it("should add creativeId if url parameter given", function () { - // set from bid.params - const bidRequest = deepClone(bidRequests[0]); + it('uses the v2 bid endpoint', function () { + expect(BID_ENDPOINT).to.equal('https://prebid.cwi.re/v2/bid'); + }); - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); - expect(payload.cwcreative).to.exist; - expect(payload.cwcreative).to.deep.equal("str-str"); + it('uses the v2 event endpoint', function () { + expect(EVENT_ENDPOINT).to.equal('https://prebid.cwi.re/v2/event'); }); }); - describe("buildRequests reads adUnit offsetWidth and offsetHeight", function () { - beforeEach(function () { - const documentStub = sandbox.stub(adUnits, "getAdUnitElement"); - documentStub.returns({ - offsetWidth: 200, - offsetHeight: 250, - getBoundingClientRect() { - return { width: 200, height: 250 }; - }, - }); + describe('isBidRequestValid', function () { + it('returns true when domainId is a number', function () { + expect(spec.isBidRequestValid(makeBannerBid({ params: { domainId: 42 } }))).to.equal(true); }); - it("width and height should be set", function () { - const bidRequest = deepClone(bidRequests[0]); - - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); - logInfo(JSON.stringify(payload)); - - expect(payload.slots[0].cwExt.dimensions.width).to.equal(200); - expect(payload.slots[0].cwExt.dimensions.height).to.equal(250); - expect(payload.slots[0].cwExt.style.maxHeight).to.not.exist; - expect(payload.slots[0].cwExt.style.maxWidth).to.not.exist; - }); - afterEach(function () { - sandbox.restore(); + it('returns true with legacy pageId+placementId when domainId is missing', function () { + expect( + spec.isBidRequestValid(makeBannerBid({ params: { pageId: 42, placementId: 99 } })) + ).to.equal(true); }); - }); - describe("buildRequests reads style attributes", function () { - beforeEach(function () { - const documentStub = sandbox.stub(adUnits, "getAdUnitElement"); - documentStub.returns({ - style: { - maxWidth: "400px", - maxHeight: "350px", - }, - getBoundingClientRect() { - return { width: 0, height: 0 }; - }, - }); - }); - it("css maxWidth should be set", function () { - const bidRequest = deepClone(bidRequests[0]); - - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); - logInfo(JSON.stringify(payload)); - expect(payload.slots[0].cwExt.style.maxWidth).to.eq("400px"); - expect(payload.slots[0].cwExt.style.maxHeight).to.eq("350px"); + it('returns false when params are absent', function () { + const bid = makeBannerBid(); + delete bid.params; + expect(spec.isBidRequestValid(bid)).to.equal(false); }); - afterEach(function () { - sandbox.restore(); + + it('returns false when domainId is missing and pageId+placementId incomplete', function () { + expect(spec.isBidRequestValid(makeBannerBid({ params: { pageId: 42 } }))).to.equal(false); }); }); - describe("buildRequests reads feature flags", function () { - beforeEach(function () { - sandbox.stub(utils, "getParameterByName").callsFake(function () { - return "feature1,feature2"; - }); + describe('buildRequests: envelope', function () { + it('POSTs a single ORTB2 request to /v2/bid', function () { + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + expect(req.method).to.equal('POST'); + expect(req.url).to.equal(BID_ENDPOINT); + expect(req.data).to.be.an('object'); + expect(req.data.imp).to.be.an('array').with.lengthOf(1); }); - it("read from url parameter", function () { - const bidRequest = deepClone(bidRequests[0]); - - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); - - logInfo(JSON.stringify(payload)); - - expect(payload.featureFlags).to.exist; - expect(payload.featureFlags).to.include.members(["feature1", "feature2"]); - }); - afterEach(function () { - sandbox.restore(); + it('passes bidderRequest.timeout through as tmax', function () { + const req = spec.buildRequests( + [makeBannerBid()], + makeBidderRequest({ timeout: 750 }) + ); + expect(req.data.tmax).to.equal(750); }); }); - describe("buildRequests reads cwgroups flag", function () { - beforeEach(function () { - sandbox.stub(utils, "getParameterByName").callsFake(function () { - return "group1,group2"; - }); + describe('buildRequests: imp shape', function () { + it('places bidder params under imp.ext.bidder', function () { + const bid = makeBannerBid({ params: { domainId: 1422, placementId: 2211521 } }); + const req = spec.buildRequests([bid], makeBidderRequest()); + const imp = req.data.imp[0]; + expect(imp.ext.bidder.domainId).to.equal(1422); + expect(imp.ext.bidder.placementId).to.equal(2211521); }); - it("read from url parameter", function () { - const bidRequest = deepClone(bidRequests[0]); - - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); - - logInfo(JSON.stringify(payload)); - - expect(payload.refgroups).to.exist; - expect(payload.refgroups).to.include.members(["group1", "group2"]); + it('forwards legacy pageId under imp.ext.bidder when present', function () { + const bid = makeBannerBid({ params: { pageId: 42, placementId: 99 } }); + const req = spec.buildRequests([bid], makeBidderRequest()); + const imp = req.data.imp[0]; + expect(imp.ext.bidder.pageId).to.equal(42); + expect(imp.ext.bidder.placementId).to.equal(99); }); - afterEach(function () { - sandbox.restore(); - }); - }); - describe("buildRequests reads debug flag", function () { - beforeEach(function () { - sandbox.stub(utils, "getParameterByName").callsFake(function () { - return "true"; - }); + it('emits imp.banner when adunit is banner-only', function () { + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + const imp = req.data.imp[0]; + expect(imp.banner).to.exist; + expect(imp.video).to.not.exist; }); - it("read from url parameter", function () { - const bidRequest = deepClone(bidRequests[0]); - - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); + if (FEATURES.VIDEO) { + it('emits imp.video when adunit is video-only', function () { + const req = spec.buildRequests([makeVideoBid()], makeBidderRequest()); + const imp = req.data.imp[0]; + expect(imp.video).to.exist; + expect(imp.banner).to.not.exist; + }); - logInfo(JSON.stringify(payload)); + it('emits both imp.banner and imp.video for a multi-format adunit', function () { + const bid = makeBannerBid({ + mediaTypes: { + banner: { sizes: [[300, 250]] }, + video: { context: 'instream', playerSize: [640, 480], mimes: ['video/mp4'] }, + }, + }); + const req = spec.buildRequests([bid], makeBidderRequest()); + const imp = req.data.imp[0]; + expect(imp.banner).to.exist; + expect(imp.video).to.exist; + }); + } - expect(payload.debug).to.exist; - expect(payload.debug).to.equal(true); - }); - afterEach(function () { - sandbox.restore(); + it('populates imp.bidfloor from getFloor when priceFloors module active', function () { + const bid = makeBannerBid({ + getFloor: () => ({ currency: 'USD', floor: 1.23 }), + }); + const req = spec.buildRequests([bid], makeBidderRequest()); + const imp = req.data.imp[0]; + expect(imp.bidfloor).to.equal(1.23); + expect(imp.bidfloorcur).to.equal('USD'); }); }); - describe("buildRequests reads cw_id from Localstorage", function () { - before(function () { - sandbox.stub(storage, "localStorageIsEnabled").callsFake(() => true); - sandbox.stub(storage, "setDataInLocalStorage"); - sandbox - .stub(storage, "getDataFromLocalStorage") - .callsFake((key) => "taerfagerg"); - }); - - it("cw_id is set", function () { - const bidRequest = deepClone(bidRequests[0]); - - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); - - logInfo(JSON.stringify(payload)); - - expect(payload.cwid).to.exist; - expect(payload.cwid).to.equal("taerfagerg"); - }); - afterEach(function () { - sandbox.restore(); + describe('buildRequests: imp.ext.cwire (slot signals)', function () { + it('captures slot dimensions when the slot element has bounds', function () { + sandbox.stub(adUnits, 'getAdUnitElement').returns({ + getBoundingClientRect() { + return { width: 200, height: 250 }; + }, + style: {}, + }); + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + const ext = req.data.imp[0].ext.cwire; + expect(ext.dimensions).to.deep.equal({ width: 200, height: 250 }); }); - }); - describe("buildRequests maps flattens params for legacy compat", function () { - beforeEach(function () { - const documentStub = sandbox.stub(document, "getElementById"); - documentStub.withArgs(`${bidRequests[0].adUnitCode}`).returns({ + it('captures maxWidth/maxHeight when slot style sets them', function () { + sandbox.stub(adUnits, 'getAdUnitElement').returns({ getBoundingClientRect() { return { width: 0, height: 0 }; }, + style: { maxWidth: '400px', maxHeight: '350px' }, }); + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + const ext = req.data.imp[0].ext.cwire; + expect(ext.style.maxWidth).to.equal('400px'); + expect(ext.style.maxHeight).to.equal('350px'); }); - it("pageId flattened", function () { - const bidRequest = deepClone(bidRequests[0]); - - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); - - logInfo(JSON.stringify(payload)); - expect(payload.slots[0].pageId).to.exist; - }); - afterEach(function () { - sandbox.restore(); + it('writes autoplay flag from isAutoplayEnabled', function () { + sandbox.stub(autoplayLib, 'isAutoplayEnabled').returns(true); + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + expect(req.data.imp[0].ext.cwire.autoplay).to.equal(true); }); }); - describe("pageId and placementId are required params", function () { - it("invalid request", function () { - const bidRequest = deepClone(bidRequests[0]); - delete bidRequest.params; - - const valid = spec.isBidRequestValid(bidRequest); - expect(valid).to.be.false; + describe('buildRequests: request.ext.cwire (page-level signals)', function () { + it('writes pageViewId and sdk.version', function () { + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + const ext = req.data.ext.cwire; + expect(ext.pageViewId).to.equal('326dca71-9ca0-4e8f-9e4d-6106161ac1ad'); + expect(ext.sdk).to.have.property('version'); }); - it("valid request", function () { - const bidRequest = deepClone(bidRequests[0]); - bidRequest.params.pageId = 42; - bidRequest.params.placementId = 42; - - const valid = spec.isBidRequestValid(bidRequest); - expect(valid).to.be.true; + it('writes cwid from localStorage when present', function () { + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'getDataFromLocalStorage').returns('cwid-from-storage'); + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + expect(req.data.ext.cwire.cwid).to.equal('cwid-from-storage'); }); - it("cwcreative must be of type string", function () { - const bidRequest = deepClone(bidRequests[0]); - bidRequest.params.pageId = 42; - bidRequest.params.placementId = 42; - - const valid = spec.isBidRequestValid(bidRequest); - expect(valid).to.be.true; + it('omits cwid when localStorage is disabled', function () { + sandbox.stub(storage, 'localStorageIsEnabled').returns(false); + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + expect(req.data.ext.cwire.cwid).to.equal(undefined); }); - it("build request adds pageId", function () { - const bidRequest = deepClone(bidRequests[0]); - - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); - - expect(payload.slots[0].pageId).to.exist; + it('writes refgroups from cwgroups URL parameter', function () { + sandbox.stub(utils, 'getParameterByName').callsFake((name) => { + if (name === 'cwgroups') return 'g1,g2'; + return ''; + }); + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + expect(req.data.ext.cwire.refgroups).to.deep.equal(['g1', 'g2']); }); - }); - - describe("process serverResponse", function () { - it("html to ad mapping", function () { - const bidResponse = deepClone(response); - const bids = spec.interpretResponse(bidResponse, {}); - expect(bids[0].ad).to.exist; + it('writes featureFlags from cwfeatures URL parameter', function () { + sandbox.stub(utils, 'getParameterByName').callsFake((name) => { + if (name === 'cwfeatures') return 'f1,f2'; + return ''; + }); + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + expect(req.data.ext.cwire.featureFlags).to.deep.equal(['f1', 'f2']); }); - }); - describe("add user-syncs", function () { - it("empty user-syncs if no consent given", function () { - const userSyncs = spec.getUserSyncs({}, {}, {}, {}); + it('writes cwcreative from URL parameter', function () { + sandbox.stub(utils, 'getParameterByName').callsFake((name) => { + if (name === 'cwcreative') return '1234'; + return ''; + }); + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + expect(req.data.ext.cwire.cwcreative).to.equal('1234'); + }); - expect(userSyncs).to.be.empty; + it('writes debug=true when cwdebug URL parameter is set', function () { + sandbox.stub(utils, 'getParameterByName').callsFake((name) => { + if (name === 'cwdebug') return 'true'; + return ''; + }); + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + expect(req.data.ext.cwire.debug).to.equal(true); }); - it("empty user-syncs if no syncOption enabled", function () { - const gdprConsent = { - vendorData: { - purpose: { - consents: 1, - }, - }, - gdprApplies: false, - consentString: "testConsentString", - }; - const userSyncs = spec.getUserSyncs({}, {}, gdprConsent, {}); - expect(userSyncs).to.be.empty; + it('omits cwire ext fields that are empty', function () { + sandbox.stub(utils, 'getParameterByName').returns(''); + sandbox.stub(storage, 'localStorageIsEnabled').returns(false); + const req = spec.buildRequests([makeBannerBid()], makeBidderRequest()); + const ext = req.data.ext.cwire; + expect(ext.cwid).to.equal(undefined); + expect(ext.refgroups).to.equal(undefined); + expect(ext.featureFlags).to.equal(undefined); + expect(ext.cwcreative).to.equal(undefined); + expect(ext.debug).to.equal(undefined); }); + }); - it("user-syncs with enabled pixel option", function () { - const gdprConsent = { - vendorData: { - purpose: { - consents: 1, + describe('interpretResponse', function () { + function bannerOrtbResponse(impId) { + return { + seatbid: [ + { + bid: [ + { + id: 'seat-banner', + impid: impId, + price: 1.5, + adm: '

Hello world

', + crid: 'creative-banner', + cid: 'campaign-1', + w: 300, + h: 250, + mtype: 1, + adomain: ['example.com'], + }, + ], }, - }, - gdprApplies: false, - consentString: "testConsentString", + ], + cur: 'USD', }; - const synOptions = { pixelEnabled: true, iframeEnabled: true }; - const userSyncs = spec.getUserSyncs(synOptions, {}, gdprConsent, {}); - - expect(userSyncs[0].type).to.equal("image"); - expect(userSyncs[0].url).to.equal( - "https://ib.adnxs.com/getuid?https://prebid.cwi.re/v1/cookiesync?xandrId=$UID&gdpr=0&gdpr_consent=testConsentString" - ); - }); - - it("user-syncs with enabled iframe option", function () { - const gdprConsent = { - vendorData: { - purpose: { - consents: { - 1: true, - }, + } + + function videoOrtbResponse(impId, vast = '') { + return { + seatbid: [ + { + bid: [ + { + id: 'seat-video', + impid: impId, + price: 5.0, + adm: vast, + crid: 'creative-video', + cid: 'campaign-2', + w: 640, + h: 480, + mtype: 2, + adomain: ['example.com'], + }, + ], }, - }, - gdprApplies: true, - consentString: "abc123", + ], + cur: 'USD', }; - const synOptions = { iframeEnabled: true }; - const userSyncs = spec.getUserSyncs(synOptions, {}, gdprConsent, {}); + } + + it('maps a banner ORTB response to a Prebid banner bid', function () { + const bid = makeBannerBid(); + const req = spec.buildRequests([bid], makeBidderRequest()); + const bids = spec.interpretResponse({ body: bannerOrtbResponse(req.data.imp[0].id) }, req); + expect(bids).to.have.lengthOf(1); + expect(bids[0].mediaType).to.equal(BANNER); + expect(bids[0].ad).to.equal('

Hello world

'); + expect(bids[0].cpm).to.equal(1.5); + }); + + if (FEATURES.VIDEO) { + it('maps a video ORTB response (mtype=2) to a video bid with vastXml', function () { + const bid = makeVideoBid(); + const req = spec.buildRequests([bid], makeBidderRequest()); + const vast = ''; + const bids = spec.interpretResponse( + { body: videoOrtbResponse(req.data.imp[0].id, vast) }, + req + ); + expect(bids).to.have.lengthOf(1); + expect(bids[0].mediaType).to.equal(VIDEO); + expect(bids[0].vastXml).to.equal(vast); + }); - expect(userSyncs[0].type).to.equal("iframe"); - expect(userSyncs[0].url).to.equal( - "https://ib.adnxs.com/getuid?https://prebid.cwi.re/v1/cookiesync?xandrId=$UID&gdpr=1&gdpr_consent=abc123" - ); - }); - }); + it('falls back to mediaType from the request when mtype is missing (video-only adunit)', function () { + const bid = makeVideoBid(); + const req = spec.buildRequests([bid], makeBidderRequest()); + const vast = ''; + const noMtypeResponse = videoOrtbResponse(req.data.imp[0].id, vast); + delete noMtypeResponse.seatbid[0].bid[0].mtype; + const bids = spec.interpretResponse({ body: noMtypeResponse }, req); + expect(bids).to.have.lengthOf(1); + expect(bids[0].mediaType).to.equal(VIDEO); + }); + } - describe("buildRequests includes autoplay", function () { - afterEach(function () { - sandbox.restore(); + it('returns [] when seatbid is empty', function () { + const bid = makeBannerBid(); + const req = spec.buildRequests([bid], makeBidderRequest()); + const bids = spec.interpretResponse({ body: { seatbid: [], cur: 'USD' } }, req); + expect(bids).to.deep.equal([]); }); - it("should include autoplay: true when autoplay is enabled", function () { - sandbox.stub(autoplayLib, "isAutoplayEnabled").returns(true); + it('persists cwid from response.ext.cwire.cwid into localStorage when not already stored', function () { + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'getDataFromLocalStorage').returns(null); + const setStub = sandbox.stub(storage, 'setDataInLocalStorage'); - const bidRequest = deepClone(bidRequests[0]); - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); + const bid = makeBannerBid(); + const req = spec.buildRequests([bid], makeBidderRequest()); + const body = bannerOrtbResponse(req.data.imp[0].id); + body.ext = { cwire: { cwid: 'new-cwid-from-server' } }; + spec.interpretResponse({ body }, req); - expect(payload.slots[0].params.autoplay).to.equal(true); + expect(setStub.calledWith('cw_cwid', 'new-cwid-from-server')).to.equal(true); }); - it("should include autoplay: false when autoplay is disabled", function () { - sandbox.stub(autoplayLib, "isAutoplayEnabled").returns(false); + it('does not overwrite an existing cwid in localStorage', function () { + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'getDataFromLocalStorage').returns('existing-cwid'); + const setStub = sandbox.stub(storage, 'setDataInLocalStorage'); - const bidRequest = deepClone(bidRequests[0]); - const request = spec.buildRequests([bidRequest], bidderRequest); - const payload = JSON.parse(request.data); + const bid = makeBannerBid(); + const req = spec.buildRequests([bid], makeBidderRequest()); + const body = bannerOrtbResponse(req.data.imp[0].id); + body.ext = { cwire: { cwid: 'new-cwid-from-server' } }; + spec.interpretResponse({ body }, req); - expect(payload.slots[0].params.autoplay).to.equal(false); + expect(setStub.called).to.equal(false); }); }); - describe("buildRequests with floor", function () { - it("should include floor in params when getFloor is defined", function () { - const bid = { - bidId: "123", - adUnitCode: "test-div", - mediaTypes: { - banner: { - sizes: [[300, 250]], - }, - }, - params: { - pageId: 4057, - placementId: "abc123", - }, - getFloor: function ({ currency, mediaType, size }) { - expect(currency).to.equal("USD"); - expect(mediaType).to.equal("*"); - expect(size).to.equal("*"); - return { - currency: "USD", - floor: 1.23, - }; - }, - }; - - const bidderRequest = { - refererInfo: { - page: "https://example.com", - }, - }; - - const request = spec.buildRequests([bid], bidderRequest); - - const payload = JSON.parse(request.data); - const slot = payload.slots[0]; + describe('event beacons', function () { + it('onBidWon POSTs a BID_WON beacon to the v2 event endpoint', function () { + const sendBeacon = sandbox.stub(ajaxLib, 'sendBeacon'); + const wonBid = { adUnitCode: 'au', cpm: 1.5, requestId: 'r' }; + spec.onBidWon(wonBid); + expect(sendBeacon.calledOnce).to.equal(true); + const [url, payload] = sendBeacon.firstCall.args; + expect(url).to.equal(EVENT_ENDPOINT); + const event = JSON.parse(payload); + expect(event.type).to.equal('BID_WON'); + expect(event.payload.bid).to.deep.equal(wonBid); + }); + + it('onBidderError POSTs a BID_ERROR beacon to the v2 event endpoint', function () { + const sendBeacon = sandbox.stub(ajaxLib, 'sendBeacon'); + const error = { reason: 'boom' }; + const bidderRequest = { bidderCode: 'cwire' }; + spec.onBidderError({ error, bidderRequest }); + expect(sendBeacon.calledOnce).to.equal(true); + const [url, payload] = sendBeacon.firstCall.args; + expect(url).to.equal(EVENT_ENDPOINT); + const event = JSON.parse(payload); + expect(event.type).to.equal('BID_ERROR'); + expect(event.payload.error).to.deep.equal(error); + }); + }); - expect(slot.params).to.have.property("floor"); - expect(slot.params.floor).to.deep.equal({ - currency: "USD", - floor: 1.23, - }); + describe('getUserSyncs', function () { + it('returns no syncs when GDPR purpose-1 consent is missing', function () { + expect(spec.getUserSyncs({}, {}, {}, {})).to.be.empty; }); - it("should not include floor in params if getFloor is not defined", function () { - const bid = { - bidId: "456", - adUnitCode: "test-div", - mediaTypes: { - banner: { - sizes: [[300, 250]], - }, - }, - params: { - pageId: 4057, - placementId: "abc123", - }, - // no getFloor + it('returns no syncs when no syncOption is enabled', function () { + const gdprConsent = { + vendorData: { purpose: { consents: 1 } }, + gdprApplies: false, + consentString: 'testConsentString', }; + expect(spec.getUserSyncs({}, {}, gdprConsent, {})).to.be.empty; + }); - const bidderRequest = { - refererInfo: { - page: "https://example.com", - }, + it('returns a pixel sync when pixelEnabled and gdprApplies=false', function () { + const gdprConsent = { + vendorData: { purpose: { consents: 1 } }, + gdprApplies: false, + consentString: 'testConsentString', }; + const syncs = spec.getUserSyncs( + { pixelEnabled: true, iframeEnabled: true }, + {}, + gdprConsent, + {} + ); + expect(syncs[0].type).to.equal('image'); + expect(syncs[0].url).to.equal( + 'https://ib.adnxs.com/getuid?https://prebid.cwi.re/v1/cookiesync?xandrId=$UID&gdpr=0&gdpr_consent=testConsentString' + ); + }); - const request = spec.buildRequests([bid], bidderRequest); - const payload = JSON.parse(request.data); - const slot = payload.slots[0]; - - expect(slot.params.floor).to.deep.equal({}); + it('returns an iframe sync when only iframeEnabled and gdprApplies=true', function () { + const gdprConsent = { + vendorData: { purpose: { consents: { 1: true } } }, + gdprApplies: true, + consentString: 'abc123', + }; + const syncs = spec.getUserSyncs({ iframeEnabled: true }, {}, gdprConsent, {}); + expect(syncs[0].type).to.equal('iframe'); + expect(syncs[0].url).to.equal( + 'https://ib.adnxs.com/getuid?https://prebid.cwi.re/v1/cookiesync?xandrId=$UID&gdpr=1&gdpr_consent=abc123' + ); }); }); });