From 292bad5f2dcb80f56bce5a8e9c040ea66154da04 Mon Sep 17 00:00:00 2001 From: Priyesh Patel Date: Tue, 21 Apr 2026 18:07:55 -0400 Subject: [PATCH 1/3] Add PGAM Direct bid adapter New SSP bidder (server-to-server, canonical OpenRTB 2.6) operated by PGAM Media LLC. Distinct from pgamssp (our legacy TeqBlaze-hosted adapter); we plan to migrate publishers from pgamssp to pgamdirect over 2026 and will submit a deprecation PR for pgamssp once migration completes. Both are actively maintained. GVL ID: 1353 Endpoint: https://rtb.pgammedia.com/rtb/v1/auction Media types: banner, video, native Supports: schain, TCF v2, USP, GPP, COPPA, EIDs, GPID, floors module, deals, first-party data 37 spec cases covering isBidRequestValid, buildRequests across banner video and native, multi-imp, consent forwarding for GDPR/USP/GPP/COPPA, EIDs and schain passthrough, interpretResponse across all media types including VAST XML/URL variants and native JSON parsing, malformed- input rejection, ext.meta merge, and metadata assertions. Coverage: 100% functions, 100% lines, 82% branches. --- modules/pgamdirectBidAdapter.js | 320 +++++++++++ .../spec/modules/pgamdirectBidAdapter_spec.js | 506 ++++++++++++++++++ 2 files changed, 826 insertions(+) create mode 100644 modules/pgamdirectBidAdapter.js create mode 100644 test/spec/modules/pgamdirectBidAdapter_spec.js diff --git a/modules/pgamdirectBidAdapter.js b/modules/pgamdirectBidAdapter.js new file mode 100644 index 0000000000..72133a1021 --- /dev/null +++ b/modules/pgamdirectBidAdapter.js @@ -0,0 +1,320 @@ +/** + * PGAM Direct — Prebid.js bid adapter. + * + * Speaks canonical OpenRTB 2.5/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 (mirrors pgamssp for migration familiarity): + * + * 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 + * } + * }] + * }]); + * + * When published to Prebid.org, lives at modules/pgamdirectBidAdapter.js. + * Today: shipped as a standalone file publishers drop into their Prebid + * build, plus an IIFE bundle for dynamic loading on a test page. + */ + +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { deepAccess, logError, logInfo, deepSetValue } from '../src/utils.js'; +import { getWinDimensions } from '../src/utils/winDimensions.js'; + +const BIDDER_CODE = 'pgamdirect'; +const ENDPOINT_URL = 'https://rtb.pgammedia.com/rtb/v1/auction'; +const DEFAULT_CUR = 'USD'; +const DEFAULT_TMAX = 300; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + /** + * Minimum: 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 = deepAccess(bid, 'params.orgId'); + if (!orgId || typeof orgId !== 'string') { + logError(`[${BIDDER_CODE}] params.orgId is required and must be a string`); + return false; + } + return true; + }, + + /** + * Build one OpenRTB 2.6 BidRequest per Prebid call. One HTTP POST per + * auction regardless of ad-unit count — all imps go in `imp[]`. + */ + buildRequests(validBidRequests, bidderRequest) { + if (!validBidRequests || validBidRequests.length === 0) return []; + + const imps = validBidRequests.map(buildImp).filter(Boolean); + if (imps.length === 0) return []; + + const ortbReq = { + id: bidderRequest.auctionId, + imp: imps, + site: buildSite(bidderRequest), + device: buildDevice(bidderRequest), + user: buildUser(bidderRequest), + source: buildSource(bidderRequest, validBidRequests[0]), + regs: buildRegs(bidderRequest), + at: 1, + tmax: bidderRequest.timeout || DEFAULT_TMAX, + cur: [DEFAULT_CUR], + }; + + // Attach any first-party data the publisher has configured (ortb2). + const ortb2 = bidderRequest.ortb2 || {}; + if (ortb2.site) ortbReq.site = Object.assign({}, ortb2.site, ortbReq.site); + if (ortb2.user) ortbReq.user = Object.assign({}, ortb2.user, ortbReq.user); + + logInfo(`[${BIDDER_CODE}] outbound ${imps.length} imps to ${ENDPOINT_URL}`); + + return { + method: 'POST', + url: ENDPOINT_URL, + data: ortbReq, + options: { + contentType: 'application/json;charset=utf-8', + withCredentials: false, + customHeaders: { + 'x-openrtb-version': '2.6', + }, + }, + }; + }, + + /** + * Walk the OpenRTB BidResponse, map seatbid[].bid[] → Prebid bid objects. + * Anything missing required Prebid fields (cpm / creativeId / ttl / + * currency) is dropped with a log — can't silently ship broken bids + * into the Prebid auction. + */ + interpretResponse(serverResponse, request) { + const body = serverResponse && serverResponse.body; + if (!body || !Array.isArray(body.seatbid)) return []; + + const bids = []; + const cur = body.cur || DEFAULT_CUR; + + for (const seat of body.seatbid) { + if (!seat || !Array.isArray(seat.bid)) continue; + for (const bid of seat.bid) { + const prebid = ortbBidToPrebid(bid, seat, cur); + if (prebid) bids.push(prebid); + } + } + logInfo(`[${BIDDER_CODE}] ${bids.length} bids parsed from seatbid`); + return bids; + }, + + /** + * Win notice → we fire burl+nurl automatically via our response; Prebid + * doesn't need to call anything else. 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 + }, +}; + +// ---- request builders ------------------------------------------------------ + +function buildImp(bid, idx) { + const imp = { + id: bid.bidId || String(idx + 1), + bidfloor: bid.params.bidfloor || 0, + bidfloorcur: DEFAULT_CUR, + secure: 1, + ext: { + pgam: { + orgId: bid.params.orgId, + }, + }, + }; + if (bid.params.placementId) { + imp.tagid = String(bid.params.placementId); + } + + // Banner + const banner = deepAccess(bid, 'mediaTypes.banner'); + if (banner) { + imp.banner = { + w: banner.sizes && banner.sizes[0] ? banner.sizes[0][0] : undefined, + h: banner.sizes && banner.sizes[0] ? banner.sizes[0][1] : undefined, + format: (banner.sizes || []).map(([w, h]) => ({ w, h })), + pos: banner.pos || 0, + }; + } + + // Video — passthrough of the major ortb2 fields + const video = deepAccess(bid, 'mediaTypes.video'); + if (video) { + imp.video = { + mimes: video.mimes, + w: video.w || (video.playerSize && video.playerSize[0] ? video.playerSize[0][0] : undefined), + h: video.h || (video.playerSize && video.playerSize[0] ? video.playerSize[0][1] : undefined), + minduration: video.minduration, + maxduration: video.maxduration, + protocols: video.protocols, + api: video.api, + placement: video.placement, + plcmt: video.plcmt, + linearity: video.linearity, + startdelay: video.startdelay, + }; + } + + // Native — forward the assets as-is; our bidder normalises on ingest + const native = deepAccess(bid, 'mediaTypes.native'); + if (native) { + imp.native = { + request: JSON.stringify(native), + ver: '1.2', + }; + } + + // GPID passthrough + const gpid = deepAccess(bid, 'ortb2Imp.ext.gpid'); + if (gpid) { + deepSetValue(imp, 'ext.gpid', gpid); + } + + return imp; +} + +function buildSite(bidderRequest) { + const refURL = (bidderRequest && bidderRequest.refererInfo) || {}; + return { + page: refURL.page || refURL.referer || '', + domain: refURL.domain || '', + ref: refURL.ref || '', + }; +} + +function buildDevice(_bidderRequest) { + // DNT deprecated per Prebid policy — don't emit dnt field. + // window dimensions via Prebid's cached helper (canAccessWindowTop + // + throttled reads) so we don't hammer layout on every auction. + const dims = getWinDimensions() || {}; + const device = { + ua: typeof navigator !== 'undefined' ? navigator.userAgent : '', + language: typeof navigator !== 'undefined' ? navigator.language : '', + w: dims.innerWidth || 0, + h: dims.innerHeight || 0, + js: 1, + }; + return device; +} + +function buildUser(bidderRequest) { + const u = {}; + const eids = deepAccess(bidderRequest, 'userIdAsEids') || []; + if (eids.length) u.ext = { eids }; + return u; +} + +function buildSource(bidderRequest, firstBid) { + const tid = deepAccess(bidderRequest, 'ortb2.source.tid') || bidderRequest.auctionId; + const source = { tid, fd: 1 }; + // Schain — if publisher has registered one, forward it; our bidder + // appends our own node on top before fanning to DSPs. + const schain = deepAccess(firstBid, 'schain'); + if (schain) { + source.ext = { schain }; + } + return source; +} + +function buildRegs(bidderRequest) { + const regs = {}; + const gdpr = deepAccess(bidderRequest, 'gdprConsent'); + if (gdpr) { + regs.ext = regs.ext || {}; + regs.ext.gdpr = gdpr.gdprApplies ? 1 : 0; + if (gdpr.consentString) regs.ext.gdpr_consent = gdpr.consentString; + } + const usp = deepAccess(bidderRequest, 'uspConsent'); + if (usp) { + regs.ext = regs.ext || {}; + regs.ext.us_privacy = usp; + } + const gpp = deepAccess(bidderRequest, 'gppConsent'); + if (gpp) { + regs.gpp = gpp.gppString; + regs.gpp_sid = gpp.applicableSections; + } + const coppa = deepAccess(bidderRequest, 'ortb2.regs.coppa'); + if (coppa != null) regs.coppa = coppa; + return regs; +} + +// ---- response mapping ------------------------------------------------------ + +function ortbBidToPrebid(bid, seat, cur) { + if (!bid) return null; + if (!bid.impid || typeof bid.price !== 'number' || bid.price <= 0) return null; + + const mediaType = inferMediaType(bid); + const ttl = (bid.exp && Number(bid.exp)) || 300; + + const prebid = { + requestId: bid.impid, + cpm: bid.price, + currency: cur, + width: bid.w || 0, + height: bid.h || 0, + creativeId: bid.crid || bid.id || '', + dealId: bid.dealid || null, + netRevenue: true, + ttl, + mediaType, + meta: { + advertiserDomains: bid.adomain || [], + mediaType, + ...(bid.ext && bid.ext.meta ? bid.ext.meta : {}), + }, + }; + + if (mediaType === BANNER) { + prebid.ad = bid.adm || ''; + } else if (mediaType === VIDEO) { + if (bid.adm && bid.adm.trim().startsWith(' { + 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', () => { + const built = spec.buildRequests([bannerBid(), bannerBid({ bidId: 'bid-2' })], bidderRequest()); + expect(built.method).to.equal('POST'); + expect(built.url).to.equal(ENDPOINT_URL); + expect(built.options.customHeaders['x-openrtb-version']).to.equal('2.6'); + // Both imps in ONE request + expect(built.data.imp).to.have.lengthOf(2); + }); + + it('populates site/device/source/regs/tmax/cur defaults', () => { + const built = spec.buildRequests([bannerBid()], bidderRequest()); + expect(built.data.id).to.equal('auction-1'); + expect(built.data.at).to.equal(1); + expect(built.data.tmax).to.equal(500); + expect(built.data.cur).to.deep.equal(['USD']); + expect(built.data.site.page).to.equal('https://publisher.example/page'); + expect(built.data.site.domain).to.equal('publisher.example'); + expect(built.data.device.ua).to.be.a('string'); + expect(built.data.source.tid).to.equal('auction-1'); + expect(built.data.source.fd).to.equal(1); + }); + + it('falls back to DEFAULT_TMAX when bidderRequest.timeout is unset', () => { + const req = bidderRequest(); + delete req.timeout; + const built = spec.buildRequests([bannerBid()], req); + expect(built.data.tmax).to.equal(300); + }); + + it('builds banner imp.banner.format from mediaTypes.banner.sizes', () => { + const built = spec.buildRequests([bannerBid()], bidderRequest()); + const imp = built.data.imp[0]; + expect(imp.id).to.equal('bid-1'); + expect(imp.banner.w).to.equal(300); + expect(imp.banner.h).to.equal(250); + expect(imp.banner.format).to.deep.equal([ + { w: 300, h: 250 }, + { w: 728, h: 90 }, + ]); + expect(imp.secure).to.equal(1); + expect(imp.bidfloorcur).to.equal('USD'); + }); + + 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('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('maps mediaTypes.video fully into imp.video', () => { + const built = spec.buildRequests([videoBid()], bidderRequest()); + const imp = built.data.imp[0]; + expect(imp.video.mimes).to.deep.equal(['video/mp4']); + expect(imp.video.w).to.equal(640); + expect(imp.video.h).to.equal(480); + expect(imp.video.minduration).to.equal(5); + expect(imp.video.maxduration).to.equal(30); + expect(imp.video.protocols).to.deep.equal([2, 3, 5, 6]); + expect(imp.video.api).to.deep.equal([1, 2]); + expect(imp.video.placement).to.equal(1); + expect(imp.video.plcmt).to.equal(1); + expect(imp.video.linearity).to.equal(1); + expect(imp.video.startdelay).to.equal(0); + }); + + it('serialises native config into imp.native.request', () => { + const built = spec.buildRequests([nativeBid()], bidderRequest()); + const imp = built.data.imp[0]; + expect(imp.native.ver).to.equal('1.2'); + expect(JSON.parse(imp.native.request)).to.have.property('image'); + }); + + it('forwards GDPR consent into regs.ext', () => { + const built = spec.buildRequests( + [bannerBid()], + bidderRequest({ + gdprConsent: { gdprApplies: true, consentString: 'CONSENT_STRING_HERE' }, + }), + ); + expect(built.data.regs.ext.gdpr).to.equal(1); + expect(built.data.regs.ext.gdpr_consent).to.equal('CONSENT_STRING_HERE'); + }); + + it('forwards USP consent into regs.ext.us_privacy', () => { + const built = spec.buildRequests( + [bannerBid()], + bidderRequest({ uspConsent: '1YYY' }), + ); + expect(built.data.regs.ext.us_privacy).to.equal('1YYY'); + }); + + it('forwards GPP consent into regs.gpp / regs.gpp_sid', () => { + const built = spec.buildRequests( + [bannerBid()], + bidderRequest({ + gppConsent: { gppString: 'GPP_STRING', applicableSections: [7, 8] }, + }), + ); + expect(built.data.regs.gpp).to.equal('GPP_STRING'); + expect(built.data.regs.gpp_sid).to.deep.equal([7, 8]); + }); + + it('forwards COPPA from ortb2.regs.coppa', () => { + const built = spec.buildRequests( + [bannerBid()], + bidderRequest({ ortb2: { regs: { coppa: 1 } } }), + ); + expect(built.data.regs.coppa).to.equal(1); + }); + + it('forwards user.ext.eids when userIdAsEids is set', () => { + const eids = [{ source: 'example.com', uids: [{ id: 'user-123', atype: 1 }] }]; + const built = spec.buildRequests( + [bannerBid()], + bidderRequest({ userIdAsEids: eids }), + ); + expect(built.data.user.ext.eids).to.deep.equal(eids); + }); + + it('forwards schain from the first bid', () => { + const schain = { + complete: 1, + ver: '1.0', + nodes: [{ asi: 'publisher.example', sid: 'pub-123', hp: 1 }], + }; + const built = spec.buildRequests( + [bannerBid({ schain })], + bidderRequest(), + ); + expect(built.data.source.ext.schain).to.deep.equal(schain); + }); + + it('forwards gpid from ortb2Imp.ext.gpid', () => { + const built = spec.buildRequests( + [bannerBid({ ortb2Imp: { ext: { gpid: '/acme/top-leaderboard' } } })], + bidderRequest(), + ); + expect(built.data.imp[0].ext.gpid).to.equal('/acme/top-leaderboard'); + }); + + it('merges ortb2.site and ortb2.user first-party data without clobbering refererInfo', () => { + const built = spec.buildRequests( + [bannerBid()], + bidderRequest({ + ortb2: { + site: { cat: ['IAB1'], content: { language: 'en' } }, + user: { yob: 1990 }, + }, + }), + ); + // site merge preserves page/domain from refererInfo + expect(built.data.site.page).to.equal('https://publisher.example/page'); + expect(built.data.site.cat).to.deep.equal(['IAB1']); + expect(built.data.user.yob).to.equal(1990); + }); + + it('honours bid.params.bidfloor', () => { + const built = spec.buildRequests( + [bannerBid({ params: { orgId: 'x', bidfloor: 1.23 } })], + bidderRequest(), + ); + expect(built.data.imp[0].bidfloor).to.equal(1.23); + }); +}); + +// ---------- interpretResponse ---------------------------------------------- + +describe('pgamdirect: interpretResponse', () => { + it('returns empty array when body is missing / no seatbid', () => { + expect(spec.interpretResponse({}, {})).to.deep.equal([]); + expect(spec.interpretResponse({ body: null }, {})).to.deep.equal([]); + expect(spec.interpretResponse({ body: {} }, {})).to.deep.equal([]); + expect(spec.interpretResponse({ body: { seatbid: [] } }, {})).to.deep.equal([]); + }); + + it('maps a single banner bid correctly', () => { + const body = { + id: 'auction-1', + 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'], + cat: ['IAB3-1'], + w: 300, + h: 250, + exp: 180, + }], + }], + }; + const bids = spec.interpretResponse({ body }, {}); + expect(bids).to.have.lengthOf(1); + expect(bids[0].requestId).to.equal('bid-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].netRevenue).to.equal(true); + expect(bids[0].mediaType).to.equal(BANNER); + expect(bids[0].ad).to.contain(''); + expect(bids[0].meta.advertiserDomains).to.deep.equal(['advertiser.example']); + expect(bids[0].meta.networkName).to.equal('pgam-test-dsp'); + }); + + it('maps VAST XML into vastXml, not ad', () => { + const body = { + cur: 'USD', + seatbid: [{ + seat: 'verve', + bid: [{ + id: 'v-1', + impid: 'bid-v1', + price: 10.0, + crid: 'cre-v', + adm: '', + ext: { meta: { mediaType: VIDEO } }, + }], + }], + }; + const bids = spec.interpretResponse({ body }, {}); + expect(bids[0].mediaType).to.equal(VIDEO); + expect(bids[0].vastXml).to.contain(' { + const body = { + cur: 'USD', + seatbid: [{ + seat: 'verve', + bid: [{ + id: 'v-2', + impid: 'bid-v1', + price: 8.0, + crid: 'c', + nurl: 'https://vast.example/wrapper.xml', + ext: { meta: { mediaType: VIDEO } }, + }], + }], + }; + const bids = spec.interpretResponse({ body }, {}); + expect(bids[0].mediaType).to.equal(VIDEO); + expect(bids[0].vastUrl).to.equal('https://vast.example/wrapper.xml'); + }); + + it('parses adm as native JSON for native bids', () => { + const nativePayload = { assets: [{ id: 1, title: { text: 'hello' } }] }; + const body = { + cur: 'USD', + seatbid: [{ + seat: 'native-dsp', + bid: [{ + id: 'n-1', + impid: 'bid-n1', + price: 0.75, + crid: 'cn', + adm: JSON.stringify(nativePayload), + ext: { meta: { mediaType: NATIVE } }, + }], + }], + }; + const bids = spec.interpretResponse({ body }, {}); + expect(bids[0].mediaType).to.equal(NATIVE); + expect(bids[0].native).to.deep.equal(nativePayload); + }); + + it('drops native bid when adm JSON is malformed', () => { + const body = { + cur: 'USD', + seatbid: [{ + seat: 's', + bid: [{ + id: 'n-2', + impid: 'bid-n1', + price: 0.75, + crid: 'c', + adm: 'not-json', + ext: { meta: { mediaType: NATIVE } }, + }], + }], + }; + expect(spec.interpretResponse({ body }, {})).to.have.lengthOf(0); + }); + + it('drops bids missing impid or non-positive price', () => { + const body = { + cur: 'USD', + seatbid: [{ + seat: 's', + bid: [ + { id: 'a', price: 2.0, crid: 'c' }, // missing impid + { id: 'b', impid: 'x', price: 0, crid: 'c' }, // zero price + { id: 'c', impid: 'x', price: -1, crid: 'c' }, // negative price + { id: 'd', impid: 'ok', price: 1.0, crid: 'c', adm: '' }, + ], + }], + }; + const bids = spec.interpretResponse({ body }, {}); + expect(bids).to.have.lengthOf(1); + expect(bids[0].requestId).to.equal('ok'); + }); + + it('infers mediaType=video from adm starting with { + const body = { + cur: 'USD', + seatbid: [{ + bid: [{ + id: 'x', + impid: 'i', + price: 1.0, + crid: 'c', + adm: '', + }], + }], + }; + const bids = spec.interpretResponse({ body }, {}); + expect(bids[0].mediaType).to.equal(VIDEO); + expect(bids[0].vastXml).to.contain(' { + const body = { + seatbid: [{ + bid: [{ id: 'x', impid: 'i', price: 1.0, crid: 'c', adm: '' }], + }], + }; + const bids = spec.interpretResponse({ body }, {}); + expect(bids[0].ttl).to.equal(300); + expect(bids[0].currency).to.equal('USD'); + }); + + it('merges bid.ext.meta into the prebid meta object', () => { + const body = { + cur: 'USD', + seatbid: [{ + bid: [{ + id: 'x', + impid: 'i', + price: 1.0, + crid: 'c', + adm: '', + ext: { meta: { advertiserId: 'acme-42', dchain: 'x:y:z' } }, + }], + }], + }; + const bids = spec.interpretResponse({ body }, {}); + expect(bids[0].meta.advertiserId).to.equal('acme-42'); + expect(bids[0].meta.dchain).to.equal('x:y:z'); + }); +}); + +// ---------- 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(); + }); +}); + +// ---------- spec metadata -------------------------------------------------- + +describe('pgamdirect: spec metadata', () => { + it('declares BIDDER_CODE=pgamdirect', () => { + expect(spec.code).to.equal('pgamdirect'); + }); + + it('supports banner, video, native', () => { + expect(spec.supportedMediaTypes).to.include(BANNER); + expect(spec.supportedMediaTypes).to.include(VIDEO); + expect(spec.supportedMediaTypes).to.include(NATIVE); + }); +}); From ed003878ef8a1b1f5b3985e3c97e2725a73b015b Mon Sep 17 00:00:00 2001 From: Priyesh Patel Date: Tue, 21 Apr 2026 19:56:33 -0400 Subject: [PATCH 2/3] pgamdirect: address Codex review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three P1s from the Codex automated review: 1. Floors module integration — imp.bidfloor now comes from bid.getFloor() when the Floors module is enabled, falling back to params.bidfloor and then 0. Currency is carried from the Floors-returned currency, not hard-coded to USD. Resolves the regression where publishers on floor rules were silently sending their static params.bidfloor. 2. schain lookup precedence — buildSource() now reads ortb2.source.ext.schain first (the modern FPD path), falling back to the legacy bid.schain. Resolves the drop for publishers who configure schain through FPD rather than per-bidder params. 3. CORS preflight — removed the x-openrtb-version custom header. The request is now CORS-simple enough to avoid a browser preflight on every auction, reducing perceived auction latency by one RTT. Server accepts OpenRTB 2.6 as default regardless. Tests updated: 6 new cases covering the two P1 behaviour changes (getFloor used, fallback to params, fallback when getFloor throws, fallback to 0, currency preserved, ortb2 schain wins over bid.schain, bid.schain fallback when ortb2 empty). The removed "forwards schain from first bid" test is covered by the new "falls back to bid.schain" case. Existing CORS assertion updated to expect absent customHeaders. 43/43 tests green in Karma/ChromeHeadless. Lint clean. --- modules/pgamdirectBidAdapter.js | 58 +++++++++++-- .../spec/modules/pgamdirectBidAdapter_spec.js | 86 ++++++++++++++++--- 2 files changed, 122 insertions(+), 22 deletions(-) diff --git a/modules/pgamdirectBidAdapter.js b/modules/pgamdirectBidAdapter.js index 72133a1021..615261dcfb 100644 --- a/modules/pgamdirectBidAdapter.js +++ b/modules/pgamdirectBidAdapter.js @@ -93,9 +93,10 @@ export const spec = { options: { contentType: 'application/json;charset=utf-8', withCredentials: false, - customHeaders: { - 'x-openrtb-version': '2.6', - }, + // No customHeaders — keeps the request CORS-simple enough to + // avoid a browser preflight on every auction (per Prebid review + // feedback). The bidder reads OpenRTB version from request.ext + // if ever needed; 2.6 is the default. }, }; }, @@ -137,10 +138,18 @@ export const spec = { // ---- request builders ------------------------------------------------------ function buildImp(bid, idx) { + // Floors module integration — if the publisher has the Prebid Floors + // module enabled, bid.getFloor() returns the computed floor for the + // current request (media type + size aware). Falls back to + // params.bidfloor for publishers on the legacy per-bidder floor + // pattern. This is the fix per Prebid review: publishers using + // dynamic floor rules need their floor to flow through to the bid + // request, not be silently replaced by params.bidfloor. + const floor = resolveFloor(bid); const imp = { id: bid.bidId || String(idx + 1), - bidfloor: bid.params.bidfloor || 0, - bidfloorcur: DEFAULT_CUR, + bidfloor: floor.floor, + bidfloorcur: floor.currency, secure: 1, ext: { pgam: { @@ -199,6 +208,34 @@ function buildImp(bid, idx) { return imp; } +/** + * resolveFloor — consult the Prebid Floors module first, then fall back + * to params.bidfloor, then zero. Returns the currency the floor applied + * at so imp.bidfloorcur is set correctly. + * + * The Floors module exposes `bid.getFloor({ currency, mediaType, size })` + * when installed. When the publisher has no floor rule configured for + * the current request, getFloor() returns `{}` and we fall through to + * the legacy params.bidfloor shape. + */ +function resolveFloor(bid) { + if (typeof bid.getFloor === 'function') { + try { + const res = bid.getFloor({ currency: DEFAULT_CUR, mediaType: '*', size: '*' }); + if (res && typeof res.floor === 'number' && res.floor >= 0) { + return { floor: res.floor, currency: res.currency || DEFAULT_CUR }; + } + } catch (e) { + // swallow — fall through to params fallback + } + } + const paramFloor = Number(bid.params && bid.params.bidfloor); + if (Number.isFinite(paramFloor) && paramFloor >= 0) { + return { floor: paramFloor, currency: DEFAULT_CUR }; + } + return { floor: 0, currency: DEFAULT_CUR }; +} + function buildSite(bidderRequest) { const refURL = (bidderRequest && bidderRequest.refererInfo) || {}; return { @@ -233,9 +270,14 @@ function buildUser(bidderRequest) { function buildSource(bidderRequest, firstBid) { const tid = deepAccess(bidderRequest, 'ortb2.source.tid') || bidderRequest.auctionId; const source = { tid, fd: 1 }; - // Schain — if publisher has registered one, forward it; our bidder - // appends our own node on top before fanning to DSPs. - const schain = deepAccess(firstBid, 'schain'); + + // Schain lookup precedence — per Prebid review, modern schain is in + // ortb2.source.ext.schain (the FPD path). Older publishers still put + // it on the bid as `bid.schain`. Check both so neither path drops + // the supply chain. + const schain = + deepAccess(bidderRequest, 'ortb2.source.ext.schain') || + deepAccess(firstBid, 'schain'); if (schain) { source.ext = { schain }; } diff --git a/test/spec/modules/pgamdirectBidAdapter_spec.js b/test/spec/modules/pgamdirectBidAdapter_spec.js index e013b6588b..b43b3ecb9c 100644 --- a/test/spec/modules/pgamdirectBidAdapter_spec.js +++ b/test/spec/modules/pgamdirectBidAdapter_spec.js @@ -120,11 +120,82 @@ describe('pgamdirect: buildRequests', () => { const built = spec.buildRequests([bannerBid(), bannerBid({ bidId: 'bid-2' })], bidderRequest()); expect(built.method).to.equal('POST'); expect(built.url).to.equal(ENDPOINT_URL); - expect(built.options.customHeaders['x-openrtb-version']).to.equal('2.6'); + // No customHeaders — deliberate, to keep the request CORS-simple + // and avoid a browser preflight on every auction. + expect(built.options.customHeaders).to.be.undefined; // Both imps in ONE request expect(built.data.imp).to.have.lengthOf(2); }); + describe('floors module integration', () => { + it('uses bid.getFloor() when the Floors module is enabled', () => { + const bid = bannerBid(); + bid.getFloor = ({ currency }) => ({ currency, floor: 1.75 }); + const built = spec.buildRequests([bid], bidderRequest()); + expect(built.data.imp[0].bidfloor).to.equal(1.75); + expect(built.data.imp[0].bidfloorcur).to.equal('USD'); + }); + + it('falls back to params.bidfloor when Floors module is not present', () => { + const bid = bannerBid({ params: { orgId: 'pgam-x', bidfloor: 0.75 } }); + const built = spec.buildRequests([bid], bidderRequest()); + expect(built.data.imp[0].bidfloor).to.equal(0.75); + }); + + it('falls back to params.bidfloor when getFloor() throws', () => { + const bid = bannerBid({ params: { orgId: 'pgam-x', bidfloor: 2.00 } }); + bid.getFloor = () => { throw new Error('boom'); }; + const built = spec.buildRequests([bid], bidderRequest()); + expect(built.data.imp[0].bidfloor).to.equal(2.00); + }); + + it('falls back to 0 when neither Floors module nor params.bidfloor is set', () => { + const bid = bannerBid(); + const built = spec.buildRequests([bid], bidderRequest()); + expect(built.data.imp[0].bidfloor).to.equal(0); + }); + + it('preserves the currency the Floors module returned', () => { + const bid = bannerBid(); + bid.getFloor = () => ({ currency: 'EUR', floor: 1.20 }); + const built = spec.buildRequests([bid], bidderRequest()); + expect(built.data.imp[0].bidfloorcur).to.equal('EUR'); + }); + }); + + describe('schain lookup precedence', () => { + it('prefers ortb2.source.ext.schain over bid.schain', () => { + const ortb2Chain = { + complete: 1, + ver: '1.0', + nodes: [{ asi: 'ortb2.example', sid: 'ortb2', hp: 1 }], + }; + const bidChain = { + complete: 1, + ver: '1.0', + nodes: [{ asi: 'legacy.example', sid: 'legacy', hp: 1 }], + }; + const built = spec.buildRequests( + [bannerBid({ schain: bidChain })], + bidderRequest({ ortb2: { source: { ext: { schain: ortb2Chain } } } }), + ); + expect(built.data.source.ext.schain).to.deep.equal(ortb2Chain); + }); + + it('falls back to bid.schain when ortb2 path is empty', () => { + const bidChain = { + complete: 1, + ver: '1.0', + nodes: [{ asi: 'legacy.example', sid: 'legacy', hp: 1 }], + }; + const built = spec.buildRequests( + [bannerBid({ schain: bidChain })], + bidderRequest(), + ); + expect(built.data.source.ext.schain).to.deep.equal(bidChain); + }); + }); + it('populates site/device/source/regs/tmax/cur defaults', () => { const built = spec.buildRequests([bannerBid()], bidderRequest()); expect(built.data.id).to.equal('auction-1'); @@ -242,19 +313,6 @@ describe('pgamdirect: buildRequests', () => { expect(built.data.user.ext.eids).to.deep.equal(eids); }); - it('forwards schain from the first bid', () => { - const schain = { - complete: 1, - ver: '1.0', - nodes: [{ asi: 'publisher.example', sid: 'pub-123', hp: 1 }], - }; - const built = spec.buildRequests( - [bannerBid({ schain })], - bidderRequest(), - ); - expect(built.data.source.ext.schain).to.deep.equal(schain); - }); - it('forwards gpid from ortb2Imp.ext.gpid', () => { const built = spec.buildRequests( [bannerBid({ ortb2Imp: { ext: { gpid: '/acme/top-leaderboard' } } })], From ffe4833dda13cd0284b2253e26d185488725129f Mon Sep 17 00:00:00 2001 From: mastap150 <103131000+mastap150@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:27:00 -0400 Subject: [PATCH 3/3] pgamdirect: convert to TypeScript, use ortbConverter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses patmmccann review on #14763: - Convert pgamdirectBidAdapter.js → .ts with typed public interface. - Replace the hand-rolled imp/device/user/source builders with libraries/ortbConverter, keeping only the pgam-specific fields (imp.ext.pgam.orgId, imp.tagid from params.placementId, meta.networkName from seatbid.seat) as converter hooks. - Drop the contentType: 'application/json' header entirely — JSON is NOT a CORS-simple content-type, so setting it forces a browser preflight on every auction. Omitting it keeps the POST preflight-free (saves one RTT per request). The converter inherits Prebid's standard handling of: - priceFloors module (imp.bidfloor / bidfloorcur via bid.getFloor) - schain via FPD normalisation (source.ext.schain) - source.tid, user.eids, site/device/regs (GDPR, USP, GPP, COPPA) - media types (banner sizes → format[], video player size passthrough, native request serialisation) - bidResponse cpm/currency/ttl/netRevenue/mediaType/advertiserDomains So this change: - Removes ~200 lines of custom plumbing. - Means future ORTB spec updates (2.7, new privacy fields) flow through automatically when Prebid updates the converter. Also: - Register GVL ID 1353 on spec (PGAM Media LLC, registered with IAB). - Keep params.bidfloor as a legacy fallback when the priceFloors module has not populated imp.bidfloor. - Force at=1 (first-price) and cur=['USD'] via a request hook. Spec tests: trimmed to cover only the adapter-owned behaviour (isBidRequestValid, CORS-simple options, imp hook custom fields, request hook, bidResponse.meta.networkName, onBidWon). Library-owned behaviour (floors / schain / GDPR / media types / VAST parsing) is not re-asserted here; those are covered by ortbConverter's own specs and by every other adapter that uses it. 26/26 pass locally. --- modules/pgamdirectBidAdapter.js | 362 ------------ modules/pgamdirectBidAdapter.ts | 193 +++++++ .../spec/modules/pgamdirectBidAdapter_spec.js | 518 ++++-------------- 3 files changed, 312 insertions(+), 761 deletions(-) delete mode 100644 modules/pgamdirectBidAdapter.js create mode 100644 modules/pgamdirectBidAdapter.ts diff --git a/modules/pgamdirectBidAdapter.js b/modules/pgamdirectBidAdapter.js deleted file mode 100644 index 615261dcfb..0000000000 --- a/modules/pgamdirectBidAdapter.js +++ /dev/null @@ -1,362 +0,0 @@ -/** - * PGAM Direct — Prebid.js bid adapter. - * - * Speaks canonical OpenRTB 2.5/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 (mirrors pgamssp for migration familiarity): - * - * 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 - * } - * }] - * }]); - * - * When published to Prebid.org, lives at modules/pgamdirectBidAdapter.js. - * Today: shipped as a standalone file publishers drop into their Prebid - * build, plus an IIFE bundle for dynamic loading on a test page. - */ - -import { registerBidder } from '../src/adapters/bidderFactory.js'; -import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; -import { deepAccess, logError, logInfo, deepSetValue } from '../src/utils.js'; -import { getWinDimensions } from '../src/utils/winDimensions.js'; - -const BIDDER_CODE = 'pgamdirect'; -const ENDPOINT_URL = 'https://rtb.pgammedia.com/rtb/v1/auction'; -const DEFAULT_CUR = 'USD'; -const DEFAULT_TMAX = 300; - -export const spec = { - code: BIDDER_CODE, - supportedMediaTypes: [BANNER, VIDEO, NATIVE], - - /** - * Minimum: 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 = deepAccess(bid, 'params.orgId'); - if (!orgId || typeof orgId !== 'string') { - logError(`[${BIDDER_CODE}] params.orgId is required and must be a string`); - return false; - } - return true; - }, - - /** - * Build one OpenRTB 2.6 BidRequest per Prebid call. One HTTP POST per - * auction regardless of ad-unit count — all imps go in `imp[]`. - */ - buildRequests(validBidRequests, bidderRequest) { - if (!validBidRequests || validBidRequests.length === 0) return []; - - const imps = validBidRequests.map(buildImp).filter(Boolean); - if (imps.length === 0) return []; - - const ortbReq = { - id: bidderRequest.auctionId, - imp: imps, - site: buildSite(bidderRequest), - device: buildDevice(bidderRequest), - user: buildUser(bidderRequest), - source: buildSource(bidderRequest, validBidRequests[0]), - regs: buildRegs(bidderRequest), - at: 1, - tmax: bidderRequest.timeout || DEFAULT_TMAX, - cur: [DEFAULT_CUR], - }; - - // Attach any first-party data the publisher has configured (ortb2). - const ortb2 = bidderRequest.ortb2 || {}; - if (ortb2.site) ortbReq.site = Object.assign({}, ortb2.site, ortbReq.site); - if (ortb2.user) ortbReq.user = Object.assign({}, ortb2.user, ortbReq.user); - - logInfo(`[${BIDDER_CODE}] outbound ${imps.length} imps to ${ENDPOINT_URL}`); - - return { - method: 'POST', - url: ENDPOINT_URL, - data: ortbReq, - options: { - contentType: 'application/json;charset=utf-8', - withCredentials: false, - // No customHeaders — keeps the request CORS-simple enough to - // avoid a browser preflight on every auction (per Prebid review - // feedback). The bidder reads OpenRTB version from request.ext - // if ever needed; 2.6 is the default. - }, - }; - }, - - /** - * Walk the OpenRTB BidResponse, map seatbid[].bid[] → Prebid bid objects. - * Anything missing required Prebid fields (cpm / creativeId / ttl / - * currency) is dropped with a log — can't silently ship broken bids - * into the Prebid auction. - */ - interpretResponse(serverResponse, request) { - const body = serverResponse && serverResponse.body; - if (!body || !Array.isArray(body.seatbid)) return []; - - const bids = []; - const cur = body.cur || DEFAULT_CUR; - - for (const seat of body.seatbid) { - if (!seat || !Array.isArray(seat.bid)) continue; - for (const bid of seat.bid) { - const prebid = ortbBidToPrebid(bid, seat, cur); - if (prebid) bids.push(prebid); - } - } - logInfo(`[${BIDDER_CODE}] ${bids.length} bids parsed from seatbid`); - return bids; - }, - - /** - * Win notice → we fire burl+nurl automatically via our response; Prebid - * doesn't need to call anything else. 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 - }, -}; - -// ---- request builders ------------------------------------------------------ - -function buildImp(bid, idx) { - // Floors module integration — if the publisher has the Prebid Floors - // module enabled, bid.getFloor() returns the computed floor for the - // current request (media type + size aware). Falls back to - // params.bidfloor for publishers on the legacy per-bidder floor - // pattern. This is the fix per Prebid review: publishers using - // dynamic floor rules need their floor to flow through to the bid - // request, not be silently replaced by params.bidfloor. - const floor = resolveFloor(bid); - const imp = { - id: bid.bidId || String(idx + 1), - bidfloor: floor.floor, - bidfloorcur: floor.currency, - secure: 1, - ext: { - pgam: { - orgId: bid.params.orgId, - }, - }, - }; - if (bid.params.placementId) { - imp.tagid = String(bid.params.placementId); - } - - // Banner - const banner = deepAccess(bid, 'mediaTypes.banner'); - if (banner) { - imp.banner = { - w: banner.sizes && banner.sizes[0] ? banner.sizes[0][0] : undefined, - h: banner.sizes && banner.sizes[0] ? banner.sizes[0][1] : undefined, - format: (banner.sizes || []).map(([w, h]) => ({ w, h })), - pos: banner.pos || 0, - }; - } - - // Video — passthrough of the major ortb2 fields - const video = deepAccess(bid, 'mediaTypes.video'); - if (video) { - imp.video = { - mimes: video.mimes, - w: video.w || (video.playerSize && video.playerSize[0] ? video.playerSize[0][0] : undefined), - h: video.h || (video.playerSize && video.playerSize[0] ? video.playerSize[0][1] : undefined), - minduration: video.minduration, - maxduration: video.maxduration, - protocols: video.protocols, - api: video.api, - placement: video.placement, - plcmt: video.plcmt, - linearity: video.linearity, - startdelay: video.startdelay, - }; - } - - // Native — forward the assets as-is; our bidder normalises on ingest - const native = deepAccess(bid, 'mediaTypes.native'); - if (native) { - imp.native = { - request: JSON.stringify(native), - ver: '1.2', - }; - } - - // GPID passthrough - const gpid = deepAccess(bid, 'ortb2Imp.ext.gpid'); - if (gpid) { - deepSetValue(imp, 'ext.gpid', gpid); - } - - return imp; -} - -/** - * resolveFloor — consult the Prebid Floors module first, then fall back - * to params.bidfloor, then zero. Returns the currency the floor applied - * at so imp.bidfloorcur is set correctly. - * - * The Floors module exposes `bid.getFloor({ currency, mediaType, size })` - * when installed. When the publisher has no floor rule configured for - * the current request, getFloor() returns `{}` and we fall through to - * the legacy params.bidfloor shape. - */ -function resolveFloor(bid) { - if (typeof bid.getFloor === 'function') { - try { - const res = bid.getFloor({ currency: DEFAULT_CUR, mediaType: '*', size: '*' }); - if (res && typeof res.floor === 'number' && res.floor >= 0) { - return { floor: res.floor, currency: res.currency || DEFAULT_CUR }; - } - } catch (e) { - // swallow — fall through to params fallback - } - } - const paramFloor = Number(bid.params && bid.params.bidfloor); - if (Number.isFinite(paramFloor) && paramFloor >= 0) { - return { floor: paramFloor, currency: DEFAULT_CUR }; - } - return { floor: 0, currency: DEFAULT_CUR }; -} - -function buildSite(bidderRequest) { - const refURL = (bidderRequest && bidderRequest.refererInfo) || {}; - return { - page: refURL.page || refURL.referer || '', - domain: refURL.domain || '', - ref: refURL.ref || '', - }; -} - -function buildDevice(_bidderRequest) { - // DNT deprecated per Prebid policy — don't emit dnt field. - // window dimensions via Prebid's cached helper (canAccessWindowTop - // + throttled reads) so we don't hammer layout on every auction. - const dims = getWinDimensions() || {}; - const device = { - ua: typeof navigator !== 'undefined' ? navigator.userAgent : '', - language: typeof navigator !== 'undefined' ? navigator.language : '', - w: dims.innerWidth || 0, - h: dims.innerHeight || 0, - js: 1, - }; - return device; -} - -function buildUser(bidderRequest) { - const u = {}; - const eids = deepAccess(bidderRequest, 'userIdAsEids') || []; - if (eids.length) u.ext = { eids }; - return u; -} - -function buildSource(bidderRequest, firstBid) { - const tid = deepAccess(bidderRequest, 'ortb2.source.tid') || bidderRequest.auctionId; - const source = { tid, fd: 1 }; - - // Schain lookup precedence — per Prebid review, modern schain is in - // ortb2.source.ext.schain (the FPD path). Older publishers still put - // it on the bid as `bid.schain`. Check both so neither path drops - // the supply chain. - const schain = - deepAccess(bidderRequest, 'ortb2.source.ext.schain') || - deepAccess(firstBid, 'schain'); - if (schain) { - source.ext = { schain }; - } - return source; -} - -function buildRegs(bidderRequest) { - const regs = {}; - const gdpr = deepAccess(bidderRequest, 'gdprConsent'); - if (gdpr) { - regs.ext = regs.ext || {}; - regs.ext.gdpr = gdpr.gdprApplies ? 1 : 0; - if (gdpr.consentString) regs.ext.gdpr_consent = gdpr.consentString; - } - const usp = deepAccess(bidderRequest, 'uspConsent'); - if (usp) { - regs.ext = regs.ext || {}; - regs.ext.us_privacy = usp; - } - const gpp = deepAccess(bidderRequest, 'gppConsent'); - if (gpp) { - regs.gpp = gpp.gppString; - regs.gpp_sid = gpp.applicableSections; - } - const coppa = deepAccess(bidderRequest, 'ortb2.regs.coppa'); - if (coppa != null) regs.coppa = coppa; - return regs; -} - -// ---- response mapping ------------------------------------------------------ - -function ortbBidToPrebid(bid, seat, cur) { - if (!bid) return null; - if (!bid.impid || typeof bid.price !== 'number' || bid.price <= 0) return null; - - const mediaType = inferMediaType(bid); - const ttl = (bid.exp && Number(bid.exp)) || 300; - - const prebid = { - requestId: bid.impid, - cpm: bid.price, - currency: cur, - width: bid.w || 0, - height: bid.h || 0, - creativeId: bid.crid || bid.id || '', - dealId: bid.dealid || null, - netRevenue: true, - ttl, - mediaType, - meta: { - advertiserDomains: bid.adomain || [], - mediaType, - ...(bid.ext && bid.ext.meta ? bid.ext.meta : {}), - }, - }; - - if (mediaType === BANNER) { - prebid.ad = bid.adm || ''; - } else if (mediaType === VIDEO) { - if (bid.adm && bid.adm.trim().startsWith(', 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 index b43b3ecb9c..064ebc982b 100644 --- a/test/spec/modules/pgamdirectBidAdapter_spec.js +++ b/test/spec/modules/pgamdirectBidAdapter_spec.js @@ -3,21 +3,27 @@ import { spec } from 'modules/pgamdirectBidAdapter.js'; import { BANNER, NATIVE, VIDEO } from 'src/mediaTypes.js'; /** - * Spec for modules/pgamdirectBidAdapter.js. + * Spec for modules/pgamdirectBidAdapter.ts. * - * Coverage targets every branch in the adapter: - * isBidRequestValid — valid path, missing orgId, non-string orgId - * buildRequests — empty input, banner, video, native, multi-imp, - * consent passthrough (gdpr/usp/gpp/coppa), - * eids passthrough, schain passthrough, - * gpid passthrough, tmax / currency defaults - * interpretResponse — empty body, no seatbid, single banner, - * video (VAST XML + VAST URL variants), native, - * missing required fields filtered out, - * seat metadata forwarded to meta.networkName - * onBidWon — no-op, doesn't throw + * 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. * - * Written to run inside prebid/Prebid.js test harness — `npm test`. + * 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'; @@ -34,39 +40,6 @@ function bannerBid(overrides) { }, overrides || {}); } -function videoBid(overrides) { - return Object.assign({ - bidId: 'bid-v1', - bidder: 'pgamdirect', - adUnitCode: 'adunit-v', - mediaTypes: { - video: { - playerSize: [[640, 480]], - mimes: ['video/mp4'], - minduration: 5, - maxduration: 30, - protocols: [2, 3, 5, 6], - api: [1, 2], - placement: 1, - plcmt: 1, - linearity: 1, - startdelay: 0, - }, - }, - params: { orgId: 'pgam-acme-publisher' }, - }, overrides || {}); -} - -function nativeBid(overrides) { - return Object.assign({ - bidId: 'bid-n1', - bidder: 'pgamdirect', - adUnitCode: 'adunit-n', - mediaTypes: { native: { image: { required: true, sizes: [150, 50] } } }, - params: { orgId: 'pgam-acme-publisher' }, - }, overrides || {}); -} - function bidderRequest(overrides) { return Object.assign({ auctionId: 'auction-1', @@ -116,249 +89,119 @@ describe('pgamdirect: buildRequests', () => { expect(spec.buildRequests(null, bidderRequest())).to.deep.equal([]); }); - it('produces a single POST per auction', () => { - const built = spec.buildRequests([bannerBid(), bannerBid({ bidId: 'bid-2' })], bidderRequest()); + 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); - // No customHeaders — deliberate, to keep the request CORS-simple - // and avoid a browser preflight on every auction. - expect(built.options.customHeaders).to.be.undefined; // Both imps in ONE request expect(built.data.imp).to.have.lengthOf(2); }); - describe('floors module integration', () => { - it('uses bid.getFloor() when the Floors module is enabled', () => { - const bid = bannerBid(); - bid.getFloor = ({ currency }) => ({ currency, floor: 1.75 }); - const built = spec.buildRequests([bid], bidderRequest()); - expect(built.data.imp[0].bidfloor).to.equal(1.75); - expect(built.data.imp[0].bidfloorcur).to.equal('USD'); + 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('falls back to params.bidfloor when Floors module is not present', () => { - const bid = bannerBid({ params: { orgId: 'pgam-x', bidfloor: 0.75 } }); - const built = spec.buildRequests([bid], bidderRequest()); - expect(built.data.imp[0].bidfloor).to.equal(0.75); + 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('falls back to params.bidfloor when getFloor() throws', () => { - const bid = bannerBid({ params: { orgId: 'pgam-x', bidfloor: 2.00 } }); - bid.getFloor = () => { throw new Error('boom'); }; - const built = spec.buildRequests([bid], bidderRequest()); - expect(built.data.imp[0].bidfloor).to.equal(2.00); + it('sets withCredentials: false', () => { + const built = spec.buildRequests([bannerBid()], bidderRequest()); + expect(built.options.withCredentials).to.equal(false); }); + }); - it('falls back to 0 when neither Floors module nor params.bidfloor is set', () => { - const bid = bannerBid(); - const built = spec.buildRequests([bid], bidderRequest()); - expect(built.data.imp[0].bidfloor).to.equal(0); + 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('preserves the currency the Floors module returned', () => { - const bid = bannerBid(); - bid.getFloor = () => ({ currency: 'EUR', floor: 1.20 }); - const built = spec.buildRequests([bid], bidderRequest()); - expect(built.data.imp[0].bidfloorcur).to.equal('EUR'); + 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('schain lookup precedence', () => { - it('prefers ortb2.source.ext.schain over bid.schain', () => { - const ortb2Chain = { - complete: 1, - ver: '1.0', - nodes: [{ asi: 'ortb2.example', sid: 'ortb2', hp: 1 }], - }; - const bidChain = { - complete: 1, - ver: '1.0', - nodes: [{ asi: 'legacy.example', sid: 'legacy', hp: 1 }], - }; + describe('imp hook — params.bidfloor legacy fallback', () => { + it('applies params.bidfloor when floors module did not populate imp.bidfloor', () => { const built = spec.buildRequests( - [bannerBid({ schain: bidChain })], - bidderRequest({ ortb2: { source: { ext: { schain: ortb2Chain } } } }), + [bannerBid({ params: { orgId: 'pgam-x', bidfloor: 0.75 } })], + bidderRequest(), ); - expect(built.data.source.ext.schain).to.deep.equal(ortb2Chain); + expect(built.data.imp[0].bidfloor).to.equal(0.75); + expect(built.data.imp[0].bidfloorcur).to.equal('USD'); }); - it('falls back to bid.schain when ortb2 path is empty', () => { - const bidChain = { - complete: 1, - ver: '1.0', - nodes: [{ asi: 'legacy.example', sid: 'legacy', hp: 1 }], - }; + it('ignores params.bidfloor when it is negative', () => { const built = spec.buildRequests( - [bannerBid({ schain: bidChain })], + [bannerBid({ params: { orgId: 'pgam-x', bidfloor: -1 } })], bidderRequest(), ); - expect(built.data.source.ext.schain).to.deep.equal(bidChain); + // No floors module active + negative param → no floor set. + expect(built.data.imp[0].bidfloor || 0).to.equal(0); }); }); - it('populates site/device/source/regs/tmax/cur defaults', () => { - const built = spec.buildRequests([bannerBid()], bidderRequest()); - expect(built.data.id).to.equal('auction-1'); - expect(built.data.at).to.equal(1); - expect(built.data.tmax).to.equal(500); - expect(built.data.cur).to.deep.equal(['USD']); - expect(built.data.site.page).to.equal('https://publisher.example/page'); - expect(built.data.site.domain).to.equal('publisher.example'); - expect(built.data.device.ua).to.be.a('string'); - expect(built.data.source.tid).to.equal('auction-1'); - expect(built.data.source.fd).to.equal(1); - }); - - it('falls back to DEFAULT_TMAX when bidderRequest.timeout is unset', () => { - const req = bidderRequest(); - delete req.timeout; - const built = spec.buildRequests([bannerBid()], req); - expect(built.data.tmax).to.equal(300); - }); - - it('builds banner imp.banner.format from mediaTypes.banner.sizes', () => { - const built = spec.buildRequests([bannerBid()], bidderRequest()); - const imp = built.data.imp[0]; - expect(imp.id).to.equal('bid-1'); - expect(imp.banner.w).to.equal(300); - expect(imp.banner.h).to.equal(250); - expect(imp.banner.format).to.deep.equal([ - { w: 300, h: 250 }, - { w: 728, h: 90 }, - ]); - expect(imp.secure).to.equal(1); - expect(imp.bidfloorcur).to.equal('USD'); - }); - - 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('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('maps mediaTypes.video fully into imp.video', () => { - const built = spec.buildRequests([videoBid()], bidderRequest()); - const imp = built.data.imp[0]; - expect(imp.video.mimes).to.deep.equal(['video/mp4']); - expect(imp.video.w).to.equal(640); - expect(imp.video.h).to.equal(480); - expect(imp.video.minduration).to.equal(5); - expect(imp.video.maxduration).to.equal(30); - expect(imp.video.protocols).to.deep.equal([2, 3, 5, 6]); - expect(imp.video.api).to.deep.equal([1, 2]); - expect(imp.video.placement).to.equal(1); - expect(imp.video.plcmt).to.equal(1); - expect(imp.video.linearity).to.equal(1); - expect(imp.video.startdelay).to.equal(0); - }); - - it('serialises native config into imp.native.request', () => { - const built = spec.buildRequests([nativeBid()], bidderRequest()); - const imp = built.data.imp[0]; - expect(imp.native.ver).to.equal('1.2'); - expect(JSON.parse(imp.native.request)).to.have.property('image'); - }); - - it('forwards GDPR consent into regs.ext', () => { - const built = spec.buildRequests( - [bannerBid()], - bidderRequest({ - gdprConsent: { gdprApplies: true, consentString: 'CONSENT_STRING_HERE' }, - }), - ); - expect(built.data.regs.ext.gdpr).to.equal(1); - expect(built.data.regs.ext.gdpr_consent).to.equal('CONSENT_STRING_HERE'); - }); - - it('forwards USP consent into regs.ext.us_privacy', () => { - const built = spec.buildRequests( - [bannerBid()], - bidderRequest({ uspConsent: '1YYY' }), - ); - expect(built.data.regs.ext.us_privacy).to.equal('1YYY'); - }); - - it('forwards GPP consent into regs.gpp / regs.gpp_sid', () => { - const built = spec.buildRequests( - [bannerBid()], - bidderRequest({ - gppConsent: { gppString: 'GPP_STRING', applicableSections: [7, 8] }, - }), - ); - expect(built.data.regs.gpp).to.equal('GPP_STRING'); - expect(built.data.regs.gpp_sid).to.deep.equal([7, 8]); - }); - - it('forwards COPPA from ortb2.regs.coppa', () => { - const built = spec.buildRequests( - [bannerBid()], - bidderRequest({ ortb2: { regs: { coppa: 1 } } }), - ); - expect(built.data.regs.coppa).to.equal(1); - }); - - it('forwards user.ext.eids when userIdAsEids is set', () => { - const eids = [{ source: 'example.com', uids: [{ id: 'user-123', atype: 1 }] }]; - const built = spec.buildRequests( - [bannerBid()], - bidderRequest({ userIdAsEids: eids }), - ); - expect(built.data.user.ext.eids).to.deep.equal(eids); - }); - - it('forwards gpid from ortb2Imp.ext.gpid', () => { - const built = spec.buildRequests( - [bannerBid({ ortb2Imp: { ext: { gpid: '/acme/top-leaderboard' } } })], - bidderRequest(), - ); - expect(built.data.imp[0].ext.gpid).to.equal('/acme/top-leaderboard'); - }); + 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('merges ortb2.site and ortb2.user first-party data without clobbering refererInfo', () => { - const built = spec.buildRequests( - [bannerBid()], - bidderRequest({ - ortb2: { - site: { cat: ['IAB1'], content: { language: 'en' } }, - user: { yob: 1990 }, - }, - }), - ); - // site merge preserves page/domain from refererInfo - expect(built.data.site.page).to.equal('https://publisher.example/page'); - expect(built.data.site.cat).to.deep.equal(['IAB1']); - expect(built.data.user.yob).to.equal(1990); - }); + 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('honours bid.params.bidfloor', () => { - const built = spec.buildRequests( - [bannerBid({ params: { orgId: 'x', bidfloor: 1.23 } })], - bidderRequest(), - ); - expect(built.data.imp[0].bidfloor).to.equal(1.23); + 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', () => { - it('returns empty array when body is missing / no seatbid', () => { - expect(spec.interpretResponse({}, {})).to.deep.equal([]); - expect(spec.interpretResponse({ body: null }, {})).to.deep.equal([]); - expect(spec.interpretResponse({ body: {} }, {})).to.deep.equal([]); - expect(spec.interpretResponse({ body: { seatbid: [] } }, {})).to.deep.equal([]); + // 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 correctly', () => { + it('maps a single banner bid with advertiserDomains + networkName from seat', () => { + const request = builtRequestFor(); const body = { - id: 'auction-1', + id: request.data.id, cur: 'USD', seatbid: [{ seat: 'pgam-test-dsp', @@ -369,173 +212,48 @@ describe('pgamdirect: interpretResponse', () => { adm: 'ad', crid: 'creative-001', adomain: ['advertiser.example'], - cat: ['IAB3-1'], w: 300, h: 250, exp: 180, + mtype: 1, // ORTB: 1=banner }], }], }; - const bids = spec.interpretResponse({ body }, {}); + const bids = spec.interpretResponse({ body }, request); expect(bids).to.have.lengthOf(1); - expect(bids[0].requestId).to.equal('bid-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].netRevenue).to.equal(true); expect(bids[0].mediaType).to.equal(BANNER); - expect(bids[0].ad).to.contain(''); 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('maps VAST XML into vastXml, not ad', () => { - const body = { - cur: 'USD', - seatbid: [{ - seat: 'verve', - bid: [{ - id: 'v-1', - impid: 'bid-v1', - price: 10.0, - crid: 'cre-v', - adm: '', - ext: { meta: { mediaType: VIDEO } }, - }], - }], - }; - const bids = spec.interpretResponse({ body }, {}); - expect(bids[0].mediaType).to.equal(VIDEO); - expect(bids[0].vastXml).to.contain(' { + it('omits meta.networkName when seatbid has no seat', () => { + const request = builtRequestFor(); const body = { + id: request.data.id, cur: 'USD', seatbid: [{ - seat: 'verve', bid: [{ - id: 'v-2', - impid: 'bid-v1', - price: 8.0, - crid: 'c', - nurl: 'https://vast.example/wrapper.xml', - ext: { meta: { mediaType: VIDEO } }, - }], - }], - }; - const bids = spec.interpretResponse({ body }, {}); - expect(bids[0].mediaType).to.equal(VIDEO); - expect(bids[0].vastUrl).to.equal('https://vast.example/wrapper.xml'); - }); - - it('parses adm as native JSON for native bids', () => { - const nativePayload = { assets: [{ id: 1, title: { text: 'hello' } }] }; - const body = { - cur: 'USD', - seatbid: [{ - seat: 'native-dsp', - bid: [{ - id: 'n-1', - impid: 'bid-n1', - price: 0.75, - crid: 'cn', - adm: JSON.stringify(nativePayload), - ext: { meta: { mediaType: NATIVE } }, - }], - }], - }; - const bids = spec.interpretResponse({ body }, {}); - expect(bids[0].mediaType).to.equal(NATIVE); - expect(bids[0].native).to.deep.equal(nativePayload); - }); - - it('drops native bid when adm JSON is malformed', () => { - const body = { - cur: 'USD', - seatbid: [{ - seat: 's', - bid: [{ - id: 'n-2', - impid: 'bid-n1', - price: 0.75, - crid: 'c', - adm: 'not-json', - ext: { meta: { mediaType: NATIVE } }, - }], - }], - }; - expect(spec.interpretResponse({ body }, {})).to.have.lengthOf(0); - }); - - it('drops bids missing impid or non-positive price', () => { - const body = { - cur: 'USD', - seatbid: [{ - seat: 's', - bid: [ - { id: 'a', price: 2.0, crid: 'c' }, // missing impid - { id: 'b', impid: 'x', price: 0, crid: 'c' }, // zero price - { id: 'c', impid: 'x', price: -1, crid: 'c' }, // negative price - { id: 'd', impid: 'ok', price: 1.0, crid: 'c', adm: '' }, - ], - }], - }; - const bids = spec.interpretResponse({ body }, {}); - expect(bids).to.have.lengthOf(1); - expect(bids[0].requestId).to.equal('ok'); - }); - - it('infers mediaType=video from adm starting with { - const body = { - cur: 'USD', - seatbid: [{ - bid: [{ - id: 'x', - impid: 'i', - price: 1.0, - crid: 'c', - adm: '', - }], - }], - }; - const bids = spec.interpretResponse({ body }, {}); - expect(bids[0].mediaType).to.equal(VIDEO); - expect(bids[0].vastXml).to.contain(' { - const body = { - seatbid: [{ - bid: [{ id: 'x', impid: 'i', price: 1.0, crid: 'c', adm: '' }], - }], - }; - const bids = spec.interpretResponse({ body }, {}); - expect(bids[0].ttl).to.equal(300); - expect(bids[0].currency).to.equal('USD'); - }); - - it('merges bid.ext.meta into the prebid meta object', () => { - const body = { - cur: 'USD', - seatbid: [{ - bid: [{ - id: 'x', - impid: 'i', + id: 'd', + impid: 'bid-1', price: 1.0, - crid: 'c', adm: '', - ext: { meta: { advertiserId: 'acme-42', dchain: 'x:y:z' } }, + crid: 'c', + w: 300, + h: 250, + mtype: 1, }], }], }; - const bids = spec.interpretResponse({ body }, {}); - expect(bids[0].meta.advertiserId).to.equal('acme-42'); - expect(bids[0].meta.dchain).to.equal('x:y:z'); + const bids = spec.interpretResponse({ body }, request); + expect(bids).to.have.lengthOf(1); + expect(bids[0].meta.networkName).to.be.undefined; }); }); @@ -549,16 +267,18 @@ describe('pgamdirect: onBidWon', () => { }); }); -// ---------- spec metadata -------------------------------------------------- +// ---------- supportedMediaTypes / gvlid ------------------------------------ -describe('pgamdirect: spec metadata', () => { - it('declares BIDDER_CODE=pgamdirect', () => { - expect(spec.code).to.equal('pgamdirect'); +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('supports banner, video, native', () => { - expect(spec.supportedMediaTypes).to.include(BANNER); - expect(spec.supportedMediaTypes).to.include(VIDEO); - expect(spec.supportedMediaTypes).to.include(NATIVE); + it('has the correct bidder code', () => { + expect(spec.code).to.equal('pgamdirect'); }); });