diff --git a/modules/33acrossBidAdapter.js b/modules/33acrossBidAdapter.js index 109d9387ce6..1c1af4d9828 100644 --- a/modules/33acrossBidAdapter.js +++ b/modules/33acrossBidAdapter.js @@ -1,6 +1,8 @@ -import { uniques } from 'src/utils'; +import * as utils from 'src/utils'; + const { registerBidder } = require('../src/adapters/bidderFactory'); const { config } = require('../src/config'); + const BIDDER_CODE = '33across'; const END_POINT = 'https://ssc.33across.com/api/v1/hb'; const SYNC_ENDPOINT = 'https://de.tynt.com/deb/v2?m=xch&rt=html'; @@ -28,6 +30,13 @@ function _createBidResponse(response) { function _createServerRequest(bidRequest, gdprConsent) { const ttxRequest = {}; const params = bidRequest.params; + const element = document.getElementById(bidRequest.adUnitCode); + const sizes = _transformSizes(bidRequest.sizes); + const minSize = _getMinSize(sizes); + + const contributeViewability = ViewabilityContributor( + _getPercentInView(element, window.top, minSize) + ); /* * Infer data for the request payload @@ -35,14 +44,14 @@ function _createServerRequest(bidRequest, gdprConsent) { ttxRequest.imp = []; ttxRequest.imp[0] = { banner: { - format: bidRequest.sizes.map(_getFormatSize) + format: sizes.map(size => Object.assign(size, {ext: {}})) }, ext: { ttx: { prod: params.productId } } - } + }; ttxRequest.site = { id: params.siteId }; // Go ahead send the bidId in request to 33exchange so it's kept track of in the bid response and @@ -54,19 +63,18 @@ function _createServerRequest(bidRequest, gdprConsent) { ext: { consent: gdprConsent.consentString } - } + }; ttxRequest.regs = { ext: { gdpr: (gdprConsent.gdprApplies === true) ? 1 : 0 } - } + }; // Finally, set the openRTB 'test' param if this is to be a test bid if (params.test === 1) { ttxRequest.test = 1; } - /* * Now construct the full server request */ @@ -82,7 +90,7 @@ function _createServerRequest(bidRequest, gdprConsent) { return { 'method': 'POST', 'url': url, - 'data': JSON.stringify(ttxRequest), + 'data': JSON.stringify(contributeViewability(ttxRequest)), 'options': options } } @@ -98,12 +106,111 @@ function _createSync(siteId) { } } -function _getFormatSize(sizeArr) { +function _getSize(size) { return { - w: sizeArr[0], - h: sizeArr[1], - ext: {} + w: parseInt(size[0], 10), + h: parseInt(size[1], 10) + } +} + +function _getMinSize(sizes) { + return sizes.reduce((min, size) => size.h * size.w < min.h * min.w ? size : min); +} + +function _getBoundingBox(element, { w, h } = {}) { + let { width, height, left, top, right, bottom } = element.getBoundingClientRect(); + + if ((width === 0 || height === 0) && w && h) { + width = w; + height = h; + right = left + w; + bottom = top + h; + } + + return { width, height, left, top, right, bottom }; +} + +function _transformSizes(sizes) { + if (utils.isArray(sizes) && sizes.length === 2 && !utils.isArray(sizes[0])) { + return [_getSize(sizes)]; + } + + return sizes.map(_getSize); +} + +function _getIntersectionOfRects(rects) { + const bbox = { + left: rects[0].left, + right: rects[0].right, + top: rects[0].top, + bottom: rects[0].bottom + }; + + for (let i = 1; i < rects.length; ++i) { + bbox.left = Math.max(bbox.left, rects[i].left); + bbox.right = Math.min(bbox.right, rects[i].right); + + if (bbox.left >= bbox.right) { + return null; + } + + bbox.top = Math.max(bbox.top, rects[i].top); + bbox.bottom = Math.min(bbox.bottom, rects[i].bottom); + + if (bbox.top >= bbox.bottom) { + return null; + } + } + + bbox.width = bbox.right - bbox.left; + bbox.height = bbox.bottom - bbox.top; + + return bbox; +} + +function _getPercentInView(element, topWin, { w, h } = {}) { + const elementBoundingBox = _getBoundingBox(element, { w, h }); + + // Obtain the intersection of the element and the viewport + const elementInViewBoundingBox = _getIntersectionOfRects([ { + left: 0, + top: 0, + right: topWin.innerWidth, + bottom: topWin.innerHeight + }, elementBoundingBox ]); + + let elementInViewArea, elementTotalArea; + + if (elementInViewBoundingBox !== null) { + // Some or all of the element is in view + elementInViewArea = elementInViewBoundingBox.width * elementInViewBoundingBox.height; + elementTotalArea = elementBoundingBox.width * elementBoundingBox.height; + + return ((elementInViewArea / elementTotalArea) * 100); + } + + // No overlap between element and the viewport; therefore, the element + // lies completely out of view + return 0; +} + +/** + * Viewability contribution to request.. + */ +function ViewabilityContributor(viewabilityAmount) { + function contributeViewability(ttxRequest) { + const req = Object.assign({}, ttxRequest); + const imp = req.imp = req.imp.map(impItem => Object.assign({}, impItem)); + const banner = imp[0].banner = Object.assign({}, imp[0].banner); + const ext = banner.ext = Object.assign({}, banner.ext); + const ttx = ext.ttx = Object.assign({}, ext.ttx); + + ttx.viewability = { amount: Math.round(viewabilityAmount) }; + + return req; } + + return contributeViewability; } function isBidRequestValid(bid) { @@ -123,9 +230,9 @@ function isBidRequestValid(bid) { // - the server, at this point, also doesn't need the consent string to handle gdpr compliance. So passing // value whether set or not, for the sake of future dev. function buildRequests(bidRequests, bidderRequest) { - const gdprConsent = Object.assign({ consentString: undefined, gdprApplies: false }, bidderRequest && bidderRequest.gdprConsent) + const gdprConsent = Object.assign({ consentString: undefined, gdprApplies: false }, bidderRequest && bidderRequest.gdprConsent); - adapterState.uniqueSiteIds = bidRequests.map(req => req.params.siteId).filter(uniques); + adapterState.uniqueSiteIds = bidRequests.map(req => req.params.siteId).filter(utils.uniques); return bidRequests.map((req) => { return _createServerRequest(req, gdprConsent); @@ -160,7 +267,7 @@ const spec = { buildRequests, interpretResponse, getUserSyncs -} +}; registerBidder(spec); diff --git a/test/spec/modules/33acrossBidAdapter_spec.js b/test/spec/modules/33acrossBidAdapter_spec.js index 2779209c1cf..f768e4ee39b 100644 --- a/test/spec/modules/33acrossBidAdapter_spec.js +++ b/test/spec/modules/33acrossBidAdapter_spec.js @@ -1,4 +1,3 @@ -const { userSync } = require('../../../src/userSync'); const { config } = require('../../../src/config'); const { expect } = require('chai'); @@ -16,8 +15,118 @@ describe('33acrossBidAdapter:', function () { const END_POINT = 'https://ssc.33across.com/api/v1/hb'; const SYNC_ENDPOINT = 'https://de.tynt.com/deb/v2?m=xch&rt=html'; + let element; + let sandbox; + let bidRequests; + + function TtxRequestBuilder() { + const ttxRequest = { + imp: [{ + banner: { + format: [ + { + w: 300, + h: 250, + ext: {} + }, + { + w: 728, + h: 90, + ext: {} + } + ], + ext: { + ttx: { + viewability: { + amount: 100 + } + } + } + }, + ext: { + ttx: { + prod: PRODUCT_ID + } + } + }], + site: { + id: SITE_ID + }, + id: 'b1', + user: { + ext: { + consent: undefined + } + }, + regs: { + ext: { + gdpr: 0 + } + } + }; + + this.withSizes = sizes => { + Object.assign(ttxRequest.imp[0].banner, { format: sizes }); + return this; + }; + + this.withViewabiliuty = viewability => { + Object.assign(ttxRequest.imp[0].banner, { + ext: { + ttx: { viewability } + } + }); + return this; + }; + + this.withGdprConsent = (consent, gdpr) => { + Object.assign(ttxRequest, { + user: { + ext: { consent } + } + }); + Object.assign(ttxRequest, { + regs: { + ext: { gdpr } + } + }); + return this; + }; + + this.build = () => ttxRequest; + } + + function ServerRequestBuilder() { + const serverRequest = { + 'method': 'POST', + 'url': END_POINT, + 'data': null, + 'options': { + 'contentType': 'text/plain', + 'withCredentials': true + } + }; + + this.withData = data => { + serverRequest['data'] = JSON.stringify(data); + return this; + }; + + this.withUrl = url => { + serverRequest['url'] = url; + return this; + }; + + this.withOptions = options => { + serverRequest['options'] = options; + return this; + }; + + this.build = () => serverRequest; + } + beforeEach(function() { - this.bidRequests = [ + bidRequests = [ { bidId: 'b1', bidder: '33across', @@ -29,21 +138,42 @@ describe('33acrossBidAdapter:', function () { adUnitCode: 'div-id', auctionId: 'r1', sizes: [ - [ 300, 250 ], - [ 728, 90 ] + [300, 250], + [728, 90] ], transactionId: 't1' } ]; - this.sandbox = sinon.sandbox.create(); + + sandbox = sinon.sandbox.create(); + sandbox.stub(document, 'getElementById').withArgs('div-id').returns(element); + + element = { + x: 0, + y: 0, + + width: 0, + height: 0, + + getBoundingClientRect: () => { + return { + width: element.width, + height: element.height, + + left: element.x, + top: element.y, + right: element.x + element.width, + bottom: element.y + element.height + }; + } + }; }); afterEach(function() { - this.sandbox.restore(); - delete this.bidRequests; + sandbox.restore(); }); - describe('isBidRequestValid:', function () { + describe('isBidRequestValid:', function() { it('returns true when valid bid request is sent', function() { const validBid = { bidder: BIDDER_CODE, @@ -51,7 +181,7 @@ describe('33acrossBidAdapter:', function () { siteId: SITE_ID, productId: PRODUCT_ID } - } + }; expect(isBidRequestValid(validBid)).to.be.true; }); @@ -64,19 +194,19 @@ describe('33acrossBidAdapter:', function () { productId: PRODUCT_ID, test: 1 } - } + }; expect(isBidRequestValid(validBid)).to.be.true; }); - it('returns false when bidder not set to "33across"', function () { + it('returns false when bidder not set to "33across"', function() { const invalidBid = { bidder: 'foo', params: { siteId: SITE_ID, productId: PRODUCT_ID } - } + }; expect(isBidRequestValid(invalidBid)).to.be.false; }); @@ -84,7 +214,7 @@ describe('33acrossBidAdapter:', function () { it('returns false when params not set', function() { const invalidBid = { bidder: 'foo' - } + }; expect(isBidRequestValid(invalidBid)).to.be.false; }); @@ -95,7 +225,7 @@ describe('33acrossBidAdapter:', function () { params: { productId: PRODUCT_ID } - } + }; expect(isBidRequestValid(invalidBid)).to.be.false; }); @@ -106,16 +236,80 @@ describe('33acrossBidAdapter:', function () { params: { siteId: SITE_ID } - } + }; expect(isBidRequestValid(invalidBid)).to.be.false; }); }); describe('buildRequests:', function() { + context('when element is fully in view', function() { + it('returns 100', function() { + const ttxRequest = new TtxRequestBuilder() + .withViewabiliuty({amount: 100}) + .build(); + const serverRequest = new ServerRequestBuilder() + .withData(ttxRequest) + .build(); + + Object.assign(element, { width: 600, height: 400 }); + + expect(buildRequests(bidRequests)).to.deep.equal([serverRequest]); + }); + }); + + context('when element is out of view', function() { + it('returns 0', function() { + const ttxRequest = new TtxRequestBuilder() + .withViewabiliuty({amount: 0}) + .build(); + const serverRequest = new ServerRequestBuilder() + .withData(ttxRequest) + .build(); + + Object.assign(element, { x: -300, y: 0, width: 207, height: 320 }); + + expect(buildRequests(bidRequests)).to.deep.equal([serverRequest]); + }); + }); + + context('when element is partially in view', function() { + it('returns percentage', function() { + const ttxRequest = new TtxRequestBuilder() + .withViewabiliuty({amount: 40}) + .build(); + const serverRequest = new ServerRequestBuilder() + .withData(ttxRequest) + .build(); + + Object.assign(element, { width: 100, height: 1500 }); + + expect(buildRequests(bidRequests)).to.deep.equal([serverRequest]); + }); + }); + + context('when width or height of the element is zero', function() { + it('try to use alternative values', function() { + const ttxRequest = new TtxRequestBuilder() + .withSizes([{ w: 800, h: 1200, ext: {} }]) + .withViewabiliuty({amount: 50}) + .build(); + const serverRequest = new ServerRequestBuilder() + .withData(ttxRequest) + .build(); + + Object.assign(element, { width: 0, height: 0 }); + bidRequests[0].sizes = [[800, 1200]]; + + expect(buildRequests(bidRequests)).to.deep.equal([serverRequest]); + }); + }); + context('when gdpr consent data exists', function() { + let bidderRequest; + beforeEach(function() { - this.bidderRequest = { + bidderRequest = { gdprConsent: { consentString: 'foobarMyPreference', gdprApplies: true @@ -124,249 +318,81 @@ describe('33acrossBidAdapter:', function () { }); it('returns corresponding server requests with gdpr consent data', function() { - const ttxRequest = { - imp: [ { - banner: { - format: [ - { - w: 300, - h: 250, - ext: {} - }, - { - w: 728, - h: 90, - ext: {} - } - ] - }, - ext: { - ttx: { - prod: PRODUCT_ID - } - } - } ], - site: { - id: SITE_ID - }, - id: 'b1', - user: { - ext: { - consent: 'foobarMyPreference' - } - }, - regs: { - ext: { - gdpr: 1 - } - } - }; - - const serverRequest = { - 'method': 'POST', - 'url': END_POINT, - 'data': JSON.stringify(ttxRequest), - 'options': { - 'contentType': 'text/plain', - 'withCredentials': true - } - } - const builtServerRequests = buildRequests(this.bidRequests, this.bidderRequest); - expect(builtServerRequests).to.deep.equal([ serverRequest ]); - expect(builtServerRequests.length).to.equal(1); + const ttxRequest = new TtxRequestBuilder() + .withGdprConsent('foobarMyPreference', 1) + .build(); + const serverRequest = new ServerRequestBuilder() + .withData(ttxRequest) + .build(); + const builtServerRequests = buildRequests(bidRequests, bidderRequest); + + expect(builtServerRequests).to.deep.equal([serverRequest]); }); it('returns corresponding test server requests with gdpr consent data', function() { - this.sandbox.stub(config, 'getConfig').callsFake(() => { + sandbox.stub(config, 'getConfig').callsFake(() => { return { 'url': 'https://foo.com/hb/' } }); - const ttxRequest = { - imp: [ { - banner: { - format: [ - { - w: 300, - h: 250, - ext: { } - }, - { - w: 728, - h: 90, - ext: { } - } - ] - }, - ext: { - ttx: { - prod: PRODUCT_ID - } - } - } ], - site: { - id: SITE_ID - }, - id: 'b1', - user: { - ext: { - consent: 'foobarMyPreference' - } - }, - regs: { - ext: { - gdpr: 1 - } - } - }; - const serverRequest = { - method: 'POST', - url: 'https://foo.com/hb/', - data: JSON.stringify(ttxRequest), - options: { - contentType: 'text/plain', - withCredentials: true - } - }; + const ttxRequest = new TtxRequestBuilder() + .withGdprConsent('foobarMyPreference', 1) + .build(); + const serverRequest = new ServerRequestBuilder() + .withData(ttxRequest) + .withUrl('https://foo.com/hb/') + .build(); + const builtServerRequests = buildRequests(bidRequests, bidderRequest); - const builtServerRequests = buildRequests(this.bidRequests, this.bidderRequest); - expect(builtServerRequests).to.deep.equal([ serverRequest ]); - expect(builtServerRequests.length).to.equal(1); + expect(builtServerRequests).to.deep.equal([serverRequest]); }); - - afterEach(function() { - delete this.bidderRequest; - }) }); context('when gdpr consent data does not exist', function() { + let bidderRequest; + beforeEach(function() { - this.bidderRequest = { } + bidderRequest = {}; }); it('returns corresponding server requests with default gdpr consent data', function() { - const ttxRequest = { - imp: [ { - banner: { - format: [ - { - w: 300, - h: 250, - ext: {} - }, - { - w: 728, - h: 90, - ext: {} - } - ] - }, - ext: { - ttx: { - prod: PRODUCT_ID - } - } - } ], - site: { - id: SITE_ID - }, - id: 'b1', - user: { - ext: { - consent: undefined - } - }, - regs: { - ext: { - gdpr: 0 - } - } - }; - - const serverRequest = { - 'method': 'POST', - 'url': END_POINT, - 'data': JSON.stringify(ttxRequest), - 'options': { - 'contentType': 'text/plain', - 'withCredentials': true - } - } - const builtServerRequests = buildRequests(this.bidRequests, this.bidderRequest); - expect(builtServerRequests).to.deep.equal([ serverRequest ]); - expect(builtServerRequests.length).to.equal(1); + const ttxRequest = new TtxRequestBuilder() + .build(); + const serverRequest = new ServerRequestBuilder() + .withData(ttxRequest) + .build(); + const builtServerRequests = buildRequests(bidRequests, bidderRequest); + + expect(builtServerRequests).to.deep.equal([serverRequest]); }); it('returns corresponding test server requests with default gdpr consent data', function() { - this.sandbox.stub(config, 'getConfig').callsFake(() => { + sandbox.stub(config, 'getConfig').callsFake(() => { return { 'url': 'https://foo.com/hb/' } }); - const ttxRequest = { - imp: [ { - banner: { - format: [ - { - w: 300, - h: 250, - ext: { } - }, - { - w: 728, - h: 90, - ext: { } - } - ] - }, - ext: { - ttx: { - prod: PRODUCT_ID - } - } - } ], - site: { - id: SITE_ID - }, - id: 'b1', - user: { - ext: { - consent: undefined - } - }, - regs: { - ext: { - gdpr: 0 - } - } - }; - const serverRequest = { - method: 'POST', - url: 'https://foo.com/hb/', - data: JSON.stringify(ttxRequest), - options: { - contentType: 'text/plain', - withCredentials: true - } - }; + const ttxRequest = new TtxRequestBuilder() + .build(); + const serverRequest = new ServerRequestBuilder() + .withData(ttxRequest) + .withUrl('https://foo.com/hb/') + .build(); + const builtServerRequests = buildRequests(bidRequests, bidderRequest); - const builtServerRequests = buildRequests(this.bidRequests, this.bidderRequest); - expect(builtServerRequests).to.deep.equal([ serverRequest ]); - expect(builtServerRequests.length).to.equal(1); + expect(builtServerRequests).to.deep.equal([serverRequest]); }); - - afterEach(function() { - delete this.bidderRequest; - }) }); }); describe('interpretResponse', function() { + let ttxRequest, serverRequest; + beforeEach(function() { - this.ttxRequest = { - imp: [ { + ttxRequest = { + imp: [{ banner: { format: [ { @@ -386,22 +412,21 @@ describe('33acrossBidAdapter:', function () { prod: PRODUCT_ID } } - } ], + }], site: { id: SITE_ID, page: 'http://test-url.com' }, id: 'b1' }; - this.serverRequest = { - method: 'POST', - url: '//staging-ssc.33across.com/api/v1/hb', - data: JSON.stringify(this.ttxRequest), - options: { + serverRequest = new ServerRequestBuilder() + .withUrl('//staging-ssc.33across.com/api/v1/hb') + .withData(ttxRequest) + .withOptions({ contentType: 'text/plain', withCredentials: false - } - }; + }) + .build(); }); context('when exactly one bid is returned', function() { @@ -412,18 +437,17 @@ describe('33acrossBidAdapter:', function () { id: 'b1', seatbid: [ { - bid: [ { + bid: [{ id: '1', adm: '

I am an ad

', crid: 1, h: 250, w: 300, price: 0.0938 - } ] + }] } ] }; - const bidResponse = { requestId: 'b1', bidderCode: BIDDER_CODE, @@ -435,9 +459,9 @@ describe('33acrossBidAdapter:', function () { creativeId: 1, currency: 'USD', netRevenue: true - } + }; - expect(interpretResponse({ body: serverResponse }, this.serverRequest)).to.deep.equal([ bidResponse ]); + expect(interpretResponse({ body: serverResponse }, serverRequest)).to.deep.equal([bidResponse]); }); }); @@ -450,7 +474,7 @@ describe('33acrossBidAdapter:', function () { seatbid: [] }; - expect(interpretResponse({ body: serverResponse }, this.serverRequest)).to.deep.equal([]); + expect(interpretResponse({ body: serverResponse }, serverRequest)).to.deep.equal([]); }); }); @@ -462,7 +486,7 @@ describe('33acrossBidAdapter:', function () { id: 'b1', seatbid: [ { - bid: [ { + bid: [{ id: '1', adm: '

I am an ad

', crid: 1, @@ -481,18 +505,17 @@ describe('33acrossBidAdapter:', function () { ] }, { - bid: [ { + bid: [{ id: '3', adm: '

I am an ad

', crid: 3, h: 250, w: 300, price: 0.0938 - } ] + }] } ] }; - const bidResponse = { requestId: 'b1', bidderCode: BIDDER_CODE, @@ -506,14 +529,16 @@ describe('33acrossBidAdapter:', function () { netRevenue: true }; - expect(interpretResponse({ body: serverResponse }, this.serverRequest)).to.deep.equal([ bidResponse ]); + expect(interpretResponse({ body: serverResponse }, serverRequest)).to.deep.equal([bidResponse]); }); }); }); describe('getUserSyncs', function() { + let syncs; + beforeEach(function() { - this.syncs = [ + syncs = [ { type: 'iframe', url: 'https://de.tynt.com/deb/v2?m=xch&rt=html&id=id1' @@ -523,7 +548,7 @@ describe('33acrossBidAdapter:', function () { url: 'https://de.tynt.com/deb/v2?m=xch&rt=html&id=id2' }, ]; - this.bidRequests = [ + bidRequests = [ { bidId: 'b1', bidder: '33across', @@ -535,7 +560,7 @@ describe('33acrossBidAdapter:', function () { adUnitCode: 'div-id', auctionId: 'r1', sizes: [ - [ 300, 250 ] + [300, 250] ], transactionId: 't1' }, @@ -550,7 +575,7 @@ describe('33acrossBidAdapter:', function () { adUnitCode: 'div-id', auctionId: 'r1', sizes: [ - [ 300, 250 ] + [300, 250] ], transactionId: 't2' } @@ -558,17 +583,21 @@ describe('33acrossBidAdapter:', function () { }); context('when gdpr does not apply', function() { + let gdprConsent; + beforeEach(function() { - this.gdprConsent = { + gdprConsent = { gdprApplies: false - } + }; }); context('when iframe is not enabled', function() { it('returns empty sync array', function() { const syncOptions = {}; - buildRequests(this.bidRequests); - expect(getUserSyncs(syncOptions, {}, this.gdprConsent)).to.deep.equal([]); + + buildRequests(bidRequests); + + expect(getUserSyncs(syncOptions, {}, gdprConsent)).to.deep.equal([]); }); }); @@ -577,9 +606,10 @@ describe('33acrossBidAdapter:', function () { const syncOptions = { iframeEnabled: true }; - buildRequests(this.bidRequests); - const syncs = getUserSyncs(syncOptions, {}, this.gdprConsent); - expect(syncs).to.deep.equal(this.syncs); + + buildRequests(bidRequests); + + expect(getUserSyncs(syncOptions, {}, gdprConsent)).to.deep.equal(syncs); }); }); }); @@ -588,7 +618,9 @@ describe('33acrossBidAdapter:', function () { context('when iframe is not enabled', function() { it('returns empty sync array', function() { const syncOptions = {}; - buildRequests(this.bidRequests); + + buildRequests(bidRequests); + expect(getUserSyncs(syncOptions)).to.deep.equal([]); }); }); @@ -598,9 +630,10 @@ describe('33acrossBidAdapter:', function () { const syncOptions = { iframeEnabled: true }; - buildRequests(this.bidRequests); - const syncs = getUserSyncs(syncOptions); - expect(syncs).to.deep.equal(this.syncs); + + buildRequests(bidRequests); + + expect(getUserSyncs(syncOptions)).to.deep.equal(syncs); }); }); }); @@ -610,8 +643,10 @@ describe('33acrossBidAdapter:', function () { const syncOptions = {}; const gdprConsent = { gdprApplies: true - } - buildRequests(this.bidRequests); + }; + + buildRequests(bidRequests); + expect(getUserSyncs(syncOptions, {}, gdprConsent)).to.deep.equal([]); }); })