From 9cb47c1a54c7d09cf282c3f388a8b43c6c6f689e Mon Sep 17 00:00:00 2001 From: William Harris Date: Tue, 25 Aug 2026 09:44:28 +0200 Subject: [PATCH 1/2] Yieldlab: price-floor fixes and native asset-id regression tests NOTE: one test is deliberately RED and fails on this commit - "a publisher using the standard mediaTypes.native shorthand gets a usable native bid" It expresses the desired behaviour rather than the current one. The following commit adds the facet-type mapping fix and re-scopes it. Suite here is 50 passed / 1 failed; that failure is the bug, not a broken test. --- modules/yieldlabBidAdapter.js | 67 ++- test/spec/modules/yieldlabBidAdapter_spec.js | 492 ++++++++++++++++++- 2 files changed, 550 insertions(+), 9 deletions(-) diff --git a/modules/yieldlabBidAdapter.js b/modules/yieldlabBidAdapter.js index 38cfaa40c86..567b2ada976 100644 --- a/modules/yieldlabBidAdapter.js +++ b/modules/yieldlabBidAdapter.js @@ -1,7 +1,7 @@ import { registerBidder } from '../src/adapters/bidderFactory.js'; -import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { ALL_MEDIATYPES, BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; import { Renderer } from '../src/Renderer.js'; -import { deepAccess, isArray, timestamp } from '../src/utils.js'; +import { deepAccess, isArray, logWarn, timestamp } from '../src/utils.js'; import { ortbConverter } from '../libraries/ortbConverter/converter.js'; /** @@ -103,7 +103,7 @@ export const spec = { const resp = buildBidResponse(bid, ctx); - applyNetRevenue(resp, bid) + applyNetRevenue(resp, bid); sanitizeVideoAsset(resp); ensureBannerSize(resp, ctx); @@ -128,6 +128,15 @@ export const spec = { }); const ortbRequest = converter.toORTB({ bidderRequest, bidRequests: validBidRequests }); + + // The converter skips any imp it fails to build, so an otherwise valid looking + // request can end up with no impressions at all. Sending that would be an + // invalid ORTB request, so drop out of the auction instead. + if (!ortbRequest?.imp?.length) { + logWarn('yieldlab: no impressions could be built, skipping request'); + return []; + } + const url = `${ENDPOINT}${ORTB_PATH}`; return { @@ -591,12 +600,44 @@ function impFn(buildImp, bidRequest, context) { return imp; } +/** + * Remove non-EUR granular floors from an ORTB `imp`. + * + * The price floors module writes per-mediatype and per-format floors as + * `ext.bidfloor`/`ext.bidfloorcur` on `imp[mediaType]` and on every + * `imp.banner.format[]` entry whenever they differ from the top level pair. + * Those copies are not covered by deleting `imp.bidfloor`, so a non-EUR floor + * would otherwise still travel in a request that only transacts in EUR. + * Floors already denominated in EUR are left alone - they may have been set + * deliberately by the publisher through `ortb2Imp`. + * + * @param {Object} imp OpenRTB impression object to mutate. + * @returns {void} + */ +function stripForeignGranularFloors(imp) { + const holders = ALL_MEDIATYPES.map((mediaType) => imp[mediaType]).concat(imp.banner?.format || []); + + holders.forEach((holder) => { + const ext = holder && holder.ext; + if (!ext || ext.bidfloorcur === CURRENCY_CODE) { + return; + } + delete ext.bidfloor; + delete ext.bidfloorcur; + if (Object.keys(ext).length === 0) { + delete holder.ext; + } + }); +} + /** * Apply Price Floors to a single ORTB `imp`. * * Behavior: * - Clears any previously set imp.bidfloor/bidfloorcur (e.g., from default processors). + * - Clears non-EUR granular floors left on the imp by the price floors module. * - No-op when `bidRequest.getFloor` is missing. + * - Leaves the imp unfloored when `getFloor` throws, rather than losing the imp. * - Uses `derivePrimaryMediaType(bidRequest)` to select the mediaType (defaults to `'*'`). * - For banner: if exactly one size exists, passes `[w, h]`; otherwise passes `'*'`. * For video: always passes `'*'`. @@ -617,6 +658,8 @@ function applyFloorToImp(imp, bidRequest) { delete imp.bidfloorcur; } + stripForeignGranularFloors(imp); + const mediaType = derivePrimaryMediaType(bidRequest) || '*'; // size param: single banner size -> [w,h]; otherwise '*'. For video always '*'. @@ -626,11 +669,19 @@ function applyFloorToImp(imp, bidRequest) { if (sizes.length === 1) sizeArg = sizes[0]; } - const floor = bidRequest.getFloor({ - currency: CURRENCY_CODE, - mediaType, - size: sizeArg - }); + let floor; + try { + floor = bidRequest.getFloor({ + currency: CURRENCY_CODE, + mediaType, + size: sizeArg + }); + } catch (e) { + // Publisher supplied floor code can throw; core's own tryGetFloor swallows it + // too. Bid without a floor instead of letting the imp be dropped. + logWarn('yieldlab: unable to compute floor for bid', bidRequest, e); + return; + } if (floor && floor.currency === CURRENCY_CODE && Number.isFinite(floor.floor)) { imp.bidfloor = floor.floor; diff --git a/test/spec/modules/yieldlabBidAdapter_spec.js b/test/spec/modules/yieldlabBidAdapter_spec.js index 8832cf3fceb..a20b0af6b3f 100644 --- a/test/spec/modules/yieldlabBidAdapter_spec.js +++ b/test/spec/modules/yieldlabBidAdapter_spec.js @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { spec } from 'modules/yieldlabBidAdapter.js'; import { newBidder } from 'src/adapters/bidderFactory.js'; +import { isNativeOpenRTBBidValid, toLegacyResponse, toOrtbNativeRequest, toOrtbNativeResponse } from 'src/native.js'; import 'src/prebid.js'; import 'modules/currency.js'; @@ -9,7 +10,6 @@ import 'modules/multibid/index.js'; import 'modules/priceFloors.js'; import 'modules/consentManagementTcf.js'; import 'modules/consentManagementUsp.js'; -import 'modules/paapi.js'; import { hook } from 'src/hook.js'; @@ -149,6 +149,14 @@ describe('yieldlabBidAdapter (ORTB)', () => { expect(ortb).to.have.nested.property('source.schain.ver', '1.0'); }); + it('returns no request at all when not a single imp could be built', () => { + // An ORTB request without impressions is invalid, and the converter drops + // any imp it fails to build - so the adapter leaves the auction instead. + const req = spec.buildRequests([], { auctionId: 'no-imps', timeout: 300 }); + + expect(req).to.deep.equal([]); + }); + describe('applyIabContent', () => { it('copies fields to site.content, aliases live to livestream, ignores nulls, preserves unknown keys', () => { const bid = DEFAULT_REQUEST(); @@ -471,6 +479,146 @@ describe('yieldlabBidAdapter (ORTB)', () => { } }); + describe('native asset-id contract with the adserver native template', () => { + if (FEATURES.NATIVE) { + // The adserver DISCARDS imp.native.request and rebuilds the native request from the + // adslot's native template (OpenRTBRequestGeneratorServiceImpl.getNativeRequest reads + // only the template's assets). The DSP therefore answers with the TEMPLATE's asset ids, + // and this adapter passes that ORTB straight through as bid.native.ortb. Prebid then + // validates the response against the PUBLISHER's request, matching by asset id + // (isNativeOpenRTBBidValid / toLegacyResponse), so the two only line up if the + // publisher's config mirrors the Yieldlab template. + // + // Fixture below is stage native template 13: a single icon image asset with id 4. + // (img.type 1 = ICON, 3 = MAIN, per NATIVE_IMAGE_TYPES in src/constants.) + const TEMPLATE_IMAGE_URL = 'https://ad.yieldlab.net/qa/main.jpg'; + const TEMPLATE_13_ADM = JSON.stringify({ + native: { + ver: '1.2', + link: { url: 'https://ad.yieldlab.net/qa/click' }, + assets: [ + { id: 4, required: 0, img: { type: 1, url: TEMPLATE_IMAGE_URL, w: 300, h: 250 } } + ], + eventtrackers: [{ event: 1, method: 1, url: 'https://ad.yieldlab.net/1x1.gif' }] + } + }); + + /** Run a bid request through buildRequests + interpretResponse against the template adm. */ + const interpretTemplateResponse = (bidRequest) => { + const req = spec.buildRequests([bidRequest], { auctionId: 'nat-tpl' }); + const ortbReq = JSON.parse(req.data); + const res = spec.interpretResponse({ + body: { + id: 'nat-tpl', + cur: 'EUR', + seatbid: [{ + bid: [{ + impid: ortbReq.imp[0].id, + price: 1.0, + adm: TEMPLATE_13_ADM, + adomain: ['yieldlab'], + crid: 'qa-native-crid' + }] + }] + } + }, req); + expect(res).to.have.length(1); + return res[0]; + }; + + /** A publisher ad unit whose native config is the usual mediaTypes.native shorthand. */ + const shorthandRequest = () => { + const bid = NATIVE_REQUEST(); + bid.nativeOrtbRequest = toOrtbNativeRequest(bid.mediaTypes.native); + return bid; + }; + + it('passes the template asset ids through to bid.native.ortb untouched', () => { + const bidResponse = interpretTemplateResponse(shorthandRequest()); + + expect(bidResponse.mediaType).to.equal('native'); + expect(bidResponse.native.ortb.assets.map(asset => asset.id)).to.deep.equal([4]); + expect(bidResponse.native.ortb.link.url).to.equal('https://ad.yieldlab.net/qa/click'); + }); + + it('numbers publisher assets from 0, so the template id 4 is never among them', () => { + const publisherRequest = toOrtbNativeRequest(NATIVE_REQUEST().mediaTypes.native); + const ids = publisherRequest.assets.map(asset => asset.id); + + expect(ids[0]).to.equal(0); + expect(ids).to.not.include(4); + expect(publisherRequest.assets.some(asset => asset.required === 1)).to.equal(true); + }); + + it('CURRENT BEHAVIOUR (defective): the bid is rejected when the publisher config does not mirror the template', () => { + const bid = shorthandRequest(); + const bidResponse = interpretTemplateResponse(bid); + + // Required ids 0..3 were asked for; only id 4 came back. + expect(isNativeOpenRTBBidValid(bidResponse.native.ortb, bid.nativeOrtbRequest)).to.equal(false); + }); + + // ------------------------------------------------------------------ + // DESIRED BEHAVIOUR — currently FAILS. This is the bug, expressed as a + // test. It should go green when native is fixed, wherever the fix lands: + // * adserver-side — honour/intersect the incoming imp.native.request so + // the response already carries the publisher's asset ids; or + // * adapter-side — reconcile the DSP's template-shaped assets onto the + // publisher's requested ids by facet type, which is what the legacy + // (pre-POST-ORTB) adapter effectively did via toOrtbNativeResponse. + // Either fix satisfies this test, so it does not prejudge the design. + // Delete the characterization tests above once this one passes. + // ------------------------------------------------------------------ + it('a publisher using the standard mediaTypes.native shorthand gets a usable native bid', () => { + const bid = shorthandRequest(); + const bidResponse = interpretTemplateResponse(bid); + + expect(isNativeOpenRTBBidValid(bidResponse.native.ortb, bid.nativeOrtbRequest)).to.equal(true); + + const legacy = toLegacyResponse(bidResponse.native.ortb, bid.nativeOrtbRequest); + expect(legacy.image?.url).to.equal(TEMPLATE_IMAGE_URL); + }); + + it('ACCEPTS the bid when the publisher config mirrors the template asset ids', () => { + const mirroredOrtb = { + ver: '1.2', + assets: [{ id: 4, required: 0, img: { type: 1, wmin: 100, hmin: 100 } }] + }; + const bid = { ...DEFAULT_REQUEST(), mediaTypes: { native: { ortb: mirroredOrtb } } }; + bid.nativeOrtbRequest = mirroredOrtb; + + const bidResponse = interpretTemplateResponse(bid); + + expect(isNativeOpenRTBBidValid(bidResponse.native.ortb, bid.nativeOrtbRequest)).to.equal(true); + }); + + it('CURRENT BEHAVIOUR (defective): required:0 lets the id mismatch through, and the image is mis-filed as icon', () => { + const bid = { + ...DEFAULT_REQUEST(), + mediaTypes: { + native: { + title: { required: false, len: 90 }, + body: { required: false }, + image: { required: false, sizes: [300, 250] } + } + } + }; + bid.nativeOrtbRequest = toOrtbNativeRequest(bid.mediaTypes.native); + + const bidResponse = interpretTemplateResponse(bid); + + // Nothing is required, so the required-asset check cannot fail... + expect(isNativeOpenRTBBidValid(bidResponse.native.ortb, bid.nativeOrtbRequest)).to.equal(true); + + // ...but the mapping is still by id, and id 4 matches nothing the publisher asked for, + // so the creative's image is filed as `icon` and `image` is never populated. + const legacy = toLegacyResponse(bidResponse.native.ortb, bid.nativeOrtbRequest); + expect(legacy.icon?.url).to.equal(TEMPLATE_IMAGE_URL); + expect(legacy.image).to.equal(undefined); + }); + } + }); + describe('applyNetRevenue', () => { it('sets bidResponse.netRevenue from bid.ext.netrevenue (true)', () => { const bid = DEFAULT_REQUEST(); @@ -527,6 +675,33 @@ describe('yieldlabBidAdapter (ORTB)', () => { const syncs = spec.getUserSyncs({ iframeEnabled: false, pixelEnabled: false }, [], null, null); expect(syncs).to.deep.equal([]); }); + + it('omits both GDPR params when there is no consent data', () => { + const syncs = spec.getUserSyncs(syncOptions, [], undefined, undefined); + + expect(syncs).to.be.an('array').with.length(1); + expect(syncs[0].url).to.include('/d/6846326/766/2x2?'); + expect(syncs[0].url).to.include('type=h'); + expect(syncs[0].url).to.match(/ts=\d+/); + expect(syncs[0].url).to.not.include('gdpr='); + expect(syncs[0].url).to.not.include('gdpr_consent='); + }); + + it('sends gdpr=0 when gdprApplies is false', () => { + const syncs = spec.getUserSyncs(syncOptions, [], { gdprApplies: false, consentString: 'CONSENT-0' }, null); + + expect(syncs).to.have.length(1); + expect(syncs[0].url).to.include('gdpr=0'); + expect(syncs[0].url).to.include('gdpr_consent=CONSENT-0'); + }); + + it('omits gdpr but keeps the consent string when gdprApplies is not a boolean', () => { + const syncs = spec.getUserSyncs(syncOptions, [], { consentString: 'CONSENT-ONLY' }, null); + + expect(syncs).to.have.length(1); + expect(syncs[0].url).to.not.include('gdpr='); + expect(syncs[0].url).to.include('gdpr_consent=CONSENT-ONLY'); + }); }); describe('applies floors (impFn)', () => { @@ -573,6 +748,157 @@ describe('yieldlabBidAdapter (ORTB)', () => { expect(ortb.imp[0]).to.not.have.property('bidfloor'); expect(ortb.imp[0]).to.not.have.property('bidfloorcur'); }); + + it('deletes the floor the converter already set when the currency is not EUR', () => { + const bid = DEFAULT_REQUEST(); + const calls = []; + bid.getFloor = (args) => { + calls.push(args); + return { currency: 'USD', floor: 4.5 }; + }; + + const req = spec.buildRequests([bid], { auctionId: 'flo-usd-delete', timeout: 300 }); + const ortb = JSON.parse(req.data); + + // The converter's own bidfloor processor asks first (with size '*'), so a USD + // floor is on the imp by the time applyFloorToImp runs; it has to remove it + // rather than let a USD number travel in a EUR-only request. + expect(calls.length).to.be.greaterThan(1); + expect(ortb.imp[0]).to.not.have.property('bidfloor'); + expect(ortb.imp[0]).to.not.have.property('bidfloorcur'); + }); + + it('strips the non-EUR granular floors the price floors module scatters over the imp', () => { + // setGranularBidfloors copies per-mediatype and per-format floors onto + // imp[mediaType].ext and imp.banner.format[].ext whenever they differ from + // the top level pair; deleting imp.bidfloor alone would leave them behind. + const bid = DEFAULT_REQUEST(); + bid.mediaTypes.video = { playerSize: [[640, 480]], context: 'outstream', mimes: ['video/mp4'] }; + bid.getFloor = ({ mediaType, size }) => { + if (Array.isArray(size)) return { currency: 'USD', floor: 7 }; + if (mediaType === 'banner') return { currency: 'USD', floor: 6 }; + if (mediaType === 'video') return { currency: 'USD', floor: 8 }; + return { currency: 'USD', floor: 5 }; + }; + + const req = spec.buildRequests([bid], { auctionId: 'flo-granular-usd', timeout: 300 }); + const imp = JSON.parse(req.data).imp[0]; + + expect(imp).to.not.have.property('bidfloor'); + expect(imp).to.not.have.property('bidfloorcur'); + expect(imp.banner).to.not.have.property('ext'); + (imp.banner.format || []).forEach((format) => { + expect(format).to.not.have.property('ext'); + }); + if (imp.video) { + expect(imp.video).to.not.have.property('ext'); + } + }); + + it('keeps granular floors that are already in EUR', () => { + const bid = DEFAULT_REQUEST(); + bid.getFloor = ({ mediaType, size }) => { + if (Array.isArray(size)) return { currency: 'EUR', floor: 7 }; + if (mediaType === 'banner') return { currency: 'EUR', floor: 6 }; + return { currency: 'EUR', floor: 5 }; + }; + + const req = spec.buildRequests([bid], { auctionId: 'flo-granular-eur', timeout: 300 }); + const imp = JSON.parse(req.data).imp[0]; + + expect(imp.bidfloor).to.equal(7); + expect(imp).to.have.property('bidfloorcur', 'EUR'); + expect(imp.banner).to.have.nested.property('ext.bidfloor', 6); + expect(imp.banner).to.have.nested.property('ext.bidfloorcur', 'EUR'); + }); + + it('keeps the imp, unfloored, when getFloor throws', () => { + // Publisher supplied floor code can throw (priceFloors calls + // inverseBidAdjustment unguarded). Without a catch the converter drops the + // whole imp and the request goes out with no impressions. + const bid = DEFAULT_REQUEST(); + bid.getFloor = () => { + throw new Error('floor provider exploded'); + }; + + const req = spec.buildRequests([bid], { auctionId: 'flo-throws', timeout: 300 }); + const ortb = JSON.parse(req.data); + + expect(ortb.imp).to.be.an('array').with.length(1); + expect(ortb.imp[0].id).to.equal(bid.bidId); + expect(ortb.imp[0].tagid).to.equal('1111'); + expect(ortb.imp[0]).to.not.have.property('bidfloor'); + expect(ortb.imp[0]).to.not.have.property('bidfloorcur'); + }); + + it('passes "*" as size when the banner has more than one size', () => { + const bid = DEFAULT_REQUEST(); + bid.mediaTypes.banner.sizes = [[728, 90], [800, 250]]; + let lastArgs; + bid.getFloor = (args) => { + lastArgs = args; + return { currency: 'EUR', floor: 0.8 }; + }; + + const req = spec.buildRequests([bid], { auctionId: 'flo-multisize', timeout: 300 }); + const ortb = JSON.parse(req.data); + + expect(lastArgs).to.deep.equal({ currency: 'EUR', mediaType: 'banner', size: '*' }); + expect(ortb.imp[0].bidfloor).to.equal(0.8); + expect(ortb.imp[0]).to.have.property('bidfloorcur', 'EUR'); + }); + + it('prefers banner over video on a multiformat ad unit', () => { + const bid = DEFAULT_REQUEST(); + bid.mediaTypes.video = { playerSize: [[640, 480]], context: 'outstream' }; + let lastArgs; + bid.getFloor = (args) => { + lastArgs = args; + return { currency: 'EUR', floor: 1.7 }; + }; + + const req = spec.buildRequests([bid], { auctionId: 'flo-multiformat', timeout: 300 }); + const ortb = JSON.parse(req.data); + + expect(lastArgs.mediaType).to.equal('banner'); + expect(lastArgs.size).to.deep.equal([728, 90]); + expect(ortb.imp[0].bidfloor).to.equal(1.7); + }); + + it('leaves the imp unfloored when the bid has no getFloor', () => { + const bid = DEFAULT_REQUEST(); + expect(bid.getFloor).to.equal(undefined); + + const req = spec.buildRequests([bid], { auctionId: 'flo-nogetfloor', timeout: 300 }); + const ortb = JSON.parse(req.data); + + expect(ortb.imp[0]).to.not.have.property('bidfloor'); + expect(ortb.imp[0]).to.not.have.property('bidfloorcur'); + }); + + it('leaves the imp unfloored when getFloor answers with a non-finite floor', () => { + const bid = DEFAULT_REQUEST(); + bid.getFloor = () => ({ currency: 'EUR', floor: undefined }); + + const req = spec.buildRequests([bid], { auctionId: 'flo-nonfinite', timeout: 300 }); + const ortb = JSON.parse(req.data); + + expect(ortb.imp[0]).to.not.have.property('bidfloor'); + expect(ortb.imp[0]).to.not.have.property('bidfloorcur'); + }); + + it('forwards a zero floor, which the converter alone would have skipped', () => { + const bid = DEFAULT_REQUEST(); + bid.getFloor = () => ({ currency: 'EUR', floor: 0 }); + + const req = spec.buildRequests([bid], { auctionId: 'flo-zero', timeout: 300 }); + const ortb = JSON.parse(req.data); + + // applyFloorToImp gates on Number.isFinite, the converter's tryGetFloor on + // truthiness - so a floor of exactly 0 only survives because of the adapter. + expect(ortb.imp[0].bidfloor).to.equal(0); + expect(ortb.imp[0]).to.have.property('bidfloorcur', 'EUR'); + }); }); describe('applyConsent', () => { @@ -616,6 +942,34 @@ describe('yieldlabBidAdapter (ORTB)', () => { expect(ortb).to.have.nested.property('regs.gdpr', 0); expect(ortb).to.not.have.nested.property('user.consent'); }); + + it('mirrors an empty consent string rather than dropping it', () => { + const bid = DEFAULT_REQUEST(); + const bidderRequest = { + auctionId: 'gdpr-empty-consent', + timeout: 400, + ortb2: { + regs: { ext: { gdpr: 1 } }, + user: { ext: { consent: '' } } + } + }; + + const req = spec.buildRequests([bid], bidderRequest); + const ortb = JSON.parse(req.data); + + expect(ortb).to.have.nested.property('user.ext.consent', ''); + expect(ortb).to.have.nested.property('user.consent', ''); + }); + + it('leaves both 2.6 fields absent when the page supplies no consent data', () => { + const bid = DEFAULT_REQUEST(); + + const req = spec.buildRequests([bid], { auctionId: 'gdpr-absent', timeout: 400 }); + const ortb = JSON.parse(req.data); + + expect(ortb).to.not.have.nested.property('regs.gdpr'); + expect(ortb).to.not.have.nested.property('user.consent'); + }); }); describe('multiple imps', () => { @@ -658,5 +1012,141 @@ describe('yieldlabBidAdapter (ORTB)', () => { expect(ortb).to.have.nested.property('source.ext.schain.ver', '1.0'); expect(ortb).to.have.nested.property('source.schain.ver', '1.0'); }); + + it('mirrors the whole chain, nodes included', () => { + const bid = DEFAULT_REQUEST(); + + const req = spec.buildRequests([bid], { + auctionId: 'schain-nodes', + timeout: 300, + ortb2: { source: { ext: { schain: bid.schain } } } + }); + const ortb = JSON.parse(req.data); + + expect(ortb.source.schain).to.deep.equal(bid.schain); + expect(ortb.source.schain.nodes).to.have.length(2); + expect(ortb.source.schain.nodes[1].name).to.equal('indirectseller2 name with comma , and bang !'); + }); + + it('leaves source.schain absent when the page provides no chain', () => { + const bid = DEFAULT_REQUEST(); + + const req = spec.buildRequests([bid], { auctionId: 'schain-none', timeout: 300 }); + const ortb = JSON.parse(req.data); + + expect(ortb).to.not.have.nested.property('source.schain'); + }); + }); + + describe('applyEids', () => { + it('mirrors user.ext.eids to user.eids', () => { + const bid = DEFAULT_REQUEST(); + + const req = spec.buildRequests([bid], { + auctionId: 'eids-mirror', + timeout: 300, + ortb2: { user: { ext: { eids: bid.userIdAsEids } } } + }); + const ortb = JSON.parse(req.data); + + expect(ortb.user.ext.eids).to.deep.equal(bid.userIdAsEids); + expect(ortb.user.eids).to.deep.equal(bid.userIdAsEids); + expect(ortb.user.eids[0].source).to.equal('netid.de'); + expect(ortb.user.eids[1].uids[0].atype).to.equal(2); + }); + + it('leaves user.eids absent when the page provides no eids', () => { + const bid = DEFAULT_REQUEST(); + + const req = spec.buildRequests([bid], { auctionId: 'eids-none', timeout: 300 }); + const ortb = JSON.parse(req.data); + + expect(ortb).to.not.have.nested.property('user.eids'); + }); + }); + + describe('dealId', () => { + it('is left unset when the served bid carries no dealid', () => { + const bid = DEFAULT_REQUEST(); + const req = spec.buildRequests([bid], { auctionId: 'deal-none', timeout: 300 }); + const ortbReq = JSON.parse(req.data); + + const serverResponse = { + body: { + id: 'deal-none', + seatbid: [{ + bid: [{ + impid: ortbReq.imp[0].id, + price: 1.0, + adm: '
banner creative
', + crid: 'no-deal', + adomain: ['yieldlab'] + // no dealid + }] + }], + cur: 'EUR' + } + }; + + const res = spec.interpretResponse(serverResponse, req); + expect(res).to.have.length(1); + expect(res[0]).to.not.have.property('dealId'); + }); + }); + describe('facet-type mapping as a candidate fix for the native id mismatch', () => { + if (FEATURES.NATIVE) { + // The legacy (pre-POST-ORTB) adapter matched the DSP's assets by FACET TYPE and emitted + // legacy keys; Prebid then converted back via toOrtbNativeResponse, which clones the + // PUBLISHER's requested asset and so stamps the publisher's id on. That is why legacy + // never hit the id mismatch. These two tests pin what that approach does and does NOT fix. + const legacyStyleFacetMap = (ortbAssets) => { + const icon = ortbAssets.find(a => a.img && a.img.type === 1); + const image = ortbAssets.find(a => a.img && a.img.type === 3); + const title = ortbAssets.find(a => a.title); + const body = ortbAssets.find(a => a.data); + const out = { clickUrl: 'https://ad.yieldlab.net/qa/click' }; + if (title) out.title = title.title.text; + if (body) out.body = body.data.value; + if (image) out.image = { url: image.img.url, width: image.img.w, height: image.img.h }; + if (icon) out.icon = { url: icon.img.url, width: icon.img.w, height: icon.img.h }; + return out; + }; + + // Stage native template 13: exactly ONE asset, an icon image (img.type 1), id 4. + const TPL_IMAGE_URL = 'https://ad.yieldlab.net/qa/main.jpg'; + const TEMPLATE_13_ASSETS = [ + { id: 4, img: { type: 1, url: TPL_IMAGE_URL, w: 300, h: 250 } } + ]; + + const validateVia = (publisherNativeCfg) => { + const publisherRequest = toOrtbNativeRequest(publisherNativeCfg); + const converted = toOrtbNativeResponse(legacyStyleFacetMap(TEMPLATE_13_ASSETS), publisherRequest); + const withLink = { ...converted, link: { url: 'https://ad.yieldlab.net/qa/click' } }; + return { + publisherRequest, + converted, + valid: isNativeOpenRTBBidValid(withLink, publisherRequest) + }; + }; + + it('FIXES the id mismatch when the template covers the facets the publisher asked for', () => { + const { converted, valid } = validateVia({ icon: { required: true, sizes: [100, 100] } }); + + // The template's id 4 has been rewritten to the publisher's own id 0. + expect(converted.assets.map(a => a.id)).to.deep.equal([0]); + expect(valid).to.equal(true); + }); + + it('does NOT help when the template lacks facets the publisher marked required', () => { + const { publisherRequest, converted, valid } = validateVia(NATIVE_REQUEST().mediaTypes.native); + + // Publisher required title + body + image + icon; template 13 only has an icon, so only + // the icon can be mapped. The bid is still rejected -- and rightly so: the missing assets + // were never requested from the DSP. No response-side mapping can invent them. + expect(publisherRequest.assets.filter(a => a.required === 1).map(a => a.id)).to.deep.equal([0, 1, 2, 3]); + expect(converted.assets.map(a => a.id)).to.deep.equal([3]); + expect(valid).to.equal(false); + }); + } }); }); From 54c9c070827acda2c6bd32d4133e6ca6bdb8878b Mon Sep 17 00:00:00 2001 From: William Harris Date: Tue, 25 Aug 2026 09:54:04 +0200 Subject: [PATCH 2/2] Yieldlab: reconcile native asset ids by facet type Fixes the native regression documented by the tests in the previous commit. --- modules/yieldlabBidAdapter.js | 114 +++++++++++++++++ test/spec/modules/yieldlabBidAdapter_spec.js | 128 ++++++++----------- 2 files changed, 168 insertions(+), 74 deletions(-) diff --git a/modules/yieldlabBidAdapter.js b/modules/yieldlabBidAdapter.js index 567b2ada976..baae911b08d 100644 --- a/modules/yieldlabBidAdapter.js +++ b/modules/yieldlabBidAdapter.js @@ -106,6 +106,7 @@ export const spec = { applyNetRevenue(resp, bid); sanitizeVideoAsset(resp); ensureBannerSize(resp, ctx); + reconcileNativeAssetIds(resp, ctx); if (resp.mediaType === VIDEO && deepAccess(ctx, 'bidRequest.mediaTypes.video')) { setVideoSize(resp, ctx); @@ -324,6 +325,119 @@ function ensureMeta(resp, bid) { * Also hints "mtype" to native when appropriate. * @param {Object} body ORTB response body */ +/** + * Returns which native facet an asset carries, or undefined. + * + * @param {Object} asset a native request or response asset + * @returns {'title'|'img'|'data'|'video'|undefined} + */ +function nativeFacetOf(asset) { + if (!asset) return undefined; + if (asset.title) return 'title'; + if (asset.img) return 'img'; + if (asset.data) return 'data'; + if (asset.video) return 'video'; + return undefined; +} + +/** + * Pick the requested image asset a response image best fits, by dimension. + * + * Only used to break a tie between several candidate image assets. Returns + * undefined unless there is a single unambiguous winner - guessing wrong files a + * main image into an icon slot, which is worse than leaving the asset alone. + * + * @param {Object[]} candidates unused requested assets carrying an `img` + * @param {Object} img the response image object + * @returns {Object|undefined} + */ +function bestImageFit(candidates, img) { + if (img.w == null || img.h == null) return undefined; + const scored = candidates + .map((candidate) => { + const spec = candidate.img || {}; + if (spec.w != null && spec.h != null) { + return { candidate, score: (spec.w === img.w && spec.h === img.h) ? 0 : null }; + } + const wmin = spec.wmin || 0; + const hmin = spec.hmin || 0; + if (img.w < wmin || img.h < hmin) return { candidate, score: null }; + return { candidate, score: (img.w - wmin) + (img.h - hmin) }; + }) + .filter((entry) => entry.score !== null) + .sort((a, b) => a.score - b.score); + + if (!scored.length) return undefined; + if (scored.length > 1 && scored[0].score === scored[1].score) return undefined; + return scored[0].candidate; +} + +/** + * Rewrite the DSP's native asset ids onto the ids the publisher asked for. + * + * The adserver builds its outgoing native request from the adslot's configured + * native template and deliberately ignores `imp.native.request` (YL-6463), so the + * response comes back carrying the TEMPLATE's asset ids. Prebid then validates and + * renders that response against the ids IT sent - `isNativeOpenRTBBidValid` and + * `toLegacyResponse` both match by id - so without this step every native bid is + * discarded client-side. The legacy adapter avoided the problem by emitting legacy + * keys, which routed through `toOrtbNativeResponse` and re-stamped the publisher's + * ids; this restores that behaviour for the ORTB path. + * + * Matching is by facet (title / img / data / video), preferring an exact sub-type + * match where the response provides one. Note the adserver currently strips + * `img.type` from response assets, so icon-vs-main-image is usually decided by the + * dimension fit above, and is left untouched when it cannot be resolved. + * + * @param {Bid} resp bid response to mutate + * @param {Object} ctx converter context; `ctx.bidRequest.nativeOrtbRequest` is read + * @returns {void} + */ +function reconcileNativeAssetIds(resp, ctx) { + if (resp?.mediaType !== NATIVE) return; + + const requested = deepAccess(ctx, 'bidRequest.nativeOrtbRequest.assets'); + const responseAssets = deepAccess(resp, 'native.ortb.assets'); + if (!Array.isArray(requested) || !requested.length) return; + if (!Array.isArray(responseAssets) || !responseAssets.length) return; + + const claimed = new Set(); + + responseAssets.forEach((asset) => { + const facet = nativeFacetOf(asset); + if (!facet) return; + + // Already addressed to an asset the publisher asked for - leave it be. + const exact = requested.find((r) => r.id === asset.id && nativeFacetOf(r) === facet); + if (exact) { + claimed.add(exact.id); + return; + } + + const candidates = requested.filter( + (r) => nativeFacetOf(r) === facet && !claimed.has(r.id) + ); + if (!candidates.length) return; + + const subType = facet === 'img' ? asset.img.type : (facet === 'data' ? asset.data.type : undefined); + + let match; + if (subType != null) { + match = candidates.find((c) => (facet === 'img' ? c.img : c.data)?.type === subType); + } + if (!match && candidates.length === 1) { + match = candidates[0]; + } + if (!match && facet === 'img') { + match = bestImageFit(candidates, asset.img); + } + if (!match) return; // ambiguous - do not guess + + claimed.add(match.id); + asset.id = match.id; + }); +} + function unwrapNativeAdm(body) { (body.seatbid || []).forEach((seat) => { (seat.bid || []).forEach((b) => { diff --git a/test/spec/modules/yieldlabBidAdapter_spec.js b/test/spec/modules/yieldlabBidAdapter_spec.js index a20b0af6b3f..2f609579dbc 100644 --- a/test/spec/modules/yieldlabBidAdapter_spec.js +++ b/test/spec/modules/yieldlabBidAdapter_spec.js @@ -481,28 +481,29 @@ describe('yieldlabBidAdapter (ORTB)', () => { describe('native asset-id contract with the adserver native template', () => { if (FEATURES.NATIVE) { - // The adserver DISCARDS imp.native.request and rebuilds the native request from the - // adslot's native template (OpenRTBRequestGeneratorServiceImpl.getNativeRequest reads - // only the template's assets). The DSP therefore answers with the TEMPLATE's asset ids, - // and this adapter passes that ORTB straight through as bid.native.ortb. Prebid then - // validates the response against the PUBLISHER's request, matching by asset id - // (isNativeOpenRTBBidValid / toLegacyResponse), so the two only line up if the - // publisher's config mirrors the Yieldlab template. - // - // Fixture below is stage native template 13: a single icon image asset with id 4. - // (img.type 1 = ICON, 3 = MAIN, per NATIVE_IMAGE_TYPES in src/constants.) + // The adserver builds its outgoing native request from the adslot's configured native + // template and deliberately ignores imp.native.request, so the DSP answers with + // the TEMPLATE's asset ids. Prebid validates and renders by id, so the adapter reconciles + // the two in reconcileNativeAssetIds(). Note Adserver strips img.type from the response. const TEMPLATE_IMAGE_URL = 'https://ad.yieldlab.net/qa/main.jpg'; const TEMPLATE_13_ADM = JSON.stringify({ native: { ver: '1.2', link: { url: 'https://ad.yieldlab.net/qa/click' }, assets: [ - { id: 4, required: 0, img: { type: 1, url: TEMPLATE_IMAGE_URL, w: 300, h: 250 } } + { id: 4, img: { url: TEMPLATE_IMAGE_URL, w: 300, h: 250 } } ], eventtrackers: [{ event: 1, method: 1, url: 'https://ad.yieldlab.net/1x1.gif' }] } }); + /** Build a native bid request the way the auction would, with nativeOrtbRequest populated. */ + const nativeBid = (nativeCfg) => { + const bid = { ...DEFAULT_REQUEST(), mediaTypes: { native: nativeCfg } }; + bid.nativeOrtbRequest = toOrtbNativeRequest(nativeCfg); + return bid; + }; + /** Run a bid request through buildRequests + interpretResponse against the template adm. */ const interpretTemplateResponse = (bidRequest) => { const req = spec.buildRequests([bidRequest], { auctionId: 'nat-tpl' }); @@ -526,99 +527,78 @@ describe('yieldlabBidAdapter (ORTB)', () => { return res[0]; }; - /** A publisher ad unit whose native config is the usual mediaTypes.native shorthand. */ - const shorthandRequest = () => { - const bid = NATIVE_REQUEST(); - bid.nativeOrtbRequest = toOrtbNativeRequest(bid.mediaTypes.native); - return bid; - }; - - it('passes the template asset ids through to bid.native.ortb untouched', () => { - const bidResponse = interpretTemplateResponse(shorthandRequest()); - - expect(bidResponse.mediaType).to.equal('native'); - expect(bidResponse.native.ortb.assets.map(asset => asset.id)).to.deep.equal([4]); - expect(bidResponse.native.ortb.link.url).to.equal('https://ad.yieldlab.net/qa/click'); - }); - it('numbers publisher assets from 0, so the template id 4 is never among them', () => { const publisherRequest = toOrtbNativeRequest(NATIVE_REQUEST().mediaTypes.native); - const ids = publisherRequest.assets.map(asset => asset.id); + const ids = publisherRequest.assets.map((asset) => asset.id); expect(ids[0]).to.equal(0); expect(ids).to.not.include(4); - expect(publisherRequest.assets.some(asset => asset.required === 1)).to.equal(true); }); - it('CURRENT BEHAVIOUR (defective): the bid is rejected when the publisher config does not mirror the template', () => { - const bid = shorthandRequest(); + it('a publisher using the standard mediaTypes.native shorthand gets a usable native bid', () => { + const bid = nativeBid({ icon: { required: true, sizes: [100, 100] } }); const bidResponse = interpretTemplateResponse(bid); - // Required ids 0..3 were asked for; only id 4 came back. - expect(isNativeOpenRTBBidValid(bidResponse.native.ortb, bid.nativeOrtbRequest)).to.equal(false); + // The template's id 4 has been reconciled onto the publisher's own id. + expect(bidResponse.mediaType).to.equal('native'); + expect(bidResponse.native.ortb.assets.map((a) => a.id)).to.deep.equal([0]); + expect(isNativeOpenRTBBidValid(bidResponse.native.ortb, bid.nativeOrtbRequest)).to.equal(true); }); - // ------------------------------------------------------------------ - // DESIRED BEHAVIOUR — currently FAILS. This is the bug, expressed as a - // test. It should go green when native is fixed, wherever the fix lands: - // * adserver-side — honour/intersect the incoming imp.native.request so - // the response already carries the publisher's asset ids; or - // * adapter-side — reconcile the DSP's template-shaped assets onto the - // publisher's requested ids by facet type, which is what the legacy - // (pre-POST-ORTB) adapter effectively did via toOrtbNativeResponse. - // Either fix satisfies this test, so it does not prejudge the design. - // Delete the characterization tests above once this one passes. - // ------------------------------------------------------------------ - it('a publisher using the standard mediaTypes.native shorthand gets a usable native bid', () => { - const bid = shorthandRequest(); + it('files the image in the image slot, not the icon slot', () => { + const bid = nativeBid({ + title: { required: false, len: 90 }, + body: { required: false }, + image: { required: false, sizes: [300, 250] } + }); const bidResponse = interpretTemplateResponse(bid); + const legacy = toLegacyResponse(bidResponse.native.ortb, bid.nativeOrtbRequest); expect(isNativeOpenRTBBidValid(bidResponse.native.ortb, bid.nativeOrtbRequest)).to.equal(true); - - const legacy = toLegacyResponse(bidResponse.native.ortb, bid.nativeOrtbRequest); expect(legacy.image?.url).to.equal(TEMPLATE_IMAGE_URL); + expect(legacy.icon).to.equal(undefined); }); - it('ACCEPTS the bid when the publisher config mirrors the template asset ids', () => { - const mirroredOrtb = { - ver: '1.2', - assets: [{ id: 4, required: 0, img: { type: 1, wmin: 100, hmin: 100 } }] - }; - const bid = { ...DEFAULT_REQUEST(), mediaTypes: { native: { ortb: mirroredOrtb } } }; - bid.nativeOrtbRequest = mirroredOrtb; + it('leaves the id alone when the facet is ambiguous and cannot be resolved', () => { + // Publisher wants BOTH an icon and a main image. The adserver strips img.type from the + // response, and neither requested size fits 300x250, so there is no safe way to tell + // which slot this image belongs in. Guessing would render a main image as an icon, so + // the asset is left untouched and the bid is rejected downstream instead. + const bid = nativeBid({ + icon: { required: false, sizes: [16, 16] }, + image: { required: false, sizes: [100, 100] } + }); + const bidResponse = interpretTemplateResponse(bid); + + expect(bidResponse.native.ortb.assets.map((a) => a.id)).to.deep.equal([4]); + }); + it('still rejects when the template cannot supply a required facet', () => { + // Publisher requires title + body + image + icon; template 13 has only one image. No + // amount of id reconciliation can invent a title, so the bid is correctly rejected. + // This is the residual gap: it is publisher misconfiguration under the template-driven + // model, not an adapter defect. + const bid = nativeBid(NATIVE_REQUEST().mediaTypes.native); const bidResponse = interpretTemplateResponse(bid); - expect(isNativeOpenRTBBidValid(bidResponse.native.ortb, bid.nativeOrtbRequest)).to.equal(true); + expect(isNativeOpenRTBBidValid(bidResponse.native.ortb, bid.nativeOrtbRequest)).to.equal(false); }); - it('CURRENT BEHAVIOUR (defective): required:0 lets the id mismatch through, and the image is mis-filed as icon', () => { - const bid = { - ...DEFAULT_REQUEST(), - mediaTypes: { - native: { - title: { required: false, len: 90 }, - body: { required: false }, - image: { required: false, sizes: [300, 250] } - } - } + it('leaves an already-correct id untouched when the publisher mirrors the template', () => { + const mirroredOrtb = { + ver: '1.2', + assets: [{ id: 4, required: 0, img: { type: 1, wmin: 100, hmin: 100 } }] }; - bid.nativeOrtbRequest = toOrtbNativeRequest(bid.mediaTypes.native); + const bid = { ...DEFAULT_REQUEST(), mediaTypes: { native: { ortb: mirroredOrtb } } }; + bid.nativeOrtbRequest = mirroredOrtb; const bidResponse = interpretTemplateResponse(bid); - // Nothing is required, so the required-asset check cannot fail... + expect(bidResponse.native.ortb.assets.map((a) => a.id)).to.deep.equal([4]); expect(isNativeOpenRTBBidValid(bidResponse.native.ortb, bid.nativeOrtbRequest)).to.equal(true); - - // ...but the mapping is still by id, and id 4 matches nothing the publisher asked for, - // so the creative's image is filed as `icon` and `image` is never populated. - const legacy = toLegacyResponse(bidResponse.native.ortb, bid.nativeOrtbRequest); - expect(legacy.icon?.url).to.equal(TEMPLATE_IMAGE_URL); - expect(legacy.image).to.equal(undefined); }); } }); - describe('applyNetRevenue', () => { it('sets bidResponse.netRevenue from bid.ext.netrevenue (true)', () => { const bid = DEFAULT_REQUEST();