diff --git a/modules/pgamdirectBidAdapter.ts b/modules/pgamdirectBidAdapter.ts new file mode 100644 index 0000000000..49bc146635 --- /dev/null +++ b/modules/pgamdirectBidAdapter.ts @@ -0,0 +1,193 @@ +/** + * PGAM Direct — Prebid.js bid adapter. + * + * Speaks canonical OpenRTB 2.6 directly to our bidder at + * https://rtb.pgammedia.com/rtb/v1/auction + * + * Intentionally NOT a fork of pgamsspBidAdapter — that adapter uses a + * custom TeqBlaze envelope shape, while our bidder speaks real OpenRTB. + * Sharing code would mean adding compat paths on both sides; cleaner to + * have a purpose-built adapter that's 1:1 with our server contract. + * + * Publisher-facing params shape: + * + * pbjs.addAdUnits([{ + * code: 'ad-slot-1', + * mediaTypes: { banner: { sizes: [[300, 250], [728, 90]] } }, + * bids: [{ + * bidder: 'pgamdirect', + * params: { + * orgId: 'pgam-acme-publisher', // REQUIRED — we issue this to the publisher + * placementId: 'leaderboard-728x90', // optional — maps to imp.tagid + * } + * }] + * }]); + * + * Built on top of libraries/ortbConverter so we inherit Prebid's + * standard handling of media types, floors (priceFloors module), + * schain (FPD normalisation → source.ext.schain), user.eids, GDPR/ + * USP/GPP/COPPA, site/device enrichment, and bidResponse mapping. + * All we layer in is our distinctive shape: + * + * - imp.ext.pgam.orgId (publisher tenant, required) + * - imp.tagid from params.placementId + * - bidResponse.meta.networkName from seatbid.seat + * + * GVL ID 1353 = PGAM Media LLC (registered with IAB Europe). + */ +import { BidderSpec, registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { deepSetValue } from '../src/utils.js'; + +import type { BidRequest } from '../src/adapterManager.js'; +import type { ORTBImp, ORTBRequest } from '../src/prebid.public.js'; +import type { BidResponse } from '../src/bidfactory.js'; + +const BIDDER_CODE = 'pgamdirect'; +const ENDPOINT_URL = 'https://rtb.pgammedia.com/rtb/v1/auction'; +const GVLID = 1353; +const DEFAULT_TTL = 300; + +/** + * Public params interface — contracted with publishers. + * Only orgId is required; placementId and bidfloor are optional + * conveniences. Floors module is the preferred path for dynamic + * bidfloor; params.bidfloor remains as a fallback for publishers on + * the legacy per-bidder floor shape. + */ +type PgamDirectBidParams = { + orgId: string; + placementId?: string; + bidfloor?: number; +}; + +declare module '../src/adUnits' { + interface BidderParams { + [BIDDER_CODE]: PgamDirectBidParams; + } +} + +const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: DEFAULT_TTL, + }, + /** + * Imp hook — layer our pgam-specific fields on top of the stock imp. + * The stock converter already populates id, secure, banner/video/ + * native, bidfloor (via priceFloors when enabled), and the ortb2Imp + * FPD merge (gpid, etc.). + */ + imp(buildImp, bidRequest: BidRequest, context) { + const imp: ORTBImp = buildImp(bidRequest, context); + // imp.ext.pgam.orgId routes the request to the publisher tenant on + // our side. Without it, the bidder returns publisher_not_found. + deepSetValue(imp, 'ext.pgam.orgId', bidRequest.params.orgId); + if (bidRequest.params.placementId) { + imp.tagid = String(bidRequest.params.placementId); + } + // Legacy params.bidfloor fallback — only applied when the + // priceFloors module DIDN'T populate imp.bidfloor. Keeps + // publishers who haven't adopted the floors module working. + if ( + (imp.bidfloor === undefined || imp.bidfloor === 0) && + typeof bidRequest.params.bidfloor === 'number' && + bidRequest.params.bidfloor >= 0 + ) { + imp.bidfloor = bidRequest.params.bidfloor; + imp.bidfloorcur = imp.bidfloorcur || 'USD'; + } + return imp; + }, + /** + * Request hook — first-price auction (at: 1) and USD currency + * passthrough. Everything else (tmax, source.tid, schain, + * user.eids, site, device, regs) is handled by the stock + * converter + FPD/ortb2 pipeline. + */ + request(buildRequest, imps, bidderRequest, context) { + const request: ORTBRequest = buildRequest(imps, bidderRequest, context); + request.at = 1; + if (!request.cur || request.cur.length === 0) { + request.cur = ['USD']; + } + return request; + }, + /** + * bidResponse hook — forward seatbid.seat into bidResponse.meta.networkName + * so publishers see which of our downstream DSPs cleared the auction. + * Everything else (cpm, currency, width/height, creativeId, ttl, + * netRevenue, advertiserDomains, mediaType + media-type-specific + * payload) is handled by the stock bidResponse processor. + */ + bidResponse(buildBidResponse, bid, context) { + const bidResponse: BidResponse = buildBidResponse(bid, context); + if (context.seatbid?.seat) { + bidResponse.meta = bidResponse.meta || {}; + bidResponse.meta.networkName = context.seatbid.seat; + } + return bidResponse; + }, +}); + +export const spec: BidderSpec = { + code: BIDDER_CODE, + gvlid: GVLID, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + /** + * Minimum validation: orgId must be a non-empty string. That's the + * identifier we use to route the request to a publisher/tenant on + * our side; without it the bidder returns publisher_not_found. + */ + isBidRequestValid(bid) { + const orgId = bid?.params?.orgId; + return typeof orgId === 'string' && orgId.length > 0; + }, + + buildRequests(bidRequests, bidderRequest) { + if (!bidRequests || bidRequests.length === 0) return []; + const data = converter.toORTB({ bidRequests, bidderRequest }); + return { + method: 'POST', + url: ENDPOINT_URL, + data, + options: { + // Deliberately omit `contentType`. A content-type of + // `application/json` is NOT a CORS-simple header (only + // text/plain, application/x-www-form-urlencoded, and + // multipart/form-data qualify), so setting it would force a + // browser preflight on every auction. Leaving it unset keeps + // the POST CORS-simple, which saves one RTT per request — + // meaningful at auction timeouts of 300ms. + withCredentials: false, + }, + }; + }, + + interpretResponse(serverResponse, request) { + if (!serverResponse?.body) return []; + // `fromORTB` returns an ExtendedResponse wrapper (`{ bids: [...] }`) + // in practice; unwrap to the flat BidResponse[] that publishers + // expect from interpretResponse. `AdapterResponse` is a union + // type so TS can't statically prove `.bids` is there — assert. + const result = converter.fromORTB({ + response: serverResponse.body, + request: request.data, + }) as { bids?: BidResponse[] }; + return result.bids || []; + }, + + /** + * Win notice — our bidder fires burl + nurl server-side on every + * winning auction, so Prebid doesn't need to do anything. Keeping + * this hook as a no-op so older Prebid.js versions that expect the + * method don't crash. + */ + onBidWon(_bid) { + // no-op — burl/nurl handled server-side by bidder-edge + }, +}; + +registerBidder(spec); diff --git a/test/spec/modules/pgamdirectBidAdapter_spec.js b/test/spec/modules/pgamdirectBidAdapter_spec.js new file mode 100644 index 0000000000..064ebc982b --- /dev/null +++ b/test/spec/modules/pgamdirectBidAdapter_spec.js @@ -0,0 +1,284 @@ +import { expect } from 'chai'; +import { spec } from 'modules/pgamdirectBidAdapter.js'; +import { BANNER, NATIVE, VIDEO } from 'src/mediaTypes.js'; + +/** + * Spec for modules/pgamdirectBidAdapter.ts. + * + * Scope: only what's specific to this adapter. We deliberately do NOT + * re-test the behaviour that's owned by libraries/ortbConverter (floors + * module integration, schain-from-ortb2 lookup, GDPR/USP/GPP/COPPA + * forwarding, userIdAsEids, tmax, native JSON parsing, VAST detection, + * advertiserDomains, etc.) — those have comprehensive coverage in + * ortbConverter_spec.js and spec files for other adapters that use it. + * If we copy-pasted those tests here we'd be asserting on the library, + * not the adapter, and would need to update them every time Prebid + * shifts ORTB defaults. + * + * What IS covered here: + * - isBidRequestValid — orgId validation + * - buildRequests — POST shape, URL, CORS-simple options + * (no contentType, no customHeaders) + * - imp hook — ext.pgam.orgId, placementId → tagid + * params.bidfloor fallback when floors module absent + * - request hook — at=1 (first-price), currency default + * - bidResponse hook — seatbid.seat → meta.networkName + * - onBidWon — doesn't throw + */ + +const ENDPOINT_URL = 'https://rtb.pgammedia.com/rtb/v1/auction'; + +// ---------- fixtures -------------------------------------------------------- + +function bannerBid(overrides) { + return Object.assign({ + bidId: 'bid-1', + bidder: 'pgamdirect', + adUnitCode: 'adunit-1', + mediaTypes: { banner: { sizes: [[300, 250], [728, 90]] } }, + params: { orgId: 'pgam-acme-publisher' }, + }, overrides || {}); +} + +function bidderRequest(overrides) { + return Object.assign({ + auctionId: 'auction-1', + bidderCode: 'pgamdirect', + timeout: 500, + refererInfo: { + page: 'https://publisher.example/page', + domain: 'publisher.example', + ref: 'https://referrer.example/', + }, + }, overrides || {}); +} + +// ---------- isBidRequestValid ---------------------------------------------- + +describe('pgamdirect: isBidRequestValid', () => { + it('accepts a bid with params.orgId as a non-empty string', () => { + expect(spec.isBidRequestValid(bannerBid())).to.equal(true); + }); + + it('rejects when params is missing entirely', () => { + expect(spec.isBidRequestValid({ bidId: 'x' })).to.equal(false); + }); + + it('rejects when params.orgId is missing', () => { + expect(spec.isBidRequestValid(bannerBid({ params: {} }))).to.equal(false); + }); + + it('rejects empty string orgId', () => { + expect(spec.isBidRequestValid(bannerBid({ params: { orgId: '' } }))).to.equal(false); + }); + + it('rejects non-string orgId (number)', () => { + expect(spec.isBidRequestValid(bannerBid({ params: { orgId: 42 } }))).to.equal(false); + }); + + it('rejects non-string orgId (object)', () => { + expect(spec.isBidRequestValid(bannerBid({ params: { orgId: { x: 1 } } }))).to.equal(false); + }); +}); + +// ---------- buildRequests -------------------------------------------------- + +describe('pgamdirect: buildRequests', () => { + it('returns empty array on empty input', () => { + expect(spec.buildRequests([], bidderRequest())).to.deep.equal([]); + expect(spec.buildRequests(null, bidderRequest())).to.deep.equal([]); + }); + + it('produces a single POST per auction with the correct URL', () => { + const built = spec.buildRequests( + [bannerBid(), bannerBid({ bidId: 'bid-2' })], + bidderRequest(), + ); + expect(built.method).to.equal('POST'); + expect(built.url).to.equal(ENDPOINT_URL); + // Both imps in ONE request + expect(built.data.imp).to.have.lengthOf(2); + }); + + describe('CORS-simple request shape (avoids browser preflight)', () => { + it('does NOT set customHeaders', () => { + const built = spec.buildRequests([bannerBid()], bidderRequest()); + expect(built.options.customHeaders).to.be.undefined; + }); + + it('does NOT set contentType', () => { + // Even `application/json` forces a preflight — only text/plain, + // application/x-www-form-urlencoded, and multipart/form-data + // qualify as CORS-simple content types. By leaving contentType + // unset we keep the POST preflight-free. + const built = spec.buildRequests([bannerBid()], bidderRequest()); + expect(built.options.contentType).to.be.undefined; + }); + + it('sets withCredentials: false', () => { + const built = spec.buildRequests([bannerBid()], bidderRequest()); + expect(built.options.withCredentials).to.equal(false); + }); + }); + + describe('imp hook — pgam-specific fields', () => { + it('injects imp.ext.pgam.orgId for every imp', () => { + const built = spec.buildRequests([bannerBid()], bidderRequest()); + expect(built.data.imp[0].ext.pgam.orgId).to.equal('pgam-acme-publisher'); + }); + + it('carries placementId into imp.tagid', () => { + const built = spec.buildRequests( + [bannerBid({ params: { orgId: 'pgam-x', placementId: 'leader-728x90' } })], + bidderRequest(), + ); + expect(built.data.imp[0].tagid).to.equal('leader-728x90'); + }); + + it('omits tagid when placementId is not set', () => { + const built = spec.buildRequests([bannerBid()], bidderRequest()); + expect(built.data.imp[0].tagid).to.be.undefined; + }); + }); + + describe('imp hook — params.bidfloor legacy fallback', () => { + it('applies params.bidfloor when floors module did not populate imp.bidfloor', () => { + const built = spec.buildRequests( + [bannerBid({ params: { orgId: 'pgam-x', bidfloor: 0.75 } })], + bidderRequest(), + ); + expect(built.data.imp[0].bidfloor).to.equal(0.75); + expect(built.data.imp[0].bidfloorcur).to.equal('USD'); + }); + + it('ignores params.bidfloor when it is negative', () => { + const built = spec.buildRequests( + [bannerBid({ params: { orgId: 'pgam-x', bidfloor: -1 } })], + bidderRequest(), + ); + // No floors module active + negative param → no floor set. + expect(built.data.imp[0].bidfloor || 0).to.equal(0); + }); + }); + + describe('request hook — first-price + currency default', () => { + it('sets request.at = 1 (first-price)', () => { + const built = spec.buildRequests([bannerBid()], bidderRequest()); + expect(built.data.at).to.equal(1); + }); + + it('defaults currency to USD when none is set upstream', () => { + const built = spec.buildRequests([bannerBid()], bidderRequest()); + expect(built.data.cur).to.deep.equal(['USD']); + }); + + it('respects an existing currency when the converter already set one', () => { + const built = spec.buildRequests( + [bannerBid()], + bidderRequest({ ortb2: { cur: ['EUR'] } }), + ); + expect(built.data.cur).to.deep.equal(['EUR']); + }); + }); +}); + +// ---------- interpretResponse ---------------------------------------------- + +describe('pgamdirect: interpretResponse', () => { + // A minimal valid built request so fromORTB can correlate impids back + // to their bidRequest. The adapter's interpretResponse hands the same + // request.data it produced in buildRequests back to ortbConverter. + function builtRequestFor(bids) { + return spec.buildRequests(bids || [bannerBid()], bidderRequest()); + } + + it('returns an empty array when the body is missing', () => { + const request = builtRequestFor(); + expect(spec.interpretResponse({}, request)).to.deep.equal([]); + expect(spec.interpretResponse({ body: null }, request)).to.deep.equal([]); + }); + + it('maps a single banner bid with advertiserDomains + networkName from seat', () => { + const request = builtRequestFor(); + const body = { + id: request.data.id, + cur: 'USD', + seatbid: [{ + seat: 'pgam-test-dsp', + bid: [{ + id: 'dsp-bid-1', + impid: 'bid-1', + price: 2.5, + adm: 'ad', + crid: 'creative-001', + adomain: ['advertiser.example'], + w: 300, + h: 250, + exp: 180, + mtype: 1, // ORTB: 1=banner + }], + }], + }; + const bids = spec.interpretResponse({ body }, request); + expect(bids).to.have.lengthOf(1); + expect(bids[0].cpm).to.equal(2.5); + expect(bids[0].currency).to.equal('USD'); + expect(bids[0].width).to.equal(300); + expect(bids[0].height).to.equal(250); + expect(bids[0].creativeId).to.equal('creative-001'); + expect(bids[0].ttl).to.equal(180); + expect(bids[0].mediaType).to.equal(BANNER); + expect(bids[0].meta.advertiserDomains).to.deep.equal(['advertiser.example']); + // The one thing this adapter explicitly layers on the response: + expect(bids[0].meta.networkName).to.equal('pgam-test-dsp'); + }); + + it('omits meta.networkName when seatbid has no seat', () => { + const request = builtRequestFor(); + const body = { + id: request.data.id, + cur: 'USD', + seatbid: [{ + bid: [{ + id: 'd', + impid: 'bid-1', + price: 1.0, + adm: '', + crid: 'c', + w: 300, + h: 250, + mtype: 1, + }], + }], + }; + const bids = spec.interpretResponse({ body }, request); + expect(bids).to.have.lengthOf(1); + expect(bids[0].meta.networkName).to.be.undefined; + }); +}); + +// ---------- onBidWon ------------------------------------------------------- + +describe('pgamdirect: onBidWon', () => { + it('does not throw on arbitrary bid input', () => { + expect(() => spec.onBidWon({})).to.not.throw(); + expect(() => spec.onBidWon(null)).to.not.throw(); + expect(() => spec.onBidWon(undefined)).to.not.throw(); + }); +}); + +// ---------- supportedMediaTypes / gvlid ------------------------------------ + +describe('pgamdirect: adapter metadata', () => { + it('declares BANNER, VIDEO, NATIVE as supported media types', () => { + expect(spec.supportedMediaTypes).to.deep.equal([BANNER, VIDEO, NATIVE]); + }); + + it('registers GVL ID 1353 (PGAM Media LLC)', () => { + expect(spec.gvlid).to.equal(1353); + }); + + it('has the correct bidder code', () => { + expect(spec.code).to.equal('pgamdirect'); + }); +});