From 35756e90487583b75ce057ff149b14d6e9d07884 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 14:56:06 -0500 Subject: [PATCH 1/8] Add Prebid error diagnostics Prebid non-2xx responses were reduced to bare provider errors, making intermittent failures difficult to diagnose. Surface safe HTTP metadata and bounded debug details while correlating server logs with the auction ID. --- .../src/integrations/prebid.rs | 642 +++++++++++++----- docs/guide/integrations/prebid.md | 28 +- 2 files changed, 487 insertions(+), 183 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 37096e15..2e765538 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -4,15 +4,15 @@ use std::time::Duration; use async_trait::async_trait; use base64::{ - Engine as _, engine::general_purpose::{ STANDARD as BASE64_STANDARD, STANDARD_NO_PAD as BASE64_STANDARD_NO_PAD, }, + Engine as _, }; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::header::HeaderValue; -use http::{Method, StatusCode, header}; +use http::{header, Method, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::Value as Json; use url::{Url, Url as ParsedUrl}; @@ -23,25 +23,26 @@ use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, }; use crate::consent_config::ConsentForwardingMode; -use crate::cookies::{CONSENT_COOKIE_NAMES, strip_cookies}; +use crate::cookies::{strip_cookies, CONSENT_COOKIE_NAMES}; use crate::error::TrustedServerError; use crate::http_util::RequestInfo; use crate::integrations::{ - AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, - IntegrationRegistration, UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded, - ensure_integration_backend_with_timeout, predict_integration_backend_name, + collect_response_bounded, ensure_integration_backend_with_timeout, + predict_integration_backend_name, AttributeRewriteAction, IntegrationAttributeContext, + IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationHeadInjector, + IntegrationHtmlContext, IntegrationProxy, IntegrationRegistration, + UPSTREAM_RTB_MAX_RESPONSE_BYTES, }; use crate::openrtb::{ - Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, ImpStoredRequest, - OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, RequestExt, Site, ToExt, - TrustedServerExt, User, UserExt, to_openrtb_i32, + to_openrtb_i32, Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, + ImpStoredRequest, OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, + RequestExt, Site, ToExt, TrustedServerExt, User, UserExt, }; use crate::platform::{ PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, }; -use crate::proxy::{ProxyRequestConfig, is_host_allowed, proxy_request}; -use crate::request_signing::{RequestSigner, SIGNING_VERSION, SigningParams}; +use crate::proxy::{is_host_allowed, proxy_request, ProxyRequestConfig}; +use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; use crate::settings::{IntegrationConfig, Settings}; const PREBID_INTEGRATION_ID: &str = "prebid"; @@ -61,20 +62,134 @@ const ZONE_KEY: &str = "zone"; /// Default currency for `OpenRTB` bid floors and responses. const DEFAULT_CURRENCY: &str = "USD"; -#[cfg(test)] +const PREBID_ERROR_TYPE_UPSTREAM_HTTP: &str = "upstream_http"; +const PREBID_PUBLIC_ERROR_MESSAGE_CHARS: usize = 500; const PREBID_ERROR_BODY_PREVIEW_CHARS: usize = 1000; - -#[cfg(test)] const PREBID_ERROR_BODY_PREVIEW_BYTES: usize = PREBID_ERROR_BODY_PREVIEW_CHARS * 4; +const PREBID_ERROR_JSON_MAX_DEPTH: usize = 6; +const PREBID_ERROR_JSON_KEYS: [&str; 6] = + ["message", "error", "errors", "detail", "title", "reason"]; + +#[derive(Debug, Eq, PartialEq)] +struct BoundedPrebidErrorText { + text: String, + truncated: bool, +} -#[cfg(test)] -fn prebid_body_preview(body: &[u8]) -> String { +fn bounded_prebid_error_text(value: &str, max_chars: usize) -> Option { + let mut text = String::new(); + let mut char_count = 0; + let mut pending_space = false; + let mut truncated = false; + + for character in value.chars() { + if character.is_whitespace() || character.is_control() { + pending_space = !text.is_empty(); + continue; + } + + if pending_space { + if char_count == max_chars { + truncated = true; + break; + } + text.push(' '); + char_count += 1; + pending_space = false; + } + + if char_count == max_chars { + truncated = true; + break; + } + text.push(character); + char_count += 1; + } + + (!text.is_empty()).then_some(BoundedPrebidErrorText { text, truncated }) +} + +fn prebid_body_preview(body: &[u8]) -> Option { let bounded_body = &body[..body.len().min(PREBID_ERROR_BODY_PREVIEW_BYTES)]; + let mut preview = bounded_prebid_error_text( + &String::from_utf8_lossy(bounded_body), + PREBID_ERROR_BODY_PREVIEW_CHARS, + )?; + preview.truncated |= body.len() > bounded_body.len(); + Some(preview) +} - String::from_utf8_lossy(bounded_body) - .chars() - .take(PREBID_ERROR_BODY_PREVIEW_CHARS) - .collect() +fn nested_prebid_json_error_message( + value: &Json, + depth: usize, + allow_direct_string: bool, +) -> Option<&str> { + if depth > PREBID_ERROR_JSON_MAX_DEPTH { + return None; + } + + match value { + Json::String(message) if allow_direct_string => { + (!message.trim().is_empty()).then_some(message.as_str()) + } + Json::Array(values) => values.iter().find_map(|value| { + nested_prebid_json_error_message(value, depth + 1, allow_direct_string) + }), + Json::Object(values) => PREBID_ERROR_JSON_KEYS + .iter() + .find_map(|key| { + values + .get(*key) + .and_then(|value| nested_prebid_json_error_message(value, depth + 1, true)) + }) + .or_else(|| { + values + .values() + .find_map(|value| nested_prebid_json_error_message(value, depth + 1, false)) + }), + _ => None, + } +} + +fn prebid_json_error_message(value: &Json) -> Option<&str> { + let Json::Object(values) = value else { + return None; + }; + + PREBID_ERROR_JSON_KEYS.iter().find_map(|key| { + values + .get(*key) + .and_then(|value| nested_prebid_json_error_message(value, 0, true)) + }) +} + +fn is_plain_text_content_type(content_type: Option<&str>) -> bool { + content_type.is_some_and(|value| { + value + .split(';') + .next() + .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/plain")) + }) +} + +fn extract_prebid_error_message( + body: &[u8], + content_type: Option<&str>, +) -> Option { + let candidate = match serde_json::from_slice::(body) { + Ok(value) => prebid_json_error_message(&value)?.to_owned(), + Err(_) if is_plain_text_content_type(content_type) => { + std::str::from_utf8(body).ok()?.to_owned() + } + Err(_) => return None, + }; + + // Do not expose an HTML error page even if an intermediary labels it as text/plain. + if candidate.trim_start().starts_with('<') { + return None; + } + + bounded_prebid_error_text(&candidate, PREBID_PUBLIC_ERROR_MESSAGE_CHARS) } /// CCPA/US-privacy string sent when the `Sec-GPC` header signals opt-out. @@ -1177,16 +1292,16 @@ impl CompiledBidParamOverrideRule { } fn matches(&self, facts: BidParamOverrideFacts<'_>) -> bool { - if let Some(expected_bidder) = self.bidder.as_deref() - && expected_bidder != facts.bidder - { - return false; + if let Some(expected_bidder) = self.bidder.as_deref() { + if expected_bidder != facts.bidder { + return false; + } } - if let Some(expected_zone) = self.zone.as_deref() - && facts.zone != Some(expected_zone) - { - return false; + if let Some(expected_zone) = self.zone.as_deref() { + if facts.zone != Some(expected_zone) { + return false; + } } true @@ -1278,11 +1393,11 @@ fn copy_request_headers( } } - if let Some(ip) = client_ip - && let Ok(value) = HeaderValue::from_str(&ip.to_string()) - { - to.headers_mut() - .insert(header::HeaderName::from_static("x-forwarded-for"), value); + if let Some(ip) = client_ip { + if let Ok(value) = HeaderValue::from_str(&ip.to_string()) { + to.headers_mut() + .insert(header::HeaderName::from_static("x-forwarded-for"), value); + } } let Some(cookie_value) = from.headers().get(header::COOKIE) else { @@ -1844,6 +1959,113 @@ impl PrebidAuctionProvider { } } + async fn parse_response_inner( + &self, + response: PlatformResponse, + response_time_ms: u64, + auction_id: Option<&str>, + ) -> Result> { + let response = response.response; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + + // Parse response — collect_response_bounded caps memory from misbehaving providers. + let body_bytes = collect_response_bounded( + response.into_body(), + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + "prebid", + ) + .await + .change_context(TrustedServerError::Prebid { + message: "Failed to read Prebid response body".to_string(), + })?; + + if !status.is_success() { + let auction_id = auction_id.unwrap_or(""); + log::warn!("Prebid auction {auction_id:?} returned non-success status: {status}"); + + if self.config.debug { + match prebid_body_preview(&body_bytes) { + Some(preview) => { + let truncation = if preview.truncated { + " (truncated)" + } else { + "" + }; + log::warn!( + "Prebid auction {auction_id:?} error response body preview{truncation}: {}", + preview.text + ); + } + None => log::warn!( + "Prebid auction {auction_id:?} returned an empty error response body" + ), + } + } + + let status_code = status.as_u16(); + let mut auction_response = + AuctionResponse::error(PREBID_INTEGRATION_ID, response_time_ms) + .with_metadata( + "error_type", + serde_json::json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP), + ) + .with_metadata("http_status", serde_json::json!(status_code)) + .with_metadata( + "message", + serde_json::json!(format!("Prebid Server returned HTTP {status_code}")), + ); + + if self.config.debug { + if let Some(message) = + extract_prebid_error_message(&body_bytes, content_type.as_deref()) + { + auction_response.metadata.insert( + "upstream_message".to_string(), + serde_json::json!(message.text), + ); + auction_response.metadata.insert( + "upstream_message_truncated".to_string(), + serde_json::json!(message.truncated), + ); + } + } + + return Ok(auction_response); + } + + let response_json: Json = + serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { + message: "Failed to parse Prebid response".to_string(), + })?; + + // Log the full response body when debug is enabled to surface + // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. + if self.config.debug && log::log_enabled!(log::Level::Trace) { + match serde_json::to_string_pretty(&response_json) { + Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), + Err(e) => { + log::warn!("Prebid: failed to serialize response for logging: {e}"); + } + } + } + + let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); + self.enrich_response_metadata(&response_json, &mut auction_response); + + log::info!( + "Prebid returned {} bids in {}ms", + auction_response.bids.len(), + response_time_ms + ); + + Ok(auction_response) + } + fn should_suppress_bid_notifications(&self, bidder: &str) -> bool { self.config.suppress_nurl || self @@ -2107,76 +2329,19 @@ impl AuctionProvider for PrebidAuctionProvider { response: PlatformResponse, response_time_ms: u64, ) -> Result> { - let response = response.response; - let status = response.status(); - - // Parse response — collect_response_bounded caps memory from misbehaving providers. - let body_bytes = collect_response_bounded( - response.into_body(), - UPSTREAM_RTB_MAX_RESPONSE_BYTES, - "prebid", - ) - .await - .change_context(TrustedServerError::Prebid { - message: "Failed to read Prebid response body".to_string(), - })?; - - if !status.is_success() { - let body_preview = String::from_utf8_lossy(&body_bytes); - // SECURITY: the PBS response body is upstream-controlled and may leak - // internal detail (hostnames, stack traces, auth hints). Per the - // invariant documented in `auction/orchestrator.rs`, it MUST NOT reach - // the public `/auction` response, which happens if it lands in - // `AuctionResponse.metadata` (cloned verbatim into - // `ext.orchestrator.provider_details[].metadata`). Log the snippet - // server-side and surface only the numeric HTTP status — enough for an - // operator to tell an error from a no-bid without publishing the body. - log::warn!( - "Prebid returned non-success status {status}: {}", - &body_preview[..body_preview.floor_char_boundary(512)] - ); - return Ok(AuctionResponse::error("prebid", response_time_ms) - .with_metadata( - "error_type", - serde_json::json!(crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS), - ) - // Static message only (no upstream content) — mirrors the - // orchestrator's error constructors so every consumer of - // `provider_details[].metadata` sees a consistent - // `error_type` + `message` shape. - .with_metadata( - "message", - serde_json::json!("Prebid returned non-success status"), - ) - .with_metadata("status", serde_json::json!(status.as_u16()))); - } - - let response_json: Json = - serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { - message: "Failed to parse Prebid response".to_string(), - })?; - - // Log the full response body when debug is enabled to surface - // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. - if self.config.debug && log::log_enabled!(log::Level::Trace) { - match serde_json::to_string_pretty(&response_json) { - Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), - Err(e) => { - log::warn!("Prebid: failed to serialize response for logging: {e}"); - } - } - } - - let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); - self.enrich_response_metadata(&response_json, &mut auction_response); - - log::info!( - "Prebid returned {} bids in {}ms", - auction_response.bids.len(), - response_time_ms - ); + self.parse_response_inner(response, response_time_ms, None) + .await + } - Ok(auction_response) + async fn parse_response_with_context( + &self, + response: PlatformResponse, + response_time_ms: u64, + request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + self.parse_response_inner(response, response_time_ms, Some(request.id.as_str())) + .await } fn supports_media_type(&self, media_type: &MediaType) -> bool { @@ -2250,19 +2415,18 @@ mod tests { use super::*; use crate::auction::test_support::create_test_auction_context as shared_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo, - UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, }; use crate::consent::{ConsentContext, ConsentSource}; use crate::geo::GeoInfo; - use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; + use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; use crate::integrations::{ AttributeRewriteAction, IntegrationDocumentState, IntegrationRegistry, }; use crate::platform::test_support::{ - NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, StubHttpClient, - build_services_with_http_client, + build_services_with_http_client, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, + StubHttpClient, }; use crate::platform::{ ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformError, RuntimeServices, @@ -2359,57 +2523,6 @@ mod tests { ); } - #[test] - fn parse_response_attaches_status_metadata_without_leaking_body_on_http_error() { - let provider = PrebidAuctionProvider::new(base_config()); - let response = PlatformResponse::new( - edgezero_core::http::response_builder() - .status(403) - .body(EdgeBody::from( - br#"{"error":"upstream-secret-detail"}"#.to_vec(), - )) - .expect("should build test response"), - ); - - let result = futures::executor::block_on(provider.parse_response(response, 643)) - .expect("should return Ok(error response) for non-success status"); - - assert_eq!( - result.status, - BidStatus::Error, - "non-success HTTP status should map to an error response" - ); - assert_eq!( - result.metadata["error_type"], - json!("http_status"), - "should tag the error path so telemetry buckets it as an http status error" - ); - assert_eq!( - result.metadata["status"], - json!(403), - "should surface the upstream HTTP status code" - ); - // A static, upstream-free message keeps the error shape consistent with - // the orchestrator's other provider error responses. - assert_eq!( - result.metadata["message"], - json!("Prebid returned non-success status"), - "should carry a static message like every other provider error path" - ); - // SECURITY: the upstream response body must never reach the public - // /auction response via AuctionResponse.metadata. - assert!( - !result.metadata.contains_key("body"), - "upstream response body must not be surfaced on the response metadata" - ); - assert!( - !result.metadata.values().any(|v| v - .as_str() - .is_some_and(|s| s.contains("upstream-secret-detail"))), - "no metadata value may contain the upstream body" - ); - } - fn test_sri(algorithm: &str, digest: &[u8]) -> String { format!("{algorithm}-{}", TEST_BASE64_STANDARD.encode(digest)) } @@ -2444,6 +2557,23 @@ mod tests { .expect("should parse response body as utf-8") } + fn prebid_platform_response( + status: StatusCode, + content_type: Option<&str>, + body: impl Into>, + ) -> PlatformResponse { + let mut builder = http::Response::builder().status(status); + if let Some(content_type) = content_type { + builder = builder.header(header::CONTENT_TYPE, content_type); + } + + PlatformResponse::new( + builder + .body(EdgeBody::from(body.into())) + .expect("should build Prebid platform response"), + ) + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), @@ -2752,11 +2882,9 @@ script_patterns = ["/prebid.js", "/custom/prebid.min.js"] assert_eq!(config.script_patterns.len(), 2); assert!(config.script_patterns.contains(&"/prebid.js".to_string())); - assert!( - config - .script_patterns - .contains(&"/custom/prebid.min.js".to_string()) - ); + assert!(config + .script_patterns + .contains(&"/custom/prebid.min.js".to_string())); } #[test] @@ -2771,11 +2899,9 @@ server_url = "https://prebid.example" assert!(!config.script_patterns.is_empty()); assert!(config.script_patterns.contains(&"/prebid.js".to_string())); - assert!( - config - .script_patterns - .contains(&"/prebid.min.js".to_string()) - ); + assert!(config + .script_patterns + .contains(&"/prebid.min.js".to_string())); } #[test] @@ -4853,27 +4979,42 @@ external_bundle_sri = "sha384-AAAA" ); } + #[test] + fn bounded_prebid_error_text_normalizes_control_characters_and_whitespace() { + let message = bounded_prebid_error_text("\n invalid\trequest\0 payload \r\n", 100) + .expect("should extract bounded text"); + + assert_eq!( + message.text, "invalid request payload", + "should make upstream text safe for one-line responses and logs" + ); + assert!(!message.truncated, "should retain the complete message"); + } + #[test] fn prebid_body_preview_truncates_to_character_limit() { let body = "x".repeat(PREBID_ERROR_BODY_PREVIEW_CHARS + 100); - let preview = prebid_body_preview(body.as_bytes()); + let preview = prebid_body_preview(body.as_bytes()).expect("should build body preview"); assert_eq!( - preview.chars().count(), + preview.text.chars().count(), PREBID_ERROR_BODY_PREVIEW_CHARS, "should cap the upstream body preview" ); + assert!(preview.truncated, "should report body preview truncation"); } #[test] fn prebid_body_preview_handles_non_utf8_lossily() { - let preview = prebid_body_preview(&[b'o', b'k', 0xff, b'!']); + let preview = + prebid_body_preview(&[b'o', b'k', 0xff, b'!']).expect("should build body preview"); assert_eq!( - preview, "ok\u{fffd}!", + preview.text, "ok\u{fffd}!", "should replace invalid UTF-8 bytes without panicking" ); + assert!(!preview.truncated, "should retain the complete preview"); } #[test] @@ -4881,17 +5022,18 @@ external_bundle_sri = "sha384-AAAA" let mut body = vec![b'x'; PREBID_ERROR_BODY_PREVIEW_BYTES]; body.extend_from_slice(&[0xff, b't', b'a', b'i', b'l']); - let preview = prebid_body_preview(&body); + let preview = prebid_body_preview(&body).expect("should build body preview"); assert_eq!( - preview.chars().count(), + preview.text.chars().count(), PREBID_ERROR_BODY_PREVIEW_CHARS, - "should keep the public preview capped" + "should keep the log preview capped" ); assert!( - !preview.contains('\u{fffd}') && !preview.contains("tail"), + !preview.text.contains('\u{fffd}') && !preview.text.contains("tail"), "should not process bytes beyond the bounded preview slice" ); + assert!(preview.truncated, "should report bounded-slice truncation"); } #[test] @@ -4900,17 +5042,152 @@ external_bundle_sri = "sha384-AAAA" body.extend_from_slice("\u{2603}".as_bytes()); body.extend_from_slice(b"tail"); - let preview = prebid_body_preview(&body); + let preview = prebid_body_preview(&body).expect("should build body preview"); assert_eq!( - preview.chars().count(), + preview.text.chars().count(), PREBID_ERROR_BODY_PREVIEW_CHARS, - "should keep the public preview capped" + "should keep the log preview capped" ); assert!( - !preview.contains("tail"), + !preview.text.contains("tail"), "should not include bytes beyond the bounded preview slice" ); + assert!(preview.truncated, "should report partial-body truncation"); + } + + #[test] + fn extract_prebid_error_message_reads_nested_json_message() { + let body = br#"{ + "errors": { + "exampleBidder": [{"code": 1, "message": " invalid\nrequest "}] + } + }"#; + + let message = extract_prebid_error_message(body, Some("application/json")) + .expect("should extract nested JSON error message"); + + assert_eq!(message.text, "invalid request"); + assert!(!message.truncated, "should retain the complete message"); + } + + #[test] + fn extract_prebid_error_message_reads_plain_text() { + let message = extract_prebid_error_message( + b" request rejected\r\nby Prebid Server ", + Some("Text/Plain; charset=utf-8"), + ) + .expect("should extract plain-text error message"); + + assert_eq!(message.text, "request rejected by Prebid Server"); + assert!(!message.truncated, "should retain the complete message"); + } + + #[test] + fn extract_prebid_error_message_rejects_html_and_unknown_json_fields() { + assert!( + extract_prebid_error_message( + b"internal proxy error", + Some("text/plain"), + ) + .is_none(), + "should not expose HTML error pages" + ); + + for body in [ + br#"{"resolvedrequest":{"account":"internal"}}"#.as_slice(), + br#"{"errors":{"resolvedrequest":{"account":"internal"}}}"#.as_slice(), + br#""internal""#.as_slice(), + br#"["internal"]"#.as_slice(), + ] { + assert!( + extract_prebid_error_message(body, Some("application/json")).is_none(), + "should only expose strings associated with allowlisted JSON error fields" + ); + } + } + + #[test] + fn extract_prebid_error_message_truncates_public_message() { + let body = serde_json::to_vec(&json!({ + "message": "x".repeat(PREBID_PUBLIC_ERROR_MESSAGE_CHARS + 100), + })) + .expect("should serialize test error response"); + + let message = extract_prebid_error_message(&body, Some("application/json")) + .expect("should extract JSON error message"); + + assert_eq!( + message.text.chars().count(), + PREBID_PUBLIC_ERROR_MESSAGE_CHARS, + "should cap the browser-visible upstream message" + ); + assert!(message.truncated, "should report public message truncation"); + } + + #[test] + fn non_success_prebid_response_always_includes_safe_http_metadata() { + let provider = PrebidAuctionProvider::new(base_config()); + let response = prebid_platform_response( + StatusCode::BAD_REQUEST, + Some("application/json"), + br#"{"message":"request details should remain hidden"}"#.to_vec(), + ); + + let auction_response = futures::executor::block_on(provider.parse_response(response, 42)) + .expect("should convert upstream HTTP failure to auction response"); + + assert_eq!( + auction_response.status, + crate::auction::types::BidStatus::Error + ); + assert_eq!( + auction_response.metadata["error_type"], + json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP) + ); + assert_eq!(auction_response.metadata["http_status"], json!(400)); + assert_eq!( + auction_response.metadata["message"], + json!("Prebid Server returned HTTP 400") + ); + assert!( + !auction_response.metadata.contains_key("upstream_message"), + "should hide upstream text when Prebid debug is disabled" + ); + } + + #[test] + fn debug_non_success_prebid_response_includes_bounded_upstream_message() { + let mut config = base_config(); + config.debug = true; + let provider = PrebidAuctionProvider::new(config); + let response = prebid_platform_response( + StatusCode::UNPROCESSABLE_ENTITY, + Some("application/json; charset=utf-8"), + br#"{"error":{"message":"imp[0] has no valid bidders"}}"#.to_vec(), + ); + let settings = make_settings(); + let http_request = build_test_request(); + let context = create_test_auction_context(&settings, &http_request); + let auction_request = create_test_auction_request(); + + let auction_response = futures::executor::block_on(provider.parse_response_with_context( + response, + 66, + &auction_request, + &context, + )) + .expect("should convert upstream HTTP failure to debug auction response"); + + assert_eq!(auction_response.metadata["http_status"], json!(422)); + assert_eq!( + auction_response.metadata["upstream_message"], + json!("imp[0] has no valid bidders") + ); + assert_eq!( + auction_response.metadata["upstream_message_truncated"], + json!(false) + ); } fn make_auction_request(slots: Vec) -> AuctionRequest { @@ -5795,7 +6072,8 @@ set = { placementId = "explicit_header" } let actual = engine.rules.iter().map(rule_signature).collect::>(); assert_eq!( - actual, expected, + actual, + expected, "compatibility rules should compile in sorted matcher order on iteration {iteration}" ); } diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index c1e85a55..b8340cfb 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -132,8 +132,34 @@ The Prebid provider extracts metadata from the Prebid Server response and attach | `debug` | `ext.debug` | Prebid Server debug payload (httpcalls, resolvedrequest) | | `bidstatus` | `ext.prebid.bidstatus` | Per-bid status from every invited bidder | +### Upstream HTTP errors + +When Prebid Server returns a non-2xx status, the provider detail always includes a safe error classification, HTTP status, and generic message: + +```json +{ + "error_type": "upstream_http", + "http_status": 400, + "message": "Prebid Server returned HTTP 400" +} +``` + +With `debug = true`, Trusted Server also extracts the first error message from allowlisted JSON fields (`message`, `error`, `errors`, `detail`, `title`, or `reason`) or a plain-text response. The message is normalized to one line and limited to 500 characters: + +```json +{ + "error_type": "upstream_http", + "http_status": 400, + "message": "Prebid Server returned HTTP 400", + "upstream_message": "Invalid request: imp[0] has no valid bidders", + "upstream_message_truncated": false +} +``` + +HTML error pages and unrecognized JSON payloads are not exposed. Debug mode also writes a bounded error-body preview to `tslog`, correlated with the auction ID. + ::: warning -Enabling `debug` increases response sizes and adds overhead. Use it in development or when diagnosing auction issues — not in production. +Enabling `debug` increases response sizes and adds overhead. It can also expose bounded upstream diagnostics to `/auction` callers and logs. Use it temporarily when diagnosing auction issues, not as a permanent production setting. ::: ### Test mode vs. debug From 5e319b6a924523f5a40f62130ec4f936d08f2a37 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 16:57:56 -0500 Subject: [PATCH 2/8] Use HTTP status error type for Prebid failures --- crates/trusted-server-core/src/integrations/prebid.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 2e765538..9366512e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -18,6 +18,7 @@ use serde_json::Value as Json; use url::{Url, Url as ParsedUrl}; use validator::{Validate, ValidationError}; +use crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS; use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, @@ -62,7 +63,6 @@ const ZONE_KEY: &str = "zone"; /// Default currency for `OpenRTB` bid floors and responses. const DEFAULT_CURRENCY: &str = "USD"; -const PREBID_ERROR_TYPE_UPSTREAM_HTTP: &str = "upstream_http"; const PREBID_PUBLIC_ERROR_MESSAGE_CHARS: usize = 500; const PREBID_ERROR_BODY_PREVIEW_CHARS: usize = 1000; const PREBID_ERROR_BODY_PREVIEW_BYTES: usize = PREBID_ERROR_BODY_PREVIEW_CHARS * 4; @@ -2010,10 +2010,7 @@ impl PrebidAuctionProvider { let status_code = status.as_u16(); let mut auction_response = AuctionResponse::error(PREBID_INTEGRATION_ID, response_time_ms) - .with_metadata( - "error_type", - serde_json::json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP), - ) + .with_metadata("error_type", serde_json::json!(ERROR_TYPE_HTTP_STATUS)) .with_metadata("http_status", serde_json::json!(status_code)) .with_metadata( "message", @@ -5143,7 +5140,7 @@ external_bundle_sri = "sha384-AAAA" ); assert_eq!( auction_response.metadata["error_type"], - json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP) + json!(ERROR_TYPE_HTTP_STATUS) ); assert_eq!(auction_response.metadata["http_status"], json!(400)); assert_eq!( From ba09f9c0987fc7ac8a8ff4268b5844d916bb08fa Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Jul 2026 10:38:20 -0500 Subject: [PATCH 3/8] Fix CI lint failures --- .../src/integrations/prebid.rs | 27 ++++++++++--------- crates/trusted-server-core/src/publisher.rs | 6 +++-- docs/guide/integrations/prebid.md | 4 +-- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 9366512e..8f445a56 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2017,19 +2017,20 @@ impl PrebidAuctionProvider { serde_json::json!(format!("Prebid Server returned HTTP {status_code}")), ); - if self.config.debug { - if let Some(message) = - extract_prebid_error_message(&body_bytes, content_type.as_deref()) - { - auction_response.metadata.insert( - "upstream_message".to_string(), - serde_json::json!(message.text), - ); - auction_response.metadata.insert( - "upstream_message_truncated".to_string(), - serde_json::json!(message.truncated), - ); - } + let upstream_message = self + .config + .debug + .then(|| extract_prebid_error_message(&body_bytes, content_type.as_deref())) + .flatten(); + if let Some(message) = upstream_message { + auction_response.metadata.insert( + "upstream_message".to_string(), + serde_json::json!(message.text), + ); + auction_response.metadata.insert( + "upstream_message_truncated".to_string(), + serde_json::json!(message.truncated), + ); } return Ok(auction_response); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 7f94a6b0..85abef61 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2636,11 +2636,13 @@ mod tests { }; let state = Arc::new(Mutex::new(Some("BIDS_SCRIPT".to_string()))); prepend_auction_debug_comment("stream", &result, &state); - state + let comment = state .lock() .expect("should lock state") .clone() - .expect("should have comment") + .expect("should have comment"); + drop(state); + comment } #[test] diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index b8340cfb..4bc73c0c 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -138,7 +138,7 @@ When Prebid Server returns a non-2xx status, the provider detail always includes ```json { - "error_type": "upstream_http", + "error_type": "http_status", "http_status": 400, "message": "Prebid Server returned HTTP 400" } @@ -148,7 +148,7 @@ With `debug = true`, Trusted Server also extracts the first error message from a ```json { - "error_type": "upstream_http", + "error_type": "http_status", "http_status": 400, "message": "Prebid Server returned HTTP 400", "upstream_message": "Invalid request: imp[0] has no valid bidders", From 7af7598f2e17a49c54951df33cfa4f93ef93c76e Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Jul 2026 17:06:55 -0500 Subject: [PATCH 4/8] Preserve Prebid HTTP error classification --- .../src/integrations/prebid.rs | 270 +++++++++++++++--- 1 file changed, 225 insertions(+), 45 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 8f445a56..9a2c1d65 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -4,15 +4,15 @@ use std::time::Duration; use async_trait::async_trait; use base64::{ + Engine as _, engine::general_purpose::{ STANDARD as BASE64_STANDARD, STANDARD_NO_PAD as BASE64_STANDARD_NO_PAD, }, - Engine as _, }; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::header::HeaderValue; -use http::{header, Method, StatusCode}; +use http::{Method, StatusCode, header}; use serde::{Deserialize, Serialize}; use serde_json::Value as Json; use url::{Url, Url as ParsedUrl}; @@ -24,26 +24,25 @@ use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, }; use crate::consent_config::ConsentForwardingMode; -use crate::cookies::{strip_cookies, CONSENT_COOKIE_NAMES}; +use crate::cookies::{CONSENT_COOKIE_NAMES, strip_cookies}; use crate::error::TrustedServerError; use crate::http_util::RequestInfo; use crate::integrations::{ - collect_response_bounded, ensure_integration_backend_with_timeout, - predict_integration_backend_name, AttributeRewriteAction, IntegrationAttributeContext, - IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationHeadInjector, - IntegrationHtmlContext, IntegrationProxy, IntegrationRegistration, - UPSTREAM_RTB_MAX_RESPONSE_BYTES, + AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, + IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, + IntegrationRegistration, UPSTREAM_RTB_MAX_RESPONSE_BYTES, collect_response_bounded, + ensure_integration_backend_with_timeout, predict_integration_backend_name, }; use crate::openrtb::{ - to_openrtb_i32, Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, - ImpStoredRequest, OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, - RequestExt, Site, ToExt, TrustedServerExt, User, UserExt, + Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, ImpStoredRequest, + OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, RequestExt, Site, ToExt, + TrustedServerExt, User, UserExt, to_openrtb_i32, }; use crate::platform::{ PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, }; -use crate::proxy::{is_host_allowed, proxy_request, ProxyRequestConfig}; -use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; +use crate::proxy::{ProxyRequestConfig, is_host_allowed, proxy_request}; +use crate::request_signing::{RequestSigner, SIGNING_VERSION, SigningParams}; use crate::settings::{IntegrationConfig, Settings}; const PREBID_INTEGRATION_ID: &str = "prebid"; @@ -1982,28 +1981,40 @@ impl PrebidAuctionProvider { .await .change_context(TrustedServerError::Prebid { message: "Failed to read Prebid response body".to_string(), - })?; + }); if !status.is_success() { let auction_id = auction_id.unwrap_or(""); log::warn!("Prebid auction {auction_id:?} returned non-success status: {status}"); + let body_bytes = match body_bytes { + Ok(body_bytes) => Some(body_bytes), + Err(error) => { + log::warn!( + "Prebid auction {auction_id:?} failed to read non-success response body: {error:?}" + ); + None + } + }; + if self.config.debug { - match prebid_body_preview(&body_bytes) { - Some(preview) => { - let truncation = if preview.truncated { - " (truncated)" - } else { - "" - }; - log::warn!( - "Prebid auction {auction_id:?} error response body preview{truncation}: {}", - preview.text - ); + if let Some(body_bytes) = body_bytes.as_deref() { + match prebid_body_preview(body_bytes) { + Some(preview) => { + let truncation = if preview.truncated { + " (truncated)" + } else { + "" + }; + log::warn!( + "Prebid auction {auction_id:?} error response body preview{truncation}: {}", + preview.text + ); + } + None => log::warn!( + "Prebid auction {auction_id:?} returned an empty error response body" + ), } - None => log::warn!( - "Prebid auction {auction_id:?} returned an empty error response body" - ), } } @@ -2017,11 +2028,13 @@ impl PrebidAuctionProvider { serde_json::json!(format!("Prebid Server returned HTTP {status_code}")), ); - let upstream_message = self - .config - .debug - .then(|| extract_prebid_error_message(&body_bytes, content_type.as_deref())) - .flatten(); + let upstream_message = if self.config.debug { + body_bytes.as_deref().and_then(|body_bytes| { + extract_prebid_error_message(body_bytes, content_type.as_deref()) + }) + } else { + None + }; if let Some(message) = upstream_message { auction_response.metadata.insert( "upstream_message".to_string(), @@ -2036,6 +2049,7 @@ impl PrebidAuctionProvider { return Ok(auction_response); } + let body_bytes = body_bytes?; let response_json: Json = serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { message: "Failed to parse Prebid response".to_string(), @@ -2411,6 +2425,8 @@ mod tests { use std::sync::Arc; use super::*; + use crate::auction::formats::convert_to_openrtb_response; + use crate::auction::orchestrator::OrchestrationResult; use crate::auction::test_support::create_test_auction_context as shared_test_auction_context; use crate::auction::types::{ AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, @@ -2418,13 +2434,13 @@ mod tests { use crate::consent::{ConsentContext, ConsentSource}; use crate::geo::GeoInfo; - use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; + use crate::html_processor::{HtmlProcessorConfig, create_html_processor}; use crate::integrations::{ AttributeRewriteAction, IntegrationDocumentState, IntegrationRegistry, }; use crate::platform::test_support::{ - build_services_with_http_client, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, - StubHttpClient, + NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, StubHttpClient, + build_services_with_http_client, }; use crate::platform::{ ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformError, RuntimeServices, @@ -2433,6 +2449,7 @@ mod tests { use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; use crate::test_support::tests::create_test_settings; use base64::engine::general_purpose::STANDARD as TEST_BASE64_STANDARD; + use bytes::Bytes; use http::Method; use serde_json::json; use std::collections::HashMap; @@ -2559,6 +2576,14 @@ mod tests { status: StatusCode, content_type: Option<&str>, body: impl Into>, + ) -> PlatformResponse { + prebid_platform_response_with_body(status, content_type, EdgeBody::from(body.into())) + } + + fn prebid_platform_response_with_body( + status: StatusCode, + content_type: Option<&str>, + body: EdgeBody, ) -> PlatformResponse { let mut builder = http::Response::builder().status(status); if let Some(content_type) = content_type { @@ -2567,7 +2592,7 @@ mod tests { PlatformResponse::new( builder - .body(EdgeBody::from(body.into())) + .body(body) .expect("should build Prebid platform response"), ) } @@ -2880,9 +2905,11 @@ script_patterns = ["/prebid.js", "/custom/prebid.min.js"] assert_eq!(config.script_patterns.len(), 2); assert!(config.script_patterns.contains(&"/prebid.js".to_string())); - assert!(config - .script_patterns - .contains(&"/custom/prebid.min.js".to_string())); + assert!( + config + .script_patterns + .contains(&"/custom/prebid.min.js".to_string()) + ); } #[test] @@ -2897,9 +2924,11 @@ server_url = "https://prebid.example" assert!(!config.script_patterns.is_empty()); assert!(config.script_patterns.contains(&"/prebid.js".to_string())); - assert!(config - .script_patterns - .contains(&"/prebid.min.js".to_string())); + assert!( + config + .script_patterns + .contains(&"/prebid.min.js".to_string()) + ); } #[test] @@ -5188,6 +5217,158 @@ external_bundle_sri = "sha384-AAAA" ); } + fn assert_oversized_http_error_is_classified( + auction_response: &AuctionResponse, + expected_status: u16, + ) { + assert_eq!( + auction_response.status, + crate::auction::types::BidStatus::Error + ); + assert_eq!( + auction_response.metadata["error_type"], + json!(ERROR_TYPE_HTTP_STATUS) + ); + assert_eq!( + auction_response.metadata["http_status"], + json!(expected_status) + ); + assert_eq!( + auction_response.metadata["message"], + json!(format!("Prebid Server returned HTTP {expected_status}")) + ); + assert!( + !auction_response.metadata.contains_key("upstream_message"), + "should not expose a body that could not be collected safely" + ); + } + + #[test] + fn oversized_once_non_success_prebid_response_preserves_http_classification() { + let mut config = base_config(); + config.debug = true; + let provider = PrebidAuctionProvider::new(config); + let response = prebid_platform_response( + StatusCode::BAD_GATEWAY, + Some("text/plain"), + vec![b'x'; UPSTREAM_RTB_MAX_RESPONSE_BYTES + 1], + ); + + let auction_response = futures::executor::block_on(provider.parse_response(response, 42)) + .expect("should preserve HTTP classification for oversized one-shot body"); + + assert_oversized_http_error_is_classified( + &auction_response, + StatusCode::BAD_GATEWAY.as_u16(), + ); + } + + #[test] + fn oversized_streaming_non_success_prebid_response_preserves_http_classification() { + let mut config = base_config(); + config.debug = true; + let provider = PrebidAuctionProvider::new(config); + let stream = futures::stream::iter([ + Bytes::from(vec![b'x'; UPSTREAM_RTB_MAX_RESPONSE_BYTES]), + Bytes::from_static(b"x"), + ]); + let response = prebid_platform_response_with_body( + StatusCode::SERVICE_UNAVAILABLE, + Some("text/plain"), + EdgeBody::stream(stream), + ); + + let auction_response = futures::executor::block_on(provider.parse_response(response, 42)) + .expect("should preserve HTTP classification for oversized streaming body"); + + assert_oversized_http_error_is_classified( + &auction_response, + StatusCode::SERVICE_UNAVAILABLE.as_u16(), + ); + } + + fn public_prebid_error_metadata(debug: bool, upstream_message: &str) -> Json { + let mut config = base_config(); + config.debug = debug; + let provider = PrebidAuctionProvider::new(config); + let body = serde_json::to_vec(&json!({ "message": upstream_message })) + .expect("should serialize upstream error response"); + let response = + prebid_platform_response(StatusCode::BAD_REQUEST, Some("application/json"), body); + let provider_response = futures::executor::block_on(provider.parse_response(response, 42)) + .expect("should classify upstream HTTP error"); + let result = OrchestrationResult { + provider_responses: vec![provider_response], + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 42, + metadata: HashMap::new(), + }; + let response = convert_to_openrtb_response( + &result, + &make_settings(), + &create_test_auction_request(), + false, + ) + .expect("should serialize public auction response"); + let body = response.into_body().into_bytes().unwrap_or_default(); + let response_json: Json = + serde_json::from_slice(&body).expect("should parse public auction response"); + + response_json["ext"]["orchestrator"]["provider_details"][0]["metadata"].clone() + } + + #[test] + fn public_auction_response_gates_and_bounds_prebid_upstream_message() { + let trailing_marker = "trailing-private-marker"; + let upstream_message = format!( + " \nprivate-details\t{}\n{trailing_marker}", + "x".repeat(PREBID_PUBLIC_ERROR_MESSAGE_CHARS) + ); + + let no_debug_metadata = public_prebid_error_metadata(false, &upstream_message); + assert_eq!( + no_debug_metadata["error_type"], + json!(ERROR_TYPE_HTTP_STATUS) + ); + assert_eq!(no_debug_metadata["http_status"], json!(400)); + assert_eq!( + no_debug_metadata["message"], + json!("Prebid Server returned HTTP 400") + ); + assert!( + no_debug_metadata.get("upstream_message").is_none(), + "should hide upstream text in the public response when debug is disabled" + ); + assert!( + !no_debug_metadata.to_string().contains("private-details"), + "should not serialize upstream-only text when debug is disabled" + ); + + let debug_metadata = public_prebid_error_metadata(true, &upstream_message); + let expected_message = format!( + "private-details {}", + "x".repeat(PREBID_PUBLIC_ERROR_MESSAGE_CHARS - "private-details ".chars().count()) + ); + assert_eq!( + debug_metadata["upstream_message"], + json!(expected_message), + "should serialize only the normalized, bounded upstream message" + ); + assert_eq!(debug_metadata["upstream_message_truncated"], json!(true)); + let serialized_debug_metadata = debug_metadata.to_string(); + assert!( + !serialized_debug_metadata.contains(trailing_marker), + "should omit upstream text beyond the public message bound" + ); + assert!( + !debug_metadata["upstream_message"] + .as_str() + .is_some_and(|message| message.contains(['\n', '\t'])), + "should normalize control characters in the public response" + ); + } + fn make_auction_request(slots: Vec) -> AuctionRequest { AuctionRequest { id: "test-auction-1".to_string(), @@ -6070,8 +6251,7 @@ set = { placementId = "explicit_header" } let actual = engine.rules.iter().map(rule_signature).collect::>(); assert_eq!( - actual, - expected, + actual, expected, "compatibility rules should compile in sorted matcher order on iteration {iteration}" ); } From ec9d64aa948f11e44652a180c7a0ce7d5468d71a Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 17 Jul 2026 11:52:46 -0500 Subject: [PATCH 5/8] Fix Prebid clippy warnings --- .../src/integrations/prebid.rs | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 9a2c1d65..b6b686ed 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1291,16 +1291,16 @@ impl CompiledBidParamOverrideRule { } fn matches(&self, facts: BidParamOverrideFacts<'_>) -> bool { - if let Some(expected_bidder) = self.bidder.as_deref() { - if expected_bidder != facts.bidder { - return false; - } + if let Some(expected_bidder) = self.bidder.as_deref() + && expected_bidder != facts.bidder + { + return false; } - if let Some(expected_zone) = self.zone.as_deref() { - if facts.zone != Some(expected_zone) { - return false; - } + if let Some(expected_zone) = self.zone.as_deref() + && facts.zone != Some(expected_zone) + { + return false; } true @@ -1392,11 +1392,11 @@ fn copy_request_headers( } } - if let Some(ip) = client_ip { - if let Ok(value) = HeaderValue::from_str(&ip.to_string()) { - to.headers_mut() - .insert(header::HeaderName::from_static("x-forwarded-for"), value); - } + if let Some(ip) = client_ip + && let Ok(value) = HeaderValue::from_str(&ip.to_string()) + { + to.headers_mut() + .insert(header::HeaderName::from_static("x-forwarded-for"), value); } let Some(cookie_value) = from.headers().get(header::COOKIE) else { @@ -1997,24 +1997,24 @@ impl PrebidAuctionProvider { } }; - if self.config.debug { - if let Some(body_bytes) = body_bytes.as_deref() { - match prebid_body_preview(body_bytes) { - Some(preview) => { - let truncation = if preview.truncated { - " (truncated)" - } else { - "" - }; - log::warn!( - "Prebid auction {auction_id:?} error response body preview{truncation}: {}", - preview.text - ); - } - None => log::warn!( - "Prebid auction {auction_id:?} returned an empty error response body" - ), + if self.config.debug + && let Some(body_bytes) = body_bytes.as_deref() + { + match prebid_body_preview(body_bytes) { + Some(preview) => { + let truncation = if preview.truncated { + " (truncated)" + } else { + "" + }; + log::warn!( + "Prebid auction {auction_id:?} error response body preview{truncation}: {}", + preview.text + ); } + None => log::warn!( + "Prebid auction {auction_id:?} returned an empty error response body" + ), } } From ebebbb298263d0b2c69200895ff255a9a2534e79 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 14:07:32 -0500 Subject: [PATCH 6/8] Preserve Prebid ad units across GPT refreshes --- .../lib/src/integrations/prebid/index.ts | 239 +++++++- .../test/integrations/prebid/index.test.ts | 509 ++++++++++++++++++ 2 files changed, 731 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index be839d8f..65932d5b 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -228,6 +228,17 @@ type TrustedServerAdUnit = { mediaTypes?: { banner?: TrustedServerBanner }; bids?: TrustedServerBid[]; }; +type ClientSideBidSnapshot = { bidder: string; params: Record }; +type PublisherAdUnitSnapshot = { + bidderParams: Record>; + clientSideBids: ClientSideBidSnapshot[]; + zone?: string; +}; +type PublisherDeliveryContext = { remainingCodes: Set }; + +let publisherAdUnitSnapshots = new Map(); +let syntheticRefreshAdUnits = new WeakSet(); +const activePublisherDeliveryContexts: PublisherDeliveryContext[] = []; type TrustedServerBidRequest = { adUnitCode?: string; code?: string; @@ -363,6 +374,17 @@ function firstTargetingValue(values: string[] | undefined): string | undefined { * code in order and return the first matching ad unit, so container-backed slots * still recover the publisher's configured params and bidders. */ +function findRefreshSnapshot( + candidateCodes: Array +): PublisherAdUnitSnapshot | undefined { + for (const code of candidateCodes) { + if (!code) continue; + const snapshot = publisherAdUnitSnapshots.get(code); + if (snapshot) return snapshot; + } + return undefined; +} + function findRefreshAdUnit( candidateCodes: Array ): TrustedServerAdUnit | undefined { @@ -375,6 +397,89 @@ function findRefreshAdUnit( return undefined; } +function copyParamValue(value: unknown, seen = new WeakMap()): unknown { + if (Array.isArray(value)) { + const existing = seen.get(value); + if (existing) return existing; + const copy: unknown[] = []; + seen.set(value, copy); + value.forEach((entry) => copy.push(copyParamValue(entry, seen))); + return copy; + } + + if (value && typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return value; + + const existing = seen.get(value); + if (existing) return existing; + const copy = Object.create(prototype) as Record; + seen.set(value, copy); + for (const [key, entry] of Object.entries(value)) { + Object.defineProperty(copy, key, { + value: copyParamValue(entry, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } + return copy; + } + + return value; +} + +function copyParams(params: Record | undefined): Record { + return copyParamValue(params ?? {}) as Record; +} + +function foldedBidderParams( + bid: TrustedServerBid | undefined +): Record> { + const folded = (bid?.params?.[BIDDER_PARAMS_KEY] ?? {}) as Record< + string, + Record + >; + return Object.fromEntries( + Object.entries(folded).map(([bidder, params]) => [bidder, copyParams(params)]) + ); +} + +function capturePublisherAdUnitSnapshot( + unit: TrustedServerAdUnit, + clientSideBidders: Set +): PublisherAdUnitSnapshot | undefined { + if (typeof unit.code !== 'string' || unit.code.length === 0) return undefined; + + const rawBidderParams: Record> = {}; + const clientSideBids: ClientSideBidSnapshot[] = []; + let existingTsBid: TrustedServerBid | undefined; + + const bids = Array.isArray(unit.bids) ? unit.bids : []; + for (const bid of bids) { + if (!bid?.bidder) continue; + if (bid.bidder === ADAPTER_CODE) { + existingTsBid ??= bid; + continue; + } + if (clientSideBidders.has(bid.bidder)) { + clientSideBids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + continue; + } + rawBidderParams[bid.bidder] = copyParams(bid.params); + } + + const bidderParams = + Object.keys(rawBidderParams).length > 0 ? rawBidderParams : foldedBidderParams(existingTsBid); + const zone = unit.mediaTypes?.banner?.name; + + return { + bidderParams, + clientSideBids, + ...(zone ? { zone } : {}), + }; +} + /** * Collect the configured client-side bidder entries for a refreshing slot. * @@ -389,6 +494,14 @@ function findRefreshAdUnit( function clientSideBidsForRefresh( candidateCodes: Array ): Array<{ bidder: string; params: Record }> { + const snapshot = findRefreshSnapshot(candidateCodes); + if (snapshot) { + return snapshot.clientSideBids.map((bid) => ({ + bidder: bid.bidder, + params: copyParams(bid.params), + })); + } + const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); if (clientSideBidders.size === 0) return []; @@ -398,7 +511,7 @@ function clientSideBidsForRefresh( const bids: Array<{ bidder: string; params: Record }> = []; for (const bid of match.bids) { if (bid?.bidder && clientSideBidders.has(bid.bidder)) { - bids.push({ bidder: bid.bidder, params: bid.params ?? {} }); + bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); } } return bids; @@ -420,6 +533,13 @@ function clientSideBidsForRefresh( function serverSideBidderParamsForRefresh( candidateCodes: Array ): Record> { + const snapshot = findRefreshSnapshot(candidateCodes); + if (snapshot) { + return Object.fromEntries( + Object.entries(snapshot.bidderParams).map(([bidder, params]) => [bidder, copyParams(params)]) + ); + } + const match = findRefreshAdUnit(candidateCodes); if (!match?.bids) return {}; @@ -456,6 +576,50 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } +function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { + const index = activePublisherDeliveryContexts.lastIndexOf(context); + if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); +} + +function consumeBarePublisherDeliveryContext(): boolean { + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; + if (context.remainingCodes.size === 0) continue; + context.remainingCodes.clear(); + return true; + } + return false; +} + +function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { + if (targetSlots.length === 0) return false; + + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; + const coveredCodes: string[] = []; + let allCovered = true; + + for (const slot of targetSlots) { + const injectedSlot = findInjectedSlotForRefresh(slot); + const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + const coveredCode = candidates.find( + (code): code is string => !!code && context.remainingCodes.has(code) + ); + if (!coveredCode) { + allCovered = false; + break; + } + coveredCodes.push(coveredCode); + } + + if (!allCovered) continue; + coveredCodes.forEach((code) => context.remainingCodes.delete(code)); + return true; + } + + return false; +} + function collectAuctionEids(): AuctionEid[] | undefined { if (typeof pbjs.getUserIdsAsEids !== 'function') { return undefined; @@ -492,6 +656,10 @@ function collectAuctionEids(): AuctionEid[] | undefined { * 2. `config` argument — explicit overrides from the publisher's JS */ export function installPrebidNpm(config?: Partial): typeof pbjs { + publisherAdUnitSnapshots = new Map(); + syntheticRefreshAdUnits = new WeakSet(); + activePublisherDeliveryContexts.length = 0; + const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { endpoint: config?.endpoint, @@ -563,9 +731,20 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const opts = requestObj || {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; + const isSyntheticRefresh = + adUnits.length > 0 && adUnits.every((unit) => syntheticRefreshAdUnits.has(unit)); + const publisherAdUnitCodes = new Set(); // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { + if (!syntheticRefreshAdUnits.has(unit)) { + const snapshot = capturePublisherAdUnitSnapshot(unit, clientSideBidders); + if (snapshot && unit.code) { + publisherAdUnitSnapshots.set(unit.code, snapshot); + publisherAdUnitCodes.add(unit.code); + } + } + if (!Array.isArray(unit.bids)) { unit.bids = []; } @@ -649,8 +828,22 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const originalBidsBack = opts.bidsBackHandler; opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); - if (typeof originalBidsBack === 'function') { - originalBidsBack.apply(this, args); + if (typeof originalBidsBack !== 'function') return; + if (isSyntheticRefresh || publisherAdUnitCodes.size === 0) { + originalBidsBack.apply(this, args as Parameters); + return; + } + + const context: PublisherDeliveryContext = { + remainingCodes: new Set(publisherAdUnitCodes), + }; + // Delivery attribution is intentionally synchronous and ends as soon as + // the publisher's original callback returns. + activePublisherDeliveryContexts.push(context); + try { + originalBidsBack.apply(this, args as Parameters); + } finally { + removePublisherDeliveryContext(context); } }; @@ -734,6 +927,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { const originalRefresh = pubads.refresh.bind(pubads); pubads.refresh = function (slots?: unknown[], opts?: unknown) { + // For bare refresh() calls (no slots arg), get all registered slots from GPT + // so we can auction the same concrete slot list and avoid stale targeting. + const targetSlots = ( + slots ?? + (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? + [] + ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); + // One-shot bypass for adInit()'s internal refresh: that refresh delivers // freshly applied server-side targeting to GAM and must not be turned // into a client-side auction (which would clear the TS targeting). @@ -743,13 +944,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - // For bare refresh() calls (no slots arg), get all registered slots from GPT - // so we can auction the same concrete slot list and avoid stale targeting. - const targetSlots = ( - slots ?? - (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? - [] - ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); + const isExplicitSlotList = slots !== undefined; + const hasOnlyValidExplicitSlots = !isExplicitSlotList || targetSlots.length === slots.length; + const isPublisherDeliveryRefresh = isExplicitSlotList + ? hasOnlyValidExplicitSlots && consumeExplicitPublisherDeliveryContext(targetSlots) + : consumeBarePublisherDeliveryContext(); + if (isPublisherDeliveryRefresh) { + return originalRefresh(slots, opts); + } if (!targetSlots.length) { return originalRefresh(slots, opts); @@ -759,8 +961,16 @@ export function installRefreshHandler(timeoutMs = 1500): void { const adUnits = targetSlots.map((slot) => { const injectedSlot = findInjectedSlotForRefresh(slot); + const code = refreshSlotElementId(slot) ?? 'refresh-slot'; + // A TS-owned slot may be defined on `${div_id}-container`, so the GPT + // element id used as the synthetic refresh code can differ from the + // inner `div_id` the publisher keyed their ad unit by. Recover from both. + const candidateCodes = [code, injectedSlot?.div_id]; + const snapshot = findRefreshSnapshot(candidateCodes); const zone = - injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)); + injectedSlot?.targeting?.[ZONE_KEY] ?? + firstTargetingValue(slot.getTargeting?.(ZONE_KEY)) ?? + snapshot?.zone; const banner: TrustedServerBanner = { sizes: bannerSizesFromInjectedSlot(injectedSlot) ?? @@ -768,12 +978,6 @@ export function installRefreshHandler(timeoutMs = 1500): void { DEFAULT_REFRESH_SIZES, ...(zone ? { name: zone } : {}), }; - - const code = refreshSlotElementId(slot) ?? 'refresh-slot'; - // A TS-owned slot may be defined on `${div_id}-container`, so the GPT - // element id used as the synthetic refresh code can differ from the - // inner `div_id` the publisher keyed their ad unit by. Recover from both. - const candidateCodes = [code, injectedSlot?.div_id]; const tsParams: Record = zone ? { [ZONE_KEY]: zone } : {}; // Carry the publisher's inline server-side (PBS) bidder params captured // on the initial ad unit so refresh/scroll auctions don't drop them. @@ -796,6 +1000,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { // unrelated GPT slots whose targeting this wrapper only cleared for // `targetSlots` — leaving their next request dependent on stale state. const refreshAdUnitCodes = adUnits.map((unit) => unit.code); + adUnits.forEach((unit) => syntheticRefreshAdUnits.add(unit)); pbjs.requestBids({ adUnits, bidsBackHandler: () => { diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 9ad7945c..6435d970 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -617,6 +617,15 @@ describe('prebid/installPrebidNpm', () => { expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); }); + it('normalizes a truthy non-array bids value without throwing', () => { + const pbjs = installPrebidNpm(); + const adUnits = [{ code: 'example-malformed-slot', bids: { malformed: true } }] as any[]; + + expect(() => pbjs.requestBids({ adUnits } as any)).not.toThrow(); + + expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); + }); + it('includes zone from mediaTypes.banner.name in trustedServer params', () => { const pbjs = installPrebidNpm(); @@ -1352,6 +1361,506 @@ describe('prebid/installRefreshHandler', () => { }); }); +describe('prebid publisher snapshots and delivery refreshes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRequestBids.mockReset(); + mockPbjs.requestBids = mockRequestBids; + mockPbjs.adUnits = []; + mockGetUserIdsAsEids.mockReset(); + mockGetUserIdsAsEids.mockReturnValue([]); + mockGetBidAdapter.mockReturnValue({}); + delete (mockPbjs as any).setTargetingForGPTAsync; + delete (window as any).__tsjs_prebid; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + afterEach(() => { + delete (window as any).__tsjs_prebid; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + function installGpt(slots: any[]) { + const originalRefresh = vi.fn(); + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => slots), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + installRefreshHandler(640); + return { originalRefresh, pubads }; + } + + function refreshAdUnitFromLastRequest(): any { + const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; + return lastCall?.[0]?.adUnits?.[0]; + } + + it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const runtimeInstance = 'example-runtime-instance'; + const code = `example-slot-${runtimeInstance}`; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [{ getWidth: () => 320, getHeight: () => 100 }], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const firstParams = { placement: 'first' }; + const effectiveParams = { placement: 'effective' }; + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, + bids: [ + { bidder: 'exampleServer', params: firstParams }, + { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, + { bidder: 'exampleServer', params: effectiveParams }, + { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, + ], + }, + ], + } as any); + effectiveParams.placement = 'changed-after-auction'; + + pubads.refresh([slot]); + + expect(mockPbjs.adUnits).toEqual([]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(refreshAdUnitFromLastRequest()).toEqual({ + code, + mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { exampleServer: { placement: 'effective' } }, + zone: 'example-zone', + }, + }, + { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, + { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, + ], + }); + }); + + it('isolates nested bidder-param objects and arrays from later publisher mutation', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const code = 'example-nested-params-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const serverParams = { + placement: { + rules: [{ label: 'original-rule' }], + sizes: [300, 250], + }, + }; + const browserParams = { + groups: [{ values: ['original-value'] }], + }; + + pbjs.requestBids({ + adUnits: [ + { + code, + bids: [ + { bidder: 'exampleServer', params: serverParams }, + { bidder: 'exampleBrowser', params: browserParams }, + ], + }, + ], + } as any); + serverParams.placement.rules[0].label = 'changed-rule'; + serverParams.placement.sizes.push(999); + browserParams.groups[0].values[0] = 'changed-value'; + + pubads.refresh([slot]); + + const expectedBids = [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + exampleServer: { + placement: { + rules: [{ label: 'original-rule' }], + sizes: [300, 250], + }, + }, + }, + }, + }, + { + bidder: 'exampleBrowser', + params: { groups: [{ values: ['original-value'] }] }, + }, + ]; + const firstRefreshBids = refreshAdUnitFromLastRequest().bids; + expect(firstRefreshBids).toEqual(expectedBids); + + firstRefreshBids[0].params.bidderParams.exampleServer.placement.rules[0].label = + 'changed-refresh-rule'; + firstRefreshBids[0].params.bidderParams.exampleServer.placement.sizes.push(777); + firstRefreshBids[1].params.groups[0].values[0] = 'changed-refresh-value'; + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); + }); + + it('keeps snapshots across repeated synthetic refreshes and overwrites newer publisher config', () => { + const code = 'example-dynamic-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone-one', sizes: [[300, 250]] } }, + bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], + }, + ], + } as any); + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'one' } }, + zone: 'example-zone-one', + }); + + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'one' } }, + zone: 'example-zone-one', + }); + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone-two', sizes: [[300, 250]] } }, + bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], + }, + ], + } as any); + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'two' } }, + zone: 'example-zone-two', + }); + }); + + it('does not cross-contaminate dynamic-code snapshots and retains the global fallback', () => { + const slotOne = { + getSlotElementId: () => 'example-code-one', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const slotTwo = { + getSlotElementId: () => 'example-code-two', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const globalSlot = { + getSlotElementId: () => 'example-global-code', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slotOne, slotTwo, globalSlot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { + code: 'example-code-one', + bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], + }, + { + code: 'example-code-two', + bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], + }, + ], + } as any); + mockPbjs.adUnits = [ + { + code: 'example-global-code', + bids: [{ bidder: 'exampleFallback', params: { placement: 'global' } }], + }, + ]; + + pubads.refresh([slotOne]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: 'one' }, + }); + pubads.refresh([slotTwo]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: 'two' }, + }); + pubads.refresh([globalSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleFallback: { placement: 'global' }, + }); + }); + + it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { + const slotOne = { + getSlotElementId: () => 'example-covered-one', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const slotTwo = { + getSlotElementId: () => 'example-covered-two-container', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + (window as any).tsjs = { + adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], + }; + const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-covered-one', bids: [{ bidder: 'exampleServer', params: {} }] }, + { code: 'example-covered-two', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pubads.refresh([slotOne]); + pubads.refresh([slotTwo]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slotOne.clearTargeting).not.toHaveBeenCalled(); + expect(slotTwo.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slotOne], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); + }); + + it('bypasses a bare delivery refresh even when GPT includes a GAM-only extra slot', () => { + const coveredSlot = { + getSlotElementId: () => 'example-covered', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh(), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); + expect(gamOnlySlot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); + }); + + it('keeps explicit unrelated and mixed delivery lists on the synthetic path', () => { + const coveredSlot = { + getSlotElementId: () => 'example-covered', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const unrelatedSlot = { + getSlotElementId: () => 'example-unrelated', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + pubads.refresh([unrelatedSlot]); + pubads.refresh([coveredSlot, unrelatedSlot]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-unrelated', + ]); + expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-covered', + 'example-unrelated', + ]); + expect(coveredSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); + expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); + }); + + it('treats a microtask refresh after publisher delivery as an independent auction', async () => { + const slot = { + getSlotElementId: () => 'example-deferred-refresh', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + let deferredRefresh: Promise | undefined; + + pbjs.requestBids({ + adUnits: [ + { code: 'example-deferred-refresh', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + deferredRefresh = Promise.resolve().then(() => pubads.refresh([slot])); + }, + } as any); + await deferredRefresh; + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { + const outerSlot = { + getSlotElementId: () => 'example-outer-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const innerSlot = { + getSlotElementId: () => 'example-inner-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pbjs.requestBids({ + adUnits: [ + { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh([innerSlot]), + } as any); + pubads.refresh([outerSlot]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [outerSlot], undefined); + }); + + it('cleans delivery context after a publisher callback throws', () => { + const slot = { + getSlotElementId: () => 'example-throwing-callback', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + expect(() => + pbjs.requestBids({ + adUnits: [ + { + code: 'example-throwing-callback', + bids: [{ bidder: 'exampleServer', params: {} }], + }, + ], + bidsBackHandler: () => { + throw new Error('example callback failure'); + }, + } as any) + ).toThrow('example callback failure'); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + }); + + it('completes an internal synthetic refresh once without recursion', () => { + const slot = { + getSlotElementId: () => 'example-independent-refresh', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + installPrebidNpm(); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); +}); + describe('prebid/client-side bidders', () => { beforeEach(() => { vi.clearAllMocks(); From 83bf5bb67361b845266fca28652b34c7dc5ab26f Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 16:21:19 -0500 Subject: [PATCH 7/8] Handle deferred Prebid delivery refreshes --- .../lib/src/integrations/prebid/index.ts | 101 ++++++++-- .../test/integrations/prebid/index.test.ts | 188 +++++++++++++++++- 2 files changed, 259 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 65932d5b..e01cdd87 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -45,6 +45,7 @@ const TS_REFRESH_TARGETING_KEYS = [ 'hb_cache_host', 'hb_cache_path', ] as const; +const PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS = 1000; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -234,7 +235,12 @@ type PublisherAdUnitSnapshot = { clientSideBids: ClientSideBidSnapshot[]; zone?: string; }; -type PublisherDeliveryContext = { remainingCodes: Set }; +type PublisherDeliveryContext = { + remainingCodes: Set; + retainForTargetedRefresh: boolean; + cleanupTimer?: ReturnType; +}; +type SetTargetingForGptAsync = (...args: unknown[]) => unknown; let publisherAdUnitSnapshots = new Map(); let syntheticRefreshAdUnits = new WeakSet(); @@ -577,15 +583,32 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { + if (context.cleanupTimer !== undefined) { + clearTimeout(context.cleanupTimer); + context.cleanupTimer = undefined; + } const index = activePublisherDeliveryContexts.lastIndexOf(context); if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); } +function targetingCoversPublisherDeliveryContext( + adUnitCodes: unknown, + context: PublisherDeliveryContext +): boolean { + if (adUnitCodes === undefined) return context.remainingCodes.size > 0; + const codes = typeof adUnitCodes === 'string' ? [adUnitCodes] : adUnitCodes; + return ( + Array.isArray(codes) && + codes.some((code) => typeof code === 'string' && context.remainingCodes.has(code)) + ); +} + function consumeBarePublisherDeliveryContext(): boolean { for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { const context = activePublisherDeliveryContexts[index]; if (context.remainingCodes.size === 0) continue; context.remainingCodes.clear(); + removePublisherDeliveryContext(context); return true; } return false; @@ -594,30 +617,35 @@ function consumeBarePublisherDeliveryContext(): boolean { function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { if (targetSlots.length === 0) return false; - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - const coveredCodes: string[] = []; - let allCovered = true; - - for (const slot of targetSlots) { - const injectedSlot = findInjectedSlotForRefresh(slot); - const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + // Publishers may include GAM-only slots in the same explicit refresh that + // delivers a completed Prebid auction. Attribute the call to delivery when + // any slot is covered, while consuming only the covered codes so an + // unrelated-only refresh still follows the synthetic auction path. + const matches = new Map>(); + for (const slot of targetSlots) { + const injectedSlot = findInjectedSlotForRefresh(slot); + const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; const coveredCode = candidates.find( (code): code is string => !!code && context.remainingCodes.has(code) ); - if (!coveredCode) { - allCovered = false; - break; - } - coveredCodes.push(coveredCode); + if (!coveredCode) continue; + + const contextMatches = matches.get(context) ?? new Set(); + contextMatches.add(coveredCode); + matches.set(context, contextMatches); + break; } + } - if (!allCovered) continue; + if (matches.size === 0) return false; + for (const [context, coveredCodes] of matches) { coveredCodes.forEach((code) => context.remainingCodes.delete(code)); - return true; + if (context.remainingCodes.size === 0) removePublisherDeliveryContext(context); } - - return false; + return true; } function collectAuctionEids(): AuctionEid[] | undefined { @@ -658,7 +686,7 @@ function collectAuctionEids(): AuctionEid[] | undefined { export function installPrebidNpm(config?: Partial): typeof pbjs { publisherAdUnitSnapshots = new Map(); syntheticRefreshAdUnits = new WeakSet(); - activePublisherDeliveryContexts.length = 0; + [...activePublisherDeliveryContexts].forEach(removePublisherDeliveryContext); const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { @@ -836,14 +864,43 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const context: PublisherDeliveryContext = { remainingCodes: new Set(publisherAdUnitCodes), + retainForTargetedRefresh: false, + }; + const targetingPbjs = pbjs as unknown as { + setTargetingForGPTAsync?: SetTargetingForGptAsync; }; - // Delivery attribution is intentionally synchronous and ends as soon as - // the publisher's original callback returns. + const originalSetTargeting = targetingPbjs.setTargetingForGPTAsync; + let targetingWrapper: SetTargetingForGptAsync | undefined; + if (typeof originalSetTargeting === 'function') { + targetingWrapper = (...targetingArgs: unknown[]) => { + const result = originalSetTargeting.apply(targetingPbjs, targetingArgs); + if (targetingCoversPublisherDeliveryContext(targetingArgs[0], context)) { + context.retainForTargetedRefresh = true; + } + return result; + }; + targetingPbjs.setTargetingForGPTAsync = targetingWrapper; + } + activePublisherDeliveryContexts.push(context); try { originalBidsBack.apply(this, args as Parameters); } finally { - removePublisherDeliveryContext(context); + if (targetingWrapper && targetingPbjs.setTargetingForGPTAsync === targetingWrapper) { + targetingPbjs.setTargetingForGPTAsync = originalSetTargeting; + } + if (context.retainForTargetedRefresh && context.remainingCodes.size > 0) { + // Some publisher wrappers set targeting in bidsBackHandler, return, + // and schedule the matching GPT refresh shortly afterward. Retain + // this one-shot context only after that targeting signal, with a + // bounded expiry so a later independent refresh remains independent. + context.cleanupTimer = setTimeout( + () => removePublisherDeliveryContext(context), + PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS + ); + } else { + removePublisherDeliveryContext(context); + } } }; diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 6435d970..bdb336fc 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1694,7 +1694,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); }); - it('keeps explicit unrelated and mixed delivery lists on the synthetic path', () => { + it('keeps explicit unrelated lists synthetic and bypasses mixed delivery lists', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1721,15 +1721,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ 'example-unrelated', ]); - expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ - 'example-covered', - 'example-unrelated', - ]); - expect(coveredSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); expect(originalRefresh).toHaveBeenCalledTimes(2); @@ -1737,7 +1733,183 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); }); - it('treats a microtask refresh after publisher delivery as an independent auction', async () => { + it('bypasses an explicit delivery refresh with four covered slots and a GAM-only extra', () => { + const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ + getSlotElementId: () => `example-covered-${index}`, + getTargeting: () => [], + clearTargeting: vi.fn(), + })); + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [...coveredSlots, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: coveredSlots.map((_, index) => ({ + code: `example-covered-${index}`, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + })), + bidsBackHandler: () => pubads.refresh(refreshSlots), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('bypasses a targeted delivery refresh shortly after the publisher callback returns', () => { + vi.useFakeTimers(); + try { + const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ + getSlotElementId: () => `example-targeted-${index}`, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + })); + const gamOnlySlot = { + getSlotElementId: () => 'example-targeted-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [...coveredSlots, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + let refreshAfterCallback: (() => void) | undefined; + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + const pendingRefresh = refreshAfterCallback; + refreshAfterCallback = undefined; + if (pendingRefresh) setTimeout(pendingRefresh, 750); + }); + const pbjs = installPrebidNpm(); + const coveredCodes = coveredSlots.map((slot) => slot.getSlotElementId()); + + pbjs.requestBids({ + adUnits: coveredCodes.map((code, index) => ({ + code, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + })), + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync([gamOnlySlot.getSlotElementId(), ...coveredCodes]); + refreshAfterCallback = () => pubads.refresh(refreshSlots); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(setTargetingForGPTAsync).toHaveBeenCalledWith([ + gamOnlySlot.getSlotElementId(), + ...coveredCodes, + ]); + expect((mockPbjs as any).setTargetingForGPTAsync).toBe(setTargetingForGPTAsync); + + vi.advanceTimersByTime(750); + + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + + vi.runOnlyPendingTimers(); + pubads.refresh([coveredSlots[0]]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(coveredSlots[0].clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (mockPbjs as any).setTargetingForGPTAsync; + } + }); + + it('expires a targeted delivery context before a later event-loop task', () => { + vi.useFakeTimers(); + try { + const slot = { + getSlotElementId: () => 'example-expiring-delivery', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + (mockPbjs as any).setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-expiring-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => (pbjs as any).setTargetingForGPTAsync(['example-expiring-delivery']), + } as any); + vi.runOnlyPendingTimers(); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (mockPbjs as any).setTargetingForGPTAsync; + } + }); + + it('bypasses a mixed explicit delivery list spanning nested contexts', () => { + const outerSlot = { + getSlotElementId: () => 'example-outer-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const innerSlot = { + getSlotElementId: () => 'example-inner-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pbjs.requestBids({ + adUnits: [ + { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh(refreshSlots), + } as any); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('treats a microtask refresh without a targeting signal as an independent auction', async () => { const slot = { getSlotElementId: () => 'example-deferred-refresh', getTargeting: () => [], From fed84393802e69dd9a2331617a20771d99f1faa9 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 17 Jul 2026 11:54:55 -0500 Subject: [PATCH 8/8] Address Prebid refresh review feedback --- .../lib/src/integrations/prebid/index.ts | 359 ++++++------ .../test/integrations/prebid/index.test.ts | 542 ++++++++++++++---- 2 files changed, 603 insertions(+), 298 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index e01cdd87..f0ee3622 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -45,7 +45,8 @@ const TS_REFRESH_TARGETING_KEYS = [ 'hb_cache_host', 'hb_cache_path', ] as const; -const PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS = 1000; +const MAX_PUBLISHER_AD_UNIT_SNAPSHOTS = 256; +const MAX_PENDING_PUBLISHER_BIDS = 2048; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -235,16 +236,14 @@ type PublisherAdUnitSnapshot = { clientSideBids: ClientSideBidSnapshot[]; zone?: string; }; -type PublisherDeliveryContext = { - remainingCodes: Set; - retainForTargetedRefresh: boolean; - cleanupTimer?: ReturnType; +type PendingPublisherBid = { + adUnitCode: string; }; -type SetTargetingForGptAsync = (...args: unknown[]) => unknown; +type RemoveAdUnit = (adUnitCode?: string | string[]) => unknown; let publisherAdUnitSnapshots = new Map(); +let pendingPublisherBids = new Map(); let syntheticRefreshAdUnits = new WeakSet(); -const activePublisherDeliveryContexts: PublisherDeliveryContext[] = []; type TrustedServerBidRequest = { adUnitCode?: string; code?: string; @@ -371,26 +370,41 @@ function firstTargetingValue(values: string[] | undefined): string | undefined { return values?.find((value) => value.length > 0); } -/** - * Find the publisher's original `pbjs.adUnits` entry for a refreshing slot. - * - * A TS-owned GPT slot may be defined on `${div_id}-container`, so the GPT - * element id used as the synthetic refresh ad unit code can differ from the - * inner `div_id` the publisher keyed their Prebid ad unit by. Try each candidate - * code in order and return the first matching ad unit, so container-backed slots - * still recover the publisher's configured params and bidders. - */ +/** Store a snapshot and evict the least-recently used entry when capacity is exceeded. */ +function storePublisherAdUnitSnapshot(code: string, snapshot: PublisherAdUnitSnapshot): void { + publisherAdUnitSnapshots.delete(code); + publisherAdUnitSnapshots.set(code, snapshot); + + if (publisherAdUnitSnapshots.size > MAX_PUBLISHER_AD_UNIT_SNAPSHOTS) { + const oldestCode = publisherAdUnitSnapshots.keys().next().value; + if (oldestCode !== undefined) publisherAdUnitSnapshots.delete(oldestCode); + } +} + +/** Find and touch a request-scoped publisher snapshot by candidate code. */ function findRefreshSnapshot( candidateCodes: Array ): PublisherAdUnitSnapshot | undefined { for (const code of candidateCodes) { if (!code) continue; const snapshot = publisherAdUnitSnapshots.get(code); - if (snapshot) return snapshot; + if (!snapshot) continue; + publisherAdUnitSnapshots.delete(code); + publisherAdUnitSnapshots.set(code, snapshot); + return snapshot; } return undefined; } +/** + * Find the publisher's live `pbjs.adUnits` entry for a refreshing slot. + * + * A TS-owned GPT slot may be defined on `${div_id}-container`, so the GPT + * element id used as the synthetic refresh ad unit code can differ from the + * inner `div_id` the publisher keyed their Prebid ad unit by. Try each candidate + * code in order and return the first matching ad unit, so container-backed slots + * still recover the publisher's configured params and bidders. + */ function findRefreshAdUnit( candidateCodes: Array ): TrustedServerAdUnit | undefined { @@ -403,6 +417,7 @@ function findRefreshAdUnit( return undefined; } +/** Deep-copy plain publisher params while preserving cycles and non-plain values. */ function copyParamValue(value: unknown, seen = new WeakMap()): unknown { if (Array.isArray(value)) { const existing = seen.get(value); @@ -439,6 +454,7 @@ function copyParams(params: Record | undefined): Record; } +/** Copy bidder params previously folded into a `trustedServer` bid. */ function foldedBidderParams( bid: TrustedServerBid | undefined ): Record> { @@ -451,6 +467,7 @@ function foldedBidderParams( ); } +/** Capture immutable request-scoped bidder and zone data before the shim mutates an ad unit. */ function capturePublisherAdUnitSnapshot( unit: TrustedServerAdUnit, clientSideBidders: Set @@ -493,34 +510,34 @@ function capturePublisherAdUnitSnapshot( * `requestBids` shim preserves a client-side bidder only when its bid entry is * already present on the ad unit, so without re-attaching them here publishers * that split demand between server-side and native Prebid adapters would lose - * all client-side demand on refresh/scroll impressions. Bids are sourced from - * the matching `pbjs.adUnits` entry (by candidate ad unit code) so the - * publisher's configured params are preserved. + * all client-side demand on refresh/scroll impressions. A live exact + * `pbjs.adUnits` match is authoritative; request-scoped snapshots are used only + * when no live unit exists. */ function clientSideBidsForRefresh( candidateCodes: Array ): Array<{ bidder: string; params: Record }> { - const snapshot = findRefreshSnapshot(candidateCodes); - if (snapshot) { - return snapshot.clientSideBids.map((bid) => ({ - bidder: bid.bidder, - params: copyParams(bid.params), - })); - } - const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); - if (clientSideBidders.size === 0) return []; - const match = findRefreshAdUnit(candidateCodes); - if (!match?.bids) return []; + if (match) { + if (clientSideBidders.size === 0 || !Array.isArray(match.bids)) return []; - const bids: Array<{ bidder: string; params: Record }> = []; - for (const bid of match.bids) { - if (bid?.bidder && clientSideBidders.has(bid.bidder)) { - bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + const bids: Array<{ bidder: string; params: Record }> = []; + for (const bid of match.bids) { + if (bid?.bidder && clientSideBidders.has(bid.bidder)) { + bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + } } + return bids; } - return bids; + + const snapshot = findRefreshSnapshot(candidateCodes); + return ( + snapshot?.clientSideBids.map((bid) => ({ + bidder: bid.bidder, + params: copyParams(bid.params), + })) ?? [] + ); } /** @@ -529,49 +546,49 @@ function clientSideBidsForRefresh( * The synthetic refresh ad unit carries only the `trustedServer` bid, so the * `requestBids` shim has no original server-side bidder entries to collect into * `bidderParams` — without this, refresh/scroll `/auction` requests send `{}` - * and lose demand the publisher configured only on the initial ad unit. Source - * the params from the matching `pbjs.adUnits` entry by candidate code, covering - * both states the initial auction can leave that entry in: - * - raw server-side bidder entries (`{ bidder, params }`) not yet folded, and - * - params already folded into that unit's `trustedServer` bid `bidderParams` - * by a prior `requestBids` call. + * and lose demand the publisher configured only on the initial ad unit. A live + * exact `pbjs.adUnits` match is authoritative and covers both raw bidder entries + * and params already folded into a `trustedServer` bid. A request-scoped + * snapshot is used only when no live unit exists. */ function serverSideBidderParamsForRefresh( candidateCodes: Array ): Record> { - const snapshot = findRefreshSnapshot(candidateCodes); - if (snapshot) { - return Object.fromEntries( - Object.entries(snapshot.bidderParams).map(([bidder, params]) => [bidder, copyParams(params)]) - ); - } - const match = findRefreshAdUnit(candidateCodes); - if (!match?.bids) return {}; + if (match) { + if (!Array.isArray(match.bids)) return {}; - const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); - const params: Record> = {}; + const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); + const params: Record> = {}; - for (const bid of match.bids) { - if (!bid?.bidder) continue; - if (bid.bidder === ADAPTER_CODE) { - // Params captured and folded onto the trustedServer bid by an earlier - // requestBids call. - const folded = (bid.params?.[BIDDER_PARAMS_KEY] ?? {}) as Record< - string, - Record - >; - for (const [bidder, bidderParams] of Object.entries(folded)) { - params[bidder] = bidderParams; + for (const bid of match.bids) { + if (!bid?.bidder) continue; + if (bid.bidder === ADAPTER_CODE) { + Object.assign(params, foldedBidderParams(bid)); + continue; } - continue; + if (clientSideBidders.has(bid.bidder)) continue; + params[bid.bidder] = copyParams(bid.params); } - if (clientSideBidders.has(bid.bidder)) continue; - // Raw server-side bidder entry not yet folded by the shim. - params[bid.bidder] = bid.params ?? {}; + + return params; } - return params; + const snapshot = findRefreshSnapshot(candidateCodes); + return snapshot + ? Object.fromEntries( + Object.entries(snapshot.bidderParams).map(([bidder, params]) => [ + bidder, + copyParams(params), + ]) + ) + : {}; +} + +/** Return a live publisher zone, falling back to a request-scoped snapshot. */ +function publisherZoneForRefresh(candidateCodes: Array): string | undefined { + const match = findRefreshAdUnit(candidateCodes); + return match ? match.mediaTypes?.banner?.name : findRefreshSnapshot(candidateCodes)?.zone; } function clearRefreshTargeting(slot: RefreshGptSlot): void { @@ -582,70 +599,85 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } -function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { - if (context.cleanupTimer !== undefined) { - clearTimeout(context.cleanupTimer); - context.cleanupTimer = undefined; +/** Store an auction-local bid ID for one-shot GPT delivery correlation. */ +function storePendingPublisherBid(adId: string, pendingBid: PendingPublisherBid): void { + pendingPublisherBids.delete(adId); + pendingPublisherBids.set(adId, pendingBid); + + if (pendingPublisherBids.size > MAX_PENDING_PUBLISHER_BIDS) { + const oldestAdId = pendingPublisherBids.keys().next().value; + if (oldestAdId !== undefined) pendingPublisherBids.delete(oldestAdId); } - const index = activePublisherDeliveryContexts.lastIndexOf(context); - if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); } -function targetingCoversPublisherDeliveryContext( - adUnitCodes: unknown, - context: PublisherDeliveryContext -): boolean { - if (adUnitCodes === undefined) return context.remainingCodes.size > 0; - const codes = typeof adUnitCodes === 'string' ? [adUnitCodes] : adUnitCodes; - return ( - Array.isArray(codes) && - codes.some((code) => typeof code === 'string' && context.remainingCodes.has(code)) - ); +/** Remove every pending auction bid for an ad-unit code. */ +function removePendingPublisherBidsForCode(adUnitCode: string): void { + for (const [adId, pendingBid] of pendingPublisherBids) { + if (pendingBid.adUnitCode === adUnitCode) pendingPublisherBids.delete(adId); + } } -function consumeBarePublisherDeliveryContext(): boolean { - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - if (context.remainingCodes.size === 0) continue; - context.remainingCodes.clear(); - removePublisherDeliveryContext(context); - return true; +/** Register bid IDs from the current `bidsBackHandler` callback only. */ +function registerPendingPublisherBids(bidResponses: unknown): void { + if (!bidResponses || typeof bidResponses !== 'object' || Array.isArray(bidResponses)) return; + + for (const [responseCode, responseGroup] of Object.entries(bidResponses)) { + if (!responseGroup || typeof responseGroup !== 'object') continue; + const bids = (responseGroup as { bids?: unknown }).bids; + if (!Array.isArray(bids)) continue; + + for (const bid of bids) { + if (!bid || typeof bid !== 'object') continue; + const response = bid as { adId?: unknown; adUnitCode?: unknown }; + const adId = typeof response.adId === 'string' ? response.adId : undefined; + const adUnitCode = + typeof response.adUnitCode === 'string' ? response.adUnitCode : responseCode; + if (!adId || !adUnitCode) continue; + + storePendingPublisherBid(adId, { adUnitCode }); + } } - return false; } -function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { - if (targetSlots.length === 0) return false; +/** + * Partition slots by whether their current `hb_adid` belongs to a pending + * publisher auction, consuming every older pending bid for each matched code. + */ +function publisherDeliverySlots(targetSlots: RefreshGptSlot[]): Set { + const deliverySlots = new Set(); + const deliveredCodes = new Set(); - // Publishers may include GAM-only slots in the same explicit refresh that - // delivers a completed Prebid auction. Attribute the call to delivery when - // any slot is covered, while consuming only the covered codes so an - // unrelated-only refresh still follows the synthetic auction path. - const matches = new Map>(); for (const slot of targetSlots) { - const injectedSlot = findInjectedSlotForRefresh(slot); - const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + const adIds = slot.getTargeting?.('hb_adid'); + if (!Array.isArray(adIds)) continue; - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - const coveredCode = candidates.find( - (code): code is string => !!code && context.remainingCodes.has(code) - ); - if (!coveredCode) continue; + const pendingBid = adIds + .filter((adId): adId is string => typeof adId === 'string' && adId.length > 0) + .map((adId) => pendingPublisherBids.get(adId)) + .find((bid): bid is PendingPublisherBid => bid !== undefined); + if (!pendingBid) continue; - const contextMatches = matches.get(context) ?? new Set(); - contextMatches.add(coveredCode); - matches.set(context, contextMatches); - break; - } + deliverySlots.add(slot); + deliveredCodes.add(pendingBid.adUnitCode); } - if (matches.size === 0) return false; - for (const [context, coveredCodes] of matches) { - coveredCodes.forEach((code) => context.remainingCodes.delete(code)); - if (context.remainingCodes.size === 0) removePublisherDeliveryContext(context); + deliveredCodes.forEach(removePendingPublisherBidsForCode); + return deliverySlots; +} + +/** Evict publisher state after Prebid removes one or more ad units. */ +function removePublisherState(adUnitCode?: string | string[]): void { + if (!adUnitCode) { + publisherAdUnitSnapshots.clear(); + pendingPublisherBids.clear(); + return; + } + + const adUnitCodes = Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]; + for (const code of adUnitCodes) { + publisherAdUnitSnapshots.delete(code); + removePendingPublisherBidsForCode(code); } - return true; } function collectAuctionEids(): AuctionEid[] | undefined { @@ -685,8 +717,18 @@ function collectAuctionEids(): AuctionEid[] | undefined { */ export function installPrebidNpm(config?: Partial): typeof pbjs { publisherAdUnitSnapshots = new Map(); + pendingPublisherBids = new Map(); syntheticRefreshAdUnits = new WeakSet(); - [...activePublisherDeliveryContexts].forEach(removePublisherDeliveryContext); + + const prebidWithRemoveAdUnit = pbjs as unknown as { removeAdUnit?: RemoveAdUnit }; + const originalRemoveAdUnit = prebidWithRemoveAdUnit.removeAdUnit; + if (typeof originalRemoveAdUnit === 'function') { + prebidWithRemoveAdUnit.removeAdUnit = function (adUnitCode?: string | string[]) { + const result = originalRemoveAdUnit.call(this, adUnitCode); + removePublisherState(adUnitCode); + return result; + }; + } const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { @@ -761,15 +803,19 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; const isSyntheticRefresh = adUnits.length > 0 && adUnits.every((unit) => syntheticRefreshAdUnits.has(unit)); - const publisherAdUnitCodes = new Set(); + const publisherAdUnitCodes = new Set( + adUnits + .filter((unit) => !syntheticRefreshAdUnits.has(unit)) + .map((unit) => unit.code) + .filter((code): code is string => typeof code === 'string' && code.length > 0) + ); // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { if (!syntheticRefreshAdUnits.has(unit)) { const snapshot = capturePublisherAdUnitSnapshot(unit, clientSideBidders); if (snapshot && unit.code) { - publisherAdUnitSnapshots.set(unit.code, snapshot); - publisherAdUnitCodes.add(unit.code); + storePublisherAdUnitSnapshot(unit.code, snapshot); } } @@ -857,51 +903,11 @@ export function installPrebidNpm(config?: Partial): typeof pbjs opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); if (typeof originalBidsBack !== 'function') return; - if (isSyntheticRefresh || publisherAdUnitCodes.size === 0) { - originalBidsBack.apply(this, args as Parameters); - return; - } - - const context: PublisherDeliveryContext = { - remainingCodes: new Set(publisherAdUnitCodes), - retainForTargetedRefresh: false, - }; - const targetingPbjs = pbjs as unknown as { - setTargetingForGPTAsync?: SetTargetingForGptAsync; - }; - const originalSetTargeting = targetingPbjs.setTargetingForGPTAsync; - let targetingWrapper: SetTargetingForGptAsync | undefined; - if (typeof originalSetTargeting === 'function') { - targetingWrapper = (...targetingArgs: unknown[]) => { - const result = originalSetTargeting.apply(targetingPbjs, targetingArgs); - if (targetingCoversPublisherDeliveryContext(targetingArgs[0], context)) { - context.retainForTargetedRefresh = true; - } - return result; - }; - targetingPbjs.setTargetingForGPTAsync = targetingWrapper; - } - - activePublisherDeliveryContexts.push(context); - try { - originalBidsBack.apply(this, args as Parameters); - } finally { - if (targetingWrapper && targetingPbjs.setTargetingForGPTAsync === targetingWrapper) { - targetingPbjs.setTargetingForGPTAsync = originalSetTargeting; - } - if (context.retainForTargetedRefresh && context.remainingCodes.size > 0) { - // Some publisher wrappers set targeting in bidsBackHandler, return, - // and schedule the matching GPT refresh shortly afterward. Retain - // this one-shot context only after that targeting signal, with a - // bounded expiry so a later independent refresh remains independent. - context.cleanupTimer = setTimeout( - () => removePublisherDeliveryContext(context), - PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS - ); - } else { - removePublisherDeliveryContext(context); - } + if (!isSyntheticRefresh) { + publisherAdUnitCodes.forEach(removePendingPublisherBidsForCode); + registerPendingPublisherBids(args[0]); } + originalBidsBack.apply(this, args as Parameters); }; return originalRequestBids(opts); @@ -1001,33 +1007,30 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - const isExplicitSlotList = slots !== undefined; - const hasOnlyValidExplicitSlots = !isExplicitSlotList || targetSlots.length === slots.length; - const isPublisherDeliveryRefresh = isExplicitSlotList - ? hasOnlyValidExplicitSlots && consumeExplicitPublisherDeliveryContext(targetSlots) - : consumeBarePublisherDeliveryContext(); - if (isPublisherDeliveryRefresh) { + if (!targetSlots.length) { return originalRefresh(slots, opts); } - if (!targetSlots.length) { - return originalRefresh(slots, opts); + const deliverySlots = publisherDeliverySlots(targetSlots); + const independentSlots = targetSlots.filter((slot) => !deliverySlots.has(slot)); + if (deliverySlots.size > 0) { + originalRefresh([...deliverySlots], opts); } + if (independentSlots.length === 0) return; - targetSlots.forEach(clearRefreshTargeting); + independentSlots.forEach(clearRefreshTargeting); - const adUnits = targetSlots.map((slot) => { + const adUnits = independentSlots.map((slot) => { const injectedSlot = findInjectedSlotForRefresh(slot); const code = refreshSlotElementId(slot) ?? 'refresh-slot'; // A TS-owned slot may be defined on `${div_id}-container`, so the GPT // element id used as the synthetic refresh code can differ from the // inner `div_id` the publisher keyed their ad unit by. Recover from both. const candidateCodes = [code, injectedSlot?.div_id]; - const snapshot = findRefreshSnapshot(candidateCodes); const zone = injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)) ?? - snapshot?.zone; + publisherZoneForRefresh(candidateCodes); const banner: TrustedServerBanner = { sizes: bannerSizesFromInjectedSlot(injectedSlot) ?? @@ -1062,7 +1065,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { adUnits, bidsBackHandler: () => { pbjs.setTargetingForGPTAsync?.(refreshAdUnitCodes); - originalRefresh(targetSlots, opts); + originalRefresh(independentSlots, opts); }, timeout: timeoutMs, }); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index bdb336fc..88c3540c 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -7,6 +7,7 @@ const { mockRequestBids, mockRegisterBidAdapter, mockGetUserIdsAsEids, + mockRemoveAdUnit, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -19,13 +20,32 @@ const { const mockGetUserIdsAsEids = vi.fn( () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); - const mockPbjs = { + let mockPbjs: { + setConfig: typeof mockSetConfig; + processQueue: typeof mockProcessQueue; + requestBids: typeof mockRequestBids; + registerBidAdapter: typeof mockRegisterBidAdapter; + getUserIdsAsEids: typeof mockGetUserIdsAsEids; + removeAdUnit: ReturnType; + adUnits: any[]; + [key: string]: any; + }; + const mockRemoveAdUnit = vi.fn((adUnitCode?: string | string[]) => { + if (!adUnitCode) { + mockPbjs.adUnits = []; + return; + } + const codes = new Set(Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]); + mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !codes.has(unit.code)); + }); + mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, requestBids: mockRequestBids, registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, - adUnits: [] as any[], + removeAdUnit: mockRemoveAdUnit, + adUnits: [], }; const mockAdapterManager = { getBidAdapter: mockGetBidAdapter, @@ -36,6 +56,7 @@ const { mockRequestBids, mockRegisterBidAdapter, mockGetUserIdsAsEids, + mockRemoveAdUnit, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -1362,10 +1383,18 @@ describe('prebid/installRefreshHandler', () => { }); describe('prebid publisher snapshots and delivery refreshes', () => { + let deliveryAdIds = new WeakMap(); + let installedGptSlots: any[] = []; + let auctionSequence = 0; + beforeEach(() => { vi.clearAllMocks(); + deliveryAdIds = new WeakMap(); + installedGptSlots = []; + auctionSequence = 0; mockRequestBids.mockReset(); mockPbjs.requestBids = mockRequestBids; + mockPbjs.removeAdUnit = mockRemoveAdUnit; mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); @@ -1383,6 +1412,17 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); function installGpt(slots: any[]) { + installedGptSlots = slots; + for (const slot of slots) { + if (!slot || typeof slot !== 'object') continue; + const originalGetTargeting = slot.getTargeting?.bind(slot); + slot.getTargeting = (key: string) => { + const deliveryAdId = deliveryAdIds.get(slot); + if (key === 'hb_adid' && deliveryAdId) return [deliveryAdId]; + return originalGetTargeting?.(key) ?? []; + }; + } + const originalRefresh = vi.fn(); const pubads = { refresh: originalRefresh, @@ -1401,6 +1441,31 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return lastCall?.[0]?.adUnits?.[0]; } + function completePublisherAuction( + opts?: { adUnits?: Array<{ code?: string }>; bidsBackHandler?: (...args: any[]) => void }, + options: { auctionId?: string; applyTargeting?: boolean } = {} + ): void { + const auctionId = options.auctionId ?? `example-auction-${auctionSequence++}`; + const bidResponses: Record = {}; + + for (const unit of opts?.adUnits ?? []) { + if (!unit.code) continue; + const adId = `${auctionId}-${unit.code}`; + bidResponses[unit.code] = { + bids: [{ adId, adUnitCode: unit.code, auctionId }], + }; + if (options.applyTargeting !== false) { + const slot = installedGptSlots.find((candidate) => { + const elementId = candidate?.getSlotElementId?.(); + return elementId === unit.code || elementId === `${unit.code}-container`; + }); + if (slot) deliveryAdIds.set(slot, adId); + } + } + + opts?.bidsBackHandler?.(bidResponses, false, auctionId); + } + it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; const runtimeInstance = 'example-runtime-instance'; @@ -1626,6 +1691,143 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }); }); + it('prefers a rich live unit when a fresh same-code request overwrites the snapshot with empty bids', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const code = 'example-live-rich-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const liveUnit = { + code, + bids: [ + { bidder: 'exampleServer', params: { placement: 'live-server' } }, + { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, + ], + }; + mockPbjs.adUnits = [liveUnit]; + const pbjs = installPrebidNpm(); + + pbjs.requestBids(); + pbjs.requestBids({ adUnits: [{ code, bids: [] }] } as any); + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual([ + { + bidder: 'trustedServer', + params: { bidderParams: { exampleServer: { placement: 'live-server' } } }, + }, + { bidder: 'exampleBrowser', params: { placement: 'live-browser' } }, + ]); + }); + + it('does not resurrect an older snapshot when the live unit is intentionally empty', () => { + const code = 'example-live-empty-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: { placement: 'snapshot' } }] }], + } as any); + mockPbjs.adUnits = [{ code, bids: [] }]; + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual([ + { bidder: 'trustedServer', params: { bidderParams: {} } }, + ]); + }); + + it('evicts snapshots with the matching removeAdUnit lifecycle', () => { + const codes = ['example-remove-one', 'example-remove-two', 'example-remove-all']; + const slots = codes.map((code) => ({ + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + })); + const { pubads } = installGpt(slots); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: codes.map((code) => ({ + code, + bids: [{ bidder: 'exampleServer', params: { placement: code } }], + })), + } as any); + (pbjs as any).removeAdUnit(codes[0]); + (pbjs as any).removeAdUnit([codes[1]]); + + pubads.refresh([slots[0]]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + pubads.refresh([slots[1]]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + pubads.refresh([slots[2]]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: codes[2] }, + }); + + (pbjs as any).removeAdUnit(); + pubads.refresh([slots[2]]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + }); + + it('bounds snapshots with LRU eviction while retaining a recently refreshed entry', () => { + const capacity = 256; + const oldestCode = 'example-lru-0'; + const activeCode = `example-lru-${capacity - 1}`; + const oldestSlot = { + getSlotElementId: () => oldestCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const activeSlot = { + getSlotElementId: () => activeCode, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([oldestSlot, activeSlot]); + const pbjs = installPrebidNpm(); + + for (let index = 0; index < capacity; index += 1) { + pbjs.requestBids({ + adUnits: [ + { + code: `example-lru-${index}`, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + }, + ], + } as any); + } + + pubads.refresh([activeSlot]); + pbjs.requestBids({ + adUnits: [ + { + code: `example-lru-${capacity}`, + bids: [{ bidder: 'exampleServer', params: { placement: capacity } }], + }, + ], + } as any); + + pubads.refresh([oldestSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + pubads.refresh([activeSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: capacity - 1 }, + }); + }); + it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { const slotOne = { getSlotElementId: () => 'example-covered-one', @@ -1641,9 +1843,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], }; const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1665,7 +1865,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); }); - it('bypasses a bare delivery refresh even when GPT includes a GAM-only extra slot', () => { + it('partitions a bare delivery refresh from an unmatched GPT slot', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1677,9 +1877,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1687,14 +1885,15 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidsBackHandler: () => pubads.refresh(), } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); - expect(gamOnlySlot.clearTargeting).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); + expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [coveredSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); }); - it('keeps explicit unrelated lists synthetic and bypasses mixed delivery lists', () => { + it('keeps explicit unrelated lists synthetic and partitions mixed delivery lists', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1708,9 +1907,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1721,19 +1918,23 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(mockRequestBids).toHaveBeenCalledTimes(3); expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ 'example-unrelated', ]); + expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-unrelated', + ]); expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); - expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenCalledTimes(3); expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(3, [unrelatedSlot], undefined); }); - it('bypasses an explicit delivery refresh with four covered slots and a GAM-only extra', () => { + it('partitions four delivered slots from an unmatched explicit slot', () => { const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ getSlotElementId: () => `example-covered-${index}`, getTargeting: () => [], @@ -1746,9 +1947,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const refreshSlots = [...coveredSlots, gamOnlySlot]; const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1759,111 +1958,125 @@ describe('prebid publisher snapshots and delivery refreshes', () => { bidsBackHandler: () => pubads.refresh(refreshSlots), } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + coveredSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, coveredSlots, undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); }); - it('bypasses a targeted delivery refresh shortly after the publisher callback returns', () => { + it('correlates a targeted delivery refresh after more than one second without a timer race', () => { vi.useFakeTimers(); try { - const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ - getSlotElementId: () => `example-targeted-${index}`, + const code = 'example-delayed-delivery'; + const auctionId = 'example-delayed-auction'; + const slot = { + getSlotElementId: () => code, getTargeting: () => [], getSizes: () => [[300, 250]], clearTargeting: vi.fn(), - })); - const gamOnlySlot = { - getSlotElementId: () => 'example-targeted-interstitial', - getTargeting: () => [], - clearTargeting: vi.fn(), }; - const refreshSlots = [...coveredSlots, gamOnlySlot]; - const { originalRefresh, pubads } = installGpt(refreshSlots); - const setTargetingForGPTAsync = vi.fn(); - (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; - let refreshAfterCallback: (() => void) | undefined; - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - const pendingRefresh = refreshAfterCallback; - refreshAfterCallback = undefined; - if (pendingRefresh) setTimeout(pendingRefresh, 750); - }); + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { auctionId, applyTargeting: false }) + ); const pbjs = installPrebidNpm(); - const coveredCodes = coveredSlots.map((slot) => slot.getSlotElementId()); pbjs.requestBids({ - adUnits: coveredCodes.map((code, index) => ({ - code, - bids: [{ bidder: 'exampleServer', params: { placement: index } }], - })), + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => { - (pbjs as any).setTargetingForGPTAsync([gamOnlySlot.getSlotElementId(), ...coveredCodes]); - refreshAfterCallback = () => pubads.refresh(refreshSlots); + setTimeout(() => { + deliveryAdIds.set(slot, `${auctionId}-${code}`); + pubads.refresh([slot]); + }, 1500); }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(1); - expect(setTargetingForGPTAsync).toHaveBeenCalledWith([ - gamOnlySlot.getSlotElementId(), - ...coveredCodes, - ]); - expect((mockPbjs as any).setTargetingForGPTAsync).toBe(setTargetingForGPTAsync); - - vi.advanceTimersByTime(750); + vi.advanceTimersByTime(1500); - refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); - - vi.runOnlyPendingTimers(); - pubads.refresh([coveredSlots[0]]); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + pubads.refresh([slot]); expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(coveredSlots[0].clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); expect(originalRefresh).toHaveBeenCalledTimes(2); } finally { vi.runOnlyPendingTimers(); vi.useRealTimers(); - delete (mockPbjs as any).setTargetingForGPTAsync; } }); - it('expires a targeted delivery context before a later event-loop task', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-expiring-delivery', - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { originalRefresh, pubads } = installGpt([slot]); - (mockPbjs as any).setTargetingForGPTAsync = vi.fn(); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); - const pbjs = installPrebidNpm(); + it('correlates null and no-argument targeting with a custom GPT slot match', () => { + const code = 'example-custom-matched-code'; + const slot = { + getSlotElementId: () => 'example-different-gpt-slot', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + let auctionId = 'example-null-auction'; + const setTargetingForGPTAsync = vi.fn(() => { + deliveryAdIds.set(slot, `${auctionId}-${code}`); + }); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { auctionId, applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); - pbjs.requestBids({ - adUnits: [ - { code: 'example-expiring-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => (pbjs as any).setTargetingForGPTAsync(['example-expiring-delivery']), - } as any); - vi.runOnlyPendingTimers(); - pubads.refresh([slot]); + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync(null, () => () => true); + pubads.refresh([slot]); + }, + } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - delete (mockPbjs as any).setTargetingForGPTAsync; - } + auctionId = 'example-no-argument-auction'; + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync(); + pubads.refresh([slot]); + }, + } as any); + + expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(1, null, expect.any(Function)); + expect(setTargetingForGPTAsync).toHaveBeenNthCalledWith(2); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); + delete (mockPbjs as any).setTargetingForGPTAsync; + }); + + it('uses the synthetic path when callback bid responses are missing or malformed', () => { + const slot = { + getSlotElementId: () => 'example-no-bid-delivery', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: (...args: any[]) => void }) => { + opts?.bidsBackHandler?.({ 'example-no-bid-delivery': { bids: [null, {}] } }, false, 'bad'); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-no-bid-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh([slot]), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); it('bypasses a mixed explicit delivery list spanning nested contexts', () => { @@ -1884,9 +2097,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; const { originalRefresh, pubads } = installGpt(refreshSlots); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1903,10 +2114,13 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(2); - refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(gamOnlySlot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot, outerSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [gamOnlySlot], undefined); }); it('treats a microtask refresh without a targeting signal as an independent auction', async () => { @@ -1917,9 +2131,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); const pbjs = installPrebidNpm(); let deferredRefresh: Promise | undefined; @@ -1939,6 +2153,98 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); }); + it('correlates targeting and refresh deferred together to a microtask', async () => { + const code = 'example-targeted-microtask'; + const auctionId = 'example-targeted-microtask-auction'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { auctionId, applyTargeting: false }) + ); + const pbjs = installPrebidNpm(); + let deferredRefresh: Promise | undefined; + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + deferredRefresh = Promise.resolve().then(() => { + deliveryAdIds.set(slot, `${auctionId}-${code}`); + pubads.refresh([slot]); + }); + }, + } as any); + await deferredRefresh; + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('consumes all overlapping pending bids for the same ad-unit code', () => { + const code = 'example-overlapping-code'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => {}, + } as any); + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => {}, + } as any); + + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + + deliveryAdIds.set(slot, `example-auction-0-${code}`); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slot], undefined); + }); + + it('filters invalid explicit entries without duplicating or leaking a valid delivery', () => { + const code = 'example-valid-delivery'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh([slot, undefined, null] as any), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + + pubads.refresh([slot]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_adid'); + }); + it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { const outerSlot = { getSlotElementId: () => 'example-outer-delivery', @@ -1951,9 +2257,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); const pbjs = installPrebidNpm(); pbjs.requestBids({ @@ -1986,9 +2290,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => + completePublisherAuction(opts, { applyTargeting: false }) + ); const pbjs = installPrebidNpm(); expect(() => @@ -2020,9 +2324,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { - opts?.bidsBackHandler?.(); - }); + mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); installPrebidNpm(); pubads.refresh([slot]);