diff --git a/modules/stroeerCoreBidAdapter.js b/modules/stroeerCoreBidAdapter.js new file mode 100644 index 00000000000..ec442f5125a --- /dev/null +++ b/modules/stroeerCoreBidAdapter.js @@ -0,0 +1,196 @@ +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import {BANNER} from '../src/mediaTypes.js'; +import * as utils from '../src/utils.js'; + +const GVL_ID = 136; +const BIDDER_CODE = 'stroeerCore'; +const DEFAULT_HOST = 'hb.adscale.de'; +const DEFAULT_PATH = '/dsh'; +const DEFAULT_PORT = ''; +const FIVE_MINUTES_IN_SECONDS = 300; +const USER_SYNC_IFRAME_URL = 'https://js.adscale.de/pbsync.html'; + +const isSecureWindow = () => utils.getWindowSelf().location.protocol === 'https:'; +const isMainPageAccessible = () => getMostAccessibleTopWindow() === utils.getWindowTop(); + +function getTopWindowReferrer() { + try { + return utils.getWindowTop().document.referrer; + } catch (e) { + return utils.getWindowSelf().referrer; + } +} + +function getMostAccessibleTopWindow() { + let res = utils.getWindowSelf(); + + try { + while (utils.getWindowTop().top !== res && res.parent.location.href.length) { + res = res.parent; + } + } catch (ignore) { + } + + return res; +} + +function elementInView(elementId) { + const resolveElement = (elId) => { + const win = utils.getWindowSelf(); + + return win.document.getElementById(elId); + }; + + const visibleInWindow = (el, win) => { + const rect = el.getBoundingClientRect(); + const inView = (rect.top + rect.height >= 0) && (rect.top <= win.innerHeight); + + if (win !== win.parent) { + return inView && visibleInWindow(win.frameElement, win.parent); + } + + return inView; + }; + + try { + return visibleInWindow(resolveElement(elementId), utils.getWindowSelf()); + } catch (e) { + // old browser, element not found, cross-origin etc. + } + return undefined; +} + +function buildUrl({host: hostname = DEFAULT_HOST, port = DEFAULT_PORT, securePort, path: pathname = DEFAULT_PATH}) { + if (securePort) { + port = securePort; + } + + return utils.buildUrl({protocol: 'https', hostname, port, pathname}); +} + +function getGdprParams(gdprConsent) { + if (gdprConsent) { + const consentString = encodeURIComponent(gdprConsent.consentString || '') + const isGdpr = gdprConsent.gdprApplies ? 1 : 0; + + return `?gdpr=${isGdpr}&gdpr_consent=${consentString}` + } else { + return ''; + } +} + +export const spec = { + code: BIDDER_CODE, + gvlid: GVL_ID, + supportedMediaTypes: [BANNER], + + isBidRequestValid: (function () { + const validators = []; + + const createValidator = (checkFn, errorMsgFn) => { + return (bidRequest) => { + if (checkFn(bidRequest)) { + return true; + } else { + utils.logError(`invalid bid: ${errorMsgFn(bidRequest)}`, 'ERROR'); + return false; + } + } + }; + + function isBanner(bidReq) { + return (!bidReq.mediaTypes && !bidReq.mediaType) || + (bidReq.mediaTypes && bidReq.mediaTypes.banner) || + bidReq.mediaType === BANNER; + } + + validators.push(createValidator((bidReq) => isBanner(bidReq), + bidReq => `bid request ${bidReq.bidId} is not a banner`)); + validators.push(createValidator((bidReq) => typeof bidReq.params === 'object', + bidReq => `bid request ${bidReq.bidId} does not have custom params`)); + validators.push(createValidator((bidReq) => utils.isStr(bidReq.params.sid), + bidReq => `bid request ${bidReq.bidId} does not have a sid string field`)); + + return function (bidRequest) { + return validators.every(f => f(bidRequest)); + } + }()), + + buildRequests: function (validBidRequests = [], bidderRequest) { + const anyBid = bidderRequest.bids[0]; + + const payload = { + id: bidderRequest.auctionId, + bids: [], + ref: getTopWindowReferrer(), + ssl: isSecureWindow(), + mpa: isMainPageAccessible(), + timeout: bidderRequest.timeout - (Date.now() - bidderRequest.auctionStart) + }; + + const userIds = anyBid.userId; + + if (!utils.isEmpty(userIds)) { + payload.user = { + euids: userIds + }; + } + + const gdprConsent = bidderRequest.gdprConsent; + + if (gdprConsent && gdprConsent.consentString != null && gdprConsent.gdprApplies != null) { + payload.gdpr = { + consent: bidderRequest.gdprConsent.consentString, applies: bidderRequest.gdprConsent.gdprApplies + }; + } + + function bidSizes(bid) { + return utils.deepAccess(bid, 'mediaTypes.banner.sizes') || bid.sizes /* for prebid < 3 */ || []; + } + + validBidRequests.forEach(bid => { + payload.bids.push({ + bid: bid.bidId, sid: bid.params.sid, siz: bidSizes(bid), viz: elementInView(bid.adUnitCode) + }); + }); + + return { + method: 'POST', url: buildUrl(anyBid.params), data: payload + } + }, + + interpretResponse: function (serverResponse) { + const bids = []; + + if (serverResponse.body && typeof serverResponse.body === 'object') { + serverResponse.body.bids.forEach(bidResponse => { + bids.push({ + requestId: bidResponse.bidId, + cpm: bidResponse.cpm || 0, + width: bidResponse.width || 0, + height: bidResponse.height || 0, + ad: bidResponse.ad, + ttl: FIVE_MINUTES_IN_SECONDS, + currency: 'EUR', + netRevenue: true, + creativeId: '', + }); + }); + } + + return bids; + }, + + getUserSyncs: function (syncOptions, serverResponses, gdprConsent) { + if (serverResponses.length > 0 && syncOptions.iframeEnabled) { + return [{ + type: 'iframe', + url: USER_SYNC_IFRAME_URL + getGdprParams(gdprConsent) + }]; + } + + return []; + } +}; + +registerBidder(spec); diff --git a/modules/stroeerCoreBidAdapter.md b/modules/stroeerCoreBidAdapter.md new file mode 100644 index 00000000000..fe6e92057c6 --- /dev/null +++ b/modules/stroeerCoreBidAdapter.md @@ -0,0 +1,31 @@ +## Overview + +``` +Module Name: Stroeer Bidder Adapter +Module Type: Bidder Adapter +Maintainer: help@cz.stroeer-labs.com +``` + + +## Ad unit configuration for publishers + +```javascript +const adUnits = [{ + code: 'div-gpt-ad-1460505748561-0', + mediaTypes: { + banner: { + sizes: [[300, 250]], + } + }, + bids: [{ + bidder: 'stroeerCore', + params: { + sid: "06b782cc-091b-4f53-9cd2-0291679aa1ac" + } + }] +}]; +``` +### Config Notes + +* Slot id (`sid`) is required. The adapter will ignore bid requests from prebid if `sid` is not provided. This must be in the decoded form. For example, "1234" as opposed to "MTM0ODA=". +* The server ignores dimensions that are not supported by the slot or by the platform (such as 987x123). diff --git a/test/spec/modules/stroeerCoreBidAdapter_spec.js b/test/spec/modules/stroeerCoreBidAdapter_spec.js new file mode 100644 index 00000000000..dd02ebb7c8e --- /dev/null +++ b/test/spec/modules/stroeerCoreBidAdapter_spec.js @@ -0,0 +1,532 @@ +import {assert} from 'chai'; +import {spec} from 'modules/stroeerCoreBidAdapter.js'; +import * as utils from 'src/utils.js'; +import {BANNER, VIDEO} from '../../../src/mediaTypes.js'; + +describe('stroeerCore bid adapter', function () { + let sandbox; + let fakeServer; + let bidderRequest; + let clock; + + beforeEach(() => { + bidderRequest = buildBidderRequest(); + sandbox = sinon.sandbox.create(); + fakeServer = sandbox.useFakeServer(); + clock = sandbox.useFakeTimers(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function assertStandardFieldsOnBid(bidObject, bidId, ad, width, height, cpm) { + assert.propertyVal(bidObject, 'requestId', bidId); + assert.propertyVal(bidObject, 'ad', ad); + assert.propertyVal(bidObject, 'width', width); + assert.propertyVal(bidObject, 'height', height); + assert.propertyVal(bidObject, 'cpm', cpm); + assert.propertyVal(bidObject, 'currency', 'EUR'); + assert.propertyVal(bidObject, 'netRevenue', true); + assert.propertyVal(bidObject, 'creativeId', ''); + } + + const AUCTION_ID = utils.getUniqueIdentifierStr(); + + // Vendor user ids and associated data + const userIds = Object.freeze({ + criteoId: 'criteo-user-id', + digitrustid: { + data: { + id: 'encrypted-user-id==', + keyv: 4, + privacy: {optout: false}, + producer: 'ABC', + version: 2 + } + }, + lipb: { + lipbid: 'T7JiRRvsRAmh88', + segments: ['999'] + } + }); + + const buildBidderRequest = () => ({ + auctionId: AUCTION_ID, + bidderRequestId: 'bidder-request-id-123', + bidderCode: 'stroeerCore', + timeout: 5000, + auctionStart: 10000, + bids: [{ + bidId: 'bid1', + bidder: 'stroeerCore', + adUnitCode: 'div-1', + mediaTypes: { + banner: { + sizes: [[300, 600], [160, 60]] + } + }, + params: { + sid: 'NDA=' + }, + userId: userIds + }, { + bidId: 'bid2', + bidder: 'stroeerCore', + adUnitCode: 'div-2', + mediaTypes: { + banner: { + sizes: [[728, 90]], + } + }, + params: { + sid: 'ODA=' + }, + userId: userIds + }], + }); + + const buildBidderRequestPreVersion3 = () => { + const request = buildBidderRequest(); + request.bids.forEach((bid) => { + bid.sizes = bid.mediaTypes.banner.sizes; + delete bid.mediaTypes; + bid.mediaType = 'banner'; + }); + return request; + }; + + const buildBidderResponse = () => ({ + 'bids': [{ + 'bidId': 'bid1', 'cpm': 4.0, 'width': 300, 'height': 600, 'ad': '
tag1
', 'tracking': {'brandId': 123} + }, { + 'bidId': 'bid2', 'cpm': 7.3, 'width': 728, 'height': 90, 'ad': '
tag2
' + }] + }); + + const createWindow = (href, params = {}) => { + let {parent, referrer, top, frameElement, placementElements = []} = params; + + const protocol = (href.indexOf('https') === 0) ? 'https:' : 'http:'; + const win = { + frameElement, + parent, + top, + location: { + protocol, href + }, + document: { + createElement: function () { + return { + setAttribute: function () { + } + } + }, + referrer, + getElementById: id => placementElements.find(el => el.id === id) + } + }; + + win.self = win; + + if (!parent) { + win.parent = win; + } + + if (!top) { + win.top = win; + } + + return win; + }; + + function createElement(id, offsetTop = 0) { + return { + id, + getBoundingClientRect: function () { + return { + top: offsetTop, height: 1 + } + } + } + } + + function setupSingleWindow(sandBox, placementElements = [createElement('div-1', 17), createElement('div-2', 54)]) { + const win = createWindow('http://www.xyz.com/', { + parent: win, top: win, frameElement: createElement(undefined, 304), placementElements: placementElements + }); + + win.innerHeight = 200; + + sandBox.stub(utils, 'getWindowSelf').returns(win); + sandBox.stub(utils, 'getWindowTop').returns(win); + + return win; + } + + function setupNestedWindows(sandBox, placementElements = [createElement('div-1', 17), createElement('div-2', 54)]) { + const topWin = createWindow('http://www.abc.org/', {referrer: 'http://www.google.com/?query=monkey'}); + topWin.innerHeight = 800; + + const midWin = createWindow('http://www.abc.org/', {parent: topWin, top: topWin, frameElement: createElement()}); + midWin.innerHeight = 400; + + const win = createWindow('http://www.xyz.com/', { + parent: midWin, top: topWin, frameElement: createElement(undefined, 304), placementElements + }); + + win.innerHeight = 200; + + sandBox.stub(utils, 'getWindowSelf').returns(win); + sandBox.stub(utils, 'getWindowTop').returns(topWin); + + return {topWin, midWin, win}; + } + + it('should only support BANNER mediaType', function () { + assert.deepEqual(spec.supportedMediaTypes, [BANNER]); + }); + + describe('bid validation entry point', () => { + let bidRequest; + + beforeEach(() => { + bidRequest = buildBidderRequest().bids[0]; + }); + + it('should have \"isBidRequestValid\" function', () => { + assert.isFunction(spec.isBidRequestValid); + }); + + it('should pass a valid bid', () => { + assert.isTrue(spec.isBidRequestValid(bidRequest)); + }); + + it('should exclude bids without slot id param', () => { + bidRequest.params.sid = undefined; + assert.isFalse(spec.isBidRequestValid(bidRequest)); + }); + + it('should exclude non-banner bids', () => { + delete bidRequest.mediaTypes.banner; + bidRequest.mediaTypes.video = { + playerSize: [640, 480] + }; + + assert.isFalse(spec.isBidRequestValid(bidRequest)); + }); + + it('should exclude non-banner, pre-version 3 bids', () => { + delete bidRequest.mediaTypes; + bidRequest.mediaType = VIDEO; + assert.isFalse(spec.isBidRequestValid(bidRequest)); + }); + }); + + describe('build request entry point', () => { + it('should have \"buildRequests\" function', () => { + assert.isFunction(spec.buildRequests); + }); + + describe('url on server request info object', () => { + let win; + beforeEach(() => { + win = setupSingleWindow(sandbox); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should use hardcoded url as default endpoint', () => { + const bidReq = buildBidderRequest(); + let serverRequestInfo = spec.buildRequests(bidReq.bids, bidReq); + + assert.equal(serverRequestInfo.method, 'POST'); + assert.isObject(serverRequestInfo.data); + assert.equal(serverRequestInfo.url, 'https://hb.adscale.de/dsh'); + }); + + describe('should use custom url if provided', () => { + const samples = [{ + protocol: 'http:', params: {sid: 'ODA=', host: 'other.com', port: '234', path: '/xyz'}, expected: 'https://other.com:234/xyz' + }, { + protocol: 'https:', params: {sid: 'ODA=', host: 'other.com', port: '234', path: '/xyz'}, expected: 'https://other.com:234/xyz' + }, { + protocol: 'https:', + params: {sid: 'ODA=', host: 'other.com', port: '234', securePort: '871', path: '/xyz'}, + expected: 'https://other.com:871/xyz' + }, { + protocol: 'http:', params: {sid: 'ODA=', port: '234', path: '/xyz'}, expected: 'https://hb.adscale.de:234/xyz' + }, ]; + + samples.forEach(sample => { + it(`should use ${sample.expected} as endpoint when given params ${JSON.stringify(sample.params)} and protocol ${sample.protocol}`, + function () { + win.location.protocol = sample.protocol; + + const bidReq = buildBidderRequest(); + bidReq.bids[0].params = sample.params; + bidReq.bids.length = 1; + + let serverRequestInfo = spec.buildRequests(bidReq.bids, bidReq); + + assert.equal(serverRequestInfo.method, 'POST'); + assert.isObject(serverRequestInfo.data); + assert.equal(serverRequestInfo.url, sample.expected); + }); + }); + }); + }); + + describe('payload on server request info object', () => { + let topWin; + let win; + + let placementElements; + beforeEach(() => { + placementElements = [createElement('div-1', 17), createElement('div-2', 54)]; + ({ topWin, win } = setupNestedWindows(sandbox, placementElements)); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should have expected JSON structure', () => { + clock.tick(13500); + const bidReq = buildBidderRequest(); + + const serverRequestInfo = spec.buildRequests(bidReq.bids, bidReq); + + const expectedTimeout = bidderRequest.timeout - (13500 - bidderRequest.auctionStart); + + assert.equal(expectedTimeout, 1500); + + const expectedJsonPayload = { + 'id': AUCTION_ID, + 'timeout': expectedTimeout, + 'ref': topWin.document.referrer, + 'mpa': true, + 'ssl': false, + 'bids': [{ + 'sid': 'NDA=', 'bid': 'bid1', 'siz': [[300, 600], [160, 60]], 'viz': true + }, { + 'sid': 'ODA=', 'bid': 'bid2', 'siz': [[728, 90]], 'viz': true + }], + 'user': { + 'euids': userIds + } + }; + + // trim away fields with undefined + const actualJsonPayload = JSON.parse(JSON.stringify(serverRequestInfo.data)); + + assert.deepEqual(actualJsonPayload, expectedJsonPayload); + }); + + it('should handle banner sizes for pre version 3', () => { + // Version 3 changes the way how banner sizes are accessed. + // We can support backwards compatibility with version 2.x + const bidReq = buildBidderRequestPreVersion3(); + const serverRequestInfo = spec.buildRequests(bidReq.bids, bidReq); + assert.deepEqual(serverRequestInfo.data.bids[0].siz, [[300, 600], [160, 60]]); + assert.deepEqual(serverRequestInfo.data.bids[1].siz, [[728, 90]]); + }); + + describe('optional fields', () => { + it('should skip viz field when unable to determine visibility of placement', () => { + placementElements.length = 0; + const bidReq = buildBidderRequest(); + + const serverRequestInfo = spec.buildRequests(bidReq.bids, bidReq); + assert.lengthOf(serverRequestInfo.data.bids, 2); + + for (let bid of serverRequestInfo.data.bids) { + assert.isUndefined(bid.viz); + } + }); + + it('should skip ref field when unable to determine document referrer', () => { + // i.e., empty if user came from bookmark, or web page using 'rel="noreferrer" on link, etc + buildBidderRequest(); + + const serverRequestInfo = spec.buildRequests(bidderRequest.bids, bidderRequest); + assert.lengthOf(serverRequestInfo.data.bids, 2); + + for (let bid of serverRequestInfo.data.bids) { + assert.isUndefined(bid.ref); + } + }); + + const gdprSamples = [{consentString: 'RG9ua2V5IEtvbmc=', gdprApplies: true}, {consentString: 'UGluZyBQb25n', gdprApplies: false}]; + gdprSamples.forEach((sample) => { + it(`should add GDPR info ${JSON.stringify(sample)} when provided`, () => { + const bidReq = buildBidderRequest(); + bidReq.gdprConsent = sample; + + const serverRequestInfo = spec.buildRequests(bidReq.bids, bidReq); + + const actualGdpr = serverRequestInfo.data.gdpr; + assert.propertyVal(actualGdpr, 'applies', sample.gdprApplies); + assert.propertyVal(actualGdpr, 'consent', sample.consentString); + }); + }); + + const skippableGdprSamples = [{consentString: null, gdprApplies: true}, // + {consentString: 'UGluZyBQb25n', gdprApplies: null}, // + {consentString: null, gdprApplies: null}, // + {consentString: undefined, gdprApplies: true}, // + {consentString: 'UGluZyBQb25n', gdprApplies: undefined}, // + {consentString: undefined, gdprApplies: undefined}]; + skippableGdprSamples.forEach((sample) => { + it(`should not add GDPR info ${JSON.stringify(sample)} when one or more values are missing`, () => { + const bidReq = buildBidderRequest(); + bidReq.gdprConsent = sample; + + const serverRequestInfo = spec.buildRequests(bidReq.bids, bidReq); + + const actualGdpr = serverRequestInfo.data.gdpr; + assert.isUndefined(actualGdpr); + }); + }); + + it('should be able to build without third party user id data', () => { + const bidReq = buildBidderRequest(); + bidReq.bids.forEach(bid => delete bid.userId); + const serverRequestInfo = spec.buildRequests(bidReq.bids, bidReq); + assert.lengthOf(serverRequestInfo.data.bids, 2); + assert.notProperty(serverRequestInfo, 'uids'); + }); + }); + }); + }); + + describe('interpret response entry point', () => { + it('should have \"interpretResponse\" function', () => { + assert.isFunction(spec.interpretResponse); + }); + + const invalidResponses = ['', ' ', ' ', undefined, null]; + invalidResponses.forEach(sample => { + it('should ignore invalid responses (\"' + sample + '\") response', () => { + const result = spec.interpretResponse({body: sample}); + assert.isArray(result); + assert.lengthOf(result, 0); + }); + }); + + it('should interpret a standard response', () => { + const bidderResponse = buildBidderResponse(); + + const result = spec.interpretResponse({body: bidderResponse}); + assertStandardFieldsOnBid(result[0], 'bid1', '
tag1
', 300, 600, 4); + assertStandardFieldsOnBid(result[1], 'bid2', '
tag2
', 728, 90, 7.3); + }); + + it('should return empty array, when response contains no bids', () => { + const result = spec.interpretResponse({body: {bids: []}}); + assert.deepStrictEqual(result, []); + }); + }); + + describe('get user syncs entry point', () => { + let win; + beforeEach(() => { + win = setupSingleWindow(sandbox); + + // fake + win.document.createElement = function () { + const attrs = {}; + return { + setAttribute: (name, value) => { + attrs[name] = value + }, + getAttribute: (name) => attrs[name], + hasAttribute: (name) => attrs[name] !== undefined, + tagName: 'SCRIPT', + } + } + }); + + it('should have \"getUserSyncs\" function', () => { + assert.isFunction(spec.getUserSyncs); + }); + + describe('when iframe option is enabled', () => { + it('should perform user connect when there was a response', () => { + const expectedUrl = 'https://js.adscale.de/pbsync.html'; + const userSyncResponse = spec.getUserSyncs({iframeEnabled: true}, ['']); + + assert.deepStrictEqual(userSyncResponse, [{type: 'iframe', url: expectedUrl}]); + }); + + it('should not perform user connect when there was no response', () => { + const userSyncResponse = spec.getUserSyncs({iframeEnabled: true}, []); + + assert.deepStrictEqual(userSyncResponse, []); + }); + + describe('and gdpr consent is defined', () => { + describe('and gdpr applies', () => { + it('should place gdpr query param to the user sync url with value of 1', () => { + const expectedUrl = 'https://js.adscale.de/pbsync.html?gdpr=1&gdpr_consent='; + const userSyncResponse = spec.getUserSyncs({iframeEnabled: true}, [''], {gdprApplies: true}); + + assert.deepStrictEqual(userSyncResponse, [{type: 'iframe', url: expectedUrl}]); + }); + }); + + describe('and gdpr does not apply', () => { + it('should place gdpr query param to the user sync url with zero value', () => { + const expectedUrl = 'https://js.adscale.de/pbsync.html?gdpr=0&gdpr_consent='; + const userSyncResponse = spec.getUserSyncs({iframeEnabled: true}, [''], {gdprApplies: false}); + + assert.deepStrictEqual(userSyncResponse, [{type: 'iframe', url: expectedUrl}]); + }); + + describe('because consent does not specify it', () => { + it('should place gdpr query param to the user sync url with zero value', () => { + const expectedUrl = 'https://js.adscale.de/pbsync.html?gdpr=0&gdpr_consent='; + const userSyncResponse = spec.getUserSyncs({iframeEnabled: true}, [''], {}); + + assert.deepStrictEqual(userSyncResponse, [{type: 'iframe', url: expectedUrl}]); + }); + }); + }); + + describe('and consent string is defined', () => { + it('should pass consent string to gdpr consent query param', () => { + const consentString = 'consent_string'; + const expectedUrl = `https://js.adscale.de/pbsync.html?gdpr=1&gdpr_consent=${consentString}`; + const userSyncResponse = spec.getUserSyncs({iframeEnabled: true}, [''], {gdprApplies: true, consentString}); + + assert.deepStrictEqual(userSyncResponse, [{type: 'iframe', url: expectedUrl}]); + }); + + it('should correctly escape invalid characters', () => { + const consentString = 'consent ?stri&ng'; + const expectedUrl = `https://js.adscale.de/pbsync.html?gdpr=1&gdpr_consent=consent%20%3Fstri%26ng`; + const userSyncResponse = spec.getUserSyncs({iframeEnabled: true}, [''], {gdprApplies: true, consentString}); + + assert.deepStrictEqual(userSyncResponse, [{type: 'iframe', url: expectedUrl}]); + }); + }); + }); + }); + + describe('when iframe option is disabled', () => { + it('should not perform user connect even when there was a response', () => { + const userSyncResponse = spec.getUserSyncs({iframeEnabled: false}, ['']); + + assert.deepStrictEqual(userSyncResponse, []); + }); + + it('should not perform user connect when there was no response', () => { + const userSyncResponse = spec.getUserSyncs({iframeEnabled: false}, []); + + assert.deepStrictEqual(userSyncResponse, []); + }); + }); + }); +});