From 888378b65daa527b5d9bfeea4a30bbe42e4d802b Mon Sep 17 00:00:00 2001 From: austinbyron <59710247+austinbyron@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:36:21 -0700 Subject: [PATCH 1/3] New Bid Adapter: Ezoic --- modules/ezoicBidAdapter.d.ts | 12 + modules/ezoicBidAdapter.js | 441 +++++++++++ modules/ezoicBidAdapter.md | 75 ++ test/spec/modules/ezoicBidAdapter_spec.js | 918 ++++++++++++++++++++++ 4 files changed, 1446 insertions(+) create mode 100644 modules/ezoicBidAdapter.d.ts create mode 100644 modules/ezoicBidAdapter.js create mode 100644 modules/ezoicBidAdapter.md create mode 100644 test/spec/modules/ezoicBidAdapter_spec.js diff --git a/modules/ezoicBidAdapter.d.ts b/modules/ezoicBidAdapter.d.ts new file mode 100644 index 0000000000..28c2874b1b --- /dev/null +++ b/modules/ezoicBidAdapter.d.ts @@ -0,0 +1,12 @@ +export interface EzoicBidderParams { + /** + * Optional placement identifier assigned during Ezoic onboarding. + */ + placementId?: string; +} + +declare module '../src/adUnits' { + interface BidderParams { + ezoic: EzoicBidderParams; + } +} diff --git a/modules/ezoicBidAdapter.js b/modules/ezoicBidAdapter.js new file mode 100644 index 0000000000..2221165568 --- /dev/null +++ b/modules/ezoicBidAdapter.js @@ -0,0 +1,441 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { getWinDimensions } from '../src/utils.js'; + +const BIDDER_CODE = 'ezoic'; +const GVL_ID = 347; +const DEFAULT_TTL = 120; +const DEFAULT_CURRENCY = 'USD'; +const ADAPTER_ENDPOINT = 'https://g.ezoic.net/ezoic/prebid/adapter'; +const USER_SYNC_ENDPOINT = 'https://g.ezoic.net/ezoic/prebid/adapter/usersync-frame'; +const ADAPTER_NAMESPACE = '__ezoicPrebidAdapter'; +const PAGEVIEW_SOURCE_ADAPTER_GENERATED = 'adapter_generated'; +const PAGEVIEW_SOURCE_PREBID_CORE = 'prebid_core'; +const FORM_FACTOR_DESKTOP = 1; +const FORM_FACTOR_PHONE = 2; +const FORM_FACTOR_TABLET = 3; + +// Mirrors the prebid-server Ezoic adapter's param contract +// (static/bidder-params/ezoic.json): a single optional placementId. No params +// are required for a valid bid request; see ezoicBidAdapter.md. +const ALLOWED_IMPRESSION_PARAM_KEYS = [ + 'placementId', +]; + +function parseInteger(value) { + const parsed = parseInt(value, 10); + return isNaN(parsed) ? undefined : parsed; +} + +function getImpressionParams(params = {}) { + return ALLOWED_IMPRESSION_PARAM_KEYS.reduce((memo, key) => { + if (params[key] != null) { + memo[key] = params[key]; + } + return memo; + }, {}); +} + +function cloneJSON(value) { + if (value == null) { + return undefined; + } + + try { + return JSON.parse(JSON.stringify(value)); + } catch (e) { + return undefined; + } +} + +function getPageMetadata(bidderRequest) { + return { + url: bidderRequest?.refererInfo?.page || window.location.href, + }; +} + +function currentPageviewEpoch() { + return Math.floor(Date.now() / 1000); +} + +function getAdapterState() { + window[ADAPTER_NAMESPACE] = window[ADAPTER_NAMESPACE] || {}; + return window[ADAPTER_NAMESPACE]; +} + +function randomPageviewId() { + if (window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + + const bytes = new Uint8Array(16); + if (window.crypto?.getRandomValues) { + window.crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')); + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; +} + +// Every pageview gets one stable id. Prebid core's pageViewId (SPA refreshes) +// takes precedence; otherwise generate once and cache in the adapter namespace +// so repeat auctions on the same pageview report the same id. +function getPageviewMetadata(bidderRequest) { + const state = getAdapterState(); + const corePageViewId = bidderRequest?.pageViewId; + + if (corePageViewId) { + if (state.corePageViewId !== corePageViewId) { + state.corePageViewId = corePageViewId; + state.pageviewEpoch = currentPageviewEpoch(); + } else if (state.pageviewEpoch == null) { + state.pageviewEpoch = currentPageviewEpoch(); + } + + return { + pageviewId: corePageViewId, + pageviewIdSource: PAGEVIEW_SOURCE_PREBID_CORE, + pageviewEpoch: state.pageviewEpoch, + }; + } + + if (!state.pageviewId) { + state.pageviewId = randomPageviewId(); + state.pageviewEpoch = currentPageviewEpoch(); + } + + return { + pageviewId: state.pageviewId, + pageviewIdSource: PAGEVIEW_SOURCE_ADAPTER_GENERATED, + pageviewEpoch: state.pageviewEpoch, + }; +} + +function getViewportWidth() { + return getWinDimensions()?.innerWidth; +} + +function inferFormFactorId() { + const width = parseInteger(getViewportWidth()); + if (!width) { + return undefined; + } + if (width <= 767) { + return FORM_FACTOR_PHONE; + } + if (width <= 1023) { + return FORM_FACTOR_TABLET; + } + return FORM_FACTOR_DESKTOP; +} + +function normalizeCountry(country) { + return typeof country === 'string' && country ? country.toUpperCase() : undefined; +} + +function getCountry(ortb2) { + return normalizeCountry(ortb2?.device?.geo?.country) || + normalizeCountry(ortb2?.user?.geo?.country) || + normalizeCountry(ortb2?.site?.geo?.country); +} + +function getORTB2Metadata(bidderRequest, validBidRequests) { + const ortb2 = cloneJSON(bidderRequest?.ortb2) || {}; + const existingEids = ortb2?.user?.ext?.eids; + const eids = existingEids?.length ? existingEids : validBidRequests.find((bid) => bid.userIdAsEids?.length)?.userIdAsEids; + + if (eids?.length) { + ortb2.user = ortb2.user || {}; + ortb2.user.ext = ortb2.user.ext || {}; + ortb2.user.ext.eids = cloneJSON(eids); + } + + return ortb2; +} + +function getEzoicMetadata(bidderRequest, ortb2) { + return { + ...getPageviewMetadata(bidderRequest), + formFactorId: inferFormFactorId(), + country: getCountry(ortb2), + }; +} + +function getPrimaryBannerSize(bid) { + const sizes = bid.mediaTypes?.banner?.sizes || bid.sizes; + if (!Array.isArray(sizes) || !sizes.length) { + return '*'; + } + if (Array.isArray(sizes[0])) { + return sizes[0]; + } + return sizes.length >= 2 ? sizes : '*'; +} + +function getPrimaryVideoSize(bid) { + let size = bid.mediaTypes?.video?.playerSize; + if (Array.isArray(size) && Array.isArray(size[0])) { + size = size[0]; + } + const width = Number(size?.[0]); + const height = Number(size?.[1]); + if (width > 0 && height > 0) { + return [width, height]; + } + if (Number(bid.mediaTypes?.video?.w) > 0 && Number(bid.mediaTypes?.video?.h) > 0) { + return [Number(bid.mediaTypes.video.w), Number(bid.mediaTypes.video.h)]; + } + return '*'; +} + +function isVideoOnlyBid(bid) { + return !!bid.mediaTypes?.video && !bid.mediaTypes?.banner; +} + +function isVideoBidRequest(bid) { + return !!bid?.mediaTypes?.video; +} + +function isNativeOnlyBid(bid) { + return !!bid?.mediaTypes?.native && !bid.mediaTypes.banner && !bid.mediaTypes.video; +} + +function isNativeBidRequest(bid) { + return !!bid?.mediaTypes?.native; +} + +function getBidFloor(bid) { + if (typeof bid.getFloor !== 'function') { + return undefined; + } + + const videoOnly = isVideoOnlyBid(bid); + const nativeOnly = isNativeOnlyBid(bid); + try { + const floor = bid.getFloor({ + currency: DEFAULT_CURRENCY, + mediaType: nativeOnly ? NATIVE : (videoOnly ? VIDEO : BANNER), + size: nativeOnly ? '*' : (videoOnly ? getPrimaryVideoSize(bid) : getPrimaryBannerSize(bid)), + }); + return floor?.floor; + } catch (e) { + return undefined; + } +} + +function getImpressionMediaTypes(bid) { + const mediaTypes = bid?.mediaTypes; + if (!mediaTypes?.banner && !mediaTypes?.video && !mediaTypes?.native) { + return undefined; + } + const proxied = {}; + if (mediaTypes.banner) { + proxied.banner = mediaTypes.banner; + } + if (mediaTypes.video) { + proxied.video = mediaTypes.video; + } + if (mediaTypes.native) { + proxied.native = mediaTypes.native; + } + return proxied; +} + +function getImpression(bid) { + return { + requestId: bid.bidId, + adUnitCode: bid.adUnitCode, + sizes: bid.sizes || bid.mediaTypes?.banner?.sizes || [], + mediaTypes: getImpressionMediaTypes(bid), + params: getImpressionParams(bid.params), + floor: getBidFloor(bid), + ortb2Imp: bid.ortb2Imp, + }; +} + +function originalBidByRequestId(request) { + const bids = request?.bidderRequest?.bids || []; + return bids.reduce((memo, bid) => { + memo[bid.bidId] = bid; + return memo; + }, {}); +} + +function getFallbackSize(sourceBid, isVideo) { + if (isVideo) { + const videoSize = getPrimaryVideoSize(sourceBid); + if (videoSize !== '*') { + return videoSize; + } + return sourceBid.sizes?.[0] || sourceBid.mediaTypes?.banner?.sizes?.[0] || []; + } + + return sourceBid.sizes?.[0] || sourceBid.mediaTypes?.banner?.sizes?.[0] || []; +} + +function normalizeBid(rawBid, sourceBid) { + if (!rawBid || !sourceBid || !rawBid.requestId || !rawBid.creativeId) { + return; + } + + const cpm = Number(rawBid.cpm); + if (!Number.isFinite(cpm) || cpm < 0) { + return; + } + + if (rawBid.mediaType === VIDEO && !isVideoBidRequest(sourceBid)) { + return; + } + if (rawBid.mediaType === NATIVE && !isNativeBidRequest(sourceBid)) { + return; + } + + const isVideo = rawBid.mediaType === VIDEO && isVideoBidRequest(sourceBid); + const isNative = rawBid.mediaType === NATIVE && isNativeBidRequest(sourceBid); + + // Outstream setup (publisher renderer vs cache/useCacheKey) is validated by + // core's checkVideoBidSetup hook, which drops invalid bids with a clear + // error; the adapter does not pre-empt that (it would silently break valid + // cache-based configurations and hooked overrides). + const firstSize = getFallbackSize(sourceBid, isVideo); + const width = rawBid.width || firstSize[0]; + const height = rawBid.height || firstSize[1]; + + if (isNative) { + if (!rawBid.native) { + return; + } + } else if (!width || !height || (!rawBid.ad && !rawBid.adUrl && !(isVideo && (rawBid.vastUrl || rawBid.vastXml)))) { + return; + } + + const bidResponse = { + requestId: rawBid.requestId, + cpm, + currency: rawBid.currency || DEFAULT_CURRENCY, + creativeId: String(rawBid.creativeId), + netRevenue: rawBid.netRevenue !== false, + ttl: rawBid.ttl || DEFAULT_TTL, + mediaType: isNative ? NATIVE : (isVideo ? VIDEO : BANNER), + // Most reviewers require meta.advertiserDomains to be present on every + // bid for block-list enforcement, so default to an empty array when the + // server does not send one. + meta: { + ...(rawBid.meta || {}), + advertiserDomains: rawBid.meta?.advertiserDomains || [], + }, + }; + if (!isNative || rawBid.width || rawBid.height) { + bidResponse.width = width; + bidResponse.height = height; + } + + [ + 'ad', + 'adUrl', + 'vastUrl', + 'vastXml', + 'dealId', + 'native', + ].forEach((key) => { + if (rawBid[key] != null) { + bidResponse[key] = rawBid[key]; + } + }); + + return bidResponse; +} + +export const spec = { + code: BIDDER_CODE, + gvlid: GVL_ID, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + // All bidder params are optional (see ezoicBidAdapter.md), so every ad + // unit routed to this bidder is a valid bid request. + isBidRequestValid(bid) { + return true; + }, + + buildRequests(validBidRequests, bidderRequest) { + if (!validBidRequests?.length) { + return; + } + + const ortb2 = getORTB2Metadata(bidderRequest, validBidRequests); + const payload = { + auctionId: bidderRequest?.auctionId || validBidRequests[0].auctionId, + bidderRequestId: bidderRequest?.bidderRequestId || validBidRequests[0].bidderRequestId, + timeout: bidderRequest?.timeout, + page: getPageMetadata(bidderRequest), + ortb2, + ezoic: getEzoicMetadata(bidderRequest, ortb2), + gdprConsent: bidderRequest?.gdprConsent, + uspConsent: bidderRequest?.uspConsent, + gppConsent: bidderRequest?.gppConsent, + imps: validBidRequests.map(getImpression), + }; + + return { + method: 'POST', + url: ADAPTER_ENDPOINT, + data: JSON.stringify(payload), + bidderRequest: { + ...bidderRequest, + bids: validBidRequests, + }, + options: { + contentType: 'application/json', + withCredentials: true, + }, + }; + }, + + interpretResponse(serverResponse, request) { + const body = serverResponse?.body; + if (!body || body.nobid) { + return []; + } + + const rawBids = Array.isArray(body.bids) ? body.bids : [body]; + const sourceBids = originalBidByRequestId(request); + + return rawBids + .map((rawBid) => normalizeBid(rawBid, sourceBids[rawBid?.requestId])) + .filter(Boolean); + }, + + // Intentionally no event callbacks (onBidWon, onAdRenderSucceeded, + // onBidViewable, onTimeout, onBidderError): every lifecycle, render, + // and viewability pixel is embedded in the creative markup by the + // adapter backend, so client-installed and S2S/PBS serves share one + // creative-owned tracking contract. + getUserSyncs(syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent) { + if (!syncOptions?.iframeEnabled) { + return []; + } + + // Cookie storage/reads happen server-side inside the sync frame; no + // redirect ("r") param is needed here. + const params = new URLSearchParams({ + gdpr: gdprConsent?.gdprApplies ? '1' : '0', + gdpr_consent: gdprConsent?.consentString || '', + gpp: gppConsent?.gppString || '', + gpp_sid: gppConsent?.applicableSections?.join(',') || '', + us_privacy: uspConsent || '', + }); + + return [{ + type: 'iframe', + url: `${USER_SYNC_ENDPOINT}?${params.toString()}`, + }]; + }, +}; + +registerBidder(spec); diff --git a/modules/ezoicBidAdapter.md b/modules/ezoicBidAdapter.md new file mode 100644 index 0000000000..0460bf6f9c --- /dev/null +++ b/modules/ezoicBidAdapter.md @@ -0,0 +1,75 @@ +# Overview + +```text +Module Name: Ezoic Bid Adapter +Module Type: Bidder Adapter +Maintainer: prebid@ezoic.com +``` + +## Description + +Ezoic Bid Adapter supports Banner, Video, and Native media types. + +Ezoic is a publisher monetization platform serving demand across a large network of +site inventory (GVL ID 347). The adapter connects to Ezoic's Prebid demand endpoint. + +Ezoic requires publisher domains to be registered and approved before bidding; +unapproved inventory receives no-bid responses. Contact prebid@ezoic.com to get +set up. + +The param contract mirrors the Ezoic Prebid Server adapter: a single optional +`placementId`. No params are required — every ad unit routed to `ezoic` is a +valid bid request. Bid floors flow through the standard Prebid floors module. + +## Bidder Params + +| Name | Scope | Type | Description | Example | +| --- | --- | --- | --- | --- | +| `placementId` | optional | `string` | Placement identifier assigned during Ezoic onboarding | `'placement-123'` | + +## Outstream Video + +The adapter returns outstream video bids as VAST (`vastUrl`/`vastXml`) and does not +bundle a renderer. Use a standard Prebid outstream setup: supply a renderer on the ad +unit or `mediaTypes.video`, or use a cache-based configuration +(`mediaTypes.video.useCacheKey` with a Prebid Cache URL) where your player fetches the +cached VAST. Prebid core validates the setup and rejects outstream bids that have +neither. Instream video is unaffected. + +## Test Parameters + +```javascript +var adUnits = [ + { + code: 'banner-ad-unit', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bids: [{ + bidder: 'ezoic', + params: {} + }] + } +]; +``` + +## User Syncing + +The adapter registers an iframe user sync (`https://g.ezoic.net/ezoic/prebid/adapter/usersync-frame`) +that carries GDPR, GPP, and CCPA/USP consent as query parameters. Cookie storage and reads happen +server-side inside the sync frame. Enable iframe syncing to allow it to run: + +```javascript +pbjs.setConfig({ + userSync: { + filterSettings: { + iframe: { + bidders: ['ezoic'], + filter: 'include' + } + } + } +}); +``` diff --git a/test/spec/modules/ezoicBidAdapter_spec.js b/test/spec/modules/ezoicBidAdapter_spec.js new file mode 100644 index 0000000000..9c263e45fd --- /dev/null +++ b/test/spec/modules/ezoicBidAdapter_spec.js @@ -0,0 +1,918 @@ +import { expect } from 'chai'; +import { spec } from 'modules/ezoicBidAdapter.js'; +import { resetWinDimensions } from 'src/utils.js'; + +const ENDPOINT = 'https://g.ezoic.net/ezoic/prebid/adapter'; +const SYNC_URL = 'https://g.ezoic.net/ezoic/prebid/adapter/usersync-frame'; +const BID_ID = 'ezoic-bid-1'; +const AD_UNIT_CODE = 'div-gpt-ad-content-1'; +const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function getBidRequest(overrides = {}) { + return { + bidder: 'ezoic', + params: { + placementId: 'placement-123', + }, + adUnitCode: AD_UNIT_CODE, + sizes: [[300, 250]], + bidId: BID_ID, + bidderRequestId: 'bidder-request-1', + auctionId: 'auction-1', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + ...overrides + }; +} + +function getVideoBidRequest(overrides = {}) { + return getBidRequest({ + params: { + placementId: 'placement-video-1', + }, + sizes: undefined, + mediaTypes: { + video: { + context: 'instream', + playerSize: [[640, 360]], + plcmt: 1, + mimes: ['video/mp4'], + protocols: [2, 3], + api: [7], + playbackmethod: [1], + minduration: 5, + maxduration: 30 + } + }, + ortb2Imp: { + video: { + plcmt: 1, + w: 640, + h: 360 + }, + ext: { + data: { + pos: 'preroll' + } + } + }, + ...overrides + }); +} + +function getNativeBidRequest(overrides = {}) { + return getBidRequest({ + params: { + placementId: 'placement-native-1', + }, + sizes: undefined, + mediaTypes: { + native: { + ortb: { + ver: '1.2', + assets: [{ + id: 1, + required: 1, + title: { len: 90 } + }, { + id: 2, + required: 1, + img: { type: 3, wmin: 300, hmin: 250 } + }], + eventtrackers: [{ event: 1, methods: [1, 2] }] + } + } + }, + ortb2Imp: { + native: { + request: JSON.stringify({ + ver: '1.2', + assets: [{ id: 1, required: 1, title: { len: 90 } }] + }) + }, + ext: { + data: { + pos: 'native-feed' + } + } + }, + ...overrides + }); +} + +function getMultiformatBidRequest(overrides = {}) { + return getBidRequest({ + sizes: [[300, 250]], + mediaTypes: { + banner: { + sizes: [[300, 250]] + }, + video: { + context: 'instream', + playerSize: [[640, 360]], + mimes: ['video/mp4'], + protocols: [2, 3], + } + }, + ...overrides + }); +} + +function getOutstreamBidRequest(overrides = {}) { + return getVideoBidRequest({ + mediaTypes: { + video: { + context: 'outstream', + playerSize: [[640, 360]], + plcmt: 3 + } + }, + ortb2Imp: { + video: { + plcmt: 3, + w: 640, + h: 360 + } + }, + ...overrides + }); +} + +function getOutstreamVastResponse(overrides = {}) { + return { + requestId: BID_ID, + cpm: 3.21, + currency: 'USD', + creativeId: 'creative-video', + mediaType: 'video', + vastUrl: 'https://vastproxy.ezoic.net/vastadapter/signed-video-token', + ...overrides + }; +} + +function getBidderRequest(overrides = {}) { + return { + auctionId: 'auction-1', + bidderRequestId: 'bidder-request-1', + timeout: 750, + refererInfo: { + page: 'https://example.com/article' + }, + gdprConsent: { + gdprApplies: false + }, + uspConsent: '1---', + gppConsent: { + gppString: 'GPP_STRING', + applicableSections: [7] + }, + ortb2: { + site: { + domain: 'example.com', + page: 'https://example.com/article' + } + }, + ...overrides + }; +} + +describe('Ezoic adapter', function () { + afterEach(function () { + // The adapter caches a generated pageview id/epoch on window for the + // life of the page; reset it between tests so each test starts fresh. + delete window.__ezoicPrebidAdapter; + sinon.restore(); + resetWinDimensions(); + }); + + it('declares Ezoic Inc GVL vendor id for Prebid TCF enforcement', function () { + expect(spec.gvlid).to.equal(347); + }); + + it('declares banner, video, and native support', function () { + expect(spec.supportedMediaTypes).to.include('banner'); + expect(spec.supportedMediaTypes).to.include('video'); + expect(spec.supportedMediaTypes).to.include('native'); + }); + + describe('isBidRequestValid', function () { + it('returns true for a bid request with no params', function () { + expect(spec.isBidRequestValid(getBidRequest({ params: {} }))).to.equal(true); + }); + + it('returns true for a banner request with params', function () { + expect(spec.isBidRequestValid(getBidRequest())).to.equal(true); + }); + + it('returns true regardless of bid shape', function () { + expect(spec.isBidRequestValid({})).to.equal(true); + }); + }); + + describe('buildRequests', function () { + it('posts Prebid and ORTB metadata to the fixed adapter endpoint', function () { + // Karma runs specs inside an iframe, so `window.top` (what + // getWinDimensions actually reads via canAccessWindowTop) is the outer + // browser window, not the local `window` binding. + sinon.stub(window.top, 'innerWidth').value(1280); + resetWinDimensions(); + const bidderRequest = getBidderRequest({ + ortb2: { + site: { + domain: 'example.com', + page: 'https://example.com/article', + cat: ['IAB13'] + }, + device: { + geo: { + country: 'CA' + } + }, + user: { + ext: { + eids: [{ source: 'pubcid.org', uids: [{ id: 'pubcid-1', atype: 1 }] }] + } + } + } + }); + const request = spec.buildRequests([getBidRequest({ + params: { + placementId: 'placement-123', + unknownParam: 'should-not-be-forwarded', + adPositionId: 1100 + } + })], bidderRequest); + + expect(request.method).to.equal('POST'); + expect(request.url).to.equal(ENDPOINT); + expect(request.options.contentType).to.equal('application/json'); + expect(request.options.withCredentials).to.equal(true); + + const payload = JSON.parse(request.data); + expect(payload.auctionId).to.equal('auction-1'); + expect(payload.timeout).to.equal(750); + expect(payload.page.url).to.equal('https://example.com/article'); + expect(payload.ortb2.site.cat).to.deep.equal(['IAB13']); + expect(payload.ortb2.device.geo.country).to.equal('CA'); + expect(payload.ortb2.user.ext.eids[0].source).to.equal('pubcid.org'); + expect(payload.gdprConsent).to.deep.equal(bidderRequest.gdprConsent); + expect(payload.uspConsent).to.equal(bidderRequest.uspConsent); + expect(payload.gppConsent).to.deep.equal(bidderRequest.gppConsent); + expect(payload.ezoic.formFactorId).to.equal(1); + expect(payload.ezoic.country).to.equal('CA'); + expect(payload).to.not.have.property('buyeruids'); + expect(payload.imps).to.have.lengthOf(1); + expect(payload.imps[0].requestId).to.equal(BID_ID); + expect(payload.imps[0].adUnitCode).to.equal(AD_UNIT_CODE); + expect(payload.imps[0].sizes[0]).to.deep.equal([300, 250]); + expect(payload.imps[0].params).to.deep.equal({ + placementId: 'placement-123' + }); + }); + + it('generates and reuses adapter pageview metadata across auctions', function () { + sinon.stub(Date, 'now').returns(1714752000000); + + const firstRequest = spec.buildRequests([getBidRequest()], getBidderRequest()); + const secondRequest = spec.buildRequests([getBidRequest({ bidId: 'ezoic-bid-2' })], getBidderRequest()); + const firstPayload = JSON.parse(firstRequest.data); + const secondPayload = JSON.parse(secondRequest.data); + + expect(firstPayload.ezoic.pageviewId).to.be.a('string').and.not.equal(''); + expect(firstPayload.ezoic.pageviewId).to.match(UUID_V4_REGEX); + expect(firstPayload.ezoic.pageviewId).to.equal(secondPayload.ezoic.pageviewId); + expect(firstPayload.ezoic.pageviewIdSource).to.equal('adapter_generated'); + expect(firstPayload.ezoic.pageviewEpoch).to.equal(1714752000); + expect(secondPayload.ezoic.pageviewEpoch).to.equal(1714752000); + }); + + it('prefers core pageViewId and refreshes epoch when it changes', function () { + sinon.stub(Date, 'now').returns(1714752000000); + + const firstRequest = spec.buildRequests([getBidRequest()], getBidderRequest({ + pageViewId: 'core-pageview-1' + })); + const secondRequest = spec.buildRequests([getBidRequest({ bidId: 'ezoic-bid-2' })], getBidderRequest({ + pageViewId: 'core-pageview-1' + })); + const firstPayload = JSON.parse(firstRequest.data); + const secondPayload = JSON.parse(secondRequest.data); + + expect(firstPayload.ezoic.pageviewId).to.equal('core-pageview-1'); + expect(firstPayload.ezoic.pageviewIdSource).to.equal('prebid_core'); + expect(firstPayload.ezoic.pageviewEpoch).to.equal(1714752000); + expect(secondPayload.ezoic.pageviewId).to.equal('core-pageview-1'); + expect(secondPayload.ezoic.pageviewEpoch).to.equal(1714752000); + + sinon.restore(); + sinon.stub(Date, 'now').returns(1714752600000); + + const thirdRequest = spec.buildRequests([getBidRequest({ bidId: 'ezoic-bid-3' })], getBidderRequest({ + pageViewId: 'core-pageview-2' + })); + const thirdPayload = JSON.parse(thirdRequest.data); + + expect(thirdPayload.ezoic.pageviewId).to.equal('core-pageview-2'); + expect(thirdPayload.ezoic.pageviewIdSource).to.equal('prebid_core'); + expect(thirdPayload.ezoic.pageviewEpoch).to.equal(1714752600); + }); + + it('falls back to adapter-generated pageview metadata when core omits pageViewId', function () { + sinon.stub(Date, 'now').returns(1714752000000); + + const request = spec.buildRequests([getBidRequest()], getBidderRequest()); + const payload = JSON.parse(request.data); + + expect(payload.ezoic.pageviewId).to.match(UUID_V4_REGEX); + expect(payload.ezoic.pageviewIdSource).to.equal('adapter_generated'); + expect(payload.ezoic.pageviewEpoch).to.equal(1714752000); + }); + + it('passes a single banner size to getFloor', function () { + const getFloor = sinon.stub().returns({ currency: 'USD', floor: 0.75 }); + const request = spec.buildRequests([getBidRequest({ getFloor })], getBidderRequest()); + const payload = JSON.parse(request.data); + + expect(getFloor.calledOnce).to.equal(true); + expect(getFloor.firstCall.args[0]).to.deep.equal({ + currency: 'USD', + mediaType: 'banner', + size: [300, 250] + }); + expect(payload.imps[0].floor).to.equal(0.75); + }); + + it('posts video media type details and asks Prebid floors for video size', function () { + const getFloor = sinon.stub().returns({ currency: 'USD', floor: 1.25 }); + const bid = getVideoBidRequest({ getFloor }); + + const request = spec.buildRequests([bid], getBidderRequest()); + const payload = JSON.parse(request.data); + + expect(getFloor.calledOnce).to.equal(true); + expect(getFloor.firstCall.args[0]).to.deep.equal({ + currency: 'USD', + mediaType: 'video', + size: [640, 360] + }); + expect(payload.imps[0].sizes).to.deep.equal([]); + expect(payload.imps[0].mediaTypes).to.deep.equal({ + video: bid.mediaTypes.video + }); + expect(payload.imps[0].floor).to.equal(1.25); + expect(payload.imps[0].ortb2Imp.video.plcmt).to.equal(1); + }); + + it('posts native media type details and asks Prebid floors for native', function () { + const getFloor = sinon.stub().returns({ currency: 'USD', floor: 0.95 }); + const bid = getNativeBidRequest({ getFloor }); + + const request = spec.buildRequests([bid], getBidderRequest()); + const payload = JSON.parse(request.data); + + expect(getFloor.calledOnce).to.equal(true); + expect(getFloor.firstCall.args[0]).to.deep.equal({ + currency: 'USD', + mediaType: 'native', + size: '*' + }); + expect(payload.imps[0].sizes).to.deep.equal([]); + expect(payload.imps[0].mediaTypes).to.deep.equal({ + native: bid.mediaTypes.native + }); + expect(payload.imps[0].floor).to.equal(0.95); + expect(payload.imps[0].ortb2Imp.native.request).to.be.a('string'); + }); + + it('sources the floor from the Prebid floors module and drops legacy floor params', function () { + const getFloor = sinon.stub().returns({ currency: 'USD', floor: 5.15 }); + const request = spec.buildRequests([getBidRequest({ + getFloor, + params: { + placementId: 'placement-123', + floor: 0.4, + bidfloor: 0.4, + bidfloorcur: 'USD' + } + })], getBidderRequest()); + const payload = JSON.parse(request.data); + + expect(payload.imps[0].params).to.deep.equal({ placementId: 'placement-123' }); + expect(payload.imps[0].floor).to.equal(5.15); + expect(getFloor.calledOnce).to.equal(true); + }); + + it('returns undefined when there are no valid bid requests', function () { + expect(spec.buildRequests([], getBidderRequest())).to.equal(undefined); + }); + + it('attaches the validBidRequests as bidderRequest.bids on the built request', function () { + const bid = getBidRequest(); + const request = spec.buildRequests([bid], getBidderRequest()); + + expect(request.bidderRequest.bids).to.deep.equal([bid]); + }); + }); + + describe('interpretResponse', function () { + it('returns no bids for an explicit no-bid response', function () { + const result = spec.interpretResponse({ body: { nobid: true } }, { + bidderRequest: { + bids: [getBidRequest()] + } + }); + + expect(result).to.deep.equal([]); + }); + + it('returns no bids when the response body is empty', function () { + const result = spec.interpretResponse({}, { + bidderRequest: { + bids: [getBidRequest()] + } + }); + + expect(result).to.deep.equal([]); + }); + + it('normalizes a bid into a Prebid banner bid response', function () { + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: BID_ID, + cpm: 1.23, + currency: 'USD', + width: 300, + height: 250, + creativeId: 'creative-1', + ad: '
ad
', + dealId: 'deal-1', + meta: { + advertiserDomains: ['advertiser.example'] + }, + ttl: 120, + netRevenue: true, + nurl: 'https://g.ezoic.net/win', + }] + } + }, { + bidderRequest: { + bids: [getBidRequest()] + } + }); + + expect(result).to.have.lengthOf(1); + expect(result[0]).to.include({ + requestId: BID_ID, + cpm: 1.23, + currency: 'USD', + width: 300, + height: 250, + creativeId: 'creative-1', + ad: '
ad
', + dealId: 'deal-1', + ttl: 120, + netRevenue: true, + mediaType: 'banner', + }); + expect(result[0].nurl).to.be.undefined; + expect(result[0].meta.advertiserDomains).to.deep.equal(['advertiser.example']); + }); + + it('defaults meta.advertiserDomains to an empty array when the server omits meta', function () { + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: BID_ID, + cpm: 1.23, + currency: 'USD', + width: 300, + height: 250, + creativeId: 'creative-1', + ad: '
ad
', + }] + } + }, { + bidderRequest: { + bids: [getBidRequest()] + } + }); + + expect(result).to.have.lengthOf(1); + expect(result[0].meta).to.deep.equal({ advertiserDomains: [] }); + }); + + it('defaults meta.advertiserDomains to an empty array when the server sends meta without it', function () { + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: BID_ID, + cpm: 1.23, + currency: 'USD', + width: 300, + height: 250, + creativeId: 'creative-1', + ad: '
ad
', + meta: { + mediaType: 'banner' + }, + }] + } + }, { + bidderRequest: { + bids: [getBidRequest()] + } + }); + + expect(result).to.have.lengthOf(1); + expect(result[0].meta).to.deep.equal({ mediaType: 'banner', advertiserDomains: [] }); + }); + + it('normalizes explicit video VAST responses into Prebid video bids', function () { + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: BID_ID, + cpm: 3.21, + currency: 'USD', + width: 640, + height: 360, + creativeId: 'creative-video', + mediaType: 'video', + vastUrl: 'https://vastproxy.ezoic.net/vastadapter/signed-video-token', + ttl: 120, + netRevenue: true, + }] + } + }, { + bidderRequest: { + bids: [getVideoBidRequest()] + } + }); + + expect(result).to.have.lengthOf(1); + expect(result[0]).to.include({ + requestId: BID_ID, + cpm: 3.21, + currency: 'USD', + width: 640, + height: 360, + creativeId: 'creative-video', + mediaType: 'video', + vastUrl: 'https://vastproxy.ezoic.net/vastadapter/signed-video-token', + ttl: 120, + netRevenue: true, + }); + expect(result[0].ad).to.equal(undefined); + }); + + it('drops explicit video responses for banner-only requests', function () { + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: BID_ID, + cpm: 3.21, + currency: 'USD', + width: 640, + height: 360, + creativeId: 'creative-video', + mediaType: 'video', + vastUrl: 'https://vastproxy.ezoic.net/vastadapter/signed-video-token', + ad: '
video fallback markup
' + }] + } + }, { + bidderRequest: { + bids: [getBidRequest()] + } + }); + + expect(result).to.deep.equal([]); + }); + + it('normalizes outstream VAST URL responses (setup validation is left to core)', function () { + // No renderer here on purpose: core's checkVideoBidSetup owns + // outstream setup validation (renderer vs useCacheKey/cache config), + // so the adapter returns the bid regardless. + const result = spec.interpretResponse({ + body: { + bids: [getOutstreamVastResponse({ + width: 640, + height: 360, + })] + } + }, { + bidderRequest: { + bids: [getOutstreamBidRequest()] + } + }); + + expect(result).to.have.lengthOf(1); + expect(result[0]).to.include({ + requestId: BID_ID, + cpm: 3.21, + currency: 'USD', + width: 640, + height: 360, + creativeId: 'creative-video', + mediaType: 'video', + vastUrl: 'https://vastproxy.ezoic.net/vastadapter/signed-video-token' + }); + expect(result[0].ad).to.equal(undefined); + }); + + it('drops bids with non-numeric or negative cpm values', function () { + const request = { + bidderRequest: { + bids: [getBidRequest()] + } + }; + const baseBid = { + requestId: BID_ID, + currency: 'USD', + width: 300, + height: 250, + creativeId: 'creative-1', + ad: '
ad
', + }; + + expect(spec.interpretResponse({ + body: { bids: [{ ...baseBid, cpm: 'not-a-number' }] } + }, request)).to.deep.equal([]); + + expect(spec.interpretResponse({ + body: { bids: [{ ...baseBid, cpm: -0.01 }] } + }, request)).to.deep.equal([]); + + expect(spec.interpretResponse({ + body: { bids: [{ ...baseBid, cpm: 0 }] } + }, request)).to.have.lengthOf(1); + }); + + it('uses video playerSize for multiformat video bids missing width and height', function () { + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: BID_ID, + cpm: 2.5, + currency: 'USD', + creativeId: 'creative-video', + mediaType: 'video', + vastUrl: 'https://vastproxy.ezoic.net/vastadapter/signed-video-token' + }] + } + }, { + bidderRequest: { + bids: [getMultiformatBidRequest()] + } + }); + + expect(result).to.have.lengthOf(1); + expect(result[0].width).to.equal(640); + expect(result[0].height).to.equal(360); + }); + + it('uses banner size for multiformat banner bids missing width and height', function () { + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: BID_ID, + cpm: 1.5, + currency: 'USD', + creativeId: 'creative-banner', + ad: '
ad
' + }] + } + }, { + bidderRequest: { + bids: [getMultiformatBidRequest()] + } + }); + + expect(result).to.have.lengthOf(1); + expect(result[0].width).to.equal(300); + expect(result[0].height).to.equal(250); + expect(result[0].mediaType).to.equal('banner'); + }); + + it('normalizes native ORTB responses into Prebid native bids', function () { + const nativeResponse = { + ortb: { + link: { + url: 'https://advertiser.example/landing', + clicktrackers: ['https://tracker.example/click'] + }, + assets: [{ + id: 1, + title: { text: 'Native title' } + }, { + id: 2, + img: { url: 'https://cdn.example/image.jpg', w: 300, h: 250 } + }], + eventtrackers: [{ event: 1, method: 1, url: 'https://tracker.example/imp' }] + } + }; + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: BID_ID, + cpm: 2.34, + currency: 'USD', + creativeId: 'creative-native', + mediaType: 'native', + native: nativeResponse, + ttl: 120, + netRevenue: true, + }] + } + }, { + bidderRequest: { + bids: [getNativeBidRequest()] + } + }); + + expect(result).to.have.lengthOf(1); + expect(result[0]).to.include({ + requestId: BID_ID, + cpm: 2.34, + currency: 'USD', + creativeId: 'creative-native', + mediaType: 'native', + ttl: 120, + netRevenue: true, + }); + expect(result[0].native).to.deep.equal(nativeResponse); + expect(result[0].ad).to.equal(undefined); + expect(result[0].width).to.equal(undefined); + expect(result[0].height).to.equal(undefined); + }); + + it('drops native responses for banner-only requests', function () { + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: BID_ID, + cpm: 2.34, + currency: 'USD', + creativeId: 'creative-native', + mediaType: 'native', + native: { + ortb: { + link: { url: 'https://advertiser.example/landing' }, + assets: [{ id: 1, title: { text: 'Native title' } }] + } + } + }] + } + }, { + bidderRequest: { + bids: [getBidRequest()] + } + }); + + expect(result).to.deep.equal([]); + }); + + it('drops bids that cannot be matched to an original request', function () { + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: 'unknown', + cpm: 1.23, + currency: 'USD', + width: 300, + height: 250, + creativeId: 'creative-1', + ad: '
ad
' + }] + } + }, { + bidderRequest: { + bids: [getBidRequest()] + } + }); + + expect(result).to.deep.equal([]); + }); + + it('drops bids missing required fields (cpm, creativeId)', function () { + const result = spec.interpretResponse({ + body: { + bids: [{ + requestId: BID_ID, + width: 300, + height: 250, + ad: '
ad
' + }] + } + }, { + bidderRequest: { + bids: [getBidRequest()] + } + }); + + expect(result).to.deep.equal([]); + }); + }); + + describe('getUserSyncs', function () { + it('returns no syncs when iframe syncing is disabled', function () { + expect(spec.getUserSyncs({ iframeEnabled: false }, [])).to.deep.equal([]); + }); + + it('returns no syncs when syncOptions is missing', function () { + expect(spec.getUserSyncs(undefined, [])).to.deep.equal([]); + }); + + it('returns a single iframe sync at the fixed usersync-frame URL when iframe syncing is enabled', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true }, []); + + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].type).to.equal('iframe'); + expect(syncs[0].url.indexOf(SYNC_URL)).to.equal(0); + }); + + it('propagates GDPR consent onto the sync URL', function () { + const syncs = spec.getUserSyncs( + { iframeEnabled: true }, + [], + { gdprApplies: true, consentString: 'CONSENT_STRING' } + ); + + const url = new URL(syncs[0].url); + expect(url.searchParams.get('gdpr')).to.equal('1'); + expect(url.searchParams.get('gdpr_consent')).to.equal('CONSENT_STRING'); + }); + + it('sends gdpr=0 and an empty gdpr_consent when GDPR does not apply or consent is missing', function () { + const syncs = spec.getUserSyncs( + { iframeEnabled: true }, + [], + { gdprApplies: false } + ); + + const url = new URL(syncs[0].url); + expect(url.searchParams.get('gdpr')).to.equal('0'); + expect(url.searchParams.get('gdpr_consent')).to.equal(''); + }); + + it('sends gdpr=0 and empty consent when gdprConsent is not provided at all', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true }, []); + + const url = new URL(syncs[0].url); + expect(url.searchParams.get('gdpr')).to.equal('0'); + expect(url.searchParams.get('gdpr_consent')).to.equal(''); + }); + + it('propagates GPP consent onto the sync URL', function () { + const syncs = spec.getUserSyncs( + { iframeEnabled: true }, + [], + undefined, + undefined, + { gppString: 'GPP_STRING', applicableSections: [7, 8] } + ); + + const url = new URL(syncs[0].url); + expect(url.searchParams.get('gpp')).to.equal('GPP_STRING'); + expect(url.searchParams.get('gpp_sid')).to.equal('7,8'); + }); + + it('sends empty gpp/gpp_sid when gppConsent is not provided', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true }, []); + + const url = new URL(syncs[0].url); + expect(url.searchParams.get('gpp')).to.equal(''); + expect(url.searchParams.get('gpp_sid')).to.equal(''); + }); + + it('propagates USP (CCPA) consent onto the sync URL', function () { + const syncs = spec.getUserSyncs( + { iframeEnabled: true }, + [], + undefined, + '1YNN' + ); + + const url = new URL(syncs[0].url); + expect(url.searchParams.get('us_privacy')).to.equal('1YNN'); + }); + + it('sends an empty us_privacy when uspConsent is not provided', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true }, []); + + const url = new URL(syncs[0].url); + expect(url.searchParams.get('us_privacy')).to.equal(''); + }); + + it('does not include a redirect ("r") param', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true }, []); + + const url = new URL(syncs[0].url); + expect(url.searchParams.has('r')).to.equal(false); + }); + }); +}); From cac1c0df5d974d3718308f1626a0e52201a92838 Mon Sep 17 00:00:00 2001 From: austinbyron <59710247+austinbyron@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:45:29 -0700 Subject: [PATCH 2/3] Ezoic: text/plain POST (no CORS preflight), wildcard floor for multiformat --- modules/ezoicBidAdapter.js | 29 +++++++++++++---------- test/spec/modules/ezoicBidAdapter_spec.js | 24 ++++++++++++++++++- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/modules/ezoicBidAdapter.js b/modules/ezoicBidAdapter.js index 2221165568..d75eb55f37 100644 --- a/modules/ezoicBidAdapter.js +++ b/modules/ezoicBidAdapter.js @@ -194,18 +194,10 @@ function getPrimaryVideoSize(bid) { return '*'; } -function isVideoOnlyBid(bid) { - return !!bid.mediaTypes?.video && !bid.mediaTypes?.banner; -} - function isVideoBidRequest(bid) { return !!bid?.mediaTypes?.video; } -function isNativeOnlyBid(bid) { - return !!bid?.mediaTypes?.native && !bid.mediaTypes.banner && !bid.mediaTypes.video; -} - function isNativeBidRequest(bid) { return !!bid?.mediaTypes?.native; } @@ -215,13 +207,21 @@ function getBidFloor(bid) { return undefined; } - const videoOnly = isVideoOnlyBid(bid); - const nativeOnly = isNativeOnlyBid(bid); + // Multiformat impressions carry a single floor, so query the wildcard rule + // rather than letting one format's floor suppress the others' demand. + const formats = [BANNER, VIDEO, NATIVE].filter((mediaType) => bid?.mediaTypes?.[mediaType]); + const single = formats.length === 1 ? formats[0] : undefined; + let size = '*'; + if (single === BANNER) { + size = getPrimaryBannerSize(bid); + } else if (single === VIDEO) { + size = getPrimaryVideoSize(bid); + } try { const floor = bid.getFloor({ currency: DEFAULT_CURRENCY, - mediaType: nativeOnly ? NATIVE : (videoOnly ? VIDEO : BANNER), - size: nativeOnly ? '*' : (videoOnly ? getPrimaryVideoSize(bid) : getPrimaryBannerSize(bid)), + mediaType: single || '*', + size, }); return floor?.floor; } catch (e) { @@ -391,7 +391,10 @@ export const spec = { bids: validBidRequests, }, options: { - contentType: 'application/json', + // text/plain keeps the JSON POST a CORS "simple request": no OPTIONS + // preflight burning bidder-timeout budget. The endpoint parses the + // body as JSON regardless of Content-Type. + contentType: 'text/plain', withCredentials: true, }, }; diff --git a/test/spec/modules/ezoicBidAdapter_spec.js b/test/spec/modules/ezoicBidAdapter_spec.js index 9c263e45fd..f715fae9d4 100644 --- a/test/spec/modules/ezoicBidAdapter_spec.js +++ b/test/spec/modules/ezoicBidAdapter_spec.js @@ -248,7 +248,7 @@ describe('Ezoic adapter', function () { expect(request.method).to.equal('POST'); expect(request.url).to.equal(ENDPOINT); - expect(request.options.contentType).to.equal('application/json'); + expect(request.options.contentType).to.equal('text/plain'); expect(request.options.withCredentials).to.equal(true); const payload = JSON.parse(request.data); @@ -345,6 +345,28 @@ describe('Ezoic adapter', function () { expect(payload.imps[0].floor).to.equal(0.75); }); + it('queries the wildcard floor for multiformat ad units', function () { + const getFloor = sinon.stub().returns({ currency: 'USD', floor: 0.6 }); + const bid = getBidRequest({ + getFloor, + mediaTypes: { + banner: { sizes: [[300, 250]] }, + video: { context: 'outstream', playerSize: [[640, 360]] } + } + }); + + const request = spec.buildRequests([bid], getBidderRequest()); + const payload = JSON.parse(request.data); + + expect(getFloor.calledOnce).to.equal(true); + expect(getFloor.firstCall.args[0]).to.deep.equal({ + currency: 'USD', + mediaType: '*', + size: '*' + }); + expect(payload.imps[0].floor).to.equal(0.6); + }); + it('posts video media type details and asks Prebid floors for video size', function () { const getFloor = sinon.stub().returns({ currency: 'USD', floor: 1.25 }); const bid = getVideoBidRequest({ getFloor }); From 2d369a21a2c735a9947c5dd299a06dbb485d6007 Mon Sep 17 00:00:00 2001 From: austinbyron <59710247+austinbyron@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:26:54 -0700 Subject: [PATCH 3/3] Ezoic: use scoped sinon sandbox in spec (fix cross-spec stub wipe) Bare sinon.restore() restores the global default sandbox, which wipes justIdSystem_spec's file-load-time getAtm stub when the two specs share a Karma chunk (ezoic* runs before justId* alphabetically), failing its "getId basic / all ok" test. Switch to a per-test sandbox and replace the mid-test global restore with a retargeted stub. --- test/spec/modules/ezoicBidAdapter_spec.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/test/spec/modules/ezoicBidAdapter_spec.js b/test/spec/modules/ezoicBidAdapter_spec.js index f715fae9d4..f062dfc9df 100644 --- a/test/spec/modules/ezoicBidAdapter_spec.js +++ b/test/spec/modules/ezoicBidAdapter_spec.js @@ -180,11 +180,17 @@ function getBidderRequest(overrides = {}) { } describe('Ezoic adapter', function () { + let sandbox; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); + afterEach(function () { // The adapter caches a generated pageview id/epoch on window for the // life of the page; reset it between tests so each test starts fresh. delete window.__ezoicPrebidAdapter; - sinon.restore(); + sandbox.restore(); resetWinDimensions(); }); @@ -217,7 +223,7 @@ describe('Ezoic adapter', function () { // Karma runs specs inside an iframe, so `window.top` (what // getWinDimensions actually reads via canAccessWindowTop) is the outer // browser window, not the local `window` binding. - sinon.stub(window.top, 'innerWidth').value(1280); + sandbox.stub(window.top, 'innerWidth').value(1280); resetWinDimensions(); const bidderRequest = getBidderRequest({ ortb2: { @@ -274,7 +280,7 @@ describe('Ezoic adapter', function () { }); it('generates and reuses adapter pageview metadata across auctions', function () { - sinon.stub(Date, 'now').returns(1714752000000); + sandbox.stub(Date, 'now').returns(1714752000000); const firstRequest = spec.buildRequests([getBidRequest()], getBidderRequest()); const secondRequest = spec.buildRequests([getBidRequest({ bidId: 'ezoic-bid-2' })], getBidderRequest()); @@ -290,7 +296,7 @@ describe('Ezoic adapter', function () { }); it('prefers core pageViewId and refreshes epoch when it changes', function () { - sinon.stub(Date, 'now').returns(1714752000000); + const nowStub = sandbox.stub(Date, 'now').returns(1714752000000); const firstRequest = spec.buildRequests([getBidRequest()], getBidderRequest({ pageViewId: 'core-pageview-1' @@ -307,8 +313,7 @@ describe('Ezoic adapter', function () { expect(secondPayload.ezoic.pageviewId).to.equal('core-pageview-1'); expect(secondPayload.ezoic.pageviewEpoch).to.equal(1714752000); - sinon.restore(); - sinon.stub(Date, 'now').returns(1714752600000); + nowStub.returns(1714752600000); const thirdRequest = spec.buildRequests([getBidRequest({ bidId: 'ezoic-bid-3' })], getBidderRequest({ pageViewId: 'core-pageview-2' @@ -321,7 +326,7 @@ describe('Ezoic adapter', function () { }); it('falls back to adapter-generated pageview metadata when core omits pageViewId', function () { - sinon.stub(Date, 'now').returns(1714752000000); + sandbox.stub(Date, 'now').returns(1714752000000); const request = spec.buildRequests([getBidRequest()], getBidderRequest()); const payload = JSON.parse(request.data);