diff --git a/modules/floxisBidAdapter.js b/modules/floxisBidAdapter.js new file mode 100644 index 0000000000..c677f054a4 --- /dev/null +++ b/modules/floxisBidAdapter.js @@ -0,0 +1,149 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { triggerPixel, mergeDeep } from '../src/utils.js'; + +const BIDDER_CODE = 'floxis'; +const DEFAULT_BID_TTL = 300; +const DEFAULT_CURRENCY = 'USD'; +const DEFAULT_NET_REVENUE = true; +const DEFAULT_REGION = 'us-e'; +const DEFAULT_PARTNER = BIDDER_CODE; +const PARTNER_REGION_WHITELIST = { + [DEFAULT_PARTNER]: [DEFAULT_REGION], +}; + +function isAllowedPartnerRegion(partner, region) { + return PARTNER_REGION_WHITELIST[partner]?.includes(region) || false; +} + +function getEndpointUrl(seat, region, partner) { + if (!isAllowedPartnerRegion(partner, region)) return null; + const host = partner === BIDDER_CODE + ? `${region}.floxis.tech` + : `${partner}-${region}.floxis.tech`; + return `https://${host}/pbjs?seat=${encodeURIComponent(seat)}`; +} + +function normalizeBidParams(params = {}) { + return { + seat: params.seat, + region: params.region ?? DEFAULT_REGION, + partner: params.partner ?? DEFAULT_PARTNER + }; +} + +const CONVERTER = ortbConverter({ + context: { + netRevenue: DEFAULT_NET_REVENUE, + ttl: DEFAULT_BID_TTL, + currency: DEFAULT_CURRENCY + }, + imp(buildImp, bidRequest, context) { + const imp = buildImp(bidRequest, context); + imp.secure = bidRequest.ortb2Imp?.secure ?? 1; + + let floorInfo; + if (typeof bidRequest.getFloor === 'function') { + try { + floorInfo = bidRequest.getFloor({ + currency: DEFAULT_CURRENCY, + mediaType: '*', + size: '*' + }); + } catch (e) { } + } + const floor = floorInfo?.floor; + const floorCur = floorInfo?.currency || DEFAULT_CURRENCY; + if (typeof floor === 'number' && !isNaN(floor)) { + imp.bidfloor = floor; + imp.bidfloorcur = floorCur; + } + + return imp; + }, + request(buildRequest, imps, bidderRequest, context) { + const req = buildRequest(imps, bidderRequest, context); + mergeDeep(req, { + at: 1, + ext: { + prebid: { + adapter: BIDDER_CODE, + version: '$prebid.version$' + } + } + }); + return req; + } +}); + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + isBidRequestValid(bid) { + const params = bid?.params; + if (!params) return false; + const { seat, region, partner } = normalizeBidParams(params); + if (typeof seat !== 'string' || !seat.length) return false; + if (!isAllowedPartnerRegion(partner, region)) return false; + return true; + }, + + buildRequests(validBidRequests = [], bidderRequest = {}) { + if (!validBidRequests.length) return []; + const filteredBidRequests = validBidRequests.filter((bidRequest) => spec.isBidRequestValid(bidRequest)); + if (!filteredBidRequests.length) return []; + + const bidRequestsByParams = filteredBidRequests.reduce((groups, bidRequest) => { + const { seat, region, partner } = normalizeBidParams(bidRequest.params); + const key = `${seat}|${region}|${partner}`; + groups[key] = groups[key] || []; + groups[key].push({ + ...bidRequest, + params: { + ...bidRequest.params, + seat, + region, + partner + } + }); + return groups; + }, {}); + + return Object.values(bidRequestsByParams).map((groupedBidRequests) => { + const { seat, region, partner } = groupedBidRequests[0].params; + const url = getEndpointUrl(seat, region, partner); + if (!url) return null; + return { + method: 'POST', + url, + data: CONVERTER.toORTB({ bidRequests: groupedBidRequests, bidderRequest }), + options: { + withCredentials: true, + contentType: 'text/plain' + } + }; + }).filter(Boolean); + }, + + interpretResponse(response, request) { + if (!response?.body || !request?.data) return []; + return CONVERTER.fromORTB({ request: request.data, response: response.body })?.bids || []; + }, + + getUserSyncs() { + return []; + }, + + onBidWon(bid) { + if (bid.burl) { + triggerPixel(bid.burl); + } + if (bid.nurl) { + triggerPixel(bid.nurl); + } + } +}; + +registerBidder(spec); diff --git a/modules/floxisBidAdapter.md b/modules/floxisBidAdapter.md new file mode 100644 index 0000000000..f36db0c657 --- /dev/null +++ b/modules/floxisBidAdapter.md @@ -0,0 +1,59 @@ +# Overview + +``` +Module Name: Floxis Bidder Adapter +Module Type: Bidder Adapter +Maintainer: admin@floxis.tech +``` + +# Description + +The Floxis Bid Adapter enables integration with the Floxis programmatic advertising platform via Prebid.js. It supports banner, video (instream and outstream), and native formats. + +**Key Features:** +- Banner, Video and Native ad support +- OpenRTB 2.x compliant +- Privacy regulation compliance (GDPR, USP, GPP, COPPA) +- Prebid.js Floors Module support + +## Supported Media Types +- Banner +- Video +- Native + +## Floors Module Support +The Floxis Bid Adapter supports the Prebid.js [Floors Module](https://docs.prebid.org/dev-docs/modules/floors.html). Floor values are automatically included in the OpenRTB request as `imp.bidfloor` and `imp.bidfloorcur`. + +## Privacy +Privacy fields (GDPR, USP, GPP, COPPA) are handled by Prebid.js core and automatically included in the OpenRTB request. + +## Example Usage +```javascript +pbjs.addAdUnits([ + { + code: 'adunit-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ + bidder: 'floxis', + params: { + seat: 'testSeat', + region: 'us-e', + partner: 'floxis' + } + }] + } +]); +``` + +# Configuration + +## Parameters + +| Name | Scope | Description | Example | Type | +| --- | --- | --- | --- | --- | +| `seat` | required | Seat identifier | `'testSeat'` | `string` | +| `region` | required | Region identifier for routing | `'us-e'` | `string` | +| `partner` | required | Partner identifier | `'floxis'` | `string` | + +## Testing +Unit tests are provided in `test/spec/modules/floxisBidAdapter_spec.js` and cover validation, request building, response interpretation, and bid-won notifications. diff --git a/test/spec/modules/floxisBidAdapter_spec.js b/test/spec/modules/floxisBidAdapter_spec.js new file mode 100644 index 0000000000..9cc8fe96c4 --- /dev/null +++ b/test/spec/modules/floxisBidAdapter_spec.js @@ -0,0 +1,501 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { spec } from 'modules/floxisBidAdapter.js'; +import { BANNER, NATIVE, VIDEO } from 'src/mediaTypes.js'; +import * as utils from 'src/utils.js'; + +describe('floxisBidAdapter', function () { + const DEFAULT_PARAMS = { seat: 'Gmtb', region: 'us-e', partner: 'floxis' }; + + const validBannerBid = { + bidId: 'bid-1', + bidder: 'floxis', + adUnitCode: 'adunit-banner', + mediaTypes: { banner: { sizes: [[300, 250], [728, 90]] } }, + params: { ...DEFAULT_PARAMS } + }; + + const validVideoBid = { + bidId: 'bid-2', + bidder: 'floxis', + adUnitCode: 'adunit-video', + mediaTypes: { + video: { + playerSize: [[640, 480]], + mimes: ['video/mp4'], + protocols: [2, 3], + context: 'instream' + } + }, + params: { ...DEFAULT_PARAMS } + }; + + const validNativeBid = { + bidId: 'bid-3', + bidder: 'floxis', + adUnitCode: 'adunit-native', + mediaTypes: { + native: { + image: { required: true, sizes: [150, 50] }, + title: { required: true, len: 80 } + } + }, + params: { ...DEFAULT_PARAMS } + }; + + describe('isBidRequestValid', function () { + it('should return true for valid banner bid', function () { + expect(spec.isBidRequestValid(validBannerBid)).to.be.true; + }); + + it('should return true for valid video bid', function () { + expect(spec.isBidRequestValid(validVideoBid)).to.be.true; + }); + + it('should return true for valid native bid', function () { + expect(spec.isBidRequestValid(validNativeBid)).to.be.true; + }); + + it('should return false when seat is missing', function () { + const bid = { ...validBannerBid, params: { region: 'us-e' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when seat is empty string', function () { + const bid = { ...validBannerBid, params: { seat: '', region: 'us-e' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return true when region is missing (default region applies)', function () { + const bid = { ...validBannerBid, params: { seat: 'Gmtb' } }; + expect(spec.isBidRequestValid(bid)).to.be.true; + }); + + it('should return false when region is empty string', function () { + const bid = { ...validBannerBid, params: { seat: 'Gmtb', region: '' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return true when partner is missing (default partner applies)', function () { + const bid = { ...validBannerBid, params: { seat: 'Gmtb', region: 'us-e' } }; + expect(spec.isBidRequestValid(bid)).to.be.true; + }); + + it('should return false when partner is empty string', function () { + const bid = { ...validBannerBid, params: { seat: 'Gmtb', region: 'us-e', partner: '' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when params is missing', function () { + const bid = { bidId: 'x', mediaTypes: { banner: { sizes: [[300, 250]] } } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when seat is not a string', function () { + const bid = { ...validBannerBid, params: { seat: 123, region: 'us-e', partner: 'floxis' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when region is not in whitelist', function () { + const bid = { ...validBannerBid, params: { ...DEFAULT_PARAMS, region: 'eu-w' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when partner is not in whitelist', function () { + const bid = { ...validBannerBid, params: { ...DEFAULT_PARAMS, partner: 'mypartner' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return true with default partner', function () { + const bid = { ...validBannerBid, params: { ...DEFAULT_PARAMS, partner: 'floxis' } }; + expect(spec.isBidRequestValid(bid)).to.be.true; + }); + }); + + describe('supportedMediaTypes', function () { + it('should include banner, video, and native', function () { + expect(spec.supportedMediaTypes).to.deep.equal([BANNER, VIDEO, NATIVE]); + }); + }); + + describe('buildRequests', function () { + const bidderRequest = { + bidderCode: 'floxis', + auctionId: 'auction-123', + timeout: 3000 + }; + + it('should return an array with one POST request', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + expect(requests).to.be.an('array').with.lengthOf(1); + expect(requests[0].method).to.equal('POST'); + }); + + it('should build URL without partner prefix when partner is floxis', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + expect(requests[0].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Gmtb'); + }); + + it('should return no requests for non-whitelisted partner', function () { + const bidWithPartner = { + ...validBannerBid, + params: { ...DEFAULT_PARAMS, partner: 'mypartner' } + }; + const requests = spec.buildRequests([bidWithPartner], bidderRequest); + expect(requests).to.be.an('array').that.is.empty; + }); + + it('should default region to us-e when missing', function () { + const bidWithoutRegion = { + ...validBannerBid, + params: { seat: 'Gmtb', partner: 'floxis' } + }; + const requests = spec.buildRequests([bidWithoutRegion], bidderRequest); + expect(requests[0].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Gmtb'); + }); + + it('should default partner to floxis when missing', function () { + const bidWithoutPartner = { + ...validBannerBid, + params: { seat: 'Gmtb', region: 'us-e' } + }; + const requests = spec.buildRequests([bidWithoutPartner], bidderRequest); + expect(requests[0].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Gmtb'); + }); + + it('should return empty array for empty bid requests', function () { + const requests = spec.buildRequests([], bidderRequest); + expect(requests).to.be.an('array').that.is.empty; + }); + + it('should produce valid ORTB request payload', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + const data = requests[0].data; + expect(data).to.be.an('object'); + expect(data.imp).to.be.an('array').with.lengthOf(1); + expect(data.at).to.equal(1); + }); + + it('should set ext with adapter info', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + const data = requests[0].data; + expect(data.ext.prebid.adapter).to.equal('floxis'); + expect(data.ext.prebid.adapterVersion).to.be.undefined; + expect(data.ext.prebid.version).to.equal('$prebid.version$'); + }); + + it('should build banner imp correctly', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + const imp = requests[0].data.imp[0]; + expect(imp).to.have.property('banner'); + expect(imp.banner.format).to.be.an('array'); + expect(imp.secure).to.equal(1); + }); + + if (FEATURES.VIDEO) { + it('should build video imp correctly', function () { + const requests = spec.buildRequests([validVideoBid], bidderRequest); + const imp = requests[0].data.imp[0]; + expect(imp).to.have.property('video'); + expect(imp.video.mimes).to.deep.equal(['video/mp4']); + expect(imp.video.protocols).to.deep.equal([2, 3]); + }); + } + + it('should handle multiple bids in single request', function () { + const requests = spec.buildRequests([validBannerBid, validVideoBid], bidderRequest); + expect(requests).to.have.lengthOf(1); + expect(requests[0].data.imp).to.have.lengthOf(2); + }); + + it('should split requests by seat when using allowed defaults', function () { + const mixedBid = { + ...validVideoBid, + params: { + seat: 'Seat2', + region: 'us-e', + partner: 'floxis' + } + }; + + const requests = spec.buildRequests([validBannerBid, mixedBid], bidderRequest); + expect(requests).to.have.lengthOf(2); + expect(requests[0].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Gmtb'); + expect(requests[1].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Seat2'); + expect(requests[0].data.imp).to.have.lengthOf(1); + expect(requests[1].data.imp).to.have.lengthOf(1); + }); + + it('should ignore non-whitelisted bids in mixed request arrays', function () { + const invalidBid = { + ...validVideoBid, + params: { + seat: 'Seat2', + region: 'eu-w', + partner: 'mypartner' + } + }; + + const requests = spec.buildRequests([validBannerBid, invalidBid], bidderRequest); + expect(requests).to.have.lengthOf(1); + expect(requests[0].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Gmtb'); + expect(requests[0].data.imp).to.have.lengthOf(1); + }); + + it('should set withCredentials option', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + expect(requests[0].options.withCredentials).to.be.true; + }); + + describe('Floors Module support', function () { + it('should set bidfloor from getFloor', function () { + const bidWithFloor = { + ...validBannerBid, + getFloor: function () { + return { floor: 2.5, currency: 'USD' }; + } + }; + const requests = spec.buildRequests([bidWithFloor], bidderRequest); + const imp = requests[0].data.imp[0]; + expect(imp.bidfloor).to.equal(2.5); + expect(imp.bidfloorcur).to.equal('USD'); + }); + + it('should not set bidfloor when getFloor is not present', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + const imp = requests[0].data.imp[0]; + expect(imp.bidfloor).to.be.undefined; + }); + + it('should handle getFloor throwing an error gracefully', function () { + const bidBrokenFloor = { + ...validBannerBid, + getFloor: function () { + throw new Error('floor error'); + } + }; + const requests = spec.buildRequests([bidBrokenFloor], bidderRequest); + const imp = requests[0].data.imp[0]; + expect(imp.bidfloor).to.be.undefined; + }); + }); + + describe('ortb2 passthrough', function () { + it('should merge ortb2 data into the ORTB request', function () { + const ortb2BidderRequest = { + ...bidderRequest, + ortb2: { + regs: { ext: { gdpr: 1 } }, + user: { ext: { consent: 'consent-string-123' } } + } + }; + const requests = spec.buildRequests([validBannerBid], ortb2BidderRequest); + const data = requests[0].data; + expect(data.regs.ext.gdpr).to.equal(1); + expect(data.user.ext.consent).to.equal('consent-string-123'); + }); + + it('should merge ortb2 USP data into the ORTB request', function () { + const uspBidderRequest = { + ...bidderRequest, + ortb2: { + regs: { ext: { us_privacy: '1YNN' } } + } + }; + const requests = spec.buildRequests([validBannerBid], uspBidderRequest); + const data = requests[0].data; + expect(data.regs.ext.us_privacy).to.equal('1YNN'); + }); + }); + }); + + describe('interpretResponse', function () { + function buildRequest() { + return spec.buildRequests([validBannerBid], { + bidderCode: 'floxis', + auctionId: 'auction-123' + })[0]; + } + + it('should parse valid banner ORTB response', function () { + const request = buildRequest(); + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: validBannerBid.bidId, + price: 1.23, + w: 300, + h: 250, + crid: 'creative-1', + adm: '
ad
', + mtype: 1 + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, request); + expect(bids).to.be.an('array').with.lengthOf(1); + expect(bids[0].cpm).to.equal(1.23); + expect(bids[0].width).to.equal(300); + expect(bids[0].height).to.equal(250); + expect(bids[0].creativeId).to.equal('creative-1'); + expect(bids[0].ad).to.equal('
ad
'); + expect(bids[0].requestId).to.equal(validBannerBid.bidId); + expect(bids[0].netRevenue).to.equal(true); + expect(bids[0].currency).to.equal('USD'); + }); + + if (FEATURES.VIDEO) { + it('should parse valid video ORTB response', function () { + const videoRequest = spec.buildRequests([validVideoBid], { + bidderCode: 'floxis', + auctionId: 'auction-456' + })[0]; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: validVideoBid.bidId, + price: 5.00, + w: 640, + h: 480, + crid: 'video-creative-1', + adm: '', + mtype: 2 + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, videoRequest); + expect(bids).to.be.an('array').with.lengthOf(1); + expect(bids[0].cpm).to.equal(5.00); + expect(bids[0].vastXml).to.equal(''); + expect(bids[0].mediaType).to.equal(VIDEO); + }); + } + + it('should return empty array for empty response', function () { + const request = buildRequest(); + const bids = spec.interpretResponse({ body: {} }, request); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should return empty array for null response body', function () { + const request = buildRequest(); + const bids = spec.interpretResponse({ body: null }, request); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should return empty array for undefined response', function () { + const request = buildRequest(); + const bids = spec.interpretResponse(undefined, request); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should return empty array for undefined request', function () { + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: validBannerBid.bidId, + price: 1.23, + w: 300, + h: 250, + crid: 'creative-1', + adm: '
ad
', + mtype: 1 + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, undefined); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should handle multiple bids in seatbid', function () { + const bids2 = [ + { ...validBannerBid, bidId: 'bid-a' }, + { ...validBannerBid, bidId: 'bid-b', adUnitCode: 'adunit-2' } + ]; + const request = spec.buildRequests(bids2, { bidderCode: 'floxis', auctionId: 'a1' })[0]; + const serverResponse = { + body: { + seatbid: [{ + bid: [ + { impid: 'bid-a', price: 1.0, w: 300, h: 250, crid: 'c1', adm: '
1
', mtype: 1 }, + { impid: 'bid-b', price: 2.0, w: 300, h: 250, crid: 'c2', adm: '
2
', mtype: 1 } + ] + }] + } + }; + const result = spec.interpretResponse(serverResponse, request); + expect(result).to.have.lengthOf(2); + expect(result[0].cpm).to.equal(1.0); + expect(result[1].cpm).to.equal(2.0); + }); + + it('should set advertiserDomains from adomain', function () { + const request = buildRequest(); + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: validBannerBid.bidId, + price: 1.0, + w: 300, + h: 250, + crid: 'c1', + adm: '
ad
', + adomain: ['adv.com'], + mtype: 1 + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, request); + expect(bids[0].meta.advertiserDomains).to.deep.equal(['adv.com']); + }); + }); + + describe('getUserSyncs', function () { + it('should return empty array', function () { + expect(spec.getUserSyncs()).to.be.an('array').that.is.empty; + }); + }); + + describe('onBidWon', function () { + let triggerPixelStub; + + beforeEach(function () { + triggerPixelStub = sinon.stub(utils, 'triggerPixel'); + }); + + afterEach(function () { + triggerPixelStub.restore(); + }); + + it('should fire burl pixel', function () { + spec.onBidWon({ burl: 'https://example.com/burl' }); + expect(triggerPixelStub.calledWith('https://example.com/burl')).to.be.true; + }); + + it('should fire nurl pixel', function () { + spec.onBidWon({ nurl: 'https://example.com/nurl' }); + expect(triggerPixelStub.calledWith('https://example.com/nurl')).to.be.true; + }); + + it('should fire both burl and nurl pixels', function () { + spec.onBidWon({ + burl: 'https://example.com/burl', + nurl: 'https://example.com/nurl' + }); + expect(triggerPixelStub.callCount).to.equal(2); + }); + + it('should not fire pixels when no urls present', function () { + spec.onBidWon({}); + expect(triggerPixelStub.called).to.be.false; + }); + }); +});