diff --git a/modules/conceptxBidAdapter.js b/modules/conceptxBidAdapter.js index 67ebd88e4e..eef57e0aaa 100644 --- a/modules/conceptxBidAdapter.js +++ b/modules/conceptxBidAdapter.js @@ -1,81 +1,258 @@ import { registerBidder } from '../src/adapters/bidderFactory.js'; import { BANNER } from '../src/mediaTypes.js'; -// import { logError, logInfo, logWarn, parseUrl } from '../src/utils.js'; const BIDDER_CODE = 'conceptx'; -const ENDPOINT_URL = 'https://conceptx.cncpt-central.com/openrtb'; -// const LOG_PREFIX = 'ConceptX: '; +const ENDPOINT_URL = 'https://cxba-s2s.cncpt.dk/openrtb2/auction'; const GVLID = 1340; export const spec = { code: BIDDER_CODE, supportedMediaTypes: [BANNER], gvlid: GVLID, + isBidRequestValid: function (bid) { - return !!(bid.bidId && bid.params.site && bid.params.adunit); + return !!(bid.bidId && bid.params && bid.params.adunit); }, buildRequests: function (validBidRequests, bidderRequest) { - // logWarn(LOG_PREFIX + 'all native assets containing URL should be sent as placeholders with sendId(icon, image, clickUrl, displayUrl, privacyLink, privacyIcon)'); const requests = []; - let requestUrl = `${ENDPOINT_URL}` - if (bidderRequest && bidderRequest.gdprConsent && bidderRequest.gdprConsent.gdprApplies) { - requestUrl += '?gdpr_applies=' + bidderRequest.gdprConsent.gdprApplies; - requestUrl += '&consentString=' + bidderRequest.gdprConsent.consentString; - } - for (var i = 0; i < validBidRequests.length; i++) { - const requestParent = { adUnits: [], meta: {} }; - const bid = validBidRequests[i] - const { adUnitCode, auctionId, bidId, bidder, bidderRequestId, ortb2 } = bid - requestParent.meta = { adUnitCode, auctionId, bidId, bidder, bidderRequestId, ortb2 } - - const { site, adunit } = bid.params - const adUnit = { site, adunit, targetId: bid.bidId } - if (bid.mediaTypes && bid.mediaTypes.banner && bid.mediaTypes.banner.sizes) adUnit.dimensions = bid.mediaTypes.banner.sizes - requestParent.adUnits.push(adUnit); + + for (let i = 0; i < validBidRequests.length; i++) { + const bid = validBidRequests[i]; + const { + adUnitCode, + auctionId, + bidId, + bidder, + bidderRequestId, + ortb2 = {}, + } = bid; + const params = bid.params || {}; + + // PBS URL + GDPR query params + let url = ENDPOINT_URL; + const query = []; + + // Only add GDPR params when gdprApplies is explicitly 0 or 1 + if (bidderRequest && bidderRequest.gdprConsent) { + let gdprApplies = bidderRequest.gdprConsent.gdprApplies; + if (typeof gdprApplies === 'boolean') { + gdprApplies = gdprApplies ? 1 : 0; + } + if (gdprApplies === 0 || gdprApplies === 1) { + query.push('gdpr_applies=' + gdprApplies); + if (bidderRequest.gdprConsent.consentString) { + query.push( + 'gdpr_consent=' + + encodeURIComponent(bidderRequest.gdprConsent.consentString) + ); + } + } + } + + if (query.length) { + url += '?' + query.join('&'); + } + + // site + const page = + params.site || (ortb2.site && ortb2.site.page) || ''; + const domain = + params.domain || (ortb2.site && ortb2.site.domain) || page; + + const site = { + id: domain || page || adUnitCode, + domain: domain || '', + page: page || '', + }; + + // banner sizes from mediaTypes.banner.sizes + const formats = []; + if ( + bid.mediaTypes && + bid.mediaTypes.banner && + bid.mediaTypes.banner.sizes + ) { + let sizes = bid.mediaTypes.banner.sizes; + if (sizes.length && typeof sizes[0] === 'number') { + sizes = [sizes]; + } + for (let j = 0; j < sizes.length; j++) { + const size = sizes[j]; + if (size && size.length === 2) { + formats.push({ w: size[0], h: size[1] }); + } + } + } + + const banner = formats.length ? { format: formats } : {}; + + // currency & timeout + let currency = 'DKK'; + if ( + bidderRequest && + bidderRequest.currency && + bidderRequest.currency.adServerCurrency + ) { + currency = bidderRequest.currency.adServerCurrency; + } + + const tmax = (bidderRequest && bidderRequest.timeout) || 500; + + // device + const ua = + typeof navigator !== 'undefined' && navigator.userAgent + ? navigator.userAgent + : 'Mozilla/5.0'; + const device = { ua }; + + // build OpenRTB request for PBS with stored requests + const ortbRequest = { + id: auctionId || bidId, + site, + device, + cur: [currency], + tmax, + imp: [ + { + id: bidId, + banner, + ext: { + prebid: { + storedrequest: { + id: params.adunit, + }, + }, + }, + }, + ], + ext: { + prebid: { + storedrequest: { + id: 'cx_global', + }, + custommeta: { + adUnitCode, + auctionId, + bidId, + bidder, + bidderRequestId, + }, + }, + }, + }; + + // GDPR in body + if (bidderRequest && bidderRequest.gdprConsent) { + let gdprAppliesBody = bidderRequest.gdprConsent.gdprApplies; + if (typeof gdprAppliesBody === 'boolean') { + gdprAppliesBody = gdprAppliesBody ? 1 : 0; + } + + if (!ortbRequest.user) ortbRequest.user = {}; + if (!ortbRequest.user.ext) ortbRequest.user.ext = {}; + + if (bidderRequest.gdprConsent.consentString) { + ortbRequest.user.ext.consent = + bidderRequest.gdprConsent.consentString; + } + + if (!ortbRequest.regs) ortbRequest.regs = {}; + if (!ortbRequest.regs.ext) ortbRequest.regs.ext = {}; + + if (gdprAppliesBody === 0 || gdprAppliesBody === 1) { + ortbRequest.regs.ext.gdpr = gdprAppliesBody; + } + } + + // user IDs -> user.ext.eids + if (bid.userIdAsEids && bid.userIdAsEids.length) { + if (!ortbRequest.user) ortbRequest.user = {}; + if (!ortbRequest.user.ext) ortbRequest.user.ext = {}; + ortbRequest.user.ext.eids = bid.userIdAsEids; + } + requests.push({ method: 'POST', - url: requestUrl, + url, options: { - withCredentials: false, + withCredentials: true, }, - data: JSON.stringify(requestParent), + data: JSON.stringify(ortbRequest), }); } return requests; }, - interpretResponse: function (serverResponse, bidRequest) { - const bidResponses = []; - const bidResponsesFromServer = serverResponse.body.bidResponses; - if (Array.isArray(bidResponsesFromServer) && bidResponsesFromServer.length === 0) { - return bidResponses - } - const firstBid = bidResponsesFromServer[0] - if (!firstBid) { - return bidResponses + interpretResponse: function (serverResponse, request) { + const body = + serverResponse && serverResponse.body ? serverResponse.body : {}; + + // PBS OpenRTB: seatbid[].bid[] + if ( + !body.seatbid || + !Array.isArray(body.seatbid) || + body.seatbid.length === 0 + ) { + return []; } - const firstSeat = firstBid.ads[0] - if (!firstSeat) { - return bidResponses + + const currency = body.cur || 'DKK'; + const bids = []; + + // recover referrer (site.page) from original request + let referrer = ''; + try { + if (request && request.data) { + const originalReq = + typeof request.data === 'string' + ? JSON.parse(request.data) + : request.data; + if (originalReq && originalReq.site && originalReq.site.page) { + referrer = originalReq.site.page; + } + } + } catch (_) {} + + for (let i = 0; i < body.seatbid.length; i++) { + const seatbid = body.seatbid[i]; + if (!seatbid.bid || !Array.isArray(seatbid.bid)) continue; + + for (let j = 0; j < seatbid.bid.length; j++) { + const b = seatbid.bid[j]; + + if (!b || typeof b.price !== 'number' || !b.adm) continue; + + bids.push({ + requestId: b.impid || b.id, + cpm: b.price, + width: b.w, + height: b.h, + creativeId: b.crid || b.id || '', + dealId: b.dealid || b.dealId || undefined, + currency, + netRevenue: true, + ttl: 300, + referrer, + ad: b.adm, + }); + } } - const bidResponse = { - requestId: firstSeat.requestId, - cpm: firstSeat.cpm, - width: firstSeat.width, - height: firstSeat.height, - creativeId: firstSeat.creativeId, - dealId: firstSeat.dealId, - currency: firstSeat.currency, - netRevenue: true, - ttl: firstSeat.ttl, - referrer: firstSeat.referrer, - ad: firstSeat.html - }; - bidResponses.push(bidResponse); - return bidResponses; + + return bids; + }, + + /** + * Cookie sync for conceptx is handled by the enrichment script's runPbsCookieSync, + * which calls https://cxba-s2s.cncpt.dk/cookie_sync with bidders. The PBS returns + * bidder_status with usersync URLs, and the script runs iframe/image syncs. + * The adapter does not return sync URLs here since those come from the cookie_sync + * endpoint, not the auction response. + */ + getUserSyncs: function () { + return []; }, +}; -} registerBidder(spec); diff --git a/test/spec/modules/conceptxBidAdapter_spec.js b/test/spec/modules/conceptxBidAdapter_spec.js index 8e9bd2f8cc..0278004549 100644 --- a/test/spec/modules/conceptxBidAdapter_spec.js +++ b/test/spec/modules/conceptxBidAdapter_spec.js @@ -1,70 +1,75 @@ -// import or require modules necessary for the test, e.g.: -import { expect } from 'chai'; // may prefer 'assert' in place of 'expect' +import { expect } from 'chai'; import { spec } from 'modules/conceptxBidAdapter.js'; import { newBidder } from 'src/adapters/bidderFactory.js'; -// import { config } from 'src/config.js'; describe('conceptxBidAdapter', function () { - const URL = 'https://conceptx.cncpt-central.com/openrtb'; - - const ENDPOINT_URL = `${URL}`; - const ENDPOINT_URL_CONSENT = `${URL}?gdpr_applies=true&consentString=ihaveconsented`; + const ENDPOINT_URL = 'https://cxba-s2s.cncpt.dk/openrtb2/auction'; + const ENDPOINT_URL_CONSENT = + ENDPOINT_URL + '?gdpr_applies=1&gdpr_consent=ihaveconsented'; const adapter = newBidder(spec); const bidderRequests = [ { bidId: '123', bidder: 'conceptx', + adUnitCode: 'div-1', + auctionId: 'auc-1', params: { - site: 'example', - adunit: 'some-id-3' + site: 'example.com', + adunit: 'some-id-3', }, mediaTypes: { banner: { sizes: [[930, 180]], - } + }, }, - } - ] - - const singleBidRequest = { - bid: [ - { - bidId: '123', - } - ] - } + }, + ]; const serverResponse = { body: { - 'bidResponses': [ + id: 'resp-1', + cur: 'DKK', + seatbid: [ { - 'ads': [ + seat: 'conceptx', + bid: [ { - 'referrer': 'http://localhost/prebidpage_concept_bidder.html', - 'ttl': 360, - 'html': '

DUMMY

', - 'requestId': '214dfadd1f8826', - 'cpm': 46, - 'currency': 'DKK', - 'width': 930, - 'height': 180, - 'creativeId': 'FAKE-ID', - 'meta': { - 'mediaType': 'banner' - }, - 'netRevenue': true, - 'destinationUrls': { - 'destination': 'https://concept.dk' - } - } + id: 'bid-1', + impid: '123', + price: 46, + w: 930, + h: 180, + crid: 'FAKE-ID', + adm: '

DUMMY

', + }, ], - 'matchedAdCount': 1, - 'targetId': '214dfadd1f8826' - } - ] - } - } + }, + ], + }, + }; + + const requestPayload = { + data: JSON.stringify({ + id: 'auc-1', + site: { id: 'example.com', domain: 'example.com', page: 'example.com' }, + imp: [ + { + id: '123', + ext: { + prebid: { + storedrequest: { id: 'some-id-3' }, + }, + }, + }, + ], + ext: { + prebid: { + storedrequest: { id: 'cx_global' }, + }, + }, + }), + }; describe('inherited functions', function () { it('exists and is a function', function () { @@ -73,52 +78,105 @@ describe('conceptxBidAdapter', function () { }); describe('isBidRequestValid', function () { - it('should return true when required params found', function () { + it('should return true when bidId and params.adunit are present', function () { expect(spec.isBidRequestValid(bidderRequests[0])).to.equal(true); }); + + it('should return false when params.adunit is missing', function () { + expect( + spec.isBidRequestValid({ + bidId: '123', + bidder: 'conceptx', + params: { site: 'example' }, + }) + ).to.equal(false); + }); + + it('should return false when bidId is missing', function () { + expect( + spec.isBidRequestValid({ + bidder: 'conceptx', + params: { site: 'example', adunit: 'id-1' }, + }) + ).to.equal(false); + }); }); describe('buildRequests', function () { - it('Test requests', function () { - const request = spec.buildRequests(bidderRequests, {}); - expect(request.length).to.equal(1); - expect(request[0]).to.have.property('data'); - const bid = JSON.parse(request[0].data).adUnits[0] - expect(bid.site).to.equal('example'); - expect(bid.adunit).to.equal('some-id-3'); - expect(JSON.stringify(bid.dimensions)).to.equal(JSON.stringify([ - [930, 180]])); + it('should build OpenRTB request with stored requests', function () { + const requests = spec.buildRequests(bidderRequests, {}); + expect(requests).to.have.lengthOf(1); + expect(requests[0]).to.have.property('method', 'POST'); + expect(requests[0]).to.have.property('url', ENDPOINT_URL); + expect(requests[0]).to.have.property('data'); + + const payload = JSON.parse(requests[0].data); + expect(payload).to.have.property('site'); + expect(payload.site).to.have.property('id', 'example.com'); + expect(payload).to.have.property('imp'); + expect(payload.imp).to.have.lengthOf(1); + expect(payload.imp[0].ext.prebid.storedrequest).to.deep.equal({ + id: 'some-id-3', + }); + expect(payload.ext.prebid.storedrequest).to.deep.equal({ + id: 'cx_global', + }); + expect(payload.imp[0].banner.format).to.deep.equal([{ w: 930, h: 180 }]); + }); + + it('should include withCredentials in options', function () { + const requests = spec.buildRequests(bidderRequests, {}); + expect(requests[0].options).to.deep.include({ withCredentials: true }); }); }); describe('user privacy', function () { - it('should NOT send GDPR Consent data if gdprApplies equals undefined', function () { - const request = spec.buildRequests(bidderRequests, { gdprConsent: { gdprApplies: undefined, consentString: 'iDoNotConsent' } }); - expect(request.length).to.equal(1); - expect(request[0]).to.have.property('url') - expect(request[0].url).to.equal(ENDPOINT_URL); + it('should NOT add GDPR params to URL when gdprApplies is undefined', function () { + const requests = spec.buildRequests(bidderRequests, { + gdprConsent: { gdprApplies: undefined, consentString: 'iDoNotConsent' }, + }); + expect(requests[0].url).to.equal(ENDPOINT_URL); }); - it('should send GDPR Consent data if gdprApplies', function () { - const request = spec.buildRequests(bidderRequests, { gdprConsent: { gdprApplies: true, consentString: 'ihaveconsented' } }); - expect(request.length).to.equal(1); - expect(request[0]).to.have.property('url') - expect(request[0].url).to.equal(ENDPOINT_URL_CONSENT); + + it('should add gdpr_applies and gdpr_consent to URL when GDPR applies', function () { + const requests = spec.buildRequests(bidderRequests, { + gdprConsent: { gdprApplies: true, consentString: 'ihaveconsented' }, + }); + expect(requests[0].url).to.include('gdpr_applies=1'); + expect(requests[0].url).to.include('gdpr_consent=ihaveconsented'); }); }); describe('interpretResponse', function () { - it('should return valid response when passed valid server response', function () { - const interpretedResponse = spec.interpretResponse(serverResponse, singleBidRequest); - const ad = serverResponse.body.bidResponses[0].ads[0] - expect(interpretedResponse).to.have.lengthOf(1); - expect(interpretedResponse[0].cpm).to.equal(ad.cpm); - expect(interpretedResponse[0].width).to.equal(Number(ad.width)); - expect(interpretedResponse[0].height).to.equal(Number(ad.height)); - expect(interpretedResponse[0].creativeId).to.equal(ad.creativeId); - expect(interpretedResponse[0].currency).to.equal(ad.currency); - expect(interpretedResponse[0].netRevenue).to.equal(true); - expect(interpretedResponse[0].ad).to.equal(ad.html); - expect(interpretedResponse[0].ttl).to.equal(360); + it('should return valid bids from PBS seatbid format', function () { + const interpreted = spec.interpretResponse(serverResponse, requestPayload); + expect(interpreted).to.have.lengthOf(1); + expect(interpreted[0].requestId).to.equal('123'); + expect(interpreted[0].cpm).to.equal(46); + expect(interpreted[0].width).to.equal(930); + expect(interpreted[0].height).to.equal(180); + expect(interpreted[0].creativeId).to.equal('FAKE-ID'); + expect(interpreted[0].currency).to.equal('DKK'); + expect(interpreted[0].netRevenue).to.equal(true); + expect(interpreted[0].ad).to.equal('

DUMMY

'); + expect(interpreted[0].ttl).to.equal(300); + }); + + it('should return empty array when no seatbid', function () { + const emptyResponse = { body: { seatbid: [] } }; + expect(spec.interpretResponse(emptyResponse, {})).to.deep.equal([]); + }); + + it('should return empty array when seatbid is missing', function () { + const noSeatbid = { body: {} }; + expect(spec.interpretResponse(noSeatbid, {})).to.deep.equal([]); + }); + }); + + describe('getUserSyncs', function () { + it('should return empty array (sync handled by runPbsCookieSync)', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true, pixelEnabled: true }, [], {}, '', {}); + expect(syncs).to.deep.equal([]); }); }); });