From eb3436cae3f7f9e56ca663f002de61de2545700c Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Mon, 2 Feb 2026 06:13:39 +0000 Subject: [PATCH 01/15] feat(ctv): add CTV VAST module for Connected TV ad processing This module provides comprehensive VAST processing for CTV ads: - vast.go: Main orchestration and BuildVastFromBidResponse entrypoint - handler.go: HTTP handler for VAST requests - types.go: Type definitions, interfaces (BidSelector, Enricher, Formatter) - config.go: PBS-style layered configuration (host/account/profile merge) - model/: VAST XML structures, parser, and helper functions - select/: Bid selection logic with SINGLE/TOP_N strategies - enrich/: VAST enrichment with VAST_WINS collision policy - format/: VAST XML formatting for GAM_SSU receiver Features: - Bid selection by price with deal prioritization - VAST XML parsing and skeleton generation - Metadata enrichment (pricing, advertiser, categories, debug) - Pod support with sequence numbering - Golden file tests for XML output --- modules/ctv/vast/README_EN.md | 336 +++++++++ modules/ctv/vast/config.go | 369 ++++++++++ modules/ctv/vast/config_test.go | 388 ++++++++++ modules/ctv/vast/enrich/enrich.go | 264 +++++++ modules/ctv/vast/enrich/enrich_test.go | 672 ++++++++++++++++++ modules/ctv/vast/format/format.go | 114 +++ modules/ctv/vast/format/format_test.go | 488 +++++++++++++ modules/ctv/vast/format/testdata/no_ad.xml | 2 + .../vast/format/testdata/pod_three_ads.xml | 45 ++ .../ctv/vast/format/testdata/pod_two_ads.xml | 39 + .../ctv/vast/format/testdata/single_ad.xml | 24 + modules/ctv/vast/handler.go | 167 +++++ modules/ctv/vast/model/model.go | 28 + modules/ctv/vast/model/parser.go | 171 +++++ modules/ctv/vast/model/parser_test.go | 528 ++++++++++++++ modules/ctv/vast/model/vast_xml.go | 282 ++++++++ modules/ctv/vast/model/vast_xml_test.go | 447 ++++++++++++ modules/ctv/vast/select/price_selector.go | 167 +++++ .../ctv/vast/select/price_selector_test.go | 501 +++++++++++++ modules/ctv/vast/select/selector.go | 42 ++ modules/ctv/vast/types.go | 191 +++++ modules/ctv/vast/vast.go | 204 ++++++ modules/ctv/vast/vast_test.go | 607 ++++++++++++++++ 23 files changed, 6076 insertions(+) create mode 100644 modules/ctv/vast/README_EN.md create mode 100644 modules/ctv/vast/config.go create mode 100644 modules/ctv/vast/config_test.go create mode 100644 modules/ctv/vast/enrich/enrich.go create mode 100644 modules/ctv/vast/enrich/enrich_test.go create mode 100644 modules/ctv/vast/format/format.go create mode 100644 modules/ctv/vast/format/format_test.go create mode 100644 modules/ctv/vast/format/testdata/no_ad.xml create mode 100644 modules/ctv/vast/format/testdata/pod_three_ads.xml create mode 100644 modules/ctv/vast/format/testdata/pod_two_ads.xml create mode 100644 modules/ctv/vast/format/testdata/single_ad.xml create mode 100644 modules/ctv/vast/handler.go create mode 100644 modules/ctv/vast/model/model.go create mode 100644 modules/ctv/vast/model/parser.go create mode 100644 modules/ctv/vast/model/parser_test.go create mode 100644 modules/ctv/vast/model/vast_xml.go create mode 100644 modules/ctv/vast/model/vast_xml_test.go create mode 100644 modules/ctv/vast/select/price_selector.go create mode 100644 modules/ctv/vast/select/price_selector_test.go create mode 100644 modules/ctv/vast/select/selector.go create mode 100644 modules/ctv/vast/types.go create mode 100644 modules/ctv/vast/vast.go create mode 100644 modules/ctv/vast/vast_test.go diff --git a/modules/ctv/vast/README_EN.md b/modules/ctv/vast/README_EN.md new file mode 100644 index 00000000000..cb588d8c606 --- /dev/null +++ b/modules/ctv/vast/README_EN.md @@ -0,0 +1,336 @@ +# CTV VAST Module + +The CTV VAST module provides comprehensive VAST (Video Ad Serving Template) processing for Connected TV (CTV) ads in Prebid Server. + +## Module Structure + +``` +modules/ctv/vast/ +├── vast.go # Main entry point and orchestration +├── handler.go # HTTP handler for VAST requests +├── types.go # Type definitions, interfaces and constants +├── config.go # Configuration and layer merging (host/account/profile) +├── model/ # VAST XML data structures +│ ├── model.go # High-level domain objects +│ ├── vast_xml.go # XML structures for marshal/unmarshal +│ └── parser.go # VAST XML parser +├── select/ # Bid selection logic +│ └── selector.go # BidSelector implementations +├── enrich/ # VAST enrichment +│ └── enrich.go # Enricher implementation (VAST_WINS) +└── format/ # VAST XML formatting + └── format.go # Formatter implementation (GAM_SSU) +``` + +## Components + +### `vast.go` - Orchestration + +Main entry point of the module. Contains: + +- **`BuildVastFromBidResponse()`** - Main function orchestrating the entire pipeline: + 1. Bid selection from auction response + 2. VAST parsing from each bid's AdM (or skeleton creation) + 3. Enrichment of each ad with metadata + 4. Formatting to final XML + +- **`Processor`** - Wrapper structure for the pipeline with injected dependencies +- **`DefaultConfig()`** - Default configuration for GAM SSU + +### `handler.go` - HTTP Handler + +HTTP request handling for CTV VAST ads: + +- **`Handler`** - HTTP handler structure with configuration and dependencies +- **`ServeHTTP()`** - Handles GET requests, returns VAST XML +- **`buildBidRequest()`** - Builds OpenRTB BidRequest from HTTP parameters +- Builder methods: `WithConfig()`, `WithSelector()`, `WithEnricher()`, `WithFormatter()`, `WithAuctionFunc()` + +### `types.go` - Types and Interfaces + +Basic type definitions: + +| Type | Description | +|------|-------------| +| `ReceiverType` | Receiver type (GAM_SSU, SPRINGSERVE, etc.) | +| `SelectionStrategy` | Bid selection strategy (SINGLE, TOP_N, MAX_REVENUE) | +| `CollisionPolicy` | Collision policy (VAST_WINS, BID_WINS, REJECT) | +| `PlacementLocation` | Element placement (VAST_PRICING, EXTENSION, etc.) | + +**Interfaces:** + +```go +type BidSelector interface { + Select(req, resp, cfg) ([]SelectedBid, []string, error) +} + +type Enricher interface { + Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) +} + +type Formatter interface { + Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) +} +``` + +**Data Structures:** + +- `CanonicalMeta` - Normalized bid metadata (BidID, Price, Currency, Adomain, etc.) +- `SelectedBid` - Selected bid with metadata and sequence number +- `EnrichedAd` - Enriched ad ready for formatting +- `VastResult` - Processing result (XML, warnings, errors) +- `ReceiverConfig` - VAST receiver configuration +- `PlacementRules` - Validation rules (pricing, advertiser, categories) + +### `config.go` - Configuration + +PBS-style layered configuration system: + +- **`CTVVastConfig`** - Configuration structure with nullable fields +- **`MergeCTVVastConfig()`** - Layer merging: Host → Account → Profile +- **`ToReceiverConfig()`** - Conversion to ReceiverConfig + +Layer priority (from lowest to highest): +1. Host (defaults) +2. Account (overrides host) +3. Profile (overrides everything) + +### `model/` - VAST XML Structures + +#### `vast_xml.go` + +Go structures mapping VAST XML elements: + +- `Vast` - Root element `` +- `Ad` - Element `` with id, sequence attributes +- `InLine` - Inline ad with full data +- `Wrapper` - Wrapper ad (redirect) +- `Creative`, `Linear`, `MediaFile` - Creative elements +- `Pricing`, `Impression`, `Extensions` - Metadata and tracking + +Helper functions: +- `BuildNoAdVast()` - Creates empty VAST (no ads) +- `BuildSkeletonInlineVast()` - Creates minimal VAST skeleton +- `SecToHHMMSS()` - Converts seconds to HH:MM:SS format + +#### `parser.go` + +VAST XML parser: + +- **`ParseVastAdm()`** - Parses AdM string to Vast structure +- **`ParseVastOrSkeleton()`** - Parses or creates skeleton if allowed +- **`ExtractFirstAd()`** - Extracts first ad from VAST +- **`ParseDurationToSeconds()`** - Parses duration "HH:MM:SS" to seconds + +### `select/` - Bid Selection + +Logic for selecting bids from auction response: + +- **`PriceSelector`** - Price-based implementation: + - Filters bids with price ≤ 0 or empty AdM + - Sorts: deal > non-deal, then by price descending + - Respects `MaxAdsInPod` for TOP_N strategy + - Assigns sequence numbers (1-indexed) + +- **`NewSelector(strategy)`** - Factory creating selector for strategy +- **`NewSingleSelector()`** - Returns only the best bid +- **`NewTopNSelector()`** - Returns top N bids + +### `enrich/` - VAST Enrichment + +Adding metadata to VAST ads: + +- **`VastEnricher`** - Implementation with VAST_WINS policy: + - Existing values in VAST are not overwritten + - Adds missing: Pricing, Advertiser, Duration, Categories + - Optional debug extensions with OpenRTB data + +Enriched elements: +| Element | Source | Location | +|---------|--------|----------| +| Pricing | meta.Price | `` or Extension | +| Advertiser | meta.Adomain | `` or Extension | +| Duration | meta.DurSec | `` in Linear | +| Categories | meta.Cats | Extension (always) | +| Debug | all fields | Extension (when cfg.Debug=true) | + +### `format/` - VAST Formatting + +Building final VAST XML: + +- **`VastFormatter`** - GAM SSU implementation: + - Builds VAST document with list of `` elements + - Sets `id` from BidID + - Sets `sequence` for pods (multiple ads) + - Adds XML declaration and formatting + +## Processing Flow + +``` +┌─────────────────┐ +│ BidRequest │ +│ BidResponse │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ BidSelector │ ← Filters and sorts bids +│ (select/) │ ← Selects top N by strategy +└────────┬────────┘ + │ []SelectedBid + ▼ +┌─────────────────┐ +│ ParseVast │ ← Parses AdM to structure +│ (model/) │ ← Or creates skeleton +└────────┬────────┘ + │ *model.Ad + ▼ +┌─────────────────┐ +│ Enricher │ ← Adds Pricing, Advertiser +│ (enrich/) │ ← VAST_WINS policy +└────────┬────────┘ + │ EnrichedAd + ▼ +┌─────────────────┐ +│ Formatter │ ← Builds final XML +│ (format/) │ ← Sets sequence, id +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ VastResult │ +│ (XML bytes) │ +└─────────────────┘ +``` + +## Usage + +### Basic Usage with Processor + +```go +import ( + "github.com/prebid/prebid-server/v3/modules/ctv/vast" + "github.com/prebid/prebid-server/v3/modules/ctv/vast/enrich" + "github.com/prebid/prebid-server/v3/modules/ctv/vast/format" + bidselect "github.com/prebid/prebid-server/v3/modules/ctv/vast/select" +) + +// Configuration +cfg := vast.DefaultConfig() +cfg.MaxAdsInPod = 3 +cfg.SelectionStrategy = vast.SelectionTopN + +// Create components +selector := bidselect.NewSelector(cfg.SelectionStrategy) +enricher := enrich.NewEnricher() +formatter := format.NewFormatter() + +// Create processor +processor := vast.NewProcessor(cfg, selector, enricher, formatter) + +// Process +result := processor.Process(ctx, bidRequest, bidResponse) + +if result.NoAd { + // No ads available +} + +// result.VastXML contains the ready XML +``` + +### HTTP Handler Usage + +```go +handler := vast.NewHandler(). + WithConfig(cfg). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter). + WithAuctionFunc(myAuctionFunc) + +http.Handle("/vast", handler) +``` + +### Direct Invocation + +```go +result, err := vast.BuildVastFromBidResponse( + ctx, + bidRequest, + bidResponse, + cfg, + selector, + enricher, + formatter, +) +``` + +## Layer Configuration + +```go +// Host configuration (defaults) +hostCfg := &vast.CTVVastConfig{ + Receiver: vast.ReceiverGAMSSU, + DefaultCurrency: "USD", + VastVersionDefault: "4.0", +} + +// Account configuration (overrides host) +accountCfg := &vast.CTVVastConfig{ + MaxAdsInPod: vast.IntPtr(5), + SelectionStrategy: vast.SelectionTopN, +} + +// Profile configuration (overrides everything) +profileCfg := &vast.CTVVastConfig{ + Debug: vast.BoolPtr(true), +} + +// Merge layers +merged := vast.MergeCTVVastConfig(hostCfg, accountCfg, profileCfg) +receiverCfg := merged.ToReceiverConfig() +``` + +## Testing + +Run all module tests: + +```bash +go test ./modules/ctv/vast/... -v +``` + +Tests with coverage: + +```bash +go test ./modules/ctv/vast/... -cover +``` + +## Extensions + +### Adding a New Receiver + +1. Add constant in `types.go`: + ```go + ReceiverMyReceiver ReceiverType = "MY_RECEIVER" + ``` + +2. Implement `Formatter` for the new format in `format/` + +3. Optionally: adjust `Enricher` if different enrichment is needed + +### Adding a New Selection Strategy + +1. Add constant in `types.go`: + ```go + SelectionMyStrategy SelectionStrategy = "MY_STRATEGY" + ``` + +2. Implement `BidSelector` in `select/` + +3. Update `NewSelector()` factory + +## Dependencies + +- `github.com/prebid/openrtb/v20/openrtb2` - OpenRTB types +- `encoding/xml` - XML parsing/serialization +- `net/http` - HTTP handler diff --git a/modules/ctv/vast/config.go b/modules/ctv/vast/config.go new file mode 100644 index 00000000000..64fea1ddb08 --- /dev/null +++ b/modules/ctv/vast/config.go @@ -0,0 +1,369 @@ +package vast + +// CTVVastConfig represents the configuration for CTV VAST processing. +// It supports PBS-style layered configuration where profile overrides account, +// and account overrides host-level settings. +type CTVVastConfig struct { + // Enabled controls whether CTV VAST processing is active. + Enabled *bool `json:"enabled,omitempty" mapstructure:"enabled"` + // Receiver identifies the downstream ad receiver type (e.g., "GAM_SSU", "GENERIC"). + Receiver string `json:"receiver,omitempty" mapstructure:"receiver"` + // DefaultCurrency is the currency to use when not specified (default: "USD"). + DefaultCurrency string `json:"default_currency,omitempty" mapstructure:"default_currency"` + // VastVersionDefault is the default VAST version to output (default: "3.0"). + VastVersionDefault string `json:"vast_version_default,omitempty" mapstructure:"vast_version_default"` + // MaxAdsInPod is the maximum number of ads allowed in a pod (default: 10). + MaxAdsInPod int `json:"max_ads_in_pod,omitempty" mapstructure:"max_ads_in_pod"` + // SelectionStrategy defines how bids are selected (e.g., "SINGLE", "TOP_N"). + SelectionStrategy string `json:"selection_strategy,omitempty" mapstructure:"selection_strategy"` + // CollisionPolicy defines how competitive separation is handled (default: "VAST_WINS"). + CollisionPolicy string `json:"collision_policy,omitempty" mapstructure:"collision_policy"` + // AllowSkeletonVast allows bids without AdM content (skeleton VAST). + AllowSkeletonVast *bool `json:"allow_skeleton_vast,omitempty" mapstructure:"allow_skeleton_vast"` + // Placement contains placement-specific rules. + Placement *PlacementRulesConfig `json:"placement,omitempty" mapstructure:"placement"` + // Debug enables debug mode with additional output. + Debug *bool `json:"debug,omitempty" mapstructure:"debug"` +} + +// PlacementRulesConfig contains rules for validating and filtering bids. +type PlacementRulesConfig struct { + // Pricing contains price floor and ceiling rules. + Pricing *PricingRulesConfig `json:"pricing,omitempty" mapstructure:"pricing"` + // Advertiser contains advertiser-based filtering rules. + Advertiser *AdvertiserRulesConfig `json:"advertiser,omitempty" mapstructure:"advertiser"` + // Categories contains category-based filtering rules. + Categories *CategoryRulesConfig `json:"categories,omitempty" mapstructure:"categories"` + // PricingPlacement defines where to place pricing: "VAST_PRICING" or "EXTENSION". + PricingPlacement string `json:"pricing_placement,omitempty" mapstructure:"pricing_placement"` + // AdvertiserPlacement defines where to place advertiser: "ADVERTISER_TAG" or "EXTENSION". + AdvertiserPlacement string `json:"advertiser_placement,omitempty" mapstructure:"advertiser_placement"` + // Debug enables debug output for placement rules. + Debug *bool `json:"debug,omitempty" mapstructure:"debug"` +} + +// PricingRulesConfig defines pricing constraints for bid selection. +type PricingRulesConfig struct { + // FloorCPM is the minimum CPM allowed. + FloorCPM *float64 `json:"floor_cpm,omitempty" mapstructure:"floor_cpm"` + // CeilingCPM is the maximum CPM allowed (0 = no ceiling). + CeilingCPM *float64 `json:"ceiling_cpm,omitempty" mapstructure:"ceiling_cpm"` + // Currency is the currency for floor/ceiling values. + Currency string `json:"currency,omitempty" mapstructure:"currency"` +} + +// AdvertiserRulesConfig defines advertiser-based filtering. +type AdvertiserRulesConfig struct { + // BlockedDomains is a list of advertiser domains to reject. + BlockedDomains []string `json:"blocked_domains,omitempty" mapstructure:"blocked_domains"` + // AllowedDomains is a whitelist of allowed domains (empty = allow all). + AllowedDomains []string `json:"allowed_domains,omitempty" mapstructure:"allowed_domains"` +} + +// CategoryRulesConfig defines category-based filtering. +type CategoryRulesConfig struct { + // BlockedCategories is a list of IAB categories to reject. + BlockedCategories []string `json:"blocked_categories,omitempty" mapstructure:"blocked_categories"` + // AllowedCategories is a whitelist of allowed categories (empty = allow all). + AllowedCategories []string `json:"allowed_categories,omitempty" mapstructure:"allowed_categories"` +} + +// Default values for CTVVastConfig. +const ( + DefaultVastVersion = "3.0" + DefaultCurrency = "USD" + DefaultMaxAdsInPod = 10 + DefaultCollisionPolicy = "VAST_WINS" + DefaultReceiver = "GAM_SSU" + DefaultSelectionStrategy = "max_revenue" + + // Placement constants for pricing + PlacementVastPricing = "VAST_PRICING" + PlacementExtension = "EXTENSION" + + // Placement constants for advertiser + PlacementAdvertiserTag = "ADVERTISER_TAG" + // PlacementExtension is also used for advertiser +) + +// MergeCTVVastConfig merges configuration from host, account, and profile layers. +// The precedence order is: profile > account > host (profile values override account, which overrides host). +// Only non-zero values override; nil pointers and empty strings are considered "not set". +func MergeCTVVastConfig(host, account, profile *CTVVastConfig) CTVVastConfig { + result := CTVVastConfig{} + + // Start with host config + if host != nil { + result = mergeIntoConfig(result, *host) + } + + // Override with account config + if account != nil { + result = mergeIntoConfig(result, *account) + } + + // Override with profile config (highest precedence) + if profile != nil { + result = mergeIntoConfig(result, *profile) + } + + return result +} + +// mergeIntoConfig merges src into dst, where non-zero values in src override dst. +func mergeIntoConfig(dst, src CTVVastConfig) CTVVastConfig { + if src.Enabled != nil { + dst.Enabled = src.Enabled + } + if src.Receiver != "" { + dst.Receiver = src.Receiver + } + if src.DefaultCurrency != "" { + dst.DefaultCurrency = src.DefaultCurrency + } + if src.VastVersionDefault != "" { + dst.VastVersionDefault = src.VastVersionDefault + } + if src.MaxAdsInPod != 0 { + dst.MaxAdsInPod = src.MaxAdsInPod + } + if src.SelectionStrategy != "" { + dst.SelectionStrategy = src.SelectionStrategy + } + if src.CollisionPolicy != "" { + dst.CollisionPolicy = src.CollisionPolicy + } + if src.AllowSkeletonVast != nil { + dst.AllowSkeletonVast = src.AllowSkeletonVast + } + if src.Debug != nil { + dst.Debug = src.Debug + } + + // Merge placement rules + if src.Placement != nil { + if dst.Placement == nil { + dst.Placement = &PlacementRulesConfig{} + } + dst.Placement = mergePlacementRules(dst.Placement, src.Placement) + } + + return dst +} + +// mergePlacementRules merges placement rules from src into dst. +func mergePlacementRules(dst, src *PlacementRulesConfig) *PlacementRulesConfig { + if dst == nil { + dst = &PlacementRulesConfig{} + } + if src == nil { + return dst + } + + if src.Debug != nil { + dst.Debug = src.Debug + } + + // Merge pricing rules + if src.Pricing != nil { + if dst.Pricing == nil { + dst.Pricing = &PricingRulesConfig{} + } + dst.Pricing = mergePricingRules(dst.Pricing, src.Pricing) + } + + // Merge advertiser rules + if src.Advertiser != nil { + if dst.Advertiser == nil { + dst.Advertiser = &AdvertiserRulesConfig{} + } + dst.Advertiser = mergeAdvertiserRules(dst.Advertiser, src.Advertiser) + } + + // Merge category rules + if src.Categories != nil { + if dst.Categories == nil { + dst.Categories = &CategoryRulesConfig{} + } + dst.Categories = mergeCategoryRules(dst.Categories, src.Categories) + } + + return dst +} + +// mergePricingRules merges pricing rules from src into dst. +func mergePricingRules(dst, src *PricingRulesConfig) *PricingRulesConfig { + if src.FloorCPM != nil { + dst.FloorCPM = src.FloorCPM + } + if src.CeilingCPM != nil { + dst.CeilingCPM = src.CeilingCPM + } + if src.Currency != "" { + dst.Currency = src.Currency + } + return dst +} + +// mergeAdvertiserRules merges advertiser rules from src into dst. +func mergeAdvertiserRules(dst, src *AdvertiserRulesConfig) *AdvertiserRulesConfig { + if len(src.BlockedDomains) > 0 { + dst.BlockedDomains = src.BlockedDomains + } + if len(src.AllowedDomains) > 0 { + dst.AllowedDomains = src.AllowedDomains + } + return dst +} + +// mergeCategoryRules merges category rules from src into dst. +func mergeCategoryRules(dst, src *CategoryRulesConfig) *CategoryRulesConfig { + if len(src.BlockedCategories) > 0 { + dst.BlockedCategories = src.BlockedCategories + } + if len(src.AllowedCategories) > 0 { + dst.AllowedCategories = src.AllowedCategories + } + return dst +} + +// ReceiverConfig converts CTVVastConfig to ReceiverConfig with defaults applied. +// Default values: +// - VastVersionDefault: "3.0" +// - DefaultCurrency: "USD" +// - MaxAdsInPod: 10 +// - CollisionPolicy: "VAST_WINS" +// - Receiver: "GAM_SSU" +// - SelectionStrategy: "max_revenue" +func (cfg CTVVastConfig) ReceiverConfig() ReceiverConfig { + rc := ReceiverConfig{} + + // Apply receiver with default + if cfg.Receiver != "" { + rc.Receiver = ReceiverType(cfg.Receiver) + } else { + rc.Receiver = ReceiverType(DefaultReceiver) + } + + // Apply currency with default + if cfg.DefaultCurrency != "" { + rc.DefaultCurrency = cfg.DefaultCurrency + } else { + rc.DefaultCurrency = DefaultCurrency + } + + // Apply VAST version with default + if cfg.VastVersionDefault != "" { + rc.VastVersionDefault = cfg.VastVersionDefault + } else { + rc.VastVersionDefault = DefaultVastVersion + } + + // Apply max ads in pod with default + if cfg.MaxAdsInPod != 0 { + rc.MaxAdsInPod = cfg.MaxAdsInPod + } else { + rc.MaxAdsInPod = DefaultMaxAdsInPod + } + + // Apply selection strategy with default + if cfg.SelectionStrategy != "" { + rc.SelectionStrategy = SelectionStrategy(cfg.SelectionStrategy) + } else { + rc.SelectionStrategy = SelectionStrategy(DefaultSelectionStrategy) + } + + // Apply collision policy with default + if cfg.CollisionPolicy != "" { + rc.CollisionPolicy = CollisionPolicy(cfg.CollisionPolicy) + } else { + rc.CollisionPolicy = CollisionPolicy(DefaultCollisionPolicy) + } + + // Apply allow skeleton vast flag + if cfg.AllowSkeletonVast != nil { + rc.AllowSkeletonVast = *cfg.AllowSkeletonVast + } + + // Apply debug flag + if cfg.Debug != nil { + rc.Debug = *cfg.Debug + } + + // Apply placement rules + rc.Placement = cfg.buildPlacementRules() + + return rc +} + +// buildPlacementRules converts PlacementRulesConfig to PlacementRules. +func (cfg CTVVastConfig) buildPlacementRules() PlacementRules { + pr := PlacementRules{} + + if cfg.Placement == nil { + return pr + } + + if cfg.Placement.Debug != nil { + pr.Debug = *cfg.Placement.Debug + } + + // Set placement locations with defaults + pr.PricingPlacement = cfg.Placement.PricingPlacement + if pr.PricingPlacement == "" { + pr.PricingPlacement = PlacementVastPricing + } + pr.AdvertiserPlacement = cfg.Placement.AdvertiserPlacement + if pr.AdvertiserPlacement == "" { + pr.AdvertiserPlacement = PlacementAdvertiserTag + } + + // Build pricing rules + if cfg.Placement.Pricing != nil { + pr.Pricing = PricingRules{ + Currency: cfg.Placement.Pricing.Currency, + } + if cfg.Placement.Pricing.FloorCPM != nil { + pr.Pricing.FloorCPM = *cfg.Placement.Pricing.FloorCPM + } + if cfg.Placement.Pricing.CeilingCPM != nil { + pr.Pricing.CeilingCPM = *cfg.Placement.Pricing.CeilingCPM + } + if pr.Pricing.Currency == "" { + pr.Pricing.Currency = DefaultCurrency + } + } + + // Build advertiser rules + if cfg.Placement.Advertiser != nil { + pr.Advertiser = AdvertiserRules{ + BlockedDomains: cfg.Placement.Advertiser.BlockedDomains, + AllowedDomains: cfg.Placement.Advertiser.AllowedDomains, + } + } + + // Build category rules + if cfg.Placement.Categories != nil { + pr.Categories = CategoryRules{ + BlockedCategories: cfg.Placement.Categories.BlockedCategories, + AllowedCategories: cfg.Placement.Categories.AllowedCategories, + } + } + + return pr +} + +// IsEnabled returns true if the config is enabled. Returns false if Enabled is nil or false. +func (cfg CTVVastConfig) IsEnabled() bool { + return cfg.Enabled != nil && *cfg.Enabled +} + +// boolPtr is a helper function to create a pointer to a bool value. +func boolPtr(b bool) *bool { + return &b +} + +// float64Ptr is a helper function to create a pointer to a float64 value. +func float64Ptr(f float64) *float64 { + return &f +} diff --git a/modules/ctv/vast/config_test.go b/modules/ctv/vast/config_test.go new file mode 100644 index 00000000000..6de0712c603 --- /dev/null +++ b/modules/ctv/vast/config_test.go @@ -0,0 +1,388 @@ +package vast + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMergeCTVVastConfig_NilInputs(t *testing.T) { + result := MergeCTVVastConfig(nil, nil, nil) + assert.Equal(t, CTVVastConfig{}, result) +} + +func TestMergeCTVVastConfig_HostOnly(t *testing.T) { + host := &CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "EUR", + VastVersionDefault: "4.0", + MaxAdsInPod: 5, + SelectionStrategy: "balanced", + CollisionPolicy: "reject", + } + + result := MergeCTVVastConfig(host, nil, nil) + + assert.Equal(t, "GAM_SSU", result.Receiver) + assert.Equal(t, "EUR", result.DefaultCurrency) + assert.Equal(t, "4.0", result.VastVersionDefault) + assert.Equal(t, 5, result.MaxAdsInPod) + assert.Equal(t, "balanced", result.SelectionStrategy) + assert.Equal(t, "reject", result.CollisionPolicy) +} + +func TestMergeCTVVastConfig_AccountOverridesHost(t *testing.T) { + host := &CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "EUR", + VastVersionDefault: "4.0", + MaxAdsInPod: 5, + } + account := &CTVVastConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 10, + } + + result := MergeCTVVastConfig(host, account, nil) + + assert.Equal(t, "GAM_SSU", result.Receiver) // from host + assert.Equal(t, "USD", result.DefaultCurrency) // overridden by account + assert.Equal(t, "4.0", result.VastVersionDefault) // from host + assert.Equal(t, 10, result.MaxAdsInPod) // overridden by account +} + +func TestMergeCTVVastConfig_ProfileOverridesAll(t *testing.T) { + host := &CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "EUR", + VastVersionDefault: "4.0", + MaxAdsInPod: 5, + SelectionStrategy: "max_revenue", + } + account := &CTVVastConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 10, + } + profile := &CTVVastConfig{ + VastVersionDefault: "4.2", + MaxAdsInPod: 3, + SelectionStrategy: "min_duration", + } + + result := MergeCTVVastConfig(host, account, profile) + + assert.Equal(t, "GAM_SSU", result.Receiver) // from host + assert.Equal(t, "USD", result.DefaultCurrency) // from account + assert.Equal(t, "4.2", result.VastVersionDefault) // overridden by profile + assert.Equal(t, 3, result.MaxAdsInPod) // overridden by profile + assert.Equal(t, "min_duration", result.SelectionStrategy) // overridden by profile +} + +func TestMergeCTVVastConfig_BoolPointers(t *testing.T) { + trueVal := true + falseVal := false + + host := &CTVVastConfig{ + Enabled: &trueVal, + Debug: &falseVal, + } + account := &CTVVastConfig{ + Debug: &trueVal, + } + profile := &CTVVastConfig{ + Enabled: &falseVal, + } + + result := MergeCTVVastConfig(host, account, profile) + + assert.NotNil(t, result.Enabled) + assert.False(t, *result.Enabled) // overridden by profile + assert.NotNil(t, result.Debug) + assert.True(t, *result.Debug) // from account (profile didn't set it) +} + +func TestMergeCTVVastConfig_PlacementRules(t *testing.T) { + floor := 1.5 + ceiling := 50.0 + profileFloor := 2.0 + + host := &CTVVastConfig{ + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: &floor, + CeilingCPM: &ceiling, + Currency: "EUR", + }, + Advertiser: &AdvertiserRulesConfig{ + BlockedDomains: []string{"blocked.com"}, + }, + }, + } + account := &CTVVastConfig{ + Placement: &PlacementRulesConfig{ + Advertiser: &AdvertiserRulesConfig{ + BlockedDomains: []string{"account-blocked.com"}, + }, + Categories: &CategoryRulesConfig{ + BlockedCategories: []string{"IAB25"}, + }, + }, + } + profile := &CTVVastConfig{ + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: &profileFloor, + }, + }, + } + + result := MergeCTVVastConfig(host, account, profile) + + assert.NotNil(t, result.Placement) + assert.NotNil(t, result.Placement.Pricing) + assert.Equal(t, 2.0, *result.Placement.Pricing.FloorCPM) // from profile + assert.Equal(t, 50.0, *result.Placement.Pricing.CeilingCPM) // from host + assert.Equal(t, "EUR", result.Placement.Pricing.Currency) // from host + + assert.NotNil(t, result.Placement.Advertiser) + assert.Equal(t, []string{"account-blocked.com"}, result.Placement.Advertiser.BlockedDomains) // from account + + assert.NotNil(t, result.Placement.Categories) + assert.Equal(t, []string{"IAB25"}, result.Placement.Categories.BlockedCategories) // from account +} + +func TestReceiverConfig_Defaults(t *testing.T) { + cfg := CTVVastConfig{} + rc := cfg.ReceiverConfig() + + assert.Equal(t, ReceiverType("GAM_SSU"), rc.Receiver) + assert.Equal(t, "USD", rc.DefaultCurrency) + assert.Equal(t, "3.0", rc.VastVersionDefault) + assert.Equal(t, 10, rc.MaxAdsInPod) + assert.Equal(t, SelectionStrategy("max_revenue"), rc.SelectionStrategy) + assert.Equal(t, CollisionPolicy("VAST_WINS"), rc.CollisionPolicy) + assert.False(t, rc.Debug) +} + +func TestReceiverConfig_WithValues(t *testing.T) { + debug := true + cfg := CTVVastConfig{ + Receiver: "GENERIC", + DefaultCurrency: "EUR", + VastVersionDefault: "4.2", + MaxAdsInPod: 7, + SelectionStrategy: "balanced", + CollisionPolicy: "warn", + Debug: &debug, + } + rc := cfg.ReceiverConfig() + + assert.Equal(t, ReceiverType("GENERIC"), rc.Receiver) + assert.Equal(t, "EUR", rc.DefaultCurrency) + assert.Equal(t, "4.2", rc.VastVersionDefault) + assert.Equal(t, 7, rc.MaxAdsInPod) + assert.Equal(t, SelectionStrategy("balanced"), rc.SelectionStrategy) + assert.Equal(t, CollisionPolicy("warn"), rc.CollisionPolicy) + assert.True(t, rc.Debug) +} + +func TestReceiverConfig_PlacementRules(t *testing.T) { + floor := 1.5 + ceiling := 100.0 + debug := true + + cfg := CTVVastConfig{ + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: &floor, + CeilingCPM: &ceiling, + Currency: "EUR", + }, + Advertiser: &AdvertiserRulesConfig{ + BlockedDomains: []string{"blocked.com", "spam.com"}, + AllowedDomains: []string{"allowed.com"}, + }, + Categories: &CategoryRulesConfig{ + BlockedCategories: []string{"IAB25", "IAB26"}, + AllowedCategories: []string{"IAB1"}, + }, + Debug: &debug, + }, + } + rc := cfg.ReceiverConfig() + + assert.Equal(t, 1.5, rc.Placement.Pricing.FloorCPM) + assert.Equal(t, 100.0, rc.Placement.Pricing.CeilingCPM) + assert.Equal(t, "EUR", rc.Placement.Pricing.Currency) + + assert.Equal(t, []string{"blocked.com", "spam.com"}, rc.Placement.Advertiser.BlockedDomains) + assert.Equal(t, []string{"allowed.com"}, rc.Placement.Advertiser.AllowedDomains) + + assert.Equal(t, []string{"IAB25", "IAB26"}, rc.Placement.Categories.BlockedCategories) + assert.Equal(t, []string{"IAB1"}, rc.Placement.Categories.AllowedCategories) + + assert.True(t, rc.Placement.Debug) +} + +func TestReceiverConfig_PlacementPricingDefaultCurrency(t *testing.T) { + floor := 1.0 + cfg := CTVVastConfig{ + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: &floor, + // Currency not set + }, + }, + } + rc := cfg.ReceiverConfig() + + assert.Equal(t, "USD", rc.Placement.Pricing.Currency) +} + +func TestIsEnabled(t *testing.T) { + tests := []struct { + name string + enabled *bool + expected bool + }{ + { + name: "nil returns false", + enabled: nil, + expected: false, + }, + { + name: "true returns true", + enabled: boolPtr(true), + expected: true, + }, + { + name: "false returns false", + enabled: boolPtr(false), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := CTVVastConfig{Enabled: tt.enabled} + assert.Equal(t, tt.expected, cfg.IsEnabled()) + }) + } +} + +func TestMergeCTVVastConfig_FullLayerPrecedence(t *testing.T) { + // This test verifies the complete layering behavior: + // profile > account > host + + host := &CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "GBP", + VastVersionDefault: "3.0", + MaxAdsInPod: 5, + SelectionStrategy: "max_revenue", + CollisionPolicy: "reject", + Enabled: boolPtr(true), + Debug: boolPtr(false), + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: float64Ptr(1.0), + CeilingCPM: float64Ptr(100.0), + Currency: "GBP", + }, + Advertiser: &AdvertiserRulesConfig{ + BlockedDomains: []string{"host-blocked.com"}, + }, + }, + } + + account := &CTVVastConfig{ + DefaultCurrency: "EUR", + MaxAdsInPod: 8, + CollisionPolicy: "warn", + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: float64Ptr(2.0), + Currency: "EUR", + }, + Categories: &CategoryRulesConfig{ + BlockedCategories: []string{"IAB25"}, + }, + }, + } + + profile := &CTVVastConfig{ + VastVersionDefault: "4.2", + MaxAdsInPod: 3, + Debug: boolPtr(true), + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: float64Ptr(3.0), + }, + }, + } + + result := MergeCTVVastConfig(host, account, profile) + + // Verify precedence + assert.Equal(t, "GAM_SSU", result.Receiver) // host (only set there) + assert.Equal(t, "EUR", result.DefaultCurrency) // account overrides host + assert.Equal(t, "4.2", result.VastVersionDefault) // profile overrides host + assert.Equal(t, 3, result.MaxAdsInPod) // profile overrides account and host + assert.Equal(t, "max_revenue", result.SelectionStrategy) // host (only set there) + assert.Equal(t, "warn", result.CollisionPolicy) // account overrides host + assert.True(t, *result.Enabled) // host (only set there) + assert.True(t, *result.Debug) // profile overrides host + + // Verify nested placement rules precedence + assert.Equal(t, 3.0, *result.Placement.Pricing.FloorCPM) // profile overrides account and host + assert.Equal(t, 100.0, *result.Placement.Pricing.CeilingCPM) // host (only set there) + assert.Equal(t, "EUR", result.Placement.Pricing.Currency) // account overrides host + + assert.Equal(t, []string{"host-blocked.com"}, result.Placement.Advertiser.BlockedDomains) // host + assert.Equal(t, []string{"IAB25"}, result.Placement.Categories.BlockedCategories) // account +} + +func TestMergeCTVVastConfig_EmptyStringsDoNotOverride(t *testing.T) { + host := &CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "EUR", + } + account := &CTVVastConfig{ + Receiver: "", // empty string should not override + DefaultCurrency: "USD", + } + + result := MergeCTVVastConfig(host, account, nil) + + assert.Equal(t, "GAM_SSU", result.Receiver) // empty string didn't override + assert.Equal(t, "USD", result.DefaultCurrency) // non-empty string did override +} + +func TestMergeCTVVastConfig_ZeroIntDoesNotOverride(t *testing.T) { + host := &CTVVastConfig{ + MaxAdsInPod: 5, + } + account := &CTVVastConfig{ + MaxAdsInPod: 0, // zero should not override + } + + result := MergeCTVVastConfig(host, account, nil) + + assert.Equal(t, 5, result.MaxAdsInPod) // zero didn't override +} + +func TestBoolPtr(t *testing.T) { + truePtr := boolPtr(true) + falsePtr := boolPtr(false) + + assert.NotNil(t, truePtr) + assert.True(t, *truePtr) + assert.NotNil(t, falsePtr) + assert.False(t, *falsePtr) +} + +func TestFloat64Ptr(t *testing.T) { + ptr := float64Ptr(1.5) + assert.NotNil(t, ptr) + assert.Equal(t, 1.5, *ptr) +} diff --git a/modules/ctv/vast/enrich/enrich.go b/modules/ctv/vast/enrich/enrich.go new file mode 100644 index 00000000000..ed4f1704985 --- /dev/null +++ b/modules/ctv/vast/enrich/enrich.go @@ -0,0 +1,264 @@ +// Package enrich provides VAST ad enrichment capabilities. +package enrich + +import ( + "fmt" + "strings" + + "github.com/prebid/prebid-server/v3/modules/ctv/vast" + "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" +) + +// VastEnricher implements the Enricher interface. +// It uses CollisionPolicy "VAST_WINS" - existing VAST values are not overwritten. +type VastEnricher struct{} + +// NewEnricher creates a new VastEnricher instance. +func NewEnricher() *VastEnricher { + return &VastEnricher{} +} + +// Enrich adds tracking, extensions, and other data to a VAST ad. +// It implements the vast.Enricher interface. +// CollisionPolicy "VAST_WINS": existing values in VAST are preserved. +func (e *VastEnricher) Enrich(ad *model.Ad, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) ([]string, error) { + var warnings []string + + if ad == nil { + return warnings, nil + } + + // Only enrich InLine ads, not Wrapper ads + if ad.InLine == nil { + warnings = append(warnings, "skipping enrichment: ad is not InLine") + return warnings, nil + } + + inline := ad.InLine + + // Ensure Extensions exists for adding extension-based enrichments + if inline.Extensions == nil { + inline.Extensions = &model.Extensions{} + } + + // Enrich Pricing + pricingWarnings := e.enrichPricing(inline, meta, cfg) + warnings = append(warnings, pricingWarnings...) + + // Enrich Advertiser + advertiserWarnings := e.enrichAdvertiser(inline, meta, cfg) + warnings = append(warnings, advertiserWarnings...) + + // Enrich Duration + durationWarnings := e.enrichDuration(inline, meta) + warnings = append(warnings, durationWarnings...) + + // Enrich Categories (always as extension) + categoryWarnings := e.enrichCategories(inline, meta) + warnings = append(warnings, categoryWarnings...) + + // Add debug extension if enabled + if cfg.Debug || cfg.Placement.Debug { + e.addDebugExtension(inline, meta) + } + + return warnings, nil +} + +// enrichPricing adds pricing information if not present. +// VAST_WINS: only adds if InLine.Pricing is nil or empty. +func (e *VastEnricher) enrichPricing(inline *model.InLine, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) []string { + var warnings []string + + // Skip if no price to add + if meta.Price <= 0 { + return warnings + } + + // Check collision policy - VAST_WINS means don't overwrite existing + if inline.Pricing != nil && inline.Pricing.Value != "" { + warnings = append(warnings, "pricing: VAST_WINS - keeping existing pricing") + return warnings + } + + // Format the price value + priceStr := formatPrice(meta.Price) + currency := meta.Currency + if currency == "" { + currency = cfg.DefaultCurrency + } + if currency == "" { + currency = "USD" + } + + // Determine placement location + placement := cfg.Placement.PricingPlacement + if placement == "" { + placement = vast.PlacementVastPricing + } + + switch placement { + case vast.PlacementVastPricing: + inline.Pricing = &model.Pricing{ + Model: "CPM", + Currency: currency, + Value: priceStr, + } + case vast.PlacementExtension: + ext := model.ExtensionXML{ + Type: "pricing", + InnerXML: fmt.Sprintf("%s", currency, priceStr), + } + inline.Extensions.Extension = append(inline.Extensions.Extension, ext) + default: + // Default to VAST_PRICING + inline.Pricing = &model.Pricing{ + Model: "CPM", + Currency: currency, + Value: priceStr, + } + } + + return warnings +} + +// enrichAdvertiser adds advertiser information if not present. +// VAST_WINS: only adds if InLine.Advertiser is empty. +func (e *VastEnricher) enrichAdvertiser(inline *model.InLine, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) []string { + var warnings []string + + // Skip if no advertiser to add + if meta.Adomain == "" { + return warnings + } + + // Check collision policy - VAST_WINS means don't overwrite existing + if strings.TrimSpace(inline.Advertiser) != "" { + warnings = append(warnings, "advertiser: VAST_WINS - keeping existing advertiser") + return warnings + } + + // Determine placement location + placement := cfg.Placement.AdvertiserPlacement + if placement == "" { + placement = vast.PlacementAdvertiserTag + } + + switch placement { + case vast.PlacementAdvertiserTag: + inline.Advertiser = meta.Adomain + case vast.PlacementExtension: + ext := model.ExtensionXML{ + Type: "advertiser", + InnerXML: fmt.Sprintf("%s", escapeXML(meta.Adomain)), + } + inline.Extensions.Extension = append(inline.Extensions.Extension, ext) + default: + // Default to ADVERTISER_TAG + inline.Advertiser = meta.Adomain + } + + return warnings +} + +// enrichDuration adds duration to Linear creative if not present. +// VAST_WINS: only adds if Linear.Duration is empty. +func (e *VastEnricher) enrichDuration(inline *model.InLine, meta vast.CanonicalMeta) []string { + var warnings []string + + // Skip if no duration to add + if meta.DurSec <= 0 { + return warnings + } + + // Find the Linear creative + if inline.Creatives == nil || len(inline.Creatives.Creative) == 0 { + return warnings + } + + for i := range inline.Creatives.Creative { + creative := &inline.Creatives.Creative[i] + if creative.Linear == nil { + continue + } + + // Check collision policy - VAST_WINS means don't overwrite existing + if strings.TrimSpace(creative.Linear.Duration) != "" { + warnings = append(warnings, "duration: VAST_WINS - keeping existing duration") + continue + } + + // Set duration in HH:MM:SS format + creative.Linear.Duration = model.SecToHHMMSS(meta.DurSec) + } + + return warnings +} + +// enrichCategories adds IAB categories as an extension. +func (e *VastEnricher) enrichCategories(inline *model.InLine, meta vast.CanonicalMeta) []string { + var warnings []string + + // Skip if no categories to add + if len(meta.Cats) == 0 { + return warnings + } + + // Build category extension XML + var categoryXML strings.Builder + for _, cat := range meta.Cats { + categoryXML.WriteString(fmt.Sprintf("%s", escapeXML(cat))) + } + + ext := model.ExtensionXML{ + Type: "iab_category", + InnerXML: categoryXML.String(), + } + inline.Extensions.Extension = append(inline.Extensions.Extension, ext) + + return warnings +} + +// addDebugExtension adds OpenRTB debug information as an extension. +func (e *VastEnricher) addDebugExtension(inline *model.InLine, meta vast.CanonicalMeta) { + var debugXML strings.Builder + debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.BidID))) + debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.ImpID))) + if meta.DealID != "" { + debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.DealID))) + } + debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.Seat))) + debugXML.WriteString(fmt.Sprintf("%s", formatPrice(meta.Price))) + debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.Currency))) + + ext := model.ExtensionXML{ + Type: "openrtb", + InnerXML: debugXML.String(), + } + inline.Extensions.Extension = append(inline.Extensions.Extension, ext) +} + +// formatPrice formats a price value with appropriate precision. +func formatPrice(price float64) string { + // Use up to 4 decimal places, trimming trailing zeros + s := fmt.Sprintf("%.4f", price) + s = strings.TrimRight(s, "0") + s = strings.TrimRight(s, ".") + if s == "" { + return "0" + } + return s +} + +// escapeXML escapes special characters for XML content. +func escapeXML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +// Ensure VastEnricher implements Enricher interface. +var _ vast.Enricher = (*VastEnricher)(nil) diff --git a/modules/ctv/vast/enrich/enrich_test.go b/modules/ctv/vast/enrich/enrich_test.go new file mode 100644 index 00000000000..fca7d098100 --- /dev/null +++ b/modules/ctv/vast/enrich/enrich_test.go @@ -0,0 +1,672 @@ +package enrich + +import ( + "testing" + + "github.com/prebid/prebid-server/v3/modules/ctv/vast" + "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewEnricher(t *testing.T) { + enricher := NewEnricher() + assert.NotNil(t, enricher) +} + +func TestEnrich_NilAd(t *testing.T) { + enricher := NewEnricher() + meta := vast.CanonicalMeta{} + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(nil, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) +} + +func TestEnrich_WrapperAd(t *testing.T) { + enricher := NewEnricher() + ad := &model.Ad{ + ID: "wrapper", + Wrapper: &model.Wrapper{}, + } + meta := vast.CanonicalMeta{Price: 5.0} + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "not InLine") +} + +func TestEnrich_Pricing_VastWins_ExistingNotOverwritten(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = &model.Pricing{ + Model: "CPM", + Currency: "EUR", + Value: "10.00", + } + + meta := vast.CanonicalMeta{ + Price: 5.0, + Currency: "USD", + } + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + Placement: vast.PlacementRules{ + PricingPlacement: vast.PlacementVastPricing, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + + // Should have warning about VAST_WINS + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "VAST_WINS") + + // Original pricing should be preserved + assert.Equal(t, "EUR", ad.InLine.Pricing.Currency) + assert.Equal(t, "10.00", ad.InLine.Pricing.Value) +} + +func TestEnrich_Pricing_AddedWhenMissing(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + + meta := vast.CanonicalMeta{ + Price: 5.5, + Currency: "USD", + } + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + Placement: vast.PlacementRules{ + PricingPlacement: vast.PlacementVastPricing, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Pricing should be added + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "CPM", ad.InLine.Pricing.Model) + assert.Equal(t, "USD", ad.InLine.Pricing.Currency) + assert.Equal(t, "5.5", ad.InLine.Pricing.Value) +} + +func TestEnrich_Pricing_AsExtension(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + + meta := vast.CanonicalMeta{ + Price: 3.25, + Currency: "EUR", + } + cfg := vast.ReceiverConfig{ + Placement: vast.PlacementRules{ + PricingPlacement: vast.PlacementExtension, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Pricing should be nil (not added to VAST element) + assert.Nil(t, ad.InLine.Pricing) + + // Should have extension with pricing + require.NotNil(t, ad.InLine.Extensions) + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "pricing" { + found = true + assert.Contains(t, ext.InnerXML, "3.25") + assert.Contains(t, ext.InnerXML, "EUR") + assert.Contains(t, ext.InnerXML, "CPM") + } + } + assert.True(t, found, "pricing extension not found") +} + +func TestEnrich_Pricing_ZeroPriceNotAdded(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + + meta := vast.CanonicalMeta{ + Price: 0, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + assert.Nil(t, ad.InLine.Pricing) +} + +func TestEnrich_Advertiser_VastWins_ExistingNotOverwritten(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Advertiser = "Original Advertiser" + + meta := vast.CanonicalMeta{ + Adomain: "newadvertiser.com", + } + cfg := vast.ReceiverConfig{ + Placement: vast.PlacementRules{ + AdvertiserPlacement: vast.PlacementAdvertiserTag, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + + // Should have warning about VAST_WINS + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "VAST_WINS") + + // Original advertiser should be preserved + assert.Equal(t, "Original Advertiser", ad.InLine.Advertiser) +} + +func TestEnrich_Advertiser_AddedWhenMissing(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Advertiser = "" + + meta := vast.CanonicalMeta{ + Adomain: "example.com", + } + cfg := vast.ReceiverConfig{ + Placement: vast.PlacementRules{ + AdvertiserPlacement: vast.PlacementAdvertiserTag, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + assert.Equal(t, "example.com", ad.InLine.Advertiser) +} + +func TestEnrich_Advertiser_AsExtension(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Advertiser = "" + + meta := vast.CanonicalMeta{ + Adomain: "example.com", + } + cfg := vast.ReceiverConfig{ + Placement: vast.PlacementRules{ + AdvertiserPlacement: vast.PlacementExtension, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Advertiser tag should be empty + assert.Equal(t, "", ad.InLine.Advertiser) + + // Should have extension with advertiser + require.NotNil(t, ad.InLine.Extensions) + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "advertiser" { + found = true + assert.Contains(t, ext.InnerXML, "example.com") + } + } + assert.True(t, found, "advertiser extension not found") +} + +func TestEnrich_Duration_VastWins_ExistingNotOverwritten(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Creatives.Creative[0].Linear.Duration = "00:00:30" + + meta := vast.CanonicalMeta{ + DurSec: 15, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + + // Should have warning about VAST_WINS + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "VAST_WINS") + + // Original duration should be preserved + assert.Equal(t, "00:00:30", ad.InLine.Creatives.Creative[0].Linear.Duration) +} + +func TestEnrich_Duration_AddedWhenMissing(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Creatives.Creative[0].Linear.Duration = "" + + meta := vast.CanonicalMeta{ + DurSec: 15, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + assert.Equal(t, "00:00:15", ad.InLine.Creatives.Creative[0].Linear.Duration) +} + +func TestEnrich_Duration_ZeroNotAdded(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Creatives.Creative[0].Linear.Duration = "" + + meta := vast.CanonicalMeta{ + DurSec: 0, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + assert.Equal(t, "", ad.InLine.Creatives.Creative[0].Linear.Duration) +} + +func TestEnrich_Categories_AddedAsExtension(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + + meta := vast.CanonicalMeta{ + Cats: []string{"IAB1", "IAB2-1", "IAB3"}, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Should have extension with categories + require.NotNil(t, ad.InLine.Extensions) + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "iab_category" { + found = true + assert.Contains(t, ext.InnerXML, "IAB1") + assert.Contains(t, ext.InnerXML, "IAB2-1") + assert.Contains(t, ext.InnerXML, "IAB3") + } + } + assert.True(t, found, "iab_category extension not found") +} + +func TestEnrich_Categories_EmptyNotAdded(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + + meta := vast.CanonicalMeta{ + Cats: []string{}, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Should not have category extension + if ad.InLine.Extensions != nil { + for _, ext := range ad.InLine.Extensions.Extension { + assert.NotEqual(t, "iab_category", ext.Type) + } + } +} + +func TestEnrich_DebugExtension(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + + meta := vast.CanonicalMeta{ + BidID: "bid123", + ImpID: "imp456", + DealID: "deal789", + Seat: "bidder1", + Price: 2.5, + Currency: "USD", + } + cfg := vast.ReceiverConfig{ + Debug: true, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Should have openrtb debug extension + require.NotNil(t, ad.InLine.Extensions) + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "openrtb" { + found = true + assert.Contains(t, ext.InnerXML, "bid123") + assert.Contains(t, ext.InnerXML, "imp456") + assert.Contains(t, ext.InnerXML, "deal789") + assert.Contains(t, ext.InnerXML, "bidder1") + assert.Contains(t, ext.InnerXML, "2.5") + assert.Contains(t, ext.InnerXML, "USD") + } + } + assert.True(t, found, "openrtb extension not found") +} + +func TestEnrich_DebugExtension_NoDealID(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + + meta := vast.CanonicalMeta{ + BidID: "bid123", + ImpID: "imp456", + DealID: "", // No deal + Seat: "bidder1", + Price: 2.5, + Currency: "USD", + } + cfg := vast.ReceiverConfig{ + Debug: true, + } + + _, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + + // Should have openrtb debug extension without DealID + require.NotNil(t, ad.InLine.Extensions) + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "openrtb" { + assert.NotContains(t, ext.InnerXML, "") + } + } +} + +func TestEnrich_DebugExtension_PlacementDebug(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + + meta := vast.CanonicalMeta{ + BidID: "bid123", + } + cfg := vast.ReceiverConfig{ + Debug: false, // Global debug off + Placement: vast.PlacementRules{ + Debug: true, // Placement debug on + }, + } + + _, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + + // Should have openrtb debug extension + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "openrtb" { + found = true + } + } + assert.True(t, found, "openrtb extension not found when placement debug enabled") +} + +func TestEnrich_FullEnrichment(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + ad.InLine.Advertiser = "" + ad.InLine.Creatives.Creative[0].Linear.Duration = "" + + meta := vast.CanonicalMeta{ + BidID: "bid123", + ImpID: "imp456", + Seat: "bidder1", + Price: 5.5, + Currency: "USD", + Adomain: "advertiser.com", + Cats: []string{"IAB1", "IAB2"}, + DurSec: 30, + } + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + Debug: true, + Placement: vast.PlacementRules{ + PricingPlacement: vast.PlacementVastPricing, + AdvertiserPlacement: vast.PlacementAdvertiserTag, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Check all enrichments + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "5.5", ad.InLine.Pricing.Value) + assert.Equal(t, "advertiser.com", ad.InLine.Advertiser) + assert.Equal(t, "00:00:30", ad.InLine.Creatives.Creative[0].Linear.Duration) + + // Check extensions + require.NotNil(t, ad.InLine.Extensions) + hasCategory := false + hasOpenRTB := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "iab_category" { + hasCategory = true + } + if ext.Type == "openrtb" { + hasOpenRTB = true + } + } + assert.True(t, hasCategory) + assert.True(t, hasOpenRTB) +} + +func TestFormatPrice(t *testing.T) { + tests := []struct { + price float64 + expected string + }{ + {0, "0"}, + {1, "1"}, + {1.5, "1.5"}, + {1.50, "1.5"}, + {1.55, "1.55"}, + {1.555, "1.555"}, + {1.5555, "1.5555"}, + {1.55555, "1.5555"}, // Truncates to 4 decimals + {10.00, "10"}, + {0.001, "0.001"}, + {0.0001, "0.0001"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := formatPrice(tt.price) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEscapeXML(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "simple"}, + {"a & b", "a & b"}, + {"", "<tag>"}, + {`"quoted"`, ""quoted""}, + {"it's", "it's"}, + {"", "<a & 'b'>"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := escapeXML(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEnrich_XMLMarshalRoundTrip(t *testing.T) { + enricher := NewEnricher() + + // Parse sample VAST + sampleVAST := ` + + + + Test + Test Ad + + + + + + + + + + + + + +` + + parsedVast, err := model.ParseVastAdm(sampleVAST) + require.NoError(t, err) + require.Len(t, parsedVast.Ads, 1) + + ad := &parsedVast.Ads[0] + meta := vast.CanonicalMeta{ + BidID: "bid123", + ImpID: "imp456", + Price: 5.0, + Currency: "USD", + Adomain: "advertiser.com", + Cats: []string{"IAB1"}, + DurSec: 30, + } + cfg := vast.ReceiverConfig{ + Debug: true, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Marshal back to XML + xmlBytes, err := parsedVast.Marshal() + require.NoError(t, err) + + xmlStr := string(xmlBytes) + assert.Contains(t, xmlStr, "Pricing") + assert.Contains(t, xmlStr, "advertiser.com") + assert.Contains(t, xmlStr, "00:00:30") + assert.Contains(t, xmlStr, "iab_category") + assert.Contains(t, xmlStr, "openrtb") +} + +// createTestAd creates a test Ad with InLine and Linear creative +func createTestAd() *model.Ad { + return &model.Ad{ + ID: "test-ad", + InLine: &model.InLine{ + AdSystem: &model.AdSystem{Value: "Test"}, + AdTitle: "Test Ad", + Creatives: &model.Creatives{ + Creative: []model.Creative{ + { + ID: "creative1", + Linear: &model.Linear{ + Duration: "", + }, + }, + }, + }, + }, + } +} + +func TestEnrich_ExistingExtensionsPreserved(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Extensions = &model.Extensions{ + Extension: []model.ExtensionXML{ + {Type: "existing", InnerXML: "preserved"}, + }, + } + + meta := vast.CanonicalMeta{ + Cats: []string{"IAB1"}, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Should have both existing and new extensions + require.NotNil(t, ad.InLine.Extensions) + assert.GreaterOrEqual(t, len(ad.InLine.Extensions.Extension), 2) + + // Check existing is preserved + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "existing" { + found = true + assert.Contains(t, ext.InnerXML, "preserved") + } + } + assert.True(t, found, "existing extension should be preserved") +} + +func TestEnrich_DefaultCurrencyFallback(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + + meta := vast.CanonicalMeta{ + Price: 5.0, + Currency: "", // No currency in meta + } + cfg := vast.ReceiverConfig{ + DefaultCurrency: "GBP", + } + + _, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "GBP", ad.InLine.Pricing.Currency) +} + +func TestEnrich_NoCurrencyDefaultsToUSD(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + + meta := vast.CanonicalMeta{ + Price: 5.0, + Currency: "", // No currency + } + cfg := vast.ReceiverConfig{ + DefaultCurrency: "", // No default either + } + + _, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "USD", ad.InLine.Pricing.Currency) +} diff --git a/modules/ctv/vast/format/format.go b/modules/ctv/vast/format/format.go new file mode 100644 index 00000000000..1a7ad9426cb --- /dev/null +++ b/modules/ctv/vast/format/format.go @@ -0,0 +1,114 @@ +// Package format provides VAST XML formatting capabilities. +package format + +import ( + "encoding/xml" + + "github.com/prebid/prebid-server/v3/modules/ctv/vast" + "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" +) + +// VastFormatter implements the Formatter interface for GAM_SSU and other receivers. +type VastFormatter struct{} + +// NewFormatter creates a new VastFormatter instance. +func NewFormatter() *VastFormatter { + return &VastFormatter{} +} + +// Format converts enriched VAST ads into XML output. +// It implements the vast.Formatter interface. +// +// For each EnrichedAd, it creates one element with: +// - id attribute from meta.AdID if available, else meta.BidID +// - sequence attribute from EnrichedAd.Sequence (if multiple ads) +// - The enriched InLine subtree from the ad +func (f *VastFormatter) Format(ads []vast.EnrichedAd, cfg vast.ReceiverConfig) ([]byte, []string, error) { + var warnings []string + + // Determine VAST version + version := cfg.VastVersionDefault + if version == "" { + version = "4.0" + } + + // Handle no-ad case + if len(ads) == 0 { + noAdXML := model.BuildNoAdVast(version) + return noAdXML, warnings, nil + } + + // Build the VAST document + vastDoc := model.Vast{ + Version: version, + Ads: make([]model.Ad, 0, len(ads)), + } + + isPod := len(ads) > 1 + + for _, enriched := range ads { + if enriched.Ad == nil { + warnings = append(warnings, "skipping nil ad in format") + continue + } + + // Create a copy of the ad to avoid modifying the original + ad := copyAd(enriched.Ad) + + // Set Ad.ID from meta (prefer AdID if tracked, else BidID) + ad.ID = deriveAdID(enriched.Meta) + + // Set sequence attribute for pods (multiple ads) + if isPod && enriched.Sequence > 0 { + ad.Sequence = enriched.Sequence + } else if !isPod { + ad.Sequence = 0 // Don't set sequence for single ad + } + + vastDoc.Ads = append(vastDoc.Ads, *ad) + } + + // Handle case where all ads were nil + if len(vastDoc.Ads) == 0 { + noAdXML := model.BuildNoAdVast(version) + warnings = append(warnings, "all ads were nil, returning no-ad VAST") + return noAdXML, warnings, nil + } + + // Marshal with indentation + xmlBytes, err := xml.MarshalIndent(vastDoc, "", " ") + if err != nil { + return nil, warnings, err + } + + // Add XML declaration + output := append([]byte(xml.Header), xmlBytes...) + + return output, warnings, nil +} + +// deriveAdID determines the Ad ID from metadata. +// Uses BidID as the identifier (AdID is not currently tracked in CanonicalMeta). +func deriveAdID(meta vast.CanonicalMeta) string { + // BidID is the primary identifier + if meta.BidID != "" { + return meta.BidID + } + // Fallback to ImpID if BidID is empty + if meta.ImpID != "" { + return "imp-" + meta.ImpID + } + return "" +} + +// copyAd creates a shallow copy of an Ad to avoid modifying the original. +func copyAd(src *model.Ad) *model.Ad { + if src == nil { + return nil + } + ad := *src + return &ad +} + +// Ensure VastFormatter implements Formatter interface. +var _ vast.Formatter = (*VastFormatter)(nil) diff --git a/modules/ctv/vast/format/format_test.go b/modules/ctv/vast/format/format_test.go new file mode 100644 index 00000000000..86b404ac5e4 --- /dev/null +++ b/modules/ctv/vast/format/format_test.go @@ -0,0 +1,488 @@ +package format + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/prebid/prebid-server/v3/modules/ctv/vast" + "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewFormatter(t *testing.T) { + formatter := NewFormatter() + assert.NotNil(t, formatter) +} + +func TestFormat_EmptyAds_ReturnsNoAdVast(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + xmlBytes, warnings, err := formatter.Format([]vast.EnrichedAd{}, cfg) + require.NoError(t, err) + assert.Empty(t, warnings) + + expected := loadGolden(t, "no_ad.xml") + assertXMLEqual(t, expected, xmlBytes) +} + +func TestFormat_SingleAd(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + ads := []vast.EnrichedAd{ + { + Ad: createTestAd("bid-123", "TestAdServer", "Test Ad", "advertiser.com", "5.5", "00:00:30", "creative1", "https://example.com/video.mp4", []string{"IAB1"}), + Meta: vast.CanonicalMeta{BidID: "bid-123"}, + Sequence: 1, + }, + } + + xmlBytes, warnings, err := formatter.Format(ads, cfg) + require.NoError(t, err) + assert.Empty(t, warnings) + + expected := loadGolden(t, "single_ad.xml") + assertXMLEqual(t, expected, xmlBytes) +} + +func TestFormat_PodWithTwoAds(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + ads := []vast.EnrichedAd{ + { + Ad: createTestAd("bid-001", "TestAdServer", "First Ad", "first.com", "10", "00:00:15", "creative1", "https://example.com/first.mp4", nil), + Meta: vast.CanonicalMeta{BidID: "bid-001"}, + Sequence: 1, + }, + { + Ad: createTestAd("bid-002", "TestAdServer", "Second Ad", "second.com", "8", "00:00:30", "creative2", "https://example.com/second.mp4", nil), + Meta: vast.CanonicalMeta{BidID: "bid-002"}, + Sequence: 2, + }, + } + + xmlBytes, warnings, err := formatter.Format(ads, cfg) + require.NoError(t, err) + assert.Empty(t, warnings) + + expected := loadGolden(t, "pod_two_ads.xml") + assertXMLEqual(t, expected, xmlBytes) +} + +func TestFormat_PodWithThreeAds(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + ads := []vast.EnrichedAd{ + { + Ad: createMinimalAd("bid-alpha", "AdServer1", "Alpha Ad", "15", "USD", "00:00:10"), + Meta: vast.CanonicalMeta{BidID: "bid-alpha"}, + Sequence: 1, + }, + { + Ad: createMinimalAd("bid-beta", "AdServer2", "Beta Ad", "12", "EUR", "00:00:20"), + Meta: vast.CanonicalMeta{BidID: "bid-beta"}, + Sequence: 2, + }, + { + Ad: createMinimalAd("bid-gamma", "AdServer3", "Gamma Ad", "9", "USD", "00:00:15"), + Meta: vast.CanonicalMeta{BidID: "bid-gamma"}, + Sequence: 3, + }, + } + + xmlBytes, warnings, err := formatter.Format(ads, cfg) + require.NoError(t, err) + assert.Empty(t, warnings) + + expected := loadGolden(t, "pod_three_ads.xml") + assertXMLEqual(t, expected, xmlBytes) +} + +func TestFormat_NilAdsInList(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + ads := []vast.EnrichedAd{ + { + Ad: nil, // nil ad + Meta: vast.CanonicalMeta{BidID: "bid-nil"}, + Sequence: 1, + }, + { + Ad: createMinimalAd("bid-valid", "AdServer", "Valid Ad", "5", "USD", "00:00:15"), + Meta: vast.CanonicalMeta{BidID: "bid-valid"}, + Sequence: 2, + }, + } + + xmlBytes, warnings, err := formatter.Format(ads, cfg) + require.NoError(t, err) + assert.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "skipping nil ad") + + // Should still produce valid VAST with the non-nil ad + xmlStr := string(xmlBytes) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, "https://tracker.example.com/start") + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, "https://tracker.example.com/complete") +} + +func TestFormat_PreservesExtensions(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + ad := createMinimalAd("", "AdServer", "WithExtensions", "5", "USD", "00:00:15") + ad.InLine.Extensions = &model.Extensions{ + Extension: []model.ExtensionXML{ + {Type: "openrtb", InnerXML: "abc123bidder1"}, + {Type: "custom", InnerXML: "custom data"}, + }, + } + + ads := []vast.EnrichedAd{ + { + Ad: ad, + Meta: vast.CanonicalMeta{BidID: "bid-ext"}, + }, + } + + xmlBytes, _, err := formatter.Format(ads, cfg) + require.NoError(t, err) + + xmlStr := string(xmlBytes) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, "abc123") + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, "custom data") +} + +func TestDeriveAdID(t *testing.T) { + tests := []struct { + name string + meta vast.CanonicalMeta + expected string + }{ + { + name: "with BidID", + meta: vast.CanonicalMeta{BidID: "bid-123"}, + expected: "bid-123", + }, + { + name: "BidID takes precedence over ImpID", + meta: vast.CanonicalMeta{BidID: "bid-456", ImpID: "imp-789"}, + expected: "bid-456", + }, + { + name: "fallback to ImpID when BidID empty", + meta: vast.CanonicalMeta{BidID: "", ImpID: "imp-123"}, + expected: "imp-imp-123", + }, + { + name: "both empty", + meta: vast.CanonicalMeta{BidID: "", ImpID: ""}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := deriveAdID(tt.meta) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Helper functions + +func createTestAd(id, adSystem, adTitle, advertiser, price, duration, creativeID, mediaURL string, categories []string) *model.Ad { + ad := &model.Ad{ + ID: id, + InLine: &model.InLine{ + AdSystem: &model.AdSystem{Value: adSystem}, + AdTitle: adTitle, + Advertiser: advertiser, + Pricing: &model.Pricing{ + Model: "CPM", + Currency: "USD", + Value: price, + }, + Creatives: &model.Creatives{ + Creative: []model.Creative{ + { + ID: creativeID, + Linear: &model.Linear{ + Duration: duration, + MediaFiles: &model.MediaFiles{ + MediaFile: []model.MediaFile{ + { + Delivery: "progressive", + Type: "video/mp4", + Width: 1920, + Height: 1080, + Value: mediaURL, + }, + }, + }, + }, + }, + }, + }, + }, + } + + if len(categories) > 0 { + var catXML string + for _, cat := range categories { + catXML += "" + cat + "" + } + ad.InLine.Extensions = &model.Extensions{ + Extension: []model.ExtensionXML{ + {Type: "iab_category", InnerXML: catXML}, + }, + } + } + + return ad +} + +func createMinimalAd(id, adSystem, adTitle, price, currency, duration string) *model.Ad { + return &model.Ad{ + ID: id, + InLine: &model.InLine{ + AdSystem: &model.AdSystem{Value: adSystem}, + AdTitle: adTitle, + Pricing: &model.Pricing{ + Model: "CPM", + Currency: currency, + Value: price, + }, + Creatives: &model.Creatives{ + Creative: []model.Creative{ + { + Linear: &model.Linear{ + Duration: duration, + }, + }, + }, + }, + }, + } +} + +func loadGolden(t *testing.T, filename string) []byte { + t.Helper() + path := filepath.Join("testdata", filename) + data, err := os.ReadFile(path) + require.NoError(t, err, "failed to read golden file: %s", path) + return data +} + +// assertXMLEqual compares two XML documents by normalizing whitespace. +func assertXMLEqual(t *testing.T, expected, actual []byte) { + t.Helper() + expectedNorm := normalizeXML(string(expected)) + actualNorm := normalizeXML(string(actual)) + assert.Equal(t, expectedNorm, actualNorm) +} + +// normalizeXML normalizes XML for comparison by trimming whitespace. +func normalizeXML(xml string) string { + // Split into lines and trim each + lines := strings.Split(xml, "\n") + var normalized []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + normalized = append(normalized, trimmed) + } + } + return strings.Join(normalized, "\n") +} diff --git a/modules/ctv/vast/format/testdata/no_ad.xml b/modules/ctv/vast/format/testdata/no_ad.xml new file mode 100644 index 00000000000..1ebd9e11b24 --- /dev/null +++ b/modules/ctv/vast/format/testdata/no_ad.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/ctv/vast/format/testdata/pod_three_ads.xml b/modules/ctv/vast/format/testdata/pod_three_ads.xml new file mode 100644 index 00000000000..e48d1591089 --- /dev/null +++ b/modules/ctv/vast/format/testdata/pod_three_ads.xml @@ -0,0 +1,45 @@ + + + + + AdServer1 + Alpha Ad + 15 + + + + 00:00:10 + + + + + + + + AdServer2 + Beta Ad + 12 + + + + 00:00:20 + + + + + + + + AdServer3 + Gamma Ad + 9 + + + + 00:00:15 + + + + + + diff --git a/modules/ctv/vast/format/testdata/pod_two_ads.xml b/modules/ctv/vast/format/testdata/pod_two_ads.xml new file mode 100644 index 00000000000..be9c4ef1794 --- /dev/null +++ b/modules/ctv/vast/format/testdata/pod_two_ads.xml @@ -0,0 +1,39 @@ + + + + + TestAdServer + First Ad + first.com + 10 + + + + 00:00:15 + + + + + + + + + + + TestAdServer + Second Ad + second.com + 8 + + + + 00:00:30 + + + + + + + + + diff --git a/modules/ctv/vast/format/testdata/single_ad.xml b/modules/ctv/vast/format/testdata/single_ad.xml new file mode 100644 index 00000000000..28c514798b8 --- /dev/null +++ b/modules/ctv/vast/format/testdata/single_ad.xml @@ -0,0 +1,24 @@ + + + + + TestAdServer + Test Ad + advertiser.com + 5.5 + + + + 00:00:30 + + + + + + + + IAB1 + + + + diff --git a/modules/ctv/vast/handler.go b/modules/ctv/vast/handler.go new file mode 100644 index 00000000000..74b8562ef8a --- /dev/null +++ b/modules/ctv/vast/handler.go @@ -0,0 +1,167 @@ +package vast + +import ( + "context" + "net/http" + + "github.com/prebid/openrtb/v20/openrtb2" +) + +// Handler provides HTTP handling for CTV VAST requests. +type Handler struct { + // Config contains the default receiver configuration. + Config ReceiverConfig + // Selector selects bids from auction response. + Selector BidSelector + // Enricher enriches VAST ads with metadata. + Enricher Enricher + // Formatter formats enriched ads as VAST XML. + Formatter Formatter + // AuctionFunc is called to run the auction pipeline. + // This should be injected with the actual auction implementation. + AuctionFunc func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) +} + +// NewHandler creates a new VAST HTTP handler with default configuration. +// Note: Selector, Enricher, and Formatter must be set via With* methods +// before the handler can process requests. +func NewHandler() *Handler { + return &Handler{ + Config: DefaultConfig(), + } +} + +// ServeHTTP handles GET requests for CTV VAST ads. +// Query parameters (TODO: implement full parsing): +// - pod_id: Pod identifier +// - duration: Requested pod duration +// - max_ads: Maximum ads in pod +// +// Response: +// - 200 OK with Content-Type: application/xml on success +// - 204 No Content if no ads available +// - 400 Bad Request for invalid parameters +// - 500 Internal Server Error for processing failures +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Only accept GET requests + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Validate required dependencies + if h.Selector == nil || h.Enricher == nil || h.Formatter == nil { + http.Error(w, "Handler not properly configured", http.StatusInternalServerError) + return + } + + // TODO: Parse query parameters and build OpenRTB request + // This is a placeholder for the actual implementation: + // - Parse pod_id, duration, max_ads from query string + // - Build openrtb2.BidRequest with Video imp + // - Apply site/app context from query or headers + bidRequest := h.buildBidRequest(r) + + // TODO: Call auction pipeline + // This is a placeholder - actual implementation would: + // - Call the Prebid Server auction endpoint + // - Get BidResponse from exchange + var bidResponse *openrtb2.BidResponse + var err error + + if h.AuctionFunc != nil { + bidResponse, err = h.AuctionFunc(ctx, bidRequest) + if err != nil { + http.Error(w, "Auction failed: "+err.Error(), http.StatusInternalServerError) + return + } + } else { + // No auction function configured - return no-ad + bidResponse = &openrtb2.BidResponse{} + } + + // Build VAST from bid response + result, err := BuildVastFromBidResponse(ctx, bidRequest, bidResponse, h.Config, h.Selector, h.Enricher, h.Formatter) + if err != nil { + // Log error but still try to return valid VAST + // result.VastXML should contain no-ad VAST + } + + // Set response headers + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + + // Handle no-ad case + if result.NoAd { + w.WriteHeader(http.StatusOK) // Still 200 per VAST spec + } + + // Write VAST XML + w.Write(result.VastXML) +} + +// buildBidRequest creates an OpenRTB BidRequest from the HTTP request. +// TODO: Implement full parsing of query parameters. +func (h *Handler) buildBidRequest(r *http.Request) *openrtb2.BidRequest { + // Placeholder implementation + // TODO: Parse these from query string: + // - pod_id -> BidRequest.ID + // - duration -> Video.MaxDuration + // - max_ads -> Video.MaxAds (via pod extension) + // - slot_count -> multiple Imp objects + + query := r.URL.Query() + podID := query.Get("pod_id") + if podID == "" { + podID = "ctv-pod-1" + } + + return &openrtb2.BidRequest{ + ID: podID, + Imp: []openrtb2.Imp{ + { + ID: "imp-1", + Video: &openrtb2.Video{ + MIMEs: []string{"video/mp4"}, + MinDuration: 5, + MaxDuration: 30, + }, + }, + }, + Site: &openrtb2.Site{ + Page: r.Header.Get("Referer"), + }, + } +} + +// WithConfig sets the receiver configuration. +func (h *Handler) WithConfig(cfg ReceiverConfig) *Handler { + h.Config = cfg + return h +} + +// WithSelector sets the bid selector. +func (h *Handler) WithSelector(s BidSelector) *Handler { + h.Selector = s + return h +} + +// WithEnricher sets the VAST enricher. +func (h *Handler) WithEnricher(e Enricher) *Handler { + h.Enricher = e + return h +} + +// WithFormatter sets the VAST formatter. +func (h *Handler) WithFormatter(f Formatter) *Handler { + h.Formatter = f + return h +} + +// WithAuctionFunc sets the auction function. +func (h *Handler) WithAuctionFunc(fn func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error)) *Handler { + h.AuctionFunc = fn + return h +} diff --git a/modules/ctv/vast/model/model.go b/modules/ctv/vast/model/model.go new file mode 100644 index 00000000000..e15a3075f8e --- /dev/null +++ b/modules/ctv/vast/model/model.go @@ -0,0 +1,28 @@ +// Package model defines VAST XML data structures for CTV ad processing. +package model + +// VastAd represents a parsed VAST ad with its components. +// This is a higher-level domain object; for XML marshaling use the Vast struct. +type VastAd struct { + // ID is the unique identifier for this ad. + ID string + // AdSystem identifies the ad server that returned the ad. + AdSystem string + // AdTitle is the common name of the ad. + AdTitle string + // Description is a longer description of the ad. + Description string + // Advertiser is the name of the advertiser. + Advertiser string + // DurationSec is the duration of the creative in seconds. + DurationSec int + // ErrorURLs contains error tracking URLs. + ErrorURLs []string + // ImpressionURLs contains impression tracking URLs. + ImpressionURLs []string + // Sequence indicates the position in an ad pod. + Sequence int + // RawVAST contains the original VAST XML if preserved. + RawVAST []byte +} + diff --git a/modules/ctv/vast/model/parser.go b/modules/ctv/vast/model/parser.go new file mode 100644 index 00000000000..9e80b143502 --- /dev/null +++ b/modules/ctv/vast/model/parser.go @@ -0,0 +1,171 @@ +package model + +import ( + "encoding/xml" + "errors" + "strings" +) + +// ErrNotVAST indicates the input string does not appear to be VAST XML. +var ErrNotVAST = errors.New("input does not contain VAST XML") + +// ErrVASTParseFailure indicates the VAST XML could not be parsed. +var ErrVASTParseFailure = errors.New("failed to parse VAST XML") + +// ParseVastAdm parses a VAST XML string from an OpenRTB bid's AdM field. +// Returns an error if the input doesn't contain " '9' { + return false, errors.New("invalid character in number") + } + n = n*10 + int(c-'0') + } + *result = n + return true, nil +} + +// IsInLineAd returns true if the ad is an InLine ad (not a Wrapper). +func IsInLineAd(ad *Ad) bool { + return ad != nil && ad.InLine != nil +} + +// IsWrapperAd returns true if the ad is a Wrapper ad. +func IsWrapperAd(ad *Ad) bool { + return ad != nil && ad.Wrapper != nil +} diff --git a/modules/ctv/vast/model/parser_test.go b/modules/ctv/vast/model/parser_test.go new file mode 100644 index 00000000000..49f35ba0b42 --- /dev/null +++ b/modules/ctv/vast/model/parser_test.go @@ -0,0 +1,528 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Sample VAST XML strings for testing +const ( + sampleVAST30 = ` + + + + Test Ad Server + Test Video Ad + Test Advertiser Inc + + + + + 00:00:30 + + + + + + + + + + + + + +` + + sampleVAST40 = ` + + + + PBS-CTV + VAST 4.0 Test + 5.50 + + + 8465 + + 00:00:15 + + + + + + 1 + + + + +` + + sampleVASTWrapper = ` + + + + Wrapper System + + + + + + + + + + + + + +` + + sampleVASTNoVersion = ` + + + + No Version Ad + + + + 00:00:10 + + + + + +` + + sampleVASTMultipleAds = ` + + + + First Ad + + + + 00:00:15 + + + + + + + + Second Ad + + + + 00:00:30 + + + + + +` + + sampleVASTMinimal = `Min00:00:05` + + sampleVASTEmpty = ` + +` + + invalidXML = `Broken` + notVAST = `Not VAST` + emptyString = `` + justWhitespace = ` ` +) + +func TestParseVastAdm_ValidVAST30(t *testing.T) { + vast, err := ParseVastAdm(sampleVAST30) + require.NoError(t, err) + require.NotNil(t, vast) + + assert.Equal(t, "3.0", vast.Version) + require.Len(t, vast.Ads, 1) + + ad := vast.Ads[0] + assert.Equal(t, "12345", ad.ID) + assert.Equal(t, 1, ad.Sequence) + + require.NotNil(t, ad.InLine) + assert.Equal(t, "Test Video Ad", ad.InLine.AdTitle) + assert.Equal(t, "Test Advertiser Inc", ad.InLine.Advertiser) + + require.NotNil(t, ad.InLine.AdSystem) + assert.Equal(t, "Test Ad Server", ad.InLine.AdSystem.Value) + assert.Equal(t, "1.0", ad.InLine.AdSystem.Version) + + require.NotNil(t, ad.InLine.Creatives) + require.Len(t, ad.InLine.Creatives.Creative, 1) + + creative := ad.InLine.Creatives.Creative[0] + assert.Equal(t, "creative1", creative.ID) + + require.NotNil(t, creative.Linear) + assert.Equal(t, "00:00:30", creative.Linear.Duration) +} + +func TestParseVastAdm_ValidVAST40WithExtensions(t *testing.T) { + vast, err := ParseVastAdm(sampleVAST40) + require.NoError(t, err) + require.NotNil(t, vast) + + assert.Equal(t, "4.0", vast.Version) + require.Len(t, vast.Ads, 1) + + ad := vast.Ads[0] + require.NotNil(t, ad.InLine) + + // Check pricing + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "cpm", ad.InLine.Pricing.Model) + assert.Equal(t, "USD", ad.InLine.Pricing.Currency) + assert.Equal(t, "5.50", ad.InLine.Pricing.Value) + + // Check extensions + require.NotNil(t, ad.InLine.Extensions) + require.Len(t, ad.InLine.Extensions.Extension, 1) + assert.Equal(t, "waterfall", ad.InLine.Extensions.Extension[0].Type) + assert.Contains(t, ad.InLine.Extensions.Extension[0].InnerXML, "WaterfallIndex") + + // Check UniversalAdId + require.NotNil(t, ad.InLine.Creatives) + require.Len(t, ad.InLine.Creatives.Creative, 1) + creative := ad.InLine.Creatives.Creative[0] + require.NotNil(t, creative.UniversalAdID) + assert.Equal(t, "ad-id.org", creative.UniversalAdID.IDRegistry) + assert.Equal(t, "8465", creative.UniversalAdID.IDValue) +} + +func TestParseVastAdm_WrapperAd(t *testing.T) { + vast, err := ParseVastAdm(sampleVASTWrapper) + require.NoError(t, err) + require.NotNil(t, vast) + + require.Len(t, vast.Ads, 1) + ad := vast.Ads[0] + + assert.Nil(t, ad.InLine) + require.NotNil(t, ad.Wrapper) + assert.Equal(t, "Wrapper System", ad.Wrapper.AdSystem.Value) + + assert.True(t, IsWrapperAd(&ad)) + assert.False(t, IsInLineAd(&ad)) +} + +func TestParseVastAdm_NoVersion(t *testing.T) { + vast, err := ParseVastAdm(sampleVASTNoVersion) + require.NoError(t, err) + require.NotNil(t, vast) + + // Empty version is acceptable + assert.Equal(t, "", vast.Version) + require.Len(t, vast.Ads, 1) + assert.Equal(t, "No Version Ad", vast.Ads[0].InLine.AdTitle) +} + +func TestParseVastAdm_MultipleAds(t *testing.T) { + vast, err := ParseVastAdm(sampleVASTMultipleAds) + require.NoError(t, err) + require.NotNil(t, vast) + + require.Len(t, vast.Ads, 2) + assert.Equal(t, "ad1", vast.Ads[0].ID) + assert.Equal(t, 1, vast.Ads[0].Sequence) + assert.Equal(t, "ad2", vast.Ads[1].ID) + assert.Equal(t, 2, vast.Ads[1].Sequence) +} + +func TestParseVastAdm_MinimalVAST(t *testing.T) { + vast, err := ParseVastAdm(sampleVASTMinimal) + require.NoError(t, err) + require.NotNil(t, vast) + + assert.Equal(t, "3.0", vast.Version) + require.Len(t, vast.Ads, 1) + assert.Equal(t, "00:00:05", vast.Ads[0].InLine.Creatives.Creative[0].Linear.Duration) +} + +func TestParseVastAdm_EmptyVAST(t *testing.T) { + vast, err := ParseVastAdm(sampleVASTEmpty) + require.NoError(t, err) + require.NotNil(t, vast) + + assert.Equal(t, "3.0", vast.Version) + assert.Empty(t, vast.Ads) +} + +func TestParseVastAdm_NotVAST(t *testing.T) { + vast, err := ParseVastAdm(notVAST) + assert.ErrorIs(t, err, ErrNotVAST) + assert.Nil(t, vast) +} + +func TestParseVastAdm_EmptyString(t *testing.T) { + vast, err := ParseVastAdm(emptyString) + assert.ErrorIs(t, err, ErrNotVAST) + assert.Nil(t, vast) +} + +func TestParseVastAdm_Whitespace(t *testing.T) { + vast, err := ParseVastAdm(justWhitespace) + assert.ErrorIs(t, err, ErrNotVAST) + assert.Nil(t, vast) +} + +func TestParseVastAdm_InvalidXML(t *testing.T) { + vast, err := ParseVastAdm(invalidXML) + assert.ErrorIs(t, err, ErrVASTParseFailure) + assert.Nil(t, vast) +} + +func TestParseVastOrSkeleton_Success(t *testing.T) { + cfg := ParserConfig{ + AllowSkeletonVast: true, + VastVersionDefault: "3.0", + } + + vast, warnings, err := ParseVastOrSkeleton(sampleVAST30, cfg) + require.NoError(t, err) + require.NotNil(t, vast) + assert.Empty(t, warnings) + assert.Equal(t, "3.0", vast.Version) +} + +func TestParseVastOrSkeleton_FailWithSkeleton(t *testing.T) { + cfg := ParserConfig{ + AllowSkeletonVast: true, + VastVersionDefault: "4.0", + } + + vast, warnings, err := ParseVastOrSkeleton(notVAST, cfg) + require.NoError(t, err) + require.NotNil(t, vast) + + // Should return skeleton + assert.Equal(t, "4.0", vast.Version) + require.Len(t, vast.Ads, 1) + assert.Equal(t, "PBS-CTV", vast.Ads[0].InLine.AdSystem.Value) + + // Should have warning + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "VAST parse failed") +} + +func TestParseVastOrSkeleton_FailWithoutSkeleton(t *testing.T) { + cfg := ParserConfig{ + AllowSkeletonVast: false, + VastVersionDefault: "3.0", + } + + vast, warnings, err := ParseVastOrSkeleton(notVAST, cfg) + assert.Error(t, err) + assert.Nil(t, vast) + assert.Empty(t, warnings) +} + +func TestParseVastOrSkeleton_InvalidXMLWithSkeleton(t *testing.T) { + cfg := ParserConfig{ + AllowSkeletonVast: true, + VastVersionDefault: "3.0", + } + + vast, warnings, err := ParseVastOrSkeleton(invalidXML, cfg) + require.NoError(t, err) + require.NotNil(t, vast) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "VAST parse failed") +} + +func TestParseVastOrSkeleton_DefaultVersion(t *testing.T) { + cfg := ParserConfig{ + AllowSkeletonVast: true, + VastVersionDefault: "", // Should default to "3.0" + } + + vast, _, err := ParseVastOrSkeleton(notVAST, cfg) + require.NoError(t, err) + require.NotNil(t, vast) + assert.Equal(t, "3.0", vast.Version) +} + +func TestParseVastFromBytes(t *testing.T) { + data := []byte(sampleVASTMinimal) + vast, err := ParseVastFromBytes(data) + require.NoError(t, err) + require.NotNil(t, vast) + assert.Equal(t, "3.0", vast.Version) +} + +func TestExtractFirstAd(t *testing.T) { + tests := []struct { + name string + vast *Vast + expectID string + expectNil bool + }{ + { + name: "nil vast", + vast: nil, + expectNil: true, + }, + { + name: "empty ads", + vast: &Vast{Ads: []Ad{}}, + expectNil: true, + }, + { + name: "single ad", + vast: &Vast{Ads: []Ad{{ID: "first"}}}, + expectID: "first", + }, + { + name: "multiple ads", + vast: &Vast{Ads: []Ad{{ID: "first"}, {ID: "second"}}}, + expectID: "first", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ad := ExtractFirstAd(tt.vast) + if tt.expectNil { + assert.Nil(t, ad) + } else { + require.NotNil(t, ad) + assert.Equal(t, tt.expectID, ad.ID) + } + }) + } +} + +func TestExtractDuration(t *testing.T) { + tests := []struct { + name string + xml string + expected string + }{ + { + name: "inline with duration", + xml: sampleVAST30, + expected: "00:00:30", + }, + { + name: "minimal vast", + xml: sampleVASTMinimal, + expected: "00:00:05", + }, + { + name: "empty vast", + xml: sampleVASTEmpty, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vast, err := ParseVastAdm(tt.xml) + require.NoError(t, err) + duration := ExtractDuration(vast) + assert.Equal(t, tt.expected, duration) + }) + } +} + +func TestParseDurationToSeconds(t *testing.T) { + tests := []struct { + name string + duration string + expected int + }{ + {"empty", "", 0}, + {"zero", "00:00:00", 0}, + {"5 seconds", "00:00:05", 5}, + {"30 seconds", "00:00:30", 30}, + {"1 minute", "00:01:00", 60}, + {"1 minute 30 seconds", "00:01:30", 90}, + {"1 hour", "01:00:00", 3600}, + {"1 hour 30 minutes 45 seconds", "01:30:45", 5445}, + {"with milliseconds", "00:00:30.500", 30}, + {"invalid format", "30", 0}, + {"invalid chars", "00:0a:30", 0}, + {"too few parts", "00:30", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseDurationToSeconds(tt.duration) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsInLineAd(t *testing.T) { + assert.False(t, IsInLineAd(nil)) + assert.False(t, IsInLineAd(&Ad{})) + assert.False(t, IsInLineAd(&Ad{Wrapper: &Wrapper{}})) + assert.True(t, IsInLineAd(&Ad{InLine: &InLine{}})) +} + +func TestIsWrapperAd(t *testing.T) { + assert.False(t, IsWrapperAd(nil)) + assert.False(t, IsWrapperAd(&Ad{})) + assert.False(t, IsWrapperAd(&Ad{InLine: &InLine{}})) + assert.True(t, IsWrapperAd(&Ad{Wrapper: &Wrapper{}})) +} + +func TestParseVastAdm_PreservesInnerXML(t *testing.T) { + // Test that unknown elements are preserved via InnerXML + customVAST := ` + + + + Custom Ad + Custom Value + + + + 00:00:15 + Some Data + + + + + +` + + vast, err := ParseVastAdm(customVAST) + require.NoError(t, err) + require.NotNil(t, vast) + + // InnerXML fields should contain the unknown elements + require.Len(t, vast.Ads, 1) + require.NotNil(t, vast.Ads[0].InLine) + + // The InnerXML on InLine should contain CustomElement + assert.Contains(t, vast.Ads[0].InLine.InnerXML, "CustomElement") +} + +func TestRoundTrip_ParseMarshalParse(t *testing.T) { + // Parse original + vast1, err := ParseVastAdm(sampleVAST30) + require.NoError(t, err) + + // Marshal back to XML + xml1, err := vast1.Marshal() + require.NoError(t, err) + + // Parse again + vast2, err := ParseVastAdm(string(xml1)) + require.NoError(t, err) + + // Compare key fields + assert.Equal(t, vast1.Version, vast2.Version) + require.Len(t, vast2.Ads, len(vast1.Ads)) + assert.Equal(t, vast1.Ads[0].ID, vast2.Ads[0].ID) + assert.Equal(t, vast1.Ads[0].InLine.AdTitle, vast2.Ads[0].InLine.AdTitle) +} diff --git a/modules/ctv/vast/model/vast_xml.go b/modules/ctv/vast/model/vast_xml.go new file mode 100644 index 00000000000..fc6dc45e03d --- /dev/null +++ b/modules/ctv/vast/model/vast_xml.go @@ -0,0 +1,282 @@ +package model + +import ( + "encoding/xml" + "fmt" +) + +// Vast represents the root VAST XML element. +type Vast struct { + XMLName xml.Name `xml:"VAST"` + Version string `xml:"version,attr,omitempty"` + Ads []Ad `xml:"Ad"` +} + +// Ad represents a VAST Ad element. +type Ad struct { + ID string `xml:"id,attr,omitempty"` + Sequence int `xml:"sequence,attr,omitempty"` + InLine *InLine `xml:"InLine,omitempty"` + Wrapper *Wrapper `xml:"Wrapper,omitempty"` + // InnerXML preserves unknown nodes if needed + InnerXML string `xml:",innerxml"` +} + +// InLine represents a VAST InLine element containing the ad data. +type InLine struct { + AdSystem *AdSystem `xml:"AdSystem,omitempty"` + AdTitle string `xml:"AdTitle,omitempty"` + Advertiser string `xml:"Advertiser,omitempty"` + Description string `xml:"Description,omitempty"` + Error string `xml:"Error,omitempty"` + Impressions []Impression `xml:"Impression,omitempty"` + Pricing *Pricing `xml:"Pricing,omitempty"` + Creatives *Creatives `xml:"Creatives,omitempty"` + Extensions *Extensions `xml:"Extensions,omitempty"` + // InnerXML preserves unknown nodes + InnerXML string `xml:",innerxml"` +} + +// Wrapper represents a VAST Wrapper element for wrapped ads. +type Wrapper struct { + AdSystem *AdSystem `xml:"AdSystem,omitempty"` + VASTAdTagURI string `xml:"VASTAdTagURI,omitempty"` + Error string `xml:"Error,omitempty"` + Impressions []Impression `xml:"Impression,omitempty"` + Creatives *Creatives `xml:"Creatives,omitempty"` + Extensions *Extensions `xml:"Extensions,omitempty"` + // InnerXML preserves unknown nodes + InnerXML string `xml:",innerxml"` +} + +// AdSystem identifies the ad server that returned the ad. +type AdSystem struct { + Version string `xml:"version,attr,omitempty"` + Value string `xml:",chardata"` +} + +// Impression represents an impression tracking URL. +type Impression struct { + ID string `xml:"id,attr,omitempty"` + Value string `xml:",cdata"` +} + +// Pricing contains pricing information for the ad. +type Pricing struct { + Model string `xml:"model,attr,omitempty"` + Currency string `xml:"currency,attr,omitempty"` + Value string `xml:",chardata"` +} + +// Creatives contains a list of Creative elements. +type Creatives struct { + Creative []Creative `xml:"Creative,omitempty"` +} + +// Creative represents a VAST Creative element. +type Creative struct { + ID string `xml:"id,attr,omitempty"` + AdID string `xml:"adId,attr,omitempty"` + Sequence int `xml:"sequence,attr,omitempty"` + UniversalAdID *UniversalAdId `xml:"UniversalAdId,omitempty"` + Linear *Linear `xml:"Linear,omitempty"` + // InnerXML preserves unknown nodes + InnerXML string `xml:",innerxml"` +} + +// UniversalAdId provides a unique creative identifier across systems. +type UniversalAdId struct { + IDRegistry string `xml:"idRegistry,attr,omitempty"` + IDValue string `xml:"idValue,attr,omitempty"` + Value string `xml:",chardata"` +} + +// Linear represents a linear (video) creative. +type Linear struct { + SkipOffset string `xml:"skipoffset,attr,omitempty"` + Duration string `xml:"Duration,omitempty"` + MediaFiles *MediaFiles `xml:"MediaFiles,omitempty"` + VideoClicks *VideoClicks `xml:"VideoClicks,omitempty"` + TrackingEvents *TrackingEvents `xml:"TrackingEvents,omitempty"` + AdParameters *AdParameters `xml:"AdParameters,omitempty"` + // InnerXML preserves unknown nodes + InnerXML string `xml:",innerxml"` +} + +// MediaFiles contains a list of MediaFile elements. +type MediaFiles struct { + MediaFile []MediaFile `xml:"MediaFile,omitempty"` +} + +// MediaFile represents a video media file. +type MediaFile struct { + ID string `xml:"id,attr,omitempty"` + Delivery string `xml:"delivery,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` + Width int `xml:"width,attr,omitempty"` + Height int `xml:"height,attr,omitempty"` + Bitrate int `xml:"bitrate,attr,omitempty"` + MinBitrate int `xml:"minBitrate,attr,omitempty"` + MaxBitrate int `xml:"maxBitrate,attr,omitempty"` + Scalable bool `xml:"scalable,attr,omitempty"` + MaintainAspectRatio bool `xml:"maintainAspectRatio,attr,omitempty"` + Codec string `xml:"codec,attr,omitempty"` + Value string `xml:",cdata"` +} + +// VideoClicks contains click tracking URLs for video ads. +type VideoClicks struct { + ClickThrough *ClickThrough `xml:"ClickThrough,omitempty"` + ClickTracking []ClickTracking `xml:"ClickTracking,omitempty"` + CustomClick []CustomClick `xml:"CustomClick,omitempty"` +} + +// ClickThrough represents the landing page URL. +type ClickThrough struct { + ID string `xml:"id,attr,omitempty"` + Value string `xml:",cdata"` +} + +// ClickTracking represents a click tracking URL. +type ClickTracking struct { + ID string `xml:"id,attr,omitempty"` + Value string `xml:",cdata"` +} + +// CustomClick represents a custom click URL. +type CustomClick struct { + ID string `xml:"id,attr,omitempty"` + Value string `xml:",cdata"` +} + +// TrackingEvents contains tracking URLs for various playback events. +type TrackingEvents struct { + Tracking []Tracking `xml:"Tracking,omitempty"` +} + +// Tracking represents a single tracking event. +type Tracking struct { + Event string `xml:"event,attr,omitempty"` + Offset string `xml:"offset,attr,omitempty"` + Value string `xml:",cdata"` +} + +// AdParameters holds custom parameters for the ad. +type AdParameters struct { + XMLEncoded bool `xml:"xmlEncoded,attr,omitempty"` + Value string `xml:",cdata"` +} + +// Extensions contains a list of Extension elements. +type Extensions struct { + Extension []ExtensionXML `xml:"Extension,omitempty"` +} + +// ExtensionXML represents a VAST extension element. +type ExtensionXML struct { + Type string `xml:"type,attr,omitempty"` + // InnerXML preserves the extension content + InnerXML string `xml:",innerxml"` +} + +// SecToHHMMSS converts seconds to HH:MM:SS format used in VAST Duration. +func SecToHHMMSS(seconds int) string { + if seconds < 0 { + seconds = 0 + } + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + secs := seconds % 60 + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs) +} + +// BuildNoAdVast creates a VAST response indicating no ad is available. +// This is a valid VAST document with no Ad elements. +func BuildNoAdVast(version string) []byte { + if version == "" { + version = "3.0" + } + vast := Vast{ + Version: version, + Ads: []Ad{}, + } + output, err := xml.MarshalIndent(vast, "", " ") + if err != nil { + // Fallback to minimal valid VAST + return []byte(fmt.Sprintf(``, version)) + } + return append([]byte(xml.Header), output...) +} + +// BuildSkeletonInlineVast creates a minimal VAST document with one InLine ad. +// This skeleton can be used as a template to fill in with actual ad data. +func BuildSkeletonInlineVast(version string) *Vast { + if version == "" { + version = "3.0" + } + return &Vast{ + Version: version, + Ads: []Ad{ + { + ID: "1", + Sequence: 1, + InLine: &InLine{ + AdSystem: &AdSystem{ + Value: "PBS-CTV", + }, + AdTitle: "Ad", + Creatives: &Creatives{ + Creative: []Creative{ + { + ID: "1", + Sequence: 1, + Linear: &Linear{ + Duration: "00:00:00", + }, + }, + }, + }, + }, + }, + }, + } +} + +// BuildSkeletonInlineVastWithDuration creates a minimal VAST document with specified duration. +func BuildSkeletonInlineVastWithDuration(version string, durationSec int) *Vast { + vast := BuildSkeletonInlineVast(version) + if len(vast.Ads) > 0 && vast.Ads[0].InLine != nil && + vast.Ads[0].InLine.Creatives != nil && + len(vast.Ads[0].InLine.Creatives.Creative) > 0 && + vast.Ads[0].InLine.Creatives.Creative[0].Linear != nil { + vast.Ads[0].InLine.Creatives.Creative[0].Linear.Duration = SecToHHMMSS(durationSec) + } + return vast +} + +// Marshal serializes the Vast struct to XML bytes with XML header. +func (v *Vast) Marshal() ([]byte, error) { + output, err := xml.MarshalIndent(v, "", " ") + if err != nil { + return nil, err + } + return append([]byte(xml.Header), output...), nil +} + +// MarshalCompact serializes the Vast struct to XML bytes without indentation. +func (v *Vast) MarshalCompact() ([]byte, error) { + output, err := xml.Marshal(v) + if err != nil { + return nil, err + } + return append([]byte(xml.Header), output...), nil +} + +// Unmarshal parses XML bytes into a Vast struct. +func Unmarshal(data []byte) (*Vast, error) { + var vast Vast + if err := xml.Unmarshal(data, &vast); err != nil { + return nil, err + } + return &vast, nil +} diff --git a/modules/ctv/vast/model/vast_xml_test.go b/modules/ctv/vast/model/vast_xml_test.go new file mode 100644 index 00000000000..6fb47bf4c92 --- /dev/null +++ b/modules/ctv/vast/model/vast_xml_test.go @@ -0,0 +1,447 @@ +package model + +import ( + "encoding/xml" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecToHHMMSS(t *testing.T) { + tests := []struct { + name string + seconds int + expected string + }{ + {"zero", 0, "00:00:00"}, + {"negative", -5, "00:00:00"}, + {"30 seconds", 30, "00:00:30"}, + {"1 minute", 60, "00:01:00"}, + {"1 minute 30 seconds", 90, "00:01:30"}, + {"1 hour", 3600, "01:00:00"}, + {"1 hour 30 minutes 45 seconds", 5445, "01:30:45"}, + {"2 hours", 7200, "02:00:00"}, + {"typical ad 15 seconds", 15, "00:00:15"}, + {"typical ad 30 seconds", 30, "00:00:30"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SecToHHMMSS(tt.seconds) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildNoAdVast(t *testing.T) { + tests := []struct { + name string + version string + }{ + {"default version", ""}, + {"version 3.0", "3.0"}, + {"version 4.0", "4.0"}, + {"version 4.2", "4.2"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BuildNoAdVast(tt.version) + require.NotEmpty(t, result) + + // Should contain XML header + assert.True(t, strings.HasPrefix(string(result), "`) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, `TestSystem`) + assert.Contains(t, xmlStr, `Test Ad`) + assert.Contains(t, xmlStr, `Test Advertiser`) + assert.Contains(t, xmlStr, `5.00`) + assert.Contains(t, xmlStr, `00:00:30`) + assert.Contains(t, xmlStr, ``) +} + +func TestVast_MarshalCompact(t *testing.T) { + vast := BuildSkeletonInlineVast("3.0") + output, err := vast.MarshalCompact() + require.NoError(t, err) + require.NotEmpty(t, output) + + xmlStr := string(output) + // Compact should not have newlines in the body + assert.Contains(t, xmlStr, ` + + + + TestAdServer + Sample Ad + Sample Inc + 10.50 + + + + 00:00:15 + + + + + +`) + + vast, err := Unmarshal(xmlData) + require.NoError(t, err) + require.NotNil(t, vast) + + assert.Equal(t, "3.0", vast.Version) + require.Len(t, vast.Ads, 1) + + ad := vast.Ads[0] + assert.Equal(t, "test-ad", ad.ID) + assert.Equal(t, 1, ad.Sequence) + + require.NotNil(t, ad.InLine) + assert.Equal(t, "Sample Ad", ad.InLine.AdTitle) + assert.Equal(t, "Sample Inc", ad.InLine.Advertiser) + + require.NotNil(t, ad.InLine.AdSystem) + assert.Equal(t, "2.0", ad.InLine.AdSystem.Version) + assert.Equal(t, "TestAdServer", ad.InLine.AdSystem.Value) + + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "cpm", ad.InLine.Pricing.Model) + assert.Equal(t, "EUR", ad.InLine.Pricing.Currency) + assert.Equal(t, "10.50", ad.InLine.Pricing.Value) + + require.NotNil(t, ad.InLine.Creatives) + require.Len(t, ad.InLine.Creatives.Creative, 1) + creative := ad.InLine.Creatives.Creative[0] + assert.Equal(t, "c1", creative.ID) + + require.NotNil(t, creative.Linear) + assert.Equal(t, "00:00:15", creative.Linear.Duration) +} + +func TestUnmarshal_WithExtensions(t *testing.T) { + xmlData := []byte(` + + + + Ad with Extensions + + + + 00:00:30 + + + + + + some value + + + test + + + + +`) + + vast, err := Unmarshal(xmlData) + require.NoError(t, err) + require.NotNil(t, vast) + require.Len(t, vast.Ads, 1) + require.NotNil(t, vast.Ads[0].InLine) + require.NotNil(t, vast.Ads[0].InLine.Extensions) + require.Len(t, vast.Ads[0].InLine.Extensions.Extension, 2) + + ext1 := vast.Ads[0].InLine.Extensions.Extension[0] + assert.Equal(t, "waterfall", ext1.Type) + assert.Contains(t, ext1.InnerXML, "CustomData") + + ext2 := vast.Ads[0].InLine.Extensions.Extension[1] + assert.Equal(t, "prebid", ext2.Type) + assert.Contains(t, ext2.InnerXML, "BidInfo") +} + +func TestUnmarshal_WrapperAd(t *testing.T) { + xmlData := []byte(` + + + + Wrapper System + + + + +`) + + vast, err := Unmarshal(xmlData) + require.NoError(t, err) + require.NotNil(t, vast) + require.Len(t, vast.Ads, 1) + + ad := vast.Ads[0] + assert.Equal(t, "wrapper-ad", ad.ID) + assert.Nil(t, ad.InLine) + require.NotNil(t, ad.Wrapper) + assert.Equal(t, "Wrapper System", ad.Wrapper.AdSystem.Value) +} + +func TestRoundTrip(t *testing.T) { + original := &Vast{ + Version: "4.0", + Ads: []Ad{ + { + ID: "roundtrip-test", + Sequence: 1, + InLine: &InLine{ + AdSystem: &AdSystem{Value: "PBS"}, + AdTitle: "Round Trip Test", + Creatives: &Creatives{ + Creative: []Creative{ + { + ID: "c1", + Linear: &Linear{ + Duration: "00:00:15", + }, + }, + }, + }, + }, + }, + }, + } + + // Marshal + xmlBytes, err := original.Marshal() + require.NoError(t, err) + + // Unmarshal + parsed, err := Unmarshal(xmlBytes) + require.NoError(t, err) + + // Verify + assert.Equal(t, original.Version, parsed.Version) + require.Len(t, parsed.Ads, 1) + assert.Equal(t, original.Ads[0].ID, parsed.Ads[0].ID) + assert.Equal(t, original.Ads[0].InLine.AdTitle, parsed.Ads[0].InLine.AdTitle) +} + +func TestMediaFileWithCDATA(t *testing.T) { + vast := &Vast{ + Version: "3.0", + Ads: []Ad{ + { + ID: "media-test", + InLine: &InLine{ + AdTitle: "Media Test", + Creatives: &Creatives{ + Creative: []Creative{ + { + Linear: &Linear{ + Duration: "00:00:30", + MediaFiles: &MediaFiles{ + MediaFile: []MediaFile{ + { + Delivery: "progressive", + Type: "video/mp4", + Width: 1280, + Height: 720, + Value: "https://example.com/video.mp4?param=value&other=123", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + output, err := vast.Marshal() + require.NoError(t, err) + + // MediaFile URL should be in CDATA + xmlStr := string(output) + assert.Contains(t, xmlStr, "") +} + +func TestTrackingEvents(t *testing.T) { + vast := &Vast{ + Version: "3.0", + Ads: []Ad{ + { + ID: "tracking-test", + InLine: &InLine{ + AdTitle: "Tracking Test", + Creatives: &Creatives{ + Creative: []Creative{ + { + Linear: &Linear{ + Duration: "00:00:30", + TrackingEvents: &TrackingEvents{ + Tracking: []Tracking{ + {Event: "start", Value: "https://example.com/start"}, + {Event: "firstQuartile", Value: "https://example.com/q1"}, + {Event: "midpoint", Value: "https://example.com/mid"}, + {Event: "thirdQuartile", Value: "https://example.com/q3"}, + {Event: "complete", Value: "https://example.com/complete"}, + {Event: "progress", Offset: "00:00:05", Value: "https://example.com/5sec"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + output, err := vast.Marshal() + require.NoError(t, err) + + xmlStr := string(output) + assert.Contains(t, xmlStr, `event="start"`) + assert.Contains(t, xmlStr, `event="complete"`) + assert.Contains(t, xmlStr, `event="progress"`) + assert.Contains(t, xmlStr, `offset="00:00:05"`) +} diff --git a/modules/ctv/vast/select/price_selector.go b/modules/ctv/vast/select/price_selector.go new file mode 100644 index 00000000000..1e8b52313e4 --- /dev/null +++ b/modules/ctv/vast/select/price_selector.go @@ -0,0 +1,167 @@ +package bidselect + +import ( + "sort" + "strings" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/ctv/vast" +) + +// PriceSelector selects bids based on price-based ranking. +// It implements the vast.BidSelector interface. +type PriceSelector struct { + // maxBids is the maximum number of bids to return. + // If 0, uses cfg.MaxAdsInPod from the config. + maxBids int +} + +// NewPriceSelector creates a new PriceSelector. +// If maxBids is 0, the selector will use cfg.MaxAdsInPod. +// If maxBids is 1, it behaves as a SINGLE selector. +func NewPriceSelector(maxBids int) *PriceSelector { + return &PriceSelector{ + maxBids: maxBids, + } +} + +// bidWithSeat holds a bid along with its seat ID for sorting and selection. +type bidWithSeat struct { + bid openrtb2.Bid + seat string +} + +// Select chooses bids from the response based on price-based ranking. +// It implements the vast.BidSelector interface. +// +// Selection process: +// 1. Collect all bids from resp.SeatBid[].Bid[] +// 2. Filter bids: price > 0 and AdM non-empty (unless AllowSkeletonVast is true) +// 3. Sort by: price desc, then deal exists desc, then bid.ID asc for stability +// 4. Return up to maxBids (or cfg.MaxAdsInPod if maxBids is 0) +// 5. Populate CanonicalMeta for each SelectedBid +func (s *PriceSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg vast.ReceiverConfig) ([]vast.SelectedBid, []string, error) { + var warnings []string + + if resp == nil || len(resp.SeatBid) == 0 { + return nil, warnings, nil + } + + // Determine currency from response or config default + currency := cfg.DefaultCurrency + if resp.Cur != "" { + currency = resp.Cur + } + + // Collect all bids from all seats + var allBids []bidWithSeat + for _, seatBid := range resp.SeatBid { + for _, bid := range seatBid.Bid { + allBids = append(allBids, bidWithSeat{ + bid: bid, + seat: seatBid.Seat, + }) + } + } + + // Filter bids + var filteredBids []bidWithSeat + for _, bws := range allBids { + // Filter: price must be > 0 + if bws.bid.Price <= 0 { + warnings = append(warnings, "bid "+bws.bid.ID+" filtered: price <= 0") + continue + } + + // Filter: AdM must be non-empty unless AllowSkeletonVast is true + if !cfg.AllowSkeletonVast && strings.TrimSpace(bws.bid.AdM) == "" { + warnings = append(warnings, "bid "+bws.bid.ID+" filtered: empty AdM (skeleton VAST not allowed)") + continue + } + + filteredBids = append(filteredBids, bws) + } + + if len(filteredBids) == 0 { + return nil, warnings, nil + } + + // Sort bids: price desc, deal exists desc, bid.ID asc for stability + sort.Slice(filteredBids, func(i, j int) bool { + bi, bj := filteredBids[i].bid, filteredBids[j].bid + + // Primary: price descending + if bi.Price != bj.Price { + return bi.Price > bj.Price + } + + // Secondary: deal exists descending (deals first) + iHasDeal := bi.DealID != "" + jHasDeal := bj.DealID != "" + if iHasDeal != jHasDeal { + return iHasDeal + } + + // Tertiary: bid ID ascending for stability + return bi.ID < bj.ID + }) + + // Determine how many bids to return + maxToReturn := s.maxBids + if maxToReturn == 0 { + maxToReturn = cfg.MaxAdsInPod + } + if maxToReturn <= 0 { + maxToReturn = 1 // Safety fallback + } + if maxToReturn > len(filteredBids) { + maxToReturn = len(filteredBids) + } + + // Select top bids and build SelectedBid with CanonicalMeta + selectedBids := make([]vast.SelectedBid, maxToReturn) + for i := 0; i < maxToReturn; i++ { + bws := filteredBids[i] + bid := bws.bid + + // Determine sequence (SlotInPod) + sequence := i + 1 + // Check if bid has explicit slot in pod via Ext or other mechanism + // For MVP, we use index+1 as sequence + + // Extract primary adomain + adomain := "" + if len(bid.ADomain) > 0 { + adomain = bid.ADomain[0] + } + + // Extract duration from bid (if available in Dur field for video) + durSec := 0 + if bid.Dur > 0 { + durSec = int(bid.Dur) + } + + selectedBids[i] = vast.SelectedBid{ + Bid: bid, + Seat: bws.seat, + Sequence: sequence, + Meta: vast.CanonicalMeta{ + BidID: bid.ID, + ImpID: bid.ImpID, + DealID: bid.DealID, + Seat: bws.seat, + Price: bid.Price, + Currency: currency, + Adomain: adomain, + Cats: bid.Cat, + DurSec: durSec, + SlotInPod: sequence, + }, + } + } + + return selectedBids, warnings, nil +} + +// Ensure PriceSelector implements BidSelector interface. +var _ vast.BidSelector = (*PriceSelector)(nil) diff --git a/modules/ctv/vast/select/price_selector_test.go b/modules/ctv/vast/select/price_selector_test.go new file mode 100644 index 00000000000..0d12353da24 --- /dev/null +++ b/modules/ctv/vast/select/price_selector_test.go @@ -0,0 +1,501 @@ +package bidselect + +import ( + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/ctv/vast" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSelector(t *testing.T) { + tests := []struct { + name string + strategy vast.SelectionStrategy + wantMax int + }{ + { + name: "SINGLE strategy", + strategy: vast.SelectionSingle, + wantMax: 1, + }, + { + name: "TOP_N strategy", + strategy: vast.SelectionTopN, + wantMax: 0, // uses cfg.MaxAdsInPod + }, + { + name: "unknown strategy defaults to TOP_N", + strategy: "unknown", + wantMax: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selector := NewSelector(tt.strategy) + require.NotNil(t, selector) + + priceSelector, ok := selector.(*PriceSelector) + require.True(t, ok) + assert.Equal(t, tt.wantMax, priceSelector.maxBids) + }) + } +} + +func TestPriceSelector_Select_NilResponse(t *testing.T) { + selector := NewPriceSelector(5) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + + selected, warnings, err := selector.Select(nil, nil, cfg) + assert.NoError(t, err) + assert.Nil(t, selected) + assert.Empty(t, warnings) +} + +func TestPriceSelector_Select_EmptySeatBid(t *testing.T) { + selector := NewPriceSelector(5) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{}, + } + + selected, warnings, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + assert.Nil(t, selected) + assert.Empty(t, warnings) +} + +func TestPriceSelector_Select_FilterZeroPrice(t *testing.T) { + selector := NewPriceSelector(5) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 0, AdM: ""}, + {ID: "bid2", Price: -1, AdM: ""}, + }, + }, + }, + } + + selected, warnings, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + assert.Empty(t, selected) + assert.Len(t, warnings, 2) + assert.Contains(t, warnings[0], "price <= 0") +} + +func TestPriceSelector_Select_FilterEmptyAdM(t *testing.T) { + selector := NewPriceSelector(5) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + AllowSkeletonVast: false, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 2.0, AdM: " "}, + }, + }, + }, + } + + selected, warnings, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + assert.Empty(t, selected) + assert.Len(t, warnings, 2) + assert.Contains(t, warnings[0], "empty AdM") +} + +func TestPriceSelector_Select_AllowSkeletonVast(t *testing.T) { + selector := NewPriceSelector(5) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + AllowSkeletonVast: true, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 2.0, AdM: ""}, + }, + }, + }, + } + + selected, warnings, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + assert.Len(t, selected, 2) + assert.Empty(t, warnings) +} + +func TestPriceSelector_Select_SortByPriceDesc(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 3.0, AdM: ""}, + {ID: "bid3", Price: 2.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 3) + + // Should be sorted by price descending + assert.Equal(t, "bid2", selected[0].Meta.BidID) + assert.Equal(t, 3.0, selected[0].Meta.Price) + assert.Equal(t, "bid3", selected[1].Meta.BidID) + assert.Equal(t, 2.0, selected[1].Meta.Price) + assert.Equal(t, "bid1", selected[2].Meta.BidID) + assert.Equal(t, 1.0, selected[2].Meta.Price) +} + +func TestPriceSelector_Select_DealsPrioritized(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 2.0, AdM: "", DealID: ""}, + {ID: "bid2", Price: 2.0, AdM: "", DealID: "deal123"}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 2) + + // At same price, deal should come first + assert.Equal(t, "bid2", selected[0].Meta.BidID) + assert.Equal(t, "deal123", selected[0].Meta.DealID) + assert.Equal(t, "bid1", selected[1].Meta.BidID) +} + +func TestPriceSelector_Select_StableSortByID(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "c", Price: 2.0, AdM: ""}, + {ID: "a", Price: 2.0, AdM: ""}, + {ID: "b", Price: 2.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 3) + + // Same price, no deals - should be sorted by ID ascending + assert.Equal(t, "a", selected[0].Meta.BidID) + assert.Equal(t, "b", selected[1].Meta.BidID) + assert.Equal(t, "c", selected[2].Meta.BidID) +} + +func TestPriceSelector_Select_SingleStrategy(t *testing.T) { + selector := NewPriceSelector(1) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 3.0, AdM: ""}, + {ID: "bid3", Price: 2.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 1) + assert.Equal(t, "bid2", selected[0].Meta.BidID) + assert.Equal(t, 3.0, selected[0].Meta.Price) +} + +func TestPriceSelector_Select_TopNRespectsMaxAdsInPod(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 2, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 3.0, AdM: ""}, + {ID: "bid3", Price: 2.0, AdM: ""}, + {ID: "bid4", Price: 4.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 2) + assert.Equal(t, "bid4", selected[0].Meta.BidID) + assert.Equal(t, "bid2", selected[1].Meta.BidID) +} + +func TestPriceSelector_Select_Sequence(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 2.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 2) + + // Sequence should be 1-indexed based on position + assert.Equal(t, 1, selected[0].Sequence) + assert.Equal(t, 1, selected[0].Meta.SlotInPod) + assert.Equal(t, 2, selected[1].Sequence) + assert.Equal(t, 2, selected[1].Meta.SlotInPod) +} + +func TestPriceSelector_Select_CanonicalMeta(t *testing.T) { + selector := NewPriceSelector(1) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "EUR", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + { + ID: "bid1", + ImpID: "imp1", + Price: 2.5, + AdM: "", + DealID: "deal123", + ADomain: []string{"advertiser.com", "other.com"}, + Cat: []string{"IAB1", "IAB2"}, + Dur: 30, + }, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 1) + + meta := selected[0].Meta + assert.Equal(t, "bid1", meta.BidID) + assert.Equal(t, "imp1", meta.ImpID) + assert.Equal(t, "deal123", meta.DealID) + assert.Equal(t, "bidder1", meta.Seat) + assert.Equal(t, 2.5, meta.Price) + assert.Equal(t, "EUR", meta.Currency) // From response + assert.Equal(t, "advertiser.com", meta.Adomain) + assert.Equal(t, []string{"IAB1", "IAB2"}, meta.Cats) + assert.Equal(t, 30, meta.DurSec) + assert.Equal(t, 1, meta.SlotInPod) +} + +func TestPriceSelector_Select_CurrencyFallback(t *testing.T) { + selector := NewPriceSelector(1) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "GBP", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "", // Empty currency + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 1) + assert.Equal(t, "GBP", selected[0].Meta.Currency) // Fallback to config +} + +func TestPriceSelector_Select_MultipleSeatBids(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + }, + }, + { + Seat: "bidder2", + Bid: []openrtb2.Bid{ + {ID: "bid2", Price: 2.0, AdM: ""}, + }, + }, + { + Seat: "bidder3", + Bid: []openrtb2.Bid{ + {ID: "bid3", Price: 3.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 3) + + // Should be sorted by price, with correct seat assignment + assert.Equal(t, "bid3", selected[0].Meta.BidID) + assert.Equal(t, "bidder3", selected[0].Seat) + assert.Equal(t, "bid2", selected[1].Meta.BidID) + assert.Equal(t, "bidder2", selected[1].Seat) + assert.Equal(t, "bid1", selected[2].Meta.BidID) + assert.Equal(t, "bidder1", selected[2].Seat) +} + +func TestPriceSelector_Select_ComplexSort(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 10, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "e", Price: 2.0, AdM: "", DealID: ""}, // Same price, no deal + {ID: "a", Price: 3.0, AdM: "", DealID: "deal1"}, // Highest price with deal + {ID: "b", Price: 3.0, AdM: "", DealID: ""}, // Highest price, no deal + {ID: "c", Price: 2.0, AdM: "", DealID: "deal2"}, // Same price with deal + {ID: "d", Price: 2.0, AdM: "", DealID: "deal3"}, // Same price with deal + {ID: "f", Price: 1.0, AdM: "", DealID: ""}, // Lowest price + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 6) + + // Expected order: + // 1. a (price 3.0, deal) - highest price with deal + // 2. b (price 3.0, no deal) - highest price, no deal + // 3. c (price 2.0, deal) - same price, deal, ID "c" + // 4. d (price 2.0, deal) - same price, deal, ID "d" + // 5. e (price 2.0, no deal) - same price, no deal + // 6. f (price 1.0) - lowest price + assert.Equal(t, "a", selected[0].Meta.BidID) + assert.Equal(t, "b", selected[1].Meta.BidID) + assert.Equal(t, "c", selected[2].Meta.BidID) + assert.Equal(t, "d", selected[3].Meta.BidID) + assert.Equal(t, "e", selected[4].Meta.BidID) + assert.Equal(t, "f", selected[5].Meta.BidID) +} + +func TestNewSingleSelector(t *testing.T) { + selector := NewSingleSelector() + require.NotNil(t, selector) + + priceSelector, ok := selector.(*PriceSelector) + require.True(t, ok) + assert.Equal(t, 1, priceSelector.maxBids) +} + +func TestNewTopNSelector(t *testing.T) { + selector := NewTopNSelector() + require.NotNil(t, selector) + + priceSelector, ok := selector.(*PriceSelector) + require.True(t, ok) + assert.Equal(t, 0, priceSelector.maxBids) +} diff --git a/modules/ctv/vast/select/selector.go b/modules/ctv/vast/select/selector.go new file mode 100644 index 00000000000..d87bbf48335 --- /dev/null +++ b/modules/ctv/vast/select/selector.go @@ -0,0 +1,42 @@ +// Package bidselect provides bid selection logic for CTV VAST ad pods. +package bidselect + +import ( + "github.com/prebid/prebid-server/v3/modules/ctv/vast" +) + +// Selector implements the vast.BidSelector interface. +// It provides factory methods for different selection strategies. +type Selector interface { + vast.BidSelector +} + +// NewSelector creates a BidSelector based on the selection strategy. +// Supported strategies: +// - "SINGLE": Returns a single best bid (PriceSelector with limit 1) +// - "TOP_N": Returns up to MaxAdsInPod bids (PriceSelector) +// - Default: Falls back to TOP_N behavior +func NewSelector(strategy vast.SelectionStrategy) Selector { + switch strategy { + case vast.SelectionSingle: + return NewPriceSelector(1) + case vast.SelectionTopN: + return NewPriceSelector(0) // 0 means use cfg.MaxAdsInPod + default: + // Default to TOP_N behavior for unknown strategies + return NewPriceSelector(0) + } +} + +// NewSingleSelector creates a selector that returns only the best bid. +func NewSingleSelector() Selector { + return NewPriceSelector(1) +} + +// NewTopNSelector creates a selector that returns up to MaxAdsInPod bids. +func NewTopNSelector() Selector { + return NewPriceSelector(0) +} + +// Ensure PriceSelector implements Selector interface. +var _ Selector = (*PriceSelector)(nil) diff --git a/modules/ctv/vast/types.go b/modules/ctv/vast/types.go new file mode 100644 index 00000000000..0fbf9456795 --- /dev/null +++ b/modules/ctv/vast/types.go @@ -0,0 +1,191 @@ +// Package vast provides CTV VAST processing capabilities for Prebid Server. +// It includes bid selection, VAST enrichment, and formatting for various receivers. +package vast + +import ( + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" +) + +// ReceiverType identifies the downstream ad receiver/player. +type ReceiverType string + +const ( + // ReceiverGAMSSU represents Google Ad Manager Server-Side Unified receiver. + ReceiverGAMSSU ReceiverType = "GAM_SSU" + // ReceiverGeneric represents a generic VAST-compliant receiver. + ReceiverGeneric ReceiverType = "GENERIC" +) + +// SelectionStrategy defines how bids are selected for ad pods. +type SelectionStrategy string + +const ( + // SelectionSingle selects a single best bid. + SelectionSingle SelectionStrategy = "SINGLE" + // SelectionTopN selects up to MaxAdsInPod bids. + SelectionTopN SelectionStrategy = "TOP_N" + // SelectionMaxRevenue selects bids to maximize total revenue. + SelectionMaxRevenue SelectionStrategy = "max_revenue" + // SelectionMinDuration selects bids to minimize total duration. + SelectionMinDuration SelectionStrategy = "min_duration" + // SelectionBalanced balances between revenue and duration. + SelectionBalanced SelectionStrategy = "balanced" +) + +// CollisionPolicy defines how to handle competitive separation violations. +type CollisionPolicy string + +const ( + // CollisionReject rejects ads that violate competitive separation. + CollisionReject CollisionPolicy = "reject" + // CollisionWarn allows ads but adds warnings for violations. + CollisionWarn CollisionPolicy = "warn" + // CollisionIgnore ignores competitive separation rules. + CollisionIgnore CollisionPolicy = "ignore" +) + +// VastResult holds the complete result of VAST processing. +type VastResult struct { + // VastXML contains the final VAST XML output. + VastXML []byte + // NoAd indicates if no valid ad was available. + NoAd bool + // Warnings contains non-fatal issues encountered during processing. + Warnings []string + // Errors contains fatal errors that occurred during processing. + Errors []error + // Selected contains the bids that were selected for the ad pod. + Selected []SelectedBid +} + +// SelectedBid represents a bid that was selected for inclusion in the VAST response. +type SelectedBid struct { + // Bid is the OpenRTB bid object. + Bid openrtb2.Bid + // Seat is the seat ID of the bidder. + Seat string + // Sequence is the position of this bid in the ad pod (1-indexed). + Sequence int + // Meta contains canonical metadata extracted from the bid. + Meta CanonicalMeta +} + +// CanonicalMeta contains normalized metadata for a selected bid. +type CanonicalMeta struct { + // BidID is the unique identifier for the bid. + BidID string + // ImpID is the impression ID this bid is for. + ImpID string + // DealID is the deal ID if this bid is from a deal. + DealID string + // Seat is the bidder seat ID. + Seat string + // Price is the bid price. + Price float64 + // Currency is the currency code for the price. + Currency string + // Adomain is the primary advertiser domain. + Adomain string + // Cats contains the IAB content categories. + Cats []string + // DurSec is the duration of the creative in seconds. + DurSec int + // SlotInPod is the position within the ad pod (1-indexed). + SlotInPod int +} + +// ReceiverConfig holds configuration for VAST processing. +type ReceiverConfig struct { + // Receiver identifies the downstream ad receiver type. + Receiver ReceiverType + // DefaultCurrency is the currency to use when not specified. + DefaultCurrency string + // VastVersionDefault is the default VAST version to output. + VastVersionDefault string + // MaxAdsInPod is the maximum number of ads allowed in a pod. + MaxAdsInPod int + // SelectionStrategy defines how bids are selected. + SelectionStrategy SelectionStrategy + // CollisionPolicy defines how competitive separation is handled. + CollisionPolicy CollisionPolicy + // Placement contains placement-specific rules. + Placement PlacementRules + // AllowSkeletonVast allows bids without AdM content (skeleton VAST). + AllowSkeletonVast bool + // Debug enables debug mode with additional output. + Debug bool +} + +// PlacementRules contains rules for validating and filtering bids. +type PlacementRules struct { + // Pricing contains price floor and ceiling rules. + Pricing PricingRules + // Advertiser contains advertiser-based filtering rules. + Advertiser AdvertiserRules + // Categories contains category-based filtering rules. + Categories CategoryRules + // PricingPlacement defines where to place pricing info: "VAST_PRICING" or "EXTENSION". + PricingPlacement string + // AdvertiserPlacement defines where to place advertiser info: "ADVERTISER_TAG" or "EXTENSION". + AdvertiserPlacement string + // Debug enables debug output for placement rules. + Debug bool +} + +// PricingRules defines pricing constraints for bid selection. +type PricingRules struct { + // FloorCPM is the minimum CPM allowed. + FloorCPM float64 + // CeilingCPM is the maximum CPM allowed (0 = no ceiling). + CeilingCPM float64 + // Currency is the currency for floor/ceiling values. + Currency string +} + +// AdvertiserRules defines advertiser-based filtering. +type AdvertiserRules struct { + // BlockedDomains is a list of advertiser domains to reject. + BlockedDomains []string + // AllowedDomains is a whitelist of allowed domains (empty = allow all). + AllowedDomains []string +} + +// CategoryRules defines category-based filtering. +type CategoryRules struct { + // BlockedCategories is a list of IAB categories to reject. + BlockedCategories []string + // AllowedCategories is a whitelist of allowed categories (empty = allow all). + AllowedCategories []string +} + +// BidSelector defines the interface for selecting bids from an auction response. +type BidSelector interface { + // Select chooses bids from the response based on configuration. + // Returns selected bids, warnings, and any fatal error. + Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg ReceiverConfig) ([]SelectedBid, []string, error) +} + +// Enricher defines the interface for enriching VAST ads with additional data. +type Enricher interface { + // Enrich adds tracking, extensions, and other data to a VAST ad. + // Returns warnings and any fatal error. + Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) +} + +// EnrichedAd pairs a VAST Ad with its associated metadata. +type EnrichedAd struct { + // Ad is the enriched VAST Ad element. + Ad *model.Ad + // Meta contains canonical metadata for this ad. + Meta CanonicalMeta + // Sequence is the position in the ad pod (1-indexed). + Sequence int +} + +// Formatter defines the interface for formatting VAST ads into XML. +type Formatter interface { + // Format converts enriched VAST ads into XML output. + // Returns the XML bytes, warnings, and any fatal error. + Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) +} diff --git a/modules/ctv/vast/vast.go b/modules/ctv/vast/vast.go new file mode 100644 index 00000000000..470da7447e5 --- /dev/null +++ b/modules/ctv/vast/vast.go @@ -0,0 +1,204 @@ +// Package vast provides CTV VAST processing capabilities for Prebid Server. +// +// This module handles the complete VAST workflow for Connected TV (CTV) ad serving: +// - Bid selection from OpenRTB auction responses +// - VAST ad enrichment with tracking and metadata +// - VAST XML formatting for various downstream receivers +// +// The package is organized into sub-packages: +// - model: VAST data structures +// - select: Bid selection logic +// - enrich: VAST ad enrichment +// - format: VAST XML formatting +// +// Example usage: +// +// cfg := vast.ReceiverConfig{ +// Receiver: vast.ReceiverGAMSSU, +// DefaultCurrency: "USD", +// VastVersionDefault: "4.0", +// MaxAdsInPod: 5, +// SelectionStrategy: vast.SelectionMaxRevenue, +// CollisionPolicy: vast.CollisionReject, +// } +// +// processor := vast.NewProcessor(cfg, selector, enricher, formatter) +// result := processor.Process(bidRequest, bidResponse) +package vast + +import ( + "context" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" +) + +// BuildVastFromBidResponse orchestrates the complete VAST processing pipeline. +// It selects bids, parses/creates VAST, enriches ads, and formats final XML. +// +// Steps: +// 1. Select bids from response using configured strategy +// 2. Parse VAST from each bid's AdM (or create skeleton if allowed) +// 3. Enrich each ad with metadata (pricing, categories, etc.) +// 4. Format all ads into final VAST XML +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - req: OpenRTB bid request +// - resp: OpenRTB bid response from auction +// - cfg: Receiver configuration +// - selector: Bid selection implementation +// - enricher: VAST enrichment implementation +// - formatter: VAST formatting implementation +// +// Returns VastResult containing XML output, warnings, and selected bids. +func BuildVastFromBidResponse( + ctx context.Context, + req *openrtb2.BidRequest, + resp *openrtb2.BidResponse, + cfg ReceiverConfig, + selector BidSelector, + enricher Enricher, + formatter Formatter, +) (VastResult, error) { + result := VastResult{ + Warnings: make([]string, 0), + Errors: make([]error, 0), + } + + // Step 1: Select bids + selected, selectWarnings, err := selector.Select(req, resp, cfg) + if err != nil { + result.Errors = append(result.Errors, err) + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + return result, err + } + result.Warnings = append(result.Warnings, selectWarnings...) + result.Selected = selected + + // Step 2: Handle no bids case + if len(selected) == 0 { + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + return result, nil + } + + // Step 3: Parse and enrich each selected bid's VAST + enrichedAds := make([]EnrichedAd, 0, len(selected)) + + parserCfg := model.ParserConfig{ + AllowSkeletonVast: cfg.AllowSkeletonVast, + VastVersionDefault: cfg.VastVersionDefault, + } + + for _, sb := range selected { + // Parse VAST from AdM (or create skeleton) + parsedVast, parseWarnings, parseErr := model.ParseVastOrSkeleton(sb.Bid.AdM, parserCfg) + result.Warnings = append(result.Warnings, parseWarnings...) + + if parseErr != nil { + result.Warnings = append(result.Warnings, "failed to parse VAST for bid "+sb.Bid.ID+": "+parseErr.Error()) + continue + } + + // Extract the first Ad from parsed VAST + ad := model.ExtractFirstAd(parsedVast) + if ad == nil { + result.Warnings = append(result.Warnings, "no ad found in VAST for bid "+sb.Bid.ID) + continue + } + + // Enrich the ad with metadata + enrichWarnings, enrichErr := enricher.Enrich(ad, sb.Meta, cfg) + result.Warnings = append(result.Warnings, enrichWarnings...) + if enrichErr != nil { + result.Warnings = append(result.Warnings, "enrichment failed for bid "+sb.Bid.ID+": "+enrichErr.Error()) + // Continue with unenriched ad + } + + // Store enriched ad + enrichedAds = append(enrichedAds, EnrichedAd{ + Ad: ad, + Meta: sb.Meta, + Sequence: sb.Sequence, + }) + } + + // Step 4: Handle case where all bids failed parsing + if len(enrichedAds) == 0 { + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + result.Warnings = append(result.Warnings, "all selected bids failed VAST parsing") + return result, nil + } + + // Step 5: Format the final VAST XML + xmlBytes, formatWarnings, formatErr := formatter.Format(enrichedAds, cfg) + result.Warnings = append(result.Warnings, formatWarnings...) + + if formatErr != nil { + result.Errors = append(result.Errors, formatErr) + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + return result, formatErr + } + + result.VastXML = xmlBytes + result.NoAd = false + + return result, nil +} + +// Processor orchestrates the VAST processing workflow. +type Processor struct { + selector BidSelector + enricher Enricher + formatter Formatter + config ReceiverConfig +} + +// NewProcessor creates a new Processor with the given configuration. +func NewProcessor(cfg ReceiverConfig, selector BidSelector, enricher Enricher, formatter Formatter) *Processor { + return &Processor{ + selector: selector, + enricher: enricher, + formatter: formatter, + config: cfg, + } +} + +// Process executes the complete VAST processing workflow. +func (p *Processor) Process(ctx context.Context, req *openrtb2.BidRequest, resp *openrtb2.BidResponse) VastResult { + result, _ := BuildVastFromBidResponse(ctx, req, resp, p.config, p.selector, p.enricher, p.formatter) + return result +} + +// DefaultConfig returns a default ReceiverConfig for GAM SSU. +func DefaultConfig() ReceiverConfig { + return ReceiverConfig{ + Receiver: ReceiverGAMSSU, + DefaultCurrency: "USD", + VastVersionDefault: "4.0", + MaxAdsInPod: 5, + SelectionStrategy: SelectionMaxRevenue, + CollisionPolicy: CollisionReject, + Placement: PlacementRules{ + Pricing: PricingRules{ + FloorCPM: 0, + CeilingCPM: 0, + Currency: "USD", + }, + Advertiser: AdvertiserRules{ + BlockedDomains: []string{}, + AllowedDomains: []string{}, + }, + Categories: CategoryRules{ + BlockedCategories: []string{}, + AllowedCategories: []string{}, + }, + Debug: false, + }, + Debug: false, + } +} diff --git a/modules/ctv/vast/vast_test.go b/modules/ctv/vast/vast_test.go new file mode 100644 index 00000000000..110171ddcf0 --- /dev/null +++ b/modules/ctv/vast/vast_test.go @@ -0,0 +1,607 @@ +package vast + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mock implementations for testing + +type mockSelector struct { + selectFn func(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg ReceiverConfig) ([]SelectedBid, []string, error) +} + +func (m *mockSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg ReceiverConfig) ([]SelectedBid, []string, error) { + if m.selectFn != nil { + return m.selectFn(req, resp, cfg) + } + // Default: select all bids with sequence numbers + var selected []SelectedBid + seq := 1 + if resp != nil { + for _, sb := range resp.SeatBid { + for _, bid := range sb.Bid { + adomain := "" + if len(bid.ADomain) > 0 { + adomain = bid.ADomain[0] + } + selected = append(selected, SelectedBid{ + Bid: bid, + Seat: sb.Seat, + Sequence: seq, + Meta: CanonicalMeta{ + BidID: bid.ID, + Seat: sb.Seat, + Price: bid.Price, + Currency: resp.Cur, + Adomain: adomain, + Cats: bid.Cat, + }, + }) + seq++ + } + } + } + return selected, nil, nil +} + +type mockEnricher struct { + enrichFn func(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) +} + +func (m *mockEnricher) Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) { + if m.enrichFn != nil { + return m.enrichFn(ad, meta, cfg) + } + // Default: add pricing extension and advertiser + if ad.InLine != nil { + ad.InLine.Pricing = &model.Pricing{ + Model: "CPM", + Currency: cfg.DefaultCurrency, + Value: formatPrice(meta.Price), + } + if meta.Adomain != "" { + ad.InLine.Advertiser = meta.Adomain + } + if cfg.Debug { + if ad.InLine.Extensions == nil { + ad.InLine.Extensions = &model.Extensions{} + } + debugXML := fmt.Sprintf("%s%s%f", + meta.BidID, meta.Seat, meta.Price) + ad.InLine.Extensions.Extension = append(ad.InLine.Extensions.Extension, model.ExtensionXML{ + Type: "openrtb", + InnerXML: debugXML, + }) + } + } + return nil, nil +} + +func formatPrice(price float64) string { + return fmt.Sprintf("%.2f", price) +} + +type mockFormatter struct { + formatFn func(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) +} + +func (m *mockFormatter) Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) { + if m.formatFn != nil { + return m.formatFn(ads, cfg) + } + // Default: build GAM SSU style VAST + version := cfg.VastVersionDefault + if version == "" { + version = "4.0" + } + vast := &model.Vast{ + Version: version, + Ads: make([]model.Ad, 0, len(ads)), + } + for _, ea := range ads { + ad := *ea.Ad + ad.ID = ea.Meta.BidID + ad.Sequence = ea.Sequence + vast.Ads = append(vast.Ads, ad) + } + xml, err := vast.Marshal() + return xml, nil, err +} + +func newTestComponents() (BidSelector, Enricher, Formatter) { + return &mockSelector{}, &mockEnricher{}, &mockFormatter{} +} + +func TestBuildVastFromBidResponse_NoAds(t *testing.T) { + cfg := DefaultConfig() + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ID: "test-resp"} + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + assert.True(t, result.NoAd) + assert.NotEmpty(t, result.VastXML) + assert.Contains(t, string(result.VastXML), ``) + assert.Empty(t, result.Selected) +} + +func TestBuildVastFromBidResponse_NilResponse(t *testing.T) { + cfg := DefaultConfig() + req := &openrtb2.BidRequest{ID: "test-req"} + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, nil, cfg, selector, enricher, formatter) + require.NoError(t, err) + + assert.True(t, result.NoAd) + assert.NotEmpty(t, result.VastXML) +} + +func TestBuildVastFromBidResponse_SingleBid(t *testing.T) { + cfg := DefaultConfig() + cfg.SelectionStrategy = SelectionSingle + + vastXML := ` + + + + TestServer + Test Ad + + + + 00:00:30 + + + + + + + + + + +` + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + { + ID: "bid-1", + ImpID: "imp-1", + Price: 5.0, + AdM: vastXML, + ADomain: []string{"advertiser.com"}, + }, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + assert.False(t, result.NoAd) + assert.NotEmpty(t, result.VastXML) + assert.Len(t, result.Selected, 1) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, `Test Ad") +} + +func TestBuildVastFromBidResponse_MultipleBids(t *testing.T) { + cfg := DefaultConfig() + cfg.SelectionStrategy = SelectionTopN + cfg.MaxAdsInPod = 3 + + makeVAST := func(adID, title string) string { + return ` + + + + TestServer + ` + title + ` + + + + 00:00:15 + + + + + +` + } + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid-1", ImpID: "imp-1", Price: 10.0, AdM: makeVAST("ad-1", "First Ad")}, + {ID: "bid-2", ImpID: "imp-2", Price: 8.0, AdM: makeVAST("ad-2", "Second Ad")}, + {ID: "bid-3", ImpID: "imp-3", Price: 5.0, AdM: makeVAST("ad-3", "Third Ad")}, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + assert.False(t, result.NoAd) + assert.Len(t, result.Selected, 3) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, `sequence="1"`) + assert.Contains(t, xmlStr, `sequence="2"`) + assert.Contains(t, xmlStr, `sequence="3"`) +} + +func TestBuildVastFromBidResponse_SkeletonVast(t *testing.T) { + cfg := DefaultConfig() + cfg.AllowSkeletonVast = true + cfg.SelectionStrategy = SelectionSingle + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + { + ID: "bid-1", + ImpID: "imp-1", + Price: 5.0, + AdM: "not-valid-vast", // Invalid VAST + }, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + // Should succeed with skeleton VAST + assert.False(t, result.NoAd) + assert.NotEmpty(t, result.VastXML) + // Check for skeleton warning + hasSkeletonWarning := false + for _, w := range result.Warnings { + if strings.Contains(strings.ToLower(w), "skeleton") { + hasSkeletonWarning = true + break + } + } + assert.True(t, hasSkeletonWarning, "Expected skeleton warning, got: %v", result.Warnings) +} + +func TestBuildVastFromBidResponse_InvalidVastNoSkeleton(t *testing.T) { + cfg := DefaultConfig() + cfg.AllowSkeletonVast = false // Don't allow skeleton + cfg.SelectionStrategy = SelectionSingle + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + { + ID: "bid-1", + ImpID: "imp-1", + Price: 5.0, + AdM: "not-valid-vast", + }, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + // Should return no-ad since parse failed and skeleton not allowed + assert.True(t, result.NoAd) +} + +func TestBuildVastFromBidResponse_EnrichmentAddsMetadata(t *testing.T) { + cfg := DefaultConfig() + cfg.SelectionStrategy = SelectionSingle + cfg.Debug = true // Enable debug extensions + + vastXML := ` + + + + TestServer + Test Ad + + + + + + + + + + + + + +` + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + { + ID: "bid-enriched", + ImpID: "imp-1", + Price: 7.5, + AdM: vastXML, + ADomain: []string{"advertiser.com"}, + Cat: []string{"IAB1", "IAB2"}, + }, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + require.False(t, result.NoAd) + + xmlStr := string(result.VastXML) + // Check enrichment added pricing + assert.Contains(t, xmlStr, "bid-enriched") +} + +// HTTP Handler Tests + +func TestHandler_MethodNotAllowed(t *testing.T) { + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter) + + req := httptest.NewRequest(http.MethodPost, "/vast", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) +} + +func TestHandler_NotConfigured(t *testing.T) { + handler := NewHandler() // No selector/enricher/formatter + + req := httptest.NewRequest(http.MethodGet, "/vast", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + body, _ := io.ReadAll(rec.Body) + assert.Contains(t, string(body), "not properly configured") +} + +func TestHandler_NoAuction_ReturnsNoAdVast(t *testing.T) { + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter) + // No AuctionFunc set, should return no-ad VAST + + req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=test-pod", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/xml; charset=utf-8", rec.Header().Get("Content-Type")) + + body, _ := io.ReadAll(rec.Body) + assert.Contains(t, string(body), ``) +} + +func TestHandler_WithMockAuction_ReturnsVast(t *testing.T) { + vastXML := ` + + + + MockServer + Mock Ad + + + + 00:00:15 + + + + + +` + + mockAuction := func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { + return &openrtb2.BidResponse{ + ID: "mock-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "mock-bidder", + Bid: []openrtb2.Bid{ + { + ID: "mock-bid-1", + ImpID: "imp-1", + Price: 3.50, + AdM: vastXML, + }, + }, + }, + }, + }, nil + } + + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter). + WithAuctionFunc(mockAuction) + + req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=test-pod", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/xml; charset=utf-8", rec.Header().Get("Content-Type")) + + body, _ := io.ReadAll(rec.Body) + xmlStr := string(body) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, `Mock Ad") +} + +func TestHandler_WithConfig(t *testing.T) { + cfg := ReceiverConfig{ + Receiver: ReceiverGAMSSU, + VastVersionDefault: "3.0", + DefaultCurrency: "EUR", + } + + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithConfig(cfg). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter) + + req := httptest.NewRequest(http.MethodGet, "/vast", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + body, _ := io.ReadAll(rec.Body) + // Should use version 3.0 from config + assert.Contains(t, string(body), `version="3.0"`) +} + +func TestHandler_CacheControlHeader(t *testing.T) { + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter) + + req := httptest.NewRequest(http.MethodGet, "/vast", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, "no-cache, no-store, must-revalidate", rec.Header().Get("Cache-Control")) +} + +func TestHandler_PodIDFromQuery(t *testing.T) { + var capturedReq *openrtb2.BidRequest + + mockAuction := func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { + capturedReq = req + return &openrtb2.BidResponse{}, nil + } + + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter). + WithAuctionFunc(mockAuction) + + req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=custom-pod-123", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + require.NotNil(t, capturedReq) + assert.Equal(t, "custom-pod-123", capturedReq.ID) +} + +// Test warnings are captured +func TestBuildVastFromBidResponse_WarningsCollected(t *testing.T) { + cfg := DefaultConfig() + cfg.AllowSkeletonVast = true + + // First bid has valid VAST, second has invalid + validVAST := `Test00:00:15` + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid-1", ImpID: "imp-1", Price: 10.0, AdM: validVAST}, + {ID: "bid-2", ImpID: "imp-2", Price: 5.0, AdM: "invalid-vast"}, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + assert.False(t, result.NoAd) + // Should have warnings about the invalid VAST using skeleton + hasSkeletonWarning := false + for _, w := range result.Warnings { + if strings.Contains(strings.ToLower(w), "skeleton") { + hasSkeletonWarning = true + break + } + } + assert.True(t, hasSkeletonWarning, "Expected skeleton warning in: %v", result.Warnings) +} From 25160ed211d568b849455bf7c62582ffa99796ce Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Thu, 5 Feb 2026 16:11:04 +0000 Subject: [PATCH 02/15] refactor(ctv_vast_enrichment): restructure as PBS module with proper Builder pattern - Rename vast.go to pipeline.go for clarity - Add module.go with Builder() and HandleRawBidderResponseHook() - Add module_test.go with comprehensive tests - Update README.md and README_EN.md with new structure - Fix import paths for new module location - Module now follows ortb2blocking/rulesengine patterns --- modules/prebid/ctv_vast_enrichment/README.md | 401 +++++++++++ .../prebid/ctv_vast_enrichment/README_EN.md | 401 +++++++++++ modules/prebid/ctv_vast_enrichment/config.go | 369 ++++++++++ .../prebid/ctv_vast_enrichment/config_test.go | 388 ++++++++++ .../ctv_vast_enrichment/enrich/enrich.go | 264 +++++++ .../ctv_vast_enrichment/enrich/enrich_test.go | 672 ++++++++++++++++++ .../ctv_vast_enrichment/format/format.go | 114 +++ .../ctv_vast_enrichment/format/format_test.go | 488 +++++++++++++ .../format/testdata/no_ad.xml | 2 + .../format/testdata/pod_three_ads.xml | 45 ++ .../format/testdata/pod_two_ads.xml | 39 + .../format/testdata/single_ad.xml | 24 + modules/prebid/ctv_vast_enrichment/handler.go | 167 +++++ .../prebid/ctv_vast_enrichment/model/model.go | 28 + .../ctv_vast_enrichment/model/parser.go | 171 +++++ .../ctv_vast_enrichment/model/parser_test.go | 528 ++++++++++++++ .../ctv_vast_enrichment/model/vast_xml.go | 282 ++++++++ .../model/vast_xml_test.go | 447 ++++++++++++ modules/prebid/ctv_vast_enrichment/module.go | 234 ++++++ .../prebid/ctv_vast_enrichment/module_test.go | 576 +++++++++++++++ .../prebid/ctv_vast_enrichment/pipeline.go | 204 ++++++ .../ctv_vast_enrichment/pipeline_test.go | 607 ++++++++++++++++ .../select/price_selector.go | 167 +++++ .../select/price_selector_test.go | 501 +++++++++++++ .../ctv_vast_enrichment/select/selector.go | 42 ++ modules/prebid/ctv_vast_enrichment/types.go | 191 +++++ 26 files changed, 7352 insertions(+) create mode 100644 modules/prebid/ctv_vast_enrichment/README.md create mode 100644 modules/prebid/ctv_vast_enrichment/README_EN.md create mode 100644 modules/prebid/ctv_vast_enrichment/config.go create mode 100644 modules/prebid/ctv_vast_enrichment/config_test.go create mode 100644 modules/prebid/ctv_vast_enrichment/enrich/enrich.go create mode 100644 modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go create mode 100644 modules/prebid/ctv_vast_enrichment/format/format.go create mode 100644 modules/prebid/ctv_vast_enrichment/format/format_test.go create mode 100644 modules/prebid/ctv_vast_enrichment/format/testdata/no_ad.xml create mode 100644 modules/prebid/ctv_vast_enrichment/format/testdata/pod_three_ads.xml create mode 100644 modules/prebid/ctv_vast_enrichment/format/testdata/pod_two_ads.xml create mode 100644 modules/prebid/ctv_vast_enrichment/format/testdata/single_ad.xml create mode 100644 modules/prebid/ctv_vast_enrichment/handler.go create mode 100644 modules/prebid/ctv_vast_enrichment/model/model.go create mode 100644 modules/prebid/ctv_vast_enrichment/model/parser.go create mode 100644 modules/prebid/ctv_vast_enrichment/model/parser_test.go create mode 100644 modules/prebid/ctv_vast_enrichment/model/vast_xml.go create mode 100644 modules/prebid/ctv_vast_enrichment/model/vast_xml_test.go create mode 100644 modules/prebid/ctv_vast_enrichment/module.go create mode 100644 modules/prebid/ctv_vast_enrichment/module_test.go create mode 100644 modules/prebid/ctv_vast_enrichment/pipeline.go create mode 100644 modules/prebid/ctv_vast_enrichment/pipeline_test.go create mode 100644 modules/prebid/ctv_vast_enrichment/select/price_selector.go create mode 100644 modules/prebid/ctv_vast_enrichment/select/price_selector_test.go create mode 100644 modules/prebid/ctv_vast_enrichment/select/selector.go create mode 100644 modules/prebid/ctv_vast_enrichment/types.go diff --git a/modules/prebid/ctv_vast_enrichment/README.md b/modules/prebid/ctv_vast_enrichment/README.md new file mode 100644 index 00000000000..2f8f412b838 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/README.md @@ -0,0 +1,401 @@ +# Moduł CTV VAST Enrichment + +Moduł CTV VAST Enrichment to moduł hook Prebid Server, który wzbogaca odpowiedzi VAST (Video Ad Serving Template) o dodatkowe metadane dla reklam Connected TV (CTV). + +## Struktura Modułu + +``` +modules/prebid/ctv_vast_enrichment/ +├── module.go # Punkt wejścia modułu PBS (Builder + HandleRawBidderResponseHook) +├── module_test.go # Testy modułu +├── pipeline.go # Samodzielny pipeline przetwarzania VAST +├── pipeline_test.go # Testy pipeline +├── handler.go # Handler HTTP dla bezpośrednich żądań VAST +├── types.go # Definicje typów, interfejsy i stałe +├── config.go # Konfiguracja i mergowanie warstw (host/account/profile) +├── config_test.go # Testy konfiguracji +├── model/ # Struktury danych VAST XML +│ ├── model.go # Obiekty domenowe wysokiego poziomu +│ ├── vast_xml.go # Struktury XML do marshal/unmarshal +│ ├── parser.go # Parser VAST XML +│ └── *_test.go # Testy +├── select/ # Logika selekcji bidów +│ ├── selector.go # Implementacje BidSelector +│ └── *_test.go # Testy +├── enrich/ # Wzbogacanie VAST +│ ├── enrich.go # Implementacja Enricher (VAST_WINS) +│ └── *_test.go # Testy +└── format/ # Formatowanie VAST XML + ├── format.go # Implementacja Formatter (GAM_SSU) + └── *_test.go # Testy +``` + +## Integracja z PBS + +Moduł jest zgodny ze standardowym wzorcem modułów Prebid Server: + +### `module.go` - Główny Punkt Wejścia + +```go +// Builder tworzy nową instancję modułu CTV VAST enrichment. +func Builder(cfg json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, error) + +// Module implementuje funkcjonalność wzbogacania CTV VAST jako moduł hook PBS. +type Module struct { + hostConfig CTVVastConfig +} + +// HandleRawBidderResponseHook przetwarza odpowiedzi bidderów, wzbogacając VAST XML. +func (m Module) HandleRawBidderResponseHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.RawBidderResponsePayload, +) (hookstage.HookResult[hookstage.RawBidderResponsePayload], error) +``` + +### Hook Stage + +Moduł działa na etapie hooka **RawBidderResponse**, przetwarzając odpowiedź każdego biddera przed agregacją. Dla każdego bida zawierającego VAST XML: + +1. Parsuje VAST XML z pola `AdM` bida +2. Wzbogaca VAST o pricing, advertiser i metadane kategorii +3. Aktualizuje pole `AdM` bida wzbogaconym VAST XML + +### Konfiguracja + +Moduł używa warstwowej konfiguracji w stylu PBS: + +```json +{ + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD", + "vast_version_default": "3.0", + "max_ads_in_pod": 10 + } + } + } +} +``` + +Konfiguracja na poziomie konta nadpisuje ustawienia na poziomie hosta. + +## Komponenty + +### `module.go` - Moduł PBS + +Główny punkt wejścia zgodny z konwencjami modułów PBS: + +- **`Builder()`** - Tworzy instancję modułu z konfiguracji JSON +- **`Module`** - Struktura przechowująca konfigurację na poziomie hosta +- **`HandleRawBidderResponseHook()`** - Implementacja hooka: + - Parsuje konfigurację na poziomie konta + - Merguje konfiguracje hosta i konta + - Wzbogaca VAST w każdym bidzie video + +### `pipeline.go` - Samodzielny Pipeline + +Alternatywny punkt wejścia do bezpośredniego wywołania (używany przez handler.go): + +- **`BuildVastFromBidResponse()`** - Orkiestruje pełny pipeline: + 1. Selekcja bidów z odpowiedzi aukcji + 2. Parsowanie VAST z AdM każdego bida + 3. Wzbogacanie metadanymi + 4. Formatowanie do końcowego XML + +- **`Processor`** - Wrapper z wstrzykniętymi zależnościami +- **`DefaultConfig()`** - Domyślna konfiguracja dla GAM SSU + +### `handler.go` - Handler HTTP + +Obsługa żądań HTTP dla reklam CTV VAST (opcjonalny endpoint): + +- **`Handler`** - Handler HTTP z konfiguracją i zależnościami +- **`ServeHTTP()`** - Obsługuje żądania GET, zwraca VAST XML +- Metody buildera: `WithConfig()`, `WithSelector()`, itp. + +### `types.go` - Typy i Interfejsy + +| Typ | Opis | +|-----|------| +| `ReceiverType` | Typ odbiorcy (GAM_SSU, GENERIC) | +| `SelectionStrategy` | Strategia selekcji bidów (SINGLE, TOP_N, MAX_REVENUE) | +| `CollisionPolicy` | Polityka kolizji (reject, warn, ignore) | + +**Interfejsy:** + +```go +type BidSelector interface { + Select(req, resp, cfg) ([]SelectedBid, []string, error) +} + +type Enricher interface { + Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) +} + +type Formatter interface { + Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) +} +``` + +**Struktury Danych:** + +- `CanonicalMeta` - Znormalizowane metadane bida (BidID, Price, Currency, Adomain, itp.) +- `SelectedBid` - Wybrany bid z metadanymi i numerem sekwencji +- `EnrichedAd` - Wzbogacona reklama gotowa do formatowania +- `VastResult` - Wynik przetwarzania (XML, ostrzeżenia, błędy) +- `ReceiverConfig` - Konfiguracja odbiorcy VAST +- `PlacementRules` - Reguły walidacji (pricing, advertiser, categories) + +### `config.go` - Konfiguracja + +Warstwowy system konfiguracji w stylu PBS: + +- **`CTVVastConfig`** - Struktura konfiguracji z polami nullable +- **`MergeCTVVastConfig()`** - Mergowanie warstw: Host → Account → Profile + +Priorytet warstw (od najniższego do najwyższego): +1. Host (domyślne) +2. Account (nadpisuje host) +3. Profile (nadpisuje wszystko) + +### `model/` - Struktury VAST XML + +#### `vast_xml.go` + +Struktury Go mapujące elementy VAST XML: + +- `Vast` - Element główny `` +- `Ad` - Element `` z atrybutami id, sequence +- `InLine` - Reklama inline z pełnymi danymi +- `Wrapper` - Reklama wrapper (przekierowanie) +- `Creative`, `Linear`, `MediaFile` - Elementy kreacji +- `Pricing`, `Impression`, `Extensions` - Metadane i tracking + +Funkcje pomocnicze: +- `BuildNoAdVast()` - Tworzy pusty VAST (brak reklam) +- `BuildSkeletonInlineVast()` - Tworzy minimalny szkielet VAST +- `Marshal()` / `MarshalCompact()` - Serializacja do XML + +#### `parser.go` + +Parser VAST XML: + +- **`ParseVastAdm()`** - Parsuje string AdM do struktury Vast +- **`ParseVastOrSkeleton()`** - Parsuje lub tworzy szkielet jeśli dozwolone +- **`ExtractFirstAd()`** - Wyciąga pierwszą reklamę z VAST + +### `select/` - Selekcja Bidów + +Logika wyboru bidów z odpowiedzi aukcji: + +- **`PriceSelector`** - Implementacja oparta na cenie: + - Filtruje bidy z ceną ≤ 0 lub pustym AdM + - Sortuje: deal > non-deal, potem po cenie malejąco + - Respektuje `MaxAdsInPod` dla strategii TOP_N + - Przypisuje numery sekwencji (1-indexed) + +- **`NewSelector(strategy)`** - Fabryka tworząca selektor dla strategii + +### `enrich/` - Wzbogacanie VAST + +Dodawanie metadanych do reklam VAST: + +- **`VastEnricher`** - Implementacja z polityką VAST_WINS: + - Istniejące wartości w VAST nie są nadpisywane + - Dodaje brakujące: Pricing, Advertiser, Duration, Categories + +Wzbogacane elementy: +| Element | Źródło | Lokalizacja | +|---------|--------|-------------| +| Pricing | meta.Price | `` lub Extension | +| Advertiser | meta.Adomain | `` lub Extension | +| Duration | meta.DurSec | `` w Linear | +| Categories | meta.Cats | Extension (zawsze) | + +### `format/` - Formatowanie VAST + +Budowanie końcowego VAST XML: + +- **`VastFormatter`** - Implementacja GAM SSU: + - Buduje dokument VAST z listą elementów `` + - Ustawia `id` z BidID + - Ustawia `sequence` dla podów (wiele reklam) + +## Przepływ Przetwarzania + +``` +┌─────────────────────────────────────────────────────┐ +│ PBS Auction Pipeline │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ RawBidderResponse Hook Stage │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ HandleRawBidderResponseHook() │ │ +│ │ Dla każdego bida z VAST w AdM: │ │ +│ │ 1. Parsuje VAST XML │ │ +│ │ 2. Wzbogaca o pricing/advertiser │ │ +│ │ 3. Aktualizuje bid.AdM │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Wzbogacona BidderResponse │ +│ (VAST z , itp.) │ +└─────────────────────────────────────────────────────┘ +``` + +## Użycie + +### Jako Moduł PBS (Rekomendowane) + +Moduł jest automatycznie wywoływany podczas pipeline aukcji gdy włączony w konfiguracji: + +```yaml +# Konfiguracja PBS +hooks: + enabled_modules: + - prebid.ctv_vast_enrichment + +modules: + prebid: + ctv_vast_enrichment: + enabled: true + default_currency: "USD" + receiver: "GAM_SSU" +``` + +Nadpisanie na poziomie konta: +```json +{ + "hooks": { + "modules": { + "prebid.ctv_vast_enrichment": { + "enabled": true, + "default_currency": "EUR" + } + } + } +} +``` + +### Samodzielny Pipeline (dla handlera HTTP) + +```go +import ( + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/enrich" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/format" + bidselect "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/select" +) + +// Konfiguracja +cfg := vast.DefaultConfig() +cfg.MaxAdsInPod = 3 + +// Tworzenie komponentów +selector := bidselect.NewSelector(cfg.SelectionStrategy) +enricher := enrich.NewEnricher() +formatter := format.NewFormatter() + +// Bezpośrednie wywołanie +result, err := vast.BuildVastFromBidResponse( + ctx, + bidRequest, + bidResponse, + cfg, + selector, + enricher, + formatter, +) +``` + +### Handler HTTP + +```go +handler := vast.NewHandler(). + WithConfig(cfg). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter). + WithAuctionFunc(myAuctionFunc) + +http.Handle("/vast", handler) +``` + +## Konfiguracja Warstwowa + +```go +// Konfiguracja hosta (domyślne) +hostCfg := &vast.CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "USD", + VastVersionDefault: "4.0", +} + +// Konfiguracja konta (nadpisuje host) +accountCfg := &vast.CTVVastConfig{ + MaxAdsInPod: 5, +} + +// Merge warstw +merged := vast.MergeCTVVastConfig(hostCfg, accountCfg, nil) +``` + +## Testowanie + +Uruchom wszystkie testy modułu: + +```bash +go test ./modules/prebid/ctv_vast_enrichment/... -v +``` + +Testy z pokryciem: + +```bash +go test ./modules/prebid/ctv_vast_enrichment/... -cover +``` + +Uruchom tylko testy module.go: + +```bash +go test ./modules/prebid/ctv_vast_enrichment -run TestBuilder -v +go test ./modules/prebid/ctv_vast_enrichment -run TestHandleRawBidderResponseHook -v +``` + +## Rozszerzenia + +### Dodawanie Nowego Odbiorcy + +1. Dodaj stałą w `types.go`: + ```go + ReceiverMyReceiver ReceiverType = "MY_RECEIVER" + ``` + +2. Zaimplementuj `Formatter` dla nowego formatu w `format/` + +3. Zaktualizuj `configToReceiverConfig()` w `module.go` + +### Dodawanie Nowej Strategii Selekcji + +1. Dodaj stałą w `types.go`: + ```go + SelectionMyStrategy SelectionStrategy = "MY_STRATEGY" + ``` + +2. Zaimplementuj `BidSelector` w `select/` + +3. Zaktualizuj fabrykę `NewSelector()` + +## Zależności + +- `github.com/prebid/prebid-server/v3/hooks/hookstage` - Interfejsy hooków PBS +- `github.com/prebid/prebid-server/v3/modules/moduledeps` - Zależności modułów +- `github.com/prebid/openrtb/v20/openrtb2` - Typy OpenRTB +- `encoding/xml` - Parsowanie/serializacja XML diff --git a/modules/prebid/ctv_vast_enrichment/README_EN.md b/modules/prebid/ctv_vast_enrichment/README_EN.md new file mode 100644 index 00000000000..c72dd29d384 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/README_EN.md @@ -0,0 +1,401 @@ +# CTV VAST Enrichment Module + +The CTV VAST Enrichment module is a Prebid Server hook module that enriches VAST (Video Ad Serving Template) XML responses with additional metadata for Connected TV (CTV) ads. + +## Module Structure + +``` +modules/prebid/ctv_vast_enrichment/ +├── module.go # PBS module entry point (Builder + HandleRawBidderResponseHook) +├── module_test.go # Module tests +├── pipeline.go # Standalone VAST processing pipeline +├── pipeline_test.go # Pipeline tests +├── handler.go # HTTP handler for direct VAST requests +├── types.go # Type definitions, interfaces and constants +├── config.go # Configuration and layer merging (host/account/profile) +├── config_test.go # Configuration tests +├── model/ # VAST XML data structures +│ ├── model.go # High-level domain objects +│ ├── vast_xml.go # XML structures for marshal/unmarshal +│ ├── parser.go # VAST XML parser +│ └── *_test.go # Tests +├── select/ # Bid selection logic +│ ├── selector.go # BidSelector implementations +│ └── *_test.go # Tests +├── enrich/ # VAST enrichment +│ ├── enrich.go # Enricher implementation (VAST_WINS) +│ └── *_test.go # Tests +└── format/ # VAST XML formatting + ├── format.go # Formatter implementation (GAM_SSU) + └── *_test.go # Tests +``` + +## PBS Module Integration + +This module follows the standard Prebid Server module pattern: + +### \`module.go\` - Main Entry Point + +\`\`\`go +// Builder creates a new CTV VAST enrichment module instance. +func Builder(cfg json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, error) + +// Module implements the CTV VAST enrichment functionality as a PBS hook module. +type Module struct { + hostConfig CTVVastConfig +} + +// HandleRawBidderResponseHook processes bidder responses to enrich VAST XML. +func (m Module) HandleRawBidderResponseHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.RawBidderResponsePayload, +) (hookstage.HookResult[hookstage.RawBidderResponsePayload], error) +\`\`\` + +### Hook Stage + +The module runs at the **RawBidderResponse** hook stage, processing each bidder's response before aggregation. For each bid containing VAST XML: + +1. Parses the VAST XML from the bid's \`AdM\` field +2. Enriches the VAST with pricing, advertiser, and category metadata +3. Updates the bid's \`AdM\` with the enriched VAST XML + +### Configuration + +The module uses PBS-style layered configuration: + +\`\`\`json +{ + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD", + "vast_version_default": "3.0", + "max_ads_in_pod": 10 + } + } + } +} +\`\`\` + +Account-level configuration overrides host-level settings. + +## Components + +### \`module.go\` - PBS Module + +Main entry point following PBS module conventions: + +- **\`Builder()\`** - Creates module instance from JSON config +- **\`Module\`** - Struct holding host-level configuration +- **\`HandleRawBidderResponseHook()\`** - Hook implementation that: + - Parses account-level config + - Merges host and account configs + - Enriches VAST in each video bid + +### \`pipeline.go\` - Standalone Pipeline + +Alternative entry point for direct invocation (used by handler.go): + +- **\`BuildVastFromBidResponse()\`** - Orchestrates the full pipeline: + 1. Bid selection from auction response + 2. VAST parsing from each bid's AdM + 3. Enrichment with metadata + 4. Formatting to final XML + +- **\`Processor\`** - Wrapper with injected dependencies +- **\`DefaultConfig()\`** - Default configuration for GAM SSU + +### \`handler.go\` - HTTP Handler + +HTTP request handling for CTV VAST ads (optional endpoint): + +- **\`Handler\`** - HTTP handler with configuration and dependencies +- **\`ServeHTTP()\`** - Handles GET requests, returns VAST XML +- Builder methods: \`WithConfig()\`, \`WithSelector()\`, etc. + +### \`types.go\` - Types and Interfaces + +| Type | Description | +|------|-------------| +| \`ReceiverType\` | Receiver type (GAM_SSU, GENERIC) | +| \`SelectionStrategy\` | Bid selection strategy (SINGLE, TOP_N, MAX_REVENUE) | +| \`CollisionPolicy\` | Collision policy (reject, warn, ignore) | + +**Interfaces:** + +\`\`\`go +type BidSelector interface { + Select(req, resp, cfg) ([]SelectedBid, []string, error) +} + +type Enricher interface { + Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) +} + +type Formatter interface { + Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) +} +\`\`\` + +**Data Structures:** + +- \`CanonicalMeta\` - Normalized bid metadata (BidID, Price, Currency, Adomain, etc.) +- \`SelectedBid\` - Selected bid with metadata and sequence number +- \`EnrichedAd\` - Enriched ad ready for formatting +- \`VastResult\` - Processing result (XML, warnings, errors) +- \`ReceiverConfig\` - VAST receiver configuration +- \`PlacementRules\` - Validation rules (pricing, advertiser, categories) + +### \`config.go\` - Configuration + +PBS-style layered configuration system: + +- **\`CTVVastConfig\`** - Configuration structure with nullable fields +- **\`MergeCTVVastConfig()\`** - Layer merging: Host → Account → Profile + +Layer priority (from lowest to highest): +1. Host (defaults) +2. Account (overrides host) +3. Profile (overrides everything) + +### \`model/\` - VAST XML Structures + +#### \`vast_xml.go\` + +Go structures mapping VAST XML elements: + +- \`Vast\` - Root element \`\` +- \`Ad\` - Element \`\` with id, sequence attributes +- \`InLine\` - Inline ad with full data +- \`Wrapper\` - Wrapper ad (redirect) +- \`Creative\`, \`Linear\`, \`MediaFile\` - Creative elements +- \`Pricing\`, \`Impression\`, \`Extensions\` - Metadata and tracking + +Helper functions: +- \`BuildNoAdVast()\` - Creates empty VAST (no ads) +- \`BuildSkeletonInlineVast()\` - Creates minimal VAST skeleton +- \`Marshal()\` / \`MarshalCompact()\` - Serialize to XML + +#### \`parser.go\` + +VAST XML parser: + +- **\`ParseVastAdm()\`** - Parses AdM string to Vast structure +- **\`ParseVastOrSkeleton()\`** - Parses or creates skeleton if allowed +- **\`ExtractFirstAd()\`** - Extracts first ad from VAST + +### \`select/\` - Bid Selection + +Logic for selecting bids from auction response: + +- **\`PriceSelector\`** - Price-based implementation: + - Filters bids with price ≤ 0 or empty AdM + - Sorts: deal > non-deal, then by price descending + - Respects \`MaxAdsInPod\` for TOP_N strategy + - Assigns sequence numbers (1-indexed) + +- **\`NewSelector(strategy)\`** - Factory creating selector for strategy + +### \`enrich/\` - VAST Enrichment + +Adding metadata to VAST ads: + +- **\`VastEnricher\`** - Implementation with VAST_WINS policy: + - Existing values in VAST are not overwritten + - Adds missing: Pricing, Advertiser, Duration, Categories + +Enriched elements: +| Element | Source | Location | +|---------|--------|----------| +| Pricing | meta.Price | \`\` or Extension | +| Advertiser | meta.Adomain | \`\` or Extension | +| Duration | meta.DurSec | \`\` in Linear | +| Categories | meta.Cats | Extension (always) | + +### \`format/\` - VAST Formatting + +Building final VAST XML: + +- **\`VastFormatter\`** - GAM SSU implementation: + - Builds VAST document with list of \`\` elements + - Sets \`id\` from BidID + - Sets \`sequence\` for pods (multiple ads) + +## Processing Flow + +\`\`\` +┌─────────────────────────────────────────────────────┐ +│ PBS Auction Pipeline │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ RawBidderResponse Hook Stage │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ HandleRawBidderResponseHook() │ │ +│ │ For each bid with VAST in AdM: │ │ +│ │ 1. Parse VAST XML │ │ +│ │ 2. Enrich with pricing/advertiser │ │ +│ │ 3. Update bid.AdM │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Enriched BidderResponse │ +│ (VAST with , etc.) │ +└─────────────────────────────────────────────────────┘ +\`\`\` + +## Usage + +### As PBS Module (Recommended) + +The module is automatically invoked during the auction pipeline when enabled in configuration: + +\`\`\`yaml +# PBS config +hooks: + enabled_modules: + - prebid.ctv_vast_enrichment + +modules: + prebid: + ctv_vast_enrichment: + enabled: true + default_currency: "USD" + receiver: "GAM_SSU" +\`\`\` + +Account-level override: +\`\`\`json +{ + "hooks": { + "modules": { + "prebid.ctv_vast_enrichment": { + "enabled": true, + "default_currency": "EUR" + } + } + } +} +\`\`\` + +### Standalone Pipeline (for HTTP handler) + +\`\`\`go +import ( + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/enrich" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/format" + bidselect "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/select" +) + +// Configuration +cfg := vast.DefaultConfig() +cfg.MaxAdsInPod = 3 + +// Create components +selector := bidselect.NewSelector(cfg.SelectionStrategy) +enricher := enrich.NewEnricher() +formatter := format.NewFormatter() + +// Direct invocation +result, err := vast.BuildVastFromBidResponse( + ctx, + bidRequest, + bidResponse, + cfg, + selector, + enricher, + formatter, +) +\`\`\` + +### HTTP Handler + +\`\`\`go +handler := vast.NewHandler(). + WithConfig(cfg). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter). + WithAuctionFunc(myAuctionFunc) + +http.Handle("/vast", handler) +\`\`\` + +## Layer Configuration + +\`\`\`go +// Host configuration (defaults) +hostCfg := &vast.CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "USD", + VastVersionDefault: "4.0", +} + +// Account configuration (overrides host) +accountCfg := &vast.CTVVastConfig{ + MaxAdsInPod: 5, +} + +// Merge layers +merged := vast.MergeCTVVastConfig(hostCfg, accountCfg, nil) +\`\`\` + +## Testing + +Run all module tests: + +\`\`\`bash +go test ./modules/prebid/ctv_vast_enrichment/... -v +\`\`\` + +Tests with coverage: + +\`\`\`bash +go test ./modules/prebid/ctv_vast_enrichment/... -cover +\`\`\` + +Run only module.go tests: + +\`\`\`bash +go test ./modules/prebid/ctv_vast_enrichment -run TestBuilder -v +go test ./modules/prebid/ctv_vast_enrichment -run TestHandleRawBidderResponseHook -v +\`\`\` + +## Extensions + +### Adding a New Receiver + +1. Add constant in \`types.go\`: + \`\`\`go + ReceiverMyReceiver ReceiverType = "MY_RECEIVER" + \`\`\` + +2. Implement \`Formatter\` for the new format in \`format/\` + +3. Update \`configToReceiverConfig()\` in \`module.go\` + +### Adding a New Selection Strategy + +1. Add constant in \`types.go\`: + \`\`\`go + SelectionMyStrategy SelectionStrategy = "MY_STRATEGY" + \`\`\` + +2. Implement \`BidSelector\` in \`select/\` + +3. Update \`NewSelector()\` factory + +## Dependencies + +- \`github.com/prebid/prebid-server/v3/hooks/hookstage\` - PBS hook interfaces +- \`github.com/prebid/prebid-server/v3/modules/moduledeps\` - Module dependencies +- \`github.com/prebid/openrtb/v20/openrtb2\` - OpenRTB types +- \`encoding/xml\` - XML parsing/serialization diff --git a/modules/prebid/ctv_vast_enrichment/config.go b/modules/prebid/ctv_vast_enrichment/config.go new file mode 100644 index 00000000000..64fea1ddb08 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/config.go @@ -0,0 +1,369 @@ +package vast + +// CTVVastConfig represents the configuration for CTV VAST processing. +// It supports PBS-style layered configuration where profile overrides account, +// and account overrides host-level settings. +type CTVVastConfig struct { + // Enabled controls whether CTV VAST processing is active. + Enabled *bool `json:"enabled,omitempty" mapstructure:"enabled"` + // Receiver identifies the downstream ad receiver type (e.g., "GAM_SSU", "GENERIC"). + Receiver string `json:"receiver,omitempty" mapstructure:"receiver"` + // DefaultCurrency is the currency to use when not specified (default: "USD"). + DefaultCurrency string `json:"default_currency,omitempty" mapstructure:"default_currency"` + // VastVersionDefault is the default VAST version to output (default: "3.0"). + VastVersionDefault string `json:"vast_version_default,omitempty" mapstructure:"vast_version_default"` + // MaxAdsInPod is the maximum number of ads allowed in a pod (default: 10). + MaxAdsInPod int `json:"max_ads_in_pod,omitempty" mapstructure:"max_ads_in_pod"` + // SelectionStrategy defines how bids are selected (e.g., "SINGLE", "TOP_N"). + SelectionStrategy string `json:"selection_strategy,omitempty" mapstructure:"selection_strategy"` + // CollisionPolicy defines how competitive separation is handled (default: "VAST_WINS"). + CollisionPolicy string `json:"collision_policy,omitempty" mapstructure:"collision_policy"` + // AllowSkeletonVast allows bids without AdM content (skeleton VAST). + AllowSkeletonVast *bool `json:"allow_skeleton_vast,omitempty" mapstructure:"allow_skeleton_vast"` + // Placement contains placement-specific rules. + Placement *PlacementRulesConfig `json:"placement,omitempty" mapstructure:"placement"` + // Debug enables debug mode with additional output. + Debug *bool `json:"debug,omitempty" mapstructure:"debug"` +} + +// PlacementRulesConfig contains rules for validating and filtering bids. +type PlacementRulesConfig struct { + // Pricing contains price floor and ceiling rules. + Pricing *PricingRulesConfig `json:"pricing,omitempty" mapstructure:"pricing"` + // Advertiser contains advertiser-based filtering rules. + Advertiser *AdvertiserRulesConfig `json:"advertiser,omitempty" mapstructure:"advertiser"` + // Categories contains category-based filtering rules. + Categories *CategoryRulesConfig `json:"categories,omitempty" mapstructure:"categories"` + // PricingPlacement defines where to place pricing: "VAST_PRICING" or "EXTENSION". + PricingPlacement string `json:"pricing_placement,omitempty" mapstructure:"pricing_placement"` + // AdvertiserPlacement defines where to place advertiser: "ADVERTISER_TAG" or "EXTENSION". + AdvertiserPlacement string `json:"advertiser_placement,omitempty" mapstructure:"advertiser_placement"` + // Debug enables debug output for placement rules. + Debug *bool `json:"debug,omitempty" mapstructure:"debug"` +} + +// PricingRulesConfig defines pricing constraints for bid selection. +type PricingRulesConfig struct { + // FloorCPM is the minimum CPM allowed. + FloorCPM *float64 `json:"floor_cpm,omitempty" mapstructure:"floor_cpm"` + // CeilingCPM is the maximum CPM allowed (0 = no ceiling). + CeilingCPM *float64 `json:"ceiling_cpm,omitempty" mapstructure:"ceiling_cpm"` + // Currency is the currency for floor/ceiling values. + Currency string `json:"currency,omitempty" mapstructure:"currency"` +} + +// AdvertiserRulesConfig defines advertiser-based filtering. +type AdvertiserRulesConfig struct { + // BlockedDomains is a list of advertiser domains to reject. + BlockedDomains []string `json:"blocked_domains,omitempty" mapstructure:"blocked_domains"` + // AllowedDomains is a whitelist of allowed domains (empty = allow all). + AllowedDomains []string `json:"allowed_domains,omitempty" mapstructure:"allowed_domains"` +} + +// CategoryRulesConfig defines category-based filtering. +type CategoryRulesConfig struct { + // BlockedCategories is a list of IAB categories to reject. + BlockedCategories []string `json:"blocked_categories,omitempty" mapstructure:"blocked_categories"` + // AllowedCategories is a whitelist of allowed categories (empty = allow all). + AllowedCategories []string `json:"allowed_categories,omitempty" mapstructure:"allowed_categories"` +} + +// Default values for CTVVastConfig. +const ( + DefaultVastVersion = "3.0" + DefaultCurrency = "USD" + DefaultMaxAdsInPod = 10 + DefaultCollisionPolicy = "VAST_WINS" + DefaultReceiver = "GAM_SSU" + DefaultSelectionStrategy = "max_revenue" + + // Placement constants for pricing + PlacementVastPricing = "VAST_PRICING" + PlacementExtension = "EXTENSION" + + // Placement constants for advertiser + PlacementAdvertiserTag = "ADVERTISER_TAG" + // PlacementExtension is also used for advertiser +) + +// MergeCTVVastConfig merges configuration from host, account, and profile layers. +// The precedence order is: profile > account > host (profile values override account, which overrides host). +// Only non-zero values override; nil pointers and empty strings are considered "not set". +func MergeCTVVastConfig(host, account, profile *CTVVastConfig) CTVVastConfig { + result := CTVVastConfig{} + + // Start with host config + if host != nil { + result = mergeIntoConfig(result, *host) + } + + // Override with account config + if account != nil { + result = mergeIntoConfig(result, *account) + } + + // Override with profile config (highest precedence) + if profile != nil { + result = mergeIntoConfig(result, *profile) + } + + return result +} + +// mergeIntoConfig merges src into dst, where non-zero values in src override dst. +func mergeIntoConfig(dst, src CTVVastConfig) CTVVastConfig { + if src.Enabled != nil { + dst.Enabled = src.Enabled + } + if src.Receiver != "" { + dst.Receiver = src.Receiver + } + if src.DefaultCurrency != "" { + dst.DefaultCurrency = src.DefaultCurrency + } + if src.VastVersionDefault != "" { + dst.VastVersionDefault = src.VastVersionDefault + } + if src.MaxAdsInPod != 0 { + dst.MaxAdsInPod = src.MaxAdsInPod + } + if src.SelectionStrategy != "" { + dst.SelectionStrategy = src.SelectionStrategy + } + if src.CollisionPolicy != "" { + dst.CollisionPolicy = src.CollisionPolicy + } + if src.AllowSkeletonVast != nil { + dst.AllowSkeletonVast = src.AllowSkeletonVast + } + if src.Debug != nil { + dst.Debug = src.Debug + } + + // Merge placement rules + if src.Placement != nil { + if dst.Placement == nil { + dst.Placement = &PlacementRulesConfig{} + } + dst.Placement = mergePlacementRules(dst.Placement, src.Placement) + } + + return dst +} + +// mergePlacementRules merges placement rules from src into dst. +func mergePlacementRules(dst, src *PlacementRulesConfig) *PlacementRulesConfig { + if dst == nil { + dst = &PlacementRulesConfig{} + } + if src == nil { + return dst + } + + if src.Debug != nil { + dst.Debug = src.Debug + } + + // Merge pricing rules + if src.Pricing != nil { + if dst.Pricing == nil { + dst.Pricing = &PricingRulesConfig{} + } + dst.Pricing = mergePricingRules(dst.Pricing, src.Pricing) + } + + // Merge advertiser rules + if src.Advertiser != nil { + if dst.Advertiser == nil { + dst.Advertiser = &AdvertiserRulesConfig{} + } + dst.Advertiser = mergeAdvertiserRules(dst.Advertiser, src.Advertiser) + } + + // Merge category rules + if src.Categories != nil { + if dst.Categories == nil { + dst.Categories = &CategoryRulesConfig{} + } + dst.Categories = mergeCategoryRules(dst.Categories, src.Categories) + } + + return dst +} + +// mergePricingRules merges pricing rules from src into dst. +func mergePricingRules(dst, src *PricingRulesConfig) *PricingRulesConfig { + if src.FloorCPM != nil { + dst.FloorCPM = src.FloorCPM + } + if src.CeilingCPM != nil { + dst.CeilingCPM = src.CeilingCPM + } + if src.Currency != "" { + dst.Currency = src.Currency + } + return dst +} + +// mergeAdvertiserRules merges advertiser rules from src into dst. +func mergeAdvertiserRules(dst, src *AdvertiserRulesConfig) *AdvertiserRulesConfig { + if len(src.BlockedDomains) > 0 { + dst.BlockedDomains = src.BlockedDomains + } + if len(src.AllowedDomains) > 0 { + dst.AllowedDomains = src.AllowedDomains + } + return dst +} + +// mergeCategoryRules merges category rules from src into dst. +func mergeCategoryRules(dst, src *CategoryRulesConfig) *CategoryRulesConfig { + if len(src.BlockedCategories) > 0 { + dst.BlockedCategories = src.BlockedCategories + } + if len(src.AllowedCategories) > 0 { + dst.AllowedCategories = src.AllowedCategories + } + return dst +} + +// ReceiverConfig converts CTVVastConfig to ReceiverConfig with defaults applied. +// Default values: +// - VastVersionDefault: "3.0" +// - DefaultCurrency: "USD" +// - MaxAdsInPod: 10 +// - CollisionPolicy: "VAST_WINS" +// - Receiver: "GAM_SSU" +// - SelectionStrategy: "max_revenue" +func (cfg CTVVastConfig) ReceiverConfig() ReceiverConfig { + rc := ReceiverConfig{} + + // Apply receiver with default + if cfg.Receiver != "" { + rc.Receiver = ReceiverType(cfg.Receiver) + } else { + rc.Receiver = ReceiverType(DefaultReceiver) + } + + // Apply currency with default + if cfg.DefaultCurrency != "" { + rc.DefaultCurrency = cfg.DefaultCurrency + } else { + rc.DefaultCurrency = DefaultCurrency + } + + // Apply VAST version with default + if cfg.VastVersionDefault != "" { + rc.VastVersionDefault = cfg.VastVersionDefault + } else { + rc.VastVersionDefault = DefaultVastVersion + } + + // Apply max ads in pod with default + if cfg.MaxAdsInPod != 0 { + rc.MaxAdsInPod = cfg.MaxAdsInPod + } else { + rc.MaxAdsInPod = DefaultMaxAdsInPod + } + + // Apply selection strategy with default + if cfg.SelectionStrategy != "" { + rc.SelectionStrategy = SelectionStrategy(cfg.SelectionStrategy) + } else { + rc.SelectionStrategy = SelectionStrategy(DefaultSelectionStrategy) + } + + // Apply collision policy with default + if cfg.CollisionPolicy != "" { + rc.CollisionPolicy = CollisionPolicy(cfg.CollisionPolicy) + } else { + rc.CollisionPolicy = CollisionPolicy(DefaultCollisionPolicy) + } + + // Apply allow skeleton vast flag + if cfg.AllowSkeletonVast != nil { + rc.AllowSkeletonVast = *cfg.AllowSkeletonVast + } + + // Apply debug flag + if cfg.Debug != nil { + rc.Debug = *cfg.Debug + } + + // Apply placement rules + rc.Placement = cfg.buildPlacementRules() + + return rc +} + +// buildPlacementRules converts PlacementRulesConfig to PlacementRules. +func (cfg CTVVastConfig) buildPlacementRules() PlacementRules { + pr := PlacementRules{} + + if cfg.Placement == nil { + return pr + } + + if cfg.Placement.Debug != nil { + pr.Debug = *cfg.Placement.Debug + } + + // Set placement locations with defaults + pr.PricingPlacement = cfg.Placement.PricingPlacement + if pr.PricingPlacement == "" { + pr.PricingPlacement = PlacementVastPricing + } + pr.AdvertiserPlacement = cfg.Placement.AdvertiserPlacement + if pr.AdvertiserPlacement == "" { + pr.AdvertiserPlacement = PlacementAdvertiserTag + } + + // Build pricing rules + if cfg.Placement.Pricing != nil { + pr.Pricing = PricingRules{ + Currency: cfg.Placement.Pricing.Currency, + } + if cfg.Placement.Pricing.FloorCPM != nil { + pr.Pricing.FloorCPM = *cfg.Placement.Pricing.FloorCPM + } + if cfg.Placement.Pricing.CeilingCPM != nil { + pr.Pricing.CeilingCPM = *cfg.Placement.Pricing.CeilingCPM + } + if pr.Pricing.Currency == "" { + pr.Pricing.Currency = DefaultCurrency + } + } + + // Build advertiser rules + if cfg.Placement.Advertiser != nil { + pr.Advertiser = AdvertiserRules{ + BlockedDomains: cfg.Placement.Advertiser.BlockedDomains, + AllowedDomains: cfg.Placement.Advertiser.AllowedDomains, + } + } + + // Build category rules + if cfg.Placement.Categories != nil { + pr.Categories = CategoryRules{ + BlockedCategories: cfg.Placement.Categories.BlockedCategories, + AllowedCategories: cfg.Placement.Categories.AllowedCategories, + } + } + + return pr +} + +// IsEnabled returns true if the config is enabled. Returns false if Enabled is nil or false. +func (cfg CTVVastConfig) IsEnabled() bool { + return cfg.Enabled != nil && *cfg.Enabled +} + +// boolPtr is a helper function to create a pointer to a bool value. +func boolPtr(b bool) *bool { + return &b +} + +// float64Ptr is a helper function to create a pointer to a float64 value. +func float64Ptr(f float64) *float64 { + return &f +} diff --git a/modules/prebid/ctv_vast_enrichment/config_test.go b/modules/prebid/ctv_vast_enrichment/config_test.go new file mode 100644 index 00000000000..6de0712c603 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/config_test.go @@ -0,0 +1,388 @@ +package vast + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMergeCTVVastConfig_NilInputs(t *testing.T) { + result := MergeCTVVastConfig(nil, nil, nil) + assert.Equal(t, CTVVastConfig{}, result) +} + +func TestMergeCTVVastConfig_HostOnly(t *testing.T) { + host := &CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "EUR", + VastVersionDefault: "4.0", + MaxAdsInPod: 5, + SelectionStrategy: "balanced", + CollisionPolicy: "reject", + } + + result := MergeCTVVastConfig(host, nil, nil) + + assert.Equal(t, "GAM_SSU", result.Receiver) + assert.Equal(t, "EUR", result.DefaultCurrency) + assert.Equal(t, "4.0", result.VastVersionDefault) + assert.Equal(t, 5, result.MaxAdsInPod) + assert.Equal(t, "balanced", result.SelectionStrategy) + assert.Equal(t, "reject", result.CollisionPolicy) +} + +func TestMergeCTVVastConfig_AccountOverridesHost(t *testing.T) { + host := &CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "EUR", + VastVersionDefault: "4.0", + MaxAdsInPod: 5, + } + account := &CTVVastConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 10, + } + + result := MergeCTVVastConfig(host, account, nil) + + assert.Equal(t, "GAM_SSU", result.Receiver) // from host + assert.Equal(t, "USD", result.DefaultCurrency) // overridden by account + assert.Equal(t, "4.0", result.VastVersionDefault) // from host + assert.Equal(t, 10, result.MaxAdsInPod) // overridden by account +} + +func TestMergeCTVVastConfig_ProfileOverridesAll(t *testing.T) { + host := &CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "EUR", + VastVersionDefault: "4.0", + MaxAdsInPod: 5, + SelectionStrategy: "max_revenue", + } + account := &CTVVastConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 10, + } + profile := &CTVVastConfig{ + VastVersionDefault: "4.2", + MaxAdsInPod: 3, + SelectionStrategy: "min_duration", + } + + result := MergeCTVVastConfig(host, account, profile) + + assert.Equal(t, "GAM_SSU", result.Receiver) // from host + assert.Equal(t, "USD", result.DefaultCurrency) // from account + assert.Equal(t, "4.2", result.VastVersionDefault) // overridden by profile + assert.Equal(t, 3, result.MaxAdsInPod) // overridden by profile + assert.Equal(t, "min_duration", result.SelectionStrategy) // overridden by profile +} + +func TestMergeCTVVastConfig_BoolPointers(t *testing.T) { + trueVal := true + falseVal := false + + host := &CTVVastConfig{ + Enabled: &trueVal, + Debug: &falseVal, + } + account := &CTVVastConfig{ + Debug: &trueVal, + } + profile := &CTVVastConfig{ + Enabled: &falseVal, + } + + result := MergeCTVVastConfig(host, account, profile) + + assert.NotNil(t, result.Enabled) + assert.False(t, *result.Enabled) // overridden by profile + assert.NotNil(t, result.Debug) + assert.True(t, *result.Debug) // from account (profile didn't set it) +} + +func TestMergeCTVVastConfig_PlacementRules(t *testing.T) { + floor := 1.5 + ceiling := 50.0 + profileFloor := 2.0 + + host := &CTVVastConfig{ + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: &floor, + CeilingCPM: &ceiling, + Currency: "EUR", + }, + Advertiser: &AdvertiserRulesConfig{ + BlockedDomains: []string{"blocked.com"}, + }, + }, + } + account := &CTVVastConfig{ + Placement: &PlacementRulesConfig{ + Advertiser: &AdvertiserRulesConfig{ + BlockedDomains: []string{"account-blocked.com"}, + }, + Categories: &CategoryRulesConfig{ + BlockedCategories: []string{"IAB25"}, + }, + }, + } + profile := &CTVVastConfig{ + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: &profileFloor, + }, + }, + } + + result := MergeCTVVastConfig(host, account, profile) + + assert.NotNil(t, result.Placement) + assert.NotNil(t, result.Placement.Pricing) + assert.Equal(t, 2.0, *result.Placement.Pricing.FloorCPM) // from profile + assert.Equal(t, 50.0, *result.Placement.Pricing.CeilingCPM) // from host + assert.Equal(t, "EUR", result.Placement.Pricing.Currency) // from host + + assert.NotNil(t, result.Placement.Advertiser) + assert.Equal(t, []string{"account-blocked.com"}, result.Placement.Advertiser.BlockedDomains) // from account + + assert.NotNil(t, result.Placement.Categories) + assert.Equal(t, []string{"IAB25"}, result.Placement.Categories.BlockedCategories) // from account +} + +func TestReceiverConfig_Defaults(t *testing.T) { + cfg := CTVVastConfig{} + rc := cfg.ReceiverConfig() + + assert.Equal(t, ReceiverType("GAM_SSU"), rc.Receiver) + assert.Equal(t, "USD", rc.DefaultCurrency) + assert.Equal(t, "3.0", rc.VastVersionDefault) + assert.Equal(t, 10, rc.MaxAdsInPod) + assert.Equal(t, SelectionStrategy("max_revenue"), rc.SelectionStrategy) + assert.Equal(t, CollisionPolicy("VAST_WINS"), rc.CollisionPolicy) + assert.False(t, rc.Debug) +} + +func TestReceiverConfig_WithValues(t *testing.T) { + debug := true + cfg := CTVVastConfig{ + Receiver: "GENERIC", + DefaultCurrency: "EUR", + VastVersionDefault: "4.2", + MaxAdsInPod: 7, + SelectionStrategy: "balanced", + CollisionPolicy: "warn", + Debug: &debug, + } + rc := cfg.ReceiverConfig() + + assert.Equal(t, ReceiverType("GENERIC"), rc.Receiver) + assert.Equal(t, "EUR", rc.DefaultCurrency) + assert.Equal(t, "4.2", rc.VastVersionDefault) + assert.Equal(t, 7, rc.MaxAdsInPod) + assert.Equal(t, SelectionStrategy("balanced"), rc.SelectionStrategy) + assert.Equal(t, CollisionPolicy("warn"), rc.CollisionPolicy) + assert.True(t, rc.Debug) +} + +func TestReceiverConfig_PlacementRules(t *testing.T) { + floor := 1.5 + ceiling := 100.0 + debug := true + + cfg := CTVVastConfig{ + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: &floor, + CeilingCPM: &ceiling, + Currency: "EUR", + }, + Advertiser: &AdvertiserRulesConfig{ + BlockedDomains: []string{"blocked.com", "spam.com"}, + AllowedDomains: []string{"allowed.com"}, + }, + Categories: &CategoryRulesConfig{ + BlockedCategories: []string{"IAB25", "IAB26"}, + AllowedCategories: []string{"IAB1"}, + }, + Debug: &debug, + }, + } + rc := cfg.ReceiverConfig() + + assert.Equal(t, 1.5, rc.Placement.Pricing.FloorCPM) + assert.Equal(t, 100.0, rc.Placement.Pricing.CeilingCPM) + assert.Equal(t, "EUR", rc.Placement.Pricing.Currency) + + assert.Equal(t, []string{"blocked.com", "spam.com"}, rc.Placement.Advertiser.BlockedDomains) + assert.Equal(t, []string{"allowed.com"}, rc.Placement.Advertiser.AllowedDomains) + + assert.Equal(t, []string{"IAB25", "IAB26"}, rc.Placement.Categories.BlockedCategories) + assert.Equal(t, []string{"IAB1"}, rc.Placement.Categories.AllowedCategories) + + assert.True(t, rc.Placement.Debug) +} + +func TestReceiverConfig_PlacementPricingDefaultCurrency(t *testing.T) { + floor := 1.0 + cfg := CTVVastConfig{ + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: &floor, + // Currency not set + }, + }, + } + rc := cfg.ReceiverConfig() + + assert.Equal(t, "USD", rc.Placement.Pricing.Currency) +} + +func TestIsEnabled(t *testing.T) { + tests := []struct { + name string + enabled *bool + expected bool + }{ + { + name: "nil returns false", + enabled: nil, + expected: false, + }, + { + name: "true returns true", + enabled: boolPtr(true), + expected: true, + }, + { + name: "false returns false", + enabled: boolPtr(false), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := CTVVastConfig{Enabled: tt.enabled} + assert.Equal(t, tt.expected, cfg.IsEnabled()) + }) + } +} + +func TestMergeCTVVastConfig_FullLayerPrecedence(t *testing.T) { + // This test verifies the complete layering behavior: + // profile > account > host + + host := &CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "GBP", + VastVersionDefault: "3.0", + MaxAdsInPod: 5, + SelectionStrategy: "max_revenue", + CollisionPolicy: "reject", + Enabled: boolPtr(true), + Debug: boolPtr(false), + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: float64Ptr(1.0), + CeilingCPM: float64Ptr(100.0), + Currency: "GBP", + }, + Advertiser: &AdvertiserRulesConfig{ + BlockedDomains: []string{"host-blocked.com"}, + }, + }, + } + + account := &CTVVastConfig{ + DefaultCurrency: "EUR", + MaxAdsInPod: 8, + CollisionPolicy: "warn", + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: float64Ptr(2.0), + Currency: "EUR", + }, + Categories: &CategoryRulesConfig{ + BlockedCategories: []string{"IAB25"}, + }, + }, + } + + profile := &CTVVastConfig{ + VastVersionDefault: "4.2", + MaxAdsInPod: 3, + Debug: boolPtr(true), + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: float64Ptr(3.0), + }, + }, + } + + result := MergeCTVVastConfig(host, account, profile) + + // Verify precedence + assert.Equal(t, "GAM_SSU", result.Receiver) // host (only set there) + assert.Equal(t, "EUR", result.DefaultCurrency) // account overrides host + assert.Equal(t, "4.2", result.VastVersionDefault) // profile overrides host + assert.Equal(t, 3, result.MaxAdsInPod) // profile overrides account and host + assert.Equal(t, "max_revenue", result.SelectionStrategy) // host (only set there) + assert.Equal(t, "warn", result.CollisionPolicy) // account overrides host + assert.True(t, *result.Enabled) // host (only set there) + assert.True(t, *result.Debug) // profile overrides host + + // Verify nested placement rules precedence + assert.Equal(t, 3.0, *result.Placement.Pricing.FloorCPM) // profile overrides account and host + assert.Equal(t, 100.0, *result.Placement.Pricing.CeilingCPM) // host (only set there) + assert.Equal(t, "EUR", result.Placement.Pricing.Currency) // account overrides host + + assert.Equal(t, []string{"host-blocked.com"}, result.Placement.Advertiser.BlockedDomains) // host + assert.Equal(t, []string{"IAB25"}, result.Placement.Categories.BlockedCategories) // account +} + +func TestMergeCTVVastConfig_EmptyStringsDoNotOverride(t *testing.T) { + host := &CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "EUR", + } + account := &CTVVastConfig{ + Receiver: "", // empty string should not override + DefaultCurrency: "USD", + } + + result := MergeCTVVastConfig(host, account, nil) + + assert.Equal(t, "GAM_SSU", result.Receiver) // empty string didn't override + assert.Equal(t, "USD", result.DefaultCurrency) // non-empty string did override +} + +func TestMergeCTVVastConfig_ZeroIntDoesNotOverride(t *testing.T) { + host := &CTVVastConfig{ + MaxAdsInPod: 5, + } + account := &CTVVastConfig{ + MaxAdsInPod: 0, // zero should not override + } + + result := MergeCTVVastConfig(host, account, nil) + + assert.Equal(t, 5, result.MaxAdsInPod) // zero didn't override +} + +func TestBoolPtr(t *testing.T) { + truePtr := boolPtr(true) + falsePtr := boolPtr(false) + + assert.NotNil(t, truePtr) + assert.True(t, *truePtr) + assert.NotNil(t, falsePtr) + assert.False(t, *falsePtr) +} + +func TestFloat64Ptr(t *testing.T) { + ptr := float64Ptr(1.5) + assert.NotNil(t, ptr) + assert.Equal(t, 1.5, *ptr) +} diff --git a/modules/prebid/ctv_vast_enrichment/enrich/enrich.go b/modules/prebid/ctv_vast_enrichment/enrich/enrich.go new file mode 100644 index 00000000000..821b93a28f1 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/enrich/enrich.go @@ -0,0 +1,264 @@ +// Package enrich provides VAST ad enrichment capabilities. +package enrich + +import ( + "fmt" + "strings" + + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" +) + +// VastEnricher implements the Enricher interface. +// It uses CollisionPolicy "VAST_WINS" - existing VAST values are not overwritten. +type VastEnricher struct{} + +// NewEnricher creates a new VastEnricher instance. +func NewEnricher() *VastEnricher { + return &VastEnricher{} +} + +// Enrich adds tracking, extensions, and other data to a VAST ad. +// It implements the vast.Enricher interface. +// CollisionPolicy "VAST_WINS": existing values in VAST are preserved. +func (e *VastEnricher) Enrich(ad *model.Ad, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) ([]string, error) { + var warnings []string + + if ad == nil { + return warnings, nil + } + + // Only enrich InLine ads, not Wrapper ads + if ad.InLine == nil { + warnings = append(warnings, "skipping enrichment: ad is not InLine") + return warnings, nil + } + + inline := ad.InLine + + // Ensure Extensions exists for adding extension-based enrichments + if inline.Extensions == nil { + inline.Extensions = &model.Extensions{} + } + + // Enrich Pricing + pricingWarnings := e.enrichPricing(inline, meta, cfg) + warnings = append(warnings, pricingWarnings...) + + // Enrich Advertiser + advertiserWarnings := e.enrichAdvertiser(inline, meta, cfg) + warnings = append(warnings, advertiserWarnings...) + + // Enrich Duration + durationWarnings := e.enrichDuration(inline, meta) + warnings = append(warnings, durationWarnings...) + + // Enrich Categories (always as extension) + categoryWarnings := e.enrichCategories(inline, meta) + warnings = append(warnings, categoryWarnings...) + + // Add debug extension if enabled + if cfg.Debug || cfg.Placement.Debug { + e.addDebugExtension(inline, meta) + } + + return warnings, nil +} + +// enrichPricing adds pricing information if not present. +// VAST_WINS: only adds if InLine.Pricing is nil or empty. +func (e *VastEnricher) enrichPricing(inline *model.InLine, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) []string { + var warnings []string + + // Skip if no price to add + if meta.Price <= 0 { + return warnings + } + + // Check collision policy - VAST_WINS means don't overwrite existing + if inline.Pricing != nil && inline.Pricing.Value != "" { + warnings = append(warnings, "pricing: VAST_WINS - keeping existing pricing") + return warnings + } + + // Format the price value + priceStr := formatPrice(meta.Price) + currency := meta.Currency + if currency == "" { + currency = cfg.DefaultCurrency + } + if currency == "" { + currency = "USD" + } + + // Determine placement location + placement := cfg.Placement.PricingPlacement + if placement == "" { + placement = vast.PlacementVastPricing + } + + switch placement { + case vast.PlacementVastPricing: + inline.Pricing = &model.Pricing{ + Model: "CPM", + Currency: currency, + Value: priceStr, + } + case vast.PlacementExtension: + ext := model.ExtensionXML{ + Type: "pricing", + InnerXML: fmt.Sprintf("%s", currency, priceStr), + } + inline.Extensions.Extension = append(inline.Extensions.Extension, ext) + default: + // Default to VAST_PRICING + inline.Pricing = &model.Pricing{ + Model: "CPM", + Currency: currency, + Value: priceStr, + } + } + + return warnings +} + +// enrichAdvertiser adds advertiser information if not present. +// VAST_WINS: only adds if InLine.Advertiser is empty. +func (e *VastEnricher) enrichAdvertiser(inline *model.InLine, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) []string { + var warnings []string + + // Skip if no advertiser to add + if meta.Adomain == "" { + return warnings + } + + // Check collision policy - VAST_WINS means don't overwrite existing + if strings.TrimSpace(inline.Advertiser) != "" { + warnings = append(warnings, "advertiser: VAST_WINS - keeping existing advertiser") + return warnings + } + + // Determine placement location + placement := cfg.Placement.AdvertiserPlacement + if placement == "" { + placement = vast.PlacementAdvertiserTag + } + + switch placement { + case vast.PlacementAdvertiserTag: + inline.Advertiser = meta.Adomain + case vast.PlacementExtension: + ext := model.ExtensionXML{ + Type: "advertiser", + InnerXML: fmt.Sprintf("%s", escapeXML(meta.Adomain)), + } + inline.Extensions.Extension = append(inline.Extensions.Extension, ext) + default: + // Default to ADVERTISER_TAG + inline.Advertiser = meta.Adomain + } + + return warnings +} + +// enrichDuration adds duration to Linear creative if not present. +// VAST_WINS: only adds if Linear.Duration is empty. +func (e *VastEnricher) enrichDuration(inline *model.InLine, meta vast.CanonicalMeta) []string { + var warnings []string + + // Skip if no duration to add + if meta.DurSec <= 0 { + return warnings + } + + // Find the Linear creative + if inline.Creatives == nil || len(inline.Creatives.Creative) == 0 { + return warnings + } + + for i := range inline.Creatives.Creative { + creative := &inline.Creatives.Creative[i] + if creative.Linear == nil { + continue + } + + // Check collision policy - VAST_WINS means don't overwrite existing + if strings.TrimSpace(creative.Linear.Duration) != "" { + warnings = append(warnings, "duration: VAST_WINS - keeping existing duration") + continue + } + + // Set duration in HH:MM:SS format + creative.Linear.Duration = model.SecToHHMMSS(meta.DurSec) + } + + return warnings +} + +// enrichCategories adds IAB categories as an extension. +func (e *VastEnricher) enrichCategories(inline *model.InLine, meta vast.CanonicalMeta) []string { + var warnings []string + + // Skip if no categories to add + if len(meta.Cats) == 0 { + return warnings + } + + // Build category extension XML + var categoryXML strings.Builder + for _, cat := range meta.Cats { + categoryXML.WriteString(fmt.Sprintf("%s", escapeXML(cat))) + } + + ext := model.ExtensionXML{ + Type: "iab_category", + InnerXML: categoryXML.String(), + } + inline.Extensions.Extension = append(inline.Extensions.Extension, ext) + + return warnings +} + +// addDebugExtension adds OpenRTB debug information as an extension. +func (e *VastEnricher) addDebugExtension(inline *model.InLine, meta vast.CanonicalMeta) { + var debugXML strings.Builder + debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.BidID))) + debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.ImpID))) + if meta.DealID != "" { + debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.DealID))) + } + debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.Seat))) + debugXML.WriteString(fmt.Sprintf("%s", formatPrice(meta.Price))) + debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.Currency))) + + ext := model.ExtensionXML{ + Type: "openrtb", + InnerXML: debugXML.String(), + } + inline.Extensions.Extension = append(inline.Extensions.Extension, ext) +} + +// formatPrice formats a price value with appropriate precision. +func formatPrice(price float64) string { + // Use up to 4 decimal places, trimming trailing zeros + s := fmt.Sprintf("%.4f", price) + s = strings.TrimRight(s, "0") + s = strings.TrimRight(s, ".") + if s == "" { + return "0" + } + return s +} + +// escapeXML escapes special characters for XML content. +func escapeXML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +// Ensure VastEnricher implements Enricher interface. +var _ vast.Enricher = (*VastEnricher)(nil) diff --git a/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go b/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go new file mode 100644 index 00000000000..657bff2dba7 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go @@ -0,0 +1,672 @@ +package enrich + +import ( + "testing" + + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewEnricher(t *testing.T) { + enricher := NewEnricher() + assert.NotNil(t, enricher) +} + +func TestEnrich_NilAd(t *testing.T) { + enricher := NewEnricher() + meta := vast.CanonicalMeta{} + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(nil, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) +} + +func TestEnrich_WrapperAd(t *testing.T) { + enricher := NewEnricher() + ad := &model.Ad{ + ID: "wrapper", + Wrapper: &model.Wrapper{}, + } + meta := vast.CanonicalMeta{Price: 5.0} + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "not InLine") +} + +func TestEnrich_Pricing_VastWins_ExistingNotOverwritten(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = &model.Pricing{ + Model: "CPM", + Currency: "EUR", + Value: "10.00", + } + + meta := vast.CanonicalMeta{ + Price: 5.0, + Currency: "USD", + } + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + Placement: vast.PlacementRules{ + PricingPlacement: vast.PlacementVastPricing, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + + // Should have warning about VAST_WINS + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "VAST_WINS") + + // Original pricing should be preserved + assert.Equal(t, "EUR", ad.InLine.Pricing.Currency) + assert.Equal(t, "10.00", ad.InLine.Pricing.Value) +} + +func TestEnrich_Pricing_AddedWhenMissing(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + + meta := vast.CanonicalMeta{ + Price: 5.5, + Currency: "USD", + } + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + Placement: vast.PlacementRules{ + PricingPlacement: vast.PlacementVastPricing, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Pricing should be added + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "CPM", ad.InLine.Pricing.Model) + assert.Equal(t, "USD", ad.InLine.Pricing.Currency) + assert.Equal(t, "5.5", ad.InLine.Pricing.Value) +} + +func TestEnrich_Pricing_AsExtension(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + + meta := vast.CanonicalMeta{ + Price: 3.25, + Currency: "EUR", + } + cfg := vast.ReceiverConfig{ + Placement: vast.PlacementRules{ + PricingPlacement: vast.PlacementExtension, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Pricing should be nil (not added to VAST element) + assert.Nil(t, ad.InLine.Pricing) + + // Should have extension with pricing + require.NotNil(t, ad.InLine.Extensions) + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "pricing" { + found = true + assert.Contains(t, ext.InnerXML, "3.25") + assert.Contains(t, ext.InnerXML, "EUR") + assert.Contains(t, ext.InnerXML, "CPM") + } + } + assert.True(t, found, "pricing extension not found") +} + +func TestEnrich_Pricing_ZeroPriceNotAdded(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + + meta := vast.CanonicalMeta{ + Price: 0, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + assert.Nil(t, ad.InLine.Pricing) +} + +func TestEnrich_Advertiser_VastWins_ExistingNotOverwritten(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Advertiser = "Original Advertiser" + + meta := vast.CanonicalMeta{ + Adomain: "newadvertiser.com", + } + cfg := vast.ReceiverConfig{ + Placement: vast.PlacementRules{ + AdvertiserPlacement: vast.PlacementAdvertiserTag, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + + // Should have warning about VAST_WINS + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "VAST_WINS") + + // Original advertiser should be preserved + assert.Equal(t, "Original Advertiser", ad.InLine.Advertiser) +} + +func TestEnrich_Advertiser_AddedWhenMissing(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Advertiser = "" + + meta := vast.CanonicalMeta{ + Adomain: "example.com", + } + cfg := vast.ReceiverConfig{ + Placement: vast.PlacementRules{ + AdvertiserPlacement: vast.PlacementAdvertiserTag, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + assert.Equal(t, "example.com", ad.InLine.Advertiser) +} + +func TestEnrich_Advertiser_AsExtension(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Advertiser = "" + + meta := vast.CanonicalMeta{ + Adomain: "example.com", + } + cfg := vast.ReceiverConfig{ + Placement: vast.PlacementRules{ + AdvertiserPlacement: vast.PlacementExtension, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Advertiser tag should be empty + assert.Equal(t, "", ad.InLine.Advertiser) + + // Should have extension with advertiser + require.NotNil(t, ad.InLine.Extensions) + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "advertiser" { + found = true + assert.Contains(t, ext.InnerXML, "example.com") + } + } + assert.True(t, found, "advertiser extension not found") +} + +func TestEnrich_Duration_VastWins_ExistingNotOverwritten(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Creatives.Creative[0].Linear.Duration = "00:00:30" + + meta := vast.CanonicalMeta{ + DurSec: 15, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + + // Should have warning about VAST_WINS + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "VAST_WINS") + + // Original duration should be preserved + assert.Equal(t, "00:00:30", ad.InLine.Creatives.Creative[0].Linear.Duration) +} + +func TestEnrich_Duration_AddedWhenMissing(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Creatives.Creative[0].Linear.Duration = "" + + meta := vast.CanonicalMeta{ + DurSec: 15, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + assert.Equal(t, "00:00:15", ad.InLine.Creatives.Creative[0].Linear.Duration) +} + +func TestEnrich_Duration_ZeroNotAdded(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Creatives.Creative[0].Linear.Duration = "" + + meta := vast.CanonicalMeta{ + DurSec: 0, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + assert.Equal(t, "", ad.InLine.Creatives.Creative[0].Linear.Duration) +} + +func TestEnrich_Categories_AddedAsExtension(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + + meta := vast.CanonicalMeta{ + Cats: []string{"IAB1", "IAB2-1", "IAB3"}, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Should have extension with categories + require.NotNil(t, ad.InLine.Extensions) + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "iab_category" { + found = true + assert.Contains(t, ext.InnerXML, "IAB1") + assert.Contains(t, ext.InnerXML, "IAB2-1") + assert.Contains(t, ext.InnerXML, "IAB3") + } + } + assert.True(t, found, "iab_category extension not found") +} + +func TestEnrich_Categories_EmptyNotAdded(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + + meta := vast.CanonicalMeta{ + Cats: []string{}, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Should not have category extension + if ad.InLine.Extensions != nil { + for _, ext := range ad.InLine.Extensions.Extension { + assert.NotEqual(t, "iab_category", ext.Type) + } + } +} + +func TestEnrich_DebugExtension(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + + meta := vast.CanonicalMeta{ + BidID: "bid123", + ImpID: "imp456", + DealID: "deal789", + Seat: "bidder1", + Price: 2.5, + Currency: "USD", + } + cfg := vast.ReceiverConfig{ + Debug: true, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Should have openrtb debug extension + require.NotNil(t, ad.InLine.Extensions) + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "openrtb" { + found = true + assert.Contains(t, ext.InnerXML, "bid123") + assert.Contains(t, ext.InnerXML, "imp456") + assert.Contains(t, ext.InnerXML, "deal789") + assert.Contains(t, ext.InnerXML, "bidder1") + assert.Contains(t, ext.InnerXML, "2.5") + assert.Contains(t, ext.InnerXML, "USD") + } + } + assert.True(t, found, "openrtb extension not found") +} + +func TestEnrich_DebugExtension_NoDealID(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + + meta := vast.CanonicalMeta{ + BidID: "bid123", + ImpID: "imp456", + DealID: "", // No deal + Seat: "bidder1", + Price: 2.5, + Currency: "USD", + } + cfg := vast.ReceiverConfig{ + Debug: true, + } + + _, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + + // Should have openrtb debug extension without DealID + require.NotNil(t, ad.InLine.Extensions) + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "openrtb" { + assert.NotContains(t, ext.InnerXML, "") + } + } +} + +func TestEnrich_DebugExtension_PlacementDebug(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + + meta := vast.CanonicalMeta{ + BidID: "bid123", + } + cfg := vast.ReceiverConfig{ + Debug: false, // Global debug off + Placement: vast.PlacementRules{ + Debug: true, // Placement debug on + }, + } + + _, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + + // Should have openrtb debug extension + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "openrtb" { + found = true + } + } + assert.True(t, found, "openrtb extension not found when placement debug enabled") +} + +func TestEnrich_FullEnrichment(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + ad.InLine.Advertiser = "" + ad.InLine.Creatives.Creative[0].Linear.Duration = "" + + meta := vast.CanonicalMeta{ + BidID: "bid123", + ImpID: "imp456", + Seat: "bidder1", + Price: 5.5, + Currency: "USD", + Adomain: "advertiser.com", + Cats: []string{"IAB1", "IAB2"}, + DurSec: 30, + } + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + Debug: true, + Placement: vast.PlacementRules{ + PricingPlacement: vast.PlacementVastPricing, + AdvertiserPlacement: vast.PlacementAdvertiserTag, + }, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Check all enrichments + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "5.5", ad.InLine.Pricing.Value) + assert.Equal(t, "advertiser.com", ad.InLine.Advertiser) + assert.Equal(t, "00:00:30", ad.InLine.Creatives.Creative[0].Linear.Duration) + + // Check extensions + require.NotNil(t, ad.InLine.Extensions) + hasCategory := false + hasOpenRTB := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "iab_category" { + hasCategory = true + } + if ext.Type == "openrtb" { + hasOpenRTB = true + } + } + assert.True(t, hasCategory) + assert.True(t, hasOpenRTB) +} + +func TestFormatPrice(t *testing.T) { + tests := []struct { + price float64 + expected string + }{ + {0, "0"}, + {1, "1"}, + {1.5, "1.5"}, + {1.50, "1.5"}, + {1.55, "1.55"}, + {1.555, "1.555"}, + {1.5555, "1.5555"}, + {1.55555, "1.5555"}, // Truncates to 4 decimals + {10.00, "10"}, + {0.001, "0.001"}, + {0.0001, "0.0001"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := formatPrice(tt.price) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEscapeXML(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "simple"}, + {"a & b", "a & b"}, + {"", "<tag>"}, + {`"quoted"`, ""quoted""}, + {"it's", "it's"}, + {"", "<a & 'b'>"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := escapeXML(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEnrich_XMLMarshalRoundTrip(t *testing.T) { + enricher := NewEnricher() + + // Parse sample VAST + sampleVAST := ` + + + + Test + Test Ad + + + + + + + + + + + + + +` + + parsedVast, err := model.ParseVastAdm(sampleVAST) + require.NoError(t, err) + require.Len(t, parsedVast.Ads, 1) + + ad := &parsedVast.Ads[0] + meta := vast.CanonicalMeta{ + BidID: "bid123", + ImpID: "imp456", + Price: 5.0, + Currency: "USD", + Adomain: "advertiser.com", + Cats: []string{"IAB1"}, + DurSec: 30, + } + cfg := vast.ReceiverConfig{ + Debug: true, + } + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Marshal back to XML + xmlBytes, err := parsedVast.Marshal() + require.NoError(t, err) + + xmlStr := string(xmlBytes) + assert.Contains(t, xmlStr, "Pricing") + assert.Contains(t, xmlStr, "advertiser.com") + assert.Contains(t, xmlStr, "00:00:30") + assert.Contains(t, xmlStr, "iab_category") + assert.Contains(t, xmlStr, "openrtb") +} + +// createTestAd creates a test Ad with InLine and Linear creative +func createTestAd() *model.Ad { + return &model.Ad{ + ID: "test-ad", + InLine: &model.InLine{ + AdSystem: &model.AdSystem{Value: "Test"}, + AdTitle: "Test Ad", + Creatives: &model.Creatives{ + Creative: []model.Creative{ + { + ID: "creative1", + Linear: &model.Linear{ + Duration: "", + }, + }, + }, + }, + }, + } +} + +func TestEnrich_ExistingExtensionsPreserved(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Extensions = &model.Extensions{ + Extension: []model.ExtensionXML{ + {Type: "existing", InnerXML: "preserved"}, + }, + } + + meta := vast.CanonicalMeta{ + Cats: []string{"IAB1"}, + } + cfg := vast.ReceiverConfig{} + + warnings, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + assert.Empty(t, warnings) + + // Should have both existing and new extensions + require.NotNil(t, ad.InLine.Extensions) + assert.GreaterOrEqual(t, len(ad.InLine.Extensions.Extension), 2) + + // Check existing is preserved + found := false + for _, ext := range ad.InLine.Extensions.Extension { + if ext.Type == "existing" { + found = true + assert.Contains(t, ext.InnerXML, "preserved") + } + } + assert.True(t, found, "existing extension should be preserved") +} + +func TestEnrich_DefaultCurrencyFallback(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + + meta := vast.CanonicalMeta{ + Price: 5.0, + Currency: "", // No currency in meta + } + cfg := vast.ReceiverConfig{ + DefaultCurrency: "GBP", + } + + _, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "GBP", ad.InLine.Pricing.Currency) +} + +func TestEnrich_NoCurrencyDefaultsToUSD(t *testing.T) { + enricher := NewEnricher() + ad := createTestAd() + ad.InLine.Pricing = nil + + meta := vast.CanonicalMeta{ + Price: 5.0, + Currency: "", // No currency + } + cfg := vast.ReceiverConfig{ + DefaultCurrency: "", // No default either + } + + _, err := enricher.Enrich(ad, meta, cfg) + assert.NoError(t, err) + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "USD", ad.InLine.Pricing.Currency) +} diff --git a/modules/prebid/ctv_vast_enrichment/format/format.go b/modules/prebid/ctv_vast_enrichment/format/format.go new file mode 100644 index 00000000000..ad4b65947a9 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/format/format.go @@ -0,0 +1,114 @@ +// Package format provides VAST XML formatting capabilities. +package format + +import ( + "encoding/xml" + + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" +) + +// VastFormatter implements the Formatter interface for GAM_SSU and other receivers. +type VastFormatter struct{} + +// NewFormatter creates a new VastFormatter instance. +func NewFormatter() *VastFormatter { + return &VastFormatter{} +} + +// Format converts enriched VAST ads into XML output. +// It implements the vast.Formatter interface. +// +// For each EnrichedAd, it creates one element with: +// - id attribute from meta.AdID if available, else meta.BidID +// - sequence attribute from EnrichedAd.Sequence (if multiple ads) +// - The enriched InLine subtree from the ad +func (f *VastFormatter) Format(ads []vast.EnrichedAd, cfg vast.ReceiverConfig) ([]byte, []string, error) { + var warnings []string + + // Determine VAST version + version := cfg.VastVersionDefault + if version == "" { + version = "4.0" + } + + // Handle no-ad case + if len(ads) == 0 { + noAdXML := model.BuildNoAdVast(version) + return noAdXML, warnings, nil + } + + // Build the VAST document + vastDoc := model.Vast{ + Version: version, + Ads: make([]model.Ad, 0, len(ads)), + } + + isPod := len(ads) > 1 + + for _, enriched := range ads { + if enriched.Ad == nil { + warnings = append(warnings, "skipping nil ad in format") + continue + } + + // Create a copy of the ad to avoid modifying the original + ad := copyAd(enriched.Ad) + + // Set Ad.ID from meta (prefer AdID if tracked, else BidID) + ad.ID = deriveAdID(enriched.Meta) + + // Set sequence attribute for pods (multiple ads) + if isPod && enriched.Sequence > 0 { + ad.Sequence = enriched.Sequence + } else if !isPod { + ad.Sequence = 0 // Don't set sequence for single ad + } + + vastDoc.Ads = append(vastDoc.Ads, *ad) + } + + // Handle case where all ads were nil + if len(vastDoc.Ads) == 0 { + noAdXML := model.BuildNoAdVast(version) + warnings = append(warnings, "all ads were nil, returning no-ad VAST") + return noAdXML, warnings, nil + } + + // Marshal with indentation + xmlBytes, err := xml.MarshalIndent(vastDoc, "", " ") + if err != nil { + return nil, warnings, err + } + + // Add XML declaration + output := append([]byte(xml.Header), xmlBytes...) + + return output, warnings, nil +} + +// deriveAdID determines the Ad ID from metadata. +// Uses BidID as the identifier (AdID is not currently tracked in CanonicalMeta). +func deriveAdID(meta vast.CanonicalMeta) string { + // BidID is the primary identifier + if meta.BidID != "" { + return meta.BidID + } + // Fallback to ImpID if BidID is empty + if meta.ImpID != "" { + return "imp-" + meta.ImpID + } + return "" +} + +// copyAd creates a shallow copy of an Ad to avoid modifying the original. +func copyAd(src *model.Ad) *model.Ad { + if src == nil { + return nil + } + ad := *src + return &ad +} + +// Ensure VastFormatter implements Formatter interface. +var _ vast.Formatter = (*VastFormatter)(nil) diff --git a/modules/prebid/ctv_vast_enrichment/format/format_test.go b/modules/prebid/ctv_vast_enrichment/format/format_test.go new file mode 100644 index 00000000000..68e3ba5e0d4 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/format/format_test.go @@ -0,0 +1,488 @@ +package format + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewFormatter(t *testing.T) { + formatter := NewFormatter() + assert.NotNil(t, formatter) +} + +func TestFormat_EmptyAds_ReturnsNoAdVast(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + xmlBytes, warnings, err := formatter.Format([]vast.EnrichedAd{}, cfg) + require.NoError(t, err) + assert.Empty(t, warnings) + + expected := loadGolden(t, "no_ad.xml") + assertXMLEqual(t, expected, xmlBytes) +} + +func TestFormat_SingleAd(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + ads := []vast.EnrichedAd{ + { + Ad: createTestAd("bid-123", "TestAdServer", "Test Ad", "advertiser.com", "5.5", "00:00:30", "creative1", "https://example.com/video.mp4", []string{"IAB1"}), + Meta: vast.CanonicalMeta{BidID: "bid-123"}, + Sequence: 1, + }, + } + + xmlBytes, warnings, err := formatter.Format(ads, cfg) + require.NoError(t, err) + assert.Empty(t, warnings) + + expected := loadGolden(t, "single_ad.xml") + assertXMLEqual(t, expected, xmlBytes) +} + +func TestFormat_PodWithTwoAds(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + ads := []vast.EnrichedAd{ + { + Ad: createTestAd("bid-001", "TestAdServer", "First Ad", "first.com", "10", "00:00:15", "creative1", "https://example.com/first.mp4", nil), + Meta: vast.CanonicalMeta{BidID: "bid-001"}, + Sequence: 1, + }, + { + Ad: createTestAd("bid-002", "TestAdServer", "Second Ad", "second.com", "8", "00:00:30", "creative2", "https://example.com/second.mp4", nil), + Meta: vast.CanonicalMeta{BidID: "bid-002"}, + Sequence: 2, + }, + } + + xmlBytes, warnings, err := formatter.Format(ads, cfg) + require.NoError(t, err) + assert.Empty(t, warnings) + + expected := loadGolden(t, "pod_two_ads.xml") + assertXMLEqual(t, expected, xmlBytes) +} + +func TestFormat_PodWithThreeAds(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + ads := []vast.EnrichedAd{ + { + Ad: createMinimalAd("bid-alpha", "AdServer1", "Alpha Ad", "15", "USD", "00:00:10"), + Meta: vast.CanonicalMeta{BidID: "bid-alpha"}, + Sequence: 1, + }, + { + Ad: createMinimalAd("bid-beta", "AdServer2", "Beta Ad", "12", "EUR", "00:00:20"), + Meta: vast.CanonicalMeta{BidID: "bid-beta"}, + Sequence: 2, + }, + { + Ad: createMinimalAd("bid-gamma", "AdServer3", "Gamma Ad", "9", "USD", "00:00:15"), + Meta: vast.CanonicalMeta{BidID: "bid-gamma"}, + Sequence: 3, + }, + } + + xmlBytes, warnings, err := formatter.Format(ads, cfg) + require.NoError(t, err) + assert.Empty(t, warnings) + + expected := loadGolden(t, "pod_three_ads.xml") + assertXMLEqual(t, expected, xmlBytes) +} + +func TestFormat_NilAdsInList(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + ads := []vast.EnrichedAd{ + { + Ad: nil, // nil ad + Meta: vast.CanonicalMeta{BidID: "bid-nil"}, + Sequence: 1, + }, + { + Ad: createMinimalAd("bid-valid", "AdServer", "Valid Ad", "5", "USD", "00:00:15"), + Meta: vast.CanonicalMeta{BidID: "bid-valid"}, + Sequence: 2, + }, + } + + xmlBytes, warnings, err := formatter.Format(ads, cfg) + require.NoError(t, err) + assert.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "skipping nil ad") + + // Should still produce valid VAST with the non-nil ad + xmlStr := string(xmlBytes) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, "https://tracker.example.com/start") + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, "https://tracker.example.com/complete") +} + +func TestFormat_PreservesExtensions(t *testing.T) { + formatter := NewFormatter() + cfg := vast.ReceiverConfig{ + VastVersionDefault: "4.0", + } + + ad := createMinimalAd("", "AdServer", "WithExtensions", "5", "USD", "00:00:15") + ad.InLine.Extensions = &model.Extensions{ + Extension: []model.ExtensionXML{ + {Type: "openrtb", InnerXML: "abc123bidder1"}, + {Type: "custom", InnerXML: "custom data"}, + }, + } + + ads := []vast.EnrichedAd{ + { + Ad: ad, + Meta: vast.CanonicalMeta{BidID: "bid-ext"}, + }, + } + + xmlBytes, _, err := formatter.Format(ads, cfg) + require.NoError(t, err) + + xmlStr := string(xmlBytes) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, "abc123") + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, "custom data") +} + +func TestDeriveAdID(t *testing.T) { + tests := []struct { + name string + meta vast.CanonicalMeta + expected string + }{ + { + name: "with BidID", + meta: vast.CanonicalMeta{BidID: "bid-123"}, + expected: "bid-123", + }, + { + name: "BidID takes precedence over ImpID", + meta: vast.CanonicalMeta{BidID: "bid-456", ImpID: "imp-789"}, + expected: "bid-456", + }, + { + name: "fallback to ImpID when BidID empty", + meta: vast.CanonicalMeta{BidID: "", ImpID: "imp-123"}, + expected: "imp-imp-123", + }, + { + name: "both empty", + meta: vast.CanonicalMeta{BidID: "", ImpID: ""}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := deriveAdID(tt.meta) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Helper functions + +func createTestAd(id, adSystem, adTitle, advertiser, price, duration, creativeID, mediaURL string, categories []string) *model.Ad { + ad := &model.Ad{ + ID: id, + InLine: &model.InLine{ + AdSystem: &model.AdSystem{Value: adSystem}, + AdTitle: adTitle, + Advertiser: advertiser, + Pricing: &model.Pricing{ + Model: "CPM", + Currency: "USD", + Value: price, + }, + Creatives: &model.Creatives{ + Creative: []model.Creative{ + { + ID: creativeID, + Linear: &model.Linear{ + Duration: duration, + MediaFiles: &model.MediaFiles{ + MediaFile: []model.MediaFile{ + { + Delivery: "progressive", + Type: "video/mp4", + Width: 1920, + Height: 1080, + Value: mediaURL, + }, + }, + }, + }, + }, + }, + }, + }, + } + + if len(categories) > 0 { + var catXML string + for _, cat := range categories { + catXML += "" + cat + "" + } + ad.InLine.Extensions = &model.Extensions{ + Extension: []model.ExtensionXML{ + {Type: "iab_category", InnerXML: catXML}, + }, + } + } + + return ad +} + +func createMinimalAd(id, adSystem, adTitle, price, currency, duration string) *model.Ad { + return &model.Ad{ + ID: id, + InLine: &model.InLine{ + AdSystem: &model.AdSystem{Value: adSystem}, + AdTitle: adTitle, + Pricing: &model.Pricing{ + Model: "CPM", + Currency: currency, + Value: price, + }, + Creatives: &model.Creatives{ + Creative: []model.Creative{ + { + Linear: &model.Linear{ + Duration: duration, + }, + }, + }, + }, + }, + } +} + +func loadGolden(t *testing.T, filename string) []byte { + t.Helper() + path := filepath.Join("testdata", filename) + data, err := os.ReadFile(path) + require.NoError(t, err, "failed to read golden file: %s", path) + return data +} + +// assertXMLEqual compares two XML documents by normalizing whitespace. +func assertXMLEqual(t *testing.T, expected, actual []byte) { + t.Helper() + expectedNorm := normalizeXML(string(expected)) + actualNorm := normalizeXML(string(actual)) + assert.Equal(t, expectedNorm, actualNorm) +} + +// normalizeXML normalizes XML for comparison by trimming whitespace. +func normalizeXML(xml string) string { + // Split into lines and trim each + lines := strings.Split(xml, "\n") + var normalized []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + normalized = append(normalized, trimmed) + } + } + return strings.Join(normalized, "\n") +} diff --git a/modules/prebid/ctv_vast_enrichment/format/testdata/no_ad.xml b/modules/prebid/ctv_vast_enrichment/format/testdata/no_ad.xml new file mode 100644 index 00000000000..1ebd9e11b24 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/format/testdata/no_ad.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/prebid/ctv_vast_enrichment/format/testdata/pod_three_ads.xml b/modules/prebid/ctv_vast_enrichment/format/testdata/pod_three_ads.xml new file mode 100644 index 00000000000..e48d1591089 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/format/testdata/pod_three_ads.xml @@ -0,0 +1,45 @@ + + + + + AdServer1 + Alpha Ad + 15 + + + + 00:00:10 + + + + + + + + AdServer2 + Beta Ad + 12 + + + + 00:00:20 + + + + + + + + AdServer3 + Gamma Ad + 9 + + + + 00:00:15 + + + + + + diff --git a/modules/prebid/ctv_vast_enrichment/format/testdata/pod_two_ads.xml b/modules/prebid/ctv_vast_enrichment/format/testdata/pod_two_ads.xml new file mode 100644 index 00000000000..be9c4ef1794 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/format/testdata/pod_two_ads.xml @@ -0,0 +1,39 @@ + + + + + TestAdServer + First Ad + first.com + 10 + + + + 00:00:15 + + + + + + + + + + + TestAdServer + Second Ad + second.com + 8 + + + + 00:00:30 + + + + + + + + + diff --git a/modules/prebid/ctv_vast_enrichment/format/testdata/single_ad.xml b/modules/prebid/ctv_vast_enrichment/format/testdata/single_ad.xml new file mode 100644 index 00000000000..28c514798b8 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/format/testdata/single_ad.xml @@ -0,0 +1,24 @@ + + + + + TestAdServer + Test Ad + advertiser.com + 5.5 + + + + 00:00:30 + + + + + + + + IAB1 + + + + diff --git a/modules/prebid/ctv_vast_enrichment/handler.go b/modules/prebid/ctv_vast_enrichment/handler.go new file mode 100644 index 00000000000..74b8562ef8a --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/handler.go @@ -0,0 +1,167 @@ +package vast + +import ( + "context" + "net/http" + + "github.com/prebid/openrtb/v20/openrtb2" +) + +// Handler provides HTTP handling for CTV VAST requests. +type Handler struct { + // Config contains the default receiver configuration. + Config ReceiverConfig + // Selector selects bids from auction response. + Selector BidSelector + // Enricher enriches VAST ads with metadata. + Enricher Enricher + // Formatter formats enriched ads as VAST XML. + Formatter Formatter + // AuctionFunc is called to run the auction pipeline. + // This should be injected with the actual auction implementation. + AuctionFunc func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) +} + +// NewHandler creates a new VAST HTTP handler with default configuration. +// Note: Selector, Enricher, and Formatter must be set via With* methods +// before the handler can process requests. +func NewHandler() *Handler { + return &Handler{ + Config: DefaultConfig(), + } +} + +// ServeHTTP handles GET requests for CTV VAST ads. +// Query parameters (TODO: implement full parsing): +// - pod_id: Pod identifier +// - duration: Requested pod duration +// - max_ads: Maximum ads in pod +// +// Response: +// - 200 OK with Content-Type: application/xml on success +// - 204 No Content if no ads available +// - 400 Bad Request for invalid parameters +// - 500 Internal Server Error for processing failures +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Only accept GET requests + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Validate required dependencies + if h.Selector == nil || h.Enricher == nil || h.Formatter == nil { + http.Error(w, "Handler not properly configured", http.StatusInternalServerError) + return + } + + // TODO: Parse query parameters and build OpenRTB request + // This is a placeholder for the actual implementation: + // - Parse pod_id, duration, max_ads from query string + // - Build openrtb2.BidRequest with Video imp + // - Apply site/app context from query or headers + bidRequest := h.buildBidRequest(r) + + // TODO: Call auction pipeline + // This is a placeholder - actual implementation would: + // - Call the Prebid Server auction endpoint + // - Get BidResponse from exchange + var bidResponse *openrtb2.BidResponse + var err error + + if h.AuctionFunc != nil { + bidResponse, err = h.AuctionFunc(ctx, bidRequest) + if err != nil { + http.Error(w, "Auction failed: "+err.Error(), http.StatusInternalServerError) + return + } + } else { + // No auction function configured - return no-ad + bidResponse = &openrtb2.BidResponse{} + } + + // Build VAST from bid response + result, err := BuildVastFromBidResponse(ctx, bidRequest, bidResponse, h.Config, h.Selector, h.Enricher, h.Formatter) + if err != nil { + // Log error but still try to return valid VAST + // result.VastXML should contain no-ad VAST + } + + // Set response headers + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + + // Handle no-ad case + if result.NoAd { + w.WriteHeader(http.StatusOK) // Still 200 per VAST spec + } + + // Write VAST XML + w.Write(result.VastXML) +} + +// buildBidRequest creates an OpenRTB BidRequest from the HTTP request. +// TODO: Implement full parsing of query parameters. +func (h *Handler) buildBidRequest(r *http.Request) *openrtb2.BidRequest { + // Placeholder implementation + // TODO: Parse these from query string: + // - pod_id -> BidRequest.ID + // - duration -> Video.MaxDuration + // - max_ads -> Video.MaxAds (via pod extension) + // - slot_count -> multiple Imp objects + + query := r.URL.Query() + podID := query.Get("pod_id") + if podID == "" { + podID = "ctv-pod-1" + } + + return &openrtb2.BidRequest{ + ID: podID, + Imp: []openrtb2.Imp{ + { + ID: "imp-1", + Video: &openrtb2.Video{ + MIMEs: []string{"video/mp4"}, + MinDuration: 5, + MaxDuration: 30, + }, + }, + }, + Site: &openrtb2.Site{ + Page: r.Header.Get("Referer"), + }, + } +} + +// WithConfig sets the receiver configuration. +func (h *Handler) WithConfig(cfg ReceiverConfig) *Handler { + h.Config = cfg + return h +} + +// WithSelector sets the bid selector. +func (h *Handler) WithSelector(s BidSelector) *Handler { + h.Selector = s + return h +} + +// WithEnricher sets the VAST enricher. +func (h *Handler) WithEnricher(e Enricher) *Handler { + h.Enricher = e + return h +} + +// WithFormatter sets the VAST formatter. +func (h *Handler) WithFormatter(f Formatter) *Handler { + h.Formatter = f + return h +} + +// WithAuctionFunc sets the auction function. +func (h *Handler) WithAuctionFunc(fn func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error)) *Handler { + h.AuctionFunc = fn + return h +} diff --git a/modules/prebid/ctv_vast_enrichment/model/model.go b/modules/prebid/ctv_vast_enrichment/model/model.go new file mode 100644 index 00000000000..e15a3075f8e --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/model/model.go @@ -0,0 +1,28 @@ +// Package model defines VAST XML data structures for CTV ad processing. +package model + +// VastAd represents a parsed VAST ad with its components. +// This is a higher-level domain object; for XML marshaling use the Vast struct. +type VastAd struct { + // ID is the unique identifier for this ad. + ID string + // AdSystem identifies the ad server that returned the ad. + AdSystem string + // AdTitle is the common name of the ad. + AdTitle string + // Description is a longer description of the ad. + Description string + // Advertiser is the name of the advertiser. + Advertiser string + // DurationSec is the duration of the creative in seconds. + DurationSec int + // ErrorURLs contains error tracking URLs. + ErrorURLs []string + // ImpressionURLs contains impression tracking URLs. + ImpressionURLs []string + // Sequence indicates the position in an ad pod. + Sequence int + // RawVAST contains the original VAST XML if preserved. + RawVAST []byte +} + diff --git a/modules/prebid/ctv_vast_enrichment/model/parser.go b/modules/prebid/ctv_vast_enrichment/model/parser.go new file mode 100644 index 00000000000..9e80b143502 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/model/parser.go @@ -0,0 +1,171 @@ +package model + +import ( + "encoding/xml" + "errors" + "strings" +) + +// ErrNotVAST indicates the input string does not appear to be VAST XML. +var ErrNotVAST = errors.New("input does not contain VAST XML") + +// ErrVASTParseFailure indicates the VAST XML could not be parsed. +var ErrVASTParseFailure = errors.New("failed to parse VAST XML") + +// ParseVastAdm parses a VAST XML string from an OpenRTB bid's AdM field. +// Returns an error if the input doesn't contain " '9' { + return false, errors.New("invalid character in number") + } + n = n*10 + int(c-'0') + } + *result = n + return true, nil +} + +// IsInLineAd returns true if the ad is an InLine ad (not a Wrapper). +func IsInLineAd(ad *Ad) bool { + return ad != nil && ad.InLine != nil +} + +// IsWrapperAd returns true if the ad is a Wrapper ad. +func IsWrapperAd(ad *Ad) bool { + return ad != nil && ad.Wrapper != nil +} diff --git a/modules/prebid/ctv_vast_enrichment/model/parser_test.go b/modules/prebid/ctv_vast_enrichment/model/parser_test.go new file mode 100644 index 00000000000..49f35ba0b42 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/model/parser_test.go @@ -0,0 +1,528 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Sample VAST XML strings for testing +const ( + sampleVAST30 = ` + + + + Test Ad Server + Test Video Ad + Test Advertiser Inc + + + + + 00:00:30 + + + + + + + + + + + + + +` + + sampleVAST40 = ` + + + + PBS-CTV + VAST 4.0 Test + 5.50 + + + 8465 + + 00:00:15 + + + + + + 1 + + + + +` + + sampleVASTWrapper = ` + + + + Wrapper System + + + + + + + + + + + + + +` + + sampleVASTNoVersion = ` + + + + No Version Ad + + + + 00:00:10 + + + + + +` + + sampleVASTMultipleAds = ` + + + + First Ad + + + + 00:00:15 + + + + + + + + Second Ad + + + + 00:00:30 + + + + + +` + + sampleVASTMinimal = `Min00:00:05` + + sampleVASTEmpty = ` + +` + + invalidXML = `Broken` + notVAST = `Not VAST` + emptyString = `` + justWhitespace = ` ` +) + +func TestParseVastAdm_ValidVAST30(t *testing.T) { + vast, err := ParseVastAdm(sampleVAST30) + require.NoError(t, err) + require.NotNil(t, vast) + + assert.Equal(t, "3.0", vast.Version) + require.Len(t, vast.Ads, 1) + + ad := vast.Ads[0] + assert.Equal(t, "12345", ad.ID) + assert.Equal(t, 1, ad.Sequence) + + require.NotNil(t, ad.InLine) + assert.Equal(t, "Test Video Ad", ad.InLine.AdTitle) + assert.Equal(t, "Test Advertiser Inc", ad.InLine.Advertiser) + + require.NotNil(t, ad.InLine.AdSystem) + assert.Equal(t, "Test Ad Server", ad.InLine.AdSystem.Value) + assert.Equal(t, "1.0", ad.InLine.AdSystem.Version) + + require.NotNil(t, ad.InLine.Creatives) + require.Len(t, ad.InLine.Creatives.Creative, 1) + + creative := ad.InLine.Creatives.Creative[0] + assert.Equal(t, "creative1", creative.ID) + + require.NotNil(t, creative.Linear) + assert.Equal(t, "00:00:30", creative.Linear.Duration) +} + +func TestParseVastAdm_ValidVAST40WithExtensions(t *testing.T) { + vast, err := ParseVastAdm(sampleVAST40) + require.NoError(t, err) + require.NotNil(t, vast) + + assert.Equal(t, "4.0", vast.Version) + require.Len(t, vast.Ads, 1) + + ad := vast.Ads[0] + require.NotNil(t, ad.InLine) + + // Check pricing + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "cpm", ad.InLine.Pricing.Model) + assert.Equal(t, "USD", ad.InLine.Pricing.Currency) + assert.Equal(t, "5.50", ad.InLine.Pricing.Value) + + // Check extensions + require.NotNil(t, ad.InLine.Extensions) + require.Len(t, ad.InLine.Extensions.Extension, 1) + assert.Equal(t, "waterfall", ad.InLine.Extensions.Extension[0].Type) + assert.Contains(t, ad.InLine.Extensions.Extension[0].InnerXML, "WaterfallIndex") + + // Check UniversalAdId + require.NotNil(t, ad.InLine.Creatives) + require.Len(t, ad.InLine.Creatives.Creative, 1) + creative := ad.InLine.Creatives.Creative[0] + require.NotNil(t, creative.UniversalAdID) + assert.Equal(t, "ad-id.org", creative.UniversalAdID.IDRegistry) + assert.Equal(t, "8465", creative.UniversalAdID.IDValue) +} + +func TestParseVastAdm_WrapperAd(t *testing.T) { + vast, err := ParseVastAdm(sampleVASTWrapper) + require.NoError(t, err) + require.NotNil(t, vast) + + require.Len(t, vast.Ads, 1) + ad := vast.Ads[0] + + assert.Nil(t, ad.InLine) + require.NotNil(t, ad.Wrapper) + assert.Equal(t, "Wrapper System", ad.Wrapper.AdSystem.Value) + + assert.True(t, IsWrapperAd(&ad)) + assert.False(t, IsInLineAd(&ad)) +} + +func TestParseVastAdm_NoVersion(t *testing.T) { + vast, err := ParseVastAdm(sampleVASTNoVersion) + require.NoError(t, err) + require.NotNil(t, vast) + + // Empty version is acceptable + assert.Equal(t, "", vast.Version) + require.Len(t, vast.Ads, 1) + assert.Equal(t, "No Version Ad", vast.Ads[0].InLine.AdTitle) +} + +func TestParseVastAdm_MultipleAds(t *testing.T) { + vast, err := ParseVastAdm(sampleVASTMultipleAds) + require.NoError(t, err) + require.NotNil(t, vast) + + require.Len(t, vast.Ads, 2) + assert.Equal(t, "ad1", vast.Ads[0].ID) + assert.Equal(t, 1, vast.Ads[0].Sequence) + assert.Equal(t, "ad2", vast.Ads[1].ID) + assert.Equal(t, 2, vast.Ads[1].Sequence) +} + +func TestParseVastAdm_MinimalVAST(t *testing.T) { + vast, err := ParseVastAdm(sampleVASTMinimal) + require.NoError(t, err) + require.NotNil(t, vast) + + assert.Equal(t, "3.0", vast.Version) + require.Len(t, vast.Ads, 1) + assert.Equal(t, "00:00:05", vast.Ads[0].InLine.Creatives.Creative[0].Linear.Duration) +} + +func TestParseVastAdm_EmptyVAST(t *testing.T) { + vast, err := ParseVastAdm(sampleVASTEmpty) + require.NoError(t, err) + require.NotNil(t, vast) + + assert.Equal(t, "3.0", vast.Version) + assert.Empty(t, vast.Ads) +} + +func TestParseVastAdm_NotVAST(t *testing.T) { + vast, err := ParseVastAdm(notVAST) + assert.ErrorIs(t, err, ErrNotVAST) + assert.Nil(t, vast) +} + +func TestParseVastAdm_EmptyString(t *testing.T) { + vast, err := ParseVastAdm(emptyString) + assert.ErrorIs(t, err, ErrNotVAST) + assert.Nil(t, vast) +} + +func TestParseVastAdm_Whitespace(t *testing.T) { + vast, err := ParseVastAdm(justWhitespace) + assert.ErrorIs(t, err, ErrNotVAST) + assert.Nil(t, vast) +} + +func TestParseVastAdm_InvalidXML(t *testing.T) { + vast, err := ParseVastAdm(invalidXML) + assert.ErrorIs(t, err, ErrVASTParseFailure) + assert.Nil(t, vast) +} + +func TestParseVastOrSkeleton_Success(t *testing.T) { + cfg := ParserConfig{ + AllowSkeletonVast: true, + VastVersionDefault: "3.0", + } + + vast, warnings, err := ParseVastOrSkeleton(sampleVAST30, cfg) + require.NoError(t, err) + require.NotNil(t, vast) + assert.Empty(t, warnings) + assert.Equal(t, "3.0", vast.Version) +} + +func TestParseVastOrSkeleton_FailWithSkeleton(t *testing.T) { + cfg := ParserConfig{ + AllowSkeletonVast: true, + VastVersionDefault: "4.0", + } + + vast, warnings, err := ParseVastOrSkeleton(notVAST, cfg) + require.NoError(t, err) + require.NotNil(t, vast) + + // Should return skeleton + assert.Equal(t, "4.0", vast.Version) + require.Len(t, vast.Ads, 1) + assert.Equal(t, "PBS-CTV", vast.Ads[0].InLine.AdSystem.Value) + + // Should have warning + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "VAST parse failed") +} + +func TestParseVastOrSkeleton_FailWithoutSkeleton(t *testing.T) { + cfg := ParserConfig{ + AllowSkeletonVast: false, + VastVersionDefault: "3.0", + } + + vast, warnings, err := ParseVastOrSkeleton(notVAST, cfg) + assert.Error(t, err) + assert.Nil(t, vast) + assert.Empty(t, warnings) +} + +func TestParseVastOrSkeleton_InvalidXMLWithSkeleton(t *testing.T) { + cfg := ParserConfig{ + AllowSkeletonVast: true, + VastVersionDefault: "3.0", + } + + vast, warnings, err := ParseVastOrSkeleton(invalidXML, cfg) + require.NoError(t, err) + require.NotNil(t, vast) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "VAST parse failed") +} + +func TestParseVastOrSkeleton_DefaultVersion(t *testing.T) { + cfg := ParserConfig{ + AllowSkeletonVast: true, + VastVersionDefault: "", // Should default to "3.0" + } + + vast, _, err := ParseVastOrSkeleton(notVAST, cfg) + require.NoError(t, err) + require.NotNil(t, vast) + assert.Equal(t, "3.0", vast.Version) +} + +func TestParseVastFromBytes(t *testing.T) { + data := []byte(sampleVASTMinimal) + vast, err := ParseVastFromBytes(data) + require.NoError(t, err) + require.NotNil(t, vast) + assert.Equal(t, "3.0", vast.Version) +} + +func TestExtractFirstAd(t *testing.T) { + tests := []struct { + name string + vast *Vast + expectID string + expectNil bool + }{ + { + name: "nil vast", + vast: nil, + expectNil: true, + }, + { + name: "empty ads", + vast: &Vast{Ads: []Ad{}}, + expectNil: true, + }, + { + name: "single ad", + vast: &Vast{Ads: []Ad{{ID: "first"}}}, + expectID: "first", + }, + { + name: "multiple ads", + vast: &Vast{Ads: []Ad{{ID: "first"}, {ID: "second"}}}, + expectID: "first", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ad := ExtractFirstAd(tt.vast) + if tt.expectNil { + assert.Nil(t, ad) + } else { + require.NotNil(t, ad) + assert.Equal(t, tt.expectID, ad.ID) + } + }) + } +} + +func TestExtractDuration(t *testing.T) { + tests := []struct { + name string + xml string + expected string + }{ + { + name: "inline with duration", + xml: sampleVAST30, + expected: "00:00:30", + }, + { + name: "minimal vast", + xml: sampleVASTMinimal, + expected: "00:00:05", + }, + { + name: "empty vast", + xml: sampleVASTEmpty, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vast, err := ParseVastAdm(tt.xml) + require.NoError(t, err) + duration := ExtractDuration(vast) + assert.Equal(t, tt.expected, duration) + }) + } +} + +func TestParseDurationToSeconds(t *testing.T) { + tests := []struct { + name string + duration string + expected int + }{ + {"empty", "", 0}, + {"zero", "00:00:00", 0}, + {"5 seconds", "00:00:05", 5}, + {"30 seconds", "00:00:30", 30}, + {"1 minute", "00:01:00", 60}, + {"1 minute 30 seconds", "00:01:30", 90}, + {"1 hour", "01:00:00", 3600}, + {"1 hour 30 minutes 45 seconds", "01:30:45", 5445}, + {"with milliseconds", "00:00:30.500", 30}, + {"invalid format", "30", 0}, + {"invalid chars", "00:0a:30", 0}, + {"too few parts", "00:30", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseDurationToSeconds(tt.duration) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsInLineAd(t *testing.T) { + assert.False(t, IsInLineAd(nil)) + assert.False(t, IsInLineAd(&Ad{})) + assert.False(t, IsInLineAd(&Ad{Wrapper: &Wrapper{}})) + assert.True(t, IsInLineAd(&Ad{InLine: &InLine{}})) +} + +func TestIsWrapperAd(t *testing.T) { + assert.False(t, IsWrapperAd(nil)) + assert.False(t, IsWrapperAd(&Ad{})) + assert.False(t, IsWrapperAd(&Ad{InLine: &InLine{}})) + assert.True(t, IsWrapperAd(&Ad{Wrapper: &Wrapper{}})) +} + +func TestParseVastAdm_PreservesInnerXML(t *testing.T) { + // Test that unknown elements are preserved via InnerXML + customVAST := ` + + + + Custom Ad + Custom Value + + + + 00:00:15 + Some Data + + + + + +` + + vast, err := ParseVastAdm(customVAST) + require.NoError(t, err) + require.NotNil(t, vast) + + // InnerXML fields should contain the unknown elements + require.Len(t, vast.Ads, 1) + require.NotNil(t, vast.Ads[0].InLine) + + // The InnerXML on InLine should contain CustomElement + assert.Contains(t, vast.Ads[0].InLine.InnerXML, "CustomElement") +} + +func TestRoundTrip_ParseMarshalParse(t *testing.T) { + // Parse original + vast1, err := ParseVastAdm(sampleVAST30) + require.NoError(t, err) + + // Marshal back to XML + xml1, err := vast1.Marshal() + require.NoError(t, err) + + // Parse again + vast2, err := ParseVastAdm(string(xml1)) + require.NoError(t, err) + + // Compare key fields + assert.Equal(t, vast1.Version, vast2.Version) + require.Len(t, vast2.Ads, len(vast1.Ads)) + assert.Equal(t, vast1.Ads[0].ID, vast2.Ads[0].ID) + assert.Equal(t, vast1.Ads[0].InLine.AdTitle, vast2.Ads[0].InLine.AdTitle) +} diff --git a/modules/prebid/ctv_vast_enrichment/model/vast_xml.go b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go new file mode 100644 index 00000000000..fc6dc45e03d --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go @@ -0,0 +1,282 @@ +package model + +import ( + "encoding/xml" + "fmt" +) + +// Vast represents the root VAST XML element. +type Vast struct { + XMLName xml.Name `xml:"VAST"` + Version string `xml:"version,attr,omitempty"` + Ads []Ad `xml:"Ad"` +} + +// Ad represents a VAST Ad element. +type Ad struct { + ID string `xml:"id,attr,omitempty"` + Sequence int `xml:"sequence,attr,omitempty"` + InLine *InLine `xml:"InLine,omitempty"` + Wrapper *Wrapper `xml:"Wrapper,omitempty"` + // InnerXML preserves unknown nodes if needed + InnerXML string `xml:",innerxml"` +} + +// InLine represents a VAST InLine element containing the ad data. +type InLine struct { + AdSystem *AdSystem `xml:"AdSystem,omitempty"` + AdTitle string `xml:"AdTitle,omitempty"` + Advertiser string `xml:"Advertiser,omitempty"` + Description string `xml:"Description,omitempty"` + Error string `xml:"Error,omitempty"` + Impressions []Impression `xml:"Impression,omitempty"` + Pricing *Pricing `xml:"Pricing,omitempty"` + Creatives *Creatives `xml:"Creatives,omitempty"` + Extensions *Extensions `xml:"Extensions,omitempty"` + // InnerXML preserves unknown nodes + InnerXML string `xml:",innerxml"` +} + +// Wrapper represents a VAST Wrapper element for wrapped ads. +type Wrapper struct { + AdSystem *AdSystem `xml:"AdSystem,omitempty"` + VASTAdTagURI string `xml:"VASTAdTagURI,omitempty"` + Error string `xml:"Error,omitempty"` + Impressions []Impression `xml:"Impression,omitempty"` + Creatives *Creatives `xml:"Creatives,omitempty"` + Extensions *Extensions `xml:"Extensions,omitempty"` + // InnerXML preserves unknown nodes + InnerXML string `xml:",innerxml"` +} + +// AdSystem identifies the ad server that returned the ad. +type AdSystem struct { + Version string `xml:"version,attr,omitempty"` + Value string `xml:",chardata"` +} + +// Impression represents an impression tracking URL. +type Impression struct { + ID string `xml:"id,attr,omitempty"` + Value string `xml:",cdata"` +} + +// Pricing contains pricing information for the ad. +type Pricing struct { + Model string `xml:"model,attr,omitempty"` + Currency string `xml:"currency,attr,omitempty"` + Value string `xml:",chardata"` +} + +// Creatives contains a list of Creative elements. +type Creatives struct { + Creative []Creative `xml:"Creative,omitempty"` +} + +// Creative represents a VAST Creative element. +type Creative struct { + ID string `xml:"id,attr,omitempty"` + AdID string `xml:"adId,attr,omitempty"` + Sequence int `xml:"sequence,attr,omitempty"` + UniversalAdID *UniversalAdId `xml:"UniversalAdId,omitempty"` + Linear *Linear `xml:"Linear,omitempty"` + // InnerXML preserves unknown nodes + InnerXML string `xml:",innerxml"` +} + +// UniversalAdId provides a unique creative identifier across systems. +type UniversalAdId struct { + IDRegistry string `xml:"idRegistry,attr,omitempty"` + IDValue string `xml:"idValue,attr,omitempty"` + Value string `xml:",chardata"` +} + +// Linear represents a linear (video) creative. +type Linear struct { + SkipOffset string `xml:"skipoffset,attr,omitempty"` + Duration string `xml:"Duration,omitempty"` + MediaFiles *MediaFiles `xml:"MediaFiles,omitempty"` + VideoClicks *VideoClicks `xml:"VideoClicks,omitempty"` + TrackingEvents *TrackingEvents `xml:"TrackingEvents,omitempty"` + AdParameters *AdParameters `xml:"AdParameters,omitempty"` + // InnerXML preserves unknown nodes + InnerXML string `xml:",innerxml"` +} + +// MediaFiles contains a list of MediaFile elements. +type MediaFiles struct { + MediaFile []MediaFile `xml:"MediaFile,omitempty"` +} + +// MediaFile represents a video media file. +type MediaFile struct { + ID string `xml:"id,attr,omitempty"` + Delivery string `xml:"delivery,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` + Width int `xml:"width,attr,omitempty"` + Height int `xml:"height,attr,omitempty"` + Bitrate int `xml:"bitrate,attr,omitempty"` + MinBitrate int `xml:"minBitrate,attr,omitempty"` + MaxBitrate int `xml:"maxBitrate,attr,omitempty"` + Scalable bool `xml:"scalable,attr,omitempty"` + MaintainAspectRatio bool `xml:"maintainAspectRatio,attr,omitempty"` + Codec string `xml:"codec,attr,omitempty"` + Value string `xml:",cdata"` +} + +// VideoClicks contains click tracking URLs for video ads. +type VideoClicks struct { + ClickThrough *ClickThrough `xml:"ClickThrough,omitempty"` + ClickTracking []ClickTracking `xml:"ClickTracking,omitempty"` + CustomClick []CustomClick `xml:"CustomClick,omitempty"` +} + +// ClickThrough represents the landing page URL. +type ClickThrough struct { + ID string `xml:"id,attr,omitempty"` + Value string `xml:",cdata"` +} + +// ClickTracking represents a click tracking URL. +type ClickTracking struct { + ID string `xml:"id,attr,omitempty"` + Value string `xml:",cdata"` +} + +// CustomClick represents a custom click URL. +type CustomClick struct { + ID string `xml:"id,attr,omitempty"` + Value string `xml:",cdata"` +} + +// TrackingEvents contains tracking URLs for various playback events. +type TrackingEvents struct { + Tracking []Tracking `xml:"Tracking,omitempty"` +} + +// Tracking represents a single tracking event. +type Tracking struct { + Event string `xml:"event,attr,omitempty"` + Offset string `xml:"offset,attr,omitempty"` + Value string `xml:",cdata"` +} + +// AdParameters holds custom parameters for the ad. +type AdParameters struct { + XMLEncoded bool `xml:"xmlEncoded,attr,omitempty"` + Value string `xml:",cdata"` +} + +// Extensions contains a list of Extension elements. +type Extensions struct { + Extension []ExtensionXML `xml:"Extension,omitempty"` +} + +// ExtensionXML represents a VAST extension element. +type ExtensionXML struct { + Type string `xml:"type,attr,omitempty"` + // InnerXML preserves the extension content + InnerXML string `xml:",innerxml"` +} + +// SecToHHMMSS converts seconds to HH:MM:SS format used in VAST Duration. +func SecToHHMMSS(seconds int) string { + if seconds < 0 { + seconds = 0 + } + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + secs := seconds % 60 + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs) +} + +// BuildNoAdVast creates a VAST response indicating no ad is available. +// This is a valid VAST document with no Ad elements. +func BuildNoAdVast(version string) []byte { + if version == "" { + version = "3.0" + } + vast := Vast{ + Version: version, + Ads: []Ad{}, + } + output, err := xml.MarshalIndent(vast, "", " ") + if err != nil { + // Fallback to minimal valid VAST + return []byte(fmt.Sprintf(``, version)) + } + return append([]byte(xml.Header), output...) +} + +// BuildSkeletonInlineVast creates a minimal VAST document with one InLine ad. +// This skeleton can be used as a template to fill in with actual ad data. +func BuildSkeletonInlineVast(version string) *Vast { + if version == "" { + version = "3.0" + } + return &Vast{ + Version: version, + Ads: []Ad{ + { + ID: "1", + Sequence: 1, + InLine: &InLine{ + AdSystem: &AdSystem{ + Value: "PBS-CTV", + }, + AdTitle: "Ad", + Creatives: &Creatives{ + Creative: []Creative{ + { + ID: "1", + Sequence: 1, + Linear: &Linear{ + Duration: "00:00:00", + }, + }, + }, + }, + }, + }, + }, + } +} + +// BuildSkeletonInlineVastWithDuration creates a minimal VAST document with specified duration. +func BuildSkeletonInlineVastWithDuration(version string, durationSec int) *Vast { + vast := BuildSkeletonInlineVast(version) + if len(vast.Ads) > 0 && vast.Ads[0].InLine != nil && + vast.Ads[0].InLine.Creatives != nil && + len(vast.Ads[0].InLine.Creatives.Creative) > 0 && + vast.Ads[0].InLine.Creatives.Creative[0].Linear != nil { + vast.Ads[0].InLine.Creatives.Creative[0].Linear.Duration = SecToHHMMSS(durationSec) + } + return vast +} + +// Marshal serializes the Vast struct to XML bytes with XML header. +func (v *Vast) Marshal() ([]byte, error) { + output, err := xml.MarshalIndent(v, "", " ") + if err != nil { + return nil, err + } + return append([]byte(xml.Header), output...), nil +} + +// MarshalCompact serializes the Vast struct to XML bytes without indentation. +func (v *Vast) MarshalCompact() ([]byte, error) { + output, err := xml.Marshal(v) + if err != nil { + return nil, err + } + return append([]byte(xml.Header), output...), nil +} + +// Unmarshal parses XML bytes into a Vast struct. +func Unmarshal(data []byte) (*Vast, error) { + var vast Vast + if err := xml.Unmarshal(data, &vast); err != nil { + return nil, err + } + return &vast, nil +} diff --git a/modules/prebid/ctv_vast_enrichment/model/vast_xml_test.go b/modules/prebid/ctv_vast_enrichment/model/vast_xml_test.go new file mode 100644 index 00000000000..6fb47bf4c92 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/model/vast_xml_test.go @@ -0,0 +1,447 @@ +package model + +import ( + "encoding/xml" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecToHHMMSS(t *testing.T) { + tests := []struct { + name string + seconds int + expected string + }{ + {"zero", 0, "00:00:00"}, + {"negative", -5, "00:00:00"}, + {"30 seconds", 30, "00:00:30"}, + {"1 minute", 60, "00:01:00"}, + {"1 minute 30 seconds", 90, "00:01:30"}, + {"1 hour", 3600, "01:00:00"}, + {"1 hour 30 minutes 45 seconds", 5445, "01:30:45"}, + {"2 hours", 7200, "02:00:00"}, + {"typical ad 15 seconds", 15, "00:00:15"}, + {"typical ad 30 seconds", 30, "00:00:30"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SecToHHMMSS(tt.seconds) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildNoAdVast(t *testing.T) { + tests := []struct { + name string + version string + }{ + {"default version", ""}, + {"version 3.0", "3.0"}, + {"version 4.0", "4.0"}, + {"version 4.2", "4.2"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BuildNoAdVast(tt.version) + require.NotEmpty(t, result) + + // Should contain XML header + assert.True(t, strings.HasPrefix(string(result), "`) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, `TestSystem`) + assert.Contains(t, xmlStr, `Test Ad`) + assert.Contains(t, xmlStr, `Test Advertiser`) + assert.Contains(t, xmlStr, `5.00`) + assert.Contains(t, xmlStr, `00:00:30`) + assert.Contains(t, xmlStr, ``) +} + +func TestVast_MarshalCompact(t *testing.T) { + vast := BuildSkeletonInlineVast("3.0") + output, err := vast.MarshalCompact() + require.NoError(t, err) + require.NotEmpty(t, output) + + xmlStr := string(output) + // Compact should not have newlines in the body + assert.Contains(t, xmlStr, ` + + + + TestAdServer + Sample Ad + Sample Inc + 10.50 + + + + 00:00:15 + + + + + +`) + + vast, err := Unmarshal(xmlData) + require.NoError(t, err) + require.NotNil(t, vast) + + assert.Equal(t, "3.0", vast.Version) + require.Len(t, vast.Ads, 1) + + ad := vast.Ads[0] + assert.Equal(t, "test-ad", ad.ID) + assert.Equal(t, 1, ad.Sequence) + + require.NotNil(t, ad.InLine) + assert.Equal(t, "Sample Ad", ad.InLine.AdTitle) + assert.Equal(t, "Sample Inc", ad.InLine.Advertiser) + + require.NotNil(t, ad.InLine.AdSystem) + assert.Equal(t, "2.0", ad.InLine.AdSystem.Version) + assert.Equal(t, "TestAdServer", ad.InLine.AdSystem.Value) + + require.NotNil(t, ad.InLine.Pricing) + assert.Equal(t, "cpm", ad.InLine.Pricing.Model) + assert.Equal(t, "EUR", ad.InLine.Pricing.Currency) + assert.Equal(t, "10.50", ad.InLine.Pricing.Value) + + require.NotNil(t, ad.InLine.Creatives) + require.Len(t, ad.InLine.Creatives.Creative, 1) + creative := ad.InLine.Creatives.Creative[0] + assert.Equal(t, "c1", creative.ID) + + require.NotNil(t, creative.Linear) + assert.Equal(t, "00:00:15", creative.Linear.Duration) +} + +func TestUnmarshal_WithExtensions(t *testing.T) { + xmlData := []byte(` + + + + Ad with Extensions + + + + 00:00:30 + + + + + + some value + + + test + + + + +`) + + vast, err := Unmarshal(xmlData) + require.NoError(t, err) + require.NotNil(t, vast) + require.Len(t, vast.Ads, 1) + require.NotNil(t, vast.Ads[0].InLine) + require.NotNil(t, vast.Ads[0].InLine.Extensions) + require.Len(t, vast.Ads[0].InLine.Extensions.Extension, 2) + + ext1 := vast.Ads[0].InLine.Extensions.Extension[0] + assert.Equal(t, "waterfall", ext1.Type) + assert.Contains(t, ext1.InnerXML, "CustomData") + + ext2 := vast.Ads[0].InLine.Extensions.Extension[1] + assert.Equal(t, "prebid", ext2.Type) + assert.Contains(t, ext2.InnerXML, "BidInfo") +} + +func TestUnmarshal_WrapperAd(t *testing.T) { + xmlData := []byte(` + + + + Wrapper System + + + + +`) + + vast, err := Unmarshal(xmlData) + require.NoError(t, err) + require.NotNil(t, vast) + require.Len(t, vast.Ads, 1) + + ad := vast.Ads[0] + assert.Equal(t, "wrapper-ad", ad.ID) + assert.Nil(t, ad.InLine) + require.NotNil(t, ad.Wrapper) + assert.Equal(t, "Wrapper System", ad.Wrapper.AdSystem.Value) +} + +func TestRoundTrip(t *testing.T) { + original := &Vast{ + Version: "4.0", + Ads: []Ad{ + { + ID: "roundtrip-test", + Sequence: 1, + InLine: &InLine{ + AdSystem: &AdSystem{Value: "PBS"}, + AdTitle: "Round Trip Test", + Creatives: &Creatives{ + Creative: []Creative{ + { + ID: "c1", + Linear: &Linear{ + Duration: "00:00:15", + }, + }, + }, + }, + }, + }, + }, + } + + // Marshal + xmlBytes, err := original.Marshal() + require.NoError(t, err) + + // Unmarshal + parsed, err := Unmarshal(xmlBytes) + require.NoError(t, err) + + // Verify + assert.Equal(t, original.Version, parsed.Version) + require.Len(t, parsed.Ads, 1) + assert.Equal(t, original.Ads[0].ID, parsed.Ads[0].ID) + assert.Equal(t, original.Ads[0].InLine.AdTitle, parsed.Ads[0].InLine.AdTitle) +} + +func TestMediaFileWithCDATA(t *testing.T) { + vast := &Vast{ + Version: "3.0", + Ads: []Ad{ + { + ID: "media-test", + InLine: &InLine{ + AdTitle: "Media Test", + Creatives: &Creatives{ + Creative: []Creative{ + { + Linear: &Linear{ + Duration: "00:00:30", + MediaFiles: &MediaFiles{ + MediaFile: []MediaFile{ + { + Delivery: "progressive", + Type: "video/mp4", + Width: 1280, + Height: 720, + Value: "https://example.com/video.mp4?param=value&other=123", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + output, err := vast.Marshal() + require.NoError(t, err) + + // MediaFile URL should be in CDATA + xmlStr := string(output) + assert.Contains(t, xmlStr, "") +} + +func TestTrackingEvents(t *testing.T) { + vast := &Vast{ + Version: "3.0", + Ads: []Ad{ + { + ID: "tracking-test", + InLine: &InLine{ + AdTitle: "Tracking Test", + Creatives: &Creatives{ + Creative: []Creative{ + { + Linear: &Linear{ + Duration: "00:00:30", + TrackingEvents: &TrackingEvents{ + Tracking: []Tracking{ + {Event: "start", Value: "https://example.com/start"}, + {Event: "firstQuartile", Value: "https://example.com/q1"}, + {Event: "midpoint", Value: "https://example.com/mid"}, + {Event: "thirdQuartile", Value: "https://example.com/q3"}, + {Event: "complete", Value: "https://example.com/complete"}, + {Event: "progress", Offset: "00:00:05", Value: "https://example.com/5sec"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + output, err := vast.Marshal() + require.NoError(t, err) + + xmlStr := string(output) + assert.Contains(t, xmlStr, `event="start"`) + assert.Contains(t, xmlStr, `event="complete"`) + assert.Contains(t, xmlStr, `event="progress"`) + assert.Contains(t, xmlStr, `offset="00:00:05"`) +} diff --git a/modules/prebid/ctv_vast_enrichment/module.go b/modules/prebid/ctv_vast_enrichment/module.go new file mode 100644 index 00000000000..ab251f57bf4 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/module.go @@ -0,0 +1,234 @@ +package vast + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/prebid/prebid-server/v3/hooks/hookstage" + "github.com/prebid/prebid-server/v3/modules/moduledeps" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" +) + +// Builder creates a new CTV VAST enrichment module instance. +// It parses the host-level configuration and initializes the module +// with default selector, enricher, and formatter implementations. +func Builder(cfg json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, error) { + var hostCfg CTVVastConfig + if len(cfg) > 0 { + if err := json.Unmarshal(cfg, &hostCfg); err != nil { + return nil, err + } + } + + return Module{ + hostConfig: hostCfg, + }, nil +} + +// Module implements the CTV VAST enrichment functionality as a PBS hook module. +// It processes raw bidder responses to enrich VAST XML with additional metadata +// such as pricing, categories, and advertiser information. +type Module struct { + hostConfig CTVVastConfig +} + +// HandleRawBidderResponseHook processes bidder responses to enrich VAST XML. +// For each bid containing VAST (video bids), the hook: +// - Parses the VAST XML from the bid's AdM field +// - Enriches the VAST with pricing, category, and advertiser metadata +// - Updates the bid's AdM with the enriched VAST XML +// +// The enrichment is controlled by the module configuration at host, account, +// and request levels. If enrichment is disabled, the response passes through unchanged. +func (m Module) HandleRawBidderResponseHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.RawBidderResponsePayload, +) (hookstage.HookResult[hookstage.RawBidderResponsePayload], error) { + result := hookstage.HookResult[hookstage.RawBidderResponsePayload]{} + + // Parse account-level config if present + var accountCfg *CTVVastConfig + if len(miCtx.AccountConfig) > 0 { + var cfg CTVVastConfig + if err := json.Unmarshal(miCtx.AccountConfig, &cfg); err != nil { + return result, err + } + accountCfg = &cfg + } + + // Merge configurations: host < account + mergedCfg := MergeCTVVastConfig(&m.hostConfig, accountCfg, nil) + + // Check if module is enabled + if mergedCfg.Enabled != nil && !*mergedCfg.Enabled { + return result, nil + } + + // No bids to process + if payload.BidderResponse == nil || len(payload.BidderResponse.Bids) == 0 { + return result, nil + } + + // Convert config to ReceiverConfig + receiverCfg := configToReceiverConfig(mergedCfg) + + // Process each bid + changesMade := false + for i := range payload.BidderResponse.Bids { + typedBid := payload.BidderResponse.Bids[i] + if typedBid == nil || typedBid.Bid == nil { + continue + } + + bid := typedBid.Bid + + // Skip non-video bids (no AdM or not VAST) + if bid.AdM == "" { + continue + } + + // Try to parse as VAST + vastDoc, err := model.ParseVastAdm(bid.AdM) + if err != nil { + // Not valid VAST, skip enrichment + continue + } + + // Build bid context for enrichment + bidContext := CanonicalMeta{ + BidID: bid.ID, + Price: bid.Price, + Currency: receiverCfg.DefaultCurrency, + Adomain: strings.Join(bid.ADomain, ","), + Cats: bid.Cat, + Seat: payload.Bidder, + } + + // Enrich the VAST document inline + enrichedVast := enrichVastDocument(vastDoc, bidContext, receiverCfg) + + // Format back to XML + xmlBytes, err := enrichedVast.Marshal() + if err != nil { + // Keep original AdM on format error + continue + } + + // Update bid with enriched VAST + bid.AdM = string(xmlBytes) + changesMade = true + } + + // If we made changes, set mutation + if changesMade { + result.ChangeSet.AddMutation( + func(payload hookstage.RawBidderResponsePayload) (hookstage.RawBidderResponsePayload, error) { + return payload, nil + }, + hookstage.MutationUpdate, + "ctv-vast-enrichment", + ) + } + + return result, nil +} + +// configToReceiverConfig converts CTVVastConfig to ReceiverConfig +func configToReceiverConfig(cfg CTVVastConfig) ReceiverConfig { + rc := DefaultConfig() + + if cfg.Receiver != "" { + switch cfg.Receiver { + case "GAM_SSU": + rc.Receiver = ReceiverGAMSSU + case "GENERIC": + rc.Receiver = ReceiverGeneric + } + } + + if cfg.DefaultCurrency != "" { + rc.DefaultCurrency = cfg.DefaultCurrency + } + + if cfg.VastVersionDefault != "" { + rc.VastVersionDefault = cfg.VastVersionDefault + } + + if cfg.MaxAdsInPod > 0 { + rc.MaxAdsInPod = cfg.MaxAdsInPod + } + + if cfg.SelectionStrategy != "" { + switch cfg.SelectionStrategy { + case "max_revenue", "MAX_REVENUE": + rc.SelectionStrategy = SelectionMaxRevenue + case "top_n", "TOP_N": + rc.SelectionStrategy = SelectionTopN + case "single", "SINGLE": + rc.SelectionStrategy = SelectionSingle + } + } + + if cfg.CollisionPolicy != "" { + switch cfg.CollisionPolicy { + case "reject", "REJECT": + rc.CollisionPolicy = CollisionReject + case "warn", "WARN": + rc.CollisionPolicy = CollisionWarn + case "ignore", "IGNORE": + rc.CollisionPolicy = CollisionIgnore + } + } + + if cfg.AllowSkeletonVast != nil { + rc.AllowSkeletonVast = *cfg.AllowSkeletonVast + } + + if cfg.Placement != nil { + if cfg.Placement.PricingPlacement != "" { + rc.Placement.PricingPlacement = cfg.Placement.PricingPlacement + } + } + + return rc +} + +// enrichVastDocument enriches a VAST document with bid metadata. +// It adds pricing and advertiser information to the VAST. +func enrichVastDocument(vast *model.Vast, meta CanonicalMeta, cfg ReceiverConfig) *model.Vast { + if vast == nil { + return vast + } + + // Process each ad + for i := range vast.Ads { + ad := &vast.Ads[i] + if ad.InLine == nil { + continue + } + inline := ad.InLine + + // Add pricing if not present + if inline.Pricing == nil && meta.Price > 0 { + currency := cfg.DefaultCurrency + if currency == "" { + currency = "USD" + } + inline.Pricing = &model.Pricing{ + Value: fmt.Sprintf("%.6f", meta.Price), + Model: "CPM", + Currency: currency, + } + } + + // Add advertiser if not present + if inline.Advertiser == "" && meta.Adomain != "" { + inline.Advertiser = meta.Adomain + } + } + + return vast +} diff --git a/modules/prebid/ctv_vast_enrichment/module_test.go b/modules/prebid/ctv_vast_enrichment/module_test.go new file mode 100644 index 00000000000..e246316039f --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/module_test.go @@ -0,0 +1,576 @@ +package vast + +import ( + "context" + "encoding/json" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/adapters" + "github.com/prebid/prebid-server/v3/hooks/hookstage" + "github.com/prebid/prebid-server/v3/modules/moduledeps" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuilder(t *testing.T) { + testCases := []struct { + name string + config json.RawMessage + expectError bool + }{ + { + name: "empty config", + config: json.RawMessage(`{}`), + expectError: false, + }, + { + name: "nil config", + config: nil, + expectError: false, + }, + { + name: "valid config", + config: json.RawMessage(`{"enabled": true, "receiver": "GAM_SSU", "default_currency": "USD"}`), + expectError: false, + }, + { + name: "invalid json", + config: json.RawMessage(`{invalid}`), + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + module, err := Builder(tc.config, moduledeps.ModuleDeps{}) + + if tc.expectError { + assert.Error(t, err) + assert.Nil(t, module) + } else { + assert.NoError(t, err) + assert.NotNil(t, module) + + _, ok := module.(Module) + assert.True(t, ok, "Builder should return Module type") + } + }) + } +} + +func TestHandleRawBidderResponseHook_NoAccountConfig(t *testing.T) { + module := Module{} + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ + ID: "bid1", + AdM: `TestTest Ad`, + }, + }, + }, + }, + } + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: nil, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + assert.NoError(t, err) + assert.Empty(t, result.Errors) +} + +func TestHandleRawBidderResponseHook_ModuleDisabled(t *testing.T) { + module := Module{} + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ + ID: "bid1", + AdM: `TestTest Ad`, + }, + }, + }, + }, + } + + // Module is disabled + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled": false}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + assert.NoError(t, err) + assert.Empty(t, result.Errors) + // No mutation should be applied when disabled +} + +func TestHandleRawBidderResponseHook_EmptyBidResponse(t *testing.T) { + module := Module{} + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: nil, + } + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled": true}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + assert.NoError(t, err) + assert.Empty(t, result.Errors) +} + +func TestHandleRawBidderResponseHook_NoBids(t *testing.T) { + module := Module{} + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{}, + }, + } + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled": true}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + assert.NoError(t, err) + assert.Empty(t, result.Errors) +} + +func TestHandleRawBidderResponseHook_EnrichesVAST(t *testing.T) { + module := Module{ + hostConfig: CTVVastConfig{ + DefaultCurrency: "USD", + }, + } + + originalVast := `TestTest Ad` + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ + ID: "bid1", + Price: 1.50, + ADomain: []string{"advertiser.com"}, + AdM: originalVast, + }, + }, + }, + }, + } + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled": true}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + require.NoError(t, err) + assert.Empty(t, result.Errors) + + // Verify the bid was enriched + enrichedAdM := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, enrichedAdM, "Pricing") + assert.Contains(t, enrichedAdM, "1.500000") + assert.Contains(t, enrichedAdM, "CPM") + assert.Contains(t, enrichedAdM, "USD") +} + +func TestHandleRawBidderResponseHook_SkipsNonVAST(t *testing.T) { + module := Module{} + + originalAdM := `Banner ad content` + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ + ID: "bid1", + Price: 1.50, + AdM: originalAdM, + }, + }, + }, + }, + } + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled": true}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + assert.NoError(t, err) + assert.Empty(t, result.Errors) + + // Non-VAST content should be unchanged + assert.Equal(t, originalAdM, payload.BidderResponse.Bids[0].Bid.AdM) +} + +func TestHandleRawBidderResponseHook_SkipsEmptyAdM(t *testing.T) { + module := Module{} + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ + ID: "bid1", + Price: 1.50, + AdM: "", + }, + }, + }, + }, + } + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled": true}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + assert.NoError(t, err) + assert.Empty(t, result.Errors) +} + +func TestHandleRawBidderResponseHook_InvalidAccountConfig(t *testing.T) { + module := Module{} + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ + ID: "bid1", + AdM: ``, + }, + }, + }, + }, + } + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{invalid json}`), + } + + _, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + assert.Error(t, err) +} + +func TestHandleRawBidderResponseHook_MergesHostAndAccountConfig(t *testing.T) { + // Host config with USD currency + module := Module{ + hostConfig: CTVVastConfig{ + DefaultCurrency: "USD", + Receiver: "GENERIC", + }, + } + + originalVast := `TestTest Ad` + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ + ID: "bid1", + Price: 2.00, + AdM: originalVast, + }, + }, + }, + }, + } + + // Account config overrides currency to EUR + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled": true, "default_currency": "EUR"}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + require.NoError(t, err) + assert.Empty(t, result.Errors) + + // Verify EUR currency was used (account overrides host) + enrichedAdM := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, enrichedAdM, "EUR") +} + +func TestHandleRawBidderResponseHook_MultipleBids(t *testing.T) { + module := Module{ + hostConfig: CTVVastConfig{ + DefaultCurrency: "USD", + }, + } + + vastTemplate := `TestTest Ad` + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ + ID: "bid1", + Price: 1.50, + AdM: `TestTest Ad`, + }, + }, + { + Bid: &openrtb2.Bid{ + ID: "bid2", + Price: 2.00, + AdM: `TestTest Ad 2`, + }, + }, + }, + }, + } + _ = vastTemplate // For reference + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled": true}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + require.NoError(t, err) + assert.Empty(t, result.Errors) + + // Both bids should be enriched + assert.Contains(t, payload.BidderResponse.Bids[0].Bid.AdM, "1.500000") + assert.Contains(t, payload.BidderResponse.Bids[1].Bid.AdM, "2.000000") +} + +func TestHandleRawBidderResponseHook_PreservesExistingPricing(t *testing.T) { + module := Module{ + hostConfig: CTVVastConfig{ + DefaultCurrency: "USD", + }, + } + + // VAST already has pricing + vastWithPricing := `TestTest Ad3.00` + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ + ID: "bid1", + Price: 1.50, // Different price + AdM: vastWithPricing, + }, + }, + }, + }, + } + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled": true}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + + require.NoError(t, err) + assert.Empty(t, result.Errors) + + // Original pricing should be preserved (VAST wins) + enrichedAdM := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, enrichedAdM, "GBP") + assert.Contains(t, enrichedAdM, "3.00") + assert.NotContains(t, enrichedAdM, "1.50") +} + +func TestConfigToReceiverConfig(t *testing.T) { + testCases := []struct { + name string + input CTVVastConfig + expected ReceiverConfig + }{ + { + name: "empty config uses defaults", + input: CTVVastConfig{}, + expected: DefaultConfig(), + }, + { + name: "receiver GAM_SSU", + input: CTVVastConfig{ + Receiver: "GAM_SSU", + }, + expected: func() ReceiverConfig { + rc := DefaultConfig() + rc.Receiver = ReceiverGAMSSU + return rc + }(), + }, + { + name: "receiver GENERIC", + input: CTVVastConfig{ + Receiver: "GENERIC", + }, + expected: func() ReceiverConfig { + rc := DefaultConfig() + rc.Receiver = ReceiverGeneric + return rc + }(), + }, + { + name: "custom currency", + input: CTVVastConfig{ + DefaultCurrency: "EUR", + }, + expected: func() ReceiverConfig { + rc := DefaultConfig() + rc.DefaultCurrency = "EUR" + return rc + }(), + }, + { + name: "selection strategy max_revenue", + input: CTVVastConfig{ + SelectionStrategy: "max_revenue", + }, + expected: func() ReceiverConfig { + rc := DefaultConfig() + rc.SelectionStrategy = SelectionMaxRevenue + return rc + }(), + }, + { + name: "collision policy reject", + input: CTVVastConfig{ + CollisionPolicy: "reject", + }, + expected: func() ReceiverConfig { + rc := DefaultConfig() + rc.CollisionPolicy = CollisionReject + return rc + }(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := configToReceiverConfig(tc.input) + assert.Equal(t, tc.expected.Receiver, result.Receiver) + assert.Equal(t, tc.expected.DefaultCurrency, result.DefaultCurrency) + assert.Equal(t, tc.expected.SelectionStrategy, result.SelectionStrategy) + assert.Equal(t, tc.expected.CollisionPolicy, result.CollisionPolicy) + }) + } +} + +func TestEnrichVastDocument(t *testing.T) { + testCases := []struct { + name string + inputVast string + meta CanonicalMeta + cfg ReceiverConfig + expectPricing bool + expectAdomain bool + }{ + { + name: "adds pricing when missing", + inputVast: `TestTest`, + meta: CanonicalMeta{ + Price: 1.50, + Currency: "USD", + }, + cfg: ReceiverConfig{ + DefaultCurrency: "USD", + }, + expectPricing: true, + expectAdomain: false, + }, + { + name: "adds advertiser when missing", + inputVast: `TestTest`, + meta: CanonicalMeta{ + Price: 1.50, + Adomain: "advertiser.com", + }, + cfg: ReceiverConfig{ + DefaultCurrency: "USD", + }, + expectPricing: true, + expectAdomain: true, + }, + { + name: "does not add pricing when price is zero", + inputVast: `TestTest`, + meta: CanonicalMeta{ + Price: 0, + }, + cfg: ReceiverConfig{ + DefaultCurrency: "USD", + }, + expectPricing: false, + expectAdomain: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + vastDoc, err := parseTestVast(tc.inputVast) + require.NoError(t, err) + + result := enrichVastDocument(vastDoc, tc.meta, tc.cfg) + require.NotNil(t, result) + + xmlBytes, err := result.Marshal() + require.NoError(t, err) + + xmlStr := string(xmlBytes) + + if tc.expectPricing { + assert.Contains(t, xmlStr, "Pricing") + } else { + assert.NotContains(t, xmlStr, "Pricing") + } + + if tc.expectAdomain { + assert.Contains(t, xmlStr, tc.meta.Adomain) + } + }) + } +} + +func TestEnrichVastDocument_NilInput(t *testing.T) { + result := enrichVastDocument(nil, CanonicalMeta{}, ReceiverConfig{}) + assert.Nil(t, result) +} + +// parseTestVast is a helper to parse VAST XML for tests +func parseTestVast(xmlStr string) (*model.Vast, error) { + return model.ParseVastAdm(xmlStr) +} diff --git a/modules/prebid/ctv_vast_enrichment/pipeline.go b/modules/prebid/ctv_vast_enrichment/pipeline.go new file mode 100644 index 00000000000..41297bc8c8f --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/pipeline.go @@ -0,0 +1,204 @@ +// Package vast provides CTV VAST processing capabilities for Prebid Server. +// +// This module handles the complete VAST workflow for Connected TV (CTV) ad serving: +// - Bid selection from OpenRTB auction responses +// - VAST ad enrichment with tracking and metadata +// - VAST XML formatting for various downstream receivers +// +// The package is organized into sub-packages: +// - model: VAST data structures +// - select: Bid selection logic +// - enrich: VAST ad enrichment +// - format: VAST XML formatting +// +// Example usage: +// +// cfg := vast.ReceiverConfig{ +// Receiver: vast.ReceiverGAMSSU, +// DefaultCurrency: "USD", +// VastVersionDefault: "4.0", +// MaxAdsInPod: 5, +// SelectionStrategy: vast.SelectionMaxRevenue, +// CollisionPolicy: vast.CollisionReject, +// } +// +// processor := vast.NewProcessor(cfg, selector, enricher, formatter) +// result := processor.Process(bidRequest, bidResponse) +package vast + +import ( + "context" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" +) + +// BuildVastFromBidResponse orchestrates the complete VAST processing pipeline. +// It selects bids, parses/creates VAST, enriches ads, and formats final XML. +// +// Steps: +// 1. Select bids from response using configured strategy +// 2. Parse VAST from each bid's AdM (or create skeleton if allowed) +// 3. Enrich each ad with metadata (pricing, categories, etc.) +// 4. Format all ads into final VAST XML +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - req: OpenRTB bid request +// - resp: OpenRTB bid response from auction +// - cfg: Receiver configuration +// - selector: Bid selection implementation +// - enricher: VAST enrichment implementation +// - formatter: VAST formatting implementation +// +// Returns VastResult containing XML output, warnings, and selected bids. +func BuildVastFromBidResponse( + ctx context.Context, + req *openrtb2.BidRequest, + resp *openrtb2.BidResponse, + cfg ReceiverConfig, + selector BidSelector, + enricher Enricher, + formatter Formatter, +) (VastResult, error) { + result := VastResult{ + Warnings: make([]string, 0), + Errors: make([]error, 0), + } + + // Step 1: Select bids + selected, selectWarnings, err := selector.Select(req, resp, cfg) + if err != nil { + result.Errors = append(result.Errors, err) + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + return result, err + } + result.Warnings = append(result.Warnings, selectWarnings...) + result.Selected = selected + + // Step 2: Handle no bids case + if len(selected) == 0 { + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + return result, nil + } + + // Step 3: Parse and enrich each selected bid's VAST + enrichedAds := make([]EnrichedAd, 0, len(selected)) + + parserCfg := model.ParserConfig{ + AllowSkeletonVast: cfg.AllowSkeletonVast, + VastVersionDefault: cfg.VastVersionDefault, + } + + for _, sb := range selected { + // Parse VAST from AdM (or create skeleton) + parsedVast, parseWarnings, parseErr := model.ParseVastOrSkeleton(sb.Bid.AdM, parserCfg) + result.Warnings = append(result.Warnings, parseWarnings...) + + if parseErr != nil { + result.Warnings = append(result.Warnings, "failed to parse VAST for bid "+sb.Bid.ID+": "+parseErr.Error()) + continue + } + + // Extract the first Ad from parsed VAST + ad := model.ExtractFirstAd(parsedVast) + if ad == nil { + result.Warnings = append(result.Warnings, "no ad found in VAST for bid "+sb.Bid.ID) + continue + } + + // Enrich the ad with metadata + enrichWarnings, enrichErr := enricher.Enrich(ad, sb.Meta, cfg) + result.Warnings = append(result.Warnings, enrichWarnings...) + if enrichErr != nil { + result.Warnings = append(result.Warnings, "enrichment failed for bid "+sb.Bid.ID+": "+enrichErr.Error()) + // Continue with unenriched ad + } + + // Store enriched ad + enrichedAds = append(enrichedAds, EnrichedAd{ + Ad: ad, + Meta: sb.Meta, + Sequence: sb.Sequence, + }) + } + + // Step 4: Handle case where all bids failed parsing + if len(enrichedAds) == 0 { + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + result.Warnings = append(result.Warnings, "all selected bids failed VAST parsing") + return result, nil + } + + // Step 5: Format the final VAST XML + xmlBytes, formatWarnings, formatErr := formatter.Format(enrichedAds, cfg) + result.Warnings = append(result.Warnings, formatWarnings...) + + if formatErr != nil { + result.Errors = append(result.Errors, formatErr) + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + return result, formatErr + } + + result.VastXML = xmlBytes + result.NoAd = false + + return result, nil +} + +// Processor orchestrates the VAST processing workflow. +type Processor struct { + selector BidSelector + enricher Enricher + formatter Formatter + config ReceiverConfig +} + +// NewProcessor creates a new Processor with the given configuration. +func NewProcessor(cfg ReceiverConfig, selector BidSelector, enricher Enricher, formatter Formatter) *Processor { + return &Processor{ + selector: selector, + enricher: enricher, + formatter: formatter, + config: cfg, + } +} + +// Process executes the complete VAST processing workflow. +func (p *Processor) Process(ctx context.Context, req *openrtb2.BidRequest, resp *openrtb2.BidResponse) VastResult { + result, _ := BuildVastFromBidResponse(ctx, req, resp, p.config, p.selector, p.enricher, p.formatter) + return result +} + +// DefaultConfig returns a default ReceiverConfig for GAM SSU. +func DefaultConfig() ReceiverConfig { + return ReceiverConfig{ + Receiver: ReceiverGAMSSU, + DefaultCurrency: "USD", + VastVersionDefault: "4.0", + MaxAdsInPod: 5, + SelectionStrategy: SelectionMaxRevenue, + CollisionPolicy: CollisionReject, + Placement: PlacementRules{ + Pricing: PricingRules{ + FloorCPM: 0, + CeilingCPM: 0, + Currency: "USD", + }, + Advertiser: AdvertiserRules{ + BlockedDomains: []string{}, + AllowedDomains: []string{}, + }, + Categories: CategoryRules{ + BlockedCategories: []string{}, + AllowedCategories: []string{}, + }, + Debug: false, + }, + Debug: false, + } +} diff --git a/modules/prebid/ctv_vast_enrichment/pipeline_test.go b/modules/prebid/ctv_vast_enrichment/pipeline_test.go new file mode 100644 index 00000000000..1369fe5f739 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/pipeline_test.go @@ -0,0 +1,607 @@ +package vast + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mock implementations for testing + +type mockSelector struct { + selectFn func(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg ReceiverConfig) ([]SelectedBid, []string, error) +} + +func (m *mockSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg ReceiverConfig) ([]SelectedBid, []string, error) { + if m.selectFn != nil { + return m.selectFn(req, resp, cfg) + } + // Default: select all bids with sequence numbers + var selected []SelectedBid + seq := 1 + if resp != nil { + for _, sb := range resp.SeatBid { + for _, bid := range sb.Bid { + adomain := "" + if len(bid.ADomain) > 0 { + adomain = bid.ADomain[0] + } + selected = append(selected, SelectedBid{ + Bid: bid, + Seat: sb.Seat, + Sequence: seq, + Meta: CanonicalMeta{ + BidID: bid.ID, + Seat: sb.Seat, + Price: bid.Price, + Currency: resp.Cur, + Adomain: adomain, + Cats: bid.Cat, + }, + }) + seq++ + } + } + } + return selected, nil, nil +} + +type mockEnricher struct { + enrichFn func(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) +} + +func (m *mockEnricher) Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) { + if m.enrichFn != nil { + return m.enrichFn(ad, meta, cfg) + } + // Default: add pricing extension and advertiser + if ad.InLine != nil { + ad.InLine.Pricing = &model.Pricing{ + Model: "CPM", + Currency: cfg.DefaultCurrency, + Value: formatPrice(meta.Price), + } + if meta.Adomain != "" { + ad.InLine.Advertiser = meta.Adomain + } + if cfg.Debug { + if ad.InLine.Extensions == nil { + ad.InLine.Extensions = &model.Extensions{} + } + debugXML := fmt.Sprintf("%s%s%f", + meta.BidID, meta.Seat, meta.Price) + ad.InLine.Extensions.Extension = append(ad.InLine.Extensions.Extension, model.ExtensionXML{ + Type: "openrtb", + InnerXML: debugXML, + }) + } + } + return nil, nil +} + +func formatPrice(price float64) string { + return fmt.Sprintf("%.2f", price) +} + +type mockFormatter struct { + formatFn func(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) +} + +func (m *mockFormatter) Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) { + if m.formatFn != nil { + return m.formatFn(ads, cfg) + } + // Default: build GAM SSU style VAST + version := cfg.VastVersionDefault + if version == "" { + version = "4.0" + } + vast := &model.Vast{ + Version: version, + Ads: make([]model.Ad, 0, len(ads)), + } + for _, ea := range ads { + ad := *ea.Ad + ad.ID = ea.Meta.BidID + ad.Sequence = ea.Sequence + vast.Ads = append(vast.Ads, ad) + } + xml, err := vast.Marshal() + return xml, nil, err +} + +func newTestComponents() (BidSelector, Enricher, Formatter) { + return &mockSelector{}, &mockEnricher{}, &mockFormatter{} +} + +func TestBuildVastFromBidResponse_NoAds(t *testing.T) { + cfg := DefaultConfig() + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ID: "test-resp"} + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + assert.True(t, result.NoAd) + assert.NotEmpty(t, result.VastXML) + assert.Contains(t, string(result.VastXML), ``) + assert.Empty(t, result.Selected) +} + +func TestBuildVastFromBidResponse_NilResponse(t *testing.T) { + cfg := DefaultConfig() + req := &openrtb2.BidRequest{ID: "test-req"} + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, nil, cfg, selector, enricher, formatter) + require.NoError(t, err) + + assert.True(t, result.NoAd) + assert.NotEmpty(t, result.VastXML) +} + +func TestBuildVastFromBidResponse_SingleBid(t *testing.T) { + cfg := DefaultConfig() + cfg.SelectionStrategy = SelectionSingle + + vastXML := ` + + + + TestServer + Test Ad + + + + 00:00:30 + + + + + + + + + + +` + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + { + ID: "bid-1", + ImpID: "imp-1", + Price: 5.0, + AdM: vastXML, + ADomain: []string{"advertiser.com"}, + }, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + assert.False(t, result.NoAd) + assert.NotEmpty(t, result.VastXML) + assert.Len(t, result.Selected, 1) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, `Test Ad") +} + +func TestBuildVastFromBidResponse_MultipleBids(t *testing.T) { + cfg := DefaultConfig() + cfg.SelectionStrategy = SelectionTopN + cfg.MaxAdsInPod = 3 + + makeVAST := func(adID, title string) string { + return ` + + + + TestServer + ` + title + ` + + + + 00:00:15 + + + + + +` + } + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid-1", ImpID: "imp-1", Price: 10.0, AdM: makeVAST("ad-1", "First Ad")}, + {ID: "bid-2", ImpID: "imp-2", Price: 8.0, AdM: makeVAST("ad-2", "Second Ad")}, + {ID: "bid-3", ImpID: "imp-3", Price: 5.0, AdM: makeVAST("ad-3", "Third Ad")}, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + assert.False(t, result.NoAd) + assert.Len(t, result.Selected, 3) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, `sequence="1"`) + assert.Contains(t, xmlStr, `sequence="2"`) + assert.Contains(t, xmlStr, `sequence="3"`) +} + +func TestBuildVastFromBidResponse_SkeletonVast(t *testing.T) { + cfg := DefaultConfig() + cfg.AllowSkeletonVast = true + cfg.SelectionStrategy = SelectionSingle + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + { + ID: "bid-1", + ImpID: "imp-1", + Price: 5.0, + AdM: "not-valid-vast", // Invalid VAST + }, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + // Should succeed with skeleton VAST + assert.False(t, result.NoAd) + assert.NotEmpty(t, result.VastXML) + // Check for skeleton warning + hasSkeletonWarning := false + for _, w := range result.Warnings { + if strings.Contains(strings.ToLower(w), "skeleton") { + hasSkeletonWarning = true + break + } + } + assert.True(t, hasSkeletonWarning, "Expected skeleton warning, got: %v", result.Warnings) +} + +func TestBuildVastFromBidResponse_InvalidVastNoSkeleton(t *testing.T) { + cfg := DefaultConfig() + cfg.AllowSkeletonVast = false // Don't allow skeleton + cfg.SelectionStrategy = SelectionSingle + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + { + ID: "bid-1", + ImpID: "imp-1", + Price: 5.0, + AdM: "not-valid-vast", + }, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + // Should return no-ad since parse failed and skeleton not allowed + assert.True(t, result.NoAd) +} + +func TestBuildVastFromBidResponse_EnrichmentAddsMetadata(t *testing.T) { + cfg := DefaultConfig() + cfg.SelectionStrategy = SelectionSingle + cfg.Debug = true // Enable debug extensions + + vastXML := ` + + + + TestServer + Test Ad + + + + + + + + + + + + + +` + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + { + ID: "bid-enriched", + ImpID: "imp-1", + Price: 7.5, + AdM: vastXML, + ADomain: []string{"advertiser.com"}, + Cat: []string{"IAB1", "IAB2"}, + }, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + require.False(t, result.NoAd) + + xmlStr := string(result.VastXML) + // Check enrichment added pricing + assert.Contains(t, xmlStr, "bid-enriched") +} + +// HTTP Handler Tests + +func TestHandler_MethodNotAllowed(t *testing.T) { + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter) + + req := httptest.NewRequest(http.MethodPost, "/vast", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) +} + +func TestHandler_NotConfigured(t *testing.T) { + handler := NewHandler() // No selector/enricher/formatter + + req := httptest.NewRequest(http.MethodGet, "/vast", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + body, _ := io.ReadAll(rec.Body) + assert.Contains(t, string(body), "not properly configured") +} + +func TestHandler_NoAuction_ReturnsNoAdVast(t *testing.T) { + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter) + // No AuctionFunc set, should return no-ad VAST + + req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=test-pod", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/xml; charset=utf-8", rec.Header().Get("Content-Type")) + + body, _ := io.ReadAll(rec.Body) + assert.Contains(t, string(body), ``) +} + +func TestHandler_WithMockAuction_ReturnsVast(t *testing.T) { + vastXML := ` + + + + MockServer + Mock Ad + + + + 00:00:15 + + + + + +` + + mockAuction := func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { + return &openrtb2.BidResponse{ + ID: "mock-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "mock-bidder", + Bid: []openrtb2.Bid{ + { + ID: "mock-bid-1", + ImpID: "imp-1", + Price: 3.50, + AdM: vastXML, + }, + }, + }, + }, + }, nil + } + + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter). + WithAuctionFunc(mockAuction) + + req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=test-pod", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/xml; charset=utf-8", rec.Header().Get("Content-Type")) + + body, _ := io.ReadAll(rec.Body) + xmlStr := string(body) + assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, `Mock Ad") +} + +func TestHandler_WithConfig(t *testing.T) { + cfg := ReceiverConfig{ + Receiver: ReceiverGAMSSU, + VastVersionDefault: "3.0", + DefaultCurrency: "EUR", + } + + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithConfig(cfg). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter) + + req := httptest.NewRequest(http.MethodGet, "/vast", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + body, _ := io.ReadAll(rec.Body) + // Should use version 3.0 from config + assert.Contains(t, string(body), `version="3.0"`) +} + +func TestHandler_CacheControlHeader(t *testing.T) { + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter) + + req := httptest.NewRequest(http.MethodGet, "/vast", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, "no-cache, no-store, must-revalidate", rec.Header().Get("Cache-Control")) +} + +func TestHandler_PodIDFromQuery(t *testing.T) { + var capturedReq *openrtb2.BidRequest + + mockAuction := func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { + capturedReq = req + return &openrtb2.BidResponse{}, nil + } + + selector, enricher, formatter := newTestComponents() + handler := NewHandler(). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter). + WithAuctionFunc(mockAuction) + + req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=custom-pod-123", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + require.NotNil(t, capturedReq) + assert.Equal(t, "custom-pod-123", capturedReq.ID) +} + +// Test warnings are captured +func TestBuildVastFromBidResponse_WarningsCollected(t *testing.T) { + cfg := DefaultConfig() + cfg.AllowSkeletonVast = true + + // First bid has valid VAST, second has invalid + validVAST := `Test00:00:15` + + req := &openrtb2.BidRequest{ID: "test-req"} + resp := &openrtb2.BidResponse{ + ID: "test-resp", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid-1", ImpID: "imp-1", Price: 10.0, AdM: validVAST}, + {ID: "bid-2", ImpID: "imp-2", Price: 5.0, AdM: "invalid-vast"}, + }, + }, + }, + } + + selector, enricher, formatter := newTestComponents() + result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + + assert.False(t, result.NoAd) + // Should have warnings about the invalid VAST using skeleton + hasSkeletonWarning := false + for _, w := range result.Warnings { + if strings.Contains(strings.ToLower(w), "skeleton") { + hasSkeletonWarning = true + break + } + } + assert.True(t, hasSkeletonWarning, "Expected skeleton warning in: %v", result.Warnings) +} diff --git a/modules/prebid/ctv_vast_enrichment/select/price_selector.go b/modules/prebid/ctv_vast_enrichment/select/price_selector.go new file mode 100644 index 00000000000..fa6ff893105 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/select/price_selector.go @@ -0,0 +1,167 @@ +package bidselect + +import ( + "sort" + "strings" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +) + +// PriceSelector selects bids based on price-based ranking. +// It implements the vast.BidSelector interface. +type PriceSelector struct { + // maxBids is the maximum number of bids to return. + // If 0, uses cfg.MaxAdsInPod from the config. + maxBids int +} + +// NewPriceSelector creates a new PriceSelector. +// If maxBids is 0, the selector will use cfg.MaxAdsInPod. +// If maxBids is 1, it behaves as a SINGLE selector. +func NewPriceSelector(maxBids int) *PriceSelector { + return &PriceSelector{ + maxBids: maxBids, + } +} + +// bidWithSeat holds a bid along with its seat ID for sorting and selection. +type bidWithSeat struct { + bid openrtb2.Bid + seat string +} + +// Select chooses bids from the response based on price-based ranking. +// It implements the vast.BidSelector interface. +// +// Selection process: +// 1. Collect all bids from resp.SeatBid[].Bid[] +// 2. Filter bids: price > 0 and AdM non-empty (unless AllowSkeletonVast is true) +// 3. Sort by: price desc, then deal exists desc, then bid.ID asc for stability +// 4. Return up to maxBids (or cfg.MaxAdsInPod if maxBids is 0) +// 5. Populate CanonicalMeta for each SelectedBid +func (s *PriceSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg vast.ReceiverConfig) ([]vast.SelectedBid, []string, error) { + var warnings []string + + if resp == nil || len(resp.SeatBid) == 0 { + return nil, warnings, nil + } + + // Determine currency from response or config default + currency := cfg.DefaultCurrency + if resp.Cur != "" { + currency = resp.Cur + } + + // Collect all bids from all seats + var allBids []bidWithSeat + for _, seatBid := range resp.SeatBid { + for _, bid := range seatBid.Bid { + allBids = append(allBids, bidWithSeat{ + bid: bid, + seat: seatBid.Seat, + }) + } + } + + // Filter bids + var filteredBids []bidWithSeat + for _, bws := range allBids { + // Filter: price must be > 0 + if bws.bid.Price <= 0 { + warnings = append(warnings, "bid "+bws.bid.ID+" filtered: price <= 0") + continue + } + + // Filter: AdM must be non-empty unless AllowSkeletonVast is true + if !cfg.AllowSkeletonVast && strings.TrimSpace(bws.bid.AdM) == "" { + warnings = append(warnings, "bid "+bws.bid.ID+" filtered: empty AdM (skeleton VAST not allowed)") + continue + } + + filteredBids = append(filteredBids, bws) + } + + if len(filteredBids) == 0 { + return nil, warnings, nil + } + + // Sort bids: price desc, deal exists desc, bid.ID asc for stability + sort.Slice(filteredBids, func(i, j int) bool { + bi, bj := filteredBids[i].bid, filteredBids[j].bid + + // Primary: price descending + if bi.Price != bj.Price { + return bi.Price > bj.Price + } + + // Secondary: deal exists descending (deals first) + iHasDeal := bi.DealID != "" + jHasDeal := bj.DealID != "" + if iHasDeal != jHasDeal { + return iHasDeal + } + + // Tertiary: bid ID ascending for stability + return bi.ID < bj.ID + }) + + // Determine how many bids to return + maxToReturn := s.maxBids + if maxToReturn == 0 { + maxToReturn = cfg.MaxAdsInPod + } + if maxToReturn <= 0 { + maxToReturn = 1 // Safety fallback + } + if maxToReturn > len(filteredBids) { + maxToReturn = len(filteredBids) + } + + // Select top bids and build SelectedBid with CanonicalMeta + selectedBids := make([]vast.SelectedBid, maxToReturn) + for i := 0; i < maxToReturn; i++ { + bws := filteredBids[i] + bid := bws.bid + + // Determine sequence (SlotInPod) + sequence := i + 1 + // Check if bid has explicit slot in pod via Ext or other mechanism + // For MVP, we use index+1 as sequence + + // Extract primary adomain + adomain := "" + if len(bid.ADomain) > 0 { + adomain = bid.ADomain[0] + } + + // Extract duration from bid (if available in Dur field for video) + durSec := 0 + if bid.Dur > 0 { + durSec = int(bid.Dur) + } + + selectedBids[i] = vast.SelectedBid{ + Bid: bid, + Seat: bws.seat, + Sequence: sequence, + Meta: vast.CanonicalMeta{ + BidID: bid.ID, + ImpID: bid.ImpID, + DealID: bid.DealID, + Seat: bws.seat, + Price: bid.Price, + Currency: currency, + Adomain: adomain, + Cats: bid.Cat, + DurSec: durSec, + SlotInPod: sequence, + }, + } + } + + return selectedBids, warnings, nil +} + +// Ensure PriceSelector implements BidSelector interface. +var _ vast.BidSelector = (*PriceSelector)(nil) diff --git a/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go b/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go new file mode 100644 index 00000000000..d65ef41c266 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go @@ -0,0 +1,501 @@ +package bidselect + +import ( + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSelector(t *testing.T) { + tests := []struct { + name string + strategy vast.SelectionStrategy + wantMax int + }{ + { + name: "SINGLE strategy", + strategy: vast.SelectionSingle, + wantMax: 1, + }, + { + name: "TOP_N strategy", + strategy: vast.SelectionTopN, + wantMax: 0, // uses cfg.MaxAdsInPod + }, + { + name: "unknown strategy defaults to TOP_N", + strategy: "unknown", + wantMax: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selector := NewSelector(tt.strategy) + require.NotNil(t, selector) + + priceSelector, ok := selector.(*PriceSelector) + require.True(t, ok) + assert.Equal(t, tt.wantMax, priceSelector.maxBids) + }) + } +} + +func TestPriceSelector_Select_NilResponse(t *testing.T) { + selector := NewPriceSelector(5) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + + selected, warnings, err := selector.Select(nil, nil, cfg) + assert.NoError(t, err) + assert.Nil(t, selected) + assert.Empty(t, warnings) +} + +func TestPriceSelector_Select_EmptySeatBid(t *testing.T) { + selector := NewPriceSelector(5) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{}, + } + + selected, warnings, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + assert.Nil(t, selected) + assert.Empty(t, warnings) +} + +func TestPriceSelector_Select_FilterZeroPrice(t *testing.T) { + selector := NewPriceSelector(5) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 0, AdM: ""}, + {ID: "bid2", Price: -1, AdM: ""}, + }, + }, + }, + } + + selected, warnings, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + assert.Empty(t, selected) + assert.Len(t, warnings, 2) + assert.Contains(t, warnings[0], "price <= 0") +} + +func TestPriceSelector_Select_FilterEmptyAdM(t *testing.T) { + selector := NewPriceSelector(5) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + AllowSkeletonVast: false, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 2.0, AdM: " "}, + }, + }, + }, + } + + selected, warnings, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + assert.Empty(t, selected) + assert.Len(t, warnings, 2) + assert.Contains(t, warnings[0], "empty AdM") +} + +func TestPriceSelector_Select_AllowSkeletonVast(t *testing.T) { + selector := NewPriceSelector(5) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + AllowSkeletonVast: true, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 2.0, AdM: ""}, + }, + }, + }, + } + + selected, warnings, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + assert.Len(t, selected, 2) + assert.Empty(t, warnings) +} + +func TestPriceSelector_Select_SortByPriceDesc(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 3.0, AdM: ""}, + {ID: "bid3", Price: 2.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 3) + + // Should be sorted by price descending + assert.Equal(t, "bid2", selected[0].Meta.BidID) + assert.Equal(t, 3.0, selected[0].Meta.Price) + assert.Equal(t, "bid3", selected[1].Meta.BidID) + assert.Equal(t, 2.0, selected[1].Meta.Price) + assert.Equal(t, "bid1", selected[2].Meta.BidID) + assert.Equal(t, 1.0, selected[2].Meta.Price) +} + +func TestPriceSelector_Select_DealsPrioritized(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 2.0, AdM: "", DealID: ""}, + {ID: "bid2", Price: 2.0, AdM: "", DealID: "deal123"}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 2) + + // At same price, deal should come first + assert.Equal(t, "bid2", selected[0].Meta.BidID) + assert.Equal(t, "deal123", selected[0].Meta.DealID) + assert.Equal(t, "bid1", selected[1].Meta.BidID) +} + +func TestPriceSelector_Select_StableSortByID(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "c", Price: 2.0, AdM: ""}, + {ID: "a", Price: 2.0, AdM: ""}, + {ID: "b", Price: 2.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 3) + + // Same price, no deals - should be sorted by ID ascending + assert.Equal(t, "a", selected[0].Meta.BidID) + assert.Equal(t, "b", selected[1].Meta.BidID) + assert.Equal(t, "c", selected[2].Meta.BidID) +} + +func TestPriceSelector_Select_SingleStrategy(t *testing.T) { + selector := NewPriceSelector(1) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 3.0, AdM: ""}, + {ID: "bid3", Price: 2.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 1) + assert.Equal(t, "bid2", selected[0].Meta.BidID) + assert.Equal(t, 3.0, selected[0].Meta.Price) +} + +func TestPriceSelector_Select_TopNRespectsMaxAdsInPod(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 2, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 3.0, AdM: ""}, + {ID: "bid3", Price: 2.0, AdM: ""}, + {ID: "bid4", Price: 4.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 2) + assert.Equal(t, "bid4", selected[0].Meta.BidID) + assert.Equal(t, "bid2", selected[1].Meta.BidID) +} + +func TestPriceSelector_Select_Sequence(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + {ID: "bid2", Price: 2.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 2) + + // Sequence should be 1-indexed based on position + assert.Equal(t, 1, selected[0].Sequence) + assert.Equal(t, 1, selected[0].Meta.SlotInPod) + assert.Equal(t, 2, selected[1].Sequence) + assert.Equal(t, 2, selected[1].Meta.SlotInPod) +} + +func TestPriceSelector_Select_CanonicalMeta(t *testing.T) { + selector := NewPriceSelector(1) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "EUR", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + { + ID: "bid1", + ImpID: "imp1", + Price: 2.5, + AdM: "", + DealID: "deal123", + ADomain: []string{"advertiser.com", "other.com"}, + Cat: []string{"IAB1", "IAB2"}, + Dur: 30, + }, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 1) + + meta := selected[0].Meta + assert.Equal(t, "bid1", meta.BidID) + assert.Equal(t, "imp1", meta.ImpID) + assert.Equal(t, "deal123", meta.DealID) + assert.Equal(t, "bidder1", meta.Seat) + assert.Equal(t, 2.5, meta.Price) + assert.Equal(t, "EUR", meta.Currency) // From response + assert.Equal(t, "advertiser.com", meta.Adomain) + assert.Equal(t, []string{"IAB1", "IAB2"}, meta.Cats) + assert.Equal(t, 30, meta.DurSec) + assert.Equal(t, 1, meta.SlotInPod) +} + +func TestPriceSelector_Select_CurrencyFallback(t *testing.T) { + selector := NewPriceSelector(1) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "GBP", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "", // Empty currency + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 1) + assert.Equal(t, "GBP", selected[0].Meta.Currency) // Fallback to config +} + +func TestPriceSelector_Select_MultipleSeatBids(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 5, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "bid1", Price: 1.0, AdM: ""}, + }, + }, + { + Seat: "bidder2", + Bid: []openrtb2.Bid{ + {ID: "bid2", Price: 2.0, AdM: ""}, + }, + }, + { + Seat: "bidder3", + Bid: []openrtb2.Bid{ + {ID: "bid3", Price: 3.0, AdM: ""}, + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 3) + + // Should be sorted by price, with correct seat assignment + assert.Equal(t, "bid3", selected[0].Meta.BidID) + assert.Equal(t, "bidder3", selected[0].Seat) + assert.Equal(t, "bid2", selected[1].Meta.BidID) + assert.Equal(t, "bidder2", selected[1].Seat) + assert.Equal(t, "bid1", selected[2].Meta.BidID) + assert.Equal(t, "bidder1", selected[2].Seat) +} + +func TestPriceSelector_Select_ComplexSort(t *testing.T) { + selector := NewPriceSelector(0) + cfg := vast.ReceiverConfig{ + DefaultCurrency: "USD", + MaxAdsInPod: 10, + } + resp := &openrtb2.BidResponse{ + Cur: "USD", + SeatBid: []openrtb2.SeatBid{ + { + Seat: "bidder1", + Bid: []openrtb2.Bid{ + {ID: "e", Price: 2.0, AdM: "", DealID: ""}, // Same price, no deal + {ID: "a", Price: 3.0, AdM: "", DealID: "deal1"}, // Highest price with deal + {ID: "b", Price: 3.0, AdM: "", DealID: ""}, // Highest price, no deal + {ID: "c", Price: 2.0, AdM: "", DealID: "deal2"}, // Same price with deal + {ID: "d", Price: 2.0, AdM: "", DealID: "deal3"}, // Same price with deal + {ID: "f", Price: 1.0, AdM: "", DealID: ""}, // Lowest price + }, + }, + }, + } + + selected, _, err := selector.Select(nil, resp, cfg) + assert.NoError(t, err) + require.Len(t, selected, 6) + + // Expected order: + // 1. a (price 3.0, deal) - highest price with deal + // 2. b (price 3.0, no deal) - highest price, no deal + // 3. c (price 2.0, deal) - same price, deal, ID "c" + // 4. d (price 2.0, deal) - same price, deal, ID "d" + // 5. e (price 2.0, no deal) - same price, no deal + // 6. f (price 1.0) - lowest price + assert.Equal(t, "a", selected[0].Meta.BidID) + assert.Equal(t, "b", selected[1].Meta.BidID) + assert.Equal(t, "c", selected[2].Meta.BidID) + assert.Equal(t, "d", selected[3].Meta.BidID) + assert.Equal(t, "e", selected[4].Meta.BidID) + assert.Equal(t, "f", selected[5].Meta.BidID) +} + +func TestNewSingleSelector(t *testing.T) { + selector := NewSingleSelector() + require.NotNil(t, selector) + + priceSelector, ok := selector.(*PriceSelector) + require.True(t, ok) + assert.Equal(t, 1, priceSelector.maxBids) +} + +func TestNewTopNSelector(t *testing.T) { + selector := NewTopNSelector() + require.NotNil(t, selector) + + priceSelector, ok := selector.(*PriceSelector) + require.True(t, ok) + assert.Equal(t, 0, priceSelector.maxBids) +} diff --git a/modules/prebid/ctv_vast_enrichment/select/selector.go b/modules/prebid/ctv_vast_enrichment/select/selector.go new file mode 100644 index 00000000000..decc385ac42 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/select/selector.go @@ -0,0 +1,42 @@ +// Package bidselect provides bid selection logic for CTV VAST ad pods. +package bidselect + +import ( + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +) + +// Selector implements the vast.BidSelector interface. +// It provides factory methods for different selection strategies. +type Selector interface { + vast.BidSelector +} + +// NewSelector creates a BidSelector based on the selection strategy. +// Supported strategies: +// - "SINGLE": Returns a single best bid (PriceSelector with limit 1) +// - "TOP_N": Returns up to MaxAdsInPod bids (PriceSelector) +// - Default: Falls back to TOP_N behavior +func NewSelector(strategy vast.SelectionStrategy) Selector { + switch strategy { + case vast.SelectionSingle: + return NewPriceSelector(1) + case vast.SelectionTopN: + return NewPriceSelector(0) // 0 means use cfg.MaxAdsInPod + default: + // Default to TOP_N behavior for unknown strategies + return NewPriceSelector(0) + } +} + +// NewSingleSelector creates a selector that returns only the best bid. +func NewSingleSelector() Selector { + return NewPriceSelector(1) +} + +// NewTopNSelector creates a selector that returns up to MaxAdsInPod bids. +func NewTopNSelector() Selector { + return NewPriceSelector(0) +} + +// Ensure PriceSelector implements Selector interface. +var _ Selector = (*PriceSelector)(nil) diff --git a/modules/prebid/ctv_vast_enrichment/types.go b/modules/prebid/ctv_vast_enrichment/types.go new file mode 100644 index 00000000000..d8e5234e49e --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/types.go @@ -0,0 +1,191 @@ +// Package vast provides CTV VAST processing capabilities for Prebid Server. +// It includes bid selection, VAST enrichment, and formatting for various receivers. +package vast + +import ( + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" +) + +// ReceiverType identifies the downstream ad receiver/player. +type ReceiverType string + +const ( + // ReceiverGAMSSU represents Google Ad Manager Server-Side Unified receiver. + ReceiverGAMSSU ReceiverType = "GAM_SSU" + // ReceiverGeneric represents a generic VAST-compliant receiver. + ReceiverGeneric ReceiverType = "GENERIC" +) + +// SelectionStrategy defines how bids are selected for ad pods. +type SelectionStrategy string + +const ( + // SelectionSingle selects a single best bid. + SelectionSingle SelectionStrategy = "SINGLE" + // SelectionTopN selects up to MaxAdsInPod bids. + SelectionTopN SelectionStrategy = "TOP_N" + // SelectionMaxRevenue selects bids to maximize total revenue. + SelectionMaxRevenue SelectionStrategy = "max_revenue" + // SelectionMinDuration selects bids to minimize total duration. + SelectionMinDuration SelectionStrategy = "min_duration" + // SelectionBalanced balances between revenue and duration. + SelectionBalanced SelectionStrategy = "balanced" +) + +// CollisionPolicy defines how to handle competitive separation violations. +type CollisionPolicy string + +const ( + // CollisionReject rejects ads that violate competitive separation. + CollisionReject CollisionPolicy = "reject" + // CollisionWarn allows ads but adds warnings for violations. + CollisionWarn CollisionPolicy = "warn" + // CollisionIgnore ignores competitive separation rules. + CollisionIgnore CollisionPolicy = "ignore" +) + +// VastResult holds the complete result of VAST processing. +type VastResult struct { + // VastXML contains the final VAST XML output. + VastXML []byte + // NoAd indicates if no valid ad was available. + NoAd bool + // Warnings contains non-fatal issues encountered during processing. + Warnings []string + // Errors contains fatal errors that occurred during processing. + Errors []error + // Selected contains the bids that were selected for the ad pod. + Selected []SelectedBid +} + +// SelectedBid represents a bid that was selected for inclusion in the VAST response. +type SelectedBid struct { + // Bid is the OpenRTB bid object. + Bid openrtb2.Bid + // Seat is the seat ID of the bidder. + Seat string + // Sequence is the position of this bid in the ad pod (1-indexed). + Sequence int + // Meta contains canonical metadata extracted from the bid. + Meta CanonicalMeta +} + +// CanonicalMeta contains normalized metadata for a selected bid. +type CanonicalMeta struct { + // BidID is the unique identifier for the bid. + BidID string + // ImpID is the impression ID this bid is for. + ImpID string + // DealID is the deal ID if this bid is from a deal. + DealID string + // Seat is the bidder seat ID. + Seat string + // Price is the bid price. + Price float64 + // Currency is the currency code for the price. + Currency string + // Adomain is the primary advertiser domain. + Adomain string + // Cats contains the IAB content categories. + Cats []string + // DurSec is the duration of the creative in seconds. + DurSec int + // SlotInPod is the position within the ad pod (1-indexed). + SlotInPod int +} + +// ReceiverConfig holds configuration for VAST processing. +type ReceiverConfig struct { + // Receiver identifies the downstream ad receiver type. + Receiver ReceiverType + // DefaultCurrency is the currency to use when not specified. + DefaultCurrency string + // VastVersionDefault is the default VAST version to output. + VastVersionDefault string + // MaxAdsInPod is the maximum number of ads allowed in a pod. + MaxAdsInPod int + // SelectionStrategy defines how bids are selected. + SelectionStrategy SelectionStrategy + // CollisionPolicy defines how competitive separation is handled. + CollisionPolicy CollisionPolicy + // Placement contains placement-specific rules. + Placement PlacementRules + // AllowSkeletonVast allows bids without AdM content (skeleton VAST). + AllowSkeletonVast bool + // Debug enables debug mode with additional output. + Debug bool +} + +// PlacementRules contains rules for validating and filtering bids. +type PlacementRules struct { + // Pricing contains price floor and ceiling rules. + Pricing PricingRules + // Advertiser contains advertiser-based filtering rules. + Advertiser AdvertiserRules + // Categories contains category-based filtering rules. + Categories CategoryRules + // PricingPlacement defines where to place pricing info: "VAST_PRICING" or "EXTENSION". + PricingPlacement string + // AdvertiserPlacement defines where to place advertiser info: "ADVERTISER_TAG" or "EXTENSION". + AdvertiserPlacement string + // Debug enables debug output for placement rules. + Debug bool +} + +// PricingRules defines pricing constraints for bid selection. +type PricingRules struct { + // FloorCPM is the minimum CPM allowed. + FloorCPM float64 + // CeilingCPM is the maximum CPM allowed (0 = no ceiling). + CeilingCPM float64 + // Currency is the currency for floor/ceiling values. + Currency string +} + +// AdvertiserRules defines advertiser-based filtering. +type AdvertiserRules struct { + // BlockedDomains is a list of advertiser domains to reject. + BlockedDomains []string + // AllowedDomains is a whitelist of allowed domains (empty = allow all). + AllowedDomains []string +} + +// CategoryRules defines category-based filtering. +type CategoryRules struct { + // BlockedCategories is a list of IAB categories to reject. + BlockedCategories []string + // AllowedCategories is a whitelist of allowed categories (empty = allow all). + AllowedCategories []string +} + +// BidSelector defines the interface for selecting bids from an auction response. +type BidSelector interface { + // Select chooses bids from the response based on configuration. + // Returns selected bids, warnings, and any fatal error. + Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg ReceiverConfig) ([]SelectedBid, []string, error) +} + +// Enricher defines the interface for enriching VAST ads with additional data. +type Enricher interface { + // Enrich adds tracking, extensions, and other data to a VAST ad. + // Returns warnings and any fatal error. + Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) +} + +// EnrichedAd pairs a VAST Ad with its associated metadata. +type EnrichedAd struct { + // Ad is the enriched VAST Ad element. + Ad *model.Ad + // Meta contains canonical metadata for this ad. + Meta CanonicalMeta + // Sequence is the position in the ad pod (1-indexed). + Sequence int +} + +// Formatter defines the interface for formatting VAST ads into XML. +type Formatter interface { + // Format converts enriched VAST ads into XML output. + // Returns the XML bytes, warnings, and any fatal error. + Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) +} From 7f6ac61c516ec0c23e569b85768de171cc32a1e8 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Thu, 5 Feb 2026 16:19:05 +0000 Subject: [PATCH 03/15] chore: remove old modules/ctv/vast folder (moved to modules/prebid/ctv_vast_enrichment) --- modules/ctv/vast/README_EN.md | 336 --------- modules/ctv/vast/config.go | 369 ---------- modules/ctv/vast/config_test.go | 388 ---------- modules/ctv/vast/enrich/enrich.go | 264 ------- modules/ctv/vast/enrich/enrich_test.go | 672 ------------------ modules/ctv/vast/format/format.go | 114 --- modules/ctv/vast/format/format_test.go | 488 ------------- modules/ctv/vast/format/testdata/no_ad.xml | 2 - .../vast/format/testdata/pod_three_ads.xml | 45 -- .../ctv/vast/format/testdata/pod_two_ads.xml | 39 - .../ctv/vast/format/testdata/single_ad.xml | 24 - modules/ctv/vast/handler.go | 167 ----- modules/ctv/vast/model/model.go | 28 - modules/ctv/vast/model/parser.go | 171 ----- modules/ctv/vast/model/parser_test.go | 528 -------------- modules/ctv/vast/model/vast_xml.go | 282 -------- modules/ctv/vast/model/vast_xml_test.go | 447 ------------ modules/ctv/vast/select/price_selector.go | 167 ----- .../ctv/vast/select/price_selector_test.go | 501 ------------- modules/ctv/vast/select/selector.go | 42 -- modules/ctv/vast/types.go | 191 ----- modules/ctv/vast/vast.go | 204 ------ modules/ctv/vast/vast_test.go | 607 ---------------- 23 files changed, 6076 deletions(-) delete mode 100644 modules/ctv/vast/README_EN.md delete mode 100644 modules/ctv/vast/config.go delete mode 100644 modules/ctv/vast/config_test.go delete mode 100644 modules/ctv/vast/enrich/enrich.go delete mode 100644 modules/ctv/vast/enrich/enrich_test.go delete mode 100644 modules/ctv/vast/format/format.go delete mode 100644 modules/ctv/vast/format/format_test.go delete mode 100644 modules/ctv/vast/format/testdata/no_ad.xml delete mode 100644 modules/ctv/vast/format/testdata/pod_three_ads.xml delete mode 100644 modules/ctv/vast/format/testdata/pod_two_ads.xml delete mode 100644 modules/ctv/vast/format/testdata/single_ad.xml delete mode 100644 modules/ctv/vast/handler.go delete mode 100644 modules/ctv/vast/model/model.go delete mode 100644 modules/ctv/vast/model/parser.go delete mode 100644 modules/ctv/vast/model/parser_test.go delete mode 100644 modules/ctv/vast/model/vast_xml.go delete mode 100644 modules/ctv/vast/model/vast_xml_test.go delete mode 100644 modules/ctv/vast/select/price_selector.go delete mode 100644 modules/ctv/vast/select/price_selector_test.go delete mode 100644 modules/ctv/vast/select/selector.go delete mode 100644 modules/ctv/vast/types.go delete mode 100644 modules/ctv/vast/vast.go delete mode 100644 modules/ctv/vast/vast_test.go diff --git a/modules/ctv/vast/README_EN.md b/modules/ctv/vast/README_EN.md deleted file mode 100644 index cb588d8c606..00000000000 --- a/modules/ctv/vast/README_EN.md +++ /dev/null @@ -1,336 +0,0 @@ -# CTV VAST Module - -The CTV VAST module provides comprehensive VAST (Video Ad Serving Template) processing for Connected TV (CTV) ads in Prebid Server. - -## Module Structure - -``` -modules/ctv/vast/ -├── vast.go # Main entry point and orchestration -├── handler.go # HTTP handler for VAST requests -├── types.go # Type definitions, interfaces and constants -├── config.go # Configuration and layer merging (host/account/profile) -├── model/ # VAST XML data structures -│ ├── model.go # High-level domain objects -│ ├── vast_xml.go # XML structures for marshal/unmarshal -│ └── parser.go # VAST XML parser -├── select/ # Bid selection logic -│ └── selector.go # BidSelector implementations -├── enrich/ # VAST enrichment -│ └── enrich.go # Enricher implementation (VAST_WINS) -└── format/ # VAST XML formatting - └── format.go # Formatter implementation (GAM_SSU) -``` - -## Components - -### `vast.go` - Orchestration - -Main entry point of the module. Contains: - -- **`BuildVastFromBidResponse()`** - Main function orchestrating the entire pipeline: - 1. Bid selection from auction response - 2. VAST parsing from each bid's AdM (or skeleton creation) - 3. Enrichment of each ad with metadata - 4. Formatting to final XML - -- **`Processor`** - Wrapper structure for the pipeline with injected dependencies -- **`DefaultConfig()`** - Default configuration for GAM SSU - -### `handler.go` - HTTP Handler - -HTTP request handling for CTV VAST ads: - -- **`Handler`** - HTTP handler structure with configuration and dependencies -- **`ServeHTTP()`** - Handles GET requests, returns VAST XML -- **`buildBidRequest()`** - Builds OpenRTB BidRequest from HTTP parameters -- Builder methods: `WithConfig()`, `WithSelector()`, `WithEnricher()`, `WithFormatter()`, `WithAuctionFunc()` - -### `types.go` - Types and Interfaces - -Basic type definitions: - -| Type | Description | -|------|-------------| -| `ReceiverType` | Receiver type (GAM_SSU, SPRINGSERVE, etc.) | -| `SelectionStrategy` | Bid selection strategy (SINGLE, TOP_N, MAX_REVENUE) | -| `CollisionPolicy` | Collision policy (VAST_WINS, BID_WINS, REJECT) | -| `PlacementLocation` | Element placement (VAST_PRICING, EXTENSION, etc.) | - -**Interfaces:** - -```go -type BidSelector interface { - Select(req, resp, cfg) ([]SelectedBid, []string, error) -} - -type Enricher interface { - Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) -} - -type Formatter interface { - Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) -} -``` - -**Data Structures:** - -- `CanonicalMeta` - Normalized bid metadata (BidID, Price, Currency, Adomain, etc.) -- `SelectedBid` - Selected bid with metadata and sequence number -- `EnrichedAd` - Enriched ad ready for formatting -- `VastResult` - Processing result (XML, warnings, errors) -- `ReceiverConfig` - VAST receiver configuration -- `PlacementRules` - Validation rules (pricing, advertiser, categories) - -### `config.go` - Configuration - -PBS-style layered configuration system: - -- **`CTVVastConfig`** - Configuration structure with nullable fields -- **`MergeCTVVastConfig()`** - Layer merging: Host → Account → Profile -- **`ToReceiverConfig()`** - Conversion to ReceiverConfig - -Layer priority (from lowest to highest): -1. Host (defaults) -2. Account (overrides host) -3. Profile (overrides everything) - -### `model/` - VAST XML Structures - -#### `vast_xml.go` - -Go structures mapping VAST XML elements: - -- `Vast` - Root element `` -- `Ad` - Element `` with id, sequence attributes -- `InLine` - Inline ad with full data -- `Wrapper` - Wrapper ad (redirect) -- `Creative`, `Linear`, `MediaFile` - Creative elements -- `Pricing`, `Impression`, `Extensions` - Metadata and tracking - -Helper functions: -- `BuildNoAdVast()` - Creates empty VAST (no ads) -- `BuildSkeletonInlineVast()` - Creates minimal VAST skeleton -- `SecToHHMMSS()` - Converts seconds to HH:MM:SS format - -#### `parser.go` - -VAST XML parser: - -- **`ParseVastAdm()`** - Parses AdM string to Vast structure -- **`ParseVastOrSkeleton()`** - Parses or creates skeleton if allowed -- **`ExtractFirstAd()`** - Extracts first ad from VAST -- **`ParseDurationToSeconds()`** - Parses duration "HH:MM:SS" to seconds - -### `select/` - Bid Selection - -Logic for selecting bids from auction response: - -- **`PriceSelector`** - Price-based implementation: - - Filters bids with price ≤ 0 or empty AdM - - Sorts: deal > non-deal, then by price descending - - Respects `MaxAdsInPod` for TOP_N strategy - - Assigns sequence numbers (1-indexed) - -- **`NewSelector(strategy)`** - Factory creating selector for strategy -- **`NewSingleSelector()`** - Returns only the best bid -- **`NewTopNSelector()`** - Returns top N bids - -### `enrich/` - VAST Enrichment - -Adding metadata to VAST ads: - -- **`VastEnricher`** - Implementation with VAST_WINS policy: - - Existing values in VAST are not overwritten - - Adds missing: Pricing, Advertiser, Duration, Categories - - Optional debug extensions with OpenRTB data - -Enriched elements: -| Element | Source | Location | -|---------|--------|----------| -| Pricing | meta.Price | `` or Extension | -| Advertiser | meta.Adomain | `` or Extension | -| Duration | meta.DurSec | `` in Linear | -| Categories | meta.Cats | Extension (always) | -| Debug | all fields | Extension (when cfg.Debug=true) | - -### `format/` - VAST Formatting - -Building final VAST XML: - -- **`VastFormatter`** - GAM SSU implementation: - - Builds VAST document with list of `` elements - - Sets `id` from BidID - - Sets `sequence` for pods (multiple ads) - - Adds XML declaration and formatting - -## Processing Flow - -``` -┌─────────────────┐ -│ BidRequest │ -│ BidResponse │ -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ BidSelector │ ← Filters and sorts bids -│ (select/) │ ← Selects top N by strategy -└────────┬────────┘ - │ []SelectedBid - ▼ -┌─────────────────┐ -│ ParseVast │ ← Parses AdM to structure -│ (model/) │ ← Or creates skeleton -└────────┬────────┘ - │ *model.Ad - ▼ -┌─────────────────┐ -│ Enricher │ ← Adds Pricing, Advertiser -│ (enrich/) │ ← VAST_WINS policy -└────────┬────────┘ - │ EnrichedAd - ▼ -┌─────────────────┐ -│ Formatter │ ← Builds final XML -│ (format/) │ ← Sets sequence, id -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ VastResult │ -│ (XML bytes) │ -└─────────────────┘ -``` - -## Usage - -### Basic Usage with Processor - -```go -import ( - "github.com/prebid/prebid-server/v3/modules/ctv/vast" - "github.com/prebid/prebid-server/v3/modules/ctv/vast/enrich" - "github.com/prebid/prebid-server/v3/modules/ctv/vast/format" - bidselect "github.com/prebid/prebid-server/v3/modules/ctv/vast/select" -) - -// Configuration -cfg := vast.DefaultConfig() -cfg.MaxAdsInPod = 3 -cfg.SelectionStrategy = vast.SelectionTopN - -// Create components -selector := bidselect.NewSelector(cfg.SelectionStrategy) -enricher := enrich.NewEnricher() -formatter := format.NewFormatter() - -// Create processor -processor := vast.NewProcessor(cfg, selector, enricher, formatter) - -// Process -result := processor.Process(ctx, bidRequest, bidResponse) - -if result.NoAd { - // No ads available -} - -// result.VastXML contains the ready XML -``` - -### HTTP Handler Usage - -```go -handler := vast.NewHandler(). - WithConfig(cfg). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter). - WithAuctionFunc(myAuctionFunc) - -http.Handle("/vast", handler) -``` - -### Direct Invocation - -```go -result, err := vast.BuildVastFromBidResponse( - ctx, - bidRequest, - bidResponse, - cfg, - selector, - enricher, - formatter, -) -``` - -## Layer Configuration - -```go -// Host configuration (defaults) -hostCfg := &vast.CTVVastConfig{ - Receiver: vast.ReceiverGAMSSU, - DefaultCurrency: "USD", - VastVersionDefault: "4.0", -} - -// Account configuration (overrides host) -accountCfg := &vast.CTVVastConfig{ - MaxAdsInPod: vast.IntPtr(5), - SelectionStrategy: vast.SelectionTopN, -} - -// Profile configuration (overrides everything) -profileCfg := &vast.CTVVastConfig{ - Debug: vast.BoolPtr(true), -} - -// Merge layers -merged := vast.MergeCTVVastConfig(hostCfg, accountCfg, profileCfg) -receiverCfg := merged.ToReceiverConfig() -``` - -## Testing - -Run all module tests: - -```bash -go test ./modules/ctv/vast/... -v -``` - -Tests with coverage: - -```bash -go test ./modules/ctv/vast/... -cover -``` - -## Extensions - -### Adding a New Receiver - -1. Add constant in `types.go`: - ```go - ReceiverMyReceiver ReceiverType = "MY_RECEIVER" - ``` - -2. Implement `Formatter` for the new format in `format/` - -3. Optionally: adjust `Enricher` if different enrichment is needed - -### Adding a New Selection Strategy - -1. Add constant in `types.go`: - ```go - SelectionMyStrategy SelectionStrategy = "MY_STRATEGY" - ``` - -2. Implement `BidSelector` in `select/` - -3. Update `NewSelector()` factory - -## Dependencies - -- `github.com/prebid/openrtb/v20/openrtb2` - OpenRTB types -- `encoding/xml` - XML parsing/serialization -- `net/http` - HTTP handler diff --git a/modules/ctv/vast/config.go b/modules/ctv/vast/config.go deleted file mode 100644 index 64fea1ddb08..00000000000 --- a/modules/ctv/vast/config.go +++ /dev/null @@ -1,369 +0,0 @@ -package vast - -// CTVVastConfig represents the configuration for CTV VAST processing. -// It supports PBS-style layered configuration where profile overrides account, -// and account overrides host-level settings. -type CTVVastConfig struct { - // Enabled controls whether CTV VAST processing is active. - Enabled *bool `json:"enabled,omitempty" mapstructure:"enabled"` - // Receiver identifies the downstream ad receiver type (e.g., "GAM_SSU", "GENERIC"). - Receiver string `json:"receiver,omitempty" mapstructure:"receiver"` - // DefaultCurrency is the currency to use when not specified (default: "USD"). - DefaultCurrency string `json:"default_currency,omitempty" mapstructure:"default_currency"` - // VastVersionDefault is the default VAST version to output (default: "3.0"). - VastVersionDefault string `json:"vast_version_default,omitempty" mapstructure:"vast_version_default"` - // MaxAdsInPod is the maximum number of ads allowed in a pod (default: 10). - MaxAdsInPod int `json:"max_ads_in_pod,omitempty" mapstructure:"max_ads_in_pod"` - // SelectionStrategy defines how bids are selected (e.g., "SINGLE", "TOP_N"). - SelectionStrategy string `json:"selection_strategy,omitempty" mapstructure:"selection_strategy"` - // CollisionPolicy defines how competitive separation is handled (default: "VAST_WINS"). - CollisionPolicy string `json:"collision_policy,omitempty" mapstructure:"collision_policy"` - // AllowSkeletonVast allows bids without AdM content (skeleton VAST). - AllowSkeletonVast *bool `json:"allow_skeleton_vast,omitempty" mapstructure:"allow_skeleton_vast"` - // Placement contains placement-specific rules. - Placement *PlacementRulesConfig `json:"placement,omitempty" mapstructure:"placement"` - // Debug enables debug mode with additional output. - Debug *bool `json:"debug,omitempty" mapstructure:"debug"` -} - -// PlacementRulesConfig contains rules for validating and filtering bids. -type PlacementRulesConfig struct { - // Pricing contains price floor and ceiling rules. - Pricing *PricingRulesConfig `json:"pricing,omitempty" mapstructure:"pricing"` - // Advertiser contains advertiser-based filtering rules. - Advertiser *AdvertiserRulesConfig `json:"advertiser,omitempty" mapstructure:"advertiser"` - // Categories contains category-based filtering rules. - Categories *CategoryRulesConfig `json:"categories,omitempty" mapstructure:"categories"` - // PricingPlacement defines where to place pricing: "VAST_PRICING" or "EXTENSION". - PricingPlacement string `json:"pricing_placement,omitempty" mapstructure:"pricing_placement"` - // AdvertiserPlacement defines where to place advertiser: "ADVERTISER_TAG" or "EXTENSION". - AdvertiserPlacement string `json:"advertiser_placement,omitempty" mapstructure:"advertiser_placement"` - // Debug enables debug output for placement rules. - Debug *bool `json:"debug,omitempty" mapstructure:"debug"` -} - -// PricingRulesConfig defines pricing constraints for bid selection. -type PricingRulesConfig struct { - // FloorCPM is the minimum CPM allowed. - FloorCPM *float64 `json:"floor_cpm,omitempty" mapstructure:"floor_cpm"` - // CeilingCPM is the maximum CPM allowed (0 = no ceiling). - CeilingCPM *float64 `json:"ceiling_cpm,omitempty" mapstructure:"ceiling_cpm"` - // Currency is the currency for floor/ceiling values. - Currency string `json:"currency,omitempty" mapstructure:"currency"` -} - -// AdvertiserRulesConfig defines advertiser-based filtering. -type AdvertiserRulesConfig struct { - // BlockedDomains is a list of advertiser domains to reject. - BlockedDomains []string `json:"blocked_domains,omitempty" mapstructure:"blocked_domains"` - // AllowedDomains is a whitelist of allowed domains (empty = allow all). - AllowedDomains []string `json:"allowed_domains,omitempty" mapstructure:"allowed_domains"` -} - -// CategoryRulesConfig defines category-based filtering. -type CategoryRulesConfig struct { - // BlockedCategories is a list of IAB categories to reject. - BlockedCategories []string `json:"blocked_categories,omitempty" mapstructure:"blocked_categories"` - // AllowedCategories is a whitelist of allowed categories (empty = allow all). - AllowedCategories []string `json:"allowed_categories,omitempty" mapstructure:"allowed_categories"` -} - -// Default values for CTVVastConfig. -const ( - DefaultVastVersion = "3.0" - DefaultCurrency = "USD" - DefaultMaxAdsInPod = 10 - DefaultCollisionPolicy = "VAST_WINS" - DefaultReceiver = "GAM_SSU" - DefaultSelectionStrategy = "max_revenue" - - // Placement constants for pricing - PlacementVastPricing = "VAST_PRICING" - PlacementExtension = "EXTENSION" - - // Placement constants for advertiser - PlacementAdvertiserTag = "ADVERTISER_TAG" - // PlacementExtension is also used for advertiser -) - -// MergeCTVVastConfig merges configuration from host, account, and profile layers. -// The precedence order is: profile > account > host (profile values override account, which overrides host). -// Only non-zero values override; nil pointers and empty strings are considered "not set". -func MergeCTVVastConfig(host, account, profile *CTVVastConfig) CTVVastConfig { - result := CTVVastConfig{} - - // Start with host config - if host != nil { - result = mergeIntoConfig(result, *host) - } - - // Override with account config - if account != nil { - result = mergeIntoConfig(result, *account) - } - - // Override with profile config (highest precedence) - if profile != nil { - result = mergeIntoConfig(result, *profile) - } - - return result -} - -// mergeIntoConfig merges src into dst, where non-zero values in src override dst. -func mergeIntoConfig(dst, src CTVVastConfig) CTVVastConfig { - if src.Enabled != nil { - dst.Enabled = src.Enabled - } - if src.Receiver != "" { - dst.Receiver = src.Receiver - } - if src.DefaultCurrency != "" { - dst.DefaultCurrency = src.DefaultCurrency - } - if src.VastVersionDefault != "" { - dst.VastVersionDefault = src.VastVersionDefault - } - if src.MaxAdsInPod != 0 { - dst.MaxAdsInPod = src.MaxAdsInPod - } - if src.SelectionStrategy != "" { - dst.SelectionStrategy = src.SelectionStrategy - } - if src.CollisionPolicy != "" { - dst.CollisionPolicy = src.CollisionPolicy - } - if src.AllowSkeletonVast != nil { - dst.AllowSkeletonVast = src.AllowSkeletonVast - } - if src.Debug != nil { - dst.Debug = src.Debug - } - - // Merge placement rules - if src.Placement != nil { - if dst.Placement == nil { - dst.Placement = &PlacementRulesConfig{} - } - dst.Placement = mergePlacementRules(dst.Placement, src.Placement) - } - - return dst -} - -// mergePlacementRules merges placement rules from src into dst. -func mergePlacementRules(dst, src *PlacementRulesConfig) *PlacementRulesConfig { - if dst == nil { - dst = &PlacementRulesConfig{} - } - if src == nil { - return dst - } - - if src.Debug != nil { - dst.Debug = src.Debug - } - - // Merge pricing rules - if src.Pricing != nil { - if dst.Pricing == nil { - dst.Pricing = &PricingRulesConfig{} - } - dst.Pricing = mergePricingRules(dst.Pricing, src.Pricing) - } - - // Merge advertiser rules - if src.Advertiser != nil { - if dst.Advertiser == nil { - dst.Advertiser = &AdvertiserRulesConfig{} - } - dst.Advertiser = mergeAdvertiserRules(dst.Advertiser, src.Advertiser) - } - - // Merge category rules - if src.Categories != nil { - if dst.Categories == nil { - dst.Categories = &CategoryRulesConfig{} - } - dst.Categories = mergeCategoryRules(dst.Categories, src.Categories) - } - - return dst -} - -// mergePricingRules merges pricing rules from src into dst. -func mergePricingRules(dst, src *PricingRulesConfig) *PricingRulesConfig { - if src.FloorCPM != nil { - dst.FloorCPM = src.FloorCPM - } - if src.CeilingCPM != nil { - dst.CeilingCPM = src.CeilingCPM - } - if src.Currency != "" { - dst.Currency = src.Currency - } - return dst -} - -// mergeAdvertiserRules merges advertiser rules from src into dst. -func mergeAdvertiserRules(dst, src *AdvertiserRulesConfig) *AdvertiserRulesConfig { - if len(src.BlockedDomains) > 0 { - dst.BlockedDomains = src.BlockedDomains - } - if len(src.AllowedDomains) > 0 { - dst.AllowedDomains = src.AllowedDomains - } - return dst -} - -// mergeCategoryRules merges category rules from src into dst. -func mergeCategoryRules(dst, src *CategoryRulesConfig) *CategoryRulesConfig { - if len(src.BlockedCategories) > 0 { - dst.BlockedCategories = src.BlockedCategories - } - if len(src.AllowedCategories) > 0 { - dst.AllowedCategories = src.AllowedCategories - } - return dst -} - -// ReceiverConfig converts CTVVastConfig to ReceiverConfig with defaults applied. -// Default values: -// - VastVersionDefault: "3.0" -// - DefaultCurrency: "USD" -// - MaxAdsInPod: 10 -// - CollisionPolicy: "VAST_WINS" -// - Receiver: "GAM_SSU" -// - SelectionStrategy: "max_revenue" -func (cfg CTVVastConfig) ReceiverConfig() ReceiverConfig { - rc := ReceiverConfig{} - - // Apply receiver with default - if cfg.Receiver != "" { - rc.Receiver = ReceiverType(cfg.Receiver) - } else { - rc.Receiver = ReceiverType(DefaultReceiver) - } - - // Apply currency with default - if cfg.DefaultCurrency != "" { - rc.DefaultCurrency = cfg.DefaultCurrency - } else { - rc.DefaultCurrency = DefaultCurrency - } - - // Apply VAST version with default - if cfg.VastVersionDefault != "" { - rc.VastVersionDefault = cfg.VastVersionDefault - } else { - rc.VastVersionDefault = DefaultVastVersion - } - - // Apply max ads in pod with default - if cfg.MaxAdsInPod != 0 { - rc.MaxAdsInPod = cfg.MaxAdsInPod - } else { - rc.MaxAdsInPod = DefaultMaxAdsInPod - } - - // Apply selection strategy with default - if cfg.SelectionStrategy != "" { - rc.SelectionStrategy = SelectionStrategy(cfg.SelectionStrategy) - } else { - rc.SelectionStrategy = SelectionStrategy(DefaultSelectionStrategy) - } - - // Apply collision policy with default - if cfg.CollisionPolicy != "" { - rc.CollisionPolicy = CollisionPolicy(cfg.CollisionPolicy) - } else { - rc.CollisionPolicy = CollisionPolicy(DefaultCollisionPolicy) - } - - // Apply allow skeleton vast flag - if cfg.AllowSkeletonVast != nil { - rc.AllowSkeletonVast = *cfg.AllowSkeletonVast - } - - // Apply debug flag - if cfg.Debug != nil { - rc.Debug = *cfg.Debug - } - - // Apply placement rules - rc.Placement = cfg.buildPlacementRules() - - return rc -} - -// buildPlacementRules converts PlacementRulesConfig to PlacementRules. -func (cfg CTVVastConfig) buildPlacementRules() PlacementRules { - pr := PlacementRules{} - - if cfg.Placement == nil { - return pr - } - - if cfg.Placement.Debug != nil { - pr.Debug = *cfg.Placement.Debug - } - - // Set placement locations with defaults - pr.PricingPlacement = cfg.Placement.PricingPlacement - if pr.PricingPlacement == "" { - pr.PricingPlacement = PlacementVastPricing - } - pr.AdvertiserPlacement = cfg.Placement.AdvertiserPlacement - if pr.AdvertiserPlacement == "" { - pr.AdvertiserPlacement = PlacementAdvertiserTag - } - - // Build pricing rules - if cfg.Placement.Pricing != nil { - pr.Pricing = PricingRules{ - Currency: cfg.Placement.Pricing.Currency, - } - if cfg.Placement.Pricing.FloorCPM != nil { - pr.Pricing.FloorCPM = *cfg.Placement.Pricing.FloorCPM - } - if cfg.Placement.Pricing.CeilingCPM != nil { - pr.Pricing.CeilingCPM = *cfg.Placement.Pricing.CeilingCPM - } - if pr.Pricing.Currency == "" { - pr.Pricing.Currency = DefaultCurrency - } - } - - // Build advertiser rules - if cfg.Placement.Advertiser != nil { - pr.Advertiser = AdvertiserRules{ - BlockedDomains: cfg.Placement.Advertiser.BlockedDomains, - AllowedDomains: cfg.Placement.Advertiser.AllowedDomains, - } - } - - // Build category rules - if cfg.Placement.Categories != nil { - pr.Categories = CategoryRules{ - BlockedCategories: cfg.Placement.Categories.BlockedCategories, - AllowedCategories: cfg.Placement.Categories.AllowedCategories, - } - } - - return pr -} - -// IsEnabled returns true if the config is enabled. Returns false if Enabled is nil or false. -func (cfg CTVVastConfig) IsEnabled() bool { - return cfg.Enabled != nil && *cfg.Enabled -} - -// boolPtr is a helper function to create a pointer to a bool value. -func boolPtr(b bool) *bool { - return &b -} - -// float64Ptr is a helper function to create a pointer to a float64 value. -func float64Ptr(f float64) *float64 { - return &f -} diff --git a/modules/ctv/vast/config_test.go b/modules/ctv/vast/config_test.go deleted file mode 100644 index 6de0712c603..00000000000 --- a/modules/ctv/vast/config_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package vast - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMergeCTVVastConfig_NilInputs(t *testing.T) { - result := MergeCTVVastConfig(nil, nil, nil) - assert.Equal(t, CTVVastConfig{}, result) -} - -func TestMergeCTVVastConfig_HostOnly(t *testing.T) { - host := &CTVVastConfig{ - Receiver: "GAM_SSU", - DefaultCurrency: "EUR", - VastVersionDefault: "4.0", - MaxAdsInPod: 5, - SelectionStrategy: "balanced", - CollisionPolicy: "reject", - } - - result := MergeCTVVastConfig(host, nil, nil) - - assert.Equal(t, "GAM_SSU", result.Receiver) - assert.Equal(t, "EUR", result.DefaultCurrency) - assert.Equal(t, "4.0", result.VastVersionDefault) - assert.Equal(t, 5, result.MaxAdsInPod) - assert.Equal(t, "balanced", result.SelectionStrategy) - assert.Equal(t, "reject", result.CollisionPolicy) -} - -func TestMergeCTVVastConfig_AccountOverridesHost(t *testing.T) { - host := &CTVVastConfig{ - Receiver: "GAM_SSU", - DefaultCurrency: "EUR", - VastVersionDefault: "4.0", - MaxAdsInPod: 5, - } - account := &CTVVastConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 10, - } - - result := MergeCTVVastConfig(host, account, nil) - - assert.Equal(t, "GAM_SSU", result.Receiver) // from host - assert.Equal(t, "USD", result.DefaultCurrency) // overridden by account - assert.Equal(t, "4.0", result.VastVersionDefault) // from host - assert.Equal(t, 10, result.MaxAdsInPod) // overridden by account -} - -func TestMergeCTVVastConfig_ProfileOverridesAll(t *testing.T) { - host := &CTVVastConfig{ - Receiver: "GAM_SSU", - DefaultCurrency: "EUR", - VastVersionDefault: "4.0", - MaxAdsInPod: 5, - SelectionStrategy: "max_revenue", - } - account := &CTVVastConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 10, - } - profile := &CTVVastConfig{ - VastVersionDefault: "4.2", - MaxAdsInPod: 3, - SelectionStrategy: "min_duration", - } - - result := MergeCTVVastConfig(host, account, profile) - - assert.Equal(t, "GAM_SSU", result.Receiver) // from host - assert.Equal(t, "USD", result.DefaultCurrency) // from account - assert.Equal(t, "4.2", result.VastVersionDefault) // overridden by profile - assert.Equal(t, 3, result.MaxAdsInPod) // overridden by profile - assert.Equal(t, "min_duration", result.SelectionStrategy) // overridden by profile -} - -func TestMergeCTVVastConfig_BoolPointers(t *testing.T) { - trueVal := true - falseVal := false - - host := &CTVVastConfig{ - Enabled: &trueVal, - Debug: &falseVal, - } - account := &CTVVastConfig{ - Debug: &trueVal, - } - profile := &CTVVastConfig{ - Enabled: &falseVal, - } - - result := MergeCTVVastConfig(host, account, profile) - - assert.NotNil(t, result.Enabled) - assert.False(t, *result.Enabled) // overridden by profile - assert.NotNil(t, result.Debug) - assert.True(t, *result.Debug) // from account (profile didn't set it) -} - -func TestMergeCTVVastConfig_PlacementRules(t *testing.T) { - floor := 1.5 - ceiling := 50.0 - profileFloor := 2.0 - - host := &CTVVastConfig{ - Placement: &PlacementRulesConfig{ - Pricing: &PricingRulesConfig{ - FloorCPM: &floor, - CeilingCPM: &ceiling, - Currency: "EUR", - }, - Advertiser: &AdvertiserRulesConfig{ - BlockedDomains: []string{"blocked.com"}, - }, - }, - } - account := &CTVVastConfig{ - Placement: &PlacementRulesConfig{ - Advertiser: &AdvertiserRulesConfig{ - BlockedDomains: []string{"account-blocked.com"}, - }, - Categories: &CategoryRulesConfig{ - BlockedCategories: []string{"IAB25"}, - }, - }, - } - profile := &CTVVastConfig{ - Placement: &PlacementRulesConfig{ - Pricing: &PricingRulesConfig{ - FloorCPM: &profileFloor, - }, - }, - } - - result := MergeCTVVastConfig(host, account, profile) - - assert.NotNil(t, result.Placement) - assert.NotNil(t, result.Placement.Pricing) - assert.Equal(t, 2.0, *result.Placement.Pricing.FloorCPM) // from profile - assert.Equal(t, 50.0, *result.Placement.Pricing.CeilingCPM) // from host - assert.Equal(t, "EUR", result.Placement.Pricing.Currency) // from host - - assert.NotNil(t, result.Placement.Advertiser) - assert.Equal(t, []string{"account-blocked.com"}, result.Placement.Advertiser.BlockedDomains) // from account - - assert.NotNil(t, result.Placement.Categories) - assert.Equal(t, []string{"IAB25"}, result.Placement.Categories.BlockedCategories) // from account -} - -func TestReceiverConfig_Defaults(t *testing.T) { - cfg := CTVVastConfig{} - rc := cfg.ReceiverConfig() - - assert.Equal(t, ReceiverType("GAM_SSU"), rc.Receiver) - assert.Equal(t, "USD", rc.DefaultCurrency) - assert.Equal(t, "3.0", rc.VastVersionDefault) - assert.Equal(t, 10, rc.MaxAdsInPod) - assert.Equal(t, SelectionStrategy("max_revenue"), rc.SelectionStrategy) - assert.Equal(t, CollisionPolicy("VAST_WINS"), rc.CollisionPolicy) - assert.False(t, rc.Debug) -} - -func TestReceiverConfig_WithValues(t *testing.T) { - debug := true - cfg := CTVVastConfig{ - Receiver: "GENERIC", - DefaultCurrency: "EUR", - VastVersionDefault: "4.2", - MaxAdsInPod: 7, - SelectionStrategy: "balanced", - CollisionPolicy: "warn", - Debug: &debug, - } - rc := cfg.ReceiverConfig() - - assert.Equal(t, ReceiverType("GENERIC"), rc.Receiver) - assert.Equal(t, "EUR", rc.DefaultCurrency) - assert.Equal(t, "4.2", rc.VastVersionDefault) - assert.Equal(t, 7, rc.MaxAdsInPod) - assert.Equal(t, SelectionStrategy("balanced"), rc.SelectionStrategy) - assert.Equal(t, CollisionPolicy("warn"), rc.CollisionPolicy) - assert.True(t, rc.Debug) -} - -func TestReceiverConfig_PlacementRules(t *testing.T) { - floor := 1.5 - ceiling := 100.0 - debug := true - - cfg := CTVVastConfig{ - Placement: &PlacementRulesConfig{ - Pricing: &PricingRulesConfig{ - FloorCPM: &floor, - CeilingCPM: &ceiling, - Currency: "EUR", - }, - Advertiser: &AdvertiserRulesConfig{ - BlockedDomains: []string{"blocked.com", "spam.com"}, - AllowedDomains: []string{"allowed.com"}, - }, - Categories: &CategoryRulesConfig{ - BlockedCategories: []string{"IAB25", "IAB26"}, - AllowedCategories: []string{"IAB1"}, - }, - Debug: &debug, - }, - } - rc := cfg.ReceiverConfig() - - assert.Equal(t, 1.5, rc.Placement.Pricing.FloorCPM) - assert.Equal(t, 100.0, rc.Placement.Pricing.CeilingCPM) - assert.Equal(t, "EUR", rc.Placement.Pricing.Currency) - - assert.Equal(t, []string{"blocked.com", "spam.com"}, rc.Placement.Advertiser.BlockedDomains) - assert.Equal(t, []string{"allowed.com"}, rc.Placement.Advertiser.AllowedDomains) - - assert.Equal(t, []string{"IAB25", "IAB26"}, rc.Placement.Categories.BlockedCategories) - assert.Equal(t, []string{"IAB1"}, rc.Placement.Categories.AllowedCategories) - - assert.True(t, rc.Placement.Debug) -} - -func TestReceiverConfig_PlacementPricingDefaultCurrency(t *testing.T) { - floor := 1.0 - cfg := CTVVastConfig{ - Placement: &PlacementRulesConfig{ - Pricing: &PricingRulesConfig{ - FloorCPM: &floor, - // Currency not set - }, - }, - } - rc := cfg.ReceiverConfig() - - assert.Equal(t, "USD", rc.Placement.Pricing.Currency) -} - -func TestIsEnabled(t *testing.T) { - tests := []struct { - name string - enabled *bool - expected bool - }{ - { - name: "nil returns false", - enabled: nil, - expected: false, - }, - { - name: "true returns true", - enabled: boolPtr(true), - expected: true, - }, - { - name: "false returns false", - enabled: boolPtr(false), - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := CTVVastConfig{Enabled: tt.enabled} - assert.Equal(t, tt.expected, cfg.IsEnabled()) - }) - } -} - -func TestMergeCTVVastConfig_FullLayerPrecedence(t *testing.T) { - // This test verifies the complete layering behavior: - // profile > account > host - - host := &CTVVastConfig{ - Receiver: "GAM_SSU", - DefaultCurrency: "GBP", - VastVersionDefault: "3.0", - MaxAdsInPod: 5, - SelectionStrategy: "max_revenue", - CollisionPolicy: "reject", - Enabled: boolPtr(true), - Debug: boolPtr(false), - Placement: &PlacementRulesConfig{ - Pricing: &PricingRulesConfig{ - FloorCPM: float64Ptr(1.0), - CeilingCPM: float64Ptr(100.0), - Currency: "GBP", - }, - Advertiser: &AdvertiserRulesConfig{ - BlockedDomains: []string{"host-blocked.com"}, - }, - }, - } - - account := &CTVVastConfig{ - DefaultCurrency: "EUR", - MaxAdsInPod: 8, - CollisionPolicy: "warn", - Placement: &PlacementRulesConfig{ - Pricing: &PricingRulesConfig{ - FloorCPM: float64Ptr(2.0), - Currency: "EUR", - }, - Categories: &CategoryRulesConfig{ - BlockedCategories: []string{"IAB25"}, - }, - }, - } - - profile := &CTVVastConfig{ - VastVersionDefault: "4.2", - MaxAdsInPod: 3, - Debug: boolPtr(true), - Placement: &PlacementRulesConfig{ - Pricing: &PricingRulesConfig{ - FloorCPM: float64Ptr(3.0), - }, - }, - } - - result := MergeCTVVastConfig(host, account, profile) - - // Verify precedence - assert.Equal(t, "GAM_SSU", result.Receiver) // host (only set there) - assert.Equal(t, "EUR", result.DefaultCurrency) // account overrides host - assert.Equal(t, "4.2", result.VastVersionDefault) // profile overrides host - assert.Equal(t, 3, result.MaxAdsInPod) // profile overrides account and host - assert.Equal(t, "max_revenue", result.SelectionStrategy) // host (only set there) - assert.Equal(t, "warn", result.CollisionPolicy) // account overrides host - assert.True(t, *result.Enabled) // host (only set there) - assert.True(t, *result.Debug) // profile overrides host - - // Verify nested placement rules precedence - assert.Equal(t, 3.0, *result.Placement.Pricing.FloorCPM) // profile overrides account and host - assert.Equal(t, 100.0, *result.Placement.Pricing.CeilingCPM) // host (only set there) - assert.Equal(t, "EUR", result.Placement.Pricing.Currency) // account overrides host - - assert.Equal(t, []string{"host-blocked.com"}, result.Placement.Advertiser.BlockedDomains) // host - assert.Equal(t, []string{"IAB25"}, result.Placement.Categories.BlockedCategories) // account -} - -func TestMergeCTVVastConfig_EmptyStringsDoNotOverride(t *testing.T) { - host := &CTVVastConfig{ - Receiver: "GAM_SSU", - DefaultCurrency: "EUR", - } - account := &CTVVastConfig{ - Receiver: "", // empty string should not override - DefaultCurrency: "USD", - } - - result := MergeCTVVastConfig(host, account, nil) - - assert.Equal(t, "GAM_SSU", result.Receiver) // empty string didn't override - assert.Equal(t, "USD", result.DefaultCurrency) // non-empty string did override -} - -func TestMergeCTVVastConfig_ZeroIntDoesNotOverride(t *testing.T) { - host := &CTVVastConfig{ - MaxAdsInPod: 5, - } - account := &CTVVastConfig{ - MaxAdsInPod: 0, // zero should not override - } - - result := MergeCTVVastConfig(host, account, nil) - - assert.Equal(t, 5, result.MaxAdsInPod) // zero didn't override -} - -func TestBoolPtr(t *testing.T) { - truePtr := boolPtr(true) - falsePtr := boolPtr(false) - - assert.NotNil(t, truePtr) - assert.True(t, *truePtr) - assert.NotNil(t, falsePtr) - assert.False(t, *falsePtr) -} - -func TestFloat64Ptr(t *testing.T) { - ptr := float64Ptr(1.5) - assert.NotNil(t, ptr) - assert.Equal(t, 1.5, *ptr) -} diff --git a/modules/ctv/vast/enrich/enrich.go b/modules/ctv/vast/enrich/enrich.go deleted file mode 100644 index ed4f1704985..00000000000 --- a/modules/ctv/vast/enrich/enrich.go +++ /dev/null @@ -1,264 +0,0 @@ -// Package enrich provides VAST ad enrichment capabilities. -package enrich - -import ( - "fmt" - "strings" - - "github.com/prebid/prebid-server/v3/modules/ctv/vast" - "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" -) - -// VastEnricher implements the Enricher interface. -// It uses CollisionPolicy "VAST_WINS" - existing VAST values are not overwritten. -type VastEnricher struct{} - -// NewEnricher creates a new VastEnricher instance. -func NewEnricher() *VastEnricher { - return &VastEnricher{} -} - -// Enrich adds tracking, extensions, and other data to a VAST ad. -// It implements the vast.Enricher interface. -// CollisionPolicy "VAST_WINS": existing values in VAST are preserved. -func (e *VastEnricher) Enrich(ad *model.Ad, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) ([]string, error) { - var warnings []string - - if ad == nil { - return warnings, nil - } - - // Only enrich InLine ads, not Wrapper ads - if ad.InLine == nil { - warnings = append(warnings, "skipping enrichment: ad is not InLine") - return warnings, nil - } - - inline := ad.InLine - - // Ensure Extensions exists for adding extension-based enrichments - if inline.Extensions == nil { - inline.Extensions = &model.Extensions{} - } - - // Enrich Pricing - pricingWarnings := e.enrichPricing(inline, meta, cfg) - warnings = append(warnings, pricingWarnings...) - - // Enrich Advertiser - advertiserWarnings := e.enrichAdvertiser(inline, meta, cfg) - warnings = append(warnings, advertiserWarnings...) - - // Enrich Duration - durationWarnings := e.enrichDuration(inline, meta) - warnings = append(warnings, durationWarnings...) - - // Enrich Categories (always as extension) - categoryWarnings := e.enrichCategories(inline, meta) - warnings = append(warnings, categoryWarnings...) - - // Add debug extension if enabled - if cfg.Debug || cfg.Placement.Debug { - e.addDebugExtension(inline, meta) - } - - return warnings, nil -} - -// enrichPricing adds pricing information if not present. -// VAST_WINS: only adds if InLine.Pricing is nil or empty. -func (e *VastEnricher) enrichPricing(inline *model.InLine, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) []string { - var warnings []string - - // Skip if no price to add - if meta.Price <= 0 { - return warnings - } - - // Check collision policy - VAST_WINS means don't overwrite existing - if inline.Pricing != nil && inline.Pricing.Value != "" { - warnings = append(warnings, "pricing: VAST_WINS - keeping existing pricing") - return warnings - } - - // Format the price value - priceStr := formatPrice(meta.Price) - currency := meta.Currency - if currency == "" { - currency = cfg.DefaultCurrency - } - if currency == "" { - currency = "USD" - } - - // Determine placement location - placement := cfg.Placement.PricingPlacement - if placement == "" { - placement = vast.PlacementVastPricing - } - - switch placement { - case vast.PlacementVastPricing: - inline.Pricing = &model.Pricing{ - Model: "CPM", - Currency: currency, - Value: priceStr, - } - case vast.PlacementExtension: - ext := model.ExtensionXML{ - Type: "pricing", - InnerXML: fmt.Sprintf("%s", currency, priceStr), - } - inline.Extensions.Extension = append(inline.Extensions.Extension, ext) - default: - // Default to VAST_PRICING - inline.Pricing = &model.Pricing{ - Model: "CPM", - Currency: currency, - Value: priceStr, - } - } - - return warnings -} - -// enrichAdvertiser adds advertiser information if not present. -// VAST_WINS: only adds if InLine.Advertiser is empty. -func (e *VastEnricher) enrichAdvertiser(inline *model.InLine, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) []string { - var warnings []string - - // Skip if no advertiser to add - if meta.Adomain == "" { - return warnings - } - - // Check collision policy - VAST_WINS means don't overwrite existing - if strings.TrimSpace(inline.Advertiser) != "" { - warnings = append(warnings, "advertiser: VAST_WINS - keeping existing advertiser") - return warnings - } - - // Determine placement location - placement := cfg.Placement.AdvertiserPlacement - if placement == "" { - placement = vast.PlacementAdvertiserTag - } - - switch placement { - case vast.PlacementAdvertiserTag: - inline.Advertiser = meta.Adomain - case vast.PlacementExtension: - ext := model.ExtensionXML{ - Type: "advertiser", - InnerXML: fmt.Sprintf("%s", escapeXML(meta.Adomain)), - } - inline.Extensions.Extension = append(inline.Extensions.Extension, ext) - default: - // Default to ADVERTISER_TAG - inline.Advertiser = meta.Adomain - } - - return warnings -} - -// enrichDuration adds duration to Linear creative if not present. -// VAST_WINS: only adds if Linear.Duration is empty. -func (e *VastEnricher) enrichDuration(inline *model.InLine, meta vast.CanonicalMeta) []string { - var warnings []string - - // Skip if no duration to add - if meta.DurSec <= 0 { - return warnings - } - - // Find the Linear creative - if inline.Creatives == nil || len(inline.Creatives.Creative) == 0 { - return warnings - } - - for i := range inline.Creatives.Creative { - creative := &inline.Creatives.Creative[i] - if creative.Linear == nil { - continue - } - - // Check collision policy - VAST_WINS means don't overwrite existing - if strings.TrimSpace(creative.Linear.Duration) != "" { - warnings = append(warnings, "duration: VAST_WINS - keeping existing duration") - continue - } - - // Set duration in HH:MM:SS format - creative.Linear.Duration = model.SecToHHMMSS(meta.DurSec) - } - - return warnings -} - -// enrichCategories adds IAB categories as an extension. -func (e *VastEnricher) enrichCategories(inline *model.InLine, meta vast.CanonicalMeta) []string { - var warnings []string - - // Skip if no categories to add - if len(meta.Cats) == 0 { - return warnings - } - - // Build category extension XML - var categoryXML strings.Builder - for _, cat := range meta.Cats { - categoryXML.WriteString(fmt.Sprintf("%s", escapeXML(cat))) - } - - ext := model.ExtensionXML{ - Type: "iab_category", - InnerXML: categoryXML.String(), - } - inline.Extensions.Extension = append(inline.Extensions.Extension, ext) - - return warnings -} - -// addDebugExtension adds OpenRTB debug information as an extension. -func (e *VastEnricher) addDebugExtension(inline *model.InLine, meta vast.CanonicalMeta) { - var debugXML strings.Builder - debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.BidID))) - debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.ImpID))) - if meta.DealID != "" { - debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.DealID))) - } - debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.Seat))) - debugXML.WriteString(fmt.Sprintf("%s", formatPrice(meta.Price))) - debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.Currency))) - - ext := model.ExtensionXML{ - Type: "openrtb", - InnerXML: debugXML.String(), - } - inline.Extensions.Extension = append(inline.Extensions.Extension, ext) -} - -// formatPrice formats a price value with appropriate precision. -func formatPrice(price float64) string { - // Use up to 4 decimal places, trimming trailing zeros - s := fmt.Sprintf("%.4f", price) - s = strings.TrimRight(s, "0") - s = strings.TrimRight(s, ".") - if s == "" { - return "0" - } - return s -} - -// escapeXML escapes special characters for XML content. -func escapeXML(s string) string { - s = strings.ReplaceAll(s, "&", "&") - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, ">", ">") - s = strings.ReplaceAll(s, "\"", """) - s = strings.ReplaceAll(s, "'", "'") - return s -} - -// Ensure VastEnricher implements Enricher interface. -var _ vast.Enricher = (*VastEnricher)(nil) diff --git a/modules/ctv/vast/enrich/enrich_test.go b/modules/ctv/vast/enrich/enrich_test.go deleted file mode 100644 index fca7d098100..00000000000 --- a/modules/ctv/vast/enrich/enrich_test.go +++ /dev/null @@ -1,672 +0,0 @@ -package enrich - -import ( - "testing" - - "github.com/prebid/prebid-server/v3/modules/ctv/vast" - "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewEnricher(t *testing.T) { - enricher := NewEnricher() - assert.NotNil(t, enricher) -} - -func TestEnrich_NilAd(t *testing.T) { - enricher := NewEnricher() - meta := vast.CanonicalMeta{} - cfg := vast.ReceiverConfig{} - - warnings, err := enricher.Enrich(nil, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) -} - -func TestEnrich_WrapperAd(t *testing.T) { - enricher := NewEnricher() - ad := &model.Ad{ - ID: "wrapper", - Wrapper: &model.Wrapper{}, - } - meta := vast.CanonicalMeta{Price: 5.0} - cfg := vast.ReceiverConfig{} - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - require.Len(t, warnings, 1) - assert.Contains(t, warnings[0], "not InLine") -} - -func TestEnrich_Pricing_VastWins_ExistingNotOverwritten(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Pricing = &model.Pricing{ - Model: "CPM", - Currency: "EUR", - Value: "10.00", - } - - meta := vast.CanonicalMeta{ - Price: 5.0, - Currency: "USD", - } - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - Placement: vast.PlacementRules{ - PricingPlacement: vast.PlacementVastPricing, - }, - } - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - - // Should have warning about VAST_WINS - require.Len(t, warnings, 1) - assert.Contains(t, warnings[0], "VAST_WINS") - - // Original pricing should be preserved - assert.Equal(t, "EUR", ad.InLine.Pricing.Currency) - assert.Equal(t, "10.00", ad.InLine.Pricing.Value) -} - -func TestEnrich_Pricing_AddedWhenMissing(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Pricing = nil - - meta := vast.CanonicalMeta{ - Price: 5.5, - Currency: "USD", - } - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - Placement: vast.PlacementRules{ - PricingPlacement: vast.PlacementVastPricing, - }, - } - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - - // Pricing should be added - require.NotNil(t, ad.InLine.Pricing) - assert.Equal(t, "CPM", ad.InLine.Pricing.Model) - assert.Equal(t, "USD", ad.InLine.Pricing.Currency) - assert.Equal(t, "5.5", ad.InLine.Pricing.Value) -} - -func TestEnrich_Pricing_AsExtension(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Pricing = nil - - meta := vast.CanonicalMeta{ - Price: 3.25, - Currency: "EUR", - } - cfg := vast.ReceiverConfig{ - Placement: vast.PlacementRules{ - PricingPlacement: vast.PlacementExtension, - }, - } - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - - // Pricing should be nil (not added to VAST element) - assert.Nil(t, ad.InLine.Pricing) - - // Should have extension with pricing - require.NotNil(t, ad.InLine.Extensions) - found := false - for _, ext := range ad.InLine.Extensions.Extension { - if ext.Type == "pricing" { - found = true - assert.Contains(t, ext.InnerXML, "3.25") - assert.Contains(t, ext.InnerXML, "EUR") - assert.Contains(t, ext.InnerXML, "CPM") - } - } - assert.True(t, found, "pricing extension not found") -} - -func TestEnrich_Pricing_ZeroPriceNotAdded(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Pricing = nil - - meta := vast.CanonicalMeta{ - Price: 0, - } - cfg := vast.ReceiverConfig{} - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - assert.Nil(t, ad.InLine.Pricing) -} - -func TestEnrich_Advertiser_VastWins_ExistingNotOverwritten(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Advertiser = "Original Advertiser" - - meta := vast.CanonicalMeta{ - Adomain: "newadvertiser.com", - } - cfg := vast.ReceiverConfig{ - Placement: vast.PlacementRules{ - AdvertiserPlacement: vast.PlacementAdvertiserTag, - }, - } - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - - // Should have warning about VAST_WINS - require.Len(t, warnings, 1) - assert.Contains(t, warnings[0], "VAST_WINS") - - // Original advertiser should be preserved - assert.Equal(t, "Original Advertiser", ad.InLine.Advertiser) -} - -func TestEnrich_Advertiser_AddedWhenMissing(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Advertiser = "" - - meta := vast.CanonicalMeta{ - Adomain: "example.com", - } - cfg := vast.ReceiverConfig{ - Placement: vast.PlacementRules{ - AdvertiserPlacement: vast.PlacementAdvertiserTag, - }, - } - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - assert.Equal(t, "example.com", ad.InLine.Advertiser) -} - -func TestEnrich_Advertiser_AsExtension(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Advertiser = "" - - meta := vast.CanonicalMeta{ - Adomain: "example.com", - } - cfg := vast.ReceiverConfig{ - Placement: vast.PlacementRules{ - AdvertiserPlacement: vast.PlacementExtension, - }, - } - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - - // Advertiser tag should be empty - assert.Equal(t, "", ad.InLine.Advertiser) - - // Should have extension with advertiser - require.NotNil(t, ad.InLine.Extensions) - found := false - for _, ext := range ad.InLine.Extensions.Extension { - if ext.Type == "advertiser" { - found = true - assert.Contains(t, ext.InnerXML, "example.com") - } - } - assert.True(t, found, "advertiser extension not found") -} - -func TestEnrich_Duration_VastWins_ExistingNotOverwritten(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Creatives.Creative[0].Linear.Duration = "00:00:30" - - meta := vast.CanonicalMeta{ - DurSec: 15, - } - cfg := vast.ReceiverConfig{} - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - - // Should have warning about VAST_WINS - require.Len(t, warnings, 1) - assert.Contains(t, warnings[0], "VAST_WINS") - - // Original duration should be preserved - assert.Equal(t, "00:00:30", ad.InLine.Creatives.Creative[0].Linear.Duration) -} - -func TestEnrich_Duration_AddedWhenMissing(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Creatives.Creative[0].Linear.Duration = "" - - meta := vast.CanonicalMeta{ - DurSec: 15, - } - cfg := vast.ReceiverConfig{} - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - assert.Equal(t, "00:00:15", ad.InLine.Creatives.Creative[0].Linear.Duration) -} - -func TestEnrich_Duration_ZeroNotAdded(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Creatives.Creative[0].Linear.Duration = "" - - meta := vast.CanonicalMeta{ - DurSec: 0, - } - cfg := vast.ReceiverConfig{} - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - assert.Equal(t, "", ad.InLine.Creatives.Creative[0].Linear.Duration) -} - -func TestEnrich_Categories_AddedAsExtension(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - - meta := vast.CanonicalMeta{ - Cats: []string{"IAB1", "IAB2-1", "IAB3"}, - } - cfg := vast.ReceiverConfig{} - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - - // Should have extension with categories - require.NotNil(t, ad.InLine.Extensions) - found := false - for _, ext := range ad.InLine.Extensions.Extension { - if ext.Type == "iab_category" { - found = true - assert.Contains(t, ext.InnerXML, "IAB1") - assert.Contains(t, ext.InnerXML, "IAB2-1") - assert.Contains(t, ext.InnerXML, "IAB3") - } - } - assert.True(t, found, "iab_category extension not found") -} - -func TestEnrich_Categories_EmptyNotAdded(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - - meta := vast.CanonicalMeta{ - Cats: []string{}, - } - cfg := vast.ReceiverConfig{} - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - - // Should not have category extension - if ad.InLine.Extensions != nil { - for _, ext := range ad.InLine.Extensions.Extension { - assert.NotEqual(t, "iab_category", ext.Type) - } - } -} - -func TestEnrich_DebugExtension(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - - meta := vast.CanonicalMeta{ - BidID: "bid123", - ImpID: "imp456", - DealID: "deal789", - Seat: "bidder1", - Price: 2.5, - Currency: "USD", - } - cfg := vast.ReceiverConfig{ - Debug: true, - } - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - - // Should have openrtb debug extension - require.NotNil(t, ad.InLine.Extensions) - found := false - for _, ext := range ad.InLine.Extensions.Extension { - if ext.Type == "openrtb" { - found = true - assert.Contains(t, ext.InnerXML, "bid123") - assert.Contains(t, ext.InnerXML, "imp456") - assert.Contains(t, ext.InnerXML, "deal789") - assert.Contains(t, ext.InnerXML, "bidder1") - assert.Contains(t, ext.InnerXML, "2.5") - assert.Contains(t, ext.InnerXML, "USD") - } - } - assert.True(t, found, "openrtb extension not found") -} - -func TestEnrich_DebugExtension_NoDealID(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - - meta := vast.CanonicalMeta{ - BidID: "bid123", - ImpID: "imp456", - DealID: "", // No deal - Seat: "bidder1", - Price: 2.5, - Currency: "USD", - } - cfg := vast.ReceiverConfig{ - Debug: true, - } - - _, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - - // Should have openrtb debug extension without DealID - require.NotNil(t, ad.InLine.Extensions) - for _, ext := range ad.InLine.Extensions.Extension { - if ext.Type == "openrtb" { - assert.NotContains(t, ext.InnerXML, "") - } - } -} - -func TestEnrich_DebugExtension_PlacementDebug(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - - meta := vast.CanonicalMeta{ - BidID: "bid123", - } - cfg := vast.ReceiverConfig{ - Debug: false, // Global debug off - Placement: vast.PlacementRules{ - Debug: true, // Placement debug on - }, - } - - _, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - - // Should have openrtb debug extension - found := false - for _, ext := range ad.InLine.Extensions.Extension { - if ext.Type == "openrtb" { - found = true - } - } - assert.True(t, found, "openrtb extension not found when placement debug enabled") -} - -func TestEnrich_FullEnrichment(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Pricing = nil - ad.InLine.Advertiser = "" - ad.InLine.Creatives.Creative[0].Linear.Duration = "" - - meta := vast.CanonicalMeta{ - BidID: "bid123", - ImpID: "imp456", - Seat: "bidder1", - Price: 5.5, - Currency: "USD", - Adomain: "advertiser.com", - Cats: []string{"IAB1", "IAB2"}, - DurSec: 30, - } - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - Debug: true, - Placement: vast.PlacementRules{ - PricingPlacement: vast.PlacementVastPricing, - AdvertiserPlacement: vast.PlacementAdvertiserTag, - }, - } - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - - // Check all enrichments - require.NotNil(t, ad.InLine.Pricing) - assert.Equal(t, "5.5", ad.InLine.Pricing.Value) - assert.Equal(t, "advertiser.com", ad.InLine.Advertiser) - assert.Equal(t, "00:00:30", ad.InLine.Creatives.Creative[0].Linear.Duration) - - // Check extensions - require.NotNil(t, ad.InLine.Extensions) - hasCategory := false - hasOpenRTB := false - for _, ext := range ad.InLine.Extensions.Extension { - if ext.Type == "iab_category" { - hasCategory = true - } - if ext.Type == "openrtb" { - hasOpenRTB = true - } - } - assert.True(t, hasCategory) - assert.True(t, hasOpenRTB) -} - -func TestFormatPrice(t *testing.T) { - tests := []struct { - price float64 - expected string - }{ - {0, "0"}, - {1, "1"}, - {1.5, "1.5"}, - {1.50, "1.5"}, - {1.55, "1.55"}, - {1.555, "1.555"}, - {1.5555, "1.5555"}, - {1.55555, "1.5555"}, // Truncates to 4 decimals - {10.00, "10"}, - {0.001, "0.001"}, - {0.0001, "0.0001"}, - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - result := formatPrice(tt.price) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestEscapeXML(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"simple", "simple"}, - {"a & b", "a & b"}, - {"", "<tag>"}, - {`"quoted"`, ""quoted""}, - {"it's", "it's"}, - {"", "<a & 'b'>"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := escapeXML(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestEnrich_XMLMarshalRoundTrip(t *testing.T) { - enricher := NewEnricher() - - // Parse sample VAST - sampleVAST := ` - - - - Test - Test Ad - - - - - - - - - - - - - -` - - parsedVast, err := model.ParseVastAdm(sampleVAST) - require.NoError(t, err) - require.Len(t, parsedVast.Ads, 1) - - ad := &parsedVast.Ads[0] - meta := vast.CanonicalMeta{ - BidID: "bid123", - ImpID: "imp456", - Price: 5.0, - Currency: "USD", - Adomain: "advertiser.com", - Cats: []string{"IAB1"}, - DurSec: 30, - } - cfg := vast.ReceiverConfig{ - Debug: true, - } - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - - // Marshal back to XML - xmlBytes, err := parsedVast.Marshal() - require.NoError(t, err) - - xmlStr := string(xmlBytes) - assert.Contains(t, xmlStr, "Pricing") - assert.Contains(t, xmlStr, "advertiser.com") - assert.Contains(t, xmlStr, "00:00:30") - assert.Contains(t, xmlStr, "iab_category") - assert.Contains(t, xmlStr, "openrtb") -} - -// createTestAd creates a test Ad with InLine and Linear creative -func createTestAd() *model.Ad { - return &model.Ad{ - ID: "test-ad", - InLine: &model.InLine{ - AdSystem: &model.AdSystem{Value: "Test"}, - AdTitle: "Test Ad", - Creatives: &model.Creatives{ - Creative: []model.Creative{ - { - ID: "creative1", - Linear: &model.Linear{ - Duration: "", - }, - }, - }, - }, - }, - } -} - -func TestEnrich_ExistingExtensionsPreserved(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Extensions = &model.Extensions{ - Extension: []model.ExtensionXML{ - {Type: "existing", InnerXML: "preserved"}, - }, - } - - meta := vast.CanonicalMeta{ - Cats: []string{"IAB1"}, - } - cfg := vast.ReceiverConfig{} - - warnings, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - assert.Empty(t, warnings) - - // Should have both existing and new extensions - require.NotNil(t, ad.InLine.Extensions) - assert.GreaterOrEqual(t, len(ad.InLine.Extensions.Extension), 2) - - // Check existing is preserved - found := false - for _, ext := range ad.InLine.Extensions.Extension { - if ext.Type == "existing" { - found = true - assert.Contains(t, ext.InnerXML, "preserved") - } - } - assert.True(t, found, "existing extension should be preserved") -} - -func TestEnrich_DefaultCurrencyFallback(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Pricing = nil - - meta := vast.CanonicalMeta{ - Price: 5.0, - Currency: "", // No currency in meta - } - cfg := vast.ReceiverConfig{ - DefaultCurrency: "GBP", - } - - _, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - require.NotNil(t, ad.InLine.Pricing) - assert.Equal(t, "GBP", ad.InLine.Pricing.Currency) -} - -func TestEnrich_NoCurrencyDefaultsToUSD(t *testing.T) { - enricher := NewEnricher() - ad := createTestAd() - ad.InLine.Pricing = nil - - meta := vast.CanonicalMeta{ - Price: 5.0, - Currency: "", // No currency - } - cfg := vast.ReceiverConfig{ - DefaultCurrency: "", // No default either - } - - _, err := enricher.Enrich(ad, meta, cfg) - assert.NoError(t, err) - require.NotNil(t, ad.InLine.Pricing) - assert.Equal(t, "USD", ad.InLine.Pricing.Currency) -} diff --git a/modules/ctv/vast/format/format.go b/modules/ctv/vast/format/format.go deleted file mode 100644 index 1a7ad9426cb..00000000000 --- a/modules/ctv/vast/format/format.go +++ /dev/null @@ -1,114 +0,0 @@ -// Package format provides VAST XML formatting capabilities. -package format - -import ( - "encoding/xml" - - "github.com/prebid/prebid-server/v3/modules/ctv/vast" - "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" -) - -// VastFormatter implements the Formatter interface for GAM_SSU and other receivers. -type VastFormatter struct{} - -// NewFormatter creates a new VastFormatter instance. -func NewFormatter() *VastFormatter { - return &VastFormatter{} -} - -// Format converts enriched VAST ads into XML output. -// It implements the vast.Formatter interface. -// -// For each EnrichedAd, it creates one element with: -// - id attribute from meta.AdID if available, else meta.BidID -// - sequence attribute from EnrichedAd.Sequence (if multiple ads) -// - The enriched InLine subtree from the ad -func (f *VastFormatter) Format(ads []vast.EnrichedAd, cfg vast.ReceiverConfig) ([]byte, []string, error) { - var warnings []string - - // Determine VAST version - version := cfg.VastVersionDefault - if version == "" { - version = "4.0" - } - - // Handle no-ad case - if len(ads) == 0 { - noAdXML := model.BuildNoAdVast(version) - return noAdXML, warnings, nil - } - - // Build the VAST document - vastDoc := model.Vast{ - Version: version, - Ads: make([]model.Ad, 0, len(ads)), - } - - isPod := len(ads) > 1 - - for _, enriched := range ads { - if enriched.Ad == nil { - warnings = append(warnings, "skipping nil ad in format") - continue - } - - // Create a copy of the ad to avoid modifying the original - ad := copyAd(enriched.Ad) - - // Set Ad.ID from meta (prefer AdID if tracked, else BidID) - ad.ID = deriveAdID(enriched.Meta) - - // Set sequence attribute for pods (multiple ads) - if isPod && enriched.Sequence > 0 { - ad.Sequence = enriched.Sequence - } else if !isPod { - ad.Sequence = 0 // Don't set sequence for single ad - } - - vastDoc.Ads = append(vastDoc.Ads, *ad) - } - - // Handle case where all ads were nil - if len(vastDoc.Ads) == 0 { - noAdXML := model.BuildNoAdVast(version) - warnings = append(warnings, "all ads were nil, returning no-ad VAST") - return noAdXML, warnings, nil - } - - // Marshal with indentation - xmlBytes, err := xml.MarshalIndent(vastDoc, "", " ") - if err != nil { - return nil, warnings, err - } - - // Add XML declaration - output := append([]byte(xml.Header), xmlBytes...) - - return output, warnings, nil -} - -// deriveAdID determines the Ad ID from metadata. -// Uses BidID as the identifier (AdID is not currently tracked in CanonicalMeta). -func deriveAdID(meta vast.CanonicalMeta) string { - // BidID is the primary identifier - if meta.BidID != "" { - return meta.BidID - } - // Fallback to ImpID if BidID is empty - if meta.ImpID != "" { - return "imp-" + meta.ImpID - } - return "" -} - -// copyAd creates a shallow copy of an Ad to avoid modifying the original. -func copyAd(src *model.Ad) *model.Ad { - if src == nil { - return nil - } - ad := *src - return &ad -} - -// Ensure VastFormatter implements Formatter interface. -var _ vast.Formatter = (*VastFormatter)(nil) diff --git a/modules/ctv/vast/format/format_test.go b/modules/ctv/vast/format/format_test.go deleted file mode 100644 index 86b404ac5e4..00000000000 --- a/modules/ctv/vast/format/format_test.go +++ /dev/null @@ -1,488 +0,0 @@ -package format - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/prebid/prebid-server/v3/modules/ctv/vast" - "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewFormatter(t *testing.T) { - formatter := NewFormatter() - assert.NotNil(t, formatter) -} - -func TestFormat_EmptyAds_ReturnsNoAdVast(t *testing.T) { - formatter := NewFormatter() - cfg := vast.ReceiverConfig{ - VastVersionDefault: "4.0", - } - - xmlBytes, warnings, err := formatter.Format([]vast.EnrichedAd{}, cfg) - require.NoError(t, err) - assert.Empty(t, warnings) - - expected := loadGolden(t, "no_ad.xml") - assertXMLEqual(t, expected, xmlBytes) -} - -func TestFormat_SingleAd(t *testing.T) { - formatter := NewFormatter() - cfg := vast.ReceiverConfig{ - VastVersionDefault: "4.0", - } - - ads := []vast.EnrichedAd{ - { - Ad: createTestAd("bid-123", "TestAdServer", "Test Ad", "advertiser.com", "5.5", "00:00:30", "creative1", "https://example.com/video.mp4", []string{"IAB1"}), - Meta: vast.CanonicalMeta{BidID: "bid-123"}, - Sequence: 1, - }, - } - - xmlBytes, warnings, err := formatter.Format(ads, cfg) - require.NoError(t, err) - assert.Empty(t, warnings) - - expected := loadGolden(t, "single_ad.xml") - assertXMLEqual(t, expected, xmlBytes) -} - -func TestFormat_PodWithTwoAds(t *testing.T) { - formatter := NewFormatter() - cfg := vast.ReceiverConfig{ - VastVersionDefault: "4.0", - } - - ads := []vast.EnrichedAd{ - { - Ad: createTestAd("bid-001", "TestAdServer", "First Ad", "first.com", "10", "00:00:15", "creative1", "https://example.com/first.mp4", nil), - Meta: vast.CanonicalMeta{BidID: "bid-001"}, - Sequence: 1, - }, - { - Ad: createTestAd("bid-002", "TestAdServer", "Second Ad", "second.com", "8", "00:00:30", "creative2", "https://example.com/second.mp4", nil), - Meta: vast.CanonicalMeta{BidID: "bid-002"}, - Sequence: 2, - }, - } - - xmlBytes, warnings, err := formatter.Format(ads, cfg) - require.NoError(t, err) - assert.Empty(t, warnings) - - expected := loadGolden(t, "pod_two_ads.xml") - assertXMLEqual(t, expected, xmlBytes) -} - -func TestFormat_PodWithThreeAds(t *testing.T) { - formatter := NewFormatter() - cfg := vast.ReceiverConfig{ - VastVersionDefault: "4.0", - } - - ads := []vast.EnrichedAd{ - { - Ad: createMinimalAd("bid-alpha", "AdServer1", "Alpha Ad", "15", "USD", "00:00:10"), - Meta: vast.CanonicalMeta{BidID: "bid-alpha"}, - Sequence: 1, - }, - { - Ad: createMinimalAd("bid-beta", "AdServer2", "Beta Ad", "12", "EUR", "00:00:20"), - Meta: vast.CanonicalMeta{BidID: "bid-beta"}, - Sequence: 2, - }, - { - Ad: createMinimalAd("bid-gamma", "AdServer3", "Gamma Ad", "9", "USD", "00:00:15"), - Meta: vast.CanonicalMeta{BidID: "bid-gamma"}, - Sequence: 3, - }, - } - - xmlBytes, warnings, err := formatter.Format(ads, cfg) - require.NoError(t, err) - assert.Empty(t, warnings) - - expected := loadGolden(t, "pod_three_ads.xml") - assertXMLEqual(t, expected, xmlBytes) -} - -func TestFormat_NilAdsInList(t *testing.T) { - formatter := NewFormatter() - cfg := vast.ReceiverConfig{ - VastVersionDefault: "4.0", - } - - ads := []vast.EnrichedAd{ - { - Ad: nil, // nil ad - Meta: vast.CanonicalMeta{BidID: "bid-nil"}, - Sequence: 1, - }, - { - Ad: createMinimalAd("bid-valid", "AdServer", "Valid Ad", "5", "USD", "00:00:15"), - Meta: vast.CanonicalMeta{BidID: "bid-valid"}, - Sequence: 2, - }, - } - - xmlBytes, warnings, err := formatter.Format(ads, cfg) - require.NoError(t, err) - assert.Len(t, warnings, 1) - assert.Contains(t, warnings[0], "skipping nil ad") - - // Should still produce valid VAST with the non-nil ad - xmlStr := string(xmlBytes) - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, "https://tracker.example.com/start") - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, "https://tracker.example.com/complete") -} - -func TestFormat_PreservesExtensions(t *testing.T) { - formatter := NewFormatter() - cfg := vast.ReceiverConfig{ - VastVersionDefault: "4.0", - } - - ad := createMinimalAd("", "AdServer", "WithExtensions", "5", "USD", "00:00:15") - ad.InLine.Extensions = &model.Extensions{ - Extension: []model.ExtensionXML{ - {Type: "openrtb", InnerXML: "abc123bidder1"}, - {Type: "custom", InnerXML: "custom data"}, - }, - } - - ads := []vast.EnrichedAd{ - { - Ad: ad, - Meta: vast.CanonicalMeta{BidID: "bid-ext"}, - }, - } - - xmlBytes, _, err := formatter.Format(ads, cfg) - require.NoError(t, err) - - xmlStr := string(xmlBytes) - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, "abc123") - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, "custom data") -} - -func TestDeriveAdID(t *testing.T) { - tests := []struct { - name string - meta vast.CanonicalMeta - expected string - }{ - { - name: "with BidID", - meta: vast.CanonicalMeta{BidID: "bid-123"}, - expected: "bid-123", - }, - { - name: "BidID takes precedence over ImpID", - meta: vast.CanonicalMeta{BidID: "bid-456", ImpID: "imp-789"}, - expected: "bid-456", - }, - { - name: "fallback to ImpID when BidID empty", - meta: vast.CanonicalMeta{BidID: "", ImpID: "imp-123"}, - expected: "imp-imp-123", - }, - { - name: "both empty", - meta: vast.CanonicalMeta{BidID: "", ImpID: ""}, - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := deriveAdID(tt.meta) - assert.Equal(t, tt.expected, result) - }) - } -} - -// Helper functions - -func createTestAd(id, adSystem, adTitle, advertiser, price, duration, creativeID, mediaURL string, categories []string) *model.Ad { - ad := &model.Ad{ - ID: id, - InLine: &model.InLine{ - AdSystem: &model.AdSystem{Value: adSystem}, - AdTitle: adTitle, - Advertiser: advertiser, - Pricing: &model.Pricing{ - Model: "CPM", - Currency: "USD", - Value: price, - }, - Creatives: &model.Creatives{ - Creative: []model.Creative{ - { - ID: creativeID, - Linear: &model.Linear{ - Duration: duration, - MediaFiles: &model.MediaFiles{ - MediaFile: []model.MediaFile{ - { - Delivery: "progressive", - Type: "video/mp4", - Width: 1920, - Height: 1080, - Value: mediaURL, - }, - }, - }, - }, - }, - }, - }, - }, - } - - if len(categories) > 0 { - var catXML string - for _, cat := range categories { - catXML += "" + cat + "" - } - ad.InLine.Extensions = &model.Extensions{ - Extension: []model.ExtensionXML{ - {Type: "iab_category", InnerXML: catXML}, - }, - } - } - - return ad -} - -func createMinimalAd(id, adSystem, adTitle, price, currency, duration string) *model.Ad { - return &model.Ad{ - ID: id, - InLine: &model.InLine{ - AdSystem: &model.AdSystem{Value: adSystem}, - AdTitle: adTitle, - Pricing: &model.Pricing{ - Model: "CPM", - Currency: currency, - Value: price, - }, - Creatives: &model.Creatives{ - Creative: []model.Creative{ - { - Linear: &model.Linear{ - Duration: duration, - }, - }, - }, - }, - }, - } -} - -func loadGolden(t *testing.T, filename string) []byte { - t.Helper() - path := filepath.Join("testdata", filename) - data, err := os.ReadFile(path) - require.NoError(t, err, "failed to read golden file: %s", path) - return data -} - -// assertXMLEqual compares two XML documents by normalizing whitespace. -func assertXMLEqual(t *testing.T, expected, actual []byte) { - t.Helper() - expectedNorm := normalizeXML(string(expected)) - actualNorm := normalizeXML(string(actual)) - assert.Equal(t, expectedNorm, actualNorm) -} - -// normalizeXML normalizes XML for comparison by trimming whitespace. -func normalizeXML(xml string) string { - // Split into lines and trim each - lines := strings.Split(xml, "\n") - var normalized []string - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - normalized = append(normalized, trimmed) - } - } - return strings.Join(normalized, "\n") -} diff --git a/modules/ctv/vast/format/testdata/no_ad.xml b/modules/ctv/vast/format/testdata/no_ad.xml deleted file mode 100644 index 1ebd9e11b24..00000000000 --- a/modules/ctv/vast/format/testdata/no_ad.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/modules/ctv/vast/format/testdata/pod_three_ads.xml b/modules/ctv/vast/format/testdata/pod_three_ads.xml deleted file mode 100644 index e48d1591089..00000000000 --- a/modules/ctv/vast/format/testdata/pod_three_ads.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - AdServer1 - Alpha Ad - 15 - - - - 00:00:10 - - - - - - - - AdServer2 - Beta Ad - 12 - - - - 00:00:20 - - - - - - - - AdServer3 - Gamma Ad - 9 - - - - 00:00:15 - - - - - - diff --git a/modules/ctv/vast/format/testdata/pod_two_ads.xml b/modules/ctv/vast/format/testdata/pod_two_ads.xml deleted file mode 100644 index be9c4ef1794..00000000000 --- a/modules/ctv/vast/format/testdata/pod_two_ads.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - TestAdServer - First Ad - first.com - 10 - - - - 00:00:15 - - - - - - - - - - - TestAdServer - Second Ad - second.com - 8 - - - - 00:00:30 - - - - - - - - - diff --git a/modules/ctv/vast/format/testdata/single_ad.xml b/modules/ctv/vast/format/testdata/single_ad.xml deleted file mode 100644 index 28c514798b8..00000000000 --- a/modules/ctv/vast/format/testdata/single_ad.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - TestAdServer - Test Ad - advertiser.com - 5.5 - - - - 00:00:30 - - - - - - - - IAB1 - - - - diff --git a/modules/ctv/vast/handler.go b/modules/ctv/vast/handler.go deleted file mode 100644 index 74b8562ef8a..00000000000 --- a/modules/ctv/vast/handler.go +++ /dev/null @@ -1,167 +0,0 @@ -package vast - -import ( - "context" - "net/http" - - "github.com/prebid/openrtb/v20/openrtb2" -) - -// Handler provides HTTP handling for CTV VAST requests. -type Handler struct { - // Config contains the default receiver configuration. - Config ReceiverConfig - // Selector selects bids from auction response. - Selector BidSelector - // Enricher enriches VAST ads with metadata. - Enricher Enricher - // Formatter formats enriched ads as VAST XML. - Formatter Formatter - // AuctionFunc is called to run the auction pipeline. - // This should be injected with the actual auction implementation. - AuctionFunc func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) -} - -// NewHandler creates a new VAST HTTP handler with default configuration. -// Note: Selector, Enricher, and Formatter must be set via With* methods -// before the handler can process requests. -func NewHandler() *Handler { - return &Handler{ - Config: DefaultConfig(), - } -} - -// ServeHTTP handles GET requests for CTV VAST ads. -// Query parameters (TODO: implement full parsing): -// - pod_id: Pod identifier -// - duration: Requested pod duration -// - max_ads: Maximum ads in pod -// -// Response: -// - 200 OK with Content-Type: application/xml on success -// - 204 No Content if no ads available -// - 400 Bad Request for invalid parameters -// - 500 Internal Server Error for processing failures -func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Only accept GET requests - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Validate required dependencies - if h.Selector == nil || h.Enricher == nil || h.Formatter == nil { - http.Error(w, "Handler not properly configured", http.StatusInternalServerError) - return - } - - // TODO: Parse query parameters and build OpenRTB request - // This is a placeholder for the actual implementation: - // - Parse pod_id, duration, max_ads from query string - // - Build openrtb2.BidRequest with Video imp - // - Apply site/app context from query or headers - bidRequest := h.buildBidRequest(r) - - // TODO: Call auction pipeline - // This is a placeholder - actual implementation would: - // - Call the Prebid Server auction endpoint - // - Get BidResponse from exchange - var bidResponse *openrtb2.BidResponse - var err error - - if h.AuctionFunc != nil { - bidResponse, err = h.AuctionFunc(ctx, bidRequest) - if err != nil { - http.Error(w, "Auction failed: "+err.Error(), http.StatusInternalServerError) - return - } - } else { - // No auction function configured - return no-ad - bidResponse = &openrtb2.BidResponse{} - } - - // Build VAST from bid response - result, err := BuildVastFromBidResponse(ctx, bidRequest, bidResponse, h.Config, h.Selector, h.Enricher, h.Formatter) - if err != nil { - // Log error but still try to return valid VAST - // result.VastXML should contain no-ad VAST - } - - // Set response headers - w.Header().Set("Content-Type", "application/xml; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - - // Handle no-ad case - if result.NoAd { - w.WriteHeader(http.StatusOK) // Still 200 per VAST spec - } - - // Write VAST XML - w.Write(result.VastXML) -} - -// buildBidRequest creates an OpenRTB BidRequest from the HTTP request. -// TODO: Implement full parsing of query parameters. -func (h *Handler) buildBidRequest(r *http.Request) *openrtb2.BidRequest { - // Placeholder implementation - // TODO: Parse these from query string: - // - pod_id -> BidRequest.ID - // - duration -> Video.MaxDuration - // - max_ads -> Video.MaxAds (via pod extension) - // - slot_count -> multiple Imp objects - - query := r.URL.Query() - podID := query.Get("pod_id") - if podID == "" { - podID = "ctv-pod-1" - } - - return &openrtb2.BidRequest{ - ID: podID, - Imp: []openrtb2.Imp{ - { - ID: "imp-1", - Video: &openrtb2.Video{ - MIMEs: []string{"video/mp4"}, - MinDuration: 5, - MaxDuration: 30, - }, - }, - }, - Site: &openrtb2.Site{ - Page: r.Header.Get("Referer"), - }, - } -} - -// WithConfig sets the receiver configuration. -func (h *Handler) WithConfig(cfg ReceiverConfig) *Handler { - h.Config = cfg - return h -} - -// WithSelector sets the bid selector. -func (h *Handler) WithSelector(s BidSelector) *Handler { - h.Selector = s - return h -} - -// WithEnricher sets the VAST enricher. -func (h *Handler) WithEnricher(e Enricher) *Handler { - h.Enricher = e - return h -} - -// WithFormatter sets the VAST formatter. -func (h *Handler) WithFormatter(f Formatter) *Handler { - h.Formatter = f - return h -} - -// WithAuctionFunc sets the auction function. -func (h *Handler) WithAuctionFunc(fn func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error)) *Handler { - h.AuctionFunc = fn - return h -} diff --git a/modules/ctv/vast/model/model.go b/modules/ctv/vast/model/model.go deleted file mode 100644 index e15a3075f8e..00000000000 --- a/modules/ctv/vast/model/model.go +++ /dev/null @@ -1,28 +0,0 @@ -// Package model defines VAST XML data structures for CTV ad processing. -package model - -// VastAd represents a parsed VAST ad with its components. -// This is a higher-level domain object; for XML marshaling use the Vast struct. -type VastAd struct { - // ID is the unique identifier for this ad. - ID string - // AdSystem identifies the ad server that returned the ad. - AdSystem string - // AdTitle is the common name of the ad. - AdTitle string - // Description is a longer description of the ad. - Description string - // Advertiser is the name of the advertiser. - Advertiser string - // DurationSec is the duration of the creative in seconds. - DurationSec int - // ErrorURLs contains error tracking URLs. - ErrorURLs []string - // ImpressionURLs contains impression tracking URLs. - ImpressionURLs []string - // Sequence indicates the position in an ad pod. - Sequence int - // RawVAST contains the original VAST XML if preserved. - RawVAST []byte -} - diff --git a/modules/ctv/vast/model/parser.go b/modules/ctv/vast/model/parser.go deleted file mode 100644 index 9e80b143502..00000000000 --- a/modules/ctv/vast/model/parser.go +++ /dev/null @@ -1,171 +0,0 @@ -package model - -import ( - "encoding/xml" - "errors" - "strings" -) - -// ErrNotVAST indicates the input string does not appear to be VAST XML. -var ErrNotVAST = errors.New("input does not contain VAST XML") - -// ErrVASTParseFailure indicates the VAST XML could not be parsed. -var ErrVASTParseFailure = errors.New("failed to parse VAST XML") - -// ParseVastAdm parses a VAST XML string from an OpenRTB bid's AdM field. -// Returns an error if the input doesn't contain " '9' { - return false, errors.New("invalid character in number") - } - n = n*10 + int(c-'0') - } - *result = n - return true, nil -} - -// IsInLineAd returns true if the ad is an InLine ad (not a Wrapper). -func IsInLineAd(ad *Ad) bool { - return ad != nil && ad.InLine != nil -} - -// IsWrapperAd returns true if the ad is a Wrapper ad. -func IsWrapperAd(ad *Ad) bool { - return ad != nil && ad.Wrapper != nil -} diff --git a/modules/ctv/vast/model/parser_test.go b/modules/ctv/vast/model/parser_test.go deleted file mode 100644 index 49f35ba0b42..00000000000 --- a/modules/ctv/vast/model/parser_test.go +++ /dev/null @@ -1,528 +0,0 @@ -package model - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Sample VAST XML strings for testing -const ( - sampleVAST30 = ` - - - - Test Ad Server - Test Video Ad - Test Advertiser Inc - - - - - 00:00:30 - - - - - - - - - - - - - -` - - sampleVAST40 = ` - - - - PBS-CTV - VAST 4.0 Test - 5.50 - - - 8465 - - 00:00:15 - - - - - - 1 - - - - -` - - sampleVASTWrapper = ` - - - - Wrapper System - - - - - - - - - - - - - -` - - sampleVASTNoVersion = ` - - - - No Version Ad - - - - 00:00:10 - - - - - -` - - sampleVASTMultipleAds = ` - - - - First Ad - - - - 00:00:15 - - - - - - - - Second Ad - - - - 00:00:30 - - - - - -` - - sampleVASTMinimal = `Min00:00:05` - - sampleVASTEmpty = ` - -` - - invalidXML = `Broken` - notVAST = `Not VAST` - emptyString = `` - justWhitespace = ` ` -) - -func TestParseVastAdm_ValidVAST30(t *testing.T) { - vast, err := ParseVastAdm(sampleVAST30) - require.NoError(t, err) - require.NotNil(t, vast) - - assert.Equal(t, "3.0", vast.Version) - require.Len(t, vast.Ads, 1) - - ad := vast.Ads[0] - assert.Equal(t, "12345", ad.ID) - assert.Equal(t, 1, ad.Sequence) - - require.NotNil(t, ad.InLine) - assert.Equal(t, "Test Video Ad", ad.InLine.AdTitle) - assert.Equal(t, "Test Advertiser Inc", ad.InLine.Advertiser) - - require.NotNil(t, ad.InLine.AdSystem) - assert.Equal(t, "Test Ad Server", ad.InLine.AdSystem.Value) - assert.Equal(t, "1.0", ad.InLine.AdSystem.Version) - - require.NotNil(t, ad.InLine.Creatives) - require.Len(t, ad.InLine.Creatives.Creative, 1) - - creative := ad.InLine.Creatives.Creative[0] - assert.Equal(t, "creative1", creative.ID) - - require.NotNil(t, creative.Linear) - assert.Equal(t, "00:00:30", creative.Linear.Duration) -} - -func TestParseVastAdm_ValidVAST40WithExtensions(t *testing.T) { - vast, err := ParseVastAdm(sampleVAST40) - require.NoError(t, err) - require.NotNil(t, vast) - - assert.Equal(t, "4.0", vast.Version) - require.Len(t, vast.Ads, 1) - - ad := vast.Ads[0] - require.NotNil(t, ad.InLine) - - // Check pricing - require.NotNil(t, ad.InLine.Pricing) - assert.Equal(t, "cpm", ad.InLine.Pricing.Model) - assert.Equal(t, "USD", ad.InLine.Pricing.Currency) - assert.Equal(t, "5.50", ad.InLine.Pricing.Value) - - // Check extensions - require.NotNil(t, ad.InLine.Extensions) - require.Len(t, ad.InLine.Extensions.Extension, 1) - assert.Equal(t, "waterfall", ad.InLine.Extensions.Extension[0].Type) - assert.Contains(t, ad.InLine.Extensions.Extension[0].InnerXML, "WaterfallIndex") - - // Check UniversalAdId - require.NotNil(t, ad.InLine.Creatives) - require.Len(t, ad.InLine.Creatives.Creative, 1) - creative := ad.InLine.Creatives.Creative[0] - require.NotNil(t, creative.UniversalAdID) - assert.Equal(t, "ad-id.org", creative.UniversalAdID.IDRegistry) - assert.Equal(t, "8465", creative.UniversalAdID.IDValue) -} - -func TestParseVastAdm_WrapperAd(t *testing.T) { - vast, err := ParseVastAdm(sampleVASTWrapper) - require.NoError(t, err) - require.NotNil(t, vast) - - require.Len(t, vast.Ads, 1) - ad := vast.Ads[0] - - assert.Nil(t, ad.InLine) - require.NotNil(t, ad.Wrapper) - assert.Equal(t, "Wrapper System", ad.Wrapper.AdSystem.Value) - - assert.True(t, IsWrapperAd(&ad)) - assert.False(t, IsInLineAd(&ad)) -} - -func TestParseVastAdm_NoVersion(t *testing.T) { - vast, err := ParseVastAdm(sampleVASTNoVersion) - require.NoError(t, err) - require.NotNil(t, vast) - - // Empty version is acceptable - assert.Equal(t, "", vast.Version) - require.Len(t, vast.Ads, 1) - assert.Equal(t, "No Version Ad", vast.Ads[0].InLine.AdTitle) -} - -func TestParseVastAdm_MultipleAds(t *testing.T) { - vast, err := ParseVastAdm(sampleVASTMultipleAds) - require.NoError(t, err) - require.NotNil(t, vast) - - require.Len(t, vast.Ads, 2) - assert.Equal(t, "ad1", vast.Ads[0].ID) - assert.Equal(t, 1, vast.Ads[0].Sequence) - assert.Equal(t, "ad2", vast.Ads[1].ID) - assert.Equal(t, 2, vast.Ads[1].Sequence) -} - -func TestParseVastAdm_MinimalVAST(t *testing.T) { - vast, err := ParseVastAdm(sampleVASTMinimal) - require.NoError(t, err) - require.NotNil(t, vast) - - assert.Equal(t, "3.0", vast.Version) - require.Len(t, vast.Ads, 1) - assert.Equal(t, "00:00:05", vast.Ads[0].InLine.Creatives.Creative[0].Linear.Duration) -} - -func TestParseVastAdm_EmptyVAST(t *testing.T) { - vast, err := ParseVastAdm(sampleVASTEmpty) - require.NoError(t, err) - require.NotNil(t, vast) - - assert.Equal(t, "3.0", vast.Version) - assert.Empty(t, vast.Ads) -} - -func TestParseVastAdm_NotVAST(t *testing.T) { - vast, err := ParseVastAdm(notVAST) - assert.ErrorIs(t, err, ErrNotVAST) - assert.Nil(t, vast) -} - -func TestParseVastAdm_EmptyString(t *testing.T) { - vast, err := ParseVastAdm(emptyString) - assert.ErrorIs(t, err, ErrNotVAST) - assert.Nil(t, vast) -} - -func TestParseVastAdm_Whitespace(t *testing.T) { - vast, err := ParseVastAdm(justWhitespace) - assert.ErrorIs(t, err, ErrNotVAST) - assert.Nil(t, vast) -} - -func TestParseVastAdm_InvalidXML(t *testing.T) { - vast, err := ParseVastAdm(invalidXML) - assert.ErrorIs(t, err, ErrVASTParseFailure) - assert.Nil(t, vast) -} - -func TestParseVastOrSkeleton_Success(t *testing.T) { - cfg := ParserConfig{ - AllowSkeletonVast: true, - VastVersionDefault: "3.0", - } - - vast, warnings, err := ParseVastOrSkeleton(sampleVAST30, cfg) - require.NoError(t, err) - require.NotNil(t, vast) - assert.Empty(t, warnings) - assert.Equal(t, "3.0", vast.Version) -} - -func TestParseVastOrSkeleton_FailWithSkeleton(t *testing.T) { - cfg := ParserConfig{ - AllowSkeletonVast: true, - VastVersionDefault: "4.0", - } - - vast, warnings, err := ParseVastOrSkeleton(notVAST, cfg) - require.NoError(t, err) - require.NotNil(t, vast) - - // Should return skeleton - assert.Equal(t, "4.0", vast.Version) - require.Len(t, vast.Ads, 1) - assert.Equal(t, "PBS-CTV", vast.Ads[0].InLine.AdSystem.Value) - - // Should have warning - require.Len(t, warnings, 1) - assert.Contains(t, warnings[0], "VAST parse failed") -} - -func TestParseVastOrSkeleton_FailWithoutSkeleton(t *testing.T) { - cfg := ParserConfig{ - AllowSkeletonVast: false, - VastVersionDefault: "3.0", - } - - vast, warnings, err := ParseVastOrSkeleton(notVAST, cfg) - assert.Error(t, err) - assert.Nil(t, vast) - assert.Empty(t, warnings) -} - -func TestParseVastOrSkeleton_InvalidXMLWithSkeleton(t *testing.T) { - cfg := ParserConfig{ - AllowSkeletonVast: true, - VastVersionDefault: "3.0", - } - - vast, warnings, err := ParseVastOrSkeleton(invalidXML, cfg) - require.NoError(t, err) - require.NotNil(t, vast) - require.Len(t, warnings, 1) - assert.Contains(t, warnings[0], "VAST parse failed") -} - -func TestParseVastOrSkeleton_DefaultVersion(t *testing.T) { - cfg := ParserConfig{ - AllowSkeletonVast: true, - VastVersionDefault: "", // Should default to "3.0" - } - - vast, _, err := ParseVastOrSkeleton(notVAST, cfg) - require.NoError(t, err) - require.NotNil(t, vast) - assert.Equal(t, "3.0", vast.Version) -} - -func TestParseVastFromBytes(t *testing.T) { - data := []byte(sampleVASTMinimal) - vast, err := ParseVastFromBytes(data) - require.NoError(t, err) - require.NotNil(t, vast) - assert.Equal(t, "3.0", vast.Version) -} - -func TestExtractFirstAd(t *testing.T) { - tests := []struct { - name string - vast *Vast - expectID string - expectNil bool - }{ - { - name: "nil vast", - vast: nil, - expectNil: true, - }, - { - name: "empty ads", - vast: &Vast{Ads: []Ad{}}, - expectNil: true, - }, - { - name: "single ad", - vast: &Vast{Ads: []Ad{{ID: "first"}}}, - expectID: "first", - }, - { - name: "multiple ads", - vast: &Vast{Ads: []Ad{{ID: "first"}, {ID: "second"}}}, - expectID: "first", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ad := ExtractFirstAd(tt.vast) - if tt.expectNil { - assert.Nil(t, ad) - } else { - require.NotNil(t, ad) - assert.Equal(t, tt.expectID, ad.ID) - } - }) - } -} - -func TestExtractDuration(t *testing.T) { - tests := []struct { - name string - xml string - expected string - }{ - { - name: "inline with duration", - xml: sampleVAST30, - expected: "00:00:30", - }, - { - name: "minimal vast", - xml: sampleVASTMinimal, - expected: "00:00:05", - }, - { - name: "empty vast", - xml: sampleVASTEmpty, - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - vast, err := ParseVastAdm(tt.xml) - require.NoError(t, err) - duration := ExtractDuration(vast) - assert.Equal(t, tt.expected, duration) - }) - } -} - -func TestParseDurationToSeconds(t *testing.T) { - tests := []struct { - name string - duration string - expected int - }{ - {"empty", "", 0}, - {"zero", "00:00:00", 0}, - {"5 seconds", "00:00:05", 5}, - {"30 seconds", "00:00:30", 30}, - {"1 minute", "00:01:00", 60}, - {"1 minute 30 seconds", "00:01:30", 90}, - {"1 hour", "01:00:00", 3600}, - {"1 hour 30 minutes 45 seconds", "01:30:45", 5445}, - {"with milliseconds", "00:00:30.500", 30}, - {"invalid format", "30", 0}, - {"invalid chars", "00:0a:30", 0}, - {"too few parts", "00:30", 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ParseDurationToSeconds(tt.duration) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestIsInLineAd(t *testing.T) { - assert.False(t, IsInLineAd(nil)) - assert.False(t, IsInLineAd(&Ad{})) - assert.False(t, IsInLineAd(&Ad{Wrapper: &Wrapper{}})) - assert.True(t, IsInLineAd(&Ad{InLine: &InLine{}})) -} - -func TestIsWrapperAd(t *testing.T) { - assert.False(t, IsWrapperAd(nil)) - assert.False(t, IsWrapperAd(&Ad{})) - assert.False(t, IsWrapperAd(&Ad{InLine: &InLine{}})) - assert.True(t, IsWrapperAd(&Ad{Wrapper: &Wrapper{}})) -} - -func TestParseVastAdm_PreservesInnerXML(t *testing.T) { - // Test that unknown elements are preserved via InnerXML - customVAST := ` - - - - Custom Ad - Custom Value - - - - 00:00:15 - Some Data - - - - - -` - - vast, err := ParseVastAdm(customVAST) - require.NoError(t, err) - require.NotNil(t, vast) - - // InnerXML fields should contain the unknown elements - require.Len(t, vast.Ads, 1) - require.NotNil(t, vast.Ads[0].InLine) - - // The InnerXML on InLine should contain CustomElement - assert.Contains(t, vast.Ads[0].InLine.InnerXML, "CustomElement") -} - -func TestRoundTrip_ParseMarshalParse(t *testing.T) { - // Parse original - vast1, err := ParseVastAdm(sampleVAST30) - require.NoError(t, err) - - // Marshal back to XML - xml1, err := vast1.Marshal() - require.NoError(t, err) - - // Parse again - vast2, err := ParseVastAdm(string(xml1)) - require.NoError(t, err) - - // Compare key fields - assert.Equal(t, vast1.Version, vast2.Version) - require.Len(t, vast2.Ads, len(vast1.Ads)) - assert.Equal(t, vast1.Ads[0].ID, vast2.Ads[0].ID) - assert.Equal(t, vast1.Ads[0].InLine.AdTitle, vast2.Ads[0].InLine.AdTitle) -} diff --git a/modules/ctv/vast/model/vast_xml.go b/modules/ctv/vast/model/vast_xml.go deleted file mode 100644 index fc6dc45e03d..00000000000 --- a/modules/ctv/vast/model/vast_xml.go +++ /dev/null @@ -1,282 +0,0 @@ -package model - -import ( - "encoding/xml" - "fmt" -) - -// Vast represents the root VAST XML element. -type Vast struct { - XMLName xml.Name `xml:"VAST"` - Version string `xml:"version,attr,omitempty"` - Ads []Ad `xml:"Ad"` -} - -// Ad represents a VAST Ad element. -type Ad struct { - ID string `xml:"id,attr,omitempty"` - Sequence int `xml:"sequence,attr,omitempty"` - InLine *InLine `xml:"InLine,omitempty"` - Wrapper *Wrapper `xml:"Wrapper,omitempty"` - // InnerXML preserves unknown nodes if needed - InnerXML string `xml:",innerxml"` -} - -// InLine represents a VAST InLine element containing the ad data. -type InLine struct { - AdSystem *AdSystem `xml:"AdSystem,omitempty"` - AdTitle string `xml:"AdTitle,omitempty"` - Advertiser string `xml:"Advertiser,omitempty"` - Description string `xml:"Description,omitempty"` - Error string `xml:"Error,omitempty"` - Impressions []Impression `xml:"Impression,omitempty"` - Pricing *Pricing `xml:"Pricing,omitempty"` - Creatives *Creatives `xml:"Creatives,omitempty"` - Extensions *Extensions `xml:"Extensions,omitempty"` - // InnerXML preserves unknown nodes - InnerXML string `xml:",innerxml"` -} - -// Wrapper represents a VAST Wrapper element for wrapped ads. -type Wrapper struct { - AdSystem *AdSystem `xml:"AdSystem,omitempty"` - VASTAdTagURI string `xml:"VASTAdTagURI,omitempty"` - Error string `xml:"Error,omitempty"` - Impressions []Impression `xml:"Impression,omitempty"` - Creatives *Creatives `xml:"Creatives,omitempty"` - Extensions *Extensions `xml:"Extensions,omitempty"` - // InnerXML preserves unknown nodes - InnerXML string `xml:",innerxml"` -} - -// AdSystem identifies the ad server that returned the ad. -type AdSystem struct { - Version string `xml:"version,attr,omitempty"` - Value string `xml:",chardata"` -} - -// Impression represents an impression tracking URL. -type Impression struct { - ID string `xml:"id,attr,omitempty"` - Value string `xml:",cdata"` -} - -// Pricing contains pricing information for the ad. -type Pricing struct { - Model string `xml:"model,attr,omitempty"` - Currency string `xml:"currency,attr,omitempty"` - Value string `xml:",chardata"` -} - -// Creatives contains a list of Creative elements. -type Creatives struct { - Creative []Creative `xml:"Creative,omitempty"` -} - -// Creative represents a VAST Creative element. -type Creative struct { - ID string `xml:"id,attr,omitempty"` - AdID string `xml:"adId,attr,omitempty"` - Sequence int `xml:"sequence,attr,omitempty"` - UniversalAdID *UniversalAdId `xml:"UniversalAdId,omitempty"` - Linear *Linear `xml:"Linear,omitempty"` - // InnerXML preserves unknown nodes - InnerXML string `xml:",innerxml"` -} - -// UniversalAdId provides a unique creative identifier across systems. -type UniversalAdId struct { - IDRegistry string `xml:"idRegistry,attr,omitempty"` - IDValue string `xml:"idValue,attr,omitempty"` - Value string `xml:",chardata"` -} - -// Linear represents a linear (video) creative. -type Linear struct { - SkipOffset string `xml:"skipoffset,attr,omitempty"` - Duration string `xml:"Duration,omitempty"` - MediaFiles *MediaFiles `xml:"MediaFiles,omitempty"` - VideoClicks *VideoClicks `xml:"VideoClicks,omitempty"` - TrackingEvents *TrackingEvents `xml:"TrackingEvents,omitempty"` - AdParameters *AdParameters `xml:"AdParameters,omitempty"` - // InnerXML preserves unknown nodes - InnerXML string `xml:",innerxml"` -} - -// MediaFiles contains a list of MediaFile elements. -type MediaFiles struct { - MediaFile []MediaFile `xml:"MediaFile,omitempty"` -} - -// MediaFile represents a video media file. -type MediaFile struct { - ID string `xml:"id,attr,omitempty"` - Delivery string `xml:"delivery,attr,omitempty"` - Type string `xml:"type,attr,omitempty"` - Width int `xml:"width,attr,omitempty"` - Height int `xml:"height,attr,omitempty"` - Bitrate int `xml:"bitrate,attr,omitempty"` - MinBitrate int `xml:"minBitrate,attr,omitempty"` - MaxBitrate int `xml:"maxBitrate,attr,omitempty"` - Scalable bool `xml:"scalable,attr,omitempty"` - MaintainAspectRatio bool `xml:"maintainAspectRatio,attr,omitempty"` - Codec string `xml:"codec,attr,omitempty"` - Value string `xml:",cdata"` -} - -// VideoClicks contains click tracking URLs for video ads. -type VideoClicks struct { - ClickThrough *ClickThrough `xml:"ClickThrough,omitempty"` - ClickTracking []ClickTracking `xml:"ClickTracking,omitempty"` - CustomClick []CustomClick `xml:"CustomClick,omitempty"` -} - -// ClickThrough represents the landing page URL. -type ClickThrough struct { - ID string `xml:"id,attr,omitempty"` - Value string `xml:",cdata"` -} - -// ClickTracking represents a click tracking URL. -type ClickTracking struct { - ID string `xml:"id,attr,omitempty"` - Value string `xml:",cdata"` -} - -// CustomClick represents a custom click URL. -type CustomClick struct { - ID string `xml:"id,attr,omitempty"` - Value string `xml:",cdata"` -} - -// TrackingEvents contains tracking URLs for various playback events. -type TrackingEvents struct { - Tracking []Tracking `xml:"Tracking,omitempty"` -} - -// Tracking represents a single tracking event. -type Tracking struct { - Event string `xml:"event,attr,omitempty"` - Offset string `xml:"offset,attr,omitempty"` - Value string `xml:",cdata"` -} - -// AdParameters holds custom parameters for the ad. -type AdParameters struct { - XMLEncoded bool `xml:"xmlEncoded,attr,omitempty"` - Value string `xml:",cdata"` -} - -// Extensions contains a list of Extension elements. -type Extensions struct { - Extension []ExtensionXML `xml:"Extension,omitempty"` -} - -// ExtensionXML represents a VAST extension element. -type ExtensionXML struct { - Type string `xml:"type,attr,omitempty"` - // InnerXML preserves the extension content - InnerXML string `xml:",innerxml"` -} - -// SecToHHMMSS converts seconds to HH:MM:SS format used in VAST Duration. -func SecToHHMMSS(seconds int) string { - if seconds < 0 { - seconds = 0 - } - hours := seconds / 3600 - minutes := (seconds % 3600) / 60 - secs := seconds % 60 - return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs) -} - -// BuildNoAdVast creates a VAST response indicating no ad is available. -// This is a valid VAST document with no Ad elements. -func BuildNoAdVast(version string) []byte { - if version == "" { - version = "3.0" - } - vast := Vast{ - Version: version, - Ads: []Ad{}, - } - output, err := xml.MarshalIndent(vast, "", " ") - if err != nil { - // Fallback to minimal valid VAST - return []byte(fmt.Sprintf(``, version)) - } - return append([]byte(xml.Header), output...) -} - -// BuildSkeletonInlineVast creates a minimal VAST document with one InLine ad. -// This skeleton can be used as a template to fill in with actual ad data. -func BuildSkeletonInlineVast(version string) *Vast { - if version == "" { - version = "3.0" - } - return &Vast{ - Version: version, - Ads: []Ad{ - { - ID: "1", - Sequence: 1, - InLine: &InLine{ - AdSystem: &AdSystem{ - Value: "PBS-CTV", - }, - AdTitle: "Ad", - Creatives: &Creatives{ - Creative: []Creative{ - { - ID: "1", - Sequence: 1, - Linear: &Linear{ - Duration: "00:00:00", - }, - }, - }, - }, - }, - }, - }, - } -} - -// BuildSkeletonInlineVastWithDuration creates a minimal VAST document with specified duration. -func BuildSkeletonInlineVastWithDuration(version string, durationSec int) *Vast { - vast := BuildSkeletonInlineVast(version) - if len(vast.Ads) > 0 && vast.Ads[0].InLine != nil && - vast.Ads[0].InLine.Creatives != nil && - len(vast.Ads[0].InLine.Creatives.Creative) > 0 && - vast.Ads[0].InLine.Creatives.Creative[0].Linear != nil { - vast.Ads[0].InLine.Creatives.Creative[0].Linear.Duration = SecToHHMMSS(durationSec) - } - return vast -} - -// Marshal serializes the Vast struct to XML bytes with XML header. -func (v *Vast) Marshal() ([]byte, error) { - output, err := xml.MarshalIndent(v, "", " ") - if err != nil { - return nil, err - } - return append([]byte(xml.Header), output...), nil -} - -// MarshalCompact serializes the Vast struct to XML bytes without indentation. -func (v *Vast) MarshalCompact() ([]byte, error) { - output, err := xml.Marshal(v) - if err != nil { - return nil, err - } - return append([]byte(xml.Header), output...), nil -} - -// Unmarshal parses XML bytes into a Vast struct. -func Unmarshal(data []byte) (*Vast, error) { - var vast Vast - if err := xml.Unmarshal(data, &vast); err != nil { - return nil, err - } - return &vast, nil -} diff --git a/modules/ctv/vast/model/vast_xml_test.go b/modules/ctv/vast/model/vast_xml_test.go deleted file mode 100644 index 6fb47bf4c92..00000000000 --- a/modules/ctv/vast/model/vast_xml_test.go +++ /dev/null @@ -1,447 +0,0 @@ -package model - -import ( - "encoding/xml" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSecToHHMMSS(t *testing.T) { - tests := []struct { - name string - seconds int - expected string - }{ - {"zero", 0, "00:00:00"}, - {"negative", -5, "00:00:00"}, - {"30 seconds", 30, "00:00:30"}, - {"1 minute", 60, "00:01:00"}, - {"1 minute 30 seconds", 90, "00:01:30"}, - {"1 hour", 3600, "01:00:00"}, - {"1 hour 30 minutes 45 seconds", 5445, "01:30:45"}, - {"2 hours", 7200, "02:00:00"}, - {"typical ad 15 seconds", 15, "00:00:15"}, - {"typical ad 30 seconds", 30, "00:00:30"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := SecToHHMMSS(tt.seconds) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestBuildNoAdVast(t *testing.T) { - tests := []struct { - name string - version string - }{ - {"default version", ""}, - {"version 3.0", "3.0"}, - {"version 4.0", "4.0"}, - {"version 4.2", "4.2"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := BuildNoAdVast(tt.version) - require.NotEmpty(t, result) - - // Should contain XML header - assert.True(t, strings.HasPrefix(string(result), "`) - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, `TestSystem`) - assert.Contains(t, xmlStr, `Test Ad`) - assert.Contains(t, xmlStr, `Test Advertiser`) - assert.Contains(t, xmlStr, `5.00`) - assert.Contains(t, xmlStr, `00:00:30`) - assert.Contains(t, xmlStr, ``) -} - -func TestVast_MarshalCompact(t *testing.T) { - vast := BuildSkeletonInlineVast("3.0") - output, err := vast.MarshalCompact() - require.NoError(t, err) - require.NotEmpty(t, output) - - xmlStr := string(output) - // Compact should not have newlines in the body - assert.Contains(t, xmlStr, ` - - - - TestAdServer - Sample Ad - Sample Inc - 10.50 - - - - 00:00:15 - - - - - -`) - - vast, err := Unmarshal(xmlData) - require.NoError(t, err) - require.NotNil(t, vast) - - assert.Equal(t, "3.0", vast.Version) - require.Len(t, vast.Ads, 1) - - ad := vast.Ads[0] - assert.Equal(t, "test-ad", ad.ID) - assert.Equal(t, 1, ad.Sequence) - - require.NotNil(t, ad.InLine) - assert.Equal(t, "Sample Ad", ad.InLine.AdTitle) - assert.Equal(t, "Sample Inc", ad.InLine.Advertiser) - - require.NotNil(t, ad.InLine.AdSystem) - assert.Equal(t, "2.0", ad.InLine.AdSystem.Version) - assert.Equal(t, "TestAdServer", ad.InLine.AdSystem.Value) - - require.NotNil(t, ad.InLine.Pricing) - assert.Equal(t, "cpm", ad.InLine.Pricing.Model) - assert.Equal(t, "EUR", ad.InLine.Pricing.Currency) - assert.Equal(t, "10.50", ad.InLine.Pricing.Value) - - require.NotNil(t, ad.InLine.Creatives) - require.Len(t, ad.InLine.Creatives.Creative, 1) - creative := ad.InLine.Creatives.Creative[0] - assert.Equal(t, "c1", creative.ID) - - require.NotNil(t, creative.Linear) - assert.Equal(t, "00:00:15", creative.Linear.Duration) -} - -func TestUnmarshal_WithExtensions(t *testing.T) { - xmlData := []byte(` - - - - Ad with Extensions - - - - 00:00:30 - - - - - - some value - - - test - - - - -`) - - vast, err := Unmarshal(xmlData) - require.NoError(t, err) - require.NotNil(t, vast) - require.Len(t, vast.Ads, 1) - require.NotNil(t, vast.Ads[0].InLine) - require.NotNil(t, vast.Ads[0].InLine.Extensions) - require.Len(t, vast.Ads[0].InLine.Extensions.Extension, 2) - - ext1 := vast.Ads[0].InLine.Extensions.Extension[0] - assert.Equal(t, "waterfall", ext1.Type) - assert.Contains(t, ext1.InnerXML, "CustomData") - - ext2 := vast.Ads[0].InLine.Extensions.Extension[1] - assert.Equal(t, "prebid", ext2.Type) - assert.Contains(t, ext2.InnerXML, "BidInfo") -} - -func TestUnmarshal_WrapperAd(t *testing.T) { - xmlData := []byte(` - - - - Wrapper System - - - - -`) - - vast, err := Unmarshal(xmlData) - require.NoError(t, err) - require.NotNil(t, vast) - require.Len(t, vast.Ads, 1) - - ad := vast.Ads[0] - assert.Equal(t, "wrapper-ad", ad.ID) - assert.Nil(t, ad.InLine) - require.NotNil(t, ad.Wrapper) - assert.Equal(t, "Wrapper System", ad.Wrapper.AdSystem.Value) -} - -func TestRoundTrip(t *testing.T) { - original := &Vast{ - Version: "4.0", - Ads: []Ad{ - { - ID: "roundtrip-test", - Sequence: 1, - InLine: &InLine{ - AdSystem: &AdSystem{Value: "PBS"}, - AdTitle: "Round Trip Test", - Creatives: &Creatives{ - Creative: []Creative{ - { - ID: "c1", - Linear: &Linear{ - Duration: "00:00:15", - }, - }, - }, - }, - }, - }, - }, - } - - // Marshal - xmlBytes, err := original.Marshal() - require.NoError(t, err) - - // Unmarshal - parsed, err := Unmarshal(xmlBytes) - require.NoError(t, err) - - // Verify - assert.Equal(t, original.Version, parsed.Version) - require.Len(t, parsed.Ads, 1) - assert.Equal(t, original.Ads[0].ID, parsed.Ads[0].ID) - assert.Equal(t, original.Ads[0].InLine.AdTitle, parsed.Ads[0].InLine.AdTitle) -} - -func TestMediaFileWithCDATA(t *testing.T) { - vast := &Vast{ - Version: "3.0", - Ads: []Ad{ - { - ID: "media-test", - InLine: &InLine{ - AdTitle: "Media Test", - Creatives: &Creatives{ - Creative: []Creative{ - { - Linear: &Linear{ - Duration: "00:00:30", - MediaFiles: &MediaFiles{ - MediaFile: []MediaFile{ - { - Delivery: "progressive", - Type: "video/mp4", - Width: 1280, - Height: 720, - Value: "https://example.com/video.mp4?param=value&other=123", - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } - - output, err := vast.Marshal() - require.NoError(t, err) - - // MediaFile URL should be in CDATA - xmlStr := string(output) - assert.Contains(t, xmlStr, "") -} - -func TestTrackingEvents(t *testing.T) { - vast := &Vast{ - Version: "3.0", - Ads: []Ad{ - { - ID: "tracking-test", - InLine: &InLine{ - AdTitle: "Tracking Test", - Creatives: &Creatives{ - Creative: []Creative{ - { - Linear: &Linear{ - Duration: "00:00:30", - TrackingEvents: &TrackingEvents{ - Tracking: []Tracking{ - {Event: "start", Value: "https://example.com/start"}, - {Event: "firstQuartile", Value: "https://example.com/q1"}, - {Event: "midpoint", Value: "https://example.com/mid"}, - {Event: "thirdQuartile", Value: "https://example.com/q3"}, - {Event: "complete", Value: "https://example.com/complete"}, - {Event: "progress", Offset: "00:00:05", Value: "https://example.com/5sec"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } - - output, err := vast.Marshal() - require.NoError(t, err) - - xmlStr := string(output) - assert.Contains(t, xmlStr, `event="start"`) - assert.Contains(t, xmlStr, `event="complete"`) - assert.Contains(t, xmlStr, `event="progress"`) - assert.Contains(t, xmlStr, `offset="00:00:05"`) -} diff --git a/modules/ctv/vast/select/price_selector.go b/modules/ctv/vast/select/price_selector.go deleted file mode 100644 index 1e8b52313e4..00000000000 --- a/modules/ctv/vast/select/price_selector.go +++ /dev/null @@ -1,167 +0,0 @@ -package bidselect - -import ( - "sort" - "strings" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v3/modules/ctv/vast" -) - -// PriceSelector selects bids based on price-based ranking. -// It implements the vast.BidSelector interface. -type PriceSelector struct { - // maxBids is the maximum number of bids to return. - // If 0, uses cfg.MaxAdsInPod from the config. - maxBids int -} - -// NewPriceSelector creates a new PriceSelector. -// If maxBids is 0, the selector will use cfg.MaxAdsInPod. -// If maxBids is 1, it behaves as a SINGLE selector. -func NewPriceSelector(maxBids int) *PriceSelector { - return &PriceSelector{ - maxBids: maxBids, - } -} - -// bidWithSeat holds a bid along with its seat ID for sorting and selection. -type bidWithSeat struct { - bid openrtb2.Bid - seat string -} - -// Select chooses bids from the response based on price-based ranking. -// It implements the vast.BidSelector interface. -// -// Selection process: -// 1. Collect all bids from resp.SeatBid[].Bid[] -// 2. Filter bids: price > 0 and AdM non-empty (unless AllowSkeletonVast is true) -// 3. Sort by: price desc, then deal exists desc, then bid.ID asc for stability -// 4. Return up to maxBids (or cfg.MaxAdsInPod if maxBids is 0) -// 5. Populate CanonicalMeta for each SelectedBid -func (s *PriceSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg vast.ReceiverConfig) ([]vast.SelectedBid, []string, error) { - var warnings []string - - if resp == nil || len(resp.SeatBid) == 0 { - return nil, warnings, nil - } - - // Determine currency from response or config default - currency := cfg.DefaultCurrency - if resp.Cur != "" { - currency = resp.Cur - } - - // Collect all bids from all seats - var allBids []bidWithSeat - for _, seatBid := range resp.SeatBid { - for _, bid := range seatBid.Bid { - allBids = append(allBids, bidWithSeat{ - bid: bid, - seat: seatBid.Seat, - }) - } - } - - // Filter bids - var filteredBids []bidWithSeat - for _, bws := range allBids { - // Filter: price must be > 0 - if bws.bid.Price <= 0 { - warnings = append(warnings, "bid "+bws.bid.ID+" filtered: price <= 0") - continue - } - - // Filter: AdM must be non-empty unless AllowSkeletonVast is true - if !cfg.AllowSkeletonVast && strings.TrimSpace(bws.bid.AdM) == "" { - warnings = append(warnings, "bid "+bws.bid.ID+" filtered: empty AdM (skeleton VAST not allowed)") - continue - } - - filteredBids = append(filteredBids, bws) - } - - if len(filteredBids) == 0 { - return nil, warnings, nil - } - - // Sort bids: price desc, deal exists desc, bid.ID asc for stability - sort.Slice(filteredBids, func(i, j int) bool { - bi, bj := filteredBids[i].bid, filteredBids[j].bid - - // Primary: price descending - if bi.Price != bj.Price { - return bi.Price > bj.Price - } - - // Secondary: deal exists descending (deals first) - iHasDeal := bi.DealID != "" - jHasDeal := bj.DealID != "" - if iHasDeal != jHasDeal { - return iHasDeal - } - - // Tertiary: bid ID ascending for stability - return bi.ID < bj.ID - }) - - // Determine how many bids to return - maxToReturn := s.maxBids - if maxToReturn == 0 { - maxToReturn = cfg.MaxAdsInPod - } - if maxToReturn <= 0 { - maxToReturn = 1 // Safety fallback - } - if maxToReturn > len(filteredBids) { - maxToReturn = len(filteredBids) - } - - // Select top bids and build SelectedBid with CanonicalMeta - selectedBids := make([]vast.SelectedBid, maxToReturn) - for i := 0; i < maxToReturn; i++ { - bws := filteredBids[i] - bid := bws.bid - - // Determine sequence (SlotInPod) - sequence := i + 1 - // Check if bid has explicit slot in pod via Ext or other mechanism - // For MVP, we use index+1 as sequence - - // Extract primary adomain - adomain := "" - if len(bid.ADomain) > 0 { - adomain = bid.ADomain[0] - } - - // Extract duration from bid (if available in Dur field for video) - durSec := 0 - if bid.Dur > 0 { - durSec = int(bid.Dur) - } - - selectedBids[i] = vast.SelectedBid{ - Bid: bid, - Seat: bws.seat, - Sequence: sequence, - Meta: vast.CanonicalMeta{ - BidID: bid.ID, - ImpID: bid.ImpID, - DealID: bid.DealID, - Seat: bws.seat, - Price: bid.Price, - Currency: currency, - Adomain: adomain, - Cats: bid.Cat, - DurSec: durSec, - SlotInPod: sequence, - }, - } - } - - return selectedBids, warnings, nil -} - -// Ensure PriceSelector implements BidSelector interface. -var _ vast.BidSelector = (*PriceSelector)(nil) diff --git a/modules/ctv/vast/select/price_selector_test.go b/modules/ctv/vast/select/price_selector_test.go deleted file mode 100644 index 0d12353da24..00000000000 --- a/modules/ctv/vast/select/price_selector_test.go +++ /dev/null @@ -1,501 +0,0 @@ -package bidselect - -import ( - "testing" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v3/modules/ctv/vast" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewSelector(t *testing.T) { - tests := []struct { - name string - strategy vast.SelectionStrategy - wantMax int - }{ - { - name: "SINGLE strategy", - strategy: vast.SelectionSingle, - wantMax: 1, - }, - { - name: "TOP_N strategy", - strategy: vast.SelectionTopN, - wantMax: 0, // uses cfg.MaxAdsInPod - }, - { - name: "unknown strategy defaults to TOP_N", - strategy: "unknown", - wantMax: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - selector := NewSelector(tt.strategy) - require.NotNil(t, selector) - - priceSelector, ok := selector.(*PriceSelector) - require.True(t, ok) - assert.Equal(t, tt.wantMax, priceSelector.maxBids) - }) - } -} - -func TestPriceSelector_Select_NilResponse(t *testing.T) { - selector := NewPriceSelector(5) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - } - - selected, warnings, err := selector.Select(nil, nil, cfg) - assert.NoError(t, err) - assert.Nil(t, selected) - assert.Empty(t, warnings) -} - -func TestPriceSelector_Select_EmptySeatBid(t *testing.T) { - selector := NewPriceSelector(5) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - } - resp := &openrtb2.BidResponse{ - SeatBid: []openrtb2.SeatBid{}, - } - - selected, warnings, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - assert.Nil(t, selected) - assert.Empty(t, warnings) -} - -func TestPriceSelector_Select_FilterZeroPrice(t *testing.T) { - selector := NewPriceSelector(5) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid1", Price: 0, AdM: ""}, - {ID: "bid2", Price: -1, AdM: ""}, - }, - }, - }, - } - - selected, warnings, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - assert.Empty(t, selected) - assert.Len(t, warnings, 2) - assert.Contains(t, warnings[0], "price <= 0") -} - -func TestPriceSelector_Select_FilterEmptyAdM(t *testing.T) { - selector := NewPriceSelector(5) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - AllowSkeletonVast: false, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid1", Price: 1.0, AdM: ""}, - {ID: "bid2", Price: 2.0, AdM: " "}, - }, - }, - }, - } - - selected, warnings, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - assert.Empty(t, selected) - assert.Len(t, warnings, 2) - assert.Contains(t, warnings[0], "empty AdM") -} - -func TestPriceSelector_Select_AllowSkeletonVast(t *testing.T) { - selector := NewPriceSelector(5) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - AllowSkeletonVast: true, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid1", Price: 1.0, AdM: ""}, - {ID: "bid2", Price: 2.0, AdM: ""}, - }, - }, - }, - } - - selected, warnings, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - assert.Len(t, selected, 2) - assert.Empty(t, warnings) -} - -func TestPriceSelector_Select_SortByPriceDesc(t *testing.T) { - selector := NewPriceSelector(0) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid1", Price: 1.0, AdM: ""}, - {ID: "bid2", Price: 3.0, AdM: ""}, - {ID: "bid3", Price: 2.0, AdM: ""}, - }, - }, - }, - } - - selected, _, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - require.Len(t, selected, 3) - - // Should be sorted by price descending - assert.Equal(t, "bid2", selected[0].Meta.BidID) - assert.Equal(t, 3.0, selected[0].Meta.Price) - assert.Equal(t, "bid3", selected[1].Meta.BidID) - assert.Equal(t, 2.0, selected[1].Meta.Price) - assert.Equal(t, "bid1", selected[2].Meta.BidID) - assert.Equal(t, 1.0, selected[2].Meta.Price) -} - -func TestPriceSelector_Select_DealsPrioritized(t *testing.T) { - selector := NewPriceSelector(0) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid1", Price: 2.0, AdM: "", DealID: ""}, - {ID: "bid2", Price: 2.0, AdM: "", DealID: "deal123"}, - }, - }, - }, - } - - selected, _, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - require.Len(t, selected, 2) - - // At same price, deal should come first - assert.Equal(t, "bid2", selected[0].Meta.BidID) - assert.Equal(t, "deal123", selected[0].Meta.DealID) - assert.Equal(t, "bid1", selected[1].Meta.BidID) -} - -func TestPriceSelector_Select_StableSortByID(t *testing.T) { - selector := NewPriceSelector(0) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "c", Price: 2.0, AdM: ""}, - {ID: "a", Price: 2.0, AdM: ""}, - {ID: "b", Price: 2.0, AdM: ""}, - }, - }, - }, - } - - selected, _, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - require.Len(t, selected, 3) - - // Same price, no deals - should be sorted by ID ascending - assert.Equal(t, "a", selected[0].Meta.BidID) - assert.Equal(t, "b", selected[1].Meta.BidID) - assert.Equal(t, "c", selected[2].Meta.BidID) -} - -func TestPriceSelector_Select_SingleStrategy(t *testing.T) { - selector := NewPriceSelector(1) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid1", Price: 1.0, AdM: ""}, - {ID: "bid2", Price: 3.0, AdM: ""}, - {ID: "bid3", Price: 2.0, AdM: ""}, - }, - }, - }, - } - - selected, _, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - require.Len(t, selected, 1) - assert.Equal(t, "bid2", selected[0].Meta.BidID) - assert.Equal(t, 3.0, selected[0].Meta.Price) -} - -func TestPriceSelector_Select_TopNRespectsMaxAdsInPod(t *testing.T) { - selector := NewPriceSelector(0) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 2, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid1", Price: 1.0, AdM: ""}, - {ID: "bid2", Price: 3.0, AdM: ""}, - {ID: "bid3", Price: 2.0, AdM: ""}, - {ID: "bid4", Price: 4.0, AdM: ""}, - }, - }, - }, - } - - selected, _, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - require.Len(t, selected, 2) - assert.Equal(t, "bid4", selected[0].Meta.BidID) - assert.Equal(t, "bid2", selected[1].Meta.BidID) -} - -func TestPriceSelector_Select_Sequence(t *testing.T) { - selector := NewPriceSelector(0) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid1", Price: 1.0, AdM: ""}, - {ID: "bid2", Price: 2.0, AdM: ""}, - }, - }, - }, - } - - selected, _, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - require.Len(t, selected, 2) - - // Sequence should be 1-indexed based on position - assert.Equal(t, 1, selected[0].Sequence) - assert.Equal(t, 1, selected[0].Meta.SlotInPod) - assert.Equal(t, 2, selected[1].Sequence) - assert.Equal(t, 2, selected[1].Meta.SlotInPod) -} - -func TestPriceSelector_Select_CanonicalMeta(t *testing.T) { - selector := NewPriceSelector(1) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - } - resp := &openrtb2.BidResponse{ - Cur: "EUR", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - { - ID: "bid1", - ImpID: "imp1", - Price: 2.5, - AdM: "", - DealID: "deal123", - ADomain: []string{"advertiser.com", "other.com"}, - Cat: []string{"IAB1", "IAB2"}, - Dur: 30, - }, - }, - }, - }, - } - - selected, _, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - require.Len(t, selected, 1) - - meta := selected[0].Meta - assert.Equal(t, "bid1", meta.BidID) - assert.Equal(t, "imp1", meta.ImpID) - assert.Equal(t, "deal123", meta.DealID) - assert.Equal(t, "bidder1", meta.Seat) - assert.Equal(t, 2.5, meta.Price) - assert.Equal(t, "EUR", meta.Currency) // From response - assert.Equal(t, "advertiser.com", meta.Adomain) - assert.Equal(t, []string{"IAB1", "IAB2"}, meta.Cats) - assert.Equal(t, 30, meta.DurSec) - assert.Equal(t, 1, meta.SlotInPod) -} - -func TestPriceSelector_Select_CurrencyFallback(t *testing.T) { - selector := NewPriceSelector(1) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "GBP", - MaxAdsInPod: 5, - } - resp := &openrtb2.BidResponse{ - Cur: "", // Empty currency - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid1", Price: 1.0, AdM: ""}, - }, - }, - }, - } - - selected, _, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - require.Len(t, selected, 1) - assert.Equal(t, "GBP", selected[0].Meta.Currency) // Fallback to config -} - -func TestPriceSelector_Select_MultipleSeatBids(t *testing.T) { - selector := NewPriceSelector(0) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 5, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid1", Price: 1.0, AdM: ""}, - }, - }, - { - Seat: "bidder2", - Bid: []openrtb2.Bid{ - {ID: "bid2", Price: 2.0, AdM: ""}, - }, - }, - { - Seat: "bidder3", - Bid: []openrtb2.Bid{ - {ID: "bid3", Price: 3.0, AdM: ""}, - }, - }, - }, - } - - selected, _, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - require.Len(t, selected, 3) - - // Should be sorted by price, with correct seat assignment - assert.Equal(t, "bid3", selected[0].Meta.BidID) - assert.Equal(t, "bidder3", selected[0].Seat) - assert.Equal(t, "bid2", selected[1].Meta.BidID) - assert.Equal(t, "bidder2", selected[1].Seat) - assert.Equal(t, "bid1", selected[2].Meta.BidID) - assert.Equal(t, "bidder1", selected[2].Seat) -} - -func TestPriceSelector_Select_ComplexSort(t *testing.T) { - selector := NewPriceSelector(0) - cfg := vast.ReceiverConfig{ - DefaultCurrency: "USD", - MaxAdsInPod: 10, - } - resp := &openrtb2.BidResponse{ - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "e", Price: 2.0, AdM: "", DealID: ""}, // Same price, no deal - {ID: "a", Price: 3.0, AdM: "", DealID: "deal1"}, // Highest price with deal - {ID: "b", Price: 3.0, AdM: "", DealID: ""}, // Highest price, no deal - {ID: "c", Price: 2.0, AdM: "", DealID: "deal2"}, // Same price with deal - {ID: "d", Price: 2.0, AdM: "", DealID: "deal3"}, // Same price with deal - {ID: "f", Price: 1.0, AdM: "", DealID: ""}, // Lowest price - }, - }, - }, - } - - selected, _, err := selector.Select(nil, resp, cfg) - assert.NoError(t, err) - require.Len(t, selected, 6) - - // Expected order: - // 1. a (price 3.0, deal) - highest price with deal - // 2. b (price 3.0, no deal) - highest price, no deal - // 3. c (price 2.0, deal) - same price, deal, ID "c" - // 4. d (price 2.0, deal) - same price, deal, ID "d" - // 5. e (price 2.0, no deal) - same price, no deal - // 6. f (price 1.0) - lowest price - assert.Equal(t, "a", selected[0].Meta.BidID) - assert.Equal(t, "b", selected[1].Meta.BidID) - assert.Equal(t, "c", selected[2].Meta.BidID) - assert.Equal(t, "d", selected[3].Meta.BidID) - assert.Equal(t, "e", selected[4].Meta.BidID) - assert.Equal(t, "f", selected[5].Meta.BidID) -} - -func TestNewSingleSelector(t *testing.T) { - selector := NewSingleSelector() - require.NotNil(t, selector) - - priceSelector, ok := selector.(*PriceSelector) - require.True(t, ok) - assert.Equal(t, 1, priceSelector.maxBids) -} - -func TestNewTopNSelector(t *testing.T) { - selector := NewTopNSelector() - require.NotNil(t, selector) - - priceSelector, ok := selector.(*PriceSelector) - require.True(t, ok) - assert.Equal(t, 0, priceSelector.maxBids) -} diff --git a/modules/ctv/vast/select/selector.go b/modules/ctv/vast/select/selector.go deleted file mode 100644 index d87bbf48335..00000000000 --- a/modules/ctv/vast/select/selector.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package bidselect provides bid selection logic for CTV VAST ad pods. -package bidselect - -import ( - "github.com/prebid/prebid-server/v3/modules/ctv/vast" -) - -// Selector implements the vast.BidSelector interface. -// It provides factory methods for different selection strategies. -type Selector interface { - vast.BidSelector -} - -// NewSelector creates a BidSelector based on the selection strategy. -// Supported strategies: -// - "SINGLE": Returns a single best bid (PriceSelector with limit 1) -// - "TOP_N": Returns up to MaxAdsInPod bids (PriceSelector) -// - Default: Falls back to TOP_N behavior -func NewSelector(strategy vast.SelectionStrategy) Selector { - switch strategy { - case vast.SelectionSingle: - return NewPriceSelector(1) - case vast.SelectionTopN: - return NewPriceSelector(0) // 0 means use cfg.MaxAdsInPod - default: - // Default to TOP_N behavior for unknown strategies - return NewPriceSelector(0) - } -} - -// NewSingleSelector creates a selector that returns only the best bid. -func NewSingleSelector() Selector { - return NewPriceSelector(1) -} - -// NewTopNSelector creates a selector that returns up to MaxAdsInPod bids. -func NewTopNSelector() Selector { - return NewPriceSelector(0) -} - -// Ensure PriceSelector implements Selector interface. -var _ Selector = (*PriceSelector)(nil) diff --git a/modules/ctv/vast/types.go b/modules/ctv/vast/types.go deleted file mode 100644 index 0fbf9456795..00000000000 --- a/modules/ctv/vast/types.go +++ /dev/null @@ -1,191 +0,0 @@ -// Package vast provides CTV VAST processing capabilities for Prebid Server. -// It includes bid selection, VAST enrichment, and formatting for various receivers. -package vast - -import ( - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" -) - -// ReceiverType identifies the downstream ad receiver/player. -type ReceiverType string - -const ( - // ReceiverGAMSSU represents Google Ad Manager Server-Side Unified receiver. - ReceiverGAMSSU ReceiverType = "GAM_SSU" - // ReceiverGeneric represents a generic VAST-compliant receiver. - ReceiverGeneric ReceiverType = "GENERIC" -) - -// SelectionStrategy defines how bids are selected for ad pods. -type SelectionStrategy string - -const ( - // SelectionSingle selects a single best bid. - SelectionSingle SelectionStrategy = "SINGLE" - // SelectionTopN selects up to MaxAdsInPod bids. - SelectionTopN SelectionStrategy = "TOP_N" - // SelectionMaxRevenue selects bids to maximize total revenue. - SelectionMaxRevenue SelectionStrategy = "max_revenue" - // SelectionMinDuration selects bids to minimize total duration. - SelectionMinDuration SelectionStrategy = "min_duration" - // SelectionBalanced balances between revenue and duration. - SelectionBalanced SelectionStrategy = "balanced" -) - -// CollisionPolicy defines how to handle competitive separation violations. -type CollisionPolicy string - -const ( - // CollisionReject rejects ads that violate competitive separation. - CollisionReject CollisionPolicy = "reject" - // CollisionWarn allows ads but adds warnings for violations. - CollisionWarn CollisionPolicy = "warn" - // CollisionIgnore ignores competitive separation rules. - CollisionIgnore CollisionPolicy = "ignore" -) - -// VastResult holds the complete result of VAST processing. -type VastResult struct { - // VastXML contains the final VAST XML output. - VastXML []byte - // NoAd indicates if no valid ad was available. - NoAd bool - // Warnings contains non-fatal issues encountered during processing. - Warnings []string - // Errors contains fatal errors that occurred during processing. - Errors []error - // Selected contains the bids that were selected for the ad pod. - Selected []SelectedBid -} - -// SelectedBid represents a bid that was selected for inclusion in the VAST response. -type SelectedBid struct { - // Bid is the OpenRTB bid object. - Bid openrtb2.Bid - // Seat is the seat ID of the bidder. - Seat string - // Sequence is the position of this bid in the ad pod (1-indexed). - Sequence int - // Meta contains canonical metadata extracted from the bid. - Meta CanonicalMeta -} - -// CanonicalMeta contains normalized metadata for a selected bid. -type CanonicalMeta struct { - // BidID is the unique identifier for the bid. - BidID string - // ImpID is the impression ID this bid is for. - ImpID string - // DealID is the deal ID if this bid is from a deal. - DealID string - // Seat is the bidder seat ID. - Seat string - // Price is the bid price. - Price float64 - // Currency is the currency code for the price. - Currency string - // Adomain is the primary advertiser domain. - Adomain string - // Cats contains the IAB content categories. - Cats []string - // DurSec is the duration of the creative in seconds. - DurSec int - // SlotInPod is the position within the ad pod (1-indexed). - SlotInPod int -} - -// ReceiverConfig holds configuration for VAST processing. -type ReceiverConfig struct { - // Receiver identifies the downstream ad receiver type. - Receiver ReceiverType - // DefaultCurrency is the currency to use when not specified. - DefaultCurrency string - // VastVersionDefault is the default VAST version to output. - VastVersionDefault string - // MaxAdsInPod is the maximum number of ads allowed in a pod. - MaxAdsInPod int - // SelectionStrategy defines how bids are selected. - SelectionStrategy SelectionStrategy - // CollisionPolicy defines how competitive separation is handled. - CollisionPolicy CollisionPolicy - // Placement contains placement-specific rules. - Placement PlacementRules - // AllowSkeletonVast allows bids without AdM content (skeleton VAST). - AllowSkeletonVast bool - // Debug enables debug mode with additional output. - Debug bool -} - -// PlacementRules contains rules for validating and filtering bids. -type PlacementRules struct { - // Pricing contains price floor and ceiling rules. - Pricing PricingRules - // Advertiser contains advertiser-based filtering rules. - Advertiser AdvertiserRules - // Categories contains category-based filtering rules. - Categories CategoryRules - // PricingPlacement defines where to place pricing info: "VAST_PRICING" or "EXTENSION". - PricingPlacement string - // AdvertiserPlacement defines where to place advertiser info: "ADVERTISER_TAG" or "EXTENSION". - AdvertiserPlacement string - // Debug enables debug output for placement rules. - Debug bool -} - -// PricingRules defines pricing constraints for bid selection. -type PricingRules struct { - // FloorCPM is the minimum CPM allowed. - FloorCPM float64 - // CeilingCPM is the maximum CPM allowed (0 = no ceiling). - CeilingCPM float64 - // Currency is the currency for floor/ceiling values. - Currency string -} - -// AdvertiserRules defines advertiser-based filtering. -type AdvertiserRules struct { - // BlockedDomains is a list of advertiser domains to reject. - BlockedDomains []string - // AllowedDomains is a whitelist of allowed domains (empty = allow all). - AllowedDomains []string -} - -// CategoryRules defines category-based filtering. -type CategoryRules struct { - // BlockedCategories is a list of IAB categories to reject. - BlockedCategories []string - // AllowedCategories is a whitelist of allowed categories (empty = allow all). - AllowedCategories []string -} - -// BidSelector defines the interface for selecting bids from an auction response. -type BidSelector interface { - // Select chooses bids from the response based on configuration. - // Returns selected bids, warnings, and any fatal error. - Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg ReceiverConfig) ([]SelectedBid, []string, error) -} - -// Enricher defines the interface for enriching VAST ads with additional data. -type Enricher interface { - // Enrich adds tracking, extensions, and other data to a VAST ad. - // Returns warnings and any fatal error. - Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) -} - -// EnrichedAd pairs a VAST Ad with its associated metadata. -type EnrichedAd struct { - // Ad is the enriched VAST Ad element. - Ad *model.Ad - // Meta contains canonical metadata for this ad. - Meta CanonicalMeta - // Sequence is the position in the ad pod (1-indexed). - Sequence int -} - -// Formatter defines the interface for formatting VAST ads into XML. -type Formatter interface { - // Format converts enriched VAST ads into XML output. - // Returns the XML bytes, warnings, and any fatal error. - Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) -} diff --git a/modules/ctv/vast/vast.go b/modules/ctv/vast/vast.go deleted file mode 100644 index 470da7447e5..00000000000 --- a/modules/ctv/vast/vast.go +++ /dev/null @@ -1,204 +0,0 @@ -// Package vast provides CTV VAST processing capabilities for Prebid Server. -// -// This module handles the complete VAST workflow for Connected TV (CTV) ad serving: -// - Bid selection from OpenRTB auction responses -// - VAST ad enrichment with tracking and metadata -// - VAST XML formatting for various downstream receivers -// -// The package is organized into sub-packages: -// - model: VAST data structures -// - select: Bid selection logic -// - enrich: VAST ad enrichment -// - format: VAST XML formatting -// -// Example usage: -// -// cfg := vast.ReceiverConfig{ -// Receiver: vast.ReceiverGAMSSU, -// DefaultCurrency: "USD", -// VastVersionDefault: "4.0", -// MaxAdsInPod: 5, -// SelectionStrategy: vast.SelectionMaxRevenue, -// CollisionPolicy: vast.CollisionReject, -// } -// -// processor := vast.NewProcessor(cfg, selector, enricher, formatter) -// result := processor.Process(bidRequest, bidResponse) -package vast - -import ( - "context" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" -) - -// BuildVastFromBidResponse orchestrates the complete VAST processing pipeline. -// It selects bids, parses/creates VAST, enriches ads, and formats final XML. -// -// Steps: -// 1. Select bids from response using configured strategy -// 2. Parse VAST from each bid's AdM (or create skeleton if allowed) -// 3. Enrich each ad with metadata (pricing, categories, etc.) -// 4. Format all ads into final VAST XML -// -// Parameters: -// - ctx: Context for cancellation and timeouts -// - req: OpenRTB bid request -// - resp: OpenRTB bid response from auction -// - cfg: Receiver configuration -// - selector: Bid selection implementation -// - enricher: VAST enrichment implementation -// - formatter: VAST formatting implementation -// -// Returns VastResult containing XML output, warnings, and selected bids. -func BuildVastFromBidResponse( - ctx context.Context, - req *openrtb2.BidRequest, - resp *openrtb2.BidResponse, - cfg ReceiverConfig, - selector BidSelector, - enricher Enricher, - formatter Formatter, -) (VastResult, error) { - result := VastResult{ - Warnings: make([]string, 0), - Errors: make([]error, 0), - } - - // Step 1: Select bids - selected, selectWarnings, err := selector.Select(req, resp, cfg) - if err != nil { - result.Errors = append(result.Errors, err) - result.NoAd = true - result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) - return result, err - } - result.Warnings = append(result.Warnings, selectWarnings...) - result.Selected = selected - - // Step 2: Handle no bids case - if len(selected) == 0 { - result.NoAd = true - result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) - return result, nil - } - - // Step 3: Parse and enrich each selected bid's VAST - enrichedAds := make([]EnrichedAd, 0, len(selected)) - - parserCfg := model.ParserConfig{ - AllowSkeletonVast: cfg.AllowSkeletonVast, - VastVersionDefault: cfg.VastVersionDefault, - } - - for _, sb := range selected { - // Parse VAST from AdM (or create skeleton) - parsedVast, parseWarnings, parseErr := model.ParseVastOrSkeleton(sb.Bid.AdM, parserCfg) - result.Warnings = append(result.Warnings, parseWarnings...) - - if parseErr != nil { - result.Warnings = append(result.Warnings, "failed to parse VAST for bid "+sb.Bid.ID+": "+parseErr.Error()) - continue - } - - // Extract the first Ad from parsed VAST - ad := model.ExtractFirstAd(parsedVast) - if ad == nil { - result.Warnings = append(result.Warnings, "no ad found in VAST for bid "+sb.Bid.ID) - continue - } - - // Enrich the ad with metadata - enrichWarnings, enrichErr := enricher.Enrich(ad, sb.Meta, cfg) - result.Warnings = append(result.Warnings, enrichWarnings...) - if enrichErr != nil { - result.Warnings = append(result.Warnings, "enrichment failed for bid "+sb.Bid.ID+": "+enrichErr.Error()) - // Continue with unenriched ad - } - - // Store enriched ad - enrichedAds = append(enrichedAds, EnrichedAd{ - Ad: ad, - Meta: sb.Meta, - Sequence: sb.Sequence, - }) - } - - // Step 4: Handle case where all bids failed parsing - if len(enrichedAds) == 0 { - result.NoAd = true - result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) - result.Warnings = append(result.Warnings, "all selected bids failed VAST parsing") - return result, nil - } - - // Step 5: Format the final VAST XML - xmlBytes, formatWarnings, formatErr := formatter.Format(enrichedAds, cfg) - result.Warnings = append(result.Warnings, formatWarnings...) - - if formatErr != nil { - result.Errors = append(result.Errors, formatErr) - result.NoAd = true - result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) - return result, formatErr - } - - result.VastXML = xmlBytes - result.NoAd = false - - return result, nil -} - -// Processor orchestrates the VAST processing workflow. -type Processor struct { - selector BidSelector - enricher Enricher - formatter Formatter - config ReceiverConfig -} - -// NewProcessor creates a new Processor with the given configuration. -func NewProcessor(cfg ReceiverConfig, selector BidSelector, enricher Enricher, formatter Formatter) *Processor { - return &Processor{ - selector: selector, - enricher: enricher, - formatter: formatter, - config: cfg, - } -} - -// Process executes the complete VAST processing workflow. -func (p *Processor) Process(ctx context.Context, req *openrtb2.BidRequest, resp *openrtb2.BidResponse) VastResult { - result, _ := BuildVastFromBidResponse(ctx, req, resp, p.config, p.selector, p.enricher, p.formatter) - return result -} - -// DefaultConfig returns a default ReceiverConfig for GAM SSU. -func DefaultConfig() ReceiverConfig { - return ReceiverConfig{ - Receiver: ReceiverGAMSSU, - DefaultCurrency: "USD", - VastVersionDefault: "4.0", - MaxAdsInPod: 5, - SelectionStrategy: SelectionMaxRevenue, - CollisionPolicy: CollisionReject, - Placement: PlacementRules{ - Pricing: PricingRules{ - FloorCPM: 0, - CeilingCPM: 0, - Currency: "USD", - }, - Advertiser: AdvertiserRules{ - BlockedDomains: []string{}, - AllowedDomains: []string{}, - }, - Categories: CategoryRules{ - BlockedCategories: []string{}, - AllowedCategories: []string{}, - }, - Debug: false, - }, - Debug: false, - } -} diff --git a/modules/ctv/vast/vast_test.go b/modules/ctv/vast/vast_test.go deleted file mode 100644 index 110171ddcf0..00000000000 --- a/modules/ctv/vast/vast_test.go +++ /dev/null @@ -1,607 +0,0 @@ -package vast - -import ( - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v3/modules/ctv/vast/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Mock implementations for testing - -type mockSelector struct { - selectFn func(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg ReceiverConfig) ([]SelectedBid, []string, error) -} - -func (m *mockSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg ReceiverConfig) ([]SelectedBid, []string, error) { - if m.selectFn != nil { - return m.selectFn(req, resp, cfg) - } - // Default: select all bids with sequence numbers - var selected []SelectedBid - seq := 1 - if resp != nil { - for _, sb := range resp.SeatBid { - for _, bid := range sb.Bid { - adomain := "" - if len(bid.ADomain) > 0 { - adomain = bid.ADomain[0] - } - selected = append(selected, SelectedBid{ - Bid: bid, - Seat: sb.Seat, - Sequence: seq, - Meta: CanonicalMeta{ - BidID: bid.ID, - Seat: sb.Seat, - Price: bid.Price, - Currency: resp.Cur, - Adomain: adomain, - Cats: bid.Cat, - }, - }) - seq++ - } - } - } - return selected, nil, nil -} - -type mockEnricher struct { - enrichFn func(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) -} - -func (m *mockEnricher) Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) { - if m.enrichFn != nil { - return m.enrichFn(ad, meta, cfg) - } - // Default: add pricing extension and advertiser - if ad.InLine != nil { - ad.InLine.Pricing = &model.Pricing{ - Model: "CPM", - Currency: cfg.DefaultCurrency, - Value: formatPrice(meta.Price), - } - if meta.Adomain != "" { - ad.InLine.Advertiser = meta.Adomain - } - if cfg.Debug { - if ad.InLine.Extensions == nil { - ad.InLine.Extensions = &model.Extensions{} - } - debugXML := fmt.Sprintf("%s%s%f", - meta.BidID, meta.Seat, meta.Price) - ad.InLine.Extensions.Extension = append(ad.InLine.Extensions.Extension, model.ExtensionXML{ - Type: "openrtb", - InnerXML: debugXML, - }) - } - } - return nil, nil -} - -func formatPrice(price float64) string { - return fmt.Sprintf("%.2f", price) -} - -type mockFormatter struct { - formatFn func(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) -} - -func (m *mockFormatter) Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) { - if m.formatFn != nil { - return m.formatFn(ads, cfg) - } - // Default: build GAM SSU style VAST - version := cfg.VastVersionDefault - if version == "" { - version = "4.0" - } - vast := &model.Vast{ - Version: version, - Ads: make([]model.Ad, 0, len(ads)), - } - for _, ea := range ads { - ad := *ea.Ad - ad.ID = ea.Meta.BidID - ad.Sequence = ea.Sequence - vast.Ads = append(vast.Ads, ad) - } - xml, err := vast.Marshal() - return xml, nil, err -} - -func newTestComponents() (BidSelector, Enricher, Formatter) { - return &mockSelector{}, &mockEnricher{}, &mockFormatter{} -} - -func TestBuildVastFromBidResponse_NoAds(t *testing.T) { - cfg := DefaultConfig() - req := &openrtb2.BidRequest{ID: "test-req"} - resp := &openrtb2.BidResponse{ID: "test-resp"} - - selector, enricher, formatter := newTestComponents() - result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) - require.NoError(t, err) - - assert.True(t, result.NoAd) - assert.NotEmpty(t, result.VastXML) - assert.Contains(t, string(result.VastXML), ``) - assert.Empty(t, result.Selected) -} - -func TestBuildVastFromBidResponse_NilResponse(t *testing.T) { - cfg := DefaultConfig() - req := &openrtb2.BidRequest{ID: "test-req"} - - selector, enricher, formatter := newTestComponents() - result, err := BuildVastFromBidResponse(context.Background(), req, nil, cfg, selector, enricher, formatter) - require.NoError(t, err) - - assert.True(t, result.NoAd) - assert.NotEmpty(t, result.VastXML) -} - -func TestBuildVastFromBidResponse_SingleBid(t *testing.T) { - cfg := DefaultConfig() - cfg.SelectionStrategy = SelectionSingle - - vastXML := ` - - - - TestServer - Test Ad - - - - 00:00:30 - - - - - - - - - - -` - - req := &openrtb2.BidRequest{ID: "test-req"} - resp := &openrtb2.BidResponse{ - ID: "test-resp", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - { - ID: "bid-1", - ImpID: "imp-1", - Price: 5.0, - AdM: vastXML, - ADomain: []string{"advertiser.com"}, - }, - }, - }, - }, - } - - selector, enricher, formatter := newTestComponents() - result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) - require.NoError(t, err) - - assert.False(t, result.NoAd) - assert.NotEmpty(t, result.VastXML) - assert.Len(t, result.Selected, 1) - - xmlStr := string(result.VastXML) - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, `Test Ad") -} - -func TestBuildVastFromBidResponse_MultipleBids(t *testing.T) { - cfg := DefaultConfig() - cfg.SelectionStrategy = SelectionTopN - cfg.MaxAdsInPod = 3 - - makeVAST := func(adID, title string) string { - return ` - - - - TestServer - ` + title + ` - - - - 00:00:15 - - - - - -` - } - - req := &openrtb2.BidRequest{ID: "test-req"} - resp := &openrtb2.BidResponse{ - ID: "test-resp", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid-1", ImpID: "imp-1", Price: 10.0, AdM: makeVAST("ad-1", "First Ad")}, - {ID: "bid-2", ImpID: "imp-2", Price: 8.0, AdM: makeVAST("ad-2", "Second Ad")}, - {ID: "bid-3", ImpID: "imp-3", Price: 5.0, AdM: makeVAST("ad-3", "Third Ad")}, - }, - }, - }, - } - - selector, enricher, formatter := newTestComponents() - result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) - require.NoError(t, err) - - assert.False(t, result.NoAd) - assert.Len(t, result.Selected, 3) - - xmlStr := string(result.VastXML) - assert.Contains(t, xmlStr, `sequence="1"`) - assert.Contains(t, xmlStr, `sequence="2"`) - assert.Contains(t, xmlStr, `sequence="3"`) -} - -func TestBuildVastFromBidResponse_SkeletonVast(t *testing.T) { - cfg := DefaultConfig() - cfg.AllowSkeletonVast = true - cfg.SelectionStrategy = SelectionSingle - - req := &openrtb2.BidRequest{ID: "test-req"} - resp := &openrtb2.BidResponse{ - ID: "test-resp", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - { - ID: "bid-1", - ImpID: "imp-1", - Price: 5.0, - AdM: "not-valid-vast", // Invalid VAST - }, - }, - }, - }, - } - - selector, enricher, formatter := newTestComponents() - result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) - require.NoError(t, err) - - // Should succeed with skeleton VAST - assert.False(t, result.NoAd) - assert.NotEmpty(t, result.VastXML) - // Check for skeleton warning - hasSkeletonWarning := false - for _, w := range result.Warnings { - if strings.Contains(strings.ToLower(w), "skeleton") { - hasSkeletonWarning = true - break - } - } - assert.True(t, hasSkeletonWarning, "Expected skeleton warning, got: %v", result.Warnings) -} - -func TestBuildVastFromBidResponse_InvalidVastNoSkeleton(t *testing.T) { - cfg := DefaultConfig() - cfg.AllowSkeletonVast = false // Don't allow skeleton - cfg.SelectionStrategy = SelectionSingle - - req := &openrtb2.BidRequest{ID: "test-req"} - resp := &openrtb2.BidResponse{ - ID: "test-resp", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - { - ID: "bid-1", - ImpID: "imp-1", - Price: 5.0, - AdM: "not-valid-vast", - }, - }, - }, - }, - } - - selector, enricher, formatter := newTestComponents() - result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) - require.NoError(t, err) - - // Should return no-ad since parse failed and skeleton not allowed - assert.True(t, result.NoAd) -} - -func TestBuildVastFromBidResponse_EnrichmentAddsMetadata(t *testing.T) { - cfg := DefaultConfig() - cfg.SelectionStrategy = SelectionSingle - cfg.Debug = true // Enable debug extensions - - vastXML := ` - - - - TestServer - Test Ad - - - - - - - - - - - - - -` - - req := &openrtb2.BidRequest{ID: "test-req"} - resp := &openrtb2.BidResponse{ - ID: "test-resp", - Cur: "USD", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - { - ID: "bid-enriched", - ImpID: "imp-1", - Price: 7.5, - AdM: vastXML, - ADomain: []string{"advertiser.com"}, - Cat: []string{"IAB1", "IAB2"}, - }, - }, - }, - }, - } - - selector, enricher, formatter := newTestComponents() - result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) - require.NoError(t, err) - require.False(t, result.NoAd) - - xmlStr := string(result.VastXML) - // Check enrichment added pricing - assert.Contains(t, xmlStr, "bid-enriched") -} - -// HTTP Handler Tests - -func TestHandler_MethodNotAllowed(t *testing.T) { - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter) - - req := httptest.NewRequest(http.MethodPost, "/vast", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) -} - -func TestHandler_NotConfigured(t *testing.T) { - handler := NewHandler() // No selector/enricher/formatter - - req := httptest.NewRequest(http.MethodGet, "/vast", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusInternalServerError, rec.Code) - body, _ := io.ReadAll(rec.Body) - assert.Contains(t, string(body), "not properly configured") -} - -func TestHandler_NoAuction_ReturnsNoAdVast(t *testing.T) { - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter) - // No AuctionFunc set, should return no-ad VAST - - req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=test-pod", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "application/xml; charset=utf-8", rec.Header().Get("Content-Type")) - - body, _ := io.ReadAll(rec.Body) - assert.Contains(t, string(body), ``) -} - -func TestHandler_WithMockAuction_ReturnsVast(t *testing.T) { - vastXML := ` - - - - MockServer - Mock Ad - - - - 00:00:15 - - - - - -` - - mockAuction := func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { - return &openrtb2.BidResponse{ - ID: "mock-resp", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "mock-bidder", - Bid: []openrtb2.Bid{ - { - ID: "mock-bid-1", - ImpID: "imp-1", - Price: 3.50, - AdM: vastXML, - }, - }, - }, - }, - }, nil - } - - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter). - WithAuctionFunc(mockAuction) - - req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=test-pod", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "application/xml; charset=utf-8", rec.Header().Get("Content-Type")) - - body, _ := io.ReadAll(rec.Body) - xmlStr := string(body) - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, `Mock Ad") -} - -func TestHandler_WithConfig(t *testing.T) { - cfg := ReceiverConfig{ - Receiver: ReceiverGAMSSU, - VastVersionDefault: "3.0", - DefaultCurrency: "EUR", - } - - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithConfig(cfg). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter) - - req := httptest.NewRequest(http.MethodGet, "/vast", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - body, _ := io.ReadAll(rec.Body) - // Should use version 3.0 from config - assert.Contains(t, string(body), `version="3.0"`) -} - -func TestHandler_CacheControlHeader(t *testing.T) { - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter) - - req := httptest.NewRequest(http.MethodGet, "/vast", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, "no-cache, no-store, must-revalidate", rec.Header().Get("Cache-Control")) -} - -func TestHandler_PodIDFromQuery(t *testing.T) { - var capturedReq *openrtb2.BidRequest - - mockAuction := func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { - capturedReq = req - return &openrtb2.BidResponse{}, nil - } - - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter). - WithAuctionFunc(mockAuction) - - req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=custom-pod-123", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - require.NotNil(t, capturedReq) - assert.Equal(t, "custom-pod-123", capturedReq.ID) -} - -// Test warnings are captured -func TestBuildVastFromBidResponse_WarningsCollected(t *testing.T) { - cfg := DefaultConfig() - cfg.AllowSkeletonVast = true - - // First bid has valid VAST, second has invalid - validVAST := `Test00:00:15` - - req := &openrtb2.BidRequest{ID: "test-req"} - resp := &openrtb2.BidResponse{ - ID: "test-resp", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "bidder1", - Bid: []openrtb2.Bid{ - {ID: "bid-1", ImpID: "imp-1", Price: 10.0, AdM: validVAST}, - {ID: "bid-2", ImpID: "imp-2", Price: 5.0, AdM: "invalid-vast"}, - }, - }, - }, - } - - selector, enricher, formatter := newTestComponents() - result, err := BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) - require.NoError(t, err) - - assert.False(t, result.NoAd) - // Should have warnings about the invalid VAST using skeleton - hasSkeletonWarning := false - for _, w := range result.Warnings { - if strings.Contains(strings.ToLower(w), "skeleton") { - hasSkeletonWarning = true - break - } - } - assert.True(t, hasSkeletonWarning, "Expected skeleton warning in: %v", result.Warnings) -} From 0b5a4782361e2bc7044fa982b21a70dc082e0c78 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Tue, 10 Feb 2026 10:22:18 +0000 Subject: [PATCH 04/15] fix(ctv_vast_enrichment): PBS module compliance fixes - Change package name from 'vast' to 'ctv_vast_enrichment' - Register module in modules/builder.go - Fix ChangeSet mutation logic to use UpdateBids pattern - Add 'vast' import alias in subpackages (enrich, select, format) - Update tests to apply ChangeSet mutations before assertions --- modules/builder.go | 6 ++- modules/prebid/ctv_vast_enrichment/config.go | 2 +- .../prebid/ctv_vast_enrichment/config_test.go | 2 +- .../ctv_vast_enrichment/enrich/enrich.go | 2 +- .../ctv_vast_enrichment/enrich/enrich_test.go | 2 +- .../ctv_vast_enrichment/format/format.go | 2 +- .../ctv_vast_enrichment/format/format_test.go | 2 +- modules/prebid/ctv_vast_enrichment/handler.go | 2 +- modules/prebid/ctv_vast_enrichment/module.go | 45 ++++++++++++------- .../prebid/ctv_vast_enrichment/module_test.go | 42 ++++++++++++++--- .../prebid/ctv_vast_enrichment/pipeline.go | 2 +- .../ctv_vast_enrichment/pipeline_test.go | 2 +- .../select/price_selector.go | 2 +- .../select/price_selector_test.go | 2 +- .../ctv_vast_enrichment/select/selector.go | 2 +- modules/prebid/ctv_vast_enrichment/types.go | 2 +- 16 files changed, 82 insertions(+), 37 deletions(-) diff --git a/modules/builder.go b/modules/builder.go index 85a38fd0228..51ec3a4847d 100644 --- a/modules/builder.go +++ b/modules/builder.go @@ -2,6 +2,7 @@ package modules import ( fiftyonedegreesDevicedetection "github.com/prebid/prebid-server/v3/modules/fiftyonedegrees/devicedetection" + prebidCtvVastEnrichment "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" prebidOrtb2blocking "github.com/prebid/prebid-server/v3/modules/prebid/ortb2blocking" prebidRulesengine "github.com/prebid/prebid-server/v3/modules/prebid/rulesengine" scope3Rtd "github.com/prebid/prebid-server/v3/modules/scope3/rtd" @@ -15,8 +16,9 @@ func builders() ModuleBuilders { "devicedetection": fiftyonedegreesDevicedetection.Builder, }, "prebid": { - "ortb2blocking": prebidOrtb2blocking.Builder, - "rulesengine": prebidRulesengine.Builder, + "ctv_vast_enrichment": prebidCtvVastEnrichment.Builder, + "ortb2blocking": prebidOrtb2blocking.Builder, + "rulesengine": prebidRulesengine.Builder, }, "scope3": { "rtd": scope3Rtd.Builder, diff --git a/modules/prebid/ctv_vast_enrichment/config.go b/modules/prebid/ctv_vast_enrichment/config.go index 64fea1ddb08..a03ea977ed0 100644 --- a/modules/prebid/ctv_vast_enrichment/config.go +++ b/modules/prebid/ctv_vast_enrichment/config.go @@ -1,4 +1,4 @@ -package vast +package ctv_vast_enrichment // CTVVastConfig represents the configuration for CTV VAST processing. // It supports PBS-style layered configuration where profile overrides account, diff --git a/modules/prebid/ctv_vast_enrichment/config_test.go b/modules/prebid/ctv_vast_enrichment/config_test.go index 6de0712c603..642e4635d51 100644 --- a/modules/prebid/ctv_vast_enrichment/config_test.go +++ b/modules/prebid/ctv_vast_enrichment/config_test.go @@ -1,4 +1,4 @@ -package vast +package ctv_vast_enrichment import ( "testing" diff --git a/modules/prebid/ctv_vast_enrichment/enrich/enrich.go b/modules/prebid/ctv_vast_enrichment/enrich/enrich.go index 821b93a28f1..ad6628452c6 100644 --- a/modules/prebid/ctv_vast_enrichment/enrich/enrich.go +++ b/modules/prebid/ctv_vast_enrichment/enrich/enrich.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" ) diff --git a/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go b/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go index 657bff2dba7..5379c2361f0 100644 --- a/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go +++ b/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go @@ -3,7 +3,7 @@ package enrich import ( "testing" - "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/prebid/ctv_vast_enrichment/format/format.go b/modules/prebid/ctv_vast_enrichment/format/format.go index ad4b65947a9..ea63800140b 100644 --- a/modules/prebid/ctv_vast_enrichment/format/format.go +++ b/modules/prebid/ctv_vast_enrichment/format/format.go @@ -4,7 +4,7 @@ package format import ( "encoding/xml" - "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" ) diff --git a/modules/prebid/ctv_vast_enrichment/format/format_test.go b/modules/prebid/ctv_vast_enrichment/format/format_test.go index 68e3ba5e0d4..567f4c23089 100644 --- a/modules/prebid/ctv_vast_enrichment/format/format_test.go +++ b/modules/prebid/ctv_vast_enrichment/format/format_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/prebid/ctv_vast_enrichment/handler.go b/modules/prebid/ctv_vast_enrichment/handler.go index 74b8562ef8a..127b7686d2b 100644 --- a/modules/prebid/ctv_vast_enrichment/handler.go +++ b/modules/prebid/ctv_vast_enrichment/handler.go @@ -1,4 +1,4 @@ -package vast +package ctv_vast_enrichment import ( "context" diff --git a/modules/prebid/ctv_vast_enrichment/module.go b/modules/prebid/ctv_vast_enrichment/module.go index ab251f57bf4..02e8e0b992c 100644 --- a/modules/prebid/ctv_vast_enrichment/module.go +++ b/modules/prebid/ctv_vast_enrichment/module.go @@ -1,4 +1,4 @@ -package vast +package ctv_vast_enrichment import ( "context" @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/adapters" "github.com/prebid/prebid-server/v3/hooks/hookstage" "github.com/prebid/prebid-server/v3/modules/moduledeps" "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" @@ -75,11 +77,13 @@ func (m Module) HandleRawBidderResponseHook( // Convert config to ReceiverConfig receiverCfg := configToReceiverConfig(mergedCfg) - // Process each bid + // Build modified bids list + modifiedBids := make([]*adapters.TypedBid, 0, len(payload.BidderResponse.Bids)) changesMade := false - for i := range payload.BidderResponse.Bids { - typedBid := payload.BidderResponse.Bids[i] + + for _, typedBid := range payload.BidderResponse.Bids { if typedBid == nil || typedBid.Bid == nil { + modifiedBids = append(modifiedBids, typedBid) continue } @@ -87,6 +91,7 @@ func (m Module) HandleRawBidderResponseHook( // Skip non-video bids (no AdM or not VAST) if bid.AdM == "" { + modifiedBids = append(modifiedBids, typedBid) continue } @@ -94,6 +99,7 @@ func (m Module) HandleRawBidderResponseHook( vastDoc, err := model.ParseVastAdm(bid.AdM) if err != nil { // Not valid VAST, skip enrichment + modifiedBids = append(modifiedBids, typedBid) continue } @@ -113,24 +119,33 @@ func (m Module) HandleRawBidderResponseHook( // Format back to XML xmlBytes, err := enrichedVast.Marshal() if err != nil { - // Keep original AdM on format error + // Keep original bid on format error + modifiedBids = append(modifiedBids, typedBid) continue } - // Update bid with enriched VAST - bid.AdM = string(xmlBytes) + // Create new bid with enriched VAST + enrichedBid := &openrtb2.Bid{} + *enrichedBid = *bid + enrichedBid.AdM = string(xmlBytes) + + // Create new TypedBid with enriched bid + enrichedTypedBid := &adapters.TypedBid{ + Bid: enrichedBid, + BidType: typedBid.BidType, + BidVideo: typedBid.BidVideo, + DealPriority: typedBid.DealPriority, + Seat: typedBid.Seat, + } + modifiedBids = append(modifiedBids, enrichedTypedBid) changesMade = true } - // If we made changes, set mutation + // If we made changes, set mutation via ChangeSet if changesMade { - result.ChangeSet.AddMutation( - func(payload hookstage.RawBidderResponsePayload) (hookstage.RawBidderResponsePayload, error) { - return payload, nil - }, - hookstage.MutationUpdate, - "ctv-vast-enrichment", - ) + changeSet := hookstage.ChangeSet[hookstage.RawBidderResponsePayload]{} + changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids) + result.ChangeSet = changeSet } return result, nil diff --git a/modules/prebid/ctv_vast_enrichment/module_test.go b/modules/prebid/ctv_vast_enrichment/module_test.go index e246316039f..1d932b68be6 100644 --- a/modules/prebid/ctv_vast_enrichment/module_test.go +++ b/modules/prebid/ctv_vast_enrichment/module_test.go @@ -1,4 +1,4 @@ -package vast +package ctv_vast_enrichment import ( "context" @@ -188,6 +188,13 @@ func TestHandleRawBidderResponseHook_EnrichesVAST(t *testing.T) { require.NoError(t, err) assert.Empty(t, result.Errors) + // Apply mutations from ChangeSet + for _, mut := range result.ChangeSet.Mutations() { + newPayload, err := mut.Apply(payload) + require.NoError(t, err) + payload = newPayload + } + // Verify the bid was enriched enrichedAdM := payload.BidderResponse.Bids[0].Bid.AdM assert.Contains(t, enrichedAdM, "Pricing") @@ -319,6 +326,13 @@ func TestHandleRawBidderResponseHook_MergesHostAndAccountConfig(t *testing.T) { require.NoError(t, err) assert.Empty(t, result.Errors) + // Apply mutations from ChangeSet + for _, mut := range result.ChangeSet.Mutations() { + newPayload, err := mut.Apply(payload) + require.NoError(t, err) + payload = newPayload + } + // Verify EUR currency was used (account overrides host) enrichedAdM := payload.BidderResponse.Bids[0].Bid.AdM assert.Contains(t, enrichedAdM, "EUR") @@ -365,6 +379,13 @@ func TestHandleRawBidderResponseHook_MultipleBids(t *testing.T) { require.NoError(t, err) assert.Empty(t, result.Errors) + // Apply mutations from ChangeSet + for _, mut := range result.ChangeSet.Mutations() { + newPayload, err := mut.Apply(payload) + require.NoError(t, err) + payload = newPayload + } + // Both bids should be enriched assert.Contains(t, payload.BidderResponse.Bids[0].Bid.AdM, "1.500000") assert.Contains(t, payload.BidderResponse.Bids[1].Bid.AdM, "2.000000") @@ -404,6 +425,13 @@ func TestHandleRawBidderResponseHook_PreservesExistingPricing(t *testing.T) { require.NoError(t, err) assert.Empty(t, result.Errors) + // Apply mutations from ChangeSet + for _, mut := range result.ChangeSet.Mutations() { + newPayload, err := mut.Apply(payload) + require.NoError(t, err) + payload = newPayload + } + // Original pricing should be preserved (VAST wins) enrichedAdM := payload.BidderResponse.Bids[0].Bid.AdM assert.Contains(t, enrichedAdM, "GBP") @@ -492,12 +520,12 @@ func TestConfigToReceiverConfig(t *testing.T) { func TestEnrichVastDocument(t *testing.T) { testCases := []struct { - name string - inputVast string - meta CanonicalMeta - cfg ReceiverConfig - expectPricing bool - expectAdomain bool + name string + inputVast string + meta CanonicalMeta + cfg ReceiverConfig + expectPricing bool + expectAdomain bool }{ { name: "adds pricing when missing", diff --git a/modules/prebid/ctv_vast_enrichment/pipeline.go b/modules/prebid/ctv_vast_enrichment/pipeline.go index 41297bc8c8f..d304973fae6 100644 --- a/modules/prebid/ctv_vast_enrichment/pipeline.go +++ b/modules/prebid/ctv_vast_enrichment/pipeline.go @@ -24,7 +24,7 @@ // // processor := vast.NewProcessor(cfg, selector, enricher, formatter) // result := processor.Process(bidRequest, bidResponse) -package vast +package ctv_vast_enrichment import ( "context" diff --git a/modules/prebid/ctv_vast_enrichment/pipeline_test.go b/modules/prebid/ctv_vast_enrichment/pipeline_test.go index 1369fe5f739..9823788e904 100644 --- a/modules/prebid/ctv_vast_enrichment/pipeline_test.go +++ b/modules/prebid/ctv_vast_enrichment/pipeline_test.go @@ -1,4 +1,4 @@ -package vast +package ctv_vast_enrichment import ( "context" diff --git a/modules/prebid/ctv_vast_enrichment/select/price_selector.go b/modules/prebid/ctv_vast_enrichment/select/price_selector.go index fa6ff893105..96db242eafd 100644 --- a/modules/prebid/ctv_vast_enrichment/select/price_selector.go +++ b/modules/prebid/ctv_vast_enrichment/select/price_selector.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" ) // PriceSelector selects bids based on price-based ranking. diff --git a/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go b/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go index d65ef41c266..87f31f14b4e 100644 --- a/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go +++ b/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/prebid/ctv_vast_enrichment/select/selector.go b/modules/prebid/ctv_vast_enrichment/select/selector.go index decc385ac42..20a810c3fa7 100644 --- a/modules/prebid/ctv_vast_enrichment/select/selector.go +++ b/modules/prebid/ctv_vast_enrichment/select/selector.go @@ -2,7 +2,7 @@ package bidselect import ( - "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" ) // Selector implements the vast.BidSelector interface. diff --git a/modules/prebid/ctv_vast_enrichment/types.go b/modules/prebid/ctv_vast_enrichment/types.go index d8e5234e49e..0d8d1896271 100644 --- a/modules/prebid/ctv_vast_enrichment/types.go +++ b/modules/prebid/ctv_vast_enrichment/types.go @@ -1,6 +1,6 @@ // Package vast provides CTV VAST processing capabilities for Prebid Server. // It includes bid selection, VAST enrichment, and formatting for various receivers. -package vast +package ctv_vast_enrichment import ( "github.com/prebid/openrtb/v20/openrtb2" From e1a8e75029061e30bf134974a94e8a84755a5973 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Thu, 12 Feb 2026 15:15:58 +0000 Subject: [PATCH 05/15] fix duplicates in vast xml --- .../ctv_vast_enrichment/model/vast_xml.go | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/modules/prebid/ctv_vast_enrichment/model/vast_xml.go b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go index fc6dc45e03d..b4b38c4364a 100644 --- a/modules/prebid/ctv_vast_enrichment/model/vast_xml.go +++ b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go @@ -256,6 +256,8 @@ func BuildSkeletonInlineVastWithDuration(version string, durationSec int) *Vast // Marshal serializes the Vast struct to XML bytes with XML header. func (v *Vast) Marshal() ([]byte, error) { + // Clear InnerXML fields to prevent duplicate content + v.clearInnerXML() output, err := xml.MarshalIndent(v, "", " ") if err != nil { return nil, err @@ -265,6 +267,8 @@ func (v *Vast) Marshal() ([]byte, error) { // MarshalCompact serializes the Vast struct to XML bytes without indentation. func (v *Vast) MarshalCompact() ([]byte, error) { + // Clear InnerXML fields to prevent duplicate content + v.clearInnerXML() output, err := xml.Marshal(v) if err != nil { return nil, err @@ -272,6 +276,29 @@ func (v *Vast) MarshalCompact() ([]byte, error) { return append([]byte(xml.Header), output...), nil } +// clearInnerXML clears all InnerXML fields to prevent duplicate content during marshaling. +// InnerXML is used during parsing to preserve unknown elements, but must be cleared +// before marshaling to avoid outputting both structured fields AND raw XML. +func (v *Vast) clearInnerXML() { + for i := range v.Ads { + v.Ads[i].InnerXML = "" + if v.Ads[i].InLine != nil { + v.Ads[i].InLine.InnerXML = "" + if v.Ads[i].InLine.Creatives != nil { + for j := range v.Ads[i].InLine.Creatives.Creative { + v.Ads[i].InLine.Creatives.Creative[j].InnerXML = "" + if v.Ads[i].InLine.Creatives.Creative[j].Linear != nil { + v.Ads[i].InLine.Creatives.Creative[j].Linear.InnerXML = "" + } + } + } + } + if v.Ads[i].Wrapper != nil { + v.Ads[i].Wrapper.InnerXML = "" + } + } +} + // Unmarshal parses XML bytes into a Vast struct. func Unmarshal(data []byte) (*Vast, error) { var vast Vast From 43ebe5a6ab8b3eedb8b408b8ea6740418ffc1646 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Tue, 17 Feb 2026 16:21:21 +0000 Subject: [PATCH 06/15] docs(ctv_vast_enrichment): update READMEs with PBS compliance details\n\n- Add module registration section (modules/builder.go)\n- Replace YAML enabled_modules with proper host_execution_plan JSON config\n- Document ChangeSet/UpdateBids mutation pattern\n- Document clearInnerXML() XML serialization fix\n- Add package naming note (ctv_vast_enrichment + vast alias)\n- Add step-by-step PBS integration instructions --- modules/prebid/ctv_vast_enrichment/README.md | 129 ++++++++++++++---- .../prebid/ctv_vast_enrichment/README_EN.md | 99 +++++++++++--- 2 files changed, 181 insertions(+), 47 deletions(-) diff --git a/modules/prebid/ctv_vast_enrichment/README.md b/modules/prebid/ctv_vast_enrichment/README.md index 2f8f412b838..ffb381b28ae 100644 --- a/modules/prebid/ctv_vast_enrichment/README.md +++ b/modules/prebid/ctv_vast_enrichment/README.md @@ -32,7 +32,28 @@ modules/prebid/ctv_vast_enrichment/ ## Integracja z PBS -Moduł jest zgodny ze standardowym wzorcem modułów Prebid Server: +Moduł jest zgodny ze standardowym wzorcem modułów Prebid Server. + +### Rejestracja w `modules/builder.go` + +Moduł musi być zarejestrowany w pliku `modules/builder.go`, który jest centralnym rejestrem wszystkich modułów PBS: + +```go +import ( + prebidCtvVastEnrichment "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +) + +var newModuleBuilders = map[string]map[string]interface{}{ + "prebid": { + "ctv_vast_enrichment": prebidCtvVastEnrichment.Builder, + }, +} +``` + +> **Uwaga:** Nazwa pakietu Go to `ctv_vast_enrichment`, ale subpakiety (enrich, select, format) używają aliasu `vast` do importu pakietu nadrzędnego: +> ```go +> import vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +> ``` ### `module.go` - Główny Punkt Wejścia @@ -59,28 +80,55 @@ Moduł działa na etapie hooka **RawBidderResponse**, przetwarzając odpowiedź 1. Parsuje VAST XML z pola `AdM` bida 2. Wzbogaca VAST o pricing, advertiser i metadane kategorii -3. Aktualizuje pole `AdM` bida wzbogaconym VAST XML +3. Tworzy nowy `*adapters.TypedBid` z nowym `*openrtb2.Bid` zawierającym wzbogacony AdM +4. Zwraca mutację przez `changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids)` + +> **Wzorzec ChangeSet:** Hook nie modyfikuje payload bezpośrednio. Zamiast tego buduje nowy slice `[]adapters.TypedBid` i rejestruje mutację przez `UpdateBids()`. PBS stosuje mutację po powrocie z hooka — zgodnie ze wzorcem z modułu `ortb2blocking`. -### Konfiguracja +### Konfiguracja PBS (`pbs.json`) -Moduł używa warstwowej konfiguracji w stylu PBS: +Aby moduł został wywołany podczas aukcji, wymagana jest konfiguracja `host_execution_plan` w sekcji `hooks`: ```json { - "modules": { - "prebid": { - "ctv_vast_enrichment": { - "enabled": true, - "receiver": "GAM_SSU", - "default_currency": "USD", - "vast_version_default": "3.0", - "max_ads_in_pod": 10 + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } } } } } ``` +> **Ważne:** Sam `enabled_modules` nie wystarczy. PBS wymaga jawnego `host_execution_plan` z definicją stage `raw_bidder_response` i `module_code: "prebid.ctv_vast_enrichment"`, aby hook został faktycznie wywołany. + Konfiguracja na poziomie konta nadpisuje ustawienia na poziomie hosta. ## Komponenty @@ -179,6 +227,9 @@ Funkcje pomocnicze: - `BuildNoAdVast()` - Tworzy pusty VAST (brak reklam) - `BuildSkeletonInlineVast()` - Tworzy minimalny szkielet VAST - `Marshal()` / `MarshalCompact()` - Serializacja do XML +- `clearInnerXML()` - Czyści pola `InnerXML` przed serializacją (zapobiega duplikowaniu elementów) + +> **Fix XML:** Struktury VAST używają tagu `,innerxml` do zachowania surowego XML podczas parsowania. Przed `Marshal()` wywoływana jest `clearInnerXML()`, która zeruje pola `InnerXML` na strukturach `Ad`, `InLine`, `Wrapper`, `Creative` i `Linear`, zapobiegając duplikowaniu elementów w wynikowym XML. #### `parser.go` @@ -255,23 +306,51 @@ Budowanie końcowego VAST XML: ### Jako Moduł PBS (Rekomendowane) -Moduł jest automatycznie wywoływany podczas pipeline aukcji gdy włączony w konfiguracji: +Moduł jest automatycznie wywoływany podczas pipeline aukcji gdy włączony w konfiguracji. -```yaml -# Konfiguracja PBS -hooks: - enabled_modules: - - prebid.ctv_vast_enrichment +**1. Upewnij się, że moduł jest zarejestrowany w `modules/builder.go`** (patrz sekcja "Rejestracja" wyżej). -modules: - prebid: - ctv_vast_enrichment: - enabled: true - default_currency: "USD" - receiver: "GAM_SSU" +**2. Dodaj konfigurację hooks do `pbs.json`:** + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "default_currency": "USD", + "receiver": "GAM_SSU" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } + } + } +} ``` -Nadpisanie na poziomie konta: +**3. Nadpisanie na poziomie konta** (opcjonalne): ```json { "hooks": { diff --git a/modules/prebid/ctv_vast_enrichment/README_EN.md b/modules/prebid/ctv_vast_enrichment/README_EN.md index c72dd29d384..6c15316b11c 100644 --- a/modules/prebid/ctv_vast_enrichment/README_EN.md +++ b/modules/prebid/ctv_vast_enrichment/README_EN.md @@ -32,7 +32,28 @@ modules/prebid/ctv_vast_enrichment/ ## PBS Module Integration -This module follows the standard Prebid Server module pattern: +This module follows the standard Prebid Server module pattern. + +### Registration in `modules/builder.go` + +The module must be registered in `modules/builder.go`, which is the central registry of all PBS modules: + +```go +import ( + prebidCtvVastEnrichment "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +) + +var newModuleBuilders = map[string]map[string]interface{}{ + "prebid": { + "ctv_vast_enrichment": prebidCtvVastEnrichment.Builder, + }, +} +``` + +> **Note:** The Go package name is `ctv_vast_enrichment`, but subpackages (enrich, select, format) use the `vast` alias when importing the parent package: +> ```go +> import vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +> ``` ### \`module.go\` - Main Entry Point @@ -59,7 +80,10 @@ The module runs at the **RawBidderResponse** hook stage, processing each bidder' 1. Parses the VAST XML from the bid's \`AdM\` field 2. Enriches the VAST with pricing, advertiser, and category metadata -3. Updates the bid's \`AdM\` with the enriched VAST XML +3. Creates a new `*adapters.TypedBid` with a new `*openrtb2.Bid` containing the enriched AdM +4. Returns the mutation via `changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids)` + +> **ChangeSet Pattern:** The hook does not modify the payload directly. Instead, it builds a new `[]adapters.TypedBid` slice and registers the mutation via `UpdateBids()`. PBS applies the mutation after the hook returns — following the pattern from the `ortb2blocking` module. ### Configuration @@ -176,11 +200,14 @@ Go structures mapping VAST XML elements: - \`Pricing\`, \`Impression\`, \`Extensions\` - Metadata and tracking Helper functions: -- \`BuildNoAdVast()\` - Creates empty VAST (no ads) -- \`BuildSkeletonInlineVast()\` - Creates minimal VAST skeleton -- \`Marshal()\` / \`MarshalCompact()\` - Serialize to XML +- `BuildNoAdVast()` - Creates empty VAST (no ads) +- `BuildSkeletonInlineVast()` - Creates minimal VAST skeleton +- `Marshal()` / `MarshalCompact()` - Serialize to XML +- `clearInnerXML()` - Clears `InnerXML` fields before serialization (prevents element duplication) + +> **XML Fix:** VAST structures use the `,innerxml` tag to preserve raw XML during parsing. Before `Marshal()`, `clearInnerXML()` is called to zero out `InnerXML` fields on `Ad`, `InLine`, `Wrapper`, `Creative`, and `Linear` structs, preventing duplicate elements in the output XML. -#### \`parser.go\` +#### `parser.go` VAST XML parser: @@ -255,24 +282,52 @@ Building final VAST XML: ### As PBS Module (Recommended) -The module is automatically invoked during the auction pipeline when enabled in configuration: +The module is automatically invoked during the auction pipeline when enabled in configuration. -\`\`\`yaml -# PBS config -hooks: - enabled_modules: - - prebid.ctv_vast_enrichment +**1. Ensure the module is registered in `modules/builder.go`** (see "Registration" section above). -modules: - prebid: - ctv_vast_enrichment: - enabled: true - default_currency: "USD" - receiver: "GAM_SSU" -\`\`\` +**2. Add hooks configuration to `pbs.json`:** -Account-level override: -\`\`\`json +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "default_currency": "USD", + "receiver": "GAM_SSU" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } + } + } +} +``` + +**3. Account-level override** (optional): +```json { "hooks": { "modules": { @@ -283,7 +338,7 @@ Account-level override: } } } -\`\`\` +``` ### Standalone Pipeline (for HTTP handler) From 7fb4bcb35bde95e16fc92363d3ec7ee9a6b87275 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Wed, 18 Feb 2026 16:28:24 +0000 Subject: [PATCH 07/15] fix pacage name --- modules/prebid/ctv_vast_enrichment/vast.go | 204 +++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 modules/prebid/ctv_vast_enrichment/vast.go diff --git a/modules/prebid/ctv_vast_enrichment/vast.go b/modules/prebid/ctv_vast_enrichment/vast.go new file mode 100644 index 00000000000..d304973fae6 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/vast.go @@ -0,0 +1,204 @@ +// Package vast provides CTV VAST processing capabilities for Prebid Server. +// +// This module handles the complete VAST workflow for Connected TV (CTV) ad serving: +// - Bid selection from OpenRTB auction responses +// - VAST ad enrichment with tracking and metadata +// - VAST XML formatting for various downstream receivers +// +// The package is organized into sub-packages: +// - model: VAST data structures +// - select: Bid selection logic +// - enrich: VAST ad enrichment +// - format: VAST XML formatting +// +// Example usage: +// +// cfg := vast.ReceiverConfig{ +// Receiver: vast.ReceiverGAMSSU, +// DefaultCurrency: "USD", +// VastVersionDefault: "4.0", +// MaxAdsInPod: 5, +// SelectionStrategy: vast.SelectionMaxRevenue, +// CollisionPolicy: vast.CollisionReject, +// } +// +// processor := vast.NewProcessor(cfg, selector, enricher, formatter) +// result := processor.Process(bidRequest, bidResponse) +package ctv_vast_enrichment + +import ( + "context" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" +) + +// BuildVastFromBidResponse orchestrates the complete VAST processing pipeline. +// It selects bids, parses/creates VAST, enriches ads, and formats final XML. +// +// Steps: +// 1. Select bids from response using configured strategy +// 2. Parse VAST from each bid's AdM (or create skeleton if allowed) +// 3. Enrich each ad with metadata (pricing, categories, etc.) +// 4. Format all ads into final VAST XML +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - req: OpenRTB bid request +// - resp: OpenRTB bid response from auction +// - cfg: Receiver configuration +// - selector: Bid selection implementation +// - enricher: VAST enrichment implementation +// - formatter: VAST formatting implementation +// +// Returns VastResult containing XML output, warnings, and selected bids. +func BuildVastFromBidResponse( + ctx context.Context, + req *openrtb2.BidRequest, + resp *openrtb2.BidResponse, + cfg ReceiverConfig, + selector BidSelector, + enricher Enricher, + formatter Formatter, +) (VastResult, error) { + result := VastResult{ + Warnings: make([]string, 0), + Errors: make([]error, 0), + } + + // Step 1: Select bids + selected, selectWarnings, err := selector.Select(req, resp, cfg) + if err != nil { + result.Errors = append(result.Errors, err) + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + return result, err + } + result.Warnings = append(result.Warnings, selectWarnings...) + result.Selected = selected + + // Step 2: Handle no bids case + if len(selected) == 0 { + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + return result, nil + } + + // Step 3: Parse and enrich each selected bid's VAST + enrichedAds := make([]EnrichedAd, 0, len(selected)) + + parserCfg := model.ParserConfig{ + AllowSkeletonVast: cfg.AllowSkeletonVast, + VastVersionDefault: cfg.VastVersionDefault, + } + + for _, sb := range selected { + // Parse VAST from AdM (or create skeleton) + parsedVast, parseWarnings, parseErr := model.ParseVastOrSkeleton(sb.Bid.AdM, parserCfg) + result.Warnings = append(result.Warnings, parseWarnings...) + + if parseErr != nil { + result.Warnings = append(result.Warnings, "failed to parse VAST for bid "+sb.Bid.ID+": "+parseErr.Error()) + continue + } + + // Extract the first Ad from parsed VAST + ad := model.ExtractFirstAd(parsedVast) + if ad == nil { + result.Warnings = append(result.Warnings, "no ad found in VAST for bid "+sb.Bid.ID) + continue + } + + // Enrich the ad with metadata + enrichWarnings, enrichErr := enricher.Enrich(ad, sb.Meta, cfg) + result.Warnings = append(result.Warnings, enrichWarnings...) + if enrichErr != nil { + result.Warnings = append(result.Warnings, "enrichment failed for bid "+sb.Bid.ID+": "+enrichErr.Error()) + // Continue with unenriched ad + } + + // Store enriched ad + enrichedAds = append(enrichedAds, EnrichedAd{ + Ad: ad, + Meta: sb.Meta, + Sequence: sb.Sequence, + }) + } + + // Step 4: Handle case where all bids failed parsing + if len(enrichedAds) == 0 { + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + result.Warnings = append(result.Warnings, "all selected bids failed VAST parsing") + return result, nil + } + + // Step 5: Format the final VAST XML + xmlBytes, formatWarnings, formatErr := formatter.Format(enrichedAds, cfg) + result.Warnings = append(result.Warnings, formatWarnings...) + + if formatErr != nil { + result.Errors = append(result.Errors, formatErr) + result.NoAd = true + result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) + return result, formatErr + } + + result.VastXML = xmlBytes + result.NoAd = false + + return result, nil +} + +// Processor orchestrates the VAST processing workflow. +type Processor struct { + selector BidSelector + enricher Enricher + formatter Formatter + config ReceiverConfig +} + +// NewProcessor creates a new Processor with the given configuration. +func NewProcessor(cfg ReceiverConfig, selector BidSelector, enricher Enricher, formatter Formatter) *Processor { + return &Processor{ + selector: selector, + enricher: enricher, + formatter: formatter, + config: cfg, + } +} + +// Process executes the complete VAST processing workflow. +func (p *Processor) Process(ctx context.Context, req *openrtb2.BidRequest, resp *openrtb2.BidResponse) VastResult { + result, _ := BuildVastFromBidResponse(ctx, req, resp, p.config, p.selector, p.enricher, p.formatter) + return result +} + +// DefaultConfig returns a default ReceiverConfig for GAM SSU. +func DefaultConfig() ReceiverConfig { + return ReceiverConfig{ + Receiver: ReceiverGAMSSU, + DefaultCurrency: "USD", + VastVersionDefault: "4.0", + MaxAdsInPod: 5, + SelectionStrategy: SelectionMaxRevenue, + CollisionPolicy: CollisionReject, + Placement: PlacementRules{ + Pricing: PricingRules{ + FloorCPM: 0, + CeilingCPM: 0, + Currency: "USD", + }, + Advertiser: AdvertiserRules{ + BlockedDomains: []string{}, + AllowedDomains: []string{}, + }, + Categories: CategoryRules{ + BlockedCategories: []string{}, + AllowedCategories: []string{}, + }, + Debug: false, + }, + Debug: false, + } +} From 6e4220b6d35ee379257510cc58ff626473b28ff9 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Wed, 18 Feb 2026 16:35:01 +0000 Subject: [PATCH 08/15] remove unnecesary file --- modules/prebid/ctv_vast_enrichment/vast.go | 204 --------------------- 1 file changed, 204 deletions(-) delete mode 100644 modules/prebid/ctv_vast_enrichment/vast.go diff --git a/modules/prebid/ctv_vast_enrichment/vast.go b/modules/prebid/ctv_vast_enrichment/vast.go deleted file mode 100644 index d304973fae6..00000000000 --- a/modules/prebid/ctv_vast_enrichment/vast.go +++ /dev/null @@ -1,204 +0,0 @@ -// Package vast provides CTV VAST processing capabilities for Prebid Server. -// -// This module handles the complete VAST workflow for Connected TV (CTV) ad serving: -// - Bid selection from OpenRTB auction responses -// - VAST ad enrichment with tracking and metadata -// - VAST XML formatting for various downstream receivers -// -// The package is organized into sub-packages: -// - model: VAST data structures -// - select: Bid selection logic -// - enrich: VAST ad enrichment -// - format: VAST XML formatting -// -// Example usage: -// -// cfg := vast.ReceiverConfig{ -// Receiver: vast.ReceiverGAMSSU, -// DefaultCurrency: "USD", -// VastVersionDefault: "4.0", -// MaxAdsInPod: 5, -// SelectionStrategy: vast.SelectionMaxRevenue, -// CollisionPolicy: vast.CollisionReject, -// } -// -// processor := vast.NewProcessor(cfg, selector, enricher, formatter) -// result := processor.Process(bidRequest, bidResponse) -package ctv_vast_enrichment - -import ( - "context" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/model" -) - -// BuildVastFromBidResponse orchestrates the complete VAST processing pipeline. -// It selects bids, parses/creates VAST, enriches ads, and formats final XML. -// -// Steps: -// 1. Select bids from response using configured strategy -// 2. Parse VAST from each bid's AdM (or create skeleton if allowed) -// 3. Enrich each ad with metadata (pricing, categories, etc.) -// 4. Format all ads into final VAST XML -// -// Parameters: -// - ctx: Context for cancellation and timeouts -// - req: OpenRTB bid request -// - resp: OpenRTB bid response from auction -// - cfg: Receiver configuration -// - selector: Bid selection implementation -// - enricher: VAST enrichment implementation -// - formatter: VAST formatting implementation -// -// Returns VastResult containing XML output, warnings, and selected bids. -func BuildVastFromBidResponse( - ctx context.Context, - req *openrtb2.BidRequest, - resp *openrtb2.BidResponse, - cfg ReceiverConfig, - selector BidSelector, - enricher Enricher, - formatter Formatter, -) (VastResult, error) { - result := VastResult{ - Warnings: make([]string, 0), - Errors: make([]error, 0), - } - - // Step 1: Select bids - selected, selectWarnings, err := selector.Select(req, resp, cfg) - if err != nil { - result.Errors = append(result.Errors, err) - result.NoAd = true - result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) - return result, err - } - result.Warnings = append(result.Warnings, selectWarnings...) - result.Selected = selected - - // Step 2: Handle no bids case - if len(selected) == 0 { - result.NoAd = true - result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) - return result, nil - } - - // Step 3: Parse and enrich each selected bid's VAST - enrichedAds := make([]EnrichedAd, 0, len(selected)) - - parserCfg := model.ParserConfig{ - AllowSkeletonVast: cfg.AllowSkeletonVast, - VastVersionDefault: cfg.VastVersionDefault, - } - - for _, sb := range selected { - // Parse VAST from AdM (or create skeleton) - parsedVast, parseWarnings, parseErr := model.ParseVastOrSkeleton(sb.Bid.AdM, parserCfg) - result.Warnings = append(result.Warnings, parseWarnings...) - - if parseErr != nil { - result.Warnings = append(result.Warnings, "failed to parse VAST for bid "+sb.Bid.ID+": "+parseErr.Error()) - continue - } - - // Extract the first Ad from parsed VAST - ad := model.ExtractFirstAd(parsedVast) - if ad == nil { - result.Warnings = append(result.Warnings, "no ad found in VAST for bid "+sb.Bid.ID) - continue - } - - // Enrich the ad with metadata - enrichWarnings, enrichErr := enricher.Enrich(ad, sb.Meta, cfg) - result.Warnings = append(result.Warnings, enrichWarnings...) - if enrichErr != nil { - result.Warnings = append(result.Warnings, "enrichment failed for bid "+sb.Bid.ID+": "+enrichErr.Error()) - // Continue with unenriched ad - } - - // Store enriched ad - enrichedAds = append(enrichedAds, EnrichedAd{ - Ad: ad, - Meta: sb.Meta, - Sequence: sb.Sequence, - }) - } - - // Step 4: Handle case where all bids failed parsing - if len(enrichedAds) == 0 { - result.NoAd = true - result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) - result.Warnings = append(result.Warnings, "all selected bids failed VAST parsing") - return result, nil - } - - // Step 5: Format the final VAST XML - xmlBytes, formatWarnings, formatErr := formatter.Format(enrichedAds, cfg) - result.Warnings = append(result.Warnings, formatWarnings...) - - if formatErr != nil { - result.Errors = append(result.Errors, formatErr) - result.NoAd = true - result.VastXML = model.BuildNoAdVast(cfg.VastVersionDefault) - return result, formatErr - } - - result.VastXML = xmlBytes - result.NoAd = false - - return result, nil -} - -// Processor orchestrates the VAST processing workflow. -type Processor struct { - selector BidSelector - enricher Enricher - formatter Formatter - config ReceiverConfig -} - -// NewProcessor creates a new Processor with the given configuration. -func NewProcessor(cfg ReceiverConfig, selector BidSelector, enricher Enricher, formatter Formatter) *Processor { - return &Processor{ - selector: selector, - enricher: enricher, - formatter: formatter, - config: cfg, - } -} - -// Process executes the complete VAST processing workflow. -func (p *Processor) Process(ctx context.Context, req *openrtb2.BidRequest, resp *openrtb2.BidResponse) VastResult { - result, _ := BuildVastFromBidResponse(ctx, req, resp, p.config, p.selector, p.enricher, p.formatter) - return result -} - -// DefaultConfig returns a default ReceiverConfig for GAM SSU. -func DefaultConfig() ReceiverConfig { - return ReceiverConfig{ - Receiver: ReceiverGAMSSU, - DefaultCurrency: "USD", - VastVersionDefault: "4.0", - MaxAdsInPod: 5, - SelectionStrategy: SelectionMaxRevenue, - CollisionPolicy: CollisionReject, - Placement: PlacementRules{ - Pricing: PricingRules{ - FloorCPM: 0, - CeilingCPM: 0, - Currency: "USD", - }, - Advertiser: AdvertiserRules{ - BlockedDomains: []string{}, - AllowedDomains: []string{}, - }, - Categories: CategoryRules{ - BlockedCategories: []string{}, - AllowedCategories: []string{}, - }, - Debug: false, - }, - Debug: false, - } -} From 7d0bf45e2f2d1fc5bb4c70b969292d365cd746d9 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Wed, 18 Mar 2026 15:30:02 +0000 Subject: [PATCH 09/15] fix code style --- .../prebid/ctv_vast_enrichment/model/model.go | 1 - .../ctv_vast_enrichment/model/parser_test.go | 24 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/modules/prebid/ctv_vast_enrichment/model/model.go b/modules/prebid/ctv_vast_enrichment/model/model.go index e15a3075f8e..46e00979b71 100644 --- a/modules/prebid/ctv_vast_enrichment/model/model.go +++ b/modules/prebid/ctv_vast_enrichment/model/model.go @@ -25,4 +25,3 @@ type VastAd struct { // RawVAST contains the original VAST XML if preserved. RawVAST []byte } - diff --git a/modules/prebid/ctv_vast_enrichment/model/parser_test.go b/modules/prebid/ctv_vast_enrichment/model/parser_test.go index 49f35ba0b42..b4043d8a1af 100644 --- a/modules/prebid/ctv_vast_enrichment/model/parser_test.go +++ b/modules/prebid/ctv_vast_enrichment/model/parser_test.go @@ -130,10 +130,10 @@ const ( ` - invalidXML = `Broken` - notVAST = `Not VAST` - emptyString = `` - justWhitespace = ` ` + invalidXML = `Broken` + notVAST = `Not VAST` + emptyString = `` + justWhitespace = ` ` ) func TestParseVastAdm_ValidVAST30(t *testing.T) { @@ -360,9 +360,9 @@ func TestParseVastFromBytes(t *testing.T) { func TestExtractFirstAd(t *testing.T) { tests := []struct { - name string - vast *Vast - expectID string + name string + vast *Vast + expectID string expectNil bool }{ { @@ -376,13 +376,13 @@ func TestExtractFirstAd(t *testing.T) { expectNil: true, }, { - name: "single ad", - vast: &Vast{Ads: []Ad{{ID: "first"}}}, + name: "single ad", + vast: &Vast{Ads: []Ad{{ID: "first"}}}, expectID: "first", }, { - name: "multiple ads", - vast: &Vast{Ads: []Ad{{ID: "first"}, {ID: "second"}}}, + name: "multiple ads", + vast: &Vast{Ads: []Ad{{ID: "first"}, {ID: "second"}}}, expectID: "first", }, } @@ -502,7 +502,7 @@ func TestParseVastAdm_PreservesInnerXML(t *testing.T) { // InnerXML fields should contain the unknown elements require.Len(t, vast.Ads, 1) require.NotNil(t, vast.Ads[0].InLine) - + // The InnerXML on InLine should contain CustomElement assert.Contains(t, vast.Ads[0].InLine.InnerXML, "CustomElement") } From d1ba65b8c2f61e4cb870d1fb06944bb3c3bfd831 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Tue, 5 May 2026 12:33:08 +0000 Subject: [PATCH 10/15] ctv_vast_enrichment: address PR review comments - Replace custom boolPtr/float64Ptr helpers with ptrutil.ToPtr from util/ptrutil - Remove TestBoolPtr/TestFloat64Ptr tests (covered by ptrutil package) - Fix enabled check: treat nil as disabled (explicit opt-in required), use IsEnabled() - Add isValidSelectionStrategy and isValidCollisionPolicy validation with fallback to default for unknown values - Add CollisionVastWins constant ('VAST_WINS') to CollisionPolicy - Change SelectedBid.Bid from value type to pointer (*openrtb2.Bid) - Add glog error logging in handler.go BuildVastFromBidResponse error path - Lazy-allocate modifiedBids slice only when enrichment actually occurs (avoid unnecessary allocation when no bids are enriched) - Update README.md and README_EN.md to reflect all above changes --- modules/prebid/ctv_vast_enrichment/README.md | 12 +++-- .../prebid/ctv_vast_enrichment/README_EN.md | 12 +++-- modules/prebid/ctv_vast_enrichment/config.go | 28 ++++++---- .../prebid/ctv_vast_enrichment/config_test.go | 53 ++++++++++--------- modules/prebid/ctv_vast_enrichment/handler.go | 4 +- modules/prebid/ctv_vast_enrichment/module.go | 37 ++++++++----- .../ctv_vast_enrichment/pipeline_test.go | 2 +- .../select/price_selector.go | 2 +- modules/prebid/ctv_vast_enrichment/types.go | 4 +- 9 files changed, 94 insertions(+), 60 deletions(-) diff --git a/modules/prebid/ctv_vast_enrichment/README.md b/modules/prebid/ctv_vast_enrichment/README.md index ffb381b28ae..81636f07a4f 100644 --- a/modules/prebid/ctv_vast_enrichment/README.md +++ b/modules/prebid/ctv_vast_enrichment/README.md @@ -131,6 +131,8 @@ Aby moduł został wywołany podczas aukcji, wymagana jest konfiguracja `host_ex Konfiguracja na poziomie konta nadpisuje ustawienia na poziomie hosta. +> **Enabled jest opt-in:** Moduł przetwarza bidy tylko gdy `enabled` jest jawnie ustawione na `true`. Brak wartości (`null`/nieobecne) jest traktowany jako `false` — moduł nie zostanie uruchomiony. + ## Komponenty ### `module.go` - Moduł PBS @@ -142,7 +144,9 @@ Główny punkt wejścia zgodny z konwencjami modułów PBS: - **`HandleRawBidderResponseHook()`** - Implementacja hooka: - Parsuje konfigurację na poziomie konta - Merguje konfiguracje hosta i konta - - Wzbogaca VAST w każdym bidzie video + - Pomija przetwarzanie gdy `enabled` nie jest jawnie `true` (opt-in) + - Wzbogaca VAST w każdym bidzie video; slice zmodyfikowanych bidów alokowany jest leniwie (tylko gdy enrichment faktycznie następuje) + - Loguje błędy przez `glog` gdy budowanie VAST się nie powiedzie ### `pipeline.go` - Samodzielny Pipeline @@ -170,8 +174,8 @@ Obsługa żądań HTTP dla reklam CTV VAST (opcjonalny endpoint): | Typ | Opis | |-----|------| | `ReceiverType` | Typ odbiorcy (GAM_SSU, GENERIC) | -| `SelectionStrategy` | Strategia selekcji bidów (SINGLE, TOP_N, MAX_REVENUE) | -| `CollisionPolicy` | Polityka kolizji (reject, warn, ignore) | +| `SelectionStrategy` | Strategia selekcji bidów (SINGLE, TOP_N, max_revenue, min_duration, balanced); nieznane wartości cofają się do domyślnej | +| `CollisionPolicy` | Polityka kolizji (reject, warn, ignore, VAST_WINS); nieznane wartości cofają się do domyślnej | **Interfejsy:** @@ -192,7 +196,7 @@ type Formatter interface { **Struktury Danych:** - `CanonicalMeta` - Znormalizowane metadane bida (BidID, Price, Currency, Adomain, itp.) -- `SelectedBid` - Wybrany bid z metadanymi i numerem sekwencji +- `SelectedBid` - Wybrany bid z metadanymi i numerem sekwencji; pole `Bid` jest wskaźnikiem `*openrtb2.Bid` (zgodnie z resztą PBS) - `EnrichedAd` - Wzbogacona reklama gotowa do formatowania - `VastResult` - Wynik przetwarzania (XML, ostrzeżenia, błędy) - `ReceiverConfig` - Konfiguracja odbiorcy VAST diff --git a/modules/prebid/ctv_vast_enrichment/README_EN.md b/modules/prebid/ctv_vast_enrichment/README_EN.md index 6c15316b11c..df86282bc6e 100644 --- a/modules/prebid/ctv_vast_enrichment/README_EN.md +++ b/modules/prebid/ctv_vast_enrichment/README_EN.md @@ -107,6 +107,8 @@ The module uses PBS-style layered configuration: Account-level configuration overrides host-level settings. +> **Enabled is opt-in:** The module processes bids only when `enabled` is explicitly set to `true`. A `null`/missing value is treated as `false` — the module will not run. + ## Components ### \`module.go\` - PBS Module @@ -118,7 +120,9 @@ Main entry point following PBS module conventions: - **\`HandleRawBidderResponseHook()\`** - Hook implementation that: - Parses account-level config - Merges host and account configs - - Enriches VAST in each video bid + - Skips processing when `enabled` is not explicitly `true` (opt-in) + - Enriches VAST in each video bid; allocates the modified bids slice lazily (only when enrichment actually occurs) + - Logs errors via `glog` when VAST building fails ### \`pipeline.go\` - Standalone Pipeline @@ -146,8 +150,8 @@ HTTP request handling for CTV VAST ads (optional endpoint): | Type | Description | |------|-------------| | \`ReceiverType\` | Receiver type (GAM_SSU, GENERIC) | -| \`SelectionStrategy\` | Bid selection strategy (SINGLE, TOP_N, MAX_REVENUE) | -| \`CollisionPolicy\` | Collision policy (reject, warn, ignore) | +| \`SelectionStrategy\` | Bid selection strategy (SINGLE, TOP_N, max_revenue, min_duration, balanced); unknown values fall back to default | +| \`CollisionPolicy\` | Collision policy (reject, warn, ignore, VAST_WINS) | **Interfaces:** @@ -168,7 +172,7 @@ type Formatter interface { **Data Structures:** - \`CanonicalMeta\` - Normalized bid metadata (BidID, Price, Currency, Adomain, etc.) -- \`SelectedBid\` - Selected bid with metadata and sequence number +- \`SelectedBid\` - Selected bid with metadata and sequence number; `Bid` field is `*openrtb2.Bid` (pointer, consistent with the rest of PBS) - \`EnrichedAd\` - Enriched ad ready for formatting - \`VastResult\` - Processing result (XML, warnings, errors) - \`ReceiverConfig\` - VAST receiver configuration diff --git a/modules/prebid/ctv_vast_enrichment/config.go b/modules/prebid/ctv_vast_enrichment/config.go index a03ea977ed0..7ce10c3b06d 100644 --- a/modules/prebid/ctv_vast_enrichment/config.go +++ b/modules/prebid/ctv_vast_enrichment/config.go @@ -266,15 +266,15 @@ func (cfg CTVVastConfig) ReceiverConfig() ReceiverConfig { rc.MaxAdsInPod = DefaultMaxAdsInPod } - // Apply selection strategy with default - if cfg.SelectionStrategy != "" { + // Apply selection strategy with default; fall back to default for unknown values + if cfg.SelectionStrategy != "" && isValidSelectionStrategy(cfg.SelectionStrategy) { rc.SelectionStrategy = SelectionStrategy(cfg.SelectionStrategy) } else { rc.SelectionStrategy = SelectionStrategy(DefaultSelectionStrategy) } - // Apply collision policy with default - if cfg.CollisionPolicy != "" { + // Apply collision policy with default; fall back to default for unknown values + if cfg.CollisionPolicy != "" && isValidCollisionPolicy(cfg.CollisionPolicy) { rc.CollisionPolicy = CollisionPolicy(cfg.CollisionPolicy) } else { rc.CollisionPolicy = CollisionPolicy(DefaultCollisionPolicy) @@ -358,12 +358,20 @@ func (cfg CTVVastConfig) IsEnabled() bool { return cfg.Enabled != nil && *cfg.Enabled } -// boolPtr is a helper function to create a pointer to a bool value. -func boolPtr(b bool) *bool { - return &b +// isValidCollisionPolicy reports whether s is a known CollisionPolicy value. +func isValidCollisionPolicy(s string) bool { + switch CollisionPolicy(s) { + case CollisionReject, CollisionWarn, CollisionIgnore, CollisionVastWins: + return true + } + return false } -// float64Ptr is a helper function to create a pointer to a float64 value. -func float64Ptr(f float64) *float64 { - return &f +// isValidSelectionStrategy reports whether s is a known SelectionStrategy value. +func isValidSelectionStrategy(s string) bool { + switch SelectionStrategy(s) { + case SelectionSingle, SelectionTopN, SelectionMaxRevenue, SelectionMinDuration, SelectionBalanced: + return true + } + return false } diff --git a/modules/prebid/ctv_vast_enrichment/config_test.go b/modules/prebid/ctv_vast_enrichment/config_test.go index 642e4635d51..0d0d52eafb3 100644 --- a/modules/prebid/ctv_vast_enrichment/config_test.go +++ b/modules/prebid/ctv_vast_enrichment/config_test.go @@ -3,6 +3,7 @@ package ctv_vast_enrichment import ( "testing" + "github.com/prebid/prebid-server/v4/util/ptrutil" "github.com/stretchr/testify/assert" ) @@ -164,6 +165,24 @@ func TestReceiverConfig_Defaults(t *testing.T) { assert.False(t, rc.Debug) } +func TestReceiverConfig_InvalidSelectionStrategyFallsBackToDefault(t *testing.T) { + cfg := CTVVastConfig{ + SelectionStrategy: "unknown_strategy", + } + rc := cfg.ReceiverConfig() + + assert.Equal(t, SelectionStrategy(DefaultSelectionStrategy), rc.SelectionStrategy) +} + +func TestReceiverConfig_InvalidCollisionPolicyFallsBackToDefault(t *testing.T) { + cfg := CTVVastConfig{ + CollisionPolicy: "unknown_policy", + } + rc := cfg.ReceiverConfig() + + assert.Equal(t, CollisionPolicy(DefaultCollisionPolicy), rc.CollisionPolicy) +} + func TestReceiverConfig_WithValues(t *testing.T) { debug := true cfg := CTVVastConfig{ @@ -252,12 +271,12 @@ func TestIsEnabled(t *testing.T) { }, { name: "true returns true", - enabled: boolPtr(true), + enabled: ptrutil.ToPtr(true), expected: true, }, { name: "false returns false", - enabled: boolPtr(false), + enabled: ptrutil.ToPtr(false), expected: false, }, } @@ -281,12 +300,12 @@ func TestMergeCTVVastConfig_FullLayerPrecedence(t *testing.T) { MaxAdsInPod: 5, SelectionStrategy: "max_revenue", CollisionPolicy: "reject", - Enabled: boolPtr(true), - Debug: boolPtr(false), + Enabled: ptrutil.ToPtr(true), + Debug: ptrutil.ToPtr(false), Placement: &PlacementRulesConfig{ Pricing: &PricingRulesConfig{ - FloorCPM: float64Ptr(1.0), - CeilingCPM: float64Ptr(100.0), + FloorCPM: ptrutil.ToPtr(1.0), + CeilingCPM: ptrutil.ToPtr(100.0), Currency: "GBP", }, Advertiser: &AdvertiserRulesConfig{ @@ -301,7 +320,7 @@ func TestMergeCTVVastConfig_FullLayerPrecedence(t *testing.T) { CollisionPolicy: "warn", Placement: &PlacementRulesConfig{ Pricing: &PricingRulesConfig{ - FloorCPM: float64Ptr(2.0), + FloorCPM: ptrutil.ToPtr(2.0), Currency: "EUR", }, Categories: &CategoryRulesConfig{ @@ -313,10 +332,10 @@ func TestMergeCTVVastConfig_FullLayerPrecedence(t *testing.T) { profile := &CTVVastConfig{ VastVersionDefault: "4.2", MaxAdsInPod: 3, - Debug: boolPtr(true), + Debug: ptrutil.ToPtr(true), Placement: &PlacementRulesConfig{ Pricing: &PricingRulesConfig{ - FloorCPM: float64Ptr(3.0), + FloorCPM: ptrutil.ToPtr(3.0), }, }, } @@ -370,19 +389,3 @@ func TestMergeCTVVastConfig_ZeroIntDoesNotOverride(t *testing.T) { assert.Equal(t, 5, result.MaxAdsInPod) // zero didn't override } - -func TestBoolPtr(t *testing.T) { - truePtr := boolPtr(true) - falsePtr := boolPtr(false) - - assert.NotNil(t, truePtr) - assert.True(t, *truePtr) - assert.NotNil(t, falsePtr) - assert.False(t, *falsePtr) -} - -func TestFloat64Ptr(t *testing.T) { - ptr := float64Ptr(1.5) - assert.NotNil(t, ptr) - assert.Equal(t, 1.5, *ptr) -} diff --git a/modules/prebid/ctv_vast_enrichment/handler.go b/modules/prebid/ctv_vast_enrichment/handler.go index 127b7686d2b..3f002eeec0c 100644 --- a/modules/prebid/ctv_vast_enrichment/handler.go +++ b/modules/prebid/ctv_vast_enrichment/handler.go @@ -4,6 +4,7 @@ import ( "context" "net/http" + "github.com/golang/glog" "github.com/prebid/openrtb/v20/openrtb2" ) @@ -85,8 +86,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Build VAST from bid response result, err := BuildVastFromBidResponse(ctx, bidRequest, bidResponse, h.Config, h.Selector, h.Enricher, h.Formatter) if err != nil { - // Log error but still try to return valid VAST - // result.VastXML should contain no-ad VAST + glog.Errorf("ctv_vast_enrichment: BuildVastFromBidResponse error: %v", err) } // Set response headers diff --git a/modules/prebid/ctv_vast_enrichment/module.go b/modules/prebid/ctv_vast_enrichment/module.go index 4061b2a9125..4732cbec248 100644 --- a/modules/prebid/ctv_vast_enrichment/module.go +++ b/modules/prebid/ctv_vast_enrichment/module.go @@ -64,8 +64,8 @@ func (m Module) HandleRawBidderResponseHook( // Merge configurations: host < account mergedCfg := MergeCTVVastConfig(&m.hostConfig, accountCfg, nil) - // Check if module is enabled - if mergedCfg.Enabled != nil && !*mergedCfg.Enabled { + // Check if module is enabled; nil is treated as disabled (explicit opt-in required) + if !mergedCfg.IsEnabled() { return result, nil } @@ -77,13 +77,15 @@ func (m Module) HandleRawBidderResponseHook( // Convert config to ReceiverConfig receiverCfg := configToReceiverConfig(mergedCfg) - // Build modified bids list - modifiedBids := make([]*adapters.TypedBid, 0, len(payload.BidderResponse.Bids)) - changesMade := false + // modifiedBids is allocated lazily — only when the first enrichment actually happens. + // Until then we track the index so we can back-fill originals if needed. + var modifiedBids []*adapters.TypedBid - for _, typedBid := range payload.BidderResponse.Bids { + for i, typedBid := range payload.BidderResponse.Bids { if typedBid == nil || typedBid.Bid == nil { - modifiedBids = append(modifiedBids, typedBid) + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } continue } @@ -91,7 +93,9 @@ func (m Module) HandleRawBidderResponseHook( // Skip non-video bids (no AdM or not VAST) if bid.AdM == "" { - modifiedBids = append(modifiedBids, typedBid) + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } continue } @@ -99,7 +103,9 @@ func (m Module) HandleRawBidderResponseHook( vastDoc, err := model.ParseVastAdm(bid.AdM) if err != nil { // Not valid VAST, skip enrichment - modifiedBids = append(modifiedBids, typedBid) + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } continue } @@ -120,10 +126,18 @@ func (m Module) HandleRawBidderResponseHook( xmlBytes, err := enrichedVast.Marshal() if err != nil { // Keep original bid on format error - modifiedBids = append(modifiedBids, typedBid) + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } continue } + // First enrichment: lazily allocate and back-fill all preceding original bids + if modifiedBids == nil { + modifiedBids = make([]*adapters.TypedBid, i, len(payload.BidderResponse.Bids)) + copy(modifiedBids, payload.BidderResponse.Bids[:i]) + } + // Create new bid with enriched VAST enrichedBid := &openrtb2.Bid{} *enrichedBid = *bid @@ -138,11 +152,10 @@ func (m Module) HandleRawBidderResponseHook( Seat: typedBid.Seat, } modifiedBids = append(modifiedBids, enrichedTypedBid) - changesMade = true } // If we made changes, set mutation via ChangeSet - if changesMade { + if modifiedBids != nil { changeSet := hookstage.ChangeSet[hookstage.RawBidderResponsePayload]{} changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids) result.ChangeSet = changeSet diff --git a/modules/prebid/ctv_vast_enrichment/pipeline_test.go b/modules/prebid/ctv_vast_enrichment/pipeline_test.go index e75408dc018..b409afdf13c 100644 --- a/modules/prebid/ctv_vast_enrichment/pipeline_test.go +++ b/modules/prebid/ctv_vast_enrichment/pipeline_test.go @@ -36,7 +36,7 @@ func (m *mockSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidRespon adomain = bid.ADomain[0] } selected = append(selected, SelectedBid{ - Bid: bid, + Bid: &bid, Seat: sb.Seat, Sequence: seq, Meta: CanonicalMeta{ diff --git a/modules/prebid/ctv_vast_enrichment/select/price_selector.go b/modules/prebid/ctv_vast_enrichment/select/price_selector.go index f3b4b53cc85..e34940be0b6 100644 --- a/modules/prebid/ctv_vast_enrichment/select/price_selector.go +++ b/modules/prebid/ctv_vast_enrichment/select/price_selector.go @@ -142,7 +142,7 @@ func (s *PriceSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidRespo } selectedBids[i] = vast.SelectedBid{ - Bid: bid, + Bid: &bid, Seat: bws.seat, Sequence: sequence, Meta: vast.CanonicalMeta{ diff --git a/modules/prebid/ctv_vast_enrichment/types.go b/modules/prebid/ctv_vast_enrichment/types.go index a082844854a..fd02253cd3a 100644 --- a/modules/prebid/ctv_vast_enrichment/types.go +++ b/modules/prebid/ctv_vast_enrichment/types.go @@ -43,6 +43,8 @@ const ( CollisionWarn CollisionPolicy = "warn" // CollisionIgnore ignores competitive separation rules. CollisionIgnore CollisionPolicy = "ignore" + // CollisionVastWins allows ads and VAST takes precedence. + CollisionVastWins CollisionPolicy = "VAST_WINS" ) // VastResult holds the complete result of VAST processing. @@ -62,7 +64,7 @@ type VastResult struct { // SelectedBid represents a bid that was selected for inclusion in the VAST response. type SelectedBid struct { // Bid is the OpenRTB bid object. - Bid openrtb2.Bid + Bid *openrtb2.Bid // Seat is the seat ID of the bidder. Seat string // Sequence is the position of this bid in the ad pod (1-indexed). From 294b482356ac43d896fe05687dcf6bb55c9f664e Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Wed, 13 May 2026 06:53:34 +0200 Subject: [PATCH 11/15] fix(ctv_vast_enrichment): resolve 12 bugs from ctv-bugs-and-resolve.md BUG 1 - Use BidderResponse.Currency for VAST currency instead of DefaultCurrency from host config. Falls back to DefaultCurrency then USD. BUG 2 - Align DefaultConfig() CollisionPolicy to CollisionVastWins (was CollisionReject, inconsistent with DefaultCollisionPolicy = VAST_WINS). BUG 3 - Replace private enrichVastDocument() with Enricher interface injection (EnricherFactory + init() in enrich/register.go) so all subpackage features (Duration, Categories, AdvertiserPlacement, PricingPlacement, debug extensions) are actually reached by the hook. BUG 4 - clearInnerXML() now only clears Ad/InLine/Wrapper InnerXML. Creative, Linear, MediaFiles and DSP extensions are preserved, preventing silent modification of creatives beyond the module's declared scope. BUG 5 - Add explicit BidType != BidTypeVideo guard before VAST parsing. Banner and native bids now skip enrichment by type assertion, not parse error. BUG 6 - Copy BidMeta to enriched TypedBid. Prevents loss of NetworkID, AdvertiserID, BrandID and other fields used for targeting and analytics. BUG 7 - Replace strings.Join(ADomain, ',') with primaryDomain() helper. VAST is a single string; joining multiple domains is non-standard. BUG 8 - Remove duplicate configToReceiverConfig() from module.go. Replace call site with canonical mergedCfg.ReceiverConfig() from config.go. BUG 9 - Add //go:build ignore to handler.go. The handler is a non-functional placeholder with no router registration or exchange.Exchange integration. BUG 10 - DefaultConfig() VastVersionDefault now uses DefaultVastVersion constant (3.0) instead of hardcoded 4.0, aligned with config.go. BUG 11 - handler.go (containing glog usage) excluded from build by //go:build ignore. BUG 12 - Replace README.md (Polish) with README_EN.md (English). Rename original Polish README to README_PL.md. Also adds: - enrich/register.go: init() registers VastEnricher via EnricherFactory to avoid import cycle between parent package and enrich subpackage - modules/builder.go: blank import of enrich package to trigger init() - module_e2e_test.go: end-to-end tests covering all 12 bug scenarios - Update module_test.go and pipeline_test.go to match fixed behaviour All 169 tests pass, full project builds clean. --- modules/builder.go | 1 + modules/prebid/ctv_vast_enrichment/README.md | 440 ++++----- .../prebid/ctv_vast_enrichment/README_PL.md | 484 ++++++++++ .../ctv_vast_enrichment/enrich/register.go | 15 + modules/prebid/ctv_vast_enrichment/handler.go | 32 +- .../ctv_vast_enrichment/model/vast_xml.go | 18 +- modules/prebid/ctv_vast_enrichment/module.go | 180 ++-- .../ctv_vast_enrichment/module_e2e_test.go | 886 ++++++++++++++++++ .../prebid/ctv_vast_enrichment/module_test.go | 43 +- .../prebid/ctv_vast_enrichment/pipeline.go | 10 +- .../ctv_vast_enrichment/pipeline_test.go | 180 +--- 11 files changed, 1734 insertions(+), 555 deletions(-) create mode 100644 modules/prebid/ctv_vast_enrichment/README_PL.md create mode 100644 modules/prebid/ctv_vast_enrichment/enrich/register.go create mode 100644 modules/prebid/ctv_vast_enrichment/module_e2e_test.go diff --git a/modules/builder.go b/modules/builder.go index 79fd297023b..28616346eb4 100644 --- a/modules/builder.go +++ b/modules/builder.go @@ -3,6 +3,7 @@ package modules import ( fiftyonedegreesDevicedetection "github.com/prebid/prebid-server/v4/modules/fiftyonedegrees/devicedetection" prebidCtvVastEnrichment "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" + _ "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/enrich" // registers VastEnricher via init() prebidOrtb2blocking "github.com/prebid/prebid-server/v4/modules/prebid/ortb2blocking" prebidRulesengine "github.com/prebid/prebid-server/v4/modules/prebid/rulesengine" wurflDevicedetection "github.com/prebid/prebid-server/v4/modules/scientiamobile/wurfl_devicedetection" diff --git a/modules/prebid/ctv_vast_enrichment/README.md b/modules/prebid/ctv_vast_enrichment/README.md index 81636f07a4f..df86282bc6e 100644 --- a/modules/prebid/ctv_vast_enrichment/README.md +++ b/modules/prebid/ctv_vast_enrichment/README.md @@ -1,42 +1,42 @@ -# Moduł CTV VAST Enrichment +# CTV VAST Enrichment Module -Moduł CTV VAST Enrichment to moduł hook Prebid Server, który wzbogaca odpowiedzi VAST (Video Ad Serving Template) o dodatkowe metadane dla reklam Connected TV (CTV). +The CTV VAST Enrichment module is a Prebid Server hook module that enriches VAST (Video Ad Serving Template) XML responses with additional metadata for Connected TV (CTV) ads. -## Struktura Modułu +## Module Structure ``` modules/prebid/ctv_vast_enrichment/ -├── module.go # Punkt wejścia modułu PBS (Builder + HandleRawBidderResponseHook) -├── module_test.go # Testy modułu -├── pipeline.go # Samodzielny pipeline przetwarzania VAST -├── pipeline_test.go # Testy pipeline -├── handler.go # Handler HTTP dla bezpośrednich żądań VAST -├── types.go # Definicje typów, interfejsy i stałe -├── config.go # Konfiguracja i mergowanie warstw (host/account/profile) -├── config_test.go # Testy konfiguracji -├── model/ # Struktury danych VAST XML -│ ├── model.go # Obiekty domenowe wysokiego poziomu -│ ├── vast_xml.go # Struktury XML do marshal/unmarshal -│ ├── parser.go # Parser VAST XML -│ └── *_test.go # Testy -├── select/ # Logika selekcji bidów -│ ├── selector.go # Implementacje BidSelector -│ └── *_test.go # Testy -├── enrich/ # Wzbogacanie VAST -│ ├── enrich.go # Implementacja Enricher (VAST_WINS) -│ └── *_test.go # Testy -└── format/ # Formatowanie VAST XML - ├── format.go # Implementacja Formatter (GAM_SSU) - └── *_test.go # Testy +├── module.go # PBS module entry point (Builder + HandleRawBidderResponseHook) +├── module_test.go # Module tests +├── pipeline.go # Standalone VAST processing pipeline +├── pipeline_test.go # Pipeline tests +├── handler.go # HTTP handler for direct VAST requests +├── types.go # Type definitions, interfaces and constants +├── config.go # Configuration and layer merging (host/account/profile) +├── config_test.go # Configuration tests +├── model/ # VAST XML data structures +│ ├── model.go # High-level domain objects +│ ├── vast_xml.go # XML structures for marshal/unmarshal +│ ├── parser.go # VAST XML parser +│ └── *_test.go # Tests +├── select/ # Bid selection logic +│ ├── selector.go # BidSelector implementations +│ └── *_test.go # Tests +├── enrich/ # VAST enrichment +│ ├── enrich.go # Enricher implementation (VAST_WINS) +│ └── *_test.go # Tests +└── format/ # VAST XML formatting + ├── format.go # Formatter implementation (GAM_SSU) + └── *_test.go # Tests ``` -## Integracja z PBS +## PBS Module Integration -Moduł jest zgodny ze standardowym wzorcem modułów Prebid Server. +This module follows the standard Prebid Server module pattern. -### Rejestracja w `modules/builder.go` +### Registration in `modules/builder.go` -Moduł musi być zarejestrowany w pliku `modules/builder.go`, który jest centralnym rejestrem wszystkich modułów PBS: +The module must be registered in `modules/builder.go`, which is the central registry of all PBS modules: ```go import ( @@ -50,136 +50,112 @@ var newModuleBuilders = map[string]map[string]interface{}{ } ``` -> **Uwaga:** Nazwa pakietu Go to `ctv_vast_enrichment`, ale subpakiety (enrich, select, format) używają aliasu `vast` do importu pakietu nadrzędnego: +> **Note:** The Go package name is `ctv_vast_enrichment`, but subpackages (enrich, select, format) use the `vast` alias when importing the parent package: > ```go > import vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" > ``` -### `module.go` - Główny Punkt Wejścia +### \`module.go\` - Main Entry Point -```go -// Builder tworzy nową instancję modułu CTV VAST enrichment. +\`\`\`go +// Builder creates a new CTV VAST enrichment module instance. func Builder(cfg json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, error) -// Module implementuje funkcjonalność wzbogacania CTV VAST jako moduł hook PBS. +// Module implements the CTV VAST enrichment functionality as a PBS hook module. type Module struct { hostConfig CTVVastConfig } -// HandleRawBidderResponseHook przetwarza odpowiedzi bidderów, wzbogacając VAST XML. +// HandleRawBidderResponseHook processes bidder responses to enrich VAST XML. func (m Module) HandleRawBidderResponseHook( ctx context.Context, miCtx hookstage.ModuleInvocationContext, payload hookstage.RawBidderResponsePayload, ) (hookstage.HookResult[hookstage.RawBidderResponsePayload], error) -``` +\`\`\` ### Hook Stage -Moduł działa na etapie hooka **RawBidderResponse**, przetwarzając odpowiedź każdego biddera przed agregacją. Dla każdego bida zawierającego VAST XML: +The module runs at the **RawBidderResponse** hook stage, processing each bidder's response before aggregation. For each bid containing VAST XML: -1. Parsuje VAST XML z pola `AdM` bida -2. Wzbogaca VAST o pricing, advertiser i metadane kategorii -3. Tworzy nowy `*adapters.TypedBid` z nowym `*openrtb2.Bid` zawierającym wzbogacony AdM -4. Zwraca mutację przez `changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids)` +1. Parses the VAST XML from the bid's \`AdM\` field +2. Enriches the VAST with pricing, advertiser, and category metadata +3. Creates a new `*adapters.TypedBid` with a new `*openrtb2.Bid` containing the enriched AdM +4. Returns the mutation via `changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids)` -> **Wzorzec ChangeSet:** Hook nie modyfikuje payload bezpośrednio. Zamiast tego buduje nowy slice `[]adapters.TypedBid` i rejestruje mutację przez `UpdateBids()`. PBS stosuje mutację po powrocie z hooka — zgodnie ze wzorcem z modułu `ortb2blocking`. +> **ChangeSet Pattern:** The hook does not modify the payload directly. Instead, it builds a new `[]adapters.TypedBid` slice and registers the mutation via `UpdateBids()`. PBS applies the mutation after the hook returns — following the pattern from the `ortb2blocking` module. -### Konfiguracja PBS (`pbs.json`) +### Configuration -Aby moduł został wywołany podczas aukcji, wymagana jest konfiguracja `host_execution_plan` w sekcji `hooks`: +The module uses PBS-style layered configuration: -```json +\`\`\`json { - "hooks": { - "enabled": true, - "modules": { - "prebid": { - "ctv_vast_enrichment": { - "enabled": true, - "receiver": "GAM_SSU", - "default_currency": "USD" - } - } - }, - "host_execution_plan": { - "endpoints": { - "/openrtb2/auction": { - "stages": { - "raw_bidder_response": { - "groups": [ - { - "timeout": 1000, - "hook_sequence": [ - { - "module_code": "prebid.ctv_vast_enrichment", - "hook_impl_code": "code123" - } - ] - } - ] - } - } - } + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD", + "vast_version_default": "3.0", + "max_ads_in_pod": 10 } } } } -``` - -> **Ważne:** Sam `enabled_modules` nie wystarczy. PBS wymaga jawnego `host_execution_plan` z definicją stage `raw_bidder_response` i `module_code: "prebid.ctv_vast_enrichment"`, aby hook został faktycznie wywołany. +\`\`\` -Konfiguracja na poziomie konta nadpisuje ustawienia na poziomie hosta. +Account-level configuration overrides host-level settings. -> **Enabled jest opt-in:** Moduł przetwarza bidy tylko gdy `enabled` jest jawnie ustawione na `true`. Brak wartości (`null`/nieobecne) jest traktowany jako `false` — moduł nie zostanie uruchomiony. +> **Enabled is opt-in:** The module processes bids only when `enabled` is explicitly set to `true`. A `null`/missing value is treated as `false` — the module will not run. -## Komponenty +## Components -### `module.go` - Moduł PBS +### \`module.go\` - PBS Module -Główny punkt wejścia zgodny z konwencjami modułów PBS: +Main entry point following PBS module conventions: -- **`Builder()`** - Tworzy instancję modułu z konfiguracji JSON -- **`Module`** - Struktura przechowująca konfigurację na poziomie hosta -- **`HandleRawBidderResponseHook()`** - Implementacja hooka: - - Parsuje konfigurację na poziomie konta - - Merguje konfiguracje hosta i konta - - Pomija przetwarzanie gdy `enabled` nie jest jawnie `true` (opt-in) - - Wzbogaca VAST w każdym bidzie video; slice zmodyfikowanych bidów alokowany jest leniwie (tylko gdy enrichment faktycznie następuje) - - Loguje błędy przez `glog` gdy budowanie VAST się nie powiedzie +- **\`Builder()\`** - Creates module instance from JSON config +- **\`Module\`** - Struct holding host-level configuration +- **\`HandleRawBidderResponseHook()\`** - Hook implementation that: + - Parses account-level config + - Merges host and account configs + - Skips processing when `enabled` is not explicitly `true` (opt-in) + - Enriches VAST in each video bid; allocates the modified bids slice lazily (only when enrichment actually occurs) + - Logs errors via `glog` when VAST building fails -### `pipeline.go` - Samodzielny Pipeline +### \`pipeline.go\` - Standalone Pipeline -Alternatywny punkt wejścia do bezpośredniego wywołania (używany przez handler.go): +Alternative entry point for direct invocation (used by handler.go): -- **`BuildVastFromBidResponse()`** - Orkiestruje pełny pipeline: - 1. Selekcja bidów z odpowiedzi aukcji - 2. Parsowanie VAST z AdM każdego bida - 3. Wzbogacanie metadanymi - 4. Formatowanie do końcowego XML +- **\`BuildVastFromBidResponse()\`** - Orchestrates the full pipeline: + 1. Bid selection from auction response + 2. VAST parsing from each bid's AdM + 3. Enrichment with metadata + 4. Formatting to final XML -- **`Processor`** - Wrapper z wstrzykniętymi zależnościami -- **`DefaultConfig()`** - Domyślna konfiguracja dla GAM SSU +- **\`Processor\`** - Wrapper with injected dependencies +- **\`DefaultConfig()\`** - Default configuration for GAM SSU -### `handler.go` - Handler HTTP +### \`handler.go\` - HTTP Handler -Obsługa żądań HTTP dla reklam CTV VAST (opcjonalny endpoint): +HTTP request handling for CTV VAST ads (optional endpoint): -- **`Handler`** - Handler HTTP z konfiguracją i zależnościami -- **`ServeHTTP()`** - Obsługuje żądania GET, zwraca VAST XML -- Metody buildera: `WithConfig()`, `WithSelector()`, itp. +- **\`Handler\`** - HTTP handler with configuration and dependencies +- **\`ServeHTTP()\`** - Handles GET requests, returns VAST XML +- Builder methods: \`WithConfig()\`, \`WithSelector()\`, etc. -### `types.go` - Typy i Interfejsy +### \`types.go\` - Types and Interfaces -| Typ | Opis | -|-----|------| -| `ReceiverType` | Typ odbiorcy (GAM_SSU, GENERIC) | -| `SelectionStrategy` | Strategia selekcji bidów (SINGLE, TOP_N, max_revenue, min_duration, balanced); nieznane wartości cofają się do domyślnej | -| `CollisionPolicy` | Polityka kolizji (reject, warn, ignore, VAST_WINS); nieznane wartości cofają się do domyślnej | +| Type | Description | +|------|-------------| +| \`ReceiverType\` | Receiver type (GAM_SSU, GENERIC) | +| \`SelectionStrategy\` | Bid selection strategy (SINGLE, TOP_N, max_revenue, min_duration, balanced); unknown values fall back to default | +| \`CollisionPolicy\` | Collision policy (reject, warn, ignore, VAST_WINS) | -**Interfejsy:** +**Interfaces:** -```go +\`\`\`go type BidSelector interface { Select(req, resp, cfg) ([]SelectedBid, []string, error) } @@ -191,98 +167,98 @@ type Enricher interface { type Formatter interface { Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) } -``` +\`\`\` -**Struktury Danych:** +**Data Structures:** -- `CanonicalMeta` - Znormalizowane metadane bida (BidID, Price, Currency, Adomain, itp.) -- `SelectedBid` - Wybrany bid z metadanymi i numerem sekwencji; pole `Bid` jest wskaźnikiem `*openrtb2.Bid` (zgodnie z resztą PBS) -- `EnrichedAd` - Wzbogacona reklama gotowa do formatowania -- `VastResult` - Wynik przetwarzania (XML, ostrzeżenia, błędy) -- `ReceiverConfig` - Konfiguracja odbiorcy VAST -- `PlacementRules` - Reguły walidacji (pricing, advertiser, categories) +- \`CanonicalMeta\` - Normalized bid metadata (BidID, Price, Currency, Adomain, etc.) +- \`SelectedBid\` - Selected bid with metadata and sequence number; `Bid` field is `*openrtb2.Bid` (pointer, consistent with the rest of PBS) +- \`EnrichedAd\` - Enriched ad ready for formatting +- \`VastResult\` - Processing result (XML, warnings, errors) +- \`ReceiverConfig\` - VAST receiver configuration +- \`PlacementRules\` - Validation rules (pricing, advertiser, categories) -### `config.go` - Konfiguracja +### \`config.go\` - Configuration -Warstwowy system konfiguracji w stylu PBS: +PBS-style layered configuration system: -- **`CTVVastConfig`** - Struktura konfiguracji z polami nullable -- **`MergeCTVVastConfig()`** - Mergowanie warstw: Host → Account → Profile +- **\`CTVVastConfig\`** - Configuration structure with nullable fields +- **\`MergeCTVVastConfig()\`** - Layer merging: Host → Account → Profile -Priorytet warstw (od najniższego do najwyższego): -1. Host (domyślne) -2. Account (nadpisuje host) -3. Profile (nadpisuje wszystko) +Layer priority (from lowest to highest): +1. Host (defaults) +2. Account (overrides host) +3. Profile (overrides everything) -### `model/` - Struktury VAST XML +### \`model/\` - VAST XML Structures -#### `vast_xml.go` +#### \`vast_xml.go\` -Struktury Go mapujące elementy VAST XML: +Go structures mapping VAST XML elements: -- `Vast` - Element główny `` -- `Ad` - Element `` z atrybutami id, sequence -- `InLine` - Reklama inline z pełnymi danymi -- `Wrapper` - Reklama wrapper (przekierowanie) -- `Creative`, `Linear`, `MediaFile` - Elementy kreacji -- `Pricing`, `Impression`, `Extensions` - Metadane i tracking +- \`Vast\` - Root element \`\` +- \`Ad\` - Element \`\` with id, sequence attributes +- \`InLine\` - Inline ad with full data +- \`Wrapper\` - Wrapper ad (redirect) +- \`Creative\`, \`Linear\`, \`MediaFile\` - Creative elements +- \`Pricing\`, \`Impression\`, \`Extensions\` - Metadata and tracking -Funkcje pomocnicze: -- `BuildNoAdVast()` - Tworzy pusty VAST (brak reklam) -- `BuildSkeletonInlineVast()` - Tworzy minimalny szkielet VAST -- `Marshal()` / `MarshalCompact()` - Serializacja do XML -- `clearInnerXML()` - Czyści pola `InnerXML` przed serializacją (zapobiega duplikowaniu elementów) +Helper functions: +- `BuildNoAdVast()` - Creates empty VAST (no ads) +- `BuildSkeletonInlineVast()` - Creates minimal VAST skeleton +- `Marshal()` / `MarshalCompact()` - Serialize to XML +- `clearInnerXML()` - Clears `InnerXML` fields before serialization (prevents element duplication) -> **Fix XML:** Struktury VAST używają tagu `,innerxml` do zachowania surowego XML podczas parsowania. Przed `Marshal()` wywoływana jest `clearInnerXML()`, która zeruje pola `InnerXML` na strukturach `Ad`, `InLine`, `Wrapper`, `Creative` i `Linear`, zapobiegając duplikowaniu elementów w wynikowym XML. +> **XML Fix:** VAST structures use the `,innerxml` tag to preserve raw XML during parsing. Before `Marshal()`, `clearInnerXML()` is called to zero out `InnerXML` fields on `Ad`, `InLine`, `Wrapper`, `Creative`, and `Linear` structs, preventing duplicate elements in the output XML. #### `parser.go` -Parser VAST XML: +VAST XML parser: -- **`ParseVastAdm()`** - Parsuje string AdM do struktury Vast -- **`ParseVastOrSkeleton()`** - Parsuje lub tworzy szkielet jeśli dozwolone -- **`ExtractFirstAd()`** - Wyciąga pierwszą reklamę z VAST +- **\`ParseVastAdm()\`** - Parses AdM string to Vast structure +- **\`ParseVastOrSkeleton()\`** - Parses or creates skeleton if allowed +- **\`ExtractFirstAd()\`** - Extracts first ad from VAST -### `select/` - Selekcja Bidów +### \`select/\` - Bid Selection -Logika wyboru bidów z odpowiedzi aukcji: +Logic for selecting bids from auction response: -- **`PriceSelector`** - Implementacja oparta na cenie: - - Filtruje bidy z ceną ≤ 0 lub pustym AdM - - Sortuje: deal > non-deal, potem po cenie malejąco - - Respektuje `MaxAdsInPod` dla strategii TOP_N - - Przypisuje numery sekwencji (1-indexed) +- **\`PriceSelector\`** - Price-based implementation: + - Filters bids with price ≤ 0 or empty AdM + - Sorts: deal > non-deal, then by price descending + - Respects \`MaxAdsInPod\` for TOP_N strategy + - Assigns sequence numbers (1-indexed) -- **`NewSelector(strategy)`** - Fabryka tworząca selektor dla strategii +- **\`NewSelector(strategy)\`** - Factory creating selector for strategy -### `enrich/` - Wzbogacanie VAST +### \`enrich/\` - VAST Enrichment -Dodawanie metadanych do reklam VAST: +Adding metadata to VAST ads: -- **`VastEnricher`** - Implementacja z polityką VAST_WINS: - - Istniejące wartości w VAST nie są nadpisywane - - Dodaje brakujące: Pricing, Advertiser, Duration, Categories +- **\`VastEnricher\`** - Implementation with VAST_WINS policy: + - Existing values in VAST are not overwritten + - Adds missing: Pricing, Advertiser, Duration, Categories -Wzbogacane elementy: -| Element | Źródło | Lokalizacja | -|---------|--------|-------------| -| Pricing | meta.Price | `` lub Extension | -| Advertiser | meta.Adomain | `` lub Extension | -| Duration | meta.DurSec | `` w Linear | -| Categories | meta.Cats | Extension (zawsze) | +Enriched elements: +| Element | Source | Location | +|---------|--------|----------| +| Pricing | meta.Price | \`\` or Extension | +| Advertiser | meta.Adomain | \`\` or Extension | +| Duration | meta.DurSec | \`\` in Linear | +| Categories | meta.Cats | Extension (always) | -### `format/` - Formatowanie VAST +### \`format/\` - VAST Formatting -Budowanie końcowego VAST XML: +Building final VAST XML: -- **`VastFormatter`** - Implementacja GAM SSU: - - Buduje dokument VAST z listą elementów `` - - Ustawia `id` z BidID - - Ustawia `sequence` dla podów (wiele reklam) +- **\`VastFormatter\`** - GAM SSU implementation: + - Builds VAST document with list of \`\` elements + - Sets \`id\` from BidID + - Sets \`sequence\` for pods (multiple ads) -## Przepływ Przetwarzania +## Processing Flow -``` +\`\`\` ┌─────────────────────────────────────────────────────┐ │ PBS Auction Pipeline │ └─────────────────────────────────────────────────────┘ @@ -292,29 +268,29 @@ Budowanie końcowego VAST XML: │ RawBidderResponse Hook Stage │ │ ┌───────────────────────────────────────────────┐ │ │ │ HandleRawBidderResponseHook() │ │ -│ │ Dla każdego bida z VAST w AdM: │ │ -│ │ 1. Parsuje VAST XML │ │ -│ │ 2. Wzbogaca o pricing/advertiser │ │ -│ │ 3. Aktualizuje bid.AdM │ │ +│ │ For each bid with VAST in AdM: │ │ +│ │ 1. Parse VAST XML │ │ +│ │ 2. Enrich with pricing/advertiser │ │ +│ │ 3. Update bid.AdM │ │ │ └───────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ -│ Wzbogacona BidderResponse │ -│ (VAST z , itp.) │ +│ Enriched BidderResponse │ +│ (VAST with , etc.) │ └─────────────────────────────────────────────────────┘ -``` +\`\`\` -## Użycie +## Usage -### Jako Moduł PBS (Rekomendowane) +### As PBS Module (Recommended) -Moduł jest automatycznie wywoływany podczas pipeline aukcji gdy włączony w konfiguracji. +The module is automatically invoked during the auction pipeline when enabled in configuration. -**1. Upewnij się, że moduł jest zarejestrowany w `modules/builder.go`** (patrz sekcja "Rejestracja" wyżej). +**1. Ensure the module is registered in `modules/builder.go`** (see "Registration" section above). -**2. Dodaj konfigurację hooks do `pbs.json`:** +**2. Add hooks configuration to `pbs.json`:** ```json { @@ -354,7 +330,7 @@ Moduł jest automatycznie wywoływany podczas pipeline aukcji gdy włączony w k } ``` -**3. Nadpisanie na poziomie konta** (opcjonalne): +**3. Account-level override** (optional): ```json { "hooks": { @@ -368,9 +344,9 @@ Moduł jest automatycznie wywoływany podczas pipeline aukcji gdy włączony w k } ``` -### Samodzielny Pipeline (dla handlera HTTP) +### Standalone Pipeline (for HTTP handler) -```go +\`\`\`go import ( vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/enrich" @@ -378,16 +354,16 @@ import ( bidselect "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/select" ) -// Konfiguracja +// Configuration cfg := vast.DefaultConfig() cfg.MaxAdsInPod = 3 -// Tworzenie komponentów +// Create components selector := bidselect.NewSelector(cfg.SelectionStrategy) enricher := enrich.NewEnricher() formatter := format.NewFormatter() -// Bezpośrednie wywołanie +// Direct invocation result, err := vast.BuildVastFromBidResponse( ctx, bidRequest, @@ -397,11 +373,11 @@ result, err := vast.BuildVastFromBidResponse( enricher, formatter, ) -``` +\`\`\` -### Handler HTTP +### HTTP Handler -```go +\`\`\`go handler := vast.NewHandler(). WithConfig(cfg). WithSelector(selector). @@ -410,75 +386,75 @@ handler := vast.NewHandler(). WithAuctionFunc(myAuctionFunc) http.Handle("/vast", handler) -``` +\`\`\` -## Konfiguracja Warstwowa +## Layer Configuration -```go -// Konfiguracja hosta (domyślne) +\`\`\`go +// Host configuration (defaults) hostCfg := &vast.CTVVastConfig{ Receiver: "GAM_SSU", DefaultCurrency: "USD", VastVersionDefault: "4.0", } -// Konfiguracja konta (nadpisuje host) +// Account configuration (overrides host) accountCfg := &vast.CTVVastConfig{ MaxAdsInPod: 5, } -// Merge warstw +// Merge layers merged := vast.MergeCTVVastConfig(hostCfg, accountCfg, nil) -``` +\`\`\` -## Testowanie +## Testing -Uruchom wszystkie testy modułu: +Run all module tests: -```bash +\`\`\`bash go test ./modules/prebid/ctv_vast_enrichment/... -v -``` +\`\`\` -Testy z pokryciem: +Tests with coverage: -```bash +\`\`\`bash go test ./modules/prebid/ctv_vast_enrichment/... -cover -``` +\`\`\` -Uruchom tylko testy module.go: +Run only module.go tests: -```bash +\`\`\`bash go test ./modules/prebid/ctv_vast_enrichment -run TestBuilder -v go test ./modules/prebid/ctv_vast_enrichment -run TestHandleRawBidderResponseHook -v -``` +\`\`\` -## Rozszerzenia +## Extensions -### Dodawanie Nowego Odbiorcy +### Adding a New Receiver -1. Dodaj stałą w `types.go`: - ```go +1. Add constant in \`types.go\`: + \`\`\`go ReceiverMyReceiver ReceiverType = "MY_RECEIVER" - ``` + \`\`\` -2. Zaimplementuj `Formatter` dla nowego formatu w `format/` +2. Implement \`Formatter\` for the new format in \`format/\` -3. Zaktualizuj `configToReceiverConfig()` w `module.go` +3. Update \`configToReceiverConfig()\` in \`module.go\` -### Dodawanie Nowej Strategii Selekcji +### Adding a New Selection Strategy -1. Dodaj stałą w `types.go`: - ```go +1. Add constant in \`types.go\`: + \`\`\`go SelectionMyStrategy SelectionStrategy = "MY_STRATEGY" - ``` + \`\`\` -2. Zaimplementuj `BidSelector` w `select/` +2. Implement \`BidSelector\` in \`select/\` -3. Zaktualizuj fabrykę `NewSelector()` +3. Update \`NewSelector()\` factory -## Zależności +## Dependencies -- `github.com/prebid/prebid-server/v3/hooks/hookstage` - Interfejsy hooków PBS -- `github.com/prebid/prebid-server/v3/modules/moduledeps` - Zależności modułów -- `github.com/prebid/openrtb/v20/openrtb2` - Typy OpenRTB -- `encoding/xml` - Parsowanie/serializacja XML +- \`github.com/prebid/prebid-server/v3/hooks/hookstage\` - PBS hook interfaces +- \`github.com/prebid/prebid-server/v3/modules/moduledeps\` - Module dependencies +- \`github.com/prebid/openrtb/v20/openrtb2\` - OpenRTB types +- \`encoding/xml\` - XML parsing/serialization diff --git a/modules/prebid/ctv_vast_enrichment/README_PL.md b/modules/prebid/ctv_vast_enrichment/README_PL.md new file mode 100644 index 00000000000..81636f07a4f --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/README_PL.md @@ -0,0 +1,484 @@ +# Moduł CTV VAST Enrichment + +Moduł CTV VAST Enrichment to moduł hook Prebid Server, który wzbogaca odpowiedzi VAST (Video Ad Serving Template) o dodatkowe metadane dla reklam Connected TV (CTV). + +## Struktura Modułu + +``` +modules/prebid/ctv_vast_enrichment/ +├── module.go # Punkt wejścia modułu PBS (Builder + HandleRawBidderResponseHook) +├── module_test.go # Testy modułu +├── pipeline.go # Samodzielny pipeline przetwarzania VAST +├── pipeline_test.go # Testy pipeline +├── handler.go # Handler HTTP dla bezpośrednich żądań VAST +├── types.go # Definicje typów, interfejsy i stałe +├── config.go # Konfiguracja i mergowanie warstw (host/account/profile) +├── config_test.go # Testy konfiguracji +├── model/ # Struktury danych VAST XML +│ ├── model.go # Obiekty domenowe wysokiego poziomu +│ ├── vast_xml.go # Struktury XML do marshal/unmarshal +│ ├── parser.go # Parser VAST XML +│ └── *_test.go # Testy +├── select/ # Logika selekcji bidów +│ ├── selector.go # Implementacje BidSelector +│ └── *_test.go # Testy +├── enrich/ # Wzbogacanie VAST +│ ├── enrich.go # Implementacja Enricher (VAST_WINS) +│ └── *_test.go # Testy +└── format/ # Formatowanie VAST XML + ├── format.go # Implementacja Formatter (GAM_SSU) + └── *_test.go # Testy +``` + +## Integracja z PBS + +Moduł jest zgodny ze standardowym wzorcem modułów Prebid Server. + +### Rejestracja w `modules/builder.go` + +Moduł musi być zarejestrowany w pliku `modules/builder.go`, który jest centralnym rejestrem wszystkich modułów PBS: + +```go +import ( + prebidCtvVastEnrichment "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +) + +var newModuleBuilders = map[string]map[string]interface{}{ + "prebid": { + "ctv_vast_enrichment": prebidCtvVastEnrichment.Builder, + }, +} +``` + +> **Uwaga:** Nazwa pakietu Go to `ctv_vast_enrichment`, ale subpakiety (enrich, select, format) używają aliasu `vast` do importu pakietu nadrzędnego: +> ```go +> import vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +> ``` + +### `module.go` - Główny Punkt Wejścia + +```go +// Builder tworzy nową instancję modułu CTV VAST enrichment. +func Builder(cfg json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, error) + +// Module implementuje funkcjonalność wzbogacania CTV VAST jako moduł hook PBS. +type Module struct { + hostConfig CTVVastConfig +} + +// HandleRawBidderResponseHook przetwarza odpowiedzi bidderów, wzbogacając VAST XML. +func (m Module) HandleRawBidderResponseHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.RawBidderResponsePayload, +) (hookstage.HookResult[hookstage.RawBidderResponsePayload], error) +``` + +### Hook Stage + +Moduł działa na etapie hooka **RawBidderResponse**, przetwarzając odpowiedź każdego biddera przed agregacją. Dla każdego bida zawierającego VAST XML: + +1. Parsuje VAST XML z pola `AdM` bida +2. Wzbogaca VAST o pricing, advertiser i metadane kategorii +3. Tworzy nowy `*adapters.TypedBid` z nowym `*openrtb2.Bid` zawierającym wzbogacony AdM +4. Zwraca mutację przez `changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids)` + +> **Wzorzec ChangeSet:** Hook nie modyfikuje payload bezpośrednio. Zamiast tego buduje nowy slice `[]adapters.TypedBid` i rejestruje mutację przez `UpdateBids()`. PBS stosuje mutację po powrocie z hooka — zgodnie ze wzorcem z modułu `ortb2blocking`. + +### Konfiguracja PBS (`pbs.json`) + +Aby moduł został wywołany podczas aukcji, wymagana jest konfiguracja `host_execution_plan` w sekcji `hooks`: + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } + } + } +} +``` + +> **Ważne:** Sam `enabled_modules` nie wystarczy. PBS wymaga jawnego `host_execution_plan` z definicją stage `raw_bidder_response` i `module_code: "prebid.ctv_vast_enrichment"`, aby hook został faktycznie wywołany. + +Konfiguracja na poziomie konta nadpisuje ustawienia na poziomie hosta. + +> **Enabled jest opt-in:** Moduł przetwarza bidy tylko gdy `enabled` jest jawnie ustawione na `true`. Brak wartości (`null`/nieobecne) jest traktowany jako `false` — moduł nie zostanie uruchomiony. + +## Komponenty + +### `module.go` - Moduł PBS + +Główny punkt wejścia zgodny z konwencjami modułów PBS: + +- **`Builder()`** - Tworzy instancję modułu z konfiguracji JSON +- **`Module`** - Struktura przechowująca konfigurację na poziomie hosta +- **`HandleRawBidderResponseHook()`** - Implementacja hooka: + - Parsuje konfigurację na poziomie konta + - Merguje konfiguracje hosta i konta + - Pomija przetwarzanie gdy `enabled` nie jest jawnie `true` (opt-in) + - Wzbogaca VAST w każdym bidzie video; slice zmodyfikowanych bidów alokowany jest leniwie (tylko gdy enrichment faktycznie następuje) + - Loguje błędy przez `glog` gdy budowanie VAST się nie powiedzie + +### `pipeline.go` - Samodzielny Pipeline + +Alternatywny punkt wejścia do bezpośredniego wywołania (używany przez handler.go): + +- **`BuildVastFromBidResponse()`** - Orkiestruje pełny pipeline: + 1. Selekcja bidów z odpowiedzi aukcji + 2. Parsowanie VAST z AdM każdego bida + 3. Wzbogacanie metadanymi + 4. Formatowanie do końcowego XML + +- **`Processor`** - Wrapper z wstrzykniętymi zależnościami +- **`DefaultConfig()`** - Domyślna konfiguracja dla GAM SSU + +### `handler.go` - Handler HTTP + +Obsługa żądań HTTP dla reklam CTV VAST (opcjonalny endpoint): + +- **`Handler`** - Handler HTTP z konfiguracją i zależnościami +- **`ServeHTTP()`** - Obsługuje żądania GET, zwraca VAST XML +- Metody buildera: `WithConfig()`, `WithSelector()`, itp. + +### `types.go` - Typy i Interfejsy + +| Typ | Opis | +|-----|------| +| `ReceiverType` | Typ odbiorcy (GAM_SSU, GENERIC) | +| `SelectionStrategy` | Strategia selekcji bidów (SINGLE, TOP_N, max_revenue, min_duration, balanced); nieznane wartości cofają się do domyślnej | +| `CollisionPolicy` | Polityka kolizji (reject, warn, ignore, VAST_WINS); nieznane wartości cofają się do domyślnej | + +**Interfejsy:** + +```go +type BidSelector interface { + Select(req, resp, cfg) ([]SelectedBid, []string, error) +} + +type Enricher interface { + Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) +} + +type Formatter interface { + Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) +} +``` + +**Struktury Danych:** + +- `CanonicalMeta` - Znormalizowane metadane bida (BidID, Price, Currency, Adomain, itp.) +- `SelectedBid` - Wybrany bid z metadanymi i numerem sekwencji; pole `Bid` jest wskaźnikiem `*openrtb2.Bid` (zgodnie z resztą PBS) +- `EnrichedAd` - Wzbogacona reklama gotowa do formatowania +- `VastResult` - Wynik przetwarzania (XML, ostrzeżenia, błędy) +- `ReceiverConfig` - Konfiguracja odbiorcy VAST +- `PlacementRules` - Reguły walidacji (pricing, advertiser, categories) + +### `config.go` - Konfiguracja + +Warstwowy system konfiguracji w stylu PBS: + +- **`CTVVastConfig`** - Struktura konfiguracji z polami nullable +- **`MergeCTVVastConfig()`** - Mergowanie warstw: Host → Account → Profile + +Priorytet warstw (od najniższego do najwyższego): +1. Host (domyślne) +2. Account (nadpisuje host) +3. Profile (nadpisuje wszystko) + +### `model/` - Struktury VAST XML + +#### `vast_xml.go` + +Struktury Go mapujące elementy VAST XML: + +- `Vast` - Element główny `` +- `Ad` - Element `` z atrybutami id, sequence +- `InLine` - Reklama inline z pełnymi danymi +- `Wrapper` - Reklama wrapper (przekierowanie) +- `Creative`, `Linear`, `MediaFile` - Elementy kreacji +- `Pricing`, `Impression`, `Extensions` - Metadane i tracking + +Funkcje pomocnicze: +- `BuildNoAdVast()` - Tworzy pusty VAST (brak reklam) +- `BuildSkeletonInlineVast()` - Tworzy minimalny szkielet VAST +- `Marshal()` / `MarshalCompact()` - Serializacja do XML +- `clearInnerXML()` - Czyści pola `InnerXML` przed serializacją (zapobiega duplikowaniu elementów) + +> **Fix XML:** Struktury VAST używają tagu `,innerxml` do zachowania surowego XML podczas parsowania. Przed `Marshal()` wywoływana jest `clearInnerXML()`, która zeruje pola `InnerXML` na strukturach `Ad`, `InLine`, `Wrapper`, `Creative` i `Linear`, zapobiegając duplikowaniu elementów w wynikowym XML. + +#### `parser.go` + +Parser VAST XML: + +- **`ParseVastAdm()`** - Parsuje string AdM do struktury Vast +- **`ParseVastOrSkeleton()`** - Parsuje lub tworzy szkielet jeśli dozwolone +- **`ExtractFirstAd()`** - Wyciąga pierwszą reklamę z VAST + +### `select/` - Selekcja Bidów + +Logika wyboru bidów z odpowiedzi aukcji: + +- **`PriceSelector`** - Implementacja oparta na cenie: + - Filtruje bidy z ceną ≤ 0 lub pustym AdM + - Sortuje: deal > non-deal, potem po cenie malejąco + - Respektuje `MaxAdsInPod` dla strategii TOP_N + - Przypisuje numery sekwencji (1-indexed) + +- **`NewSelector(strategy)`** - Fabryka tworząca selektor dla strategii + +### `enrich/` - Wzbogacanie VAST + +Dodawanie metadanych do reklam VAST: + +- **`VastEnricher`** - Implementacja z polityką VAST_WINS: + - Istniejące wartości w VAST nie są nadpisywane + - Dodaje brakujące: Pricing, Advertiser, Duration, Categories + +Wzbogacane elementy: +| Element | Źródło | Lokalizacja | +|---------|--------|-------------| +| Pricing | meta.Price | `` lub Extension | +| Advertiser | meta.Adomain | `` lub Extension | +| Duration | meta.DurSec | `` w Linear | +| Categories | meta.Cats | Extension (zawsze) | + +### `format/` - Formatowanie VAST + +Budowanie końcowego VAST XML: + +- **`VastFormatter`** - Implementacja GAM SSU: + - Buduje dokument VAST z listą elementów `` + - Ustawia `id` z BidID + - Ustawia `sequence` dla podów (wiele reklam) + +## Przepływ Przetwarzania + +``` +┌─────────────────────────────────────────────────────┐ +│ PBS Auction Pipeline │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ RawBidderResponse Hook Stage │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ HandleRawBidderResponseHook() │ │ +│ │ Dla każdego bida z VAST w AdM: │ │ +│ │ 1. Parsuje VAST XML │ │ +│ │ 2. Wzbogaca o pricing/advertiser │ │ +│ │ 3. Aktualizuje bid.AdM │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Wzbogacona BidderResponse │ +│ (VAST z , itp.) │ +└─────────────────────────────────────────────────────┘ +``` + +## Użycie + +### Jako Moduł PBS (Rekomendowane) + +Moduł jest automatycznie wywoływany podczas pipeline aukcji gdy włączony w konfiguracji. + +**1. Upewnij się, że moduł jest zarejestrowany w `modules/builder.go`** (patrz sekcja "Rejestracja" wyżej). + +**2. Dodaj konfigurację hooks do `pbs.json`:** + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "default_currency": "USD", + "receiver": "GAM_SSU" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } + } + } +} +``` + +**3. Nadpisanie na poziomie konta** (opcjonalne): +```json +{ + "hooks": { + "modules": { + "prebid.ctv_vast_enrichment": { + "enabled": true, + "default_currency": "EUR" + } + } + } +} +``` + +### Samodzielny Pipeline (dla handlera HTTP) + +```go +import ( + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/enrich" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/format" + bidselect "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/select" +) + +// Konfiguracja +cfg := vast.DefaultConfig() +cfg.MaxAdsInPod = 3 + +// Tworzenie komponentów +selector := bidselect.NewSelector(cfg.SelectionStrategy) +enricher := enrich.NewEnricher() +formatter := format.NewFormatter() + +// Bezpośrednie wywołanie +result, err := vast.BuildVastFromBidResponse( + ctx, + bidRequest, + bidResponse, + cfg, + selector, + enricher, + formatter, +) +``` + +### Handler HTTP + +```go +handler := vast.NewHandler(). + WithConfig(cfg). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter). + WithAuctionFunc(myAuctionFunc) + +http.Handle("/vast", handler) +``` + +## Konfiguracja Warstwowa + +```go +// Konfiguracja hosta (domyślne) +hostCfg := &vast.CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "USD", + VastVersionDefault: "4.0", +} + +// Konfiguracja konta (nadpisuje host) +accountCfg := &vast.CTVVastConfig{ + MaxAdsInPod: 5, +} + +// Merge warstw +merged := vast.MergeCTVVastConfig(hostCfg, accountCfg, nil) +``` + +## Testowanie + +Uruchom wszystkie testy modułu: + +```bash +go test ./modules/prebid/ctv_vast_enrichment/... -v +``` + +Testy z pokryciem: + +```bash +go test ./modules/prebid/ctv_vast_enrichment/... -cover +``` + +Uruchom tylko testy module.go: + +```bash +go test ./modules/prebid/ctv_vast_enrichment -run TestBuilder -v +go test ./modules/prebid/ctv_vast_enrichment -run TestHandleRawBidderResponseHook -v +``` + +## Rozszerzenia + +### Dodawanie Nowego Odbiorcy + +1. Dodaj stałą w `types.go`: + ```go + ReceiverMyReceiver ReceiverType = "MY_RECEIVER" + ``` + +2. Zaimplementuj `Formatter` dla nowego formatu w `format/` + +3. Zaktualizuj `configToReceiverConfig()` w `module.go` + +### Dodawanie Nowej Strategii Selekcji + +1. Dodaj stałą w `types.go`: + ```go + SelectionMyStrategy SelectionStrategy = "MY_STRATEGY" + ``` + +2. Zaimplementuj `BidSelector` w `select/` + +3. Zaktualizuj fabrykę `NewSelector()` + +## Zależności + +- `github.com/prebid/prebid-server/v3/hooks/hookstage` - Interfejsy hooków PBS +- `github.com/prebid/prebid-server/v3/modules/moduledeps` - Zależności modułów +- `github.com/prebid/openrtb/v20/openrtb2` - Typy OpenRTB +- `encoding/xml` - Parsowanie/serializacja XML diff --git a/modules/prebid/ctv_vast_enrichment/enrich/register.go b/modules/prebid/ctv_vast_enrichment/enrich/register.go new file mode 100644 index 00000000000..865de3ebd9a --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/enrich/register.go @@ -0,0 +1,15 @@ +package enrich + +import ( + vast "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" +) + +func init() { + // Register VastEnricher as the default enricher for the module hook. + // This breaks the potential import cycle: parent cannot import enrich directly, + // so enrich registers itself via EnricherFactory when its package is loaded. + // The modules/builder.go blank-imports this package to trigger the init. + vast.EnricherFactory = func() vast.Enricher { + return NewEnricher() + } +} diff --git a/modules/prebid/ctv_vast_enrichment/handler.go b/modules/prebid/ctv_vast_enrichment/handler.go index 3f002eeec0c..464a425080d 100644 --- a/modules/prebid/ctv_vast_enrichment/handler.go +++ b/modules/prebid/ctv_vast_enrichment/handler.go @@ -1,3 +1,15 @@ +//go:build ignore +// +build ignore + +// Package ctv_vast_enrichment handler is a work-in-progress standalone HTTP endpoint. +// Excluded from the build until AuctionFunc integration with exchange.Exchange is complete. +// See BUG 9 in ctv-bugs-and-resolve.md. +// +// To restore: remove the //go:build ignore directive and implement: +// - Full query parameter parsing (pod_id, duration, max_ads) +// - exchange.Exchange injection via AuctionFunc +// - Router registration + package ctv_vast_enrichment import ( @@ -59,16 +71,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // TODO: Parse query parameters and build OpenRTB request - // This is a placeholder for the actual implementation: - // - Parse pod_id, duration, max_ads from query string - // - Build openrtb2.BidRequest with Video imp - // - Apply site/app context from query or headers bidRequest := h.buildBidRequest(r) // TODO: Call auction pipeline - // This is a placeholder - actual implementation would: - // - Call the Prebid Server auction endpoint - // - Get BidResponse from exchange var bidResponse *openrtb2.BidResponse var err error @@ -79,7 +84,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } } else { - // No auction function configured - return no-ad bidResponse = &openrtb2.BidResponse{} } @@ -89,29 +93,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { glog.Errorf("ctv_vast_enrichment: BuildVastFromBidResponse error: %v", err) } - // Set response headers w.Header().Set("Content-Type", "application/xml; charset=utf-8") w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - // Handle no-ad case if result.NoAd { - w.WriteHeader(http.StatusOK) // Still 200 per VAST spec + w.WriteHeader(http.StatusOK) } - // Write VAST XML w.Write(result.VastXML) } // buildBidRequest creates an OpenRTB BidRequest from the HTTP request. // TODO: Implement full parsing of query parameters. func (h *Handler) buildBidRequest(r *http.Request) *openrtb2.BidRequest { - // Placeholder implementation - // TODO: Parse these from query string: - // - pod_id -> BidRequest.ID - // - duration -> Video.MaxDuration - // - max_ads -> Video.MaxAds (via pod extension) - // - slot_count -> multiple Imp objects - query := r.URL.Query() podID := query.Get("pod_id") if podID == "" { diff --git a/modules/prebid/ctv_vast_enrichment/model/vast_xml.go b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go index b4b38c4364a..83704987def 100644 --- a/modules/prebid/ctv_vast_enrichment/model/vast_xml.go +++ b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go @@ -276,22 +276,18 @@ func (v *Vast) MarshalCompact() ([]byte, error) { return append([]byte(xml.Header), output...), nil } -// clearInnerXML clears all InnerXML fields to prevent duplicate content during marshaling. -// InnerXML is used during parsing to preserve unknown elements, but must be cleared -// before marshaling to avoid outputting both structured fields AND raw XML. +// clearInnerXML clears InnerXML only on nodes that are directly modified by enrichment. +// Nodes higher in the tree (Ad, InLine, Wrapper) get their InnerXML cleared so structured +// fields (Pricing, Advertiser, etc.) are serialized without duplication. +// Creative, Linear, MediaFiles, TrackingEvents and other leaf nodes are preserved — +// this keeps DSP-specific extensions and unknown elements intact (BUG 4 fix). func (v *Vast) clearInnerXML() { for i := range v.Ads { v.Ads[i].InnerXML = "" if v.Ads[i].InLine != nil { v.Ads[i].InLine.InnerXML = "" - if v.Ads[i].InLine.Creatives != nil { - for j := range v.Ads[i].InLine.Creatives.Creative { - v.Ads[i].InLine.Creatives.Creative[j].InnerXML = "" - if v.Ads[i].InLine.Creatives.Creative[j].Linear != nil { - v.Ads[i].InLine.Creatives.Creative[j].Linear.InnerXML = "" - } - } - } + // Creative.InnerXML and Linear.InnerXML are intentionally preserved + // to keep MediaFiles, TrackingEvents, and DSP extensions intact. } if v.Ads[i].Wrapper != nil { v.Ads[i].Wrapper.InnerXML = "" diff --git a/modules/prebid/ctv_vast_enrichment/module.go b/modules/prebid/ctv_vast_enrichment/module.go index 4732cbec248..fa8b111ef55 100644 --- a/modules/prebid/ctv_vast_enrichment/module.go +++ b/modules/prebid/ctv_vast_enrichment/module.go @@ -10,6 +10,7 @@ import ( "github.com/prebid/prebid-server/v4/adapters" "github.com/prebid/prebid-server/v4/hooks/hookstage" "github.com/prebid/prebid-server/v4/modules/moduledeps" + "github.com/prebid/prebid-server/v4/openrtb_ext" "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/model" ) @@ -26,6 +27,7 @@ func Builder(cfg json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, erro return Module{ hostConfig: hostCfg, + enricher: EnricherFactory(), }, nil } @@ -34,6 +36,18 @@ func Builder(cfg json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, erro // such as pricing, categories, and advertiser information. type Module struct { hostConfig CTVVastConfig + // enricher is the VAST enricher used by the hook. It is set in Builder via + // the EnricherFactory variable to avoid an import cycle with the enrich subpackage. + enricher Enricher +} + +// EnricherFactory is a package-level variable that provides the default Enricher +// implementation. It is overridden by the enrich subpackage via init() to avoid +// an import cycle (parent → enrich → parent). +// NOTE: Architectural TODO — move shared types (CanonicalMeta, ReceiverConfig) to a +// types subpackage so parent and enrich can both import it without a cycle. +var EnricherFactory func() Enricher = func() Enricher { + return &hookEnricher{} } // HandleRawBidderResponseHook processes bidder responses to enrich VAST XML. @@ -74,8 +88,15 @@ func (m Module) HandleRawBidderResponseHook( return result, nil } - // Convert config to ReceiverConfig - receiverCfg := configToReceiverConfig(mergedCfg) + // Convert config to ReceiverConfig (canonical method in config.go) + receiverCfg := mergedCfg.ReceiverConfig() + + // Use injected enricher (set via EnricherFactory in Builder). + // Fall back to hookEnricher for tests that construct Module{} directly. + enricher := m.enricher + if enricher == nil { + enricher = &hookEnricher{} + } // modifiedBids is allocated lazily — only when the first enrichment actually happens. // Until then we track the index so we can back-fill originals if needed. @@ -91,8 +112,8 @@ func (m Module) HandleRawBidderResponseHook( bid := typedBid.Bid - // Skip non-video bids (no AdM or not VAST) - if bid.AdM == "" { + // Skip non-video bids — explicit type check prevents banner/native from being parsed as VAST + if typedBid.BidType != openrtb_ext.BidTypeVideo || bid.AdM == "" { if modifiedBids != nil { modifiedBids = append(modifiedBids, typedBid) } @@ -109,21 +130,46 @@ func (m Module) HandleRawBidderResponseHook( continue } + // Resolve the actual DSP currency — BidderResponse.Currency is the authoritative source + bidCurrency := payload.BidderResponse.Currency + if bidCurrency == "" { + bidCurrency = receiverCfg.DefaultCurrency + } + if bidCurrency == "" { + bidCurrency = "USD" + } + // Build bid context for enrichment bidContext := CanonicalMeta{ BidID: bid.ID, Price: bid.Price, - Currency: receiverCfg.DefaultCurrency, - Adomain: strings.Join(bid.ADomain, ","), + Currency: bidCurrency, + Adomain: primaryDomain(bid.ADomain), Cats: bid.Cat, Seat: payload.Bidder, } - // Enrich the VAST document inline - enrichedVast := enrichVastDocument(vastDoc, bidContext, receiverCfg) + // Extract the first Ad element and delegate enrichment to the injected Enricher. + // The enricher (set via EnricherFactory) handles Duration, Categories, + // AdvertiserPlacement, PricingPlacement, and debug extensions — BUG 3 fix. + ad := model.ExtractFirstAd(vastDoc) + if ad == nil { + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } + continue + } + + if _, enrichErr := enricher.Enrich(ad, bidContext, receiverCfg); enrichErr != nil { + // Enrichment failed — keep original bid + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } + continue + } // Format back to XML - xmlBytes, err := enrichedVast.Marshal() + xmlBytes, err := vastDoc.Marshal() if err != nil { // Keep original bid on format error if modifiedBids != nil { @@ -143,11 +189,12 @@ func (m Module) HandleRawBidderResponseHook( *enrichedBid = *bid enrichedBid.AdM = string(xmlBytes) - // Create new TypedBid with enriched bid + // Create new TypedBid with enriched bid — preserve BidMeta for analytics/targeting enrichedTypedBid := &adapters.TypedBid{ Bid: enrichedBid, BidType: typedBid.BidType, BidVideo: typedBid.BidVideo, + BidMeta: typedBid.BidMeta, DealPriority: typedBid.DealPriority, Seat: typedBid.Seat, } @@ -164,99 +211,46 @@ func (m Module) HandleRawBidderResponseHook( return result, nil } -// configToReceiverConfig converts CTVVastConfig to ReceiverConfig -func configToReceiverConfig(cfg CTVVastConfig) ReceiverConfig { - rc := DefaultConfig() - - if cfg.Receiver != "" { - switch cfg.Receiver { - case "GAM_SSU": - rc.Receiver = ReceiverGAMSSU - case "GENERIC": - rc.Receiver = ReceiverGeneric - } - } - - if cfg.DefaultCurrency != "" { - rc.DefaultCurrency = cfg.DefaultCurrency +// primaryDomain returns the first domain from a slice, or empty string if none. +// VAST is a single human-readable string — joining multiple domains is non-standard. +func primaryDomain(domains []string) string { + if len(domains) == 0 { + return "" } + return domains[0] +} - if cfg.VastVersionDefault != "" { - rc.VastVersionDefault = cfg.VastVersionDefault - } +// hookEnricher is a minimal fallback enricher used when EnricherFactory is not overridden. +// In production the enrich subpackage registers its VastEnricher via init(). +// This fallback handles only Pricing and Advertiser to remain backward-compatible. +type hookEnricher struct{} - if cfg.MaxAdsInPod > 0 { - rc.MaxAdsInPod = cfg.MaxAdsInPod +func (h *hookEnricher) Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) { + if ad == nil || ad.InLine == nil { + return nil, nil } + inline := ad.InLine - if cfg.SelectionStrategy != "" { - switch cfg.SelectionStrategy { - case "max_revenue", "MAX_REVENUE": - rc.SelectionStrategy = SelectionMaxRevenue - case "top_n", "TOP_N": - rc.SelectionStrategy = SelectionTopN - case "single", "SINGLE": - rc.SelectionStrategy = SelectionSingle + // Pricing + if inline.Pricing == nil && meta.Price > 0 { + currency := meta.Currency + if currency == "" { + currency = cfg.DefaultCurrency } - } - - if cfg.CollisionPolicy != "" { - switch cfg.CollisionPolicy { - case "reject", "REJECT": - rc.CollisionPolicy = CollisionReject - case "warn", "WARN": - rc.CollisionPolicy = CollisionWarn - case "ignore", "IGNORE": - rc.CollisionPolicy = CollisionIgnore + if currency == "" { + currency = "USD" } - } - - if cfg.AllowSkeletonVast != nil { - rc.AllowSkeletonVast = *cfg.AllowSkeletonVast - } - - if cfg.Placement != nil { - if cfg.Placement.PricingPlacement != "" { - rc.Placement.PricingPlacement = cfg.Placement.PricingPlacement + inline.Pricing = &model.Pricing{ + Value: fmt.Sprintf("%.6f", meta.Price), + Model: "CPM", + Currency: currency, } } - return rc -} - -// enrichVastDocument enriches a VAST document with bid metadata. -// It adds pricing and advertiser information to the VAST. -func enrichVastDocument(vast *model.Vast, meta CanonicalMeta, cfg ReceiverConfig) *model.Vast { - if vast == nil { - return vast - } - - // Process each ad - for i := range vast.Ads { - ad := &vast.Ads[i] - if ad.InLine == nil { - continue - } - inline := ad.InLine - - // Add pricing if not present - if inline.Pricing == nil && meta.Price > 0 { - currency := cfg.DefaultCurrency - if currency == "" { - currency = "USD" - } - inline.Pricing = &model.Pricing{ - Value: fmt.Sprintf("%.6f", meta.Price), - Model: "CPM", - Currency: currency, - } - } - - // Add advertiser if not present - if inline.Advertiser == "" && meta.Adomain != "" { - inline.Advertiser = meta.Adomain - } + // Advertiser + if strings.TrimSpace(inline.Advertiser) == "" && meta.Adomain != "" { + inline.Advertiser = meta.Adomain } - return vast + return nil, nil } diff --git a/modules/prebid/ctv_vast_enrichment/module_e2e_test.go b/modules/prebid/ctv_vast_enrichment/module_e2e_test.go new file mode 100644 index 00000000000..deca350359c --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/module_e2e_test.go @@ -0,0 +1,886 @@ +package ctv_vast_enrichment_test + +// End-to-end test suite for the ctv_vast_enrichment module. +// +// These tests exercise the full hook path — from HandleRawBidderResponseHook through +// config merging, enrichVastDocument, and model.Marshal — using real sub-package +// implementations (enrich.NewEnricher, format.NewFormatter, select.NewSelector) +// rather than mocks, so regressions in integration points are caught. +// +// Test matrix: +// A. Hook path correctness +// A1 Video bid enriched — and injected +// A2 Banner bid passes through untouched (BidType guard) +// A3 Native bid passes through untouched (BidType guard) +// A4 BidMeta preserved on enriched TypedBid +// A5 BidderResponse.Currency used in , not DefaultCurrency from config +// A6 Fallback to DefaultCurrency when BidderResponse.Currency is empty +// A7 VAST_WINS: existing not overwritten +// A8 Unknown/DSP-specific VAST extensions preserved after marshal +// A9 Mixed bid types — only video bids modified +// +// B. Config correctness +// B1 VAST_WINS CollisionPolicy round-trips through account config +// B2 Account config overrides host config +// +// C. Pipeline end-to-end (BuildVastFromBidResponse with real components) +// C1 Single video bid → enriched VAST with and +// C2 Ad pod — multiple bids → correct sequence attributes +// C3 No bids → NoAd VAST returned +// C4 All-invalid VAST, skeleton disabled → NoAd +// C5 All-invalid VAST, skeleton enabled → VAST with warnings +// C6 Duration from bid meta injected into +// C7 IAB categories injected as extension +// C8 Debug extension enabled → / present in output +// +// D. Regression — previously reported bugs (see CTV.md / ctv-bugs-and-resolve.md) +// D1 Non-USD DSP currency preserved in (BUG 1) +// D2 VAST_WINS policy not silently converted to Reject (BUG 2) +// D3 Hook uses enrich subpackage — debug extension added when debug=true (BUG 3) +// D4 clearInnerXML does not drop content (BUG 4) +// D5 BidMeta fields (NetworkID, AdvertiserID, BrandID) survive hook (BUG 6) +// D6 Only first ADomain used in , not comma-joined list (BUG 7) + +import ( + "context" + "encoding/json" + "encoding/xml" + "strings" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/adapters" + "github.com/prebid/prebid-server/v4/hooks/hookstage" + ctv "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/enrich" + "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/format" + bidselect "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/select" + "github.com/prebid/prebid-server/v4/modules/moduledeps" + "github.com/prebid/prebid-server/v4/openrtb_ext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Shared VAST fixtures +// --------------------------------------------------------------------------- + +const ( + // minimalVAST is a well-formed VAST 3.0 with one InLine ad containing + // a video MediaFile. Used as a baseline "clean" input for most tests. + minimalVAST = `` + + `TestTest Ad` + + `` + + `00:00:30` + + `` + + `` + + `` + + `` + + // vastWithPricing already contains 3.00. + // Used to verify VAST_WINS collision policy. + vastWithPricing = `` + + `TestTest Ad` + + `3.00` + + `` + + `` + + // vastWithExtensions contains a DSP-specific . + // Used to verify clearInnerXML does not drop unknown elements. + vastWithExtensions = `` + + `TestTest Ad` + + `` + + `00:00:15` + + `` + + `` + + `` + + `` + + `https://tracker.dsp.example/ping` + + `` + + `` +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// hookHandler is the subset of the Module interface we need. +type hookHandler interface { + HandleRawBidderResponseHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.RawBidderResponsePayload, + ) (hookstage.HookResult[hookstage.RawBidderResponsePayload], error) +} + +// buildModule creates a Module via the public Builder with given JSON host config. +func buildModule(t *testing.T, hostCfgJSON string) hookHandler { + t.Helper() + m, err := ctv.Builder(json.RawMessage(hostCfgJSON), moduledeps.ModuleDeps{}) + require.NoError(t, err) + h, ok := m.(hookHandler) + require.True(t, ok, "Builder result must implement HandleRawBidderResponseHook") + return h +} + +// applyMutations replays all ChangeSet mutations onto payload and returns the result. +func applyMutations( + t *testing.T, + result hookstage.HookResult[hookstage.RawBidderResponsePayload], + payload hookstage.RawBidderResponsePayload, +) hookstage.RawBidderResponsePayload { + t.Helper() + for _, mut := range result.ChangeSet.Mutations() { + var err error + payload, err = mut.Apply(payload) + require.NoError(t, err) + } + return payload +} + +// enabledCtx returns a minimal account config that enables the module. +func enabledCtx() hookstage.ModuleInvocationContext { + return hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled":true}`), + } +} + +// videoTypedBid wraps a Bid in a TypedBid with BidTypeVideo. +func videoTypedBid(bid *openrtb2.Bid) *adapters.TypedBid { + return &adapters.TypedBid{Bid: bid, BidType: openrtb_ext.BidTypeVideo} +} + +// newRealComponents returns real (non-mock) selector/enricher/formatter. +func newRealComponents(strategy ctv.SelectionStrategy) (ctv.BidSelector, ctv.Enricher, ctv.Formatter) { + return bidselect.NewSelector(strategy), enrich.NewEnricher(), format.NewFormatter() +} + +// --------------------------------------------------------------------------- +// A. Hook path correctness +// --------------------------------------------------------------------------- + +// A1 — video bid is enriched; and injected. +func TestE2E_A1_VideoBidEnriched(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "rubicon", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ + ID: "b1", + Price: 2.50, + ADomain: []string{"brand.com"}, + AdM: minimalVAST, + }), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, " to be injected") + assert.Contains(t, adm, "2.5", "expected price value in VAST") + assert.Contains(t, adm, `currency="USD"`) + assert.Contains(t, adm, "brand.com", "expected with domain") +} + +// A2 — banner bid must pass through unchanged. +func TestE2E_A2_BannerBidNotTouched(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + bannerAdM := `banner content` + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{{ + Bid: &openrtb2.Bid{ID: "b1", Price: 1.0, AdM: bannerAdM}, + BidType: openrtb_ext.BidTypeBanner, + }}, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + assert.Equal(t, bannerAdM, payload.BidderResponse.Bids[0].Bid.AdM, + "banner AdM must not be modified") +} + +// A3 — native bid must pass through unchanged. +func TestE2E_A3_NativeBidNotTouched(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + nativeAdM := `{"ver":"1.1","assets":[]}` + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{{ + Bid: &openrtb2.Bid{ID: "b1", Price: 0.5, AdM: nativeAdM}, + BidType: openrtb_ext.BidTypeNative, + }}, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + assert.Equal(t, nativeAdM, payload.BidderResponse.Bids[0].Bid.AdM, + "native AdM must not be modified") +} + +// A4 — BidMeta must be preserved on the enriched TypedBid. +func TestE2E_A4_BidMetaPreserved(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{{ + Bid: &openrtb2.Bid{ID: "b1", Price: 1.0, AdM: minimalVAST}, + BidType: openrtb_ext.BidTypeVideo, + BidMeta: &openrtb_ext.ExtBidPrebidMeta{ + NetworkID: 42, + AdvertiserID: 99, + BrandID: 7, + }, + }}, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + meta := payload.BidderResponse.Bids[0].BidMeta + require.NotNil(t, meta, "BidMeta must not be nil after hook") + assert.Equal(t, 42, meta.NetworkID) + assert.Equal(t, 99, meta.AdvertiserID) + assert.Equal(t, 7, meta.BrandID) +} + +// A5 — BidderResponse.Currency must be used in , not DefaultCurrency from host config. +func TestE2E_A5_BidderResponseCurrencyUsedInPricing(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) // host says USD + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder_eur", + BidderResponse: &adapters.BidderResponse{ + Currency: "EUR", // DSP responds in EUR + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 1.80, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, `currency="EUR"`, "EUR from BidderResponse must appear in ") + assert.NotContains(t, adm, `currency="USD"`, "USD from host config must NOT override DSP currency") +} + +// A6 — fallback to DefaultCurrency when BidderResponse.Currency is empty. +func TestE2E_A6_FallbackToDefaultCurrencyWhenBidderCurrencyEmpty(t *testing.T) { + module := buildModule(t, `{"default_currency":"GBP"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "", // DSP omits currency + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 3.00, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, `currency="GBP"`, "should fallback to host DefaultCurrency GBP") +} + +// A7 — VAST_WINS: existing in VAST must not be overwritten. +func TestE2E_A7_VastWinsPreservesExistingPricing(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD","collision_policy":"VAST_WINS"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ + ID: "b1", + Price: 9.99, // bidder price + AdM: vastWithPricing, // already has GBP 3.00 + }), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "GBP", "original currency GBP must be preserved") + assert.Contains(t, adm, "3.00", "original price 3.00 must be preserved") + assert.NotContains(t, adm, "9.99", "bidder price must NOT overwrite existing VAST pricing") +} + +// A8 — DSP-specific VAST extensions must survive the marshal round-trip. +func TestE2E_A8_UnknownVastExtensionsPreserved(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 2.00, AdM: vastWithExtensions}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "dsp_custom", "DSP extension type must survive marshal") + assert.Contains(t, adm, "https://tracker.dsp.example/ping", "DSP tracker URL must survive marshal") +} + +// A9 — mixed bid types: only video bids enriched, others unchanged. +func TestE2E_A9_MixedBidTypesOnlyVideoEnriched(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + bannerAdM := `banner` + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ID: "banner-bid", Price: 1.0, AdM: bannerAdM}, + BidType: openrtb_ext.BidTypeBanner, + }, + videoTypedBid(&openrtb2.Bid{ID: "video-bid", Price: 2.5, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + assert.Equal(t, bannerAdM, payload.BidderResponse.Bids[0].Bid.AdM, + "banner bid must not be modified") + assert.Contains(t, payload.BidderResponse.Bids[1].Bid.AdM, " in VAST is preserved when VAST_WINS is set. +func TestE2E_B1_VastWinsCollisionPolicyRoundTrip(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) // host has no collision_policy + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 9.99, AdM: vastWithPricing}), + }, + }, + } + + // Account overrides collision_policy to VAST_WINS + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled":true,"collision_policy":"VAST_WINS"}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "GBP", "VAST_WINS: original GBP currency must survive") + assert.Contains(t, adm, "3.00", "VAST_WINS: original price must survive") + assert.NotContains(t, adm, "9.99", "VAST_WINS: bidder price must not replace existing pricing") +} + +// B2 — account config overrides host config; host fields not in account are preserved. +func TestE2E_B2_AccountConfigOverridesHost(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD","receiver":"GENERIC"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "", // empty — falls back to config currency + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 1.0, AdM: minimalVAST}), + }, + }, + } + + // Account overrides currency to EUR + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled":true,"default_currency":"EUR"}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, `currency="EUR"`, "account currency EUR must override host USD") +} + +// --------------------------------------------------------------------------- +// C. Pipeline end-to-end (BuildVastFromBidResponse with real components) +// --------------------------------------------------------------------------- + +// C1 — single video bid → enriched VAST with and . +func TestE2E_C1_PipelineSingleBidEnriched(t *testing.T) { + cfg := ctv.DefaultConfig() + cfg.SelectionStrategy = ctv.SelectionSingle + cfg.DefaultCurrency = "USD" + + req := &openrtb2.BidRequest{ID: "req-1"} + resp := &openrtb2.BidResponse{ + ID: "resp-1", + Cur: "USD", + SeatBid: []openrtb2.SeatBid{{ + Seat: "rubicon", + Bid: []openrtb2.Bid{{ + ID: "bid-1", + ImpID: "imp-1", + Price: 5.00, + AdM: minimalVAST, + ADomain: []string{"brand.example.com"}, + }}, + }}, + } + + selector, enricher, formatter := newRealComponents(cfg.SelectionStrategy) + result, err := ctv.BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + require.False(t, result.NoAd) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, ". +func TestE2E_C6_DurationInjectedFromMeta(t *testing.T) { + cfg := ctv.DefaultConfig() + cfg.DefaultCurrency = "USD" + + vastNoDuration := `` + + `TestNo Duration` + + `` + + `` + + `` + + `` + + req := &openrtb2.BidRequest{ID: "req-dur"} + resp := &openrtb2.BidResponse{ + ID: "resp-dur", + SeatBid: []openrtb2.SeatBid{{ + Seat: "bidder1", + Bid: []openrtb2.Bid{{ID: "b1", Price: 2.0, AdM: vastNoDuration}}, + }}, + } + + sel := &durationInjectingSelector{durSec: 45} + result, err := ctv.BuildVastFromBidResponse(context.Background(), req, resp, cfg, sel, enrich.NewEnricher(), format.NewFormatter()) + require.NoError(t, err) + require.False(t, result.NoAd) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, "00:00:45", "duration 45s must appear as 00:00:45") +} + +// C7 — IAB categories injected as VAST extension. +func TestE2E_C7_IABCategoriesInjectedAsExtension(t *testing.T) { + cfg := ctv.DefaultConfig() + cfg.DefaultCurrency = "USD" + + req := &openrtb2.BidRequest{ID: "req-cat"} + resp := &openrtb2.BidResponse{ + ID: "resp-cat", + SeatBid: []openrtb2.SeatBid{{ + Seat: "bidder1", + Bid: []openrtb2.Bid{{ + ID: "b1", + Price: 3.0, + AdM: minimalVAST, + Cat: []string{"IAB1", "IAB2-3"}, + }}, + }}, + } + + selector, enricher, formatter := newRealComponents(ctv.SelectionSingle) + result, err := ctv.BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + require.False(t, result.NoAd) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, "IAB1") + assert.Contains(t, xmlStr, "IAB2-3") + assert.Contains(t, xmlStr, "iab_category") +} + +// C8 — debug extension enabled → and in output. +func TestE2E_C8_DebugExtensionIncludesBidIDAndSeat(t *testing.T) { + cfg := ctv.DefaultConfig() + cfg.DefaultCurrency = "USD" + cfg.Debug = true + + req := &openrtb2.BidRequest{ID: "req-dbg"} + resp := &openrtb2.BidResponse{ + ID: "resp-dbg", + SeatBid: []openrtb2.SeatBid{{ + Seat: "rubicon", + Bid: []openrtb2.Bid{{ID: "debug-bid-123", Price: 4.0, AdM: minimalVAST}}, + }}, + } + + selector, enricher, formatter := newRealComponents(ctv.SelectionSingle) + result, err := ctv.BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + require.False(t, result.NoAd) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, "debug-bid-123") + assert.Contains(t, xmlStr, "rubicon") +} + +// --------------------------------------------------------------------------- +// D. Regression tests — previously reported bugs +// --------------------------------------------------------------------------- + +// D1 — (BUG 1) Non-USD DSP currency preserved in . +func TestE2E_D1_NonUSDCurrencyPreserved(t *testing.T) { + for _, dspCurrency := range []string{"EUR", "JPY", "BRL", "AUD"} { + t.Run(dspCurrency, func(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder", + BidderResponse: &adapters.BidderResponse{ + Currency: dspCurrency, + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 1.0, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, `currency="`+dspCurrency+`"`, + "DSP currency %s must appear in ", dspCurrency) + assert.NotContains(t, adm, `currency="USD"`, + "host DefaultCurrency USD must NOT override DSP currency %s", dspCurrency) + }) + } +} + +// D2 — (BUG 2) VAST_WINS collision policy must not silently become CollisionReject. +// Observable effect: existing in VAST is preserved, not replaced. +func TestE2E_D2_VastWinsNotSilentlyDropped(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD","collision_policy":"VAST_WINS"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 9.99, AdM: vastWithPricing}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "3.00", "VAST_WINS: original price must not be overwritten") + assert.Contains(t, adm, "GBP", "VAST_WINS: original currency must not be overwritten") + assert.NotContains(t, adm, "9.99", "VAST_WINS: bidder price must not replace existing") +} + +// D3 — (BUG 3) Hook uses enrich subpackage: debug extension added when debug=true via account config. +// This verifies the hook path actually reaches enrich.VastEnricher, not a partial inline impl. +func TestE2E_D3_HookUsesEnrichSubpackage_DebugExtension(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled":true,"debug":true}`), + } + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "rubicon", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "hook-debug-bid", Price: 2.0, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + // enrich.VastEnricher adds with when debug=true + assert.Contains(t, adm, `type="openrtb"`, + "debug extension must be present — proves hook reached enrich subpackage") + assert.Contains(t, adm, "hook-debug-bid", + "BidID must appear in debug extension") +} + +// D4 — (BUG 4) clearInnerXML must not drop content. +func TestE2E_D4_MediaFilesPreservedAfterMarshal(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 1.0, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "https://example.com/video.mp4", + "MediaFile URL must survive clearInnerXML + marshal") + assert.Contains(t, adm, ", not all domains comma-joined. +func TestE2E_D6_OnlyFirstADomainUsedInAdvertiser(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ + ID: "b1", + Price: 1.0, + ADomain: []string{"primary.com", "secondary.com", "tertiary.com"}, + AdM: minimalVAST, + }), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "primary.com", "first domain must be in ") + assert.NotContains(t, adm, "primary.com,secondary.com", + "domains must NOT be comma-joined in ") + + // Parse XML to check value is exactly first domain + var vastParsed struct { + XMLName xml.Name `xml:"VAST"` + Ad struct { + InLine struct { + Advertiser string `xml:"Advertiser"` + } `xml:"InLine"` + } `xml:"Ad"` + } + err = xml.Unmarshal([]byte(adm), &vastParsed) + require.NoError(t, err) + assert.Equal(t, "primary.com", vastParsed.Ad.InLine.Advertiser, + " must contain exactly the first domain") +} + +// --------------------------------------------------------------------------- +// Test helpers — custom selector implementations +// --------------------------------------------------------------------------- + +// durationInjectingSelector wraps the default selector and injects DurSec into each CanonicalMeta. +type durationInjectingSelector struct { + durSec int +} + +func (s *durationInjectingSelector) Select( + req *openrtb2.BidRequest, + resp *openrtb2.BidResponse, + cfg ctv.ReceiverConfig, +) ([]ctv.SelectedBid, []string, error) { + base := bidselect.NewSelector(ctv.SelectionSingle) + selected, warnings, err := base.Select(req, resp, cfg) + if err != nil { + return selected, warnings, err + } + for i := range selected { + selected[i].Meta.DurSec = s.durSec + } + return selected, warnings, nil +} diff --git a/modules/prebid/ctv_vast_enrichment/module_test.go b/modules/prebid/ctv_vast_enrichment/module_test.go index 5290082fde1..12030ec1b37 100644 --- a/modules/prebid/ctv_vast_enrichment/module_test.go +++ b/modules/prebid/ctv_vast_enrichment/module_test.go @@ -10,6 +10,7 @@ import ( "github.com/prebid/prebid-server/v4/hooks/hookstage" "github.com/prebid/prebid-server/v4/modules/moduledeps" "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/model" + "github.com/prebid/prebid-server/v4/openrtb_ext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -168,6 +169,7 @@ func TestHandleRawBidderResponseHook_EnrichesVAST(t *testing.T) { BidderResponse: &adapters.BidderResponse{ Bids: []*adapters.TypedBid{ { + BidType: openrtb_ext.BidTypeVideo, Bid: &openrtb2.Bid{ ID: "bid1", Price: 1.50, @@ -306,6 +308,7 @@ func TestHandleRawBidderResponseHook_MergesHostAndAccountConfig(t *testing.T) { BidderResponse: &adapters.BidderResponse{ Bids: []*adapters.TypedBid{ { + BidType: openrtb_ext.BidTypeVideo, Bid: &openrtb2.Bid{ ID: "bid1", Price: 2.00, @@ -352,6 +355,7 @@ func TestHandleRawBidderResponseHook_MultipleBids(t *testing.T) { BidderResponse: &adapters.BidderResponse{ Bids: []*adapters.TypedBid{ { + BidType: openrtb_ext.BidTypeVideo, Bid: &openrtb2.Bid{ ID: "bid1", Price: 1.50, @@ -359,6 +363,7 @@ func TestHandleRawBidderResponseHook_MultipleBids(t *testing.T) { }, }, { + BidType: openrtb_ext.BidTypeVideo, Bid: &openrtb2.Bid{ ID: "bid2", Price: 2.00, @@ -406,6 +411,7 @@ func TestHandleRawBidderResponseHook_PreservesExistingPricing(t *testing.T) { BidderResponse: &adapters.BidderResponse{ Bids: []*adapters.TypedBid{ { + BidType: openrtb_ext.BidTypeVideo, Bid: &openrtb2.Bid{ ID: "bid1", Price: 1.50, // Different price @@ -448,7 +454,7 @@ func TestConfigToReceiverConfig(t *testing.T) { { name: "empty config uses defaults", input: CTVVastConfig{}, - expected: DefaultConfig(), + expected: CTVVastConfig{}.ReceiverConfig(), }, { name: "receiver GAM_SSU", @@ -456,8 +462,7 @@ func TestConfigToReceiverConfig(t *testing.T) { Receiver: "GAM_SSU", }, expected: func() ReceiverConfig { - rc := DefaultConfig() - rc.Receiver = ReceiverGAMSSU + rc := CTVVastConfig{Receiver: "GAM_SSU"}.ReceiverConfig() return rc }(), }, @@ -467,8 +472,7 @@ func TestConfigToReceiverConfig(t *testing.T) { Receiver: "GENERIC", }, expected: func() ReceiverConfig { - rc := DefaultConfig() - rc.Receiver = ReceiverGeneric + rc := CTVVastConfig{Receiver: "GENERIC"}.ReceiverConfig() return rc }(), }, @@ -478,8 +482,7 @@ func TestConfigToReceiverConfig(t *testing.T) { DefaultCurrency: "EUR", }, expected: func() ReceiverConfig { - rc := DefaultConfig() - rc.DefaultCurrency = "EUR" + rc := CTVVastConfig{DefaultCurrency: "EUR"}.ReceiverConfig() return rc }(), }, @@ -489,8 +492,7 @@ func TestConfigToReceiverConfig(t *testing.T) { SelectionStrategy: "max_revenue", }, expected: func() ReceiverConfig { - rc := DefaultConfig() - rc.SelectionStrategy = SelectionMaxRevenue + rc := CTVVastConfig{SelectionStrategy: "max_revenue"}.ReceiverConfig() return rc }(), }, @@ -500,8 +502,7 @@ func TestConfigToReceiverConfig(t *testing.T) { CollisionPolicy: "reject", }, expected: func() ReceiverConfig { - rc := DefaultConfig() - rc.CollisionPolicy = CollisionReject + rc := CTVVastConfig{CollisionPolicy: "reject"}.ReceiverConfig() return rc }(), }, @@ -509,7 +510,8 @@ func TestConfigToReceiverConfig(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := configToReceiverConfig(tc.input) + // ReceiverConfig() is now the canonical conversion (BUG 8 fix) + result := tc.input.ReceiverConfig() assert.Equal(t, tc.expected.Receiver, result.Receiver) assert.Equal(t, tc.expected.DefaultCurrency, result.DefaultCurrency) assert.Equal(t, tc.expected.SelectionStrategy, result.SelectionStrategy) @@ -519,6 +521,8 @@ func TestConfigToReceiverConfig(t *testing.T) { } func TestEnrichVastDocument(t *testing.T) { + // enrichVastDocument was replaced by hookEnricher.Enrich() (BUG 3 + BUG 8 fix). + // These tests now exercise hookEnricher directly. testCases := []struct { name string inputVast string @@ -567,15 +571,19 @@ func TestEnrichVastDocument(t *testing.T) { }, } + e := &hookEnricher{} for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { vastDoc, err := parseTestVast(tc.inputVast) require.NoError(t, err) - result := enrichVastDocument(vastDoc, tc.meta, tc.cfg) - require.NotNil(t, result) + ad := model.ExtractFirstAd(vastDoc) + require.NotNil(t, ad) - xmlBytes, err := result.Marshal() + _, enrichErr := e.Enrich(ad, tc.meta, tc.cfg) + require.NoError(t, enrichErr) + + xmlBytes, err := vastDoc.Marshal() require.NoError(t, err) xmlStr := string(xmlBytes) @@ -594,8 +602,9 @@ func TestEnrichVastDocument(t *testing.T) { } func TestEnrichVastDocument_NilInput(t *testing.T) { - result := enrichVastDocument(nil, CanonicalMeta{}, ReceiverConfig{}) - assert.Nil(t, result) + e := &hookEnricher{} + _, err := e.Enrich(nil, CanonicalMeta{}, ReceiverConfig{}) + assert.NoError(t, err) } // parseTestVast is a helper to parse VAST XML for tests diff --git a/modules/prebid/ctv_vast_enrichment/pipeline.go b/modules/prebid/ctv_vast_enrichment/pipeline.go index 93443b84e8d..fbc6959fad3 100644 --- a/modules/prebid/ctv_vast_enrichment/pipeline.go +++ b/modules/prebid/ctv_vast_enrichment/pipeline.go @@ -178,16 +178,16 @@ func (p *Processor) Process(ctx context.Context, req *openrtb2.BidRequest, resp func DefaultConfig() ReceiverConfig { return ReceiverConfig{ Receiver: ReceiverGAMSSU, - DefaultCurrency: "USD", - VastVersionDefault: "4.0", - MaxAdsInPod: 5, + DefaultCurrency: DefaultCurrency, + VastVersionDefault: DefaultVastVersion, // "3.0" — aligned with config.go constant (BUG 10) + MaxAdsInPod: DefaultMaxAdsInPod, SelectionStrategy: SelectionMaxRevenue, - CollisionPolicy: CollisionReject, + CollisionPolicy: CollisionVastWins, // "VAST_WINS" — aligned with DefaultCollisionPolicy (BUG 2) Placement: PlacementRules{ Pricing: PricingRules{ FloorCPM: 0, CeilingCPM: 0, - Currency: "USD", + Currency: DefaultCurrency, }, Advertiser: AdvertiserRules{ BlockedDomains: []string{}, diff --git a/modules/prebid/ctv_vast_enrichment/pipeline_test.go b/modules/prebid/ctv_vast_enrichment/pipeline_test.go index b409afdf13c..1cc771e7843 100644 --- a/modules/prebid/ctv_vast_enrichment/pipeline_test.go +++ b/modules/prebid/ctv_vast_enrichment/pipeline_test.go @@ -3,9 +3,6 @@ package ctv_vast_enrichment import ( "context" "fmt" - "io" - "net/http" - "net/http/httptest" "strings" "testing" @@ -134,7 +131,7 @@ func TestBuildVastFromBidResponse_NoAds(t *testing.T) { assert.True(t, result.NoAd) assert.NotEmpty(t, result.VastXML) - assert.Contains(t, string(result.VastXML), ``) + assert.Contains(t, string(result.VastXML), ``) assert.Empty(t, result.Selected) } @@ -204,7 +201,7 @@ func TestBuildVastFromBidResponse_SingleBid(t *testing.T) { assert.Len(t, result.Selected, 1) xmlStr := string(result.VastXML) - assert.Contains(t, xmlStr, ``) + assert.Contains(t, xmlStr, ``) assert.Contains(t, xmlStr, `Test Ad") } @@ -395,179 +392,6 @@ func TestBuildVastFromBidResponse_EnrichmentAddsMetadata(t *testing.T) { assert.Contains(t, xmlStr, "bid-enriched") } -// HTTP Handler Tests - -func TestHandler_MethodNotAllowed(t *testing.T) { - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter) - - req := httptest.NewRequest(http.MethodPost, "/vast", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) -} - -func TestHandler_NotConfigured(t *testing.T) { - handler := NewHandler() // No selector/enricher/formatter - - req := httptest.NewRequest(http.MethodGet, "/vast", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusInternalServerError, rec.Code) - body, _ := io.ReadAll(rec.Body) - assert.Contains(t, string(body), "not properly configured") -} - -func TestHandler_NoAuction_ReturnsNoAdVast(t *testing.T) { - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter) - // No AuctionFunc set, should return no-ad VAST - - req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=test-pod", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "application/xml; charset=utf-8", rec.Header().Get("Content-Type")) - - body, _ := io.ReadAll(rec.Body) - assert.Contains(t, string(body), ``) -} - -func TestHandler_WithMockAuction_ReturnsVast(t *testing.T) { - vastXML := ` - - - - MockServer - Mock Ad - - - - 00:00:15 - - - - - -` - - mockAuction := func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { - return &openrtb2.BidResponse{ - ID: "mock-resp", - SeatBid: []openrtb2.SeatBid{ - { - Seat: "mock-bidder", - Bid: []openrtb2.Bid{ - { - ID: "mock-bid-1", - ImpID: "imp-1", - Price: 3.50, - AdM: vastXML, - }, - }, - }, - }, - }, nil - } - - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter). - WithAuctionFunc(mockAuction) - - req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=test-pod", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "application/xml; charset=utf-8", rec.Header().Get("Content-Type")) - - body, _ := io.ReadAll(rec.Body) - xmlStr := string(body) - assert.Contains(t, xmlStr, ``) - assert.Contains(t, xmlStr, `Mock Ad") -} - -func TestHandler_WithConfig(t *testing.T) { - cfg := ReceiverConfig{ - Receiver: ReceiverGAMSSU, - VastVersionDefault: "3.0", - DefaultCurrency: "EUR", - } - - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithConfig(cfg). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter) - - req := httptest.NewRequest(http.MethodGet, "/vast", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - body, _ := io.ReadAll(rec.Body) - // Should use version 3.0 from config - assert.Contains(t, string(body), `version="3.0"`) -} - -func TestHandler_CacheControlHeader(t *testing.T) { - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter) - - req := httptest.NewRequest(http.MethodGet, "/vast", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, "no-cache, no-store, must-revalidate", rec.Header().Get("Cache-Control")) -} - -func TestHandler_PodIDFromQuery(t *testing.T) { - var capturedReq *openrtb2.BidRequest - - mockAuction := func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { - capturedReq = req - return &openrtb2.BidResponse{}, nil - } - - selector, enricher, formatter := newTestComponents() - handler := NewHandler(). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter). - WithAuctionFunc(mockAuction) - - req := httptest.NewRequest(http.MethodGet, "/vast?pod_id=custom-pod-123", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - require.NotNil(t, capturedReq) - assert.Equal(t, "custom-pod-123", capturedReq.ID) -} - // Test warnings are captured func TestBuildVastFromBidResponse_WarningsCollected(t *testing.T) { cfg := DefaultConfig() From efc068f95cb729507cf0f9d5f687f32a72996bb6 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Wed, 13 May 2026 07:02:24 +0200 Subject: [PATCH 12/15] style: run gofmt on module.go and module_e2e_test.go --- modules/prebid/ctv_vast_enrichment/module.go | 2 +- modules/prebid/ctv_vast_enrichment/module_e2e_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/prebid/ctv_vast_enrichment/module.go b/modules/prebid/ctv_vast_enrichment/module.go index fa8b111ef55..10278f7df31 100644 --- a/modules/prebid/ctv_vast_enrichment/module.go +++ b/modules/prebid/ctv_vast_enrichment/module.go @@ -10,8 +10,8 @@ import ( "github.com/prebid/prebid-server/v4/adapters" "github.com/prebid/prebid-server/v4/hooks/hookstage" "github.com/prebid/prebid-server/v4/modules/moduledeps" - "github.com/prebid/prebid-server/v4/openrtb_ext" "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/model" + "github.com/prebid/prebid-server/v4/openrtb_ext" ) // Builder creates a new CTV VAST enrichment module instance. diff --git a/modules/prebid/ctv_vast_enrichment/module_e2e_test.go b/modules/prebid/ctv_vast_enrichment/module_e2e_test.go index deca350359c..76803ea21e9 100644 --- a/modules/prebid/ctv_vast_enrichment/module_e2e_test.go +++ b/modules/prebid/ctv_vast_enrichment/module_e2e_test.go @@ -51,11 +51,11 @@ import ( "github.com/prebid/openrtb/v20/openrtb2" "github.com/prebid/prebid-server/v4/adapters" "github.com/prebid/prebid-server/v4/hooks/hookstage" + "github.com/prebid/prebid-server/v4/modules/moduledeps" ctv "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/enrich" "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/format" bidselect "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/select" - "github.com/prebid/prebid-server/v4/modules/moduledeps" "github.com/prebid/prebid-server/v4/openrtb_ext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -324,7 +324,7 @@ func TestE2E_A7_VastWinsPreservesExistingPricing(t *testing.T) { Bids: []*adapters.TypedBid{ videoTypedBid(&openrtb2.Bid{ ID: "b1", - Price: 9.99, // bidder price + Price: 9.99, // bidder price AdM: vastWithPricing, // already has GBP 3.00 }), }, From dbbab7779e5517f9d5ec56a9a8625931da5c9364 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Wed, 13 May 2026 07:04:51 +0200 Subject: [PATCH 13/15] docs(ctv_vast_enrichment): add E2EReadme.md describing end-to-end test suite Documents all 25 E2E tests across 4 groups: - A: Hook path correctness (A1-A9) - B: Config merging (B1-B2) - C: Pipeline end-to-end via BuildVastFromBidResponse (C1-C8) - D: Regression tests for BUGs 1,2,3,4,6,7 from ctv-bugs-and-resolve.md (D1-D6) Includes test fixtures reference, how-to-run commands, and related files. --- .../prebid/ctv_vast_enrichment/E2EReadme.md | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 modules/prebid/ctv_vast_enrichment/E2EReadme.md diff --git a/modules/prebid/ctv_vast_enrichment/E2EReadme.md b/modules/prebid/ctv_vast_enrichment/E2EReadme.md new file mode 100644 index 00000000000..be0723cb99b --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/E2EReadme.md @@ -0,0 +1,335 @@ +# CTV VAST Enrichment — End-to-End Test Suite + +> **File:** `modules/prebid/ctv_vast_enrichment/module_e2e_test.go` +> **Package:** `ctv_vast_enrichment_test` +> **Total tests:** 25 + +--- + +## Overview + +The end-to-end test suite exercises the **full hook path** of the `ctv_vast_enrichment` module using real sub-package implementations (`enrich.NewEnricher`, `format.NewFormatter`, `select.NewSelector`) instead of mocks. + +This means regressions in integration points — config merging, VAST parsing, enrichment, marshaling — are caught at the boundary where all components work together, not just in isolation. + +The suite is divided into four groups: + +| Group | Prefix | Focus | +|-------|--------|-------| +| A | `TestE2E_A*` | Hook path correctness (HandleRawBidderResponseHook) | +| B | `TestE2E_B*` | Configuration merging correctness | +| C | `TestE2E_C*` | Pipeline end-to-end (BuildVastFromBidResponse) | +| D | `TestE2E_D*` | Regression tests — bugs documented in `ctv-bugs-and-resolve.md` | + +--- + +## Group A — Hook Path Correctness + +These tests call `HandleRawBidderResponseHook` directly and verify the resulting VAST XML after applying all ChangeSet mutations. + +### A1 — Video bid enriched + +**Scenario:** A single video bid with a valid VAST is submitted. + +**Expectation:** +- `` is injected into the VAST +- `` is populated with the first entry from `ADomain` + +--- + +### A2 — Banner bid passes through untouched + +**Scenario:** A bid with `BidType = banner` is submitted with HTML content in `AdM`. + +**Expectation:** +- The `AdM` field is identical before and after the hook +- No VAST parsing is attempted (BidType guard fires first) + +--- + +### A3 — Native bid passes through untouched + +**Scenario:** A bid with `BidType = native` is submitted with JSON content in `AdM`. + +**Expectation:** +- The `AdM` field is identical before and after the hook +- No VAST parsing is attempted + +--- + +### A4 — BidMeta preserved on enriched TypedBid + +**Scenario:** A video bid carries `BidMeta` with `NetworkID = 42`, `AdvertiserID = 99`, `BrandID = 7`. + +**Expectation:** +- After enrichment the `TypedBid.BidMeta` pointer is not nil +- All three fields retain their original values + +--- + +### A5 — BidderResponse.Currency used in `` + +**Scenario:** Host config sets `DefaultCurrency = "USD"`. The DSP's `BidderResponse.Currency` is `"EUR"`. + +**Expectation:** +- `` appears in the VAST +- `currency="USD"` does **not** appear — DSP currency takes precedence over host default + +--- + +### A6 — Fallback to DefaultCurrency when BidderResponse.Currency is empty + +**Scenario:** The DSP omits `Currency` in its response. Host config sets `DefaultCurrency = "GBP"`. + +**Expectation:** +- `` appears in the VAST +- The fallback chain is: `BidderResponse.Currency` → `DefaultCurrency` → `"USD"` + +--- + +### A7 — VAST_WINS: existing `` not overwritten + +**Scenario:** The VAST already contains `3.00`. The bid price is 9.99. + +**Expectation:** +- Original `GBP` and `3.00` are preserved +- `9.99` does not appear — VAST_WINS collision policy protects existing data + +--- + +### A8 — DSP-specific VAST extensions preserved after marshal + +**Scenario:** The VAST contains a `` block with a custom tracking URL. + +**Expectation:** +- `type="dsp_custom"` survives the parse → enrich → marshal round-trip +- The DSP tracker URL survives unchanged + +--- + +### A9 — Mixed bid types: only video bids enriched + +**Scenario:** Two bids in one response — one `banner`, one `video`. + +**Expectation:** +- Banner `AdM` is byte-identical before and after the hook +- Video `AdM` contains `` + +--- + +## Group B — Configuration Correctness + +### B1 — VAST_WINS collision policy round-trips through account config + +**Scenario:** No host-level `collision_policy`. Account config sets `collision_policy = "VAST_WINS"`. VAST already has pricing. + +**Expectation:** +- Original pricing is preserved (VAST_WINS applied correctly) +- Bidder price does not replace existing VAST pricing +- Verifies BUG 2: `VAST_WINS` was previously silently converted to `CollisionReject` + +--- + +### B2 — Account config overrides host config + +**Scenario:** Host config sets `default_currency = "USD"`. Account config sets `default_currency = "EUR"`. DSP omits currency. + +**Expectation:** +- `currency="EUR"` appears in the VAST — account-level config wins over host-level + +--- + +## Group C — Pipeline End-to-End + +These tests call `BuildVastFromBidResponse` directly with real selector, enricher, and formatter components (no mocks). + +### C1 — Single video bid → enriched VAST + +**Scenario:** One bid at price 5.00 USD from seat "rubicon" with domain "brand.example.com". + +**Expectation:** +- `result.NoAd` is false +- VAST contains `` with value 5 +- VAST contains `brand.example.com` + +--- + +### C2 — Ad pod: sequence attributes set correctly + +**Scenario:** Three video bids with prices 10.0, 8.0, 6.0 using `SelectionTopN` with `MaxAdsInPod = 3`. + +**Expectation:** +- All three bids are selected +- VAST output contains `sequence="1"`, `sequence="2"`, `sequence="3"` on each `` + +--- + +### C3 — No bids → NoAd VAST returned + +**Scenario:** Empty `BidResponse` with no seat bids. + +**Expectation:** +- `result.NoAd` is true +- A valid empty `` document is returned (not an error) + +--- + +### C4 — Invalid VAST, skeleton disabled → NoAd + +**Scenario:** Bid has `AdM = "not-xml-at-all"`. `AllowSkeletonVast = false`. + +**Expectation:** +- `result.NoAd` is true +- No panic, no error returned at the function level + +--- + +### C5 — Invalid VAST, skeleton enabled → VAST with warning + +**Scenario:** Bid has `AdM = "not-xml-at-all"`. `AllowSkeletonVast = true`. + +**Expectation:** +- `result.NoAd` is false — a skeleton VAST is generated +- `result.Warnings` is non-empty (warning about invalid VAST parsing) + +--- + +### C6 — Duration from metadata injected into `` + +**Scenario:** A custom selector injects `DurSec = 45` into `CanonicalMeta`. The VAST has no `` element. + +**Expectation:** +- Output VAST contains `00:00:45` + +--- + +### C7 — IAB categories injected as VAST extension + +**Scenario:** Bid has `Cat = ["IAB1", "IAB2-3"]`. + +**Expectation:** +- Output VAST contains `IAB1` and `IAB2-3` +- Extension is typed `iab_category` + +--- + +### C8 — Debug extension includes BidID and Seat + +**Scenario:** `ReceiverConfig.Debug = true`. Bid ID is `"debug-bid-123"`, seat is `"rubicon"`. + +**Expectation:** +- Output VAST contains `debug-bid-123` +- Output VAST contains `rubicon` +- Both wrapped in `` + +--- + +## Group D — Regression Tests + +Each test in this group directly targets a specific bug documented in [`ctv-bugs-and-resolve.md`](ctv-bugs-and-resolve.md). + +### D1 — Non-USD DSP currency preserved (BUG 1) + +**Scenario:** Host config: `USD`. DSP responses in `EUR`, `JPY`, `BRL`, `AUD` (parametrized sub-tests). + +**Expectation:** +- Each currency appears in `` unchanged +- `USD` does not appear in any output + +**Bug fixed:** `BidderResponse.Currency` was ignored; `DefaultCurrency` from host config was always used, producing wrong labels like `0.85` for a EUR DSP. + +--- + +### D2 — VAST_WINS not silently converted to Reject (BUG 2) + +**Scenario:** Host config sets `collision_policy = "VAST_WINS"`. VAST has existing pricing. + +**Expectation:** +- Existing pricing preserved (VAST_WINS applied) + +**Bug fixed:** The `switch` statement in `configToReceiverConfig` was missing the `VAST_WINS` case, causing it to fall through to the zero value `CollisionReject`. Publishers setting `VAST_WINS` got the opposite behavior with no error. + +--- + +### D3 — Hook uses enrich subpackage (BUG 3) + +**Scenario:** Account config sets `debug = true`. A video bid is submitted. + +**Expectation:** +- Output VAST contains `` with `` +- This extension is only added by `enrich.VastEnricher`, not the fallback `hookEnricher` + +**Bug fixed:** The hook was calling a private `enrichVastDocument()` function that only handled `Pricing` and `Advertiser`. The subpackages `enrich/`, `format/`, `select/` were completely bypassed. Config fields like `debug`, `selection_strategy`, `placement` had no effect in production. + +--- + +### D4 — MediaFiles preserved after clearInnerXML + marshal (BUG 4) + +**Scenario:** A VAST with a `` containing a video URL is enriched. + +**Expectation:** +- After enrichment and marshal, `` element still exists +- The video URL is unchanged +- The `type="video/mp4"` attribute is preserved + +**Bug fixed:** `clearInnerXML()` was zeroing all `,innerxml` fields recursively, including `Creative.InnerXML` and `Linear.InnerXML`. This silently dropped ``, ``, and any DSP-specific extensions. + +--- + +### D5 — BidMeta fields survive the hook mutation (BUG 6) + +**Scenario:** A `TypedBid` carries `BidMeta` with `NetworkID`, `AdvertiserID`, `BrandID`, `PrimaryCategoryID`. + +**Expectation:** +- After the hook, all four fields retain their original values + +**Bug fixed:** When constructing the enriched `TypedBid`, `BidMeta` was not copied. Analytics and targeting systems downstream received `nil` instead of the original metadata. + +--- + +### D6 — Only first ADomain used in `` (BUG 7) + +**Scenario:** Bid has `ADomain = ["primary.com", "secondary.com", "tertiary.com"]`. + +**Expectation:** +- `primary.com` — exactly the first domain +- The string `"primary.com,secondary.com"` does not appear anywhere + +**Bug fixed:** `strings.Join(bid.ADomain, ",")` was producing `"primary.com,secondary.com,tertiary.com"` as the advertiser value. VAST `` is a single human-readable string — joining multiple domains is non-standard and breaks ad server parsing. + +--- + +## Running the tests + +```bash +# Run only the E2E suite +go test ./modules/prebid/ctv_vast_enrichment/... -run TestE2E -v + +# Run the full module test suite +go test ./modules/prebid/ctv_vast_enrichment/... -v + +# Run with race detector +go test ./modules/prebid/ctv_vast_enrichment/... -race -v +``` + +--- + +## Test fixtures used + +| Constant | Description | +|----------|-------------| +| `minimalVAST` | Well-formed VAST 3.0 with one InLine ad, ``, and ``. Baseline for most tests. | +| `vastWithPricing` | VAST with existing `3.00`. Used for VAST_WINS tests. | +| `vastWithExtensions` | VAST with a `` block. Used for clearInnerXML regression test. | + +--- + +## Related files + +| File | Description | +|------|-------------| +| [`ctv-bugs-and-resolve.md`](ctv-bugs-and-resolve.md) | Full bug descriptions, root cause analysis, and fix specifications | +| [`module.go`](module.go) | Hook implementation — entry point for all Group A and B tests | +| [`pipeline.go`](pipeline.go) | `BuildVastFromBidResponse` — entry point for all Group C tests | +| [`enrich/enrich.go`](enrich/enrich.go) | `VastEnricher` — enriches ``, ``, ``, categories, debug | +| [`model/vast_xml.go`](model/vast_xml.go) | VAST data model and `clearInnerXML` — relevant to D4 | From e20500a5c7a4761b12996502b59568f2cddd2bdd Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Thu, 14 May 2026 17:38:19 +0200 Subject: [PATCH 14/15] fix comments --- modules/prebid/ctv_vast_enrichment/README.md | 41 +- .../prebid/ctv_vast_enrichment/README_EN.md | 41 +- modules/prebid/ctv_vast_enrichment/config.go | 2 +- .../prebid/ctv_vast_enrichment/config_test.go | 2 +- .../prebid/ctv_vast_enrichment/endpoint.md | 370 +++++++++++++++++ .../prebid/ctv_vast_enrichment/endpointeng.md | 370 +++++++++++++++++ .../ctv_vast_enrichment/enrich/enrich.go | 67 +++- .../ctv_vast_enrichment/format/format.go | 27 +- .../ctv_vast_enrichment/format/format_test.go | 2 +- modules/prebid/ctv_vast_enrichment/handler.go | 161 -------- .../prebid/ctv_vast_enrichment/model/model.go | 27 -- .../ctv_vast_enrichment/model/parser.go | 22 +- .../ctv_vast_enrichment/model/vast_xml.go | 153 ++++++++ .../prebid/ctv_vast_enrichment/needChanges.md | 251 ++++++++++++ .../ctv_vast_enrichment/neededChangesENG.md | 251 ++++++++++++ .../prebid/ctv_vast_enrichment/pipeline.go | 8 +- .../select/price_selector.go | 16 +- .../select/price_selector_test.go | 20 +- .../ctv_vast_enrichment/select/selector.go | 8 +- .../prebid/ctv_vast_enrichment/testcases.md | 371 ++++++++++++++++++ .../ctv_vast_enrichment/testcasesENG.md | 371 ++++++++++++++++++ modules/prebid/ctv_vast_enrichment/types.go | 9 +- 22 files changed, 2260 insertions(+), 330 deletions(-) create mode 100644 modules/prebid/ctv_vast_enrichment/endpoint.md create mode 100644 modules/prebid/ctv_vast_enrichment/endpointeng.md delete mode 100644 modules/prebid/ctv_vast_enrichment/handler.go delete mode 100644 modules/prebid/ctv_vast_enrichment/model/model.go create mode 100644 modules/prebid/ctv_vast_enrichment/needChanges.md create mode 100644 modules/prebid/ctv_vast_enrichment/neededChangesENG.md create mode 100644 modules/prebid/ctv_vast_enrichment/testcases.md create mode 100644 modules/prebid/ctv_vast_enrichment/testcasesENG.md diff --git a/modules/prebid/ctv_vast_enrichment/README.md b/modules/prebid/ctv_vast_enrichment/README.md index df86282bc6e..86856d2f017 100644 --- a/modules/prebid/ctv_vast_enrichment/README.md +++ b/modules/prebid/ctv_vast_enrichment/README.md @@ -10,12 +10,10 @@ modules/prebid/ctv_vast_enrichment/ ├── module_test.go # Module tests ├── pipeline.go # Standalone VAST processing pipeline ├── pipeline_test.go # Pipeline tests -├── handler.go # HTTP handler for direct VAST requests ├── types.go # Type definitions, interfaces and constants ├── config.go # Configuration and layer merging (host/account/profile) ├── config_test.go # Configuration tests ├── model/ # VAST XML data structures -│ ├── model.go # High-level domain objects │ ├── vast_xml.go # XML structures for marshal/unmarshal │ ├── parser.go # VAST XML parser │ └── *_test.go # Tests @@ -126,7 +124,7 @@ Main entry point following PBS module conventions: ### \`pipeline.go\` - Standalone Pipeline -Alternative entry point for direct invocation (used by handler.go): +Alternative entry point for direct invocation: - **\`BuildVastFromBidResponse()\`** - Orchestrates the full pipeline: 1. Bid selection from auction response @@ -137,21 +135,13 @@ Alternative entry point for direct invocation (used by handler.go): - **\`Processor\`** - Wrapper with injected dependencies - **\`DefaultConfig()\`** - Default configuration for GAM SSU -### \`handler.go\` - HTTP Handler - -HTTP request handling for CTV VAST ads (optional endpoint): - -- **\`Handler\`** - HTTP handler with configuration and dependencies -- **\`ServeHTTP()\`** - Handles GET requests, returns VAST XML -- Builder methods: \`WithConfig()\`, \`WithSelector()\`, etc. - ### \`types.go\` - Types and Interfaces | Type | Description | |------|-------------| | \`ReceiverType\` | Receiver type (GAM_SSU, GENERIC) | -| \`SelectionStrategy\` | Bid selection strategy (SINGLE, TOP_N, max_revenue, min_duration, balanced); unknown values fall back to default | -| \`CollisionPolicy\` | Collision policy (reject, warn, ignore, VAST_WINS) | +| \`SelectionStrategy\` | Bid selection strategy; implemented: `SINGLE`, `TOP_N`. Constants `max_revenue`, `min_duration`, `balanced` are reserved for future implementation and fall back to `TOP_N` | +| \`CollisionPolicy\` | Collision policy: `VAST_WINS` / `reject` keep existing VAST value (with warning); `warn` overwrites with warning; `ignore` overwrites silently | **Interfaces:** @@ -209,7 +199,7 @@ Helper functions: - `Marshal()` / `MarshalCompact()` - Serialize to XML - `clearInnerXML()` - Clears `InnerXML` fields before serialization (prevents element duplication) -> **XML Fix:** VAST structures use the `,innerxml` tag to preserve raw XML during parsing. Before `Marshal()`, `clearInnerXML()` is called to zero out `InnerXML` fields on `Ad`, `InLine`, `Wrapper`, `Creative`, and `Linear` structs, preventing duplicate elements in the output XML. +> **XML Fix:** VAST structures use the `,innerxml` tag to preserve raw XML during parsing. Before `Marshal()`, `clearInnerXML()` is called to zero out `InnerXML` fields on `Ad`, `InLine`, and `Wrapper` only. `Creative` and `Linear` `InnerXML` fields are intentionally preserved to keep ``, ``, and DSP-specific extensions intact. #### `parser.go` @@ -235,8 +225,10 @@ Logic for selecting bids from auction response: Adding metadata to VAST ads: -- **\`VastEnricher\`** - Implementation with VAST_WINS policy: - - Existing values in VAST are not overwritten +- **\`VastEnricher\`** - Respects the configured `CollisionPolicy`: + - `VAST_WINS` / `reject`: keeps existing VAST values (with warning) + - `warn`: overwrites existing values, emits warning + - `ignore`: overwrites silently - Adds missing: Pricing, Advertiser, Duration, Categories Enriched elements: @@ -344,7 +336,7 @@ The module is automatically invoked during the auction pipeline when enabled in } ``` -### Standalone Pipeline (for HTTP handler) +### Standalone Pipeline \`\`\`go import ( @@ -375,19 +367,6 @@ result, err := vast.BuildVastFromBidResponse( ) \`\`\` -### HTTP Handler - -\`\`\`go -handler := vast.NewHandler(). - WithConfig(cfg). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter). - WithAuctionFunc(myAuctionFunc) - -http.Handle("/vast", handler) -\`\`\` - ## Layer Configuration \`\`\`go @@ -395,7 +374,7 @@ http.Handle("/vast", handler) hostCfg := &vast.CTVVastConfig{ Receiver: "GAM_SSU", DefaultCurrency: "USD", - VastVersionDefault: "4.0", + VastVersionDefault: "3.0", } // Account configuration (overrides host) diff --git a/modules/prebid/ctv_vast_enrichment/README_EN.md b/modules/prebid/ctv_vast_enrichment/README_EN.md index df86282bc6e..86856d2f017 100644 --- a/modules/prebid/ctv_vast_enrichment/README_EN.md +++ b/modules/prebid/ctv_vast_enrichment/README_EN.md @@ -10,12 +10,10 @@ modules/prebid/ctv_vast_enrichment/ ├── module_test.go # Module tests ├── pipeline.go # Standalone VAST processing pipeline ├── pipeline_test.go # Pipeline tests -├── handler.go # HTTP handler for direct VAST requests ├── types.go # Type definitions, interfaces and constants ├── config.go # Configuration and layer merging (host/account/profile) ├── config_test.go # Configuration tests ├── model/ # VAST XML data structures -│ ├── model.go # High-level domain objects │ ├── vast_xml.go # XML structures for marshal/unmarshal │ ├── parser.go # VAST XML parser │ └── *_test.go # Tests @@ -126,7 +124,7 @@ Main entry point following PBS module conventions: ### \`pipeline.go\` - Standalone Pipeline -Alternative entry point for direct invocation (used by handler.go): +Alternative entry point for direct invocation: - **\`BuildVastFromBidResponse()\`** - Orchestrates the full pipeline: 1. Bid selection from auction response @@ -137,21 +135,13 @@ Alternative entry point for direct invocation (used by handler.go): - **\`Processor\`** - Wrapper with injected dependencies - **\`DefaultConfig()\`** - Default configuration for GAM SSU -### \`handler.go\` - HTTP Handler - -HTTP request handling for CTV VAST ads (optional endpoint): - -- **\`Handler\`** - HTTP handler with configuration and dependencies -- **\`ServeHTTP()\`** - Handles GET requests, returns VAST XML -- Builder methods: \`WithConfig()\`, \`WithSelector()\`, etc. - ### \`types.go\` - Types and Interfaces | Type | Description | |------|-------------| | \`ReceiverType\` | Receiver type (GAM_SSU, GENERIC) | -| \`SelectionStrategy\` | Bid selection strategy (SINGLE, TOP_N, max_revenue, min_duration, balanced); unknown values fall back to default | -| \`CollisionPolicy\` | Collision policy (reject, warn, ignore, VAST_WINS) | +| \`SelectionStrategy\` | Bid selection strategy; implemented: `SINGLE`, `TOP_N`. Constants `max_revenue`, `min_duration`, `balanced` are reserved for future implementation and fall back to `TOP_N` | +| \`CollisionPolicy\` | Collision policy: `VAST_WINS` / `reject` keep existing VAST value (with warning); `warn` overwrites with warning; `ignore` overwrites silently | **Interfaces:** @@ -209,7 +199,7 @@ Helper functions: - `Marshal()` / `MarshalCompact()` - Serialize to XML - `clearInnerXML()` - Clears `InnerXML` fields before serialization (prevents element duplication) -> **XML Fix:** VAST structures use the `,innerxml` tag to preserve raw XML during parsing. Before `Marshal()`, `clearInnerXML()` is called to zero out `InnerXML` fields on `Ad`, `InLine`, `Wrapper`, `Creative`, and `Linear` structs, preventing duplicate elements in the output XML. +> **XML Fix:** VAST structures use the `,innerxml` tag to preserve raw XML during parsing. Before `Marshal()`, `clearInnerXML()` is called to zero out `InnerXML` fields on `Ad`, `InLine`, and `Wrapper` only. `Creative` and `Linear` `InnerXML` fields are intentionally preserved to keep ``, ``, and DSP-specific extensions intact. #### `parser.go` @@ -235,8 +225,10 @@ Logic for selecting bids from auction response: Adding metadata to VAST ads: -- **\`VastEnricher\`** - Implementation with VAST_WINS policy: - - Existing values in VAST are not overwritten +- **\`VastEnricher\`** - Respects the configured `CollisionPolicy`: + - `VAST_WINS` / `reject`: keeps existing VAST values (with warning) + - `warn`: overwrites existing values, emits warning + - `ignore`: overwrites silently - Adds missing: Pricing, Advertiser, Duration, Categories Enriched elements: @@ -344,7 +336,7 @@ The module is automatically invoked during the auction pipeline when enabled in } ``` -### Standalone Pipeline (for HTTP handler) +### Standalone Pipeline \`\`\`go import ( @@ -375,19 +367,6 @@ result, err := vast.BuildVastFromBidResponse( ) \`\`\` -### HTTP Handler - -\`\`\`go -handler := vast.NewHandler(). - WithConfig(cfg). - WithSelector(selector). - WithEnricher(enricher). - WithFormatter(formatter). - WithAuctionFunc(myAuctionFunc) - -http.Handle("/vast", handler) -\`\`\` - ## Layer Configuration \`\`\`go @@ -395,7 +374,7 @@ http.Handle("/vast", handler) hostCfg := &vast.CTVVastConfig{ Receiver: "GAM_SSU", DefaultCurrency: "USD", - VastVersionDefault: "4.0", + VastVersionDefault: "3.0", } // Account configuration (overrides host) diff --git a/modules/prebid/ctv_vast_enrichment/config.go b/modules/prebid/ctv_vast_enrichment/config.go index 7ce10c3b06d..e1685a379bb 100644 --- a/modules/prebid/ctv_vast_enrichment/config.go +++ b/modules/prebid/ctv_vast_enrichment/config.go @@ -75,7 +75,7 @@ const ( DefaultMaxAdsInPod = 10 DefaultCollisionPolicy = "VAST_WINS" DefaultReceiver = "GAM_SSU" - DefaultSelectionStrategy = "max_revenue" + DefaultSelectionStrategy = "TOP_N" // Placement constants for pricing PlacementVastPricing = "VAST_PRICING" diff --git a/modules/prebid/ctv_vast_enrichment/config_test.go b/modules/prebid/ctv_vast_enrichment/config_test.go index 0d0d52eafb3..9a7db8a71e4 100644 --- a/modules/prebid/ctv_vast_enrichment/config_test.go +++ b/modules/prebid/ctv_vast_enrichment/config_test.go @@ -160,7 +160,7 @@ func TestReceiverConfig_Defaults(t *testing.T) { assert.Equal(t, "USD", rc.DefaultCurrency) assert.Equal(t, "3.0", rc.VastVersionDefault) assert.Equal(t, 10, rc.MaxAdsInPod) - assert.Equal(t, SelectionStrategy("max_revenue"), rc.SelectionStrategy) + assert.Equal(t, SelectionStrategy("TOP_N"), rc.SelectionStrategy) assert.Equal(t, CollisionPolicy("VAST_WINS"), rc.CollisionPolicy) assert.False(t, rc.Debug) } diff --git a/modules/prebid/ctv_vast_enrichment/endpoint.md b/modules/prebid/ctv_vast_enrichment/endpoint.md new file mode 100644 index 00000000000..2f6df64291c --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/endpoint.md @@ -0,0 +1,370 @@ +# Propozycja: Endpoint GET `/ctv/vast` dla modułu CTV VAST Enrichment + +## Stan obecny + +Moduł `ctv_vast_enrichment` działa wyłącznie jako **hook PBS** na etapie `RawBidderResponse` — wzbogaca VAST XML w odpowiedziach bidderów podczas standardowej aukcji POST `/openrtb2/auction`. + +Istnieje już plik `handler.go` z przygotowanym `Handler struct` implementującym `http.Handler` (metoda `ServeHTTP`), ale **nie ma mechanizmu rejestracji** tego handlera w routerze PBS. Handler jest "osierocony" — nie jest podłączony do żadnej trasy HTTP. + +### Kluczowy problem + +System modułów PBS (`modules.NewBuilder().Build()`) zwraca wyłącznie `hooks.HookRepository` — repozytorium hooków. **Moduły nie mają dostępu do routera HTTP** i nie mogą samodzielnie rejestrować endpointów. Wszystkie trasy HTTP są hardkodowane w `router/router.go` w funkcji `New()`. + +## Proponowane podejście + +### Opcja A: Rejestracja endpointu bezpośrednio w routerze (rekomendowana) + +Tak samo jak robią to istniejące endpointy (`/openrtb2/amp`, `/openrtb2/video`, `/event` itp.) — endpoint jest tworzony w `router/router.go` i rejestrowany ręcznie. + +#### Kroki implementacji + +**1. Nowy interfejs `ModuleEndpointProvider` (opcjonalny, ale czyściejszy)** + +Aby nie hardkodować wszystkiego w routerze, moduł może eksponować interfejs informujący o endpointach: + +```go +// modules/moduledeps/endpoint.go +package moduledeps + +import "net/http" + +// ModuleEndpoint opisuje endpoint HTTP dostarczany przez moduł. +type ModuleEndpoint struct { + Method string // "GET", "POST" + Path string // np. "/ctv/vast" + Handler http.Handler +} + +// EndpointProvider to opcjonalny interfejs, który moduł może implementować, +// aby zarejestrować własne endpointy HTTP. +type EndpointProvider interface { + Endpoints() []ModuleEndpoint +} +``` + +**2. Implementacja interfejsu w module** + +```go +// modules/prebid/ctv_vast_enrichment/module.go + +func (m Module) Endpoints() []moduledeps.ModuleEndpoint { + handler := NewHandler(). + WithConfig(configToReceiverConfig(MergeCTVVastConfig(&m.hostConfig, nil, nil))) + // Selector, Enricher, Formatter zostaną wstrzyknięte przez router + // lub podłączone z domyślnymi implementacjami + + return []moduledeps.ModuleEndpoint{ + { + Method: "GET", + Path: "/ctv/vast", + Handler: handler, + }, + } +} +``` + +**3. Rejestracja w routerze** + +Rozszerzenie `router/router.go` — po `Build()` modułów sprawdzamy, czy moduły implementują `EndpointProvider`: + +```go +// router/router.go w funkcji New(), po modules.NewBuilder().Build() + +// Rejestracja endpointów modułów +for id, module := range builtModules { + if ep, ok := module.(moduledeps.EndpointProvider); ok { + for _, endpoint := range ep.Endpoints() { + logger.Infof("Registering module endpoint: %s %s (module: %s)", endpoint.Method, endpoint.Path, id) + r.Handler(endpoint.Method, endpoint.Path, endpoint.Handler) + } + } +} +``` + +**4. Wstrzyknięcie AuctionFunc** + +Handler potrzebuje `AuctionFunc` do wywoływania aukcji. To jest najważniejszy element — trzeba przekazać referencję do exchange'a: + +```go +handler.WithAuctionFunc(func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { + // Użyj theExchange.HoldAuction() lub dedykowaną metodę + return theExchange.RunSimpleAuction(ctx, req) +}) +``` + +#### Diagram przepływu (Opcja A) + +``` +GET /ctv/vast?pod_id=123&duration=30&max_ads=3 + │ + ▼ +┌─────────────────────┐ +│ router/router.go │ ← rejestracja: r.Handler("GET", "/ctv/vast", handler) +│ httprouter │ +└─────────┬───────────┘ + ▼ +┌─────────────────────┐ +│ Handler.ServeHTTP │ ← handler.go (już istnieje) +│ │ +│ 1. Parse query │ ← buildBidRequest() - TODO do implementacji +│ 2. Build BidReq │ +│ 3. AuctionFunc() │ ← wstrzyknięty exchange +│ 4. Pipeline VAST │ ← BuildVastFromBidResponse() - już istnieje +│ 5. Return XML │ +└─────────────────────┘ +``` + +--- + +### Opcja B: Endpoint jako osobny pakiet w `endpoints/` (prostsze, bez nowego interfejsu) + +Bez tworzenia nowego interfejsu modułowego — po prostu dodajemy endpoint w `endpoints/` tak jak inne: + +#### Kroki + +**1. Nowy plik `endpoints/ctv_vast.go`** + +```go +package endpoints + +import ( + "net/http" + + "github.com/julienschmidt/httprouter" + ctv "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" +) + +func NewCTVVastEndpoint(cfg ctv.ReceiverConfig, auctionFn ctv.AuctionFunc) httprouter.Handle { + handler := ctv.NewHandler(). + WithConfig(cfg). + WithSelector(selectpkg.NewDefaultSelector()). + WithEnricher(enrichpkg.NewDefaultEnricher()). + WithFormatter(formatpkg.NewGAMSSUFormatter()). + WithAuctionFunc(auctionFn) + + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + handler.ServeHTTP(w, r) + } +} +``` + +**2. Rejestracja w `router/router.go`** + +```go +// W funkcji New(), obok istniejących endpointów: +if cfg.Hooks.Modules["prebid"]["ctv_vast_enrichment"] != nil { + r.GET("/ctv/vast", endpoints.NewCTVVastEndpoint(ctvConfig, auctionFunc)) +} +``` + +--- + +### Opcja C: Rejestracja przez Exitpoint Hook (bez zmian w routerze) + +Zamiast nowego endpointu, moduł mógłby "przejąć" istniejący endpoint `/openrtb2/auction` przez hook `Exitpoint` i zmienić format odpowiedzi na VAST XML gdy wykryje specjalny parametr. **Nie rekomendowane** — to hack, nie czyste rozwiązanie. + +--- + +## Rekomendacja + +**Opcja A** (interfejs `EndpointProvider`) jest najczystsza architektonicznie: + +| Kryterium | Opcja A | Opcja B | Opcja C | +|-----------|---------|---------|---------| +| Czystość architektury | ✅ Extensible | ⚠️ Hardcoded | ❌ Hack | +| Łatwość implementacji | ⚠️ Nowy interfejs | ✅ Proste | ✅ Proste | +| Reużywalność | ✅ Inne moduły też skorzystają | ❌ Jednorazowe | ❌ Jednorazowe | +| Zgodność z PBS | ⚠️ Wymaga zmiany w `modules/` | ✅ Istniejący wzorzec | ⚠️ Nadużycie hooków | +| Minimum zmian w core | ❌ 3 pliki core | ✅ 2 pliki core | ✅ 0 plików core | + +**Dla szybkiego MVP: Opcja B** — najmniej zmian, zgodna z istniejącymi wzorcami PBS. + +**Dla długoterminowej architektury: Opcja A** — tworzy mechanizm reużywalny dla przyszłych modułów. + +--- + +## Co jest do zrobienia w każdym podejściu + +Niezależnie od wybranej opcji, `handler.go` wymaga implementacji: + +### 1. Parsowanie query parameters (`buildBidRequest`) + +```go +func (h *Handler) buildBidRequest(r *http.Request) (*openrtb2.BidRequest, error) { + q := r.URL.Query() + + podID := q.Get("pod_id") // wymagany + duration := q.Get("duration") // max duration w sekundach + maxAds := q.Get("max_ads") // max reklam w podzie + publisherID := q.Get("pub_id") // ID publishera + siteURL := q.Get("url") // URL strony + // ... dalsze parametry +} +``` + +### 2. Wstrzyknięcie Selector/Enricher/Formatter + +Handler potrzebuje konkretnych implementacji interfejsów. Należy zdecydować: +- Czy tworzyć je w Builderze modułu? +- Czy w endpoincie w routerze? + +### 3. Wstrzyknięcie AuctionFunc + +Najważniejsza zależność — handler musi móc wywołać aukcję PBS. Wymaga dostępu do `exchange.Exchange`. + +### 4. Testy + +- Unit test `handler_test.go` z mockowanym `AuctionFunc` +- Integration test z pełnym pipeline (Postman collection częściowo istnieje) + +--- + +## Podsumowanie ścieżki implementacji (Opcja B — MVP) + +``` +1. endpoints/ctv_vast.go ← nowy plik: wrapper endpoint +2. router/router.go ← dodanie r.GET("/ctv/vast", ...) +3. handler.go ← implementacja buildBidRequest() +4. handler_test.go ← testy +5. config (opcjonalnie) ← feature flag w pbs.json +``` + +Łączna estymacja: ~5 plików do zmiany/utworzenia. + +--- + +## Jak moduły są uruchamiane przez endpoint GET — mechanizm Hook Executor + +Moduły **nie są uruchamiane "przez endpoint" bezpośrednio** — są uruchamiane przez **hook executor**, który endpoint tworzy i wywołuje na odpowiednich etapach. + +### Łańcuch wywołań (na przykładzie AMP) + +``` +GET /openrtb2/amp?tag_id=xyz + │ + ▼ +router.go: r.GET("/openrtb2/amp", ampEndpoint) + │ + ▼ +AmpAuction handler tworzy hook executor: + hookExecutor := hookexecution.NewHookExecutor(planBuilder, "/openrtb2/amp", metrics) + │ + ├─► hookExecutor.ExecuteEntrypointStage(r, nil) ← moduły dostają raw *http.Request + │ + ├─► exchange.HoldAuction(ctx, AuctionRequest{HookExecutor: hookExecutor, ...}) + │ wewnątrz exchange: + │ ├─► ExecuteProcessedAuctionStage() + │ ├─► ExecuteBidderRequestStage() ← per bidder + │ ├─► ExecuteRawBidderResponseStage() ← TU działa ctv_vast_enrichment! + │ └─► ExecuteAllProcessedBidResponsesStage() + │ + ├─► hookExecutor.ExecuteAuctionResponseStage(response) + └─► hookExecutor.ExecuteExitpointStage(ampResponse, w) +``` + +AMP uruchamia **7 z 8 etapów** — wszystkie oprócz `RawAuctionRequest` (bo GET nie ma body JSON). + +### Kluczowy mechanizm: Execution Plan + +Hook executor wie, które moduły uruchomić, bo **szuka endpointu w `host_execution_plan`** — to jest literalne wyszukiwanie w mapie (`hooks/plan.go`): + +```go +cfg.Endpoints[endpoint].Stages[stage].Groups +``` + +Jeśli dany endpoint **nie jest kluczem** w mapie `endpoints` — **żadne hooki nie zostaną odpalone**, nawet jeśli moduł implementuje interfejs. Struktura konfiguracji (`config/hooks.go`): + +```go +type HookExecutionPlan struct { + Endpoints map[string]struct { // klucz = "/openrtb2/auction", "/openrtb2/amp", itp. + Stages map[string]struct { // klucz = "entrypoint", "raw_bidder_response", itp. + Groups []HookExecutionGroup + } + } +} +``` + +### Co to oznacza dla nowego endpointu `/ctv/vast` + +Nowy endpoint GET musi zrobić **dokładnie to samo co AMP**: + +**1. Zdefiniować stałą endpointu** w `hooks/hookexecution/executor.go`: + +```go +const EndpointCtvVast = "/ctv/vast" +``` + +**2. Stworzyć hook executor** w handlerze: + +```go +hookExecutor := hookexecution.NewHookExecutor(planBuilder, EndpointCtvVast, metrics) +``` + +**3. Wywołać etapy hooków** w odpowiednich momentach: + +```go +// Na początku +hookExecutor.ExecuteEntrypointStage(r, nil) + +// Przekazać executor do exchange +exchange.HoldAuction(ctx, AuctionRequest{HookExecutor: hookExecutor, ...}) +// ↑ wewnątrz exchange automatycznie odpalą się: +// ProcessedAuctionRequest, BidderRequest, RawBidderResponse, AllProcessedBidResponses + +// Po aukcji +hookExecutor.ExecuteAuctionResponseStage(response) +hookExecutor.ExecuteExitpointStage(vastXML, w) +``` + +**4. Dodać endpoint do execution plan** w `pbs.json`: + +```json +"host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { ... }, + "/ctv/vast": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } +} +``` + +### Podsumowanie mechanizmu + +| Element | Rola | +|---------|------| +| `hookexecution.NewHookExecutor(planBuilder, endpoint, metrics)` | Tworzy executor powiązany z endpointem | +| `planBuilder.PlanForXxxStage(endpoint)` | Szuka hooków w execution plan dla danego endpointu | +| `host_execution_plan.endpoints["/ctv/vast"]` | **Konfiguracja decyduje**, które moduły się odpalą | +| `exchange.HoldAuction(req{HookExecutor})` | Wewnętrznie odpala BidderRequest, RawBidderResponse itd. | + +Moduł `ctv_vast_enrichment` na nowym endpoincie GET zadziała **automatycznie** — o ile: +- handler tworzy `hookExecutor` i przekazuje go do `exchange.HoldAuction` +- endpoint jest dodany do `host_execution_plan` w konfiguracji + +**Nie trzeba żadnego nowego interfejsu do samego uruchomienia hooków** — cały mechanizm już istnieje. Jedyne co trzeba zrobić to podłączyć handler do routera i dać mu dostęp do `exchange` + `planBuilder`. + +### Zaktualizowana ścieżka implementacji + +``` +1. hooks/hookexecution/executor.go ← nowa stała EndpointCtvVast +2. endpoints/ctv_vast.go ← nowy handler z hookExecutor +3. router/router.go ← r.GET("/ctv/vast", ...) +4. handler.go ← implementacja buildBidRequest() +5. pbs.json ← dodanie /ctv/vast do execution plan +6. handler_test.go ← testy +``` diff --git a/modules/prebid/ctv_vast_enrichment/endpointeng.md b/modules/prebid/ctv_vast_enrichment/endpointeng.md new file mode 100644 index 00000000000..a0d3729d2d1 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/endpointeng.md @@ -0,0 +1,370 @@ +# Proposal: GET Endpoint `/ctv/vast` for the CTV VAST Enrichment Module + +## Current State + +The `ctv_vast_enrichment` module currently works exclusively as a **PBS hook** at the `RawBidderResponse` stage — it enriches VAST XML in bidder responses during a standard POST `/openrtb2/auction` auction. + +A `handler.go` file already exists with a prepared `Handler struct` implementing `http.Handler` (via the `ServeHTTP` method), but **there is no mechanism to register** this handler in the PBS router. The handler is "orphaned" — it is not attached to any HTTP route. + +### Core Problem + +The PBS module system (`modules.NewBuilder().Build()`) only returns a `hooks.HookRepository` — a hook repository. **Modules have no access to the HTTP router** and cannot register endpoints on their own. All HTTP routes are hardcoded in `router/router.go` within the `New()` function. + +## Proposed Approaches + +### Option A: Register endpoint directly in the router (recommended) + +Same approach used by existing endpoints (`/openrtb2/amp`, `/openrtb2/video`, `/event`, etc.) — the endpoint is created in `router/router.go` and registered manually. + +#### Implementation Steps + +**1. New `ModuleEndpointProvider` interface (optional, but cleaner)** + +To avoid hardcoding everything in the router, the module can expose an interface that declares its endpoints: + +```go +// modules/moduledeps/endpoint.go +package moduledeps + +import "net/http" + +// ModuleEndpoint describes an HTTP endpoint provided by a module. +type ModuleEndpoint struct { + Method string // "GET", "POST" + Path string // e.g. "/ctv/vast" + Handler http.Handler +} + +// EndpointProvider is an optional interface that a module can implement +// to register its own HTTP endpoints. +type EndpointProvider interface { + Endpoints() []ModuleEndpoint +} +``` + +**2. Implement the interface in the module** + +```go +// modules/prebid/ctv_vast_enrichment/module.go + +func (m Module) Endpoints() []moduledeps.ModuleEndpoint { + handler := NewHandler(). + WithConfig(configToReceiverConfig(MergeCTVVastConfig(&m.hostConfig, nil, nil))) + // Selector, Enricher, Formatter will be injected by the router + // or wired with default implementations + + return []moduledeps.ModuleEndpoint{ + { + Method: "GET", + Path: "/ctv/vast", + Handler: handler, + }, + } +} +``` + +**3. Registration in the router** + +Extend `router/router.go` — after `Build()` of modules, check if modules implement `EndpointProvider`: + +```go +// router/router.go in the New() function, after modules.NewBuilder().Build() + +// Register module endpoints +for id, module := range builtModules { + if ep, ok := module.(moduledeps.EndpointProvider); ok { + for _, endpoint := range ep.Endpoints() { + logger.Infof("Registering module endpoint: %s %s (module: %s)", endpoint.Method, endpoint.Path, id) + r.Handler(endpoint.Method, endpoint.Path, endpoint.Handler) + } + } +} +``` + +**4. Inject AuctionFunc** + +The handler needs `AuctionFunc` to invoke auctions. This is the most critical element — a reference to the exchange must be passed: + +```go +handler.WithAuctionFunc(func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { + // Use theExchange.HoldAuction() or a dedicated method + return theExchange.RunSimpleAuction(ctx, req) +}) +``` + +#### Flow Diagram (Option A) + +``` +GET /ctv/vast?pod_id=123&duration=30&max_ads=3 + │ + ▼ +┌─────────────────────┐ +│ router/router.go │ ← registration: r.Handler("GET", "/ctv/vast", handler) +│ httprouter │ +└─────────┬───────────┘ + ▼ +┌─────────────────────┐ +│ Handler.ServeHTTP │ ← handler.go (already exists) +│ │ +│ 1. Parse query │ ← buildBidRequest() - TODO to implement +│ 2. Build BidReq │ +│ 3. AuctionFunc() │ ← injected exchange +│ 4. Pipeline VAST │ ← BuildVastFromBidResponse() - already exists +│ 5. Return XML │ +└─────────────────────┘ +``` + +--- + +### Option B: Endpoint as a separate package in `endpoints/` (simpler, no new interface) + +Without creating a new module interface — simply add the endpoint in `endpoints/` like the others: + +#### Steps + +**1. New file `endpoints/ctv_vast.go`** + +```go +package endpoints + +import ( + "net/http" + + "github.com/julienschmidt/httprouter" + ctv "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" +) + +func NewCTVVastEndpoint(cfg ctv.ReceiverConfig, auctionFn ctv.AuctionFunc) httprouter.Handle { + handler := ctv.NewHandler(). + WithConfig(cfg). + WithSelector(selectpkg.NewDefaultSelector()). + WithEnricher(enrichpkg.NewDefaultEnricher()). + WithFormatter(formatpkg.NewGAMSSUFormatter()). + WithAuctionFunc(auctionFn) + + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + handler.ServeHTTP(w, r) + } +} +``` + +**2. Registration in `router/router.go`** + +```go +// In the New() function, alongside existing endpoints: +if cfg.Hooks.Modules["prebid"]["ctv_vast_enrichment"] != nil { + r.GET("/ctv/vast", endpoints.NewCTVVastEndpoint(ctvConfig, auctionFunc)) +} +``` + +--- + +### Option C: Registration via Exitpoint Hook (no router changes) + +Instead of a new endpoint, the module could "hijack" the existing `/openrtb2/auction` endpoint via the `Exitpoint` hook and change the response format to VAST XML when it detects a special parameter. **Not recommended** — this is a hack, not a clean solution. + +--- + +## Recommendation + +**Option A** (the `EndpointProvider` interface) is the cleanest architecturally: + +| Criterion | Option A | Option B | Option C | +|-----------|----------|----------|----------| +| Architectural cleanliness | ✅ Extensible | ⚠️ Hardcoded | ❌ Hack | +| Ease of implementation | ⚠️ New interface | ✅ Simple | ✅ Simple | +| Reusability | ✅ Other modules benefit too | ❌ One-off | ❌ One-off | +| PBS compatibility | ⚠️ Requires changes in `modules/` | ✅ Existing pattern | ⚠️ Hook misuse | +| Minimum core changes | ❌ 3 core files | ✅ 2 core files | ✅ 0 core files | + +**For a quick MVP: Option B** — fewest changes, follows existing PBS patterns. + +**For long-term architecture: Option A** — creates a reusable mechanism for future modules. + +--- + +## Work Required Regardless of Approach + +No matter which option is chosen, `handler.go` needs implementation: + +### 1. Query parameter parsing (`buildBidRequest`) + +```go +func (h *Handler) buildBidRequest(r *http.Request) (*openrtb2.BidRequest, error) { + q := r.URL.Query() + + podID := q.Get("pod_id") // required + duration := q.Get("duration") // max duration in seconds + maxAds := q.Get("max_ads") // max ads in pod + publisherID := q.Get("pub_id") // publisher ID + siteURL := q.Get("url") // site URL + // ... additional parameters +} +``` + +### 2. Inject Selector/Enricher/Formatter + +The handler needs concrete implementations of the interfaces. Decisions to make: +- Create them in the module Builder? +- Create them in the endpoint within the router? + +### 3. Inject AuctionFunc + +The most critical dependency — the handler must be able to invoke a PBS auction. Requires access to `exchange.Exchange`. + +### 4. Tests + +- Unit test `handler_test.go` with mocked `AuctionFunc` +- Integration test with full pipeline (Postman collection partially exists) + +--- + +## Implementation Path Summary (Option B — MVP) + +``` +1. endpoints/ctv_vast.go ← new file: endpoint wrapper +2. router/router.go ← add r.GET("/ctv/vast", ...) +3. handler.go ← implement buildBidRequest() +4. handler_test.go ← tests +5. config (optional) ← feature flag in pbs.json +``` + +Total: ~5 files to change/create. + +--- + +## How Modules Are Triggered by a GET Endpoint — Hook Executor Mechanism + +Modules are **not triggered "by the endpoint" directly** — they are triggered by the **hook executor**, which the endpoint creates and invokes at the appropriate stages. + +### Call Chain (AMP Example) + +``` +GET /openrtb2/amp?tag_id=xyz + │ + ▼ +router.go: r.GET("/openrtb2/amp", ampEndpoint) + │ + ▼ +AmpAuction handler creates hook executor: + hookExecutor := hookexecution.NewHookExecutor(planBuilder, "/openrtb2/amp", metrics) + │ + ├─► hookExecutor.ExecuteEntrypointStage(r, nil) ← modules get raw *http.Request + │ + ├─► exchange.HoldAuction(ctx, AuctionRequest{HookExecutor: hookExecutor, ...}) + │ inside exchange: + │ ├─► ExecuteProcessedAuctionStage() + │ ├─► ExecuteBidderRequestStage() ← per bidder + │ ├─► ExecuteRawBidderResponseStage() ← ctv_vast_enrichment fires HERE! + │ └─► ExecuteAllProcessedBidResponsesStage() + │ + ├─► hookExecutor.ExecuteAuctionResponseStage(response) + └─► hookExecutor.ExecuteExitpointStage(ampResponse, w) +``` + +AMP runs **7 of 8 stages** — all except `RawAuctionRequest` (because GET has no JSON body). + +### Key Mechanism: Execution Plan + +The hook executor knows which modules to run because it **looks up the endpoint in the `host_execution_plan`** — a literal map lookup (`hooks/plan.go`): + +```go +cfg.Endpoints[endpoint].Stages[stage].Groups +``` + +If the endpoint **is not a key** in the `endpoints` map — **no hooks will fire**, even if the module implements the interface. Configuration structure (`config/hooks.go`): + +```go +type HookExecutionPlan struct { + Endpoints map[string]struct { // key = "/openrtb2/auction", "/openrtb2/amp", etc. + Stages map[string]struct { // key = "entrypoint", "raw_bidder_response", etc. + Groups []HookExecutionGroup + } + } +} +``` + +### What This Means for a New `/ctv/vast` Endpoint + +The new GET endpoint must do **exactly what AMP does**: + +**1. Define an endpoint constant** in `hooks/hookexecution/executor.go`: + +```go +const EndpointCtvVast = "/ctv/vast" +``` + +**2. Create a hook executor** in the handler: + +```go +hookExecutor := hookexecution.NewHookExecutor(planBuilder, EndpointCtvVast, metrics) +``` + +**3. Call hook stages** at the appropriate points: + +```go +// At the start +hookExecutor.ExecuteEntrypointStage(r, nil) + +// Pass executor to exchange +exchange.HoldAuction(ctx, AuctionRequest{HookExecutor: hookExecutor, ...}) +// ↑ inside exchange, these fire automatically: +// ProcessedAuctionRequest, BidderRequest, RawBidderResponse, AllProcessedBidResponses + +// After auction +hookExecutor.ExecuteAuctionResponseStage(response) +hookExecutor.ExecuteExitpointStage(vastXML, w) +``` + +**4. Add the endpoint to the execution plan** in `pbs.json`: + +```json +"host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { ... }, + "/ctv/vast": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } +} +``` + +### Mechanism Summary + +| Element | Role | +|---------|------| +| `hookexecution.NewHookExecutor(planBuilder, endpoint, metrics)` | Creates executor bound to the endpoint | +| `planBuilder.PlanForXxxStage(endpoint)` | Looks up hooks in execution plan for the given endpoint | +| `host_execution_plan.endpoints["/ctv/vast"]` | **Configuration decides** which modules fire | +| `exchange.HoldAuction(req{HookExecutor})` | Internally fires BidderRequest, RawBidderResponse, etc. | + +The `ctv_vast_enrichment` module will work **automatically** on the new GET endpoint — as long as: +- the handler creates a `hookExecutor` and passes it to `exchange.HoldAuction` +- the endpoint is added to the `host_execution_plan` in configuration + +**No new interface is needed to trigger hooks** — the entire mechanism already exists. All that's needed is to wire the handler into the router and give it access to `exchange` + `planBuilder`. + +### Updated Implementation Path + +``` +1. hooks/hookexecution/executor.go ← new EndpointCtvVast constant +2. endpoints/ctv_vast.go ← new handler with hookExecutor +3. router/router.go ← r.GET("/ctv/vast", ...) +4. handler.go ← implement buildBidRequest() +5. pbs.json ← add /ctv/vast to execution plan +6. handler_test.go ← tests +``` diff --git a/modules/prebid/ctv_vast_enrichment/enrich/enrich.go b/modules/prebid/ctv_vast_enrichment/enrich/enrich.go index 5a8bb10d9de..a453b26f321 100644 --- a/modules/prebid/ctv_vast_enrichment/enrich/enrich.go +++ b/modules/prebid/ctv_vast_enrichment/enrich/enrich.go @@ -50,7 +50,7 @@ func (e *VastEnricher) Enrich(ad *model.Ad, meta vast.CanonicalMeta, cfg vast.Re warnings = append(warnings, advertiserWarnings...) // Enrich Duration - durationWarnings := e.enrichDuration(inline, meta) + durationWarnings := e.enrichDuration(inline, meta, cfg.CollisionPolicy) warnings = append(warnings, durationWarnings...) // Enrich Categories (always as extension) @@ -65,8 +65,7 @@ func (e *VastEnricher) Enrich(ad *model.Ad, meta vast.CanonicalMeta, cfg vast.Re return warnings, nil } -// enrichPricing adds pricing information if not present. -// VAST_WINS: only adds if InLine.Pricing is nil or empty. +// enrichPricing adds pricing information according to the collision policy. func (e *VastEnricher) enrichPricing(inline *model.InLine, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) []string { var warnings []string @@ -75,10 +74,15 @@ func (e *VastEnricher) enrichPricing(inline *model.InLine, meta vast.CanonicalMe return warnings } - // Check collision policy - VAST_WINS means don't overwrite existing + // Check collision: there is an existing Pricing value in the VAST if inline.Pricing != nil && inline.Pricing.Value != "" { - warnings = append(warnings, "pricing: VAST_WINS - keeping existing pricing") - return warnings + overwrite, w := resolveCollision(cfg.CollisionPolicy, "pricing") + if w != "" { + warnings = append(warnings, w) + } + if !overwrite { + return warnings + } } // Format the price value @@ -122,8 +126,7 @@ func (e *VastEnricher) enrichPricing(inline *model.InLine, meta vast.CanonicalMe return warnings } -// enrichAdvertiser adds advertiser information if not present. -// VAST_WINS: only adds if InLine.Advertiser is empty. +// enrichAdvertiser adds advertiser information according to the collision policy. func (e *VastEnricher) enrichAdvertiser(inline *model.InLine, meta vast.CanonicalMeta, cfg vast.ReceiverConfig) []string { var warnings []string @@ -132,10 +135,15 @@ func (e *VastEnricher) enrichAdvertiser(inline *model.InLine, meta vast.Canonica return warnings } - // Check collision policy - VAST_WINS means don't overwrite existing + // Check collision: there is an existing Advertiser value in the VAST if strings.TrimSpace(inline.Advertiser) != "" { - warnings = append(warnings, "advertiser: VAST_WINS - keeping existing advertiser") - return warnings + overwrite, w := resolveCollision(cfg.CollisionPolicy, "advertiser") + if w != "" { + warnings = append(warnings, w) + } + if !overwrite { + return warnings + } } // Determine placement location @@ -161,9 +169,8 @@ func (e *VastEnricher) enrichAdvertiser(inline *model.InLine, meta vast.Canonica return warnings } -// enrichDuration adds duration to Linear creative if not present. -// VAST_WINS: only adds if Linear.Duration is empty. -func (e *VastEnricher) enrichDuration(inline *model.InLine, meta vast.CanonicalMeta) []string { +// enrichDuration adds duration to Linear creative according to the collision policy. +func (e *VastEnricher) enrichDuration(inline *model.InLine, meta vast.CanonicalMeta, policy vast.CollisionPolicy) []string { var warnings []string // Skip if no duration to add @@ -182,10 +189,15 @@ func (e *VastEnricher) enrichDuration(inline *model.InLine, meta vast.CanonicalM continue } - // Check collision policy - VAST_WINS means don't overwrite existing + // Check collision: there is an existing Duration value in the VAST if strings.TrimSpace(creative.Linear.Duration) != "" { - warnings = append(warnings, "duration: VAST_WINS - keeping existing duration") - continue + overwrite, w := resolveCollision(policy, "duration") + if w != "" { + warnings = append(warnings, w) + } + if !overwrite { + continue + } } // Set duration in HH:MM:SS format @@ -250,6 +262,27 @@ func formatPrice(price float64) string { return s } +// resolveCollision determines how to handle a field that already exists in the VAST. +// Returns overwrite=true when the enricher should replace the existing value, +// and a non-empty warning string when the caller should record the event. +// +// - CollisionIgnore → overwrite silently +// - CollisionWarn → overwrite, emit warning +// - CollisionReject → keep existing, emit warning +// - CollisionVastWins (default) → keep existing, emit warning +func resolveCollision(policy vast.CollisionPolicy, field string) (overwrite bool, warning string) { + switch policy { + case vast.CollisionIgnore: + return true, "" + case vast.CollisionWarn: + return true, field + ": collision warn - overwriting existing VAST value" + case vast.CollisionReject: + return false, field + ": collision reject - keeping existing VAST value" + default: // CollisionVastWins and any unrecognised value + return false, field + ": VAST_WINS - keeping existing VAST value" + } +} + // escapeXML escapes special characters for XML content. func escapeXML(s string) string { s = strings.ReplaceAll(s, "&", "&") diff --git a/modules/prebid/ctv_vast_enrichment/format/format.go b/modules/prebid/ctv_vast_enrichment/format/format.go index e4fb68f6931..11601e075d1 100644 --- a/modules/prebid/ctv_vast_enrichment/format/format.go +++ b/modules/prebid/ctv_vast_enrichment/format/format.go @@ -2,8 +2,6 @@ package format import ( - "encoding/xml" - vast "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/model" ) @@ -29,7 +27,7 @@ func (f *VastFormatter) Format(ads []vast.EnrichedAd, cfg vast.ReceiverConfig) ( // Determine VAST version version := cfg.VastVersionDefault if version == "" { - version = "4.0" + version = vast.DefaultVastVersion } // Handle no-ad case @@ -52,8 +50,8 @@ func (f *VastFormatter) Format(ads []vast.EnrichedAd, cfg vast.ReceiverConfig) ( continue } - // Create a copy of the ad to avoid modifying the original - ad := copyAd(enriched.Ad) + // Deep-copy the ad so enrichment does not mutate the original parsed VAST. + ad := enriched.Ad.DeepCopy() // Set Ad.ID from meta (prefer AdID if tracked, else BidID) ad.ID = deriveAdID(enriched.Meta) @@ -75,15 +73,15 @@ func (f *VastFormatter) Format(ads []vast.EnrichedAd, cfg vast.ReceiverConfig) ( return noAdXML, warnings, nil } - // Marshal with indentation - xmlBytes, err := xml.MarshalIndent(vastDoc, "", " ") + // Marshal using Vast.Marshal() which clears InnerXML on Ad/InLine/Wrapper nodes + // before marshaling. This prevents duplicate content when structured fields + // (e.g. Pricing, Advertiser) were added by the enricher while InnerXML still + // holds the original raw XML from parsing. Consistent with the hook path. + output, err := vastDoc.Marshal() if err != nil { return nil, warnings, err } - // Add XML declaration - output := append([]byte(xml.Header), xmlBytes...) - return output, warnings, nil } @@ -101,14 +99,5 @@ func deriveAdID(meta vast.CanonicalMeta) string { return "" } -// copyAd creates a shallow copy of an Ad to avoid modifying the original. -func copyAd(src *model.Ad) *model.Ad { - if src == nil { - return nil - } - ad := *src - return &ad -} - // Ensure VastFormatter implements Formatter interface. var _ vast.Formatter = (*VastFormatter)(nil) diff --git a/modules/prebid/ctv_vast_enrichment/format/format_test.go b/modules/prebid/ctv_vast_enrichment/format/format_test.go index 168b8f5f68c..680aa11fc2b 100644 --- a/modules/prebid/ctv_vast_enrichment/format/format_test.go +++ b/modules/prebid/ctv_vast_enrichment/format/format_test.go @@ -173,7 +173,7 @@ func TestFormat_DefaultVersion(t *testing.T) { require.NoError(t, err) xmlStr := string(xmlBytes) - assert.Contains(t, xmlStr, `version="4.0"`) // defaults to 4.0 + assert.Contains(t, xmlStr, `version="3.0"`) // defaults to DefaultVastVersion (3.0) } func TestFormat_Version30(t *testing.T) { diff --git a/modules/prebid/ctv_vast_enrichment/handler.go b/modules/prebid/ctv_vast_enrichment/handler.go deleted file mode 100644 index 464a425080d..00000000000 --- a/modules/prebid/ctv_vast_enrichment/handler.go +++ /dev/null @@ -1,161 +0,0 @@ -//go:build ignore -// +build ignore - -// Package ctv_vast_enrichment handler is a work-in-progress standalone HTTP endpoint. -// Excluded from the build until AuctionFunc integration with exchange.Exchange is complete. -// See BUG 9 in ctv-bugs-and-resolve.md. -// -// To restore: remove the //go:build ignore directive and implement: -// - Full query parameter parsing (pod_id, duration, max_ads) -// - exchange.Exchange injection via AuctionFunc -// - Router registration - -package ctv_vast_enrichment - -import ( - "context" - "net/http" - - "github.com/golang/glog" - "github.com/prebid/openrtb/v20/openrtb2" -) - -// Handler provides HTTP handling for CTV VAST requests. -type Handler struct { - // Config contains the default receiver configuration. - Config ReceiverConfig - // Selector selects bids from auction response. - Selector BidSelector - // Enricher enriches VAST ads with metadata. - Enricher Enricher - // Formatter formats enriched ads as VAST XML. - Formatter Formatter - // AuctionFunc is called to run the auction pipeline. - // This should be injected with the actual auction implementation. - AuctionFunc func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) -} - -// NewHandler creates a new VAST HTTP handler with default configuration. -// Note: Selector, Enricher, and Formatter must be set via With* methods -// before the handler can process requests. -func NewHandler() *Handler { - return &Handler{ - Config: DefaultConfig(), - } -} - -// ServeHTTP handles GET requests for CTV VAST ads. -// Query parameters (TODO: implement full parsing): -// - pod_id: Pod identifier -// - duration: Requested pod duration -// - max_ads: Maximum ads in pod -// -// Response: -// - 200 OK with Content-Type: application/xml on success -// - 204 No Content if no ads available -// - 400 Bad Request for invalid parameters -// - 500 Internal Server Error for processing failures -func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Only accept GET requests - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Validate required dependencies - if h.Selector == nil || h.Enricher == nil || h.Formatter == nil { - http.Error(w, "Handler not properly configured", http.StatusInternalServerError) - return - } - - // TODO: Parse query parameters and build OpenRTB request - bidRequest := h.buildBidRequest(r) - - // TODO: Call auction pipeline - var bidResponse *openrtb2.BidResponse - var err error - - if h.AuctionFunc != nil { - bidResponse, err = h.AuctionFunc(ctx, bidRequest) - if err != nil { - http.Error(w, "Auction failed: "+err.Error(), http.StatusInternalServerError) - return - } - } else { - bidResponse = &openrtb2.BidResponse{} - } - - // Build VAST from bid response - result, err := BuildVastFromBidResponse(ctx, bidRequest, bidResponse, h.Config, h.Selector, h.Enricher, h.Formatter) - if err != nil { - glog.Errorf("ctv_vast_enrichment: BuildVastFromBidResponse error: %v", err) - } - - w.Header().Set("Content-Type", "application/xml; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - - if result.NoAd { - w.WriteHeader(http.StatusOK) - } - - w.Write(result.VastXML) -} - -// buildBidRequest creates an OpenRTB BidRequest from the HTTP request. -// TODO: Implement full parsing of query parameters. -func (h *Handler) buildBidRequest(r *http.Request) *openrtb2.BidRequest { - query := r.URL.Query() - podID := query.Get("pod_id") - if podID == "" { - podID = "ctv-pod-1" - } - - return &openrtb2.BidRequest{ - ID: podID, - Imp: []openrtb2.Imp{ - { - ID: "imp-1", - Video: &openrtb2.Video{ - MIMEs: []string{"video/mp4"}, - MinDuration: 5, - MaxDuration: 30, - }, - }, - }, - Site: &openrtb2.Site{ - Page: r.Header.Get("Referer"), - }, - } -} - -// WithConfig sets the receiver configuration. -func (h *Handler) WithConfig(cfg ReceiverConfig) *Handler { - h.Config = cfg - return h -} - -// WithSelector sets the bid selector. -func (h *Handler) WithSelector(s BidSelector) *Handler { - h.Selector = s - return h -} - -// WithEnricher sets the VAST enricher. -func (h *Handler) WithEnricher(e Enricher) *Handler { - h.Enricher = e - return h -} - -// WithFormatter sets the VAST formatter. -func (h *Handler) WithFormatter(f Formatter) *Handler { - h.Formatter = f - return h -} - -// WithAuctionFunc sets the auction function. -func (h *Handler) WithAuctionFunc(fn func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error)) *Handler { - h.AuctionFunc = fn - return h -} diff --git a/modules/prebid/ctv_vast_enrichment/model/model.go b/modules/prebid/ctv_vast_enrichment/model/model.go deleted file mode 100644 index 46e00979b71..00000000000 --- a/modules/prebid/ctv_vast_enrichment/model/model.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package model defines VAST XML data structures for CTV ad processing. -package model - -// VastAd represents a parsed VAST ad with its components. -// This is a higher-level domain object; for XML marshaling use the Vast struct. -type VastAd struct { - // ID is the unique identifier for this ad. - ID string - // AdSystem identifies the ad server that returned the ad. - AdSystem string - // AdTitle is the common name of the ad. - AdTitle string - // Description is a longer description of the ad. - Description string - // Advertiser is the name of the advertiser. - Advertiser string - // DurationSec is the duration of the creative in seconds. - DurationSec int - // ErrorURLs contains error tracking URLs. - ErrorURLs []string - // ImpressionURLs contains impression tracking URLs. - ImpressionURLs []string - // Sequence indicates the position in an ad pod. - Sequence int - // RawVAST contains the original VAST XML if preserved. - RawVAST []byte -} diff --git a/modules/prebid/ctv_vast_enrichment/model/parser.go b/modules/prebid/ctv_vast_enrichment/model/parser.go index 9e80b143502..2a671432cbe 100644 --- a/modules/prebid/ctv_vast_enrichment/model/parser.go +++ b/modules/prebid/ctv_vast_enrichment/model/parser.go @@ -3,6 +3,7 @@ package model import ( "encoding/xml" "errors" + "strconv" "strings" ) @@ -133,33 +134,20 @@ func ParseDurationToSeconds(duration string) int { } var hours, minutes, seconds int - if _, err := parseIntFromString(parts[0], &hours); err != nil { + var err error + if hours, err = strconv.Atoi(strings.TrimSpace(parts[0])); err != nil { return 0 } - if _, err := parseIntFromString(parts[1], &minutes); err != nil { + if minutes, err = strconv.Atoi(strings.TrimSpace(parts[1])); err != nil { return 0 } - if _, err := parseIntFromString(parts[2], &seconds); err != nil { + if seconds, err = strconv.Atoi(strings.TrimSpace(parts[2])); err != nil { return 0 } return hours*3600 + minutes*60 + seconds } -// parseIntFromString is a helper to parse an integer from a string. -func parseIntFromString(s string, result *int) (bool, error) { - s = strings.TrimSpace(s) - var n int - for _, c := range s { - if c < '0' || c > '9' { - return false, errors.New("invalid character in number") - } - n = n*10 + int(c-'0') - } - *result = n - return true, nil -} - // IsInLineAd returns true if the ad is an InLine ad (not a Wrapper). func IsInLineAd(ad *Ad) bool { return ad != nil && ad.InLine != nil diff --git a/modules/prebid/ctv_vast_enrichment/model/vast_xml.go b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go index 83704987def..b51b812cc8c 100644 --- a/modules/prebid/ctv_vast_enrichment/model/vast_xml.go +++ b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go @@ -254,6 +254,159 @@ func BuildSkeletonInlineVastWithDuration(version string, durationSec int) *Vast return vast } +// DeepCopy returns a fully independent copy of the Ad and all its nested +// pointer fields. Modifying any field in the returned Ad (including InLine, +// Creatives, Pricing, Extensions, etc.) will not affect the original. +func (a *Ad) DeepCopy() *Ad { + if a == nil { + return nil + } + copy := *a + copy.InLine = deepCopyInLine(a.InLine) + copy.Wrapper = deepCopyWrapper(a.Wrapper) + return © +} + +// deepCopyInLine returns a deep copy of an InLine element. +func deepCopyInLine(src *InLine) *InLine { + if src == nil { + return nil + } + c := *src + c.AdSystem = deepCopyAdSystem(src.AdSystem) + if src.Impressions != nil { + c.Impressions = make([]Impression, len(src.Impressions)) + copy(c.Impressions, src.Impressions) + } + c.Pricing = deepCopyPricing(src.Pricing) + c.Creatives = deepCopyCreatives(src.Creatives) + c.Extensions = deepCopyExtensions(src.Extensions) + return &c +} + +// deepCopyWrapper returns a deep copy of a Wrapper element. +func deepCopyWrapper(src *Wrapper) *Wrapper { + if src == nil { + return nil + } + c := *src + c.AdSystem = deepCopyAdSystem(src.AdSystem) + if src.Impressions != nil { + c.Impressions = make([]Impression, len(src.Impressions)) + copy(c.Impressions, src.Impressions) + } + c.Creatives = deepCopyCreatives(src.Creatives) + c.Extensions = deepCopyExtensions(src.Extensions) + return &c +} + +func deepCopyAdSystem(src *AdSystem) *AdSystem { + if src == nil { + return nil + } + c := *src + return &c +} + +func deepCopyPricing(src *Pricing) *Pricing { + if src == nil { + return nil + } + c := *src + return &c +} + +func deepCopyCreatives(src *Creatives) *Creatives { + if src == nil { + return nil + } + c := Creatives{} + if src.Creative != nil { + c.Creative = make([]Creative, len(src.Creative)) + for i, cr := range src.Creative { + cc := cr + if cr.UniversalAdID != nil { + uaid := *cr.UniversalAdID + cc.UniversalAdID = &uaid + } + cc.Linear = deepCopyLinear(cr.Linear) + c.Creative[i] = cc + } + } + return &c +} + +func deepCopyLinear(src *Linear) *Linear { + if src == nil { + return nil + } + c := *src + c.MediaFiles = deepCopyMediaFiles(src.MediaFiles) + c.VideoClicks = deepCopyVideoClicks(src.VideoClicks) + c.TrackingEvents = deepCopyTrackingEvents(src.TrackingEvents) + if src.AdParameters != nil { + ap := *src.AdParameters + c.AdParameters = &ap + } + return &c +} + +func deepCopyMediaFiles(src *MediaFiles) *MediaFiles { + if src == nil { + return nil + } + c := MediaFiles{} + if src.MediaFile != nil { + c.MediaFile = make([]MediaFile, len(src.MediaFile)) + copy(c.MediaFile, src.MediaFile) + } + return &c +} + +func deepCopyVideoClicks(src *VideoClicks) *VideoClicks { + if src == nil { + return nil + } + c := VideoClicks{} + if src.ClickThrough != nil { + ct := *src.ClickThrough + c.ClickThrough = &ct + } + if src.ClickTracking != nil { + c.ClickTracking = make([]ClickTracking, len(src.ClickTracking)) + copy(c.ClickTracking, src.ClickTracking) + } + if src.CustomClick != nil { + c.CustomClick = make([]CustomClick, len(src.CustomClick)) + copy(c.CustomClick, src.CustomClick) + } + return &c +} + +func deepCopyTrackingEvents(src *TrackingEvents) *TrackingEvents { + if src == nil { + return nil + } + c := TrackingEvents{} + if src.Tracking != nil { + c.Tracking = make([]Tracking, len(src.Tracking)) + copy(c.Tracking, src.Tracking) + } + return &c +} + +func deepCopyExtensions(src *Extensions) *Extensions { + if src == nil { + return nil + } + c := Extensions{} + if src.Extension != nil { + c.Extension = make([]ExtensionXML, len(src.Extension)) + copy(c.Extension, src.Extension) + } + return &c +} + // Marshal serializes the Vast struct to XML bytes with XML header. func (v *Vast) Marshal() ([]byte, error) { // Clear InnerXML fields to prevent duplicate content diff --git a/modules/prebid/ctv_vast_enrichment/needChanges.md b/modules/prebid/ctv_vast_enrichment/needChanges.md new file mode 100644 index 00000000000..24a3addc1db --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/needChanges.md @@ -0,0 +1,251 @@ +# Analiza zmian wymaganych do zgodności z Tech Spec (issue #3726) + +## Kontekst + +Issue [#3726 — Support general GET interface](https://github.com/prebid/prebid-server/issues/3726) definiuje uogólniony interfejs GET dla Prebid Server, wspierający zarówno Audio jak i CTV. Techniczny dokument odpowiedzi (Tech Spec) określa architekturę składającą się z: + +1. **GET Interface** — PBS Core obsługuje GET na `/openrtb2/auction` +2. **Profiles** — nowy feature core'owy (fragmenty ORTB per-account) +3. **Exitpoint hook stage** — nowy etap hooków do modyfikacji formatu odpowiedzi +4. **Moduły community** — HTTP Header, Ranking, VAST Response, Mapping, VAST Unwrapping + +Moduł `ctv_vast_enrichment` musi zostać dostosowany do tej architektury. + +--- + +## Stan obecny modułu + +### Co jest zaimplementowane ✅ + +| Komponent | Status | Plik | +|-----------|--------|------| +| RawBidderResponse Hook | ✅ Działa | `module.go` | +| VAST Enrichment (Pricing, Advertiser, Categories, Duration) | ✅ Działa | `enrich/enrich.go` | +| Bid Selection (SINGLE, TOP_N, MAX_REVENUE) | ✅ Działa | `select/selector.go` | +| VAST Formatting (GAM SSU) | ✅ Działa | `format/format.go` | +| 3-warstwowa konfiguracja (host → account → profile) | ✅ Merge działa | `config.go` | +| Pipeline orchestration | ✅ Działa | `pipeline.go` | +| VAST XML parser + skeleton | ✅ Działa | `model/parser.go` | +| HTTP Handler (GET, builder pattern) | ⚠️ Częściowy | `handler.go` | +| Rejestracja w `modules/builder.go` | ✅ Zarejestrowany | `modules/builder.go` | + +### Co NIE istnieje w PBS Core ❌ + +| Feature z Tech Spec | Status w PBS | Implikacja | +|---------------------|--------------|------------| +| GET na `/openrtb2/auction` | ❌ Tylko POST | Moduł nie może działać przez GET bez zmian w core | +| `ext.prebid.profiles` | ❌ Nie istnieje | Profiles nie są parsowane ani mergowane | +| `ext.prebid.of` (output format) | ❌ Nie istnieje | Brak mechanizmu sygnalizacji formatu odpowiedzi | +| `ext.prebid.rank` (ranking) | ❌ Nie istnieje | Brak standardowego rankingu bidów | +| `ext.prebid.outputmodule` | ❌ Nie istnieje | Brak mechanizmu wyboru modułu wyjściowego | +| `RequestMethod` w AuctionContext | ❌ Nie dostępne | Moduły nie wiedzą czy request przyszedł GET vs POST | +| Exitpoint hook stage | ✅ Istnieje | `hooks/hookstage/exitpoint.go` — payload `{Response any, W http.ResponseWriter}` | + +--- + +## Analiza rozbieżności: Moduł vs Tech Spec + +### 1. Endpoint: `/ctv/vast` vs GET na `/openrtb2/auction` + +**Obecny plan (endpoint.md):** Dedykowany endpoint `/ctv/vast` w `handler.go`. + +**Tech Spec mówi:** GET powinien działać na istniejącym `/openrtb2/auction`. Nie definiuje osobnego endpointu `/ctv/vast`. Format odpowiedzi zależy od `ext.prebid.of` (np. `vast3`, `vast4`). Moduł exitpoint sprawdza ten parametr i formatuje odpowiednio. + +**Co trzeba zmienić:** +- ❌ **Porzucić** koncepcję osobnego endpointu `/ctv/vast` +- ✅ Handler HTTP (`handler.go`) staje się zbędny w obecnej formie +- ✅ Moduł powinien działać jako **exitpoint hook** formatujący VAST, NIE jako osobny endpoint +- ⚠️ Alternatywa: zachować `/ctv/vast` jako prosty redirector/convenience endpoint (ale nie jest to częścią spec) + +### 2. Parsowanie parametrów query → PBS Core odpowiada za to + +**Obecny plan:** `handler.go` → `buildBidRequest()` parsuje query params. + +**Tech Spec mówi:** PBS Core parsuje ~60 parametrów GET (spreadsheet), buduje `BidRequest`, merguje stored requests i profiles. Moduł dostaje gotowy `BidRequest` jak przy POST. + +**Co trzeba zmienić:** +- ❌ `buildBidRequest()` w handler.go jest zbędny +- ✅ Moduł nie musi parsować query params — PBS Core to robi +- ✅ Moduł dostaje standardowy `BidRequest` z hooków + +### 3. Selekcja zwycięzcy → Ranking Module (osobny moduł) + +**Obecny moduł:** `select/selector.go` implementuje selekcję bidów wewnętrznie. + +**Tech Spec mówi:** Ranking to **osobny moduł** na etapie `all-processed-bid-responses`. Ustawia `seatbid.bid.ext.prebid.rank`. VAST response module czyta rank i formatuje pod. + +**Co trzeba zmienić:** +- ⚠️ Selector w module jest **redundantny** z Ranking Module — ale ten moduł jeszcze nie istnieje +- ✅ Docelowo: moduł VAST powinien czytać `ext.prebid.rank` zamiast samodzielnie selekcjonować +- ✅ Na razie: zachować selector jako fallback dopóki ranking module nie powstanie +- ⚠️ Trzeba dodać logikę: "jeśli bidy mają `ext.prebid.rank`, użyj go; w przeciwnym razie użyj selektora" + +### 4. Format odpowiedzi → Exitpoint Hook + +**Obecny moduł:** Enrichment na `RawBidderResponse` + pipeline w `handler.go`. + +**Tech Spec mówi:** VAST response module działa na **exitpoint stage**. Sprawdza: +1. Czy request przyszedł przez GET (`ext.prebid.server.requestmethod`) +2. Czy imp[] zawiera obsługiwane media types +3. Czy `ext.prebid.of` to format obsługiwany przez ten moduł + +Następnie serializuje `BidResponse` do VAST XML. + +**Co trzeba zmienić:** +- ✅ **Nowy hook:** Implementacja `HandleExitpointHook()` w `module.go` +- ✅ Hook exitpoint buduje VAST z `BidResponse` (pipeline.go już to umie) +- ✅ Hook sprawdza `ext.prebid.of` (vast3/vast4) i decyduje czy formatować +- ⚠️ `RawBidderResponse` hook **nadal jest potrzebny** do enrichmentu (dodawanie Pricing/Advertiser) +- ✅ Rozdzielenie odpowiedzialności: + - `RawBidderResponse` → enrichment VAST w bidach + - `Exitpoint` → formatowanie końcowej odpowiedzi jako VAST + +### 5. Konfiguracja — `ext.prebid.of` i `ext.prebid.outputmodule` + +**Obecny moduł:** Nie reaguje na te pola (nie istnieją w PBS). + +**Tech Spec mówi:** `ext.prebid.of` = "vast3"/"vast4" sygnalizuje format. `ext.prebid.outputmodule` pozwala określić który moduł formatuje. + +**Co trzeba zmienić:** +- ⚠️ **Blokowane przez PBS Core** — te pola muszą być najpierw dodane do `openrtb_ext.ExtRequestPrebid` +- ✅ Moduł powinien czytać je w exitpoint hook +- ✅ Jeśli `of` = "vast3"/"vast4" → moduł formatuje VAST +- ✅ Jeśli `of` jest puste lub "ortb2" → moduł nie interweniuje + +### 6. Profiles — trzecie źródło konfiguracji + +**Obecny moduł:** `MergeCTVVastConfig(host, account, profile)` — merge 3-warstwowy zaimplementowany, ale `profile` zawsze `nil`. + +**Tech Spec mówi:** Profiles to ORTB fragments, nie module config. Ale koncepcja warstwowej konfiguracji jest zgodna. + +**Co trzeba zmienić:** +- ⚠️ **Blokowane przez PBS Core** — profiles muszą być najpierw zaimplementowane w core +- ✅ Struktura `MergeCTVVastConfig()` jest gotowa na profiles +- ✅ Trzeba podłączyć pobieranie profile config z kontekstu hooka gdy feature będzie dostępny + +--- + +## Plan implementacji — priorytety + +### Faza 1: Dostosowanie modułu (bez zmian w core) + +| # | Zadanie | Plik(i) | Priorytet | +|---|---------|---------|-----------| +| 1.1 | Implementacja `HandleExitpointHook()` | `module.go` | **P0** | +| 1.2 | Logika exitpoint: sprawdź `ext.prebid.of`, zbuduj VAST z BidResponse | `module.go` | **P0** | +| 1.3 | Reużycie `pipeline.go` w exitpoint hook | `pipeline.go` | **P0** | +| 1.4 | Dodanie exitpoint do `host_execution_plan` w `pbs.json` | `pbs.json` | **P0** | +| 1.5 | Testy unit dla exitpoint hook | `module_test.go` | **P0** | +| 1.6 | Fallback rankingu — czytaj `ext.prebid.rank` jeśli dostępny | `select/selector.go` | **P1** | +| 1.7 | Obsługa VAST version z konfiguracji | `format/format.go` | **P1** | + +### Faza 2: Zmiany w PBS Core (wymagają review core team) + +| # | Zadanie | Plik(i) | Priorytet | +|---|---------|---------|-----------| +| 2.1 | Dodanie GET na `/openrtb2/auction` | `router/router.go`, nowy handler | **P0** | +| 2.2 | Parser query parameters → BidRequest | nowy pakiet w `endpoints/` | **P0** | +| 2.3 | Dodanie `ext.prebid.of` do `ExtRequestPrebid` | `openrtb_ext/request.go` | **P0** | +| 2.4 | Dodanie `ext.prebid.outputmodule` do `ExtRequestPrebid` | `openrtb_ext/request.go` | **P1** | +| 2.5 | Dodanie `ext.prebid.profiles` do `ExtRequestPrebid` | `openrtb_ext/request.go` | **P1** | +| 2.6 | Implementacja Profile storage i merge | `stored_requests/`, `config/` | **P1** | +| 2.7 | Dodanie `RequestMethod` do `AuctionContext` / `ext.prebid.server` | `exchange/exchange.go` | **P1** | +| 2.8 | Stała `EndpointAuctionGET` w hookexecution | `hooks/hookexecution/executor.go` | **P1** | + +### Faza 3: Moduły Community (osobne issue) + +| # | Moduł | Hook Stage | Status | +|---|-------|------------|--------| +| 3.1 | HTTP Header Module | `RawAuctionRequest` | ❌ Nie istnieje | +| 3.2 | Bid Response Ranking Module | `AllProcessedBidResponses` | ❌ Nie istnieje | +| 3.3 | VAST Unwrapping & Validation Module | TBD | ❌ Nie istnieje | +| 3.4 | Category Mapping Module | `ProcessedAuctionRequest` | ❌ Nie istnieje | + +--- + +## Architektura docelowa — przepływ + +``` +GET /openrtb2/auction?srid=my-stored-req&of=vast4&pubid=pub-1&mindur=15&maxdur=60 + │ + ▼ +┌────────────────────────────────────────────────┐ +│ PBS Core: GET Handler │ +│ 1. Parse query params → partial BidRequest │ +│ 2. Load stored request (srid) │ +│ 3. Load & merge profiles (rprof, iprof) │ +│ 4. Merge all layers │ +│ 5. Set ext.prebid.of = "vast4" │ +│ 6. Set ext.prebid.server.requestmethod = "GET" │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: Entrypoint Stage │ +│ → HTTP Header Module (X-Device-IP, etc.) │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ exchange.HoldAuction() │ +│ ├─ ProcessedAuction → Mapping Module │ +│ ├─ BidderRequest → per bidder │ +│ ├─ RawBidderResponse │ +│ │ └─ ctv_vast_enrichment: ENRICHMENT │ +│ │ (dodaje Pricing, Advertiser, Categories) │ +│ └─ AllProcessedBidResponses │ +│ └─ Ranking Module: ustawia ext.prebid.rank │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: AuctionResponse Stage │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: Exitpoint Stage │ +│ → ctv_vast_enrichment: VAST FORMATTING │ +│ 1. Sprawdź ext.prebid.of == "vast4"? │ +│ 2. Czytaj ext.prebid.rank z bidów │ +│ 3. Wybierz bidy (rank lub selector fallback) │ +│ 4. Buduj VAST XML (pipeline.go) │ +│ 5. Ustaw Content-Type: application/xml │ +│ 6. Zwróć VAST zamiast JSON │ +└─────────────────┬──────────────────────────────┘ + ▼ + VAST XML Response +``` + +--- + +## Podsumowanie: co moduł robi dobrze a co wymaga zmiany + +### ✅ Do zachowania (zgodne z Tech Spec) + +1. **RawBidderResponse Hook** — enrichment VAST (Pricing, Advertiser, Categories, Duration) jest dokładnie tym czego wymaga Req8/Req9 z CTV/Audio +2. **Pipeline orchestration** — `BuildVastFromBidResponse()` dobrze komponuje VAST +3. **Konfiguracja 3-warstwowa** — gotowa na profiles +4. **VAST parser + skeleton** — solidna baza +5. **Enrich policy VAST_WINS** — zgodna z wymaganiami (nie nadpisuj istniejących wartości) + +### ❌ Do zmiany + +1. **Osobny endpoint `/ctv/vast`** → porzucić, użyć `/openrtb2/auction` GET + exitpoint +2. **Handler HTTP** → zastąpić exitpoint hookiem +3. **Wewnętrzna selekcja bidów** → adaptować na `ext.prebid.rank` (z fallbackiem) +4. **Brak exitpoint hook** → zaimplementować `HandleExitpointHook()` + +### ⚠️ Blokery (czekają na PBS Core) + +1. GET na `/openrtb2/auction` — **nie istnieje** +2. `ext.prebid.of` — **nie istnieje** w `ExtRequestPrebid` +3. `ext.prebid.profiles` — **nie istnieje** w core +4. `ext.prebid.rank` — **nie istnieje** (wymaga Ranking Module) +5. `ext.prebid.server.requestmethod` — **nie istnieje** + +--- + +## Rekomendacja: co robić teraz + +1. **Zaimplementować exitpoint hook** — jedyna zmiana, która nie wymaga modyfikacji PBS Core i jest zgodna z docelową architekturą +2. **Zachować RawBidderResponse hook** — enrichment to oddzielna odpowiedzialność od formatowania +3. **Zachować handler.go jako opcjonalny** — convenience endpoint na czas przejściowy +4. **Skontaktować się z core team** (jak sugeruje issue) w sprawie timeline dla GET interface i profiles +5. **Przygotować testy** dla exitpoint hook z mockami `ext.prebid.of` diff --git a/modules/prebid/ctv_vast_enrichment/neededChangesENG.md b/modules/prebid/ctv_vast_enrichment/neededChangesENG.md new file mode 100644 index 00000000000..d4f7a185637 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/neededChangesENG.md @@ -0,0 +1,251 @@ +# Gap Analysis: CTV VAST Enrichment Module vs Tech Spec (issue #3726) + +## Context + +Issue [#3726 — Support general GET interface](https://github.com/prebid/prebid-server/issues/3726) defines a generalized GET interface for Prebid Server supporting both Audio and CTV use cases. The Technical Response document specifies an architecture consisting of: + +1. **GET Interface** — PBS Core handles GET on `/openrtb2/auction` +2. **Profiles** — new core feature (per-account ORTB fragments) +3. **Exitpoint hook stage** — new hook stage for response format modification +4. **Community Modules** — HTTP Header, Ranking, VAST Response, Mapping, VAST Unwrapping + +The `ctv_vast_enrichment` module must be aligned with this architecture. + +--- + +## Current Module State + +### What Is Implemented ✅ + +| Component | Status | File | +|-----------|--------|------| +| RawBidderResponse Hook | ✅ Working | `module.go` | +| VAST Enrichment (Pricing, Advertiser, Categories, Duration) | ✅ Working | `enrich/enrich.go` | +| Bid Selection (SINGLE, TOP_N, MAX_REVENUE) | ✅ Working | `select/selector.go` | +| VAST Formatting (GAM SSU) | ✅ Working | `format/format.go` | +| 3-layer config merge (host → account → profile) | ✅ Merge works | `config.go` | +| Pipeline orchestration | ✅ Working | `pipeline.go` | +| VAST XML parser + skeleton | ✅ Working | `model/parser.go` | +| HTTP Handler (GET, builder pattern) | ⚠️ Partial | `handler.go` | +| Registration in `modules/builder.go` | ✅ Registered | `modules/builder.go` | + +### What Does NOT Exist in PBS Core ❌ + +| Feature from Tech Spec | Status in PBS | Implication | +|------------------------|---------------|-------------| +| GET on `/openrtb2/auction` | ❌ POST only | Module cannot serve GET without core changes | +| `ext.prebid.profiles` | ❌ Does not exist | Profiles are not parsed or merged | +| `ext.prebid.of` (output format) | ❌ Does not exist | No mechanism to signal response format | +| `ext.prebid.rank` (ranking) | ❌ Does not exist | No standardized bid ranking | +| `ext.prebid.outputmodule` | ❌ Does not exist | No mechanism to select output module | +| `RequestMethod` in AuctionContext | ❌ Not available | Modules don't know GET vs POST | +| Exitpoint hook stage | ✅ Exists | `hooks/hookstage/exitpoint.go` — payload `{Response any, W http.ResponseWriter}` | + +--- + +## Gap Analysis: Module vs Tech Spec + +### 1. Endpoint: `/ctv/vast` vs GET on `/openrtb2/auction` + +**Current plan (endpoint.md):** Dedicated `/ctv/vast` endpoint in `handler.go`. + +**Tech Spec says:** GET should work on the existing `/openrtb2/auction`. No separate `/ctv/vast` endpoint is defined. Response format depends on `ext.prebid.of` (e.g., `vast3`, `vast4`). An exitpoint module checks this parameter and formats accordingly. + +**Required changes:** +- ❌ **Abandon** the separate `/ctv/vast` endpoint concept +- ✅ HTTP handler (`handler.go`) becomes unnecessary in its current form +- ✅ Module should act as an **exitpoint hook** formatting VAST, NOT as a separate endpoint +- ⚠️ Alternative: keep `/ctv/vast` as a simple convenience redirect (but this is not part of the spec) + +### 2. Query Parameter Parsing → PBS Core's Responsibility + +**Current plan:** `handler.go` → `buildBidRequest()` parses query params. + +**Tech Spec says:** PBS Core parses ~60 GET parameters (see spreadsheet), builds `BidRequest`, merges stored requests and profiles. The module receives a ready-made `BidRequest` just like with POST. + +**Required changes:** +- ❌ `buildBidRequest()` in handler.go is redundant +- ✅ Module doesn't need to parse query params — PBS Core does it +- ✅ Module receives standard `BidRequest` from hooks + +### 3. Winner Selection → Ranking Module (Separate Module) + +**Current module:** `select/selector.go` implements bid selection internally. + +**Tech Spec says:** Ranking is a **separate module** at the `all-processed-bid-responses` stage. It sets `seatbid.bid.ext.prebid.rank`. The VAST response module reads rank and formats the pod. + +**Required changes:** +- ⚠️ Internal selector is **redundant** with the Ranking Module — but that module doesn't exist yet +- ✅ Target: VAST module should read `ext.prebid.rank` instead of self-selecting +- ✅ For now: keep selector as a fallback until ranking module is built +- ⚠️ Need to add logic: "if bids have `ext.prebid.rank`, use it; otherwise use internal selector" + +### 4. Response Formatting → Exitpoint Hook + +**Current module:** Enrichment at `RawBidderResponse` + pipeline in `handler.go`. + +**Tech Spec says:** VAST response module operates at the **exitpoint stage**. It checks: +1. Whether the request came via GET (`ext.prebid.server.requestmethod`) +2. Whether imp[] contains only media types it handles +3. Whether `ext.prebid.of` is a format this module handles + +Then it serializes `BidResponse` to VAST XML. + +**Required changes:** +- ✅ **New hook:** Implement `HandleExitpointHook()` in `module.go` +- ✅ Exitpoint hook builds VAST from `BidResponse` (pipeline.go already does this) +- ✅ Hook checks `ext.prebid.of` (vast3/vast4) to decide whether to format +- ⚠️ `RawBidderResponse` hook is **still needed** for enrichment (adding Pricing/Advertiser) +- ✅ Separation of concerns: + - `RawBidderResponse` → VAST enrichment in individual bids + - `Exitpoint` → formatting the final response as VAST + +### 5. Configuration — `ext.prebid.of` and `ext.prebid.outputmodule` + +**Current module:** Does not react to these fields (they don't exist in PBS). + +**Tech Spec says:** `ext.prebid.of` = "vast3"/"vast4" signals format. `ext.prebid.outputmodule` allows specifying which module formats. + +**Required changes:** +- ⚠️ **Blocked by PBS Core** — these fields must first be added to `openrtb_ext.ExtRequestPrebid` +- ✅ Module should read them in the exitpoint hook +- ✅ If `of` = "vast3"/"vast4" → module formats VAST +- ✅ If `of` is empty or "ortb2" → module does not intervene + +### 6. Profiles — Third Configuration Source + +**Current module:** `MergeCTVVastConfig(host, account, profile)` — 3-layer merge implemented, but `profile` always `nil`. + +**Tech Spec says:** Profiles are ORTB fragments, not module config. But the layered configuration concept is compatible. + +**Required changes:** +- ⚠️ **Blocked by PBS Core** — profiles must be implemented in core first +- ✅ `MergeCTVVastConfig()` structure is already ready for profiles +- ✅ Need to connect profile config retrieval from hook context when the feature becomes available + +--- + +## Implementation Plan — Priorities + +### Phase 1: Module Adaptation (No Core Changes) + +| # | Task | File(s) | Priority | +|---|------|---------|----------| +| 1.1 | Implement `HandleExitpointHook()` | `module.go` | **P0** | +| 1.2 | Exitpoint logic: check `ext.prebid.of`, build VAST from BidResponse | `module.go` | **P0** | +| 1.3 | Reuse `pipeline.go` in exitpoint hook | `pipeline.go` | **P0** | +| 1.4 | Add exitpoint to `host_execution_plan` in `pbs.json` | `pbs.json` | **P0** | +| 1.5 | Unit tests for exitpoint hook | `module_test.go` | **P0** | +| 1.6 | Ranking fallback — read `ext.prebid.rank` if available | `select/selector.go` | **P1** | +| 1.7 | VAST version handling from config | `format/format.go` | **P1** | + +### Phase 2: PBS Core Changes (Require Core Team Review) + +| # | Task | File(s) | Priority | +|---|------|---------|----------| +| 2.1 | Add GET support to `/openrtb2/auction` | `router/router.go`, new handler | **P0** | +| 2.2 | Query parameter parser → BidRequest | new package in `endpoints/` | **P0** | +| 2.3 | Add `ext.prebid.of` to `ExtRequestPrebid` | `openrtb_ext/request.go` | **P0** | +| 2.4 | Add `ext.prebid.outputmodule` to `ExtRequestPrebid` | `openrtb_ext/request.go` | **P1** | +| 2.5 | Add `ext.prebid.profiles` to `ExtRequestPrebid` | `openrtb_ext/request.go` | **P1** | +| 2.6 | Implement Profile storage and merge | `stored_requests/`, `config/` | **P1** | +| 2.7 | Add `RequestMethod` to AuctionContext / `ext.prebid.server` | `exchange/exchange.go` | **P1** | +| 2.8 | Add `EndpointAuctionGET` constant in hookexecution | `hooks/hookexecution/executor.go` | **P1** | + +### Phase 3: Community Modules (Separate Issues) + +| # | Module | Hook Stage | Status | +|---|--------|------------|--------| +| 3.1 | HTTP Header Module | `RawAuctionRequest` | ❌ Does not exist | +| 3.2 | Bid Response Ranking Module | `AllProcessedBidResponses` | ❌ Does not exist | +| 3.3 | VAST Unwrapping & Validation Module | TBD | ❌ Does not exist | +| 3.4 | Category Mapping Module | `ProcessedAuctionRequest` | ❌ Does not exist | + +--- + +## Target Architecture — Request Flow + +``` +GET /openrtb2/auction?srid=my-stored-req&of=vast4&pubid=pub-1&mindur=15&maxdur=60 + │ + ▼ +┌────────────────────────────────────────────────┐ +│ PBS Core: GET Handler │ +│ 1. Parse query params → partial BidRequest │ +│ 2. Load stored request (srid) │ +│ 3. Load & merge profiles (rprof, iprof) │ +│ 4. Merge all layers │ +│ 5. Set ext.prebid.of = "vast4" │ +│ 6. Set ext.prebid.server.requestmethod = "GET" │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: Entrypoint Stage │ +│ → HTTP Header Module (X-Device-IP, etc.) │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ exchange.HoldAuction() │ +│ ├─ ProcessedAuction → Mapping Module │ +│ ├─ BidderRequest → per bidder │ +│ ├─ RawBidderResponse │ +│ │ └─ ctv_vast_enrichment: ENRICHMENT │ +│ │ (adds Pricing, Advertiser, Categories) │ +│ └─ AllProcessedBidResponses │ +│ └─ Ranking Module: sets ext.prebid.rank │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: AuctionResponse Stage │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: Exitpoint Stage │ +│ → ctv_vast_enrichment: VAST FORMATTING │ +│ 1. Check ext.prebid.of == "vast4"? │ +│ 2. Read ext.prebid.rank from bids │ +│ 3. Select bids (rank or selector fallback) │ +│ 4. Build VAST XML (pipeline.go) │ +│ 5. Set Content-Type: application/xml │ +│ 6. Return VAST instead of JSON │ +└─────────────────┬──────────────────────────────┘ + ▼ + VAST XML Response +``` + +--- + +## Summary: What the Module Does Well vs What Needs Change + +### ✅ Keep (Aligned with Tech Spec) + +1. **RawBidderResponse Hook** — VAST enrichment (Pricing, Advertiser, Categories, Duration) is exactly what CTV/Audio Req8/Req9 require +2. **Pipeline orchestration** — `BuildVastFromBidResponse()` composes VAST well +3. **3-layer config merge** — ready for profiles +4. **VAST parser + skeleton** — solid foundation +5. **VAST_WINS enrichment policy** — aligned with requirements (don't overwrite existing values) + +### ❌ Needs Change + +1. **Separate `/ctv/vast` endpoint** → abandon, use `/openrtb2/auction` GET + exitpoint +2. **HTTP handler** → replace with exitpoint hook +3. **Internal bid selection** → adapt to read `ext.prebid.rank` (with fallback) +4. **No exitpoint hook** → implement `HandleExitpointHook()` + +### ⚠️ Blockers (Waiting on PBS Core) + +1. GET on `/openrtb2/auction` — **does not exist** +2. `ext.prebid.of` — **does not exist** in `ExtRequestPrebid` +3. `ext.prebid.profiles` — **does not exist** in core +4. `ext.prebid.rank` — **does not exist** (requires Ranking Module) +5. `ext.prebid.server.requestmethod` — **does not exist** + +--- + +## Recommendation: What To Do Now + +1. **Implement exitpoint hook** — the only change that doesn't require PBS Core modifications and aligns with the target architecture +2. **Keep RawBidderResponse hook** — enrichment is a separate responsibility from formatting +3. **Keep handler.go as optional** — convenience endpoint during transition period +4. **Contact core team** (as suggested in the issue) regarding timeline for GET interface and profiles +5. **Prepare tests** for the exitpoint hook with mocked `ext.prebid.of` diff --git a/modules/prebid/ctv_vast_enrichment/pipeline.go b/modules/prebid/ctv_vast_enrichment/pipeline.go index fbc6959fad3..690c675a2fa 100644 --- a/modules/prebid/ctv_vast_enrichment/pipeline.go +++ b/modules/prebid/ctv_vast_enrichment/pipeline.go @@ -29,6 +29,7 @@ package ctv_vast_enrichment import ( "context" + "github.com/golang/glog" "github.com/prebid/openrtb/v20/openrtb2" "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/model" ) @@ -169,8 +170,13 @@ func NewProcessor(cfg ReceiverConfig, selector BidSelector, enricher Enricher, f } // Process executes the complete VAST processing workflow. +// If BuildVastFromBidResponse returns a fatal error it is logged and the +// result is returned as-is (NoAd=true with a no-ad VAST payload). func (p *Processor) Process(ctx context.Context, req *openrtb2.BidRequest, resp *openrtb2.BidResponse) VastResult { - result, _ := BuildVastFromBidResponse(ctx, req, resp, p.config, p.selector, p.enricher, p.formatter) + result, err := BuildVastFromBidResponse(ctx, req, resp, p.config, p.selector, p.enricher, p.formatter) + if err != nil { + glog.Errorf("ctv_vast_enrichment: pipeline error: %v", err) + } return result } diff --git a/modules/prebid/ctv_vast_enrichment/select/price_selector.go b/modules/prebid/ctv_vast_enrichment/select/price_selector.go index e34940be0b6..c4779eadd87 100644 --- a/modules/prebid/ctv_vast_enrichment/select/price_selector.go +++ b/modules/prebid/ctv_vast_enrichment/select/price_selector.go @@ -37,7 +37,7 @@ type bidWithSeat struct { // Selection process: // 1. Collect all bids from resp.SeatBid[].Bid[] // 2. Filter bids: price > 0 and AdM non-empty (unless AllowSkeletonVast is true) -// 3. Sort by: price desc, then deal exists desc, then bid.ID asc for stability +// 3. Sort by: deal exists desc, then price desc, then bid.ID asc for stability // 4. Return up to maxBids (or cfg.MaxAdsInPod if maxBids is 0) // 5. Populate CanonicalMeta for each SelectedBid func (s *PriceSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg vast.ReceiverConfig) ([]vast.SelectedBid, []string, error) { @@ -86,22 +86,22 @@ func (s *PriceSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidRespo return nil, warnings, nil } - // Sort bids: price desc, deal exists desc, bid.ID asc for stability + // Sort bids: deal exists desc, then price desc, then bid.ID asc for stability sort.Slice(filteredBids, func(i, j int) bool { bi, bj := filteredBids[i].bid, filteredBids[j].bid - // Primary: price descending - if bi.Price != bj.Price { - return bi.Price > bj.Price - } - - // Secondary: deal exists descending (deals first) + // Primary: deal exists descending (deal bids win over open auction) iHasDeal := bi.DealID != "" jHasDeal := bj.DealID != "" if iHasDeal != jHasDeal { return iHasDeal } + // Secondary: price descending + if bi.Price != bj.Price { + return bi.Price > bj.Price + } + // Tertiary: bid ID ascending for stability return bi.ID < bj.ID }) diff --git a/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go b/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go index 40a6d00fa67..3badb19498b 100644 --- a/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go +++ b/modules/prebid/ctv_vast_enrichment/select/price_selector_test.go @@ -467,17 +467,17 @@ func TestPriceSelector_Select_ComplexSort(t *testing.T) { assert.NoError(t, err) require.Len(t, selected, 6) - // Expected order: - // 1. a (price 3.0, deal) - highest price with deal - // 2. b (price 3.0, no deal) - highest price, no deal - // 3. c (price 2.0, deal) - same price, deal, ID "c" - // 4. d (price 2.0, deal) - same price, deal, ID "d" - // 5. e (price 2.0, no deal) - same price, no deal - // 6. f (price 1.0) - lowest price + // Expected order (deal > price, then price desc, then ID asc): + // 1. a (price 3.0, deal) - deal, highest price + // 2. c (price 2.0, deal) - deal, ID "c" < "d" + // 3. d (price 2.0, deal) - deal, ID "d" + // 4. b (price 3.0, no deal) - no deal, highest price + // 5. e (price 2.0, no deal) - no deal, same price, ID "e" > "b" + // 6. f (price 1.0, no deal) - no deal, lowest price assert.Equal(t, "a", selected[0].Meta.BidID) - assert.Equal(t, "b", selected[1].Meta.BidID) - assert.Equal(t, "c", selected[2].Meta.BidID) - assert.Equal(t, "d", selected[3].Meta.BidID) + assert.Equal(t, "c", selected[1].Meta.BidID) + assert.Equal(t, "d", selected[2].Meta.BidID) + assert.Equal(t, "b", selected[3].Meta.BidID) assert.Equal(t, "e", selected[4].Meta.BidID) assert.Equal(t, "f", selected[5].Meta.BidID) } diff --git a/modules/prebid/ctv_vast_enrichment/select/selector.go b/modules/prebid/ctv_vast_enrichment/select/selector.go index 591d7084143..e4a59fcef6b 100644 --- a/modules/prebid/ctv_vast_enrichment/select/selector.go +++ b/modules/prebid/ctv_vast_enrichment/select/selector.go @@ -15,7 +15,10 @@ type Selector interface { // Supported strategies: // - "SINGLE": Returns a single best bid (PriceSelector with limit 1) // - "TOP_N": Returns up to MaxAdsInPod bids (PriceSelector) -// - Default: Falls back to TOP_N behavior +// +// The following strategies are defined as constants but not yet implemented; +// they fall back to TOP_N behaviour: +// - "max_revenue", "min_duration", "balanced" func NewSelector(strategy vast.SelectionStrategy) Selector { switch strategy { case vast.SelectionSingle: @@ -23,7 +26,8 @@ func NewSelector(strategy vast.SelectionStrategy) Selector { case vast.SelectionTopN: return NewPriceSelector(0) // 0 means use cfg.MaxAdsInPod default: - // Default to TOP_N behavior for unknown strategies + // Strategies max_revenue / min_duration / balanced are reserved for future + // implementation. Until then, fall back to TOP_N (price-ranked, up to MaxAdsInPod). return NewPriceSelector(0) } } diff --git a/modules/prebid/ctv_vast_enrichment/testcases.md b/modules/prebid/ctv_vast_enrichment/testcases.md new file mode 100644 index 00000000000..73e26b678e5 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/testcases.md @@ -0,0 +1,371 @@ +# CTV VAST Enrichment — Scenariusze Testów Manualnych (Postman / curl) + +## Jak uruchomić + +```bash +cd /workspaces/prebid-server +go build . +./prebid-server -v 1 -logtostderr +``` + +Serwer startuje na **http://localhost:8000** (auction) i **:6060** (admin). + +### Import do Postmana + +Zaimportuj plik `sample/ctv_vast_enrichment_postman_collection.json` do Postmana. +Zmienna `{{base_url}}` = `http://localhost:8000`. +Kolekcja ma wbudowane asserty JS — kliknij "Run Collection" żeby odpalić wszystko naraz. + +--- + +## Stworzone pliki danych + +| Plik | Opis | +|------|------| +| `data/stored_responses/bid-vast-1.json` | VAST bez Pricing/Advertiser, price=1.50, adomain=www.advertiser.com (istniejący) | +| `data/stored_responses/bid-vast-2.json` | VAST **z** istniejącym Pricing=9.99 EUR i Advertiser=OriginalAdvertiser, price=2.75 (istniejący) | +| `data/stored_responses/bid-vast-no-pricing.json` | VAST bez Pricing/Advertiser, price=3.50, adomain=www.basic-advertiser.com | +| `data/stored_responses/bid-vast-empty-adm.json` | Bid z pustym `"adm": ""` | +| `data/stored_responses/bid-vast-banner.json` | Bid z banner HTML (``) zamiast VAST XML | +| `data/stored_responses/bid-vast-no-adomain.json` | VAST bez Pricing, price=5.00, `"adomain": []` (pusta tablica) | +| `data/stored_responses/bid-vast-multiple-cats.json` | VAST bez Pricing, price=7.25, cat=["IAB1","IAB3-1","IAB10"] | +| `data/stored_responses/bid-vast-zero-price.json` | VAST bez Pricing, price=0, adomain=www.free-advertiser.com | +| `stored_requests/data/by_id/accounts/ctv-test.json` | Konto testowe z `default_currency: "EUR"` (nadpisuje host config) | + +### Konfiguracja modułu w `pbs.json` + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD", + "vast_version_default": "3.0", + "max_ads_in_pod": 5, + "selection_strategy": "max_revenue" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [{ + "timeout": 1000, + "hook_sequence": [{ + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + }] + }] + } + } + } + } + } + } +} +``` + +--- + +## Scenariusze testowe + +### Test 1: Podstawowe wzbogacanie — dodaje `` + `` + +**Stored response:** `bid-vast-no-pricing.json` +**Co zawiera:** VAST XML bez `` i bez ``, price=3.50, adomain=www.basic-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-1", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-pricing"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ AdM zawiera `3.5` +- ✅ AdM zawiera `www.basic-advertiser.com` +- ✅ Reszta VAST (AdSystem, Duration, MediaFiles) bez zmian + +--- + +### Test 2: Polityka VAST_WINS — istniejące wartości NIE nadpisane + +**Stored response:** `bid-vast-2.json` +**Co zawiera:** VAST XML **z** istniejącym `9.99` i `OriginalAdvertiser`. Bid ma price=2.75 i adomain=www.different-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-2", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 1920, "h": 1080}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-2"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ Pricing = **9.99 EUR** (oryginał z VAST, nie 2.75 USD z bida) +- ✅ Advertiser = **OriginalAdvertiser** (oryginał, nie www.different-advertiser.com) +- ❌ Moduł NIE nadpisuje istniejących wartości + +--- + +### Test 3: Pusty AdM — moduł pomija bid + +**Stored response:** `bid-vast-empty-adm.json` +**Co zawiera:** Bid z `"adm": ""` (pusty string) + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-3", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-empty-adm"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ Bid przechodzi bez zmian +- ✅ Brak `` w odpowiedzi +- ✅ Brak błędu HTTP + +--- + +### Test 4: Non-VAST AdM (banner HTML) — graceful skip + +**Stored response:** `bid-vast-banner.json` +**Co zawiera:** Bid z `"adm": ""` — HTML zamiast VAST XML + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-4", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-banner"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ XML parsing fail → bid przechodzi bez zmian +- ✅ AdM nie zawiera `` ani `` +- ✅ Oryginalny HTML nie jest zmieniony + +--- + +### Test 5: Brak adomain — Pricing dodany, Advertiser NIE + +**Stored response:** `bid-vast-no-adomain.json` +**Co zawiera:** VAST XML, price=5.00, `"adomain": []` (pusta tablica) + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-5", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-adomain"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ `5` — dodany (price > 0) +- ❌ Brak `` — nie dodany (pusta adomain) + +--- + +### Test 6: Price=0 — brak Pricing, ale Advertiser dodany + +**Stored response:** `bid-vast-zero-price.json` +**Co zawiera:** VAST XML, price=0, adomain=www.free-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-6", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-zero-price"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ❌ Brak `` — nie dodany (price musi być > 0) +- ✅ `www.free-advertiser.com` — dodany (adomain istnieje) + +--- + +### Test 7: Kategorie IAB + +**Stored response:** `bid-vast-multiple-cats.json` +**Co zawiera:** VAST XML, price=7.25, adomain=www.categorized-advertiser.com, cat=["IAB1","IAB3-1","IAB10"] + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-7", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 1280, "h": 720}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-multiple-cats"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ `7.25` — dodany +- ✅ `www.categorized-advertiser.com` — dodany +- ✅ Kategorie IAB mogą pojawić się jako VAST Extensions + +--- + +### Test 8: Nadpisanie konfiguracji z poziomu konta — waluta EUR + +**Stored response:** `bid-vast-no-pricing.json` (ten sam co Test 1) +**Konto:** `stored_requests/data/by_id/accounts/ctv-test.json` — ustawia `default_currency: "EUR"` +**Klucz:** `"publisher": {"id": "ctv-test"}` w request body mapuje serwer na konto `ctv-test.json` + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-8", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-pricing"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "ctv-test"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ `3.5` — waluta **EUR** zamiast USD +- ✅ Konto nadpisuje konfigurację hosta + +--- + +### Test 9: Podstawowy VAST (bid-vast-1) + +**Stored response:** `bid-vast-1.json` +**Co zawiera:** VAST XML bez Pricing/Advertiser, price=1.50, adomain=www.advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-9", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-1"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ `1.5` — dodany +- ✅ `www.advertiser.com` — dodany +- ✅ Cena bida (1.50) nie zmieniona w odpowiedzi + +--- + +### Test 10: Health check — serwer działa + +```bash +curl -s http://localhost:8000/status +``` + +**Oczekiwane:** +- ✅ HTTP 200 +- ✅ Odpowiedź potwierdza, że serwer działa + +--- + +## Matryca pokrycia + +| # | Scenariusz | Stored response | Pricing | Advertiser | Uwagi | +|---|-----------|----------------|---------|------------|-------| +| 1 | Podstawowe wzbogacanie | bid-vast-no-pricing | ✅ dodany | ✅ dodany | Happy path | +| 2 | VAST_WINS | bid-vast-2 | ❌ nie nadpisany | ❌ nie nadpisany | Collision policy | +| 3 | Pusty AdM | bid-vast-empty-adm | ❌ pominięty | ❌ pominięty | Edge case | +| 4 | Non-VAST (HTML) | bid-vast-banner | ❌ pominięty | ❌ pominięty | Graceful degradation | +| 5 | Brak adomain | bid-vast-no-adomain | ✅ dodany | ❌ brak danych | Partial enrichment | +| 6 | Price=0 | bid-vast-zero-price | ❌ price=0 | ✅ dodany | Partial enrichment | +| 7 | Kategorie IAB | bid-vast-multiple-cats | ✅ dodany | ✅ dodany | Extensions | +| 8 | Account override (EUR) | bid-vast-no-pricing + ctv-test account | ✅ EUR | ✅ dodany | Config layering | +| 9 | Podstawowy VAST | bid-vast-1 | ✅ dodany | ✅ dodany | Sanity check | +| 10 | Health check | — | — | — | Serwer dostępny | diff --git a/modules/prebid/ctv_vast_enrichment/testcasesENG.md b/modules/prebid/ctv_vast_enrichment/testcasesENG.md new file mode 100644 index 00000000000..4bf83f7b08f --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/testcasesENG.md @@ -0,0 +1,371 @@ +# CTV VAST Enrichment — Manual Test Scenarios (Postman / curl) + +## How to Run + +```bash +cd /workspaces/prebid-server +go build . +./prebid-server -v 1 -logtostderr +``` + +Server starts on **http://localhost:8000** (auction) and **:6060** (admin). + +### Import into Postman + +Import the file `sample/ctv_vast_enrichment_postman_collection.json` into Postman. +Variable `{{base_url}}` = `http://localhost:8000`. +The collection has built-in JS test assertions — click "Run Collection" to execute all tests at once. + +--- + +## Created Data Files + +| File | Description | +|------|-------------| +| `data/stored_responses/bid-vast-1.json` | VAST without Pricing/Advertiser, price=1.50, adomain=www.advertiser.com (pre-existing) | +| `data/stored_responses/bid-vast-2.json` | VAST **with** existing Pricing=9.99 EUR and Advertiser=OriginalAdvertiser, price=2.75 (pre-existing) | +| `data/stored_responses/bid-vast-no-pricing.json` | VAST without Pricing/Advertiser, price=3.50, adomain=www.basic-advertiser.com | +| `data/stored_responses/bid-vast-empty-adm.json` | Bid with empty `"adm": ""` | +| `data/stored_responses/bid-vast-banner.json` | Bid with banner HTML (``) instead of VAST XML | +| `data/stored_responses/bid-vast-no-adomain.json` | VAST without Pricing, price=5.00, `"adomain": []` (empty array) | +| `data/stored_responses/bid-vast-multiple-cats.json` | VAST without Pricing, price=7.25, cat=["IAB1","IAB3-1","IAB10"] | +| `data/stored_responses/bid-vast-zero-price.json` | VAST without Pricing, price=0, adomain=www.free-advertiser.com | +| `stored_requests/data/by_id/accounts/ctv-test.json` | Test account with `default_currency: "EUR"` (overrides host config) | + +### Module Configuration in `pbs.json` + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD", + "vast_version_default": "3.0", + "max_ads_in_pod": 5, + "selection_strategy": "max_revenue" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [{ + "timeout": 1000, + "hook_sequence": [{ + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + }] + }] + } + } + } + } + } + } +} +``` + +--- + +## Test Scenarios + +### Test 1: Basic Enrichment — Adds `` + `` + +**Stored response:** `bid-vast-no-pricing.json` +**Contents:** VAST XML without `` or ``, price=3.50, adomain=www.basic-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-1", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-pricing"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ AdM contains `3.5` +- ✅ AdM contains `www.basic-advertiser.com` +- ✅ Rest of VAST (AdSystem, Duration, MediaFiles) unchanged + +--- + +### Test 2: VAST_WINS Policy — Existing Values NOT Overwritten + +**Stored response:** `bid-vast-2.json` +**Contents:** VAST XML **with** existing `9.99` and `OriginalAdvertiser`. Bid has price=2.75 and adomain=www.different-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-2", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 1920, "h": 1080}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-2"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ Pricing = **9.99 EUR** (original from VAST, not 2.75 USD from bid) +- ✅ Advertiser = **OriginalAdvertiser** (original, not www.different-advertiser.com) +- ❌ Module does NOT overwrite existing values + +--- + +### Test 3: Empty AdM — Module Skips Bid + +**Stored response:** `bid-vast-empty-adm.json` +**Contents:** Bid with `"adm": ""` (empty string) + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-3", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-empty-adm"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ Bid passes through unchanged +- ✅ No `` in response +- ✅ No HTTP error + +--- + +### Test 4: Non-VAST AdM (Banner HTML) — Graceful Skip + +**Stored response:** `bid-vast-banner.json` +**Contents:** Bid with `"adm": ""` — HTML instead of VAST XML + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-4", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-banner"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ XML parsing fails → bid passes through unchanged +- ✅ AdM does not contain `` or `` +- ✅ Original HTML is not modified + +--- + +### Test 5: Missing Adomain — Pricing Added, No Advertiser + +**Stored response:** `bid-vast-no-adomain.json` +**Contents:** VAST XML, price=5.00, `"adomain": []` (empty array) + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-5", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-adomain"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ `5` — added (price > 0) +- ❌ No `` — not added (empty adomain) + +--- + +### Test 6: Zero Price — No Pricing, But Advertiser Added + +**Stored response:** `bid-vast-zero-price.json` +**Contents:** VAST XML, price=0, adomain=www.free-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-6", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-zero-price"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ❌ No `` — not added (price must be > 0) +- ✅ `www.free-advertiser.com` — added (adomain exists) + +--- + +### Test 7: IAB Categories + +**Stored response:** `bid-vast-multiple-cats.json` +**Contents:** VAST XML, price=7.25, adomain=www.categorized-advertiser.com, cat=["IAB1","IAB3-1","IAB10"] + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-7", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 1280, "h": 720}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-multiple-cats"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ `7.25` — added +- ✅ `www.categorized-advertiser.com` — added +- ✅ IAB categories may appear as VAST Extensions + +--- + +### Test 8: Account-Level Config Override — EUR Currency + +**Stored response:** `bid-vast-no-pricing.json` (same as Test 1) +**Account:** `stored_requests/data/by_id/accounts/ctv-test.json` — sets `default_currency: "EUR"` +**Key:** `"publisher": {"id": "ctv-test"}` in request body maps the server to the `ctv-test.json` account + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-8", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-pricing"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "ctv-test"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ `3.5` — currency is **EUR** instead of USD +- ✅ Account config overrides host config + +--- + +### Test 9: Basic VAST (bid-vast-1) + +**Stored response:** `bid-vast-1.json` +**Contents:** VAST XML without Pricing/Advertiser, price=1.50, adomain=www.advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-9", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-1"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ `1.5` — added +- ✅ `www.advertiser.com` — added +- ✅ Bid price (1.50) unchanged in response + +--- + +### Test 10: Health Check — Server Running + +```bash +curl -s http://localhost:8000/status +``` + +**Expected:** +- ✅ HTTP 200 +- ✅ Response confirms server is running + +--- + +## Coverage Matrix + +| # | Scenario | Stored Response | Pricing | Advertiser | Notes | +|---|----------|----------------|---------|------------|-------| +| 1 | Basic enrichment | bid-vast-no-pricing | ✅ added | ✅ added | Happy path | +| 2 | VAST_WINS policy | bid-vast-2 | ❌ not overwritten | ❌ not overwritten | Collision policy | +| 3 | Empty AdM | bid-vast-empty-adm | ❌ skipped | ❌ skipped | Edge case | +| 4 | Non-VAST (HTML) | bid-vast-banner | ❌ skipped | ❌ skipped | Graceful degradation | +| 5 | Missing adomain | bid-vast-no-adomain | ✅ added | ❌ no data | Partial enrichment | +| 6 | Zero price | bid-vast-zero-price | ❌ price=0 | ✅ added | Partial enrichment | +| 7 | IAB categories | bid-vast-multiple-cats | ✅ added | ✅ added | Extensions | +| 8 | Account override (EUR) | bid-vast-no-pricing + ctv-test account | ✅ EUR | ✅ added | Config layering | +| 9 | Basic VAST | bid-vast-1 | ✅ added | ✅ added | Sanity check | +| 10 | Health check | — | — | — | Server available | diff --git a/modules/prebid/ctv_vast_enrichment/types.go b/modules/prebid/ctv_vast_enrichment/types.go index fd02253cd3a..d3af6e48dbc 100644 --- a/modules/prebid/ctv_vast_enrichment/types.go +++ b/modules/prebid/ctv_vast_enrichment/types.go @@ -25,11 +25,14 @@ const ( SelectionSingle SelectionStrategy = "SINGLE" // SelectionTopN selects up to MaxAdsInPod bids. SelectionTopN SelectionStrategy = "TOP_N" - // SelectionMaxRevenue selects bids to maximize total revenue. + // SelectionMaxRevenue is reserved for future implementation. + // Currently not supported; configs using this value will fall back to TOP_N. SelectionMaxRevenue SelectionStrategy = "max_revenue" - // SelectionMinDuration selects bids to minimize total duration. + // SelectionMinDuration is reserved for future implementation. + // Currently not supported; configs using this value will fall back to TOP_N. SelectionMinDuration SelectionStrategy = "min_duration" - // SelectionBalanced balances between revenue and duration. + // SelectionBalanced is reserved for future implementation. + // Currently not supported; configs using this value will fall back to TOP_N. SelectionBalanced SelectionStrategy = "balanced" ) From 5c2a1dd3d582c68d75a1b9ac194e363f60a15a2f Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Thu, 14 May 2026 17:44:52 +0200 Subject: [PATCH 15/15] fix comments --- .../prebid/ctv_vast_enrichment/enrich/enrich.go | 16 ++-------------- .../ctv_vast_enrichment/enrich/enrich_test.go | 2 +- .../prebid/ctv_vast_enrichment/model/vast_xml.go | 14 ++++++++++++++ modules/prebid/ctv_vast_enrichment/module.go | 3 +-- .../prebid/ctv_vast_enrichment/module_test.go | 6 +++--- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/modules/prebid/ctv_vast_enrichment/enrich/enrich.go b/modules/prebid/ctv_vast_enrichment/enrich/enrich.go index a453b26f321..adf5a409a14 100644 --- a/modules/prebid/ctv_vast_enrichment/enrich/enrich.go +++ b/modules/prebid/ctv_vast_enrichment/enrich/enrich.go @@ -86,7 +86,7 @@ func (e *VastEnricher) enrichPricing(inline *model.InLine, meta vast.CanonicalMe } // Format the price value - priceStr := formatPrice(meta.Price) + priceStr := model.FormatPrice(meta.Price) currency := meta.Currency if currency == "" { currency = cfg.DefaultCurrency @@ -240,7 +240,7 @@ func (e *VastEnricher) addDebugExtension(inline *model.InLine, meta vast.Canonic debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.DealID))) } debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.Seat))) - debugXML.WriteString(fmt.Sprintf("%s", formatPrice(meta.Price))) + debugXML.WriteString(fmt.Sprintf("%s", model.FormatPrice(meta.Price))) debugXML.WriteString(fmt.Sprintf("%s", escapeXML(meta.Currency))) ext := model.ExtensionXML{ @@ -250,18 +250,6 @@ func (e *VastEnricher) addDebugExtension(inline *model.InLine, meta vast.Canonic inline.Extensions.Extension = append(inline.Extensions.Extension, ext) } -// formatPrice formats a price value with appropriate precision. -func formatPrice(price float64) string { - // Use up to 4 decimal places, trimming trailing zeros - s := fmt.Sprintf("%.4f", price) - s = strings.TrimRight(s, "0") - s = strings.TrimRight(s, ".") - if s == "" { - return "0" - } - return s -} - // resolveCollision determines how to handle a field that already exists in the VAST. // Returns overwrite=true when the enricher should replace the existing value, // and a non-empty warning string when the caller should record the event. diff --git a/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go b/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go index 8ac9dd1c912..1c602f0e791 100644 --- a/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go +++ b/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go @@ -493,7 +493,7 @@ func TestFormatPrice(t *testing.T) { for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { - result := formatPrice(tt.price) + result := model.FormatPrice(tt.price) assert.Equal(t, tt.expected, result) }) } diff --git a/modules/prebid/ctv_vast_enrichment/model/vast_xml.go b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go index b51b812cc8c..173d3ec8f5d 100644 --- a/modules/prebid/ctv_vast_enrichment/model/vast_xml.go +++ b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go @@ -3,6 +3,7 @@ package model import ( "encoding/xml" "fmt" + "strings" ) // Vast represents the root VAST XML element. @@ -190,6 +191,19 @@ func SecToHHMMSS(seconds int) string { return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs) } +// FormatPrice formats a bid price with up to 4 decimal places, trimming +// trailing zeros. Used by all enrichers to ensure consistent price +// representation in VAST XML (e.g. 5.5 not 5.5000, 1.25 not 1.2500). +func FormatPrice(price float64) string { + s := fmt.Sprintf("%.4f", price) + s = strings.TrimRight(s, "0") + s = strings.TrimRight(s, ".") + if s == "" { + return "0" + } + return s +} + // BuildNoAdVast creates a VAST response indicating no ad is available. // This is a valid VAST document with no Ad elements. func BuildNoAdVast(version string) []byte { diff --git a/modules/prebid/ctv_vast_enrichment/module.go b/modules/prebid/ctv_vast_enrichment/module.go index 10278f7df31..599b0ddbf36 100644 --- a/modules/prebid/ctv_vast_enrichment/module.go +++ b/modules/prebid/ctv_vast_enrichment/module.go @@ -3,7 +3,6 @@ package ctv_vast_enrichment import ( "context" "encoding/json" - "fmt" "strings" "github.com/prebid/openrtb/v20/openrtb2" @@ -241,7 +240,7 @@ func (h *hookEnricher) Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConf currency = "USD" } inline.Pricing = &model.Pricing{ - Value: fmt.Sprintf("%.6f", meta.Price), + Value: model.FormatPrice(meta.Price), Model: "CPM", Currency: currency, } diff --git a/modules/prebid/ctv_vast_enrichment/module_test.go b/modules/prebid/ctv_vast_enrichment/module_test.go index 12030ec1b37..99ab2f1dac1 100644 --- a/modules/prebid/ctv_vast_enrichment/module_test.go +++ b/modules/prebid/ctv_vast_enrichment/module_test.go @@ -200,7 +200,7 @@ func TestHandleRawBidderResponseHook_EnrichesVAST(t *testing.T) { // Verify the bid was enriched enrichedAdM := payload.BidderResponse.Bids[0].Bid.AdM assert.Contains(t, enrichedAdM, "Pricing") - assert.Contains(t, enrichedAdM, "1.500000") + assert.Contains(t, enrichedAdM, "1.5") assert.Contains(t, enrichedAdM, "CPM") assert.Contains(t, enrichedAdM, "USD") } @@ -392,8 +392,8 @@ func TestHandleRawBidderResponseHook_MultipleBids(t *testing.T) { } // Both bids should be enriched - assert.Contains(t, payload.BidderResponse.Bids[0].Bid.AdM, "1.500000") - assert.Contains(t, payload.BidderResponse.Bids[1].Bid.AdM, "2.000000") + assert.Contains(t, payload.BidderResponse.Bids[0].Bid.AdM, "1.5") + assert.Contains(t, payload.BidderResponse.Bids[1].Bid.AdM, "2") } func TestHandleRawBidderResponseHook_PreservesExistingPricing(t *testing.T) {