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/README.md b/modules/prebid/ctv_vast_enrichment/README.md new file mode 100644 index 00000000000..ffb381b28ae --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/README.md @@ -0,0 +1,480 @@ +# Moduł CTV VAST Enrichment + +Moduł CTV VAST Enrichment to moduł hook Prebid Server, który wzbogaca odpowiedzi VAST (Video Ad Serving Template) o dodatkowe metadane dla reklam Connected TV (CTV). + +## Struktura Modułu + +``` +modules/prebid/ctv_vast_enrichment/ +├── module.go # Punkt wejścia modułu PBS (Builder + HandleRawBidderResponseHook) +├── module_test.go # Testy modułu +├── pipeline.go # Samodzielny pipeline przetwarzania VAST +├── pipeline_test.go # Testy pipeline +├── handler.go # Handler HTTP dla bezpośrednich żądań VAST +├── types.go # Definicje typów, interfejsy i stałe +├── config.go # Konfiguracja i mergowanie warstw (host/account/profile) +├── config_test.go # Testy konfiguracji +├── model/ # Struktury danych VAST XML +│ ├── model.go # Obiekty domenowe wysokiego poziomu +│ ├── vast_xml.go # Struktury XML do marshal/unmarshal +│ ├── parser.go # Parser VAST XML +│ └── *_test.go # Testy +├── select/ # Logika selekcji bidów +│ ├── selector.go # Implementacje BidSelector +│ └── *_test.go # Testy +├── enrich/ # Wzbogacanie VAST +│ ├── enrich.go # Implementacja Enricher (VAST_WINS) +│ └── *_test.go # Testy +└── format/ # Formatowanie VAST XML + ├── format.go # Implementacja Formatter (GAM_SSU) + └── *_test.go # Testy +``` + +## Integracja z PBS + +Moduł jest zgodny ze standardowym wzorcem modułów Prebid Server. + +### Rejestracja w `modules/builder.go` + +Moduł musi być zarejestrowany w pliku `modules/builder.go`, który jest centralnym rejestrem wszystkich modułów PBS: + +```go +import ( + prebidCtvVastEnrichment "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +) + +var newModuleBuilders = map[string]map[string]interface{}{ + "prebid": { + "ctv_vast_enrichment": prebidCtvVastEnrichment.Builder, + }, +} +``` + +> **Uwaga:** Nazwa pakietu Go to `ctv_vast_enrichment`, ale subpakiety (enrich, select, format) używają aliasu `vast` do importu pakietu nadrzędnego: +> ```go +> import vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +> ``` + +### `module.go` - Główny Punkt Wejścia + +```go +// Builder tworzy nową instancję modułu CTV VAST enrichment. +func Builder(cfg json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, error) + +// Module implementuje funkcjonalność wzbogacania CTV VAST jako moduł hook PBS. +type Module struct { + hostConfig CTVVastConfig +} + +// HandleRawBidderResponseHook przetwarza odpowiedzi bidderów, wzbogacając VAST XML. +func (m Module) HandleRawBidderResponseHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.RawBidderResponsePayload, +) (hookstage.HookResult[hookstage.RawBidderResponsePayload], error) +``` + +### Hook Stage + +Moduł działa na etapie hooka **RawBidderResponse**, przetwarzając odpowiedź każdego biddera przed agregacją. Dla każdego bida zawierającego VAST XML: + +1. Parsuje VAST XML z pola `AdM` bida +2. Wzbogaca VAST o pricing, advertiser i metadane kategorii +3. Tworzy nowy `*adapters.TypedBid` z nowym `*openrtb2.Bid` zawierającym wzbogacony AdM +4. Zwraca mutację przez `changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids)` + +> **Wzorzec ChangeSet:** Hook nie modyfikuje payload bezpośrednio. Zamiast tego buduje nowy slice `[]adapters.TypedBid` i rejestruje mutację przez `UpdateBids()`. PBS stosuje mutację po powrocie z hooka — zgodnie ze wzorcem z modułu `ortb2blocking`. + +### Konfiguracja PBS (`pbs.json`) + +Aby moduł został wywołany podczas aukcji, wymagana jest konfiguracja `host_execution_plan` w sekcji `hooks`: + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } + } + } +} +``` + +> **Ważne:** Sam `enabled_modules` nie wystarczy. PBS wymaga jawnego `host_execution_plan` z definicją stage `raw_bidder_response` i `module_code: "prebid.ctv_vast_enrichment"`, aby hook został faktycznie wywołany. + +Konfiguracja na poziomie konta nadpisuje ustawienia na poziomie hosta. + +## 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 +- `clearInnerXML()` - Czyści pola `InnerXML` przed serializacją (zapobiega duplikowaniu elementów) + +> **Fix XML:** Struktury VAST używają tagu `,innerxml` do zachowania surowego XML podczas parsowania. Przed `Marshal()` wywoływana jest `clearInnerXML()`, która zeruje pola `InnerXML` na strukturach `Ad`, `InLine`, `Wrapper`, `Creative` i `Linear`, zapobiegając duplikowaniu elementów w wynikowym XML. + +#### `parser.go` + +Parser VAST XML: + +- **`ParseVastAdm()`** - Parsuje string AdM do struktury Vast +- **`ParseVastOrSkeleton()`** - Parsuje lub tworzy szkielet jeśli dozwolone +- **`ExtractFirstAd()`** - Wyciąga pierwszą reklamę z VAST + +### `select/` - Selekcja Bidów + +Logika wyboru bidów z odpowiedzi aukcji: + +- **`PriceSelector`** - Implementacja oparta na cenie: + - Filtruje bidy z ceną ≤ 0 lub pustym AdM + - Sortuje: deal > non-deal, potem po cenie malejąco + - Respektuje `MaxAdsInPod` dla strategii TOP_N + - Przypisuje numery sekwencji (1-indexed) + +- **`NewSelector(strategy)`** - Fabryka tworząca selektor dla strategii + +### `enrich/` - Wzbogacanie VAST + +Dodawanie metadanych do reklam VAST: + +- **`VastEnricher`** - Implementacja z polityką VAST_WINS: + - Istniejące wartości w VAST nie są nadpisywane + - Dodaje brakujące: Pricing, Advertiser, Duration, Categories + +Wzbogacane elementy: +| Element | Źródło | Lokalizacja | +|---------|--------|-------------| +| Pricing | meta.Price | `` lub Extension | +| Advertiser | meta.Adomain | `` lub Extension | +| Duration | meta.DurSec | `` w Linear | +| Categories | meta.Cats | Extension (zawsze) | + +### `format/` - Formatowanie VAST + +Budowanie końcowego VAST XML: + +- **`VastFormatter`** - Implementacja GAM SSU: + - Buduje dokument VAST z listą elementów `` + - Ustawia `id` z BidID + - Ustawia `sequence` dla podów (wiele reklam) + +## Przepływ Przetwarzania + +``` +┌─────────────────────────────────────────────────────┐ +│ PBS Auction Pipeline │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ RawBidderResponse Hook Stage │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ HandleRawBidderResponseHook() │ │ +│ │ Dla każdego bida z VAST w AdM: │ │ +│ │ 1. Parsuje VAST XML │ │ +│ │ 2. Wzbogaca o pricing/advertiser │ │ +│ │ 3. Aktualizuje bid.AdM │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Wzbogacona BidderResponse │ +│ (VAST z , itp.) │ +└─────────────────────────────────────────────────────┘ +``` + +## Użycie + +### Jako Moduł PBS (Rekomendowane) + +Moduł jest automatycznie wywoływany podczas pipeline aukcji gdy włączony w konfiguracji. + +**1. Upewnij się, że moduł jest zarejestrowany w `modules/builder.go`** (patrz sekcja "Rejestracja" wyżej). + +**2. Dodaj konfigurację hooks do `pbs.json`:** + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "default_currency": "USD", + "receiver": "GAM_SSU" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } + } + } +} +``` + +**3. Nadpisanie na poziomie konta** (opcjonalne): +```json +{ + "hooks": { + "modules": { + "prebid.ctv_vast_enrichment": { + "enabled": true, + "default_currency": "EUR" + } + } + } +} +``` + +### Samodzielny Pipeline (dla handlera HTTP) + +```go +import ( + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/enrich" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/format" + bidselect "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/select" +) + +// Konfiguracja +cfg := vast.DefaultConfig() +cfg.MaxAdsInPod = 3 + +// Tworzenie komponentów +selector := bidselect.NewSelector(cfg.SelectionStrategy) +enricher := enrich.NewEnricher() +formatter := format.NewFormatter() + +// Bezpośrednie wywołanie +result, err := vast.BuildVastFromBidResponse( + ctx, + bidRequest, + bidResponse, + cfg, + selector, + enricher, + formatter, +) +``` + +### Handler HTTP + +```go +handler := vast.NewHandler(). + WithConfig(cfg). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter). + WithAuctionFunc(myAuctionFunc) + +http.Handle("/vast", handler) +``` + +## Konfiguracja Warstwowa + +```go +// Konfiguracja hosta (domyślne) +hostCfg := &vast.CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "USD", + VastVersionDefault: "4.0", +} + +// Konfiguracja konta (nadpisuje host) +accountCfg := &vast.CTVVastConfig{ + MaxAdsInPod: 5, +} + +// Merge warstw +merged := vast.MergeCTVVastConfig(hostCfg, accountCfg, nil) +``` + +## Testowanie + +Uruchom wszystkie testy modułu: + +```bash +go test ./modules/prebid/ctv_vast_enrichment/... -v +``` + +Testy z pokryciem: + +```bash +go test ./modules/prebid/ctv_vast_enrichment/... -cover +``` + +Uruchom tylko testy module.go: + +```bash +go test ./modules/prebid/ctv_vast_enrichment -run TestBuilder -v +go test ./modules/prebid/ctv_vast_enrichment -run TestHandleRawBidderResponseHook -v +``` + +## Rozszerzenia + +### Dodawanie Nowego Odbiorcy + +1. Dodaj stałą w `types.go`: + ```go + ReceiverMyReceiver ReceiverType = "MY_RECEIVER" + ``` + +2. Zaimplementuj `Formatter` dla nowego formatu w `format/` + +3. Zaktualizuj `configToReceiverConfig()` w `module.go` + +### Dodawanie Nowej Strategii Selekcji + +1. Dodaj stałą w `types.go`: + ```go + SelectionMyStrategy SelectionStrategy = "MY_STRATEGY" + ``` + +2. Zaimplementuj `BidSelector` w `select/` + +3. Zaktualizuj fabrykę `NewSelector()` + +## Zależności + +- `github.com/prebid/prebid-server/v3/hooks/hookstage` - Interfejsy hooków PBS +- `github.com/prebid/prebid-server/v3/modules/moduledeps` - Zależności modułów +- `github.com/prebid/openrtb/v20/openrtb2` - Typy OpenRTB +- `encoding/xml` - Parsowanie/serializacja XML diff --git a/modules/prebid/ctv_vast_enrichment/README_EN.md b/modules/prebid/ctv_vast_enrichment/README_EN.md new file mode 100644 index 00000000000..6c15316b11c --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/README_EN.md @@ -0,0 +1,456 @@ +# 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. + +### 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 + +\`\`\`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. 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 + +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 +- `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` + +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. + +**1. Ensure the module is registered in `modules/builder.go`** (see "Registration" section above). + +**2. Add hooks configuration to `pbs.json`:** + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "default_currency": "USD", + "receiver": "GAM_SSU" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } + } + } +} +``` + +**3. Account-level override** (optional): +```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..a03ea977ed0 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/config.go @@ -0,0 +1,369 @@ +package ctv_vast_enrichment + +// 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..642e4635d51 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/config_test.go @@ -0,0 +1,388 @@ +package ctv_vast_enrichment + +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..ad6628452c6 --- /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" + + vast "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..5379c2361f0 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/enrich/enrich_test.go @@ -0,0 +1,672 @@ +package enrich + +import ( + "testing" + + 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" +) + +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..ea63800140b --- /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" + + vast "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..567f4c23089 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/format/format_test.go @@ -0,0 +1,488 @@ +package format + +import ( + "os" + "path/filepath" + "strings" + "testing" + + 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" +) + +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..127b7686d2b --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/handler.go @@ -0,0 +1,167 @@ +package ctv_vast_enrichment + +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..b4b38c4364a --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go @@ -0,0 +1,309 @@ +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) { + // Clear InnerXML fields to prevent duplicate content + v.clearInnerXML() + 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) { + // Clear InnerXML fields to prevent duplicate content + v.clearInnerXML() + output, err := xml.Marshal(v) + if err != nil { + return nil, err + } + 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 + 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..02e8e0b992c --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/module.go @@ -0,0 +1,249 @@ +package ctv_vast_enrichment + +import ( + "context" + "encoding/json" + "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" +) + +// 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) + + // Build modified bids list + modifiedBids := make([]*adapters.TypedBid, 0, len(payload.BidderResponse.Bids)) + changesMade := false + + for _, typedBid := range payload.BidderResponse.Bids { + if typedBid == nil || typedBid.Bid == nil { + modifiedBids = append(modifiedBids, typedBid) + continue + } + + bid := typedBid.Bid + + // Skip non-video bids (no AdM or not VAST) + if bid.AdM == "" { + modifiedBids = append(modifiedBids, typedBid) + continue + } + + // Try to parse as VAST + vastDoc, err := model.ParseVastAdm(bid.AdM) + if err != nil { + // Not valid VAST, skip enrichment + modifiedBids = append(modifiedBids, typedBid) + 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 bid on format error + modifiedBids = append(modifiedBids, typedBid) + continue + } + + // 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 via ChangeSet + if changesMade { + changeSet := hookstage.ChangeSet[hookstage.RawBidderResponsePayload]{} + changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids) + result.ChangeSet = changeSet + } + + 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..1d932b68be6 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/module_test.go @@ -0,0 +1,604 @@ +package ctv_vast_enrichment + +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) + + // 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") + 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) + + // 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") +} + +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) + + // 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") +} + +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) + + // 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") + 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..d304973fae6 --- /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 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, + } +} 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..9823788e904 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/pipeline_test.go @@ -0,0 +1,607 @@ +package ctv_vast_enrichment + +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..96db242eafd --- /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" + vast "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..87f31f14b4e --- /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" + vast "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..20a810c3fa7 --- /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 ( + vast "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..0d8d1896271 --- /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 ctv_vast_enrichment + +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) +}