From eb3436cae3f7f9e56ca663f002de61de2545700c Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Mon, 2 Feb 2026 06:13:39 +0000 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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, - } -}