diff --git a/modules/asterioBidAdapter.md b/modules/asterioBidAdapter.md new file mode 100644 index 0000000000..f18b7156bd --- /dev/null +++ b/modules/asterioBidAdapter.md @@ -0,0 +1,35 @@ +# Overview + +``` +Module Name: Asterio Bidder Adapter +Module Type: Bidder Adapter +Maintainer: mnikulin@asteriosoft.com +``` + +# Description + +Connects to Asterio Bidder for bids. +Asterio bid adapter supports Banner and Video ads. + +# Bid Params + +| Name | Scope | Type | Description | +| ---- | ----- | ---- | ----------- | +| `adUnitToken` | required | String | Asterio ad unit token provided by Asterio. | +| `pos` | optional | Number | Ad position override. When omitted, the adapter uses `mediaTypes.banner.pos` or `mediaTypes.video.pos` from the ad unit. | + +# Test Parameters +``` +const adUnits = [ + { + bids: [ + { + bidder: 'asterio', + params: { + adUnitToken: '????????-????-????-????-????????????', // adUnitToken provided by Asterio + } + } + ] + } +]; +``` diff --git a/modules/asterioBidAdapter.ts b/modules/asterioBidAdapter.ts new file mode 100644 index 0000000000..313eb4420b --- /dev/null +++ b/modules/asterioBidAdapter.ts @@ -0,0 +1,149 @@ +import { type AdapterRequest, type BidderSpec, type ServerResponse, registerBidder } from '../src/adapters/bidderFactory.js'; +import { deepAccess, deepClone } from '../src/utils.js'; +import { ajax } from '../src/ajax.js'; +import { BANNER, VIDEO } from '../src/mediaTypes.js'; +import type { BidRequest } from '../src/adapterManager.js'; +import type { Size } from '../src/types/common.d.ts'; + +const BIDDER_CODE = 'asterio'; +export const ENDPOINT = 'https://bid.asterio.ai/prebid/bid'; + +export type AsterioBidParams = { + adUnitToken: string; + pos?: number; +}; + +declare module '../src/adUnits' { + interface BidderParams { + [BIDDER_CODE]: AsterioBidParams; + } +} + +type AsterioBidPayload = { + bidId: string; + adUnitToken: string; + pos?: number; + sizes: Array<{ width: number; height: number }>; +}; + +type AsterioServerBid = { + ad?: string; + requestId: string; + cpm: string | number; + currency?: string; + width: number; + height: number; + ttl: number; + creativeId: string; + netRevenue?: boolean; + mediaType?: string; + format?: string; + adomain?: string[]; +}; + +export const spec: BidderSpec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO], + + isBidRequestValid: function (bid) { + return !!(bid.params && bid.params.adUnitToken); + }, + + buildRequests: function (validBidRequests, bidderRequest) { + const bids: AsterioBidPayload[] = validBidRequests.map(bidRequest => ({ + bidId: bidRequest.bidId, + adUnitToken: bidRequest.params.adUnitToken, + pos: getPosition(bidRequest), + sizes: prepareSizes(bidRequest.sizes) + })); + + const payload: { + requestId: string; + bids: AsterioBidPayload[]; + referer: string; + schain: unknown; + gdprConsent?: { + consentRequired: boolean; + consentString?: string; + }; + } = { + requestId: bidderRequest.bidderRequestId, + bids, + referer: bidderRequest.refererInfo?.page, + schain: validBidRequests[0]?.ortb2?.source?.ext?.schain + }; + + if (bidderRequest?.gdprConsent) { + payload.gdprConsent = { + consentRequired: typeof bidderRequest.gdprConsent.gdprApplies === 'boolean' ? bidderRequest.gdprConsent.gdprApplies : false, + consentString: bidderRequest.gdprConsent.consentString + }; + } + + return { + method: 'POST', + url: ENDPOINT, + data: payload, + options: { + contentType: 'text/plain', + customHeaders: { + 'Rtb-Direct': 'true' + } + } + }; + }, + + interpretResponse: function (serverResponse: ServerResponse, _request: AdapterRequest) { + const serverBody = serverResponse.body; + if (!serverBody || typeof serverBody !== 'object' || !Array.isArray(serverBody.bids)) { + return []; + } + + return serverBody.bids.map((bidResponse: AsterioServerBid) => { + const bid = deepClone(bidResponse); + + bid.cpm = parseFloat(String(bidResponse.cpm)); + bid.requestId = bidResponse.requestId; + bid.ad = bidResponse.ad; + bid.width = bidResponse.width; + bid.height = bidResponse.height; + bid.currency = bidResponse.currency || 'USD'; + bid.netRevenue = typeof bidResponse.netRevenue === 'boolean' ? bidResponse.netRevenue : true; + bid.ttl = bidResponse.ttl; + bid.creativeId = bidResponse.creativeId; + bid.mediaType = bidResponse.mediaType || bidResponse.format || 'banner'; + + if (VIDEO === bid.mediaType && bidResponse.ad) { + bid.vastXml = bidResponse.ad; + } + + bid.meta = {}; + bid.meta.advertiserDomains = bid.adomain || []; + + return bid; + }); + }, + + onBidWon: function (bid: { winUrl?: string; cpm: number }) { + if (bid.winUrl) { + const winUrl = bid.winUrl.replace(/\$\{AUCTION_PRICE}/, String(bid.cpm)); + ajax(winUrl, null, undefined, { keepalive: true }); + return true; + } + return false; + } +}; + +function prepareSizes(sizes: Size | Size[]) { + if (!Array.isArray(sizes) || sizes.length === 0) { + return []; + } + const normalizedSizes = typeof sizes[0] === 'number' ? [sizes] : sizes; + return normalizedSizes.map(size => ({ width: size[0], height: size[1] })); +} + +function getPosition(bidRequest: BidRequest): number | undefined { + return bidRequest.params.pos ?? deepAccess(bidRequest, 'mediaTypes.banner.pos') ?? deepAccess(bidRequest, 'mediaTypes.video.pos'); +} + +registerBidder(spec); diff --git a/test/spec/modules/asterioBidAdapter_spec.js b/test/spec/modules/asterioBidAdapter_spec.js new file mode 100644 index 0000000000..c45ef42dd5 --- /dev/null +++ b/test/spec/modules/asterioBidAdapter_spec.js @@ -0,0 +1,255 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as ajaxModule from 'src/ajax.js'; +import { ENDPOINT, spec } from 'modules/asterioBidAdapter.js'; +import { newBidder } from 'src/adapters/bidderFactory.js'; + +const REQUEST = { + bidId: '456', + bidder: 'asterio', + sizes: [[300, 250], [300, 600]], + params: { + adUnitToken: 'bbd6b4a6-66b8-479d-9527-e17899544693', + pos: 1 + }, + ortb2: { + source: { + ext: { + schain: { + ver: '1.0', + complete: 1, + nodes: [{ + asi: 'asteriosoft.com', + sid: 'publisher-1', + hp: 1 + }] + } + } + } + } +}; + +const BIDDER_BANNER_RESPONSE = { + bids: [{ + ad: '
test
', + requestId: 'request-1', + cpm: 1.23, + currency: 'USD', + width: 300, + height: 250, + ttl: 300, + creativeId: 'creative-1', + netRevenue: true, + winUrl: 'http://tracker.test/win?price=${AUCTION_PRICE}', + format: 'banner', + mediaType: 'banner', + adomain: ['example.com'] + }] +}; + +const BIDDER_VIDEO_RESPONSE = { + bids: [{ + ad: '', + requestId: 'request-2', + cpm: 2.34, + currency: 'USD', + width: 640, + height: 360, + ttl: 300, + creativeId: 'creative-2', + netRevenue: true, + winUrl: 'http://tracker.test/win?price=${AUCTION_PRICE}', + format: 'video', + mediaType: 'video', + adomain: ['video.example.com'] + }] +}; + +describe('asterioBidAdapter', function () { + const adapter = newBidder(spec); + + describe('inherited functions', function () { + it('exists and is a function', function () { + expect(adapter.callBids).to.exist.and.to.be.a('function'); + }); + }); + + describe('isBidRequestValid', function () { + it('should return true when adUnitToken is present', function () { + expect(spec.isBidRequestValid(REQUEST)).to.equal(true); + }); + + it('should return false when params are missing', function () { + const bid = { ...REQUEST, params: { pos: 1 } }; + expect(spec.isBidRequestValid(bid)).to.equal(false); + expect(spec.isBidRequestValid({ ...REQUEST, params: null })).to.equal(false); + }); + }); + + describe('buildRequests', function () { + it('should create a POST request to the direct prebid endpoint', function () { + const bidderRequest = spec.buildRequests([REQUEST], { + bidderRequestId: '123', + gdprConsent: { + gdprApplies: true, + consentString: 'BOJ8RZsOJ8RZsABAB8AAAAAZ+A==' + }, + refererInfo: { + page: 'http://test.com/path.html' + } + }); + + expect(bidderRequest.method).to.equal('POST'); + expect(bidderRequest.url).to.equal(ENDPOINT); + expect(bidderRequest.options.customHeaders).to.deep.equal({ 'Rtb-Direct': 'true' }); + expect(bidderRequest.options.contentType).to.equal('text/plain'); + expect(bidderRequest.data.requestId).to.equal('123'); + expect(bidderRequest.data.referer).to.equal('http://test.com/path.html'); + expect(bidderRequest.data.schain).to.deep.equal(REQUEST.ortb2.source.ext.schain); + expect(bidderRequest.data.bids).to.deep.equal([{ + bidId: '456', + adUnitToken: 'bbd6b4a6-66b8-479d-9527-e17899544693', + pos: 1, + sizes: [{ width: 300, height: 250 }, { width: 300, height: 600 }] + }]); + expect(bidderRequest.data.gdprConsent).to.deep.equal({ + consentRequired: true, + consentString: 'BOJ8RZsOJ8RZsABAB8AAAAAZ+A==' + }); + }); + + it('should use banner mediaTypes pos when params pos is absent', function () { + const bidderRequest = spec.buildRequests([{ + ...REQUEST, + params: { + adUnitToken: REQUEST.params.adUnitToken + }, + mediaTypes: { + banner: { + pos: 0 + } + } + }], { + bidderRequestId: '123' + }); + + expect(bidderRequest.data.bids[0].pos).to.equal(0); + }); + + it('should use video mediaTypes pos when params pos and banner pos are absent', function () { + const bidderRequest = spec.buildRequests([{ + ...REQUEST, + params: { + adUnitToken: REQUEST.params.adUnitToken + }, + mediaTypes: { + video: { + pos: 3 + } + } + }], { + bidderRequestId: '123' + }); + + expect(bidderRequest.data.bids[0].pos).to.equal(3); + }); + + it('should use params pos as an override', function () { + const bidderRequest = spec.buildRequests([{ + ...REQUEST, + params: { + ...REQUEST.params, + pos: 2 + }, + mediaTypes: { + banner: { + pos: 1 + }, + video: { + pos: 3 + } + } + }], { + bidderRequestId: '123' + }); + + expect(bidderRequest.data.bids[0].pos).to.equal(2); + }); + + it('should support flat size tuples', function () { + const bidderRequest = spec.buildRequests([{ + ...REQUEST, + sizes: [640, 360] + }], { + bidderRequestId: '123' + }); + + expect(bidderRequest.data.bids[0].sizes).to.deep.equal([{ width: 640, height: 360 }]); + }); + }); + + describe('interpretResponse', function () { + it('should map banner bids from direct response', function () { + const result = spec.interpretResponse({ body: BIDDER_BANNER_RESPONSE }, {}); + + expect(result).to.have.lengthOf(1); + expect(result[0]).to.include({ + ad: '
test
', + requestId: 'request-1', + cpm: 1.23, + currency: 'USD', + width: 300, + height: 250, + ttl: 300, + creativeId: 'creative-1', + netRevenue: true, + winUrl: 'http://tracker.test/win?price=${AUCTION_PRICE}', + format: 'banner', + mediaType: 'banner' + }); + expect(result[0].meta.advertiserDomains).to.deep.equal(['example.com']); + }); + + it('should map video bids and expose vastXml', function () { + const result = spec.interpretResponse({ body: BIDDER_VIDEO_RESPONSE }, {}); + + expect(result).to.have.lengthOf(1); + expect(result[0].mediaType).to.equal('video'); + expect(result[0].vastXml).to.equal(''); + expect(result[0].meta.advertiserDomains).to.deep.equal(['video.example.com']); + }); + + it('should return empty array for invalid response body', function () { + expect(spec.interpretResponse({ body: undefined }, {})).to.deep.equal([]); + expect(spec.interpretResponse({ body: '' }, {})).to.deep.equal([]); + expect(spec.interpretResponse({ body: {} }, {})).to.deep.equal([]); + }); + }); + + describe('onBidWon', function () { + let ajaxStub; + + beforeEach(function () { + ajaxStub = sinon.stub(ajaxModule, 'ajax'); + }); + + afterEach(function () { + ajaxStub.restore(); + }); + + it('should fire win tracking request', function () { + const bidWonResult = spec.onBidWon(BIDDER_BANNER_RESPONSE.bids[0]); + + expect(bidWonResult).to.equal(true); + expect(ajaxStub.calledOnce).to.equal(true); + expect(ajaxStub.firstCall.args[0]).to.equal('http://tracker.test/win?price=1.23'); + expect(ajaxStub.firstCall.args[1]).to.equal(null); + expect(ajaxStub.firstCall.args[3]).to.deep.equal({ keepalive: true }); + }); + + it('should return false when there is no winUrl', function () { + expect(spec.onBidWon({ cpm: 1.23 })).to.equal(false); + expect(ajaxStub.called).to.equal(false); + }); + }); +});