diff --git a/modules/hyperbrainzBidAdapter.d.ts b/modules/hyperbrainzBidAdapter.d.ts new file mode 100644 index 0000000000..2936b3c9df --- /dev/null +++ b/modules/hyperbrainzBidAdapter.d.ts @@ -0,0 +1,51 @@ +import type { Ext } from "../src/types/ortb/common"; + +export interface HyperbrainzBidderParams { + /** + * Unique placement identifier, sent as `imp.tagid`. Required. + */ + placementId: string; + /** + * Publisher identifier, sent as `site.publisher.id`. Taken from the first bid of the request. + */ + publisherId?: string; + /** + * Hard floor in USD. Takes precedence over the priceFloors module. + */ + bidFloor?: number; + /** + * User ID to send as `user.id`. Used as a fallback when no ID is found + * in local storage. + */ + userId?: string; + /** + * Custom bidder extension fields, passed through as `imp.ext`. + */ + ext?: Ext; + /** + * Per-placement bid endpoint override. Bids with different endpoint + * values are sent as separate requests. Defaults to + * `config.hyperbrainz.endpoint`, then the production endpoint. + */ + endpoint?: string; +} + +export interface HyperbrainzConfig { + /** + * Overrides the bid endpoint URL for all hyperbrainz bids, e.g. to target a QA or + * staging environment. Individual bids can override this with `params.endpoint`. + */ + endpoint?: string; +} + +declare module "../src/adUnits" { + interface BidderParams { + hyperbrainz: HyperbrainzBidderParams; + } +} + +declare module "../src/config" { + interface Config { + hyperbrainz?: HyperbrainzConfig; + } +} diff --git a/modules/hyperbrainzBidAdapter.js b/modules/hyperbrainzBidAdapter.js new file mode 100644 index 0000000000..97a75a878c --- /dev/null +++ b/modules/hyperbrainzBidAdapter.js @@ -0,0 +1,667 @@ +/** + * HyperBrainz Bidder Adapter + * + * + * Supports: Display, Video, Native + * + * @version 1.0.0 + */ +import { + logInfo, + logWarn, + logError, + deepAccess, + deepSetValue, + generateUUID, + triggerPixel, +} from "../src/utils.js"; +import { registerBidder } from "../src/adapters/bidderFactory.js"; +import { BANNER, VIDEO, NATIVE } from "../src/mediaTypes.js"; +import { config } from "../src/config.js"; +import { getStorageManager } from "../src/storageManager.js"; + +// Bidder constants +const BIDDER_CODE = "hyperbrainz"; +const ADAPTER_VERSION = "1.0.0"; +const ENDPOINT = "https://hb.hyperbrainz.com/bid"; +const SYNC_URL = "https://hb.hyperbrainz.com/sync"; +const DEFAULT_CURRENCY = "USD"; +const TTL = 300; // 5 minutes +const DEFAULT_TIMEOUT = 1200; // milliseconds + +// Storage manager for user IDs +const storage = getStorageManager({ bidderCode: BIDDER_CODE }); +function isBidRequestValid(bidRequest) { + if (!bidRequest || !bidRequest.params) { + logError("HyperBrainz: Bid request is not valid"); + return false; + } + + if ( + !bidRequest.params.placementId || + typeof bidRequest.params.placementId !== "string" + ) { + logWarn("HyperBrainz: placementId must be a non-empty string"); + return false; + } + + const hasValidMediaType = + bidRequest.mediaTypes && + (bidRequest.mediaTypes[BANNER] || + bidRequest.mediaTypes[VIDEO] || + bidRequest.mediaTypes[NATIVE]); + + if (!hasValidMediaType) { + logWarn( + "HyperBrainz: bid must have at least one valid mediaType (banner, video, or native)" + ); + return false; + } + + if (bidRequest.mediaTypes[VIDEO]) { + const video = bidRequest.mediaTypes[VIDEO]; + if (!video.context) { + logWarn("HyperBrainz: video context is required (instream/outstream)"); + return false; + } + if (!video.playerSize && !video.w && !video.h) { + logWarn("HyperBrainz: video playerSize or w/h is required"); + return false; + } + } + + return true; +} + +function buildRequests(validBidRequests, bidderRequest) { + if (!validBidRequests || validBidRequests.length === 0) { + return []; + } + + const endpoint = config.getConfig("hyperbrainz.endpoint") || ENDPOINT; + + // Group bids by endpoint (allows custom endpoints per placement) + const grouped = groupBidsByEndpoint(validBidRequests, endpoint); + + const requests = []; + + Object.keys(grouped).forEach((endpointUrl) => { + const bids = grouped[endpointUrl]; + + const ortbRequest = buildOpenRtbRequest(bids, bidderRequest, endpointUrl); + + requests.push({ + method: "POST", + url: endpointUrl, + data: JSON.stringify(ortbRequest), + options: { + contentType: "text/plain", + withCredentials: true, + }, + bidderRequest, + bids, + }); + }); + + return requests; +} + +function groupBidsByEndpoint(validBids, defaultEndpoint) { + const map = {}; + + validBids.forEach((bid) => { + const ep = bid.params.endpoint || defaultEndpoint; + if (!map[ep]) map[ep] = []; + map[ep].push(bid); + }); + + return map; +} + +function buildOpenRtbRequest(bids, bidderRequest, endpoint) { + const timeout = + bidderRequest?.timeout || + config.getConfig("bidderTimeout") || + DEFAULT_TIMEOUT; + + const ortb = { + id: + bidderRequest.auctionId || + bidderRequest.bidderRequestId || + generateUUID(), + imp: bids.map((bid) => buildImp(bid)), + site: buildSite(bidderRequest), + device: buildDevice(bidderRequest), + user: buildUser(bidderRequest), + regs: buildRegs(bidderRequest), + at: 1, + tmax: timeout, + cur: [DEFAULT_CURRENCY], + source: buildSource(bidderRequest), + ext: { + prebid: { + version: "$prebid.version$", + adapterVersion: ADAPTER_VERSION, + auctionId: bidderRequest.auctionId, + }, + }, + }; + + // Supply chain (schain). Read from ORTB2 (the current Prebid location); + // fall back to the legacy bidderRequest.schain for older Prebid versions. + const schain = + deepAccess(bidderRequest, "ortb2.source.ext.schain") || + bidderRequest?.schain; + if (schain) { + ortb.source.ext = ortb.source.ext || {}; + ortb.source.ext.schain = schain; + } + + // First-party data (ORTB2) + const ortb2 = bidderRequest?.ortb2 || {}; + if (ortb2.site?.ext?.data) { + deepSetValue(ortb, "site.ext.data", ortb2.site.ext.data); + } + if (ortb2.user?.ext?.data) { + deepSetValue(ortb, "user.ext.data", ortb2.user.ext.data); + } + + return ortb; +} + +function interpretResponse(serverResponse, request) { + try { + const response = serverResponse?.body; + const bidderRequest = request?.bidderRequest || {}; + const bids = []; + + if (!response || !Array.isArray(response.seatbid)) { + logInfo("HyperBrainz: No valid seatbid in response"); + return bids; + } + + response.seatbid.forEach((seat) => { + if (!Array.isArray(seat.bid)) return; + seat.bid.forEach((bid) => { + const builtBid = buildBid(bid, response, bidderRequest); + if (builtBid) bids.push(builtBid); + }); + }); + + return bids; + } catch (e) { + logError(`${BIDDER_CODE}: Error parsing bid response`, e); + return []; + } +} + +function buildBid(bid, response, request) { + // Basic validation + if (!bid || !bid.price || bid.price <= 0) { + return null; + } + + // Match bid to original request by impid (which we set to bidId in buildImp) + const originalBid = request.bids?.find((b) => b.bidId === bid.impid); + + if (!originalBid) { + logWarn(`${BIDDER_CODE}: No original bid found for impid ${bid.impid}`); + return null; + } + + const mediaType = getMediaType(bid, originalBid); + + // Base Prebid bid object + const prebidBid = { + requestId: bid.impid, + cpm: bid.price, + currency: response.cur || DEFAULT_CURRENCY, + width: bid.w, + height: bid.h, + ttl: TTL, + netRevenue: true, + creativeId: bid.crid || bid.id || generateUUID(), + dealId: bid.dealid || undefined, + mediaType: mediaType, + meta: { + advertiserDomains: Array.isArray(bid.adomain) ? bid.adomain : [], + mediaType: mediaType, + }, + }; + + // Win/billing notice URLs. Prebid fires nurl when the bid wins. + if (bid.nurl) { + prebidBid.nurl = bid.nurl; + } + if (bid.burl) { + prebidBid.burl = bid.burl; + } + + if (mediaType === VIDEO) { + if (bid.adm) prebidBid.vastXml = bid.adm; + } else if (mediaType === NATIVE) { + const ortbNative = parseNative(bid.adm); + if (!ortbNative) return null; + prebidBid.native = { ortb: ortbNative }; + } else { + if (bid.adm) { + prebidBid.ad = bid.adm; + } else if (bid.nurl) { + prebidBid.adUrl = bid.nurl; + } + } + + if (bid.ext && typeof bid.ext === "object") { + prebidBid.ext = { ...bid.ext }; + } + + return prebidBid; +} + +function getMediaType(bid, originalBid) { + if (bid.ext?.prebid?.type) { + return bid.ext.prebid.type; + } + + if (bid.mtype === 1) return BANNER; + if (bid.mtype === 2) return VIDEO; + if (bid.mtype === 4) return NATIVE; + + const mt = originalBid.mediaTypes || {}; + const formatCount = [mt.banner, mt.video, mt.native].filter(Boolean).length; + if (formatCount === 1) { + if (mt.video) return VIDEO; + if (mt.native) return NATIVE; + if (mt.banner) return BANNER; + } + + return BANNER; +} + +function parseNative(adm) { + if (!adm) return null; + + let nativeAdm = adm; + if (typeof adm === "string") { + try { + nativeAdm = JSON.parse(adm); + } catch (err) { + logWarn(`${BIDDER_CODE}: Failed to parse native ADM`); + return null; + } + } + + // Prebid renders native from the raw OpenRTB native response object, + // so hand it back unmodified once we know it carries assets. + if (!nativeAdm || !Array.isArray(nativeAdm.assets)) { + return null; + } + + return nativeAdm; +} +function getBidFloor(bid) { + // Check params first + if (bid.params.bidFloor) { + return parseFloat(bid.params.bidFloor); + } + + // Check getFloor module + if (typeof bid.getFloor === "function") { + const floorInfo = bid.getFloor({ + currency: DEFAULT_CURRENCY, + mediaType: "*", + size: "*", + }); + if (floorInfo && floorInfo.floor) { + return floorInfo.floor; + } + } + + return 0; +} + +function buildImp(bid) { + const imp = { + id: bid.bidId, + tagid: bid.params.placementId, + secure: window.location.protocol === "https:" ? 1 : 0, + bidfloor: getBidFloor(bid), + bidfloorcur: DEFAULT_CURRENCY, + }; + + // Banner + if (bid.mediaTypes && bid.mediaTypes[BANNER]) { + imp.banner = buildBanner(bid); + } + + // Video + if (bid.mediaTypes && bid.mediaTypes[VIDEO]) { + imp.video = buildVideo(bid); + } + + // Native + if (bid.mediaTypes && bid.mediaTypes[NATIVE]) { + imp.native = buildNative(bid); + } + + // Bidder-specific parameters + if (bid.params.ext) { + imp.ext = bid.params.ext; + } + + return imp; +} + +// Build site object +function buildSite(bidderRequest) { + const firstBid = bidderRequest.bids && bidderRequest.bids[0]; + + return { + page: bidderRequest.refererInfo?.page || window.location.href, + domain: bidderRequest.refererInfo?.domain || window.location.hostname, + ref: bidderRequest.refererInfo?.ref || document.referrer, + publisher: { + id: firstBid?.params?.publisherId || undefined, + }, + }; +} + +// Build device object +function buildDevice(bidderRequest) { + const d = bidderRequest?.ortb2?.device || {}; + return { + ua: d.ua, + language: d.language, + w: d.w, + h: d.h, + dnt: d.dnt, + sua: d.sua, + }; +} + +// Build user object +function buildUser(bidderRequest) { + const user = {}; + + // User ID from storage or params + try { + const storedId = storage.getDataFromLocalStorage("hb_userId"); + const paramId = deepAccess(bidderRequest, "bids.0.params.userId"); + if (storedId || paramId) { + user.id = storedId || paramId; + } + } catch (e) { + logWarn("HyperBrainz: Error accessing storage", e); + } + // EIDs (Unified ID 2.0, ID5, SharedID, NetID, LI, etc.) + const eids = deepAccess(bidderRequest, "bids.0.userIdAsEids"); + if (Array.isArray(eids) && eids.length) { + user.ext = user.ext || {}; + user.ext.eids = eids; + } + + // First-party data (ORTB2 user) + const fpdUser = deepAccess(bidderRequest, "ortb2.user"); + if (fpdUser) { + Object.assign(user, fpdUser); + } + + // GDPR Consent (OpenRTB standard) + const consent = deepAccess(bidderRequest, "gdprConsent.consentString"); + if (consent) { + user.ext = user.ext || {}; + user.ext.consent = consent; + } + + return user; +} + +function buildBanner(bid) { + const banner = {}; + const sizes = bid.mediaTypes[BANNER].sizes || []; + + banner.format = sizes.map((size) => ({ + w: size[0], + h: size[1], + })); + + // Use first size as primary + if (sizes.length > 0) { + banner.w = sizes[0][0]; + banner.h = sizes[0][1]; + } + + return banner; +} + +function buildNative(bid) { + const nativeOrtbRequest = bid.nativeOrtbRequest; + if (!nativeOrtbRequest) { + return undefined; + } + + return { + request: JSON.stringify(nativeOrtbRequest), + ver: nativeOrtbRequest.ver || "1.2", + }; +} +function buildVideo(bid) { + const video = Object.assign({}, bid.mediaTypes[VIDEO] || {}); + const context = bid.mediaTypes?.[VIDEO]?.context; + + // Default mimes and protocols + video.mimes = video.mimes || ["video/mp4", "video/webm"]; + video.protocols = video.protocols || [2, 3, 5, 6]; + + // Handle playerSize + if (video.playerSize) { + if (Array.isArray(video.playerSize[0])) { + video.w = video.playerSize[0][0]; + video.h = video.playerSize[0][1]; + } else { + video.w = video.playerSize[0]; + video.h = video.playerSize[1]; + } + delete video.playerSize; + } + + // ORTB-required fields + video.linearity = video.linearity || 1; + + // OpenRTB 2.5 placement (deprecated but still used by many DSPs) + video.placement = video.placement || (context === "outstream" ? 3 : 1); + + // OpenRTB 2.6 plcmt (modern field — set both for compatibility) + // 1=instream, 2=accompanying, 3=interstitial, 4=standalone + if (!video.plcmt) { + if (context === "instream") { + video.plcmt = 1; + } else if (context === "outstream") { + video.plcmt = 4; + } + } + + // Recommended fields + video.minduration = video.minduration || 1; + video.maxduration = video.maxduration || 60; + video.startdelay = + video.startdelay ?? (context === "instream" ? 0 : undefined); + video.skip = video.skip ?? 0; + + // Remove non-ORTB fields + delete video.context; + + return video; +} + +// Build regs object +function buildRegs(bidderRequest) { + const regs = {}; + + // COPPA + if (config.getConfig("coppa") === true) { + regs.coppa = 1; + } + + // GDPR + if (bidderRequest?.gdprConsent) { + regs.gdpr = bidderRequest.gdprConsent.gdprApplies ? 1 : 0; + } + + // US privacy + regs.ext = regs.ext || {}; + if (bidderRequest?.uspConsent) { + regs.ext.us_privacy = bidderRequest.uspConsent; + } + + // GPP + const gppString = + bidderRequest?.gppConsent?.gppString || + deepAccess(bidderRequest, "ortb2.regs.gpp"); + const gppSid = + bidderRequest?.gppConsent?.applicableSections || + deepAccess(bidderRequest, "ortb2.regs.gpp_sid"); + + if (gppString) regs.gpp = gppString; + if (gppSid) regs.gpp_sid = gppSid; + + return regs; +} + +// Build source object +function buildSource(bidderRequest) { + return { + fd: 1, + tid: bidderRequest?.ortb2?.source?.tid || generateUUID(), + }; +} + +function getUserSyncs( + syncOptions, + serverResponses, + gdprConsent, + uspConsent, + gppConsent +) { + const syncs = []; + + if (!syncOptions.iframeEnabled && !syncOptions.pixelEnabled) { + return syncs; + } + + // Build privacy string + let params = []; + + // GDPR + if (gdprConsent) { + const gdprApplies = + typeof gdprConsent.gdprApplies === "boolean" + ? Number(gdprConsent.gdprApplies) + : ""; + const consent = encodeURIComponent(gdprConsent.consentString || ""); + + params.push(`gdpr=${gdprApplies}`); + params.push(`gdpr_consent=${consent}`); + } + + // CCPA + if (uspConsent) { + params.push(`us_privacy=${encodeURIComponent(uspConsent)}`); + } + + // GPP (optional but recommended) + if (gppConsent) { + const gppString = encodeURIComponent(gppConsent.gppString || ""); + const sid = (gppConsent.applicableSections || []).join(","); + + params.push(`gpp=${gppString}`); + params.push(`gpp_sid=${sid}`); + } + + const paramString = params.length ? `?${params.join("&")}` : ""; + + // Server-provided user syncs + serverResponses.forEach((response) => { + const ext = response.body && response.body.ext; + const syncList = ext && ext.sync; + + if (syncList && Array.isArray(syncList)) { + syncList.forEach((sync) => { + if (sync.type === "iframe" && syncOptions.iframeEnabled) { + syncs.push({ + type: "iframe", + url: sync.url + paramString, + }); + } + if (sync.type === "image" && syncOptions.pixelEnabled) { + syncs.push({ + type: "image", + url: sync.url + paramString, + }); + } + }); + } + }); + + // Fallback user sync + if (syncs.length === 0) { + if (syncOptions.iframeEnabled) { + syncs.push({ + type: "iframe", + url: `${SYNC_URL}${paramString}`, + }); + } else if (syncOptions.pixelEnabled) { + syncs.push({ + type: "image", + url: `${SYNC_URL}${paramString}`, + }); + } + } + + return syncs; +} + +function onBidWon(bid) { + logInfo("HB: Bid won", bid); + if (bid.nurl) { + triggerPixel(bid.nurl); + } +} + +function onBidBillable(bid) { + if (bid.burl) { + const url = bid.burl.replace( + /\$\{AUCTION_PRICE\}/g, + encodeURIComponent(bid.cpm) + ); + triggerPixel(url); + } +} + +function onTimeout(timeoutData) { + logInfo("HyperBrainz: Bid timeout", timeoutData); + if (timeoutData.ext && timeoutData.ext.timeoutPixel) { + triggerPixel(timeoutData.ext.timeoutPixel); + } +} + +function onSetTargeting(bid) { + // Called when GAM/DFP targeting is set for this bid. + // Useful for logging or custom key-value injection. + logInfo("HyperBrainz: Targeting set", bid.adUnitCode); +} + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + isBidRequestValid, + buildRequests, + interpretResponse, + getUserSyncs, + onBidWon, + onTimeout, + onSetTargeting, + onBidBillable, +}; + +registerBidder(spec); diff --git a/modules/hyperbrainzBidAdapter.md b/modules/hyperbrainzBidAdapter.md new file mode 100644 index 0000000000..4d9ff14a95 --- /dev/null +++ b/modules/hyperbrainzBidAdapter.md @@ -0,0 +1,119 @@ +# Overview + +``` +Module Name: HyperBrainz Bid Adapter +Module Type: Bidder Adapter +Maintainer: it@hyperbrainz.com +``` + +# Description + +Module that connects to the HyperBrainz Ad Exchange as a demand source. + +Supports **Banner**, **Video (Instream & Outstream)**, and **Native** for web +inventory. The adapter is OpenRTB 2.5 compliant and supports ORTB2 first-party +data, EIDs, supply chain (schain), and all major privacy frameworks +(GDPR/TCF, CCPA/USP, GPP, COPPA). + +# Bidder Parameters + +| Param | Type | Required | Description | +|---------------|----------|----------|------------------------------------------| +| `placementId` | `string` | Yes | Unique placement identifier | +| `publisherId` | `string` | No | Publisher identifier | +| `bidFloor` | `number` | No | Minimum bid floor (CPM, USD) override | +| `ext` | `object` | No | Custom bidder extension fields | +| `userId` | `string` | No | User ID for the bid request; overrides local storage value | + +# Endpoint Override + +The default endpoint can be overridden via `setConfig` — useful for +QA/staging environments without a code change, or as a migration path if the +endpoint changes in the future: + +\`\`\`javascript +pbjs.setConfig({ + hyperbrainz: { + endpoint: 'https://staging.hyperbrainz.com/bid' + } +}); +\`\`\` + +# Test Parameters + +```javascript +var adUnits = [ + // Banner + { + code: 'test-banner', + mediaTypes: { + banner: { sizes: [[300, 250], [728, 90]] } + }, + bids: [{ + bidder: 'hyperbrainz', + params: { + placementId: 'hb_test_banner', + bidFloor: 0.30 + } + }] + }, + // Video (instream / outstream) + { + code: 'test-video', + mediaTypes: { + video: { + context: 'instream', + playerSize: [640, 360], + mimes: ['video/mp4', 'video/webm'], + protocols: [2, 3, 5, 6] + } + }, + bids: [{ + bidder: 'hyperbrainz', + params: { placementId: 'hb_test_video' } + }] + }, + // Native (OpenRTB Native 1.2) + { + code: 'test-native', + mediaTypes: { + native: { + title: { required: true, len: 80 }, + body: { required: false }, + sponsoredBy: { required: false }, + image: { required: true, sizes: [1200, 627] } + } + }, + bids: [{ + bidder: 'hyperbrainz', + params: { placementId: 'hb_test_native' } + }] + } +]; +``` + +# Outstream Video + +For outstream video, the publisher must supply a renderer on the ad unit +(`mediaTypes.video.renderer`). Without a renderer, Prebid will discard the +outstream bid. Instream video does not require a renderer. + +# User Sync + +Iframe sync is recommended: + +```javascript +pbjs.setConfig({ + userSync: { + iframeEnabled: true, + filterSettings: { + iframe: { bidders: ['hyperbrainz'], filter: 'include' } + } + } +}); +``` + +# Privacy + +GDPR/TCF, CCPA/USP, GPP and COPPA signals are forwarded automatically when the +corresponding Prebid consent modules are configured. diff --git a/test/spec/modules/hyperbrainzBidAdapter_spec.js b/test/spec/modules/hyperbrainzBidAdapter_spec.js new file mode 100644 index 0000000000..6fd3f4258d --- /dev/null +++ b/test/spec/modules/hyperbrainzBidAdapter_spec.js @@ -0,0 +1,544 @@ +import { expect } from 'chai'; +import { spec } from 'modules/hyperbrainzBidAdapter.js'; +import { config } from 'src/config.js'; + +const ENDPOINT = 'https://hb.hyperbrainz.com/bid'; +const SYNC_URL = 'https://hb.hyperbrainz.com/sync'; + +function bannerBid(overrides = {}) { + return Object.assign( + { + bidder: 'hyperbrainz', + bidId: 'bid-banner', + adUnitCode: 'div-banner', + auctionId: 'auction-1', + params: { placementId: 'plc-banner', publisherId: 'pub-1' }, + mediaTypes: { banner: { sizes: [[300, 250], [728, 90]] } }, + }, + overrides + ); +} + +function videoBid(overrides = {}) { + return Object.assign( + { + bidder: 'hyperbrainz', + bidId: 'bid-video', + adUnitCode: 'div-video', + auctionId: 'auction-1', + params: { placementId: 'plc-video' }, + mediaTypes: { + video: { context: 'instream', playerSize: [[640, 360]] }, + }, + }, + overrides + ); +} + +function nativeBid(overrides = {}) { + return Object.assign( + { + bidder: 'hyperbrainz', + bidId: 'bid-native', + adUnitCode: 'div-native', + auctionId: 'auction-1', + params: { placementId: 'plc-native' }, + mediaTypes: { native: { title: { required: true, len: 80 } } }, + nativeOrtbRequest: { + ver: '1.2', + assets: [{ id: 1, required: 1, title: { len: 80 } }], + }, + }, + overrides + ); +} + +function bidderRequestFor(bids, overrides = {}) { + return Object.assign( + { + auctionId: 'auction-1', + bidderRequestId: 'breq-1', + timeout: 1000, + refererInfo: { + page: 'https://example.com/page.html', + domain: 'example.com', + ref: 'https://referrer.com', + }, + bids, + }, + overrides + ); +} + +describe('HyperBrainz Bid Adapter', function () { + afterEach(function () { + config.resetConfig(); + }); + + describe('exposed spec', function () { + it('has the correct bidder code', function () { + expect(spec.code).to.equal('hyperbrainz'); + }); + it('supports banner, video and native', function () { + expect(spec.supportedMediaTypes).to.deep.equal([ + 'banner', + 'video', + 'native', + ]); + }); + }); + + describe('isBidRequestValid', function () { + it('returns true for a valid banner bid', function () { + expect(spec.isBidRequestValid(bannerBid())).to.equal(true); + }); + + it('returns true for a valid video bid', function () { + expect(spec.isBidRequestValid(videoBid())).to.equal(true); + }); + + it('returns true for a valid native bid', function () { + expect(spec.isBidRequestValid(nativeBid())).to.equal(true); + }); + + it('returns false when params are missing', function () { + expect(spec.isBidRequestValid({})).to.equal(false); + }); + + it('returns false when placementId is missing', function () { + expect( + spec.isBidRequestValid(bannerBid({ params: {} })) + ).to.equal(false); + }); + + it('returns false when placementId is not a string', function () { + expect( + spec.isBidRequestValid(bannerBid({ params: { placementId: 123 } })) + ).to.equal(false); + }); + + it('returns false when no supported mediaType is present', function () { + expect( + spec.isBidRequestValid(bannerBid({ mediaTypes: {} })) + ).to.equal(false); + }); + + it('returns false for a video bid without context', function () { + expect( + spec.isBidRequestValid( + videoBid({ mediaTypes: { video: { playerSize: [[640, 360]] } } }) + ) + ).to.equal(false); + }); + + it('returns false for a video bid without a size', function () { + expect( + spec.isBidRequestValid( + videoBid({ mediaTypes: { video: { context: 'instream' } } }) + ) + ).to.equal(false); + }); + }); + + describe('buildRequests', function () { + it('returns an empty array when there are no bids', function () { + expect(spec.buildRequests([], bidderRequestFor([]))).to.deep.equal([]); + }); + + it('builds a POST request to the default endpoint', function () { + const bids = [bannerBid()]; + const requests = spec.buildRequests(bids, bidderRequestFor(bids)); + expect(requests).to.be.an('array').with.lengthOf(1); + expect(requests[0].method).to.equal('POST'); + expect(requests[0].url).to.equal(ENDPOINT); + expect(requests[0].options.contentType).to.equal('text/plain'); + expect(requests[0].options.withCredentials).to.equal(true); + }); + + it('builds a well-formed ORTB payload', function () { + const bids = [bannerBid()]; + const request = spec.buildRequests(bids, bidderRequestFor(bids))[0]; + const data = JSON.parse(request.data); + + expect(data.id).to.equal('auction-1'); + expect(data.at).to.equal(1); + expect(data.cur).to.deep.equal(['USD']); + expect(data.tmax).to.equal(1000); + expect(data.ext.prebid.auctionId).to.equal('auction-1'); + expect(data.site.domain).to.equal('example.com'); + expect(data.site.page).to.equal('https://example.com/page.html'); + expect(data.site.publisher.id).to.equal('pub-1'); + expect(data.device).to.be.an('object'); + expect(data.imp).to.be.an('array').with.lengthOf(1); + }); + + it('maps a banner impression', function () { + const bids = [bannerBid()]; + const request = spec.buildRequests(bids, bidderRequestFor(bids))[0]; + const imp = JSON.parse(request.data).imp[0]; + + expect(imp.id).to.equal('bid-banner'); + expect(imp.tagid).to.equal('plc-banner'); + expect(imp.bidfloorcur).to.equal('USD'); + expect(imp).to.have.property('secure'); + expect(imp.banner.format).to.deep.equal([ + { w: 300, h: 250 }, + { w: 728, h: 90 }, + ]); + expect(imp.banner.w).to.equal(300); + expect(imp.banner.h).to.equal(250); + }); + + it('maps a video impression with ORTB fields', function () { + const bids = [videoBid()]; + const request = spec.buildRequests(bids, bidderRequestFor(bids))[0]; + const imp = JSON.parse(request.data).imp[0]; + + expect(imp.video).to.be.an('object'); + expect(imp.video.w).to.equal(640); + expect(imp.video.h).to.equal(360); + expect(imp.video.mimes).to.include('video/mp4'); + expect(imp.video.plcmt).to.equal(1); + expect(imp.video).to.not.have.property('context'); + expect(imp.video).to.not.have.property('playerSize'); + }); + + it('maps a native impression', function () { + const bids = [nativeBid()]; + const request = spec.buildRequests(bids, bidderRequestFor(bids))[0]; + const imp = JSON.parse(request.data).imp[0]; + + expect(imp.native).to.be.an('object'); + expect(imp.native.ver).to.equal('1.2'); + expect(typeof imp.native.request).to.equal('string'); + expect(JSON.parse(imp.native.request)).to.deep.equal({ + ver: '1.2', + assets: [{ id: 1, required: 1, title: { len: 80 } }], + }); + }); + + it('reads the bid floor from params', function () { + const bids = [bannerBid({ params: { placementId: 'p', bidFloor: 0.75 } })]; + const request = spec.buildRequests(bids, bidderRequestFor(bids))[0]; + expect(JSON.parse(request.data).imp[0].bidfloor).to.equal(0.75); + }); + + it('reads the bid floor from the getFloor module', function () { + const bids = [ + bannerBid({ getFloor: () => ({ currency: 'USD', floor: 3.21 }) }), + ]; + const request = spec.buildRequests(bids, bidderRequestFor(bids))[0]; + expect(JSON.parse(request.data).imp[0].bidfloor).to.equal(3.21); + }); + + it('forwards GDPR consent into regs and user', function () { + const bids = [bannerBid()]; + const bidderRequest = bidderRequestFor(bids, { + gdprConsent: { gdprApplies: true, consentString: 'CONSENT_STR' }, + }); + const data = JSON.parse(spec.buildRequests(bids, bidderRequest)[0].data); + expect(data.regs.gdpr).to.equal(1); + expect(data.user.ext.consent).to.equal('CONSENT_STR'); + }); + + it('forwards US privacy consent', function () { + const bids = [bannerBid()]; + const bidderRequest = bidderRequestFor(bids, { uspConsent: '1YNN' }); + const data = JSON.parse(spec.buildRequests(bids, bidderRequest)[0].data); + expect(data.regs.ext.us_privacy).to.equal('1YNN'); + }); + + it('sets COPPA when configured', function () { + config.setConfig({ coppa: true }); + const bids = [bannerBid()]; + const data = JSON.parse( + spec.buildRequests(bids, bidderRequestFor(bids))[0].data + ); + expect(data.regs.coppa).to.equal(1); + }); + + it('forwards the supply chain from ortb2', function () { + const schain = { + ver: '1.0', + complete: 1, + nodes: [{ asi: 'exchange.com', sid: '1234', hp: 1 }], + }; + const bids = [bannerBid()]; + const bidderRequest = bidderRequestFor(bids, { + ortb2: { source: { ext: { schain } } }, + }); + const data = JSON.parse(spec.buildRequests(bids, bidderRequest)[0].data); + expect(data.source.ext.schain).to.deep.equal(schain); + }); + }); + + describe('interpretResponse', function () { + function requestFor(bids) { + return spec.buildRequests(bids, bidderRequestFor(bids))[0]; + } + + it('returns an empty array when there is no body', function () { + expect( + spec.interpretResponse({}, requestFor([bannerBid()])) + ).to.deep.equal([]); + }); + + it('returns an empty array when there is no seatbid', function () { + expect( + spec.interpretResponse({ body: {} }, requestFor([bannerBid()])) + ).to.deep.equal([]); + }); + + it('parses a banner bid', function () { + const bids = [bannerBid()]; + const response = { + body: { + cur: 'USD', + seatbid: [ + { + bid: [ + { + impid: 'bid-banner', + price: 1.5, + w: 300, + h: 250, + adm: '
ad
', + crid: 'creative-1', + adomain: ['advertiser.com'], + }, + ], + }, + ], + }, + }; + const result = spec.interpretResponse(response, requestFor(bids)); + expect(result).to.have.lengthOf(1); + expect(result[0].requestId).to.equal('bid-banner'); + expect(result[0].cpm).to.equal(1.5); + expect(result[0].width).to.equal(300); + expect(result[0].height).to.equal(250); + expect(result[0].currency).to.equal('USD'); + expect(result[0].ttl).to.equal(300); + expect(result[0].netRevenue).to.equal(true); + expect(result[0].creativeId).to.equal('creative-1'); + expect(result[0].mediaType).to.equal('banner'); + expect(result[0].ad).to.equal('
ad
'); + expect(result[0].meta.advertiserDomains).to.deep.equal([ + 'advertiser.com', + ]); + }); + + it('parses a video bid into vastXml', function () { + const bids = [videoBid()]; + const response = { + body: { + seatbid: [ + { + bid: [ + { + impid: 'bid-video', + price: 2.0, + adm: '', + nurl: 'https://hb.hyperbrainz.com/win', + }, + ], + }, + ], + }, + }; + const result = spec.interpretResponse(response, requestFor(bids)); + expect(result).to.have.lengthOf(1); + expect(result[0].mediaType).to.equal('video'); + expect(result[0].vastXml).to.equal(''); + expect(result[0].vastUrl).to.be.undefined; + }); + + it('parses a native bid into the ORTB native shape', function () { + const bids = [nativeBid()]; + const ortbNative = { + link: { url: 'https://click.com', clicktrackers: ['https://ct.com'] }, + imptrackers: ['https://imp.com'], + assets: [ + { id: 1, title: { text: 'Title' } }, + { id: 2, img: { url: 'https://img.com', w: 300, h: 250 } }, + { id: 3, data: { type: 1, value: 'Brand' } }, + ], + }; + const adm = JSON.stringify(ortbNative); + const response = { + body: { + seatbid: [{ bid: [{ impid: 'bid-native', price: 0.9, adm }] }], + }, + }; + const result = spec.interpretResponse(response, requestFor(bids)); + expect(result).to.have.lengthOf(1); + expect(result[0].mediaType).to.equal('native'); + expect(result[0].native.ortb).to.deep.equal(ortbNative); + }); + + it('skips a native bid whose ADM has no assets', function () { + const bids = [nativeBid()]; + const response = { + body: { + seatbid: [ + { bid: [{ impid: 'bid-native', price: 0.9, adm: '{}' }] }, + ], + }, + }; + expect( + spec.interpretResponse(response, requestFor(bids)) + ).to.deep.equal([]); + }); + + it('skips bids with a non-positive price', function () { + const bids = [bannerBid()]; + const response = { + body: { seatbid: [{ bid: [{ impid: 'bid-banner', price: 0 }] }] }, + }; + expect( + spec.interpretResponse(response, requestFor(bids)) + ).to.deep.equal([]); + }); + + it('skips bids that do not match a request', function () { + const bids = [bannerBid()]; + const response = { + body: { seatbid: [{ bid: [{ impid: 'unknown', price: 1 }] }] }, + }; + expect( + spec.interpretResponse(response, requestFor(bids)) + ).to.deep.equal([]); + }); + + it('attaches nurl/burl and copies bid extensions', function () { + const bids = [bannerBid()]; + const response = { + body: { + seatbid: [ + { + bid: [ + { + impid: 'bid-banner', + price: 1, + adm: '
', + nurl: 'https://win', + burl: 'https://bill', + ext: { foo: 'bar' }, + }, + ], + }, + ], + }, + }; + const result = spec.interpretResponse(response, requestFor(bids)); + expect(result[0].nurl).to.equal('https://win'); + expect(result[0].burl).to.equal('https://bill'); + expect(result[0].ext.foo).to.equal('bar'); + }); + + it('honors mediaType from the bid extension', function () { + const bids = [bannerBid()]; + const response = { + body: { + seatbid: [ + { + bid: [ + { + impid: 'bid-banner', + price: 1, + adm: '', + ext: { prebid: { type: 'video' } }, + }, + ], + }, + ], + }, + }; + const result = spec.interpretResponse(response, requestFor(bids)); + expect(result[0].mediaType).to.equal('video'); + }); + }); + + describe('getUserSyncs', function () { + it('returns nothing when no sync options are enabled', function () { + expect( + spec.getUserSyncs({ iframeEnabled: false, pixelEnabled: false }, []) + ).to.deep.equal([]); + }); + + it('falls back to an iframe sync', function () { + const syncs = spec.getUserSyncs( + { iframeEnabled: true, pixelEnabled: false }, + [] + ); + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].type).to.equal('iframe'); + expect(syncs[0].url).to.equal(SYNC_URL); + }); + + it('falls back to a pixel sync', function () { + const syncs = spec.getUserSyncs( + { iframeEnabled: false, pixelEnabled: true }, + [] + ); + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].type).to.equal('image'); + }); + + it('uses server-provided sync URLs', function () { + const serverResponses = [ + { + body: { + ext: { + sync: [ + { type: 'iframe', url: 'https://sync.com/iframe' }, + { type: 'image', url: 'https://sync.com/pixel' }, + ], + }, + }, + }, + ]; + const syncs = spec.getUserSyncs( + { iframeEnabled: true, pixelEnabled: true }, + serverResponses + ); + expect(syncs).to.have.lengthOf(2); + expect(syncs[0].url).to.contain('https://sync.com/iframe'); + }); + + it('appends GDPR, USP and GPP consent to the sync URL', function () { + const syncs = spec.getUserSyncs( + { iframeEnabled: true, pixelEnabled: false }, + [], + { gdprApplies: true, consentString: 'CONSENT' }, + '1YNN', + { gppString: 'GPP_STR', applicableSections: [2, 3] } + ); + expect(syncs[0].url).to.contain('gdpr=1'); + expect(syncs[0].url).to.contain('gdpr_consent=CONSENT'); + expect(syncs[0].url).to.contain('us_privacy=1YNN'); + expect(syncs[0].url).to.contain('gpp=GPP_STR'); + expect(syncs[0].url).to.contain('gpp_sid=2,3'); + }); + }); + + describe('event handlers', function () { + it('onBidWon does not throw', function () { + expect(() => spec.onBidWon({ nurl: 'https://win' })).to.not.throw(); + expect(() => spec.onBidWon({})).to.not.throw(); + }); + + it('onTimeout does not throw', function () { + expect(() => + spec.onTimeout({ ext: { timeoutPixel: 'https://timeout' } }) + ).to.not.throw(); + expect(() => spec.onTimeout({})).to.not.throw(); + }); + + it('onSetTargeting does not throw', function () { + expect(() => + spec.onSetTargeting({ adUnitCode: 'div-banner' }) + ).to.not.throw(); + }); + }); +});