diff --git a/modules/builder.go b/modules/builder.go index df2f1b1c3a7..28616346eb4 100644 --- a/modules/builder.go +++ b/modules/builder.go @@ -2,6 +2,8 @@ package modules import ( fiftyonedegreesDevicedetection "github.com/prebid/prebid-server/v4/modules/fiftyonedegrees/devicedetection" + prebidCtvVastEnrichment "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" + _ "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/enrich" // registers VastEnricher via init() prebidOrtb2blocking "github.com/prebid/prebid-server/v4/modules/prebid/ortb2blocking" prebidRulesengine "github.com/prebid/prebid-server/v4/modules/prebid/rulesengine" wurflDevicedetection "github.com/prebid/prebid-server/v4/modules/scientiamobile/wurfl_devicedetection" @@ -16,8 +18,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, }, "scientiamobile": { "wurfl_devicedetection": wurflDevicedetection.Builder, diff --git a/modules/prebid/ctv_vast_enrichment/E2EReadme.md b/modules/prebid/ctv_vast_enrichment/E2EReadme.md new file mode 100644 index 00000000000..be0723cb99b --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/E2EReadme.md @@ -0,0 +1,335 @@ +# CTV VAST Enrichment — End-to-End Test Suite + +> **File:** `modules/prebid/ctv_vast_enrichment/module_e2e_test.go` +> **Package:** `ctv_vast_enrichment_test` +> **Total tests:** 25 + +--- + +## Overview + +The end-to-end test suite exercises the **full hook path** of the `ctv_vast_enrichment` module using real sub-package implementations (`enrich.NewEnricher`, `format.NewFormatter`, `select.NewSelector`) instead of mocks. + +This means regressions in integration points — config merging, VAST parsing, enrichment, marshaling — are caught at the boundary where all components work together, not just in isolation. + +The suite is divided into four groups: + +| Group | Prefix | Focus | +|-------|--------|-------| +| A | `TestE2E_A*` | Hook path correctness (HandleRawBidderResponseHook) | +| B | `TestE2E_B*` | Configuration merging correctness | +| C | `TestE2E_C*` | Pipeline end-to-end (BuildVastFromBidResponse) | +| D | `TestE2E_D*` | Regression tests — bugs documented in `ctv-bugs-and-resolve.md` | + +--- + +## Group A — Hook Path Correctness + +These tests call `HandleRawBidderResponseHook` directly and verify the resulting VAST XML after applying all ChangeSet mutations. + +### A1 — Video bid enriched + +**Scenario:** A single video bid with a valid VAST is submitted. + +**Expectation:** +- `` is injected into the VAST +- `` is populated with the first entry from `ADomain` + +--- + +### A2 — Banner bid passes through untouched + +**Scenario:** A bid with `BidType = banner` is submitted with HTML content in `AdM`. + +**Expectation:** +- The `AdM` field is identical before and after the hook +- No VAST parsing is attempted (BidType guard fires first) + +--- + +### A3 — Native bid passes through untouched + +**Scenario:** A bid with `BidType = native` is submitted with JSON content in `AdM`. + +**Expectation:** +- The `AdM` field is identical before and after the hook +- No VAST parsing is attempted + +--- + +### A4 — BidMeta preserved on enriched TypedBid + +**Scenario:** A video bid carries `BidMeta` with `NetworkID = 42`, `AdvertiserID = 99`, `BrandID = 7`. + +**Expectation:** +- After enrichment the `TypedBid.BidMeta` pointer is not nil +- All three fields retain their original values + +--- + +### A5 — BidderResponse.Currency used in `` + +**Scenario:** Host config sets `DefaultCurrency = "USD"`. The DSP's `BidderResponse.Currency` is `"EUR"`. + +**Expectation:** +- `` appears in the VAST +- `currency="USD"` does **not** appear — DSP currency takes precedence over host default + +--- + +### A6 — Fallback to DefaultCurrency when BidderResponse.Currency is empty + +**Scenario:** The DSP omits `Currency` in its response. Host config sets `DefaultCurrency = "GBP"`. + +**Expectation:** +- `` appears in the VAST +- The fallback chain is: `BidderResponse.Currency` → `DefaultCurrency` → `"USD"` + +--- + +### A7 — VAST_WINS: existing `` not overwritten + +**Scenario:** The VAST already contains `3.00`. The bid price is 9.99. + +**Expectation:** +- Original `GBP` and `3.00` are preserved +- `9.99` does not appear — VAST_WINS collision policy protects existing data + +--- + +### A8 — DSP-specific VAST extensions preserved after marshal + +**Scenario:** The VAST contains a `` block with a custom tracking URL. + +**Expectation:** +- `type="dsp_custom"` survives the parse → enrich → marshal round-trip +- The DSP tracker URL survives unchanged + +--- + +### A9 — Mixed bid types: only video bids enriched + +**Scenario:** Two bids in one response — one `banner`, one `video`. + +**Expectation:** +- Banner `AdM` is byte-identical before and after the hook +- Video `AdM` contains `` + +--- + +## Group B — Configuration Correctness + +### B1 — VAST_WINS collision policy round-trips through account config + +**Scenario:** No host-level `collision_policy`. Account config sets `collision_policy = "VAST_WINS"`. VAST already has pricing. + +**Expectation:** +- Original pricing is preserved (VAST_WINS applied correctly) +- Bidder price does not replace existing VAST pricing +- Verifies BUG 2: `VAST_WINS` was previously silently converted to `CollisionReject` + +--- + +### B2 — Account config overrides host config + +**Scenario:** Host config sets `default_currency = "USD"`. Account config sets `default_currency = "EUR"`. DSP omits currency. + +**Expectation:** +- `currency="EUR"` appears in the VAST — account-level config wins over host-level + +--- + +## Group C — Pipeline End-to-End + +These tests call `BuildVastFromBidResponse` directly with real selector, enricher, and formatter components (no mocks). + +### C1 — Single video bid → enriched VAST + +**Scenario:** One bid at price 5.00 USD from seat "rubicon" with domain "brand.example.com". + +**Expectation:** +- `result.NoAd` is false +- VAST contains `` with value 5 +- VAST contains `brand.example.com` + +--- + +### C2 — Ad pod: sequence attributes set correctly + +**Scenario:** Three video bids with prices 10.0, 8.0, 6.0 using `SelectionTopN` with `MaxAdsInPod = 3`. + +**Expectation:** +- All three bids are selected +- VAST output contains `sequence="1"`, `sequence="2"`, `sequence="3"` on each `` + +--- + +### C3 — No bids → NoAd VAST returned + +**Scenario:** Empty `BidResponse` with no seat bids. + +**Expectation:** +- `result.NoAd` is true +- A valid empty `` document is returned (not an error) + +--- + +### C4 — Invalid VAST, skeleton disabled → NoAd + +**Scenario:** Bid has `AdM = "not-xml-at-all"`. `AllowSkeletonVast = false`. + +**Expectation:** +- `result.NoAd` is true +- No panic, no error returned at the function level + +--- + +### C5 — Invalid VAST, skeleton enabled → VAST with warning + +**Scenario:** Bid has `AdM = "not-xml-at-all"`. `AllowSkeletonVast = true`. + +**Expectation:** +- `result.NoAd` is false — a skeleton VAST is generated +- `result.Warnings` is non-empty (warning about invalid VAST parsing) + +--- + +### C6 — Duration from metadata injected into `` + +**Scenario:** A custom selector injects `DurSec = 45` into `CanonicalMeta`. The VAST has no `` element. + +**Expectation:** +- Output VAST contains `00:00:45` + +--- + +### C7 — IAB categories injected as VAST extension + +**Scenario:** Bid has `Cat = ["IAB1", "IAB2-3"]`. + +**Expectation:** +- Output VAST contains `IAB1` and `IAB2-3` +- Extension is typed `iab_category` + +--- + +### C8 — Debug extension includes BidID and Seat + +**Scenario:** `ReceiverConfig.Debug = true`. Bid ID is `"debug-bid-123"`, seat is `"rubicon"`. + +**Expectation:** +- Output VAST contains `debug-bid-123` +- Output VAST contains `rubicon` +- Both wrapped in `` + +--- + +## Group D — Regression Tests + +Each test in this group directly targets a specific bug documented in [`ctv-bugs-and-resolve.md`](ctv-bugs-and-resolve.md). + +### D1 — Non-USD DSP currency preserved (BUG 1) + +**Scenario:** Host config: `USD`. DSP responses in `EUR`, `JPY`, `BRL`, `AUD` (parametrized sub-tests). + +**Expectation:** +- Each currency appears in `` unchanged +- `USD` does not appear in any output + +**Bug fixed:** `BidderResponse.Currency` was ignored; `DefaultCurrency` from host config was always used, producing wrong labels like `0.85` for a EUR DSP. + +--- + +### D2 — VAST_WINS not silently converted to Reject (BUG 2) + +**Scenario:** Host config sets `collision_policy = "VAST_WINS"`. VAST has existing pricing. + +**Expectation:** +- Existing pricing preserved (VAST_WINS applied) + +**Bug fixed:** The `switch` statement in `configToReceiverConfig` was missing the `VAST_WINS` case, causing it to fall through to the zero value `CollisionReject`. Publishers setting `VAST_WINS` got the opposite behavior with no error. + +--- + +### D3 — Hook uses enrich subpackage (BUG 3) + +**Scenario:** Account config sets `debug = true`. A video bid is submitted. + +**Expectation:** +- Output VAST contains `` with `` +- This extension is only added by `enrich.VastEnricher`, not the fallback `hookEnricher` + +**Bug fixed:** The hook was calling a private `enrichVastDocument()` function that only handled `Pricing` and `Advertiser`. The subpackages `enrich/`, `format/`, `select/` were completely bypassed. Config fields like `debug`, `selection_strategy`, `placement` had no effect in production. + +--- + +### D4 — MediaFiles preserved after clearInnerXML + marshal (BUG 4) + +**Scenario:** A VAST with a `` containing a video URL is enriched. + +**Expectation:** +- After enrichment and marshal, `` element still exists +- The video URL is unchanged +- The `type="video/mp4"` attribute is preserved + +**Bug fixed:** `clearInnerXML()` was zeroing all `,innerxml` fields recursively, including `Creative.InnerXML` and `Linear.InnerXML`. This silently dropped ``, ``, and any DSP-specific extensions. + +--- + +### D5 — BidMeta fields survive the hook mutation (BUG 6) + +**Scenario:** A `TypedBid` carries `BidMeta` with `NetworkID`, `AdvertiserID`, `BrandID`, `PrimaryCategoryID`. + +**Expectation:** +- After the hook, all four fields retain their original values + +**Bug fixed:** When constructing the enriched `TypedBid`, `BidMeta` was not copied. Analytics and targeting systems downstream received `nil` instead of the original metadata. + +--- + +### D6 — Only first ADomain used in `` (BUG 7) + +**Scenario:** Bid has `ADomain = ["primary.com", "secondary.com", "tertiary.com"]`. + +**Expectation:** +- `primary.com` — exactly the first domain +- The string `"primary.com,secondary.com"` does not appear anywhere + +**Bug fixed:** `strings.Join(bid.ADomain, ",")` was producing `"primary.com,secondary.com,tertiary.com"` as the advertiser value. VAST `` is a single human-readable string — joining multiple domains is non-standard and breaks ad server parsing. + +--- + +## Running the tests + +```bash +# Run only the E2E suite +go test ./modules/prebid/ctv_vast_enrichment/... -run TestE2E -v + +# Run the full module test suite +go test ./modules/prebid/ctv_vast_enrichment/... -v + +# Run with race detector +go test ./modules/prebid/ctv_vast_enrichment/... -race -v +``` + +--- + +## Test fixtures used + +| Constant | Description | +|----------|-------------| +| `minimalVAST` | Well-formed VAST 3.0 with one InLine ad, ``, and ``. Baseline for most tests. | +| `vastWithPricing` | VAST with existing `3.00`. Used for VAST_WINS tests. | +| `vastWithExtensions` | VAST with a `` block. Used for clearInnerXML regression test. | + +--- + +## Related files + +| File | Description | +|------|-------------| +| [`ctv-bugs-and-resolve.md`](ctv-bugs-and-resolve.md) | Full bug descriptions, root cause analysis, and fix specifications | +| [`module.go`](module.go) | Hook implementation — entry point for all Group A and B tests | +| [`pipeline.go`](pipeline.go) | `BuildVastFromBidResponse` — entry point for all Group C tests | +| [`enrich/enrich.go`](enrich/enrich.go) | `VastEnricher` — enriches ``, ``, ``, categories, debug | +| [`model/vast_xml.go`](model/vast_xml.go) | VAST data model and `clearInnerXML` — relevant to D4 | diff --git a/modules/prebid/ctv_vast_enrichment/README.md b/modules/prebid/ctv_vast_enrichment/README.md new file mode 100644 index 00000000000..86856d2f017 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/README.md @@ -0,0 +1,439 @@ +# 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 +├── 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 +│ ├── 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. + +> **Enabled is opt-in:** The module processes bids only when `enabled` is explicitly set to `true`. A `null`/missing value is treated as `false` — the module will not run. + +## Components + +### \`module.go\` - PBS Module + +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 + - Skips processing when `enabled` is not explicitly `true` (opt-in) + - Enriches VAST in each video bid; allocates the modified bids slice lazily (only when enrichment actually occurs) + - Logs errors via `glog` when VAST building fails + +### \`pipeline.go\` - Standalone Pipeline + +Alternative entry point for direct invocation: + +- **\`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 + +### \`types.go\` - Types and Interfaces + +| Type | Description | +|------|-------------| +| \`ReceiverType\` | Receiver type (GAM_SSU, GENERIC) | +| \`SelectionStrategy\` | Bid selection strategy; implemented: `SINGLE`, `TOP_N`. Constants `max_revenue`, `min_duration`, `balanced` are reserved for future implementation and fall back to `TOP_N` | +| \`CollisionPolicy\` | Collision policy: `VAST_WINS` / `reject` keep existing VAST value (with warning); `warn` overwrites with warning; `ignore` overwrites silently | + +**Interfaces:** + +\`\`\`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; `Bid` field is `*openrtb2.Bid` (pointer, consistent with the rest of PBS) +- \`EnrichedAd\` - Enriched ad ready for formatting +- \`VastResult\` - Processing result (XML, warnings, errors) +- \`ReceiverConfig\` - VAST receiver configuration +- \`PlacementRules\` - Validation rules (pricing, advertiser, categories) + +### \`config.go\` - 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`, and `Wrapper` only. `Creative` and `Linear` `InnerXML` fields are intentionally preserved to keep ``, ``, and DSP-specific extensions intact. + +#### `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\`** - Respects the configured `CollisionPolicy`: + - `VAST_WINS` / `reject`: keeps existing VAST values (with warning) + - `warn`: overwrites existing values, emits warning + - `ignore`: overwrites silently + - Adds missing: Pricing, Advertiser, Duration, Categories + +Enriched elements: +| 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 + +\`\`\`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, +) +\`\`\` + +## Layer Configuration + +\`\`\`go +// Host configuration (defaults) +hostCfg := &vast.CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "USD", + VastVersionDefault: "3.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/README_EN.md b/modules/prebid/ctv_vast_enrichment/README_EN.md new file mode 100644 index 00000000000..86856d2f017 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/README_EN.md @@ -0,0 +1,439 @@ +# 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 +├── 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 +│ ├── 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. + +> **Enabled is opt-in:** The module processes bids only when `enabled` is explicitly set to `true`. A `null`/missing value is treated as `false` — the module will not run. + +## Components + +### \`module.go\` - PBS Module + +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 + - Skips processing when `enabled` is not explicitly `true` (opt-in) + - Enriches VAST in each video bid; allocates the modified bids slice lazily (only when enrichment actually occurs) + - Logs errors via `glog` when VAST building fails + +### \`pipeline.go\` - Standalone Pipeline + +Alternative entry point for direct invocation: + +- **\`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 + +### \`types.go\` - Types and Interfaces + +| Type | Description | +|------|-------------| +| \`ReceiverType\` | Receiver type (GAM_SSU, GENERIC) | +| \`SelectionStrategy\` | Bid selection strategy; implemented: `SINGLE`, `TOP_N`. Constants `max_revenue`, `min_duration`, `balanced` are reserved for future implementation and fall back to `TOP_N` | +| \`CollisionPolicy\` | Collision policy: `VAST_WINS` / `reject` keep existing VAST value (with warning); `warn` overwrites with warning; `ignore` overwrites silently | + +**Interfaces:** + +\`\`\`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; `Bid` field is `*openrtb2.Bid` (pointer, consistent with the rest of PBS) +- \`EnrichedAd\` - Enriched ad ready for formatting +- \`VastResult\` - Processing result (XML, warnings, errors) +- \`ReceiverConfig\` - VAST receiver configuration +- \`PlacementRules\` - Validation rules (pricing, advertiser, categories) + +### \`config.go\` - 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`, and `Wrapper` only. `Creative` and `Linear` `InnerXML` fields are intentionally preserved to keep ``, ``, and DSP-specific extensions intact. + +#### `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\`** - Respects the configured `CollisionPolicy`: + - `VAST_WINS` / `reject`: keeps existing VAST values (with warning) + - `warn`: overwrites existing values, emits warning + - `ignore`: overwrites silently + - Adds missing: Pricing, Advertiser, Duration, Categories + +Enriched elements: +| 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 + +\`\`\`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, +) +\`\`\` + +## Layer Configuration + +\`\`\`go +// Host configuration (defaults) +hostCfg := &vast.CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "USD", + VastVersionDefault: "3.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/README_PL.md b/modules/prebid/ctv_vast_enrichment/README_PL.md new file mode 100644 index 00000000000..81636f07a4f --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/README_PL.md @@ -0,0 +1,484 @@ +# Moduł CTV VAST Enrichment + +Moduł CTV VAST Enrichment to moduł hook Prebid Server, który wzbogaca odpowiedzi VAST (Video Ad Serving Template) o dodatkowe metadane dla reklam Connected TV (CTV). + +## Struktura Modułu + +``` +modules/prebid/ctv_vast_enrichment/ +├── module.go # Punkt wejścia modułu PBS (Builder + HandleRawBidderResponseHook) +├── module_test.go # Testy modułu +├── pipeline.go # Samodzielny pipeline przetwarzania VAST +├── pipeline_test.go # Testy pipeline +├── handler.go # Handler HTTP dla bezpośrednich żądań VAST +├── types.go # Definicje typów, interfejsy i stałe +├── config.go # Konfiguracja i mergowanie warstw (host/account/profile) +├── config_test.go # Testy konfiguracji +├── model/ # Struktury danych VAST XML +│ ├── model.go # Obiekty domenowe wysokiego poziomu +│ ├── vast_xml.go # Struktury XML do marshal/unmarshal +│ ├── parser.go # Parser VAST XML +│ └── *_test.go # Testy +├── select/ # Logika selekcji bidów +│ ├── selector.go # Implementacje BidSelector +│ └── *_test.go # Testy +├── enrich/ # Wzbogacanie VAST +│ ├── enrich.go # Implementacja Enricher (VAST_WINS) +│ └── *_test.go # Testy +└── format/ # Formatowanie VAST XML + ├── format.go # Implementacja Formatter (GAM_SSU) + └── *_test.go # Testy +``` + +## Integracja z PBS + +Moduł jest zgodny ze standardowym wzorcem modułów Prebid Server. + +### Rejestracja w `modules/builder.go` + +Moduł musi być zarejestrowany w pliku `modules/builder.go`, który jest centralnym rejestrem wszystkich modułów PBS: + +```go +import ( + prebidCtvVastEnrichment "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +) + +var newModuleBuilders = map[string]map[string]interface{}{ + "prebid": { + "ctv_vast_enrichment": prebidCtvVastEnrichment.Builder, + }, +} +``` + +> **Uwaga:** Nazwa pakietu Go to `ctv_vast_enrichment`, ale subpakiety (enrich, select, format) używają aliasu `vast` do importu pakietu nadrzędnego: +> ```go +> import vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" +> ``` + +### `module.go` - Główny Punkt Wejścia + +```go +// Builder tworzy nową instancję modułu CTV VAST enrichment. +func Builder(cfg json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, error) + +// Module implementuje funkcjonalność wzbogacania CTV VAST jako moduł hook PBS. +type Module struct { + hostConfig CTVVastConfig +} + +// HandleRawBidderResponseHook przetwarza odpowiedzi bidderów, wzbogacając VAST XML. +func (m Module) HandleRawBidderResponseHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.RawBidderResponsePayload, +) (hookstage.HookResult[hookstage.RawBidderResponsePayload], error) +``` + +### Hook Stage + +Moduł działa na etapie hooka **RawBidderResponse**, przetwarzając odpowiedź każdego biddera przed agregacją. Dla każdego bida zawierającego VAST XML: + +1. Parsuje VAST XML z pola `AdM` bida +2. Wzbogaca VAST o pricing, advertiser i metadane kategorii +3. Tworzy nowy `*adapters.TypedBid` z nowym `*openrtb2.Bid` zawierającym wzbogacony AdM +4. Zwraca mutację przez `changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids)` + +> **Wzorzec ChangeSet:** Hook nie modyfikuje payload bezpośrednio. Zamiast tego buduje nowy slice `[]adapters.TypedBid` i rejestruje mutację przez `UpdateBids()`. PBS stosuje mutację po powrocie z hooka — zgodnie ze wzorcem z modułu `ortb2blocking`. + +### Konfiguracja PBS (`pbs.json`) + +Aby moduł został wywołany podczas aukcji, wymagana jest konfiguracja `host_execution_plan` w sekcji `hooks`: + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } + } + } +} +``` + +> **Ważne:** Sam `enabled_modules` nie wystarczy. PBS wymaga jawnego `host_execution_plan` z definicją stage `raw_bidder_response` i `module_code: "prebid.ctv_vast_enrichment"`, aby hook został faktycznie wywołany. + +Konfiguracja na poziomie konta nadpisuje ustawienia na poziomie hosta. + +> **Enabled jest opt-in:** Moduł przetwarza bidy tylko gdy `enabled` jest jawnie ustawione na `true`. Brak wartości (`null`/nieobecne) jest traktowany jako `false` — moduł nie zostanie uruchomiony. + +## Komponenty + +### `module.go` - Moduł PBS + +Główny punkt wejścia zgodny z konwencjami modułów PBS: + +- **`Builder()`** - Tworzy instancję modułu z konfiguracji JSON +- **`Module`** - Struktura przechowująca konfigurację na poziomie hosta +- **`HandleRawBidderResponseHook()`** - Implementacja hooka: + - Parsuje konfigurację na poziomie konta + - Merguje konfiguracje hosta i konta + - Pomija przetwarzanie gdy `enabled` nie jest jawnie `true` (opt-in) + - Wzbogaca VAST w każdym bidzie video; slice zmodyfikowanych bidów alokowany jest leniwie (tylko gdy enrichment faktycznie następuje) + - Loguje błędy przez `glog` gdy budowanie VAST się nie powiedzie + +### `pipeline.go` - Samodzielny Pipeline + +Alternatywny punkt wejścia do bezpośredniego wywołania (używany przez handler.go): + +- **`BuildVastFromBidResponse()`** - Orkiestruje pełny pipeline: + 1. Selekcja bidów z odpowiedzi aukcji + 2. Parsowanie VAST z AdM każdego bida + 3. Wzbogacanie metadanymi + 4. Formatowanie do końcowego XML + +- **`Processor`** - Wrapper z wstrzykniętymi zależnościami +- **`DefaultConfig()`** - Domyślna konfiguracja dla GAM SSU + +### `handler.go` - Handler HTTP + +Obsługa żądań HTTP dla reklam CTV VAST (opcjonalny endpoint): + +- **`Handler`** - Handler HTTP z konfiguracją i zależnościami +- **`ServeHTTP()`** - Obsługuje żądania GET, zwraca VAST XML +- Metody buildera: `WithConfig()`, `WithSelector()`, itp. + +### `types.go` - Typy i Interfejsy + +| Typ | Opis | +|-----|------| +| `ReceiverType` | Typ odbiorcy (GAM_SSU, GENERIC) | +| `SelectionStrategy` | Strategia selekcji bidów (SINGLE, TOP_N, max_revenue, min_duration, balanced); nieznane wartości cofają się do domyślnej | +| `CollisionPolicy` | Polityka kolizji (reject, warn, ignore, VAST_WINS); nieznane wartości cofają się do domyślnej | + +**Interfejsy:** + +```go +type BidSelector interface { + Select(req, resp, cfg) ([]SelectedBid, []string, error) +} + +type Enricher interface { + Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) +} + +type Formatter interface { + Format(ads []EnrichedAd, cfg ReceiverConfig) ([]byte, []string, error) +} +``` + +**Struktury Danych:** + +- `CanonicalMeta` - Znormalizowane metadane bida (BidID, Price, Currency, Adomain, itp.) +- `SelectedBid` - Wybrany bid z metadanymi i numerem sekwencji; pole `Bid` jest wskaźnikiem `*openrtb2.Bid` (zgodnie z resztą PBS) +- `EnrichedAd` - Wzbogacona reklama gotowa do formatowania +- `VastResult` - Wynik przetwarzania (XML, ostrzeżenia, błędy) +- `ReceiverConfig` - Konfiguracja odbiorcy VAST +- `PlacementRules` - Reguły walidacji (pricing, advertiser, categories) + +### `config.go` - Konfiguracja + +Warstwowy system konfiguracji w stylu PBS: + +- **`CTVVastConfig`** - Struktura konfiguracji z polami nullable +- **`MergeCTVVastConfig()`** - Mergowanie warstw: Host → Account → Profile + +Priorytet warstw (od najniższego do najwyższego): +1. Host (domyślne) +2. Account (nadpisuje host) +3. Profile (nadpisuje wszystko) + +### `model/` - Struktury VAST XML + +#### `vast_xml.go` + +Struktury Go mapujące elementy VAST XML: + +- `Vast` - Element główny `` +- `Ad` - Element `` z atrybutami id, sequence +- `InLine` - Reklama inline z pełnymi danymi +- `Wrapper` - Reklama wrapper (przekierowanie) +- `Creative`, `Linear`, `MediaFile` - Elementy kreacji +- `Pricing`, `Impression`, `Extensions` - Metadane i tracking + +Funkcje pomocnicze: +- `BuildNoAdVast()` - Tworzy pusty VAST (brak reklam) +- `BuildSkeletonInlineVast()` - Tworzy minimalny szkielet VAST +- `Marshal()` / `MarshalCompact()` - Serializacja do XML +- `clearInnerXML()` - Czyści pola `InnerXML` przed serializacją (zapobiega duplikowaniu elementów) + +> **Fix XML:** Struktury VAST używają tagu `,innerxml` do zachowania surowego XML podczas parsowania. Przed `Marshal()` wywoływana jest `clearInnerXML()`, która zeruje pola `InnerXML` na strukturach `Ad`, `InLine`, `Wrapper`, `Creative` i `Linear`, zapobiegając duplikowaniu elementów w wynikowym XML. + +#### `parser.go` + +Parser VAST XML: + +- **`ParseVastAdm()`** - Parsuje string AdM do struktury Vast +- **`ParseVastOrSkeleton()`** - Parsuje lub tworzy szkielet jeśli dozwolone +- **`ExtractFirstAd()`** - Wyciąga pierwszą reklamę z VAST + +### `select/` - Selekcja Bidów + +Logika wyboru bidów z odpowiedzi aukcji: + +- **`PriceSelector`** - Implementacja oparta na cenie: + - Filtruje bidy z ceną ≤ 0 lub pustym AdM + - Sortuje: deal > non-deal, potem po cenie malejąco + - Respektuje `MaxAdsInPod` dla strategii TOP_N + - Przypisuje numery sekwencji (1-indexed) + +- **`NewSelector(strategy)`** - Fabryka tworząca selektor dla strategii + +### `enrich/` - Wzbogacanie VAST + +Dodawanie metadanych do reklam VAST: + +- **`VastEnricher`** - Implementacja z polityką VAST_WINS: + - Istniejące wartości w VAST nie są nadpisywane + - Dodaje brakujące: Pricing, Advertiser, Duration, Categories + +Wzbogacane elementy: +| Element | Źródło | Lokalizacja | +|---------|--------|-------------| +| Pricing | meta.Price | `` lub Extension | +| Advertiser | meta.Adomain | `` lub Extension | +| Duration | meta.DurSec | `` w Linear | +| Categories | meta.Cats | Extension (zawsze) | + +### `format/` - Formatowanie VAST + +Budowanie końcowego VAST XML: + +- **`VastFormatter`** - Implementacja GAM SSU: + - Buduje dokument VAST z listą elementów `` + - Ustawia `id` z BidID + - Ustawia `sequence` dla podów (wiele reklam) + +## Przepływ Przetwarzania + +``` +┌─────────────────────────────────────────────────────┐ +│ PBS Auction Pipeline │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ RawBidderResponse Hook Stage │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ HandleRawBidderResponseHook() │ │ +│ │ Dla każdego bida z VAST w AdM: │ │ +│ │ 1. Parsuje VAST XML │ │ +│ │ 2. Wzbogaca o pricing/advertiser │ │ +│ │ 3. Aktualizuje bid.AdM │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Wzbogacona BidderResponse │ +│ (VAST z , itp.) │ +└─────────────────────────────────────────────────────┘ +``` + +## Użycie + +### Jako Moduł PBS (Rekomendowane) + +Moduł jest automatycznie wywoływany podczas pipeline aukcji gdy włączony w konfiguracji. + +**1. Upewnij się, że moduł jest zarejestrowany w `modules/builder.go`** (patrz sekcja "Rejestracja" wyżej). + +**2. Dodaj konfigurację hooks do `pbs.json`:** + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "default_currency": "USD", + "receiver": "GAM_SSU" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } + } + } +} +``` + +**3. Nadpisanie na poziomie konta** (opcjonalne): +```json +{ + "hooks": { + "modules": { + "prebid.ctv_vast_enrichment": { + "enabled": true, + "default_currency": "EUR" + } + } + } +} +``` + +### Samodzielny Pipeline (dla handlera HTTP) + +```go +import ( + vast "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/enrich" + "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/format" + bidselect "github.com/prebid/prebid-server/v3/modules/prebid/ctv_vast_enrichment/select" +) + +// Konfiguracja +cfg := vast.DefaultConfig() +cfg.MaxAdsInPod = 3 + +// Tworzenie komponentów +selector := bidselect.NewSelector(cfg.SelectionStrategy) +enricher := enrich.NewEnricher() +formatter := format.NewFormatter() + +// Bezpośrednie wywołanie +result, err := vast.BuildVastFromBidResponse( + ctx, + bidRequest, + bidResponse, + cfg, + selector, + enricher, + formatter, +) +``` + +### Handler HTTP + +```go +handler := vast.NewHandler(). + WithConfig(cfg). + WithSelector(selector). + WithEnricher(enricher). + WithFormatter(formatter). + WithAuctionFunc(myAuctionFunc) + +http.Handle("/vast", handler) +``` + +## Konfiguracja Warstwowa + +```go +// Konfiguracja hosta (domyślne) +hostCfg := &vast.CTVVastConfig{ + Receiver: "GAM_SSU", + DefaultCurrency: "USD", + VastVersionDefault: "4.0", +} + +// Konfiguracja konta (nadpisuje host) +accountCfg := &vast.CTVVastConfig{ + MaxAdsInPod: 5, +} + +// Merge warstw +merged := vast.MergeCTVVastConfig(hostCfg, accountCfg, nil) +``` + +## Testowanie + +Uruchom wszystkie testy modułu: + +```bash +go test ./modules/prebid/ctv_vast_enrichment/... -v +``` + +Testy z pokryciem: + +```bash +go test ./modules/prebid/ctv_vast_enrichment/... -cover +``` + +Uruchom tylko testy module.go: + +```bash +go test ./modules/prebid/ctv_vast_enrichment -run TestBuilder -v +go test ./modules/prebid/ctv_vast_enrichment -run TestHandleRawBidderResponseHook -v +``` + +## Rozszerzenia + +### Dodawanie Nowego Odbiorcy + +1. Dodaj stałą w `types.go`: + ```go + ReceiverMyReceiver ReceiverType = "MY_RECEIVER" + ``` + +2. Zaimplementuj `Formatter` dla nowego formatu w `format/` + +3. Zaktualizuj `configToReceiverConfig()` w `module.go` + +### Dodawanie Nowej Strategii Selekcji + +1. Dodaj stałą w `types.go`: + ```go + SelectionMyStrategy SelectionStrategy = "MY_STRATEGY" + ``` + +2. Zaimplementuj `BidSelector` w `select/` + +3. Zaktualizuj fabrykę `NewSelector()` + +## Zależności + +- `github.com/prebid/prebid-server/v3/hooks/hookstage` - Interfejsy hooków PBS +- `github.com/prebid/prebid-server/v3/modules/moduledeps` - Zależności modułów +- `github.com/prebid/openrtb/v20/openrtb2` - Typy OpenRTB +- `encoding/xml` - Parsowanie/serializacja XML diff --git a/modules/prebid/ctv_vast_enrichment/config.go b/modules/prebid/ctv_vast_enrichment/config.go new file mode 100644 index 00000000000..e1685a379bb --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/config.go @@ -0,0 +1,377 @@ +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 = "TOP_N" + + // 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; fall back to default for unknown values + if cfg.SelectionStrategy != "" && isValidSelectionStrategy(cfg.SelectionStrategy) { + rc.SelectionStrategy = SelectionStrategy(cfg.SelectionStrategy) + } else { + rc.SelectionStrategy = SelectionStrategy(DefaultSelectionStrategy) + } + + // Apply collision policy with default; fall back to default for unknown values + if cfg.CollisionPolicy != "" && isValidCollisionPolicy(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 +} + +// isValidCollisionPolicy reports whether s is a known CollisionPolicy value. +func isValidCollisionPolicy(s string) bool { + switch CollisionPolicy(s) { + case CollisionReject, CollisionWarn, CollisionIgnore, CollisionVastWins: + return true + } + return false +} + +// isValidSelectionStrategy reports whether s is a known SelectionStrategy value. +func isValidSelectionStrategy(s string) bool { + switch SelectionStrategy(s) { + case SelectionSingle, SelectionTopN, SelectionMaxRevenue, SelectionMinDuration, SelectionBalanced: + return true + } + return false +} diff --git a/modules/prebid/ctv_vast_enrichment/config_test.go b/modules/prebid/ctv_vast_enrichment/config_test.go new file mode 100644 index 00000000000..9a7db8a71e4 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/config_test.go @@ -0,0 +1,391 @@ +package ctv_vast_enrichment + +import ( + "testing" + + "github.com/prebid/prebid-server/v4/util/ptrutil" + "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("TOP_N"), rc.SelectionStrategy) + assert.Equal(t, CollisionPolicy("VAST_WINS"), rc.CollisionPolicy) + assert.False(t, rc.Debug) +} + +func TestReceiverConfig_InvalidSelectionStrategyFallsBackToDefault(t *testing.T) { + cfg := CTVVastConfig{ + SelectionStrategy: "unknown_strategy", + } + rc := cfg.ReceiverConfig() + + assert.Equal(t, SelectionStrategy(DefaultSelectionStrategy), rc.SelectionStrategy) +} + +func TestReceiverConfig_InvalidCollisionPolicyFallsBackToDefault(t *testing.T) { + cfg := CTVVastConfig{ + CollisionPolicy: "unknown_policy", + } + rc := cfg.ReceiverConfig() + + assert.Equal(t, CollisionPolicy(DefaultCollisionPolicy), rc.CollisionPolicy) +} + +func TestReceiverConfig_WithValues(t *testing.T) { + debug := true + cfg := CTVVastConfig{ + 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: ptrutil.ToPtr(true), + expected: true, + }, + { + name: "false returns false", + enabled: ptrutil.ToPtr(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: ptrutil.ToPtr(true), + Debug: ptrutil.ToPtr(false), + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: ptrutil.ToPtr(1.0), + CeilingCPM: ptrutil.ToPtr(100.0), + Currency: "GBP", + }, + Advertiser: &AdvertiserRulesConfig{ + BlockedDomains: []string{"host-blocked.com"}, + }, + }, + } + + account := &CTVVastConfig{ + DefaultCurrency: "EUR", + MaxAdsInPod: 8, + CollisionPolicy: "warn", + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: ptrutil.ToPtr(2.0), + Currency: "EUR", + }, + Categories: &CategoryRulesConfig{ + BlockedCategories: []string{"IAB25"}, + }, + }, + } + + profile := &CTVVastConfig{ + VastVersionDefault: "4.2", + MaxAdsInPod: 3, + Debug: ptrutil.ToPtr(true), + Placement: &PlacementRulesConfig{ + Pricing: &PricingRulesConfig{ + FloorCPM: ptrutil.ToPtr(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 +} diff --git a/modules/prebid/ctv_vast_enrichment/endpoint.md b/modules/prebid/ctv_vast_enrichment/endpoint.md new file mode 100644 index 00000000000..2f6df64291c --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/endpoint.md @@ -0,0 +1,370 @@ +# Propozycja: Endpoint GET `/ctv/vast` dla modułu CTV VAST Enrichment + +## Stan obecny + +Moduł `ctv_vast_enrichment` działa wyłącznie jako **hook PBS** na etapie `RawBidderResponse` — wzbogaca VAST XML w odpowiedziach bidderów podczas standardowej aukcji POST `/openrtb2/auction`. + +Istnieje już plik `handler.go` z przygotowanym `Handler struct` implementującym `http.Handler` (metoda `ServeHTTP`), ale **nie ma mechanizmu rejestracji** tego handlera w routerze PBS. Handler jest "osierocony" — nie jest podłączony do żadnej trasy HTTP. + +### Kluczowy problem + +System modułów PBS (`modules.NewBuilder().Build()`) zwraca wyłącznie `hooks.HookRepository` — repozytorium hooków. **Moduły nie mają dostępu do routera HTTP** i nie mogą samodzielnie rejestrować endpointów. Wszystkie trasy HTTP są hardkodowane w `router/router.go` w funkcji `New()`. + +## Proponowane podejście + +### Opcja A: Rejestracja endpointu bezpośrednio w routerze (rekomendowana) + +Tak samo jak robią to istniejące endpointy (`/openrtb2/amp`, `/openrtb2/video`, `/event` itp.) — endpoint jest tworzony w `router/router.go` i rejestrowany ręcznie. + +#### Kroki implementacji + +**1. Nowy interfejs `ModuleEndpointProvider` (opcjonalny, ale czyściejszy)** + +Aby nie hardkodować wszystkiego w routerze, moduł może eksponować interfejs informujący o endpointach: + +```go +// modules/moduledeps/endpoint.go +package moduledeps + +import "net/http" + +// ModuleEndpoint opisuje endpoint HTTP dostarczany przez moduł. +type ModuleEndpoint struct { + Method string // "GET", "POST" + Path string // np. "/ctv/vast" + Handler http.Handler +} + +// EndpointProvider to opcjonalny interfejs, który moduł może implementować, +// aby zarejestrować własne endpointy HTTP. +type EndpointProvider interface { + Endpoints() []ModuleEndpoint +} +``` + +**2. Implementacja interfejsu w module** + +```go +// modules/prebid/ctv_vast_enrichment/module.go + +func (m Module) Endpoints() []moduledeps.ModuleEndpoint { + handler := NewHandler(). + WithConfig(configToReceiverConfig(MergeCTVVastConfig(&m.hostConfig, nil, nil))) + // Selector, Enricher, Formatter zostaną wstrzyknięte przez router + // lub podłączone z domyślnymi implementacjami + + return []moduledeps.ModuleEndpoint{ + { + Method: "GET", + Path: "/ctv/vast", + Handler: handler, + }, + } +} +``` + +**3. Rejestracja w routerze** + +Rozszerzenie `router/router.go` — po `Build()` modułów sprawdzamy, czy moduły implementują `EndpointProvider`: + +```go +// router/router.go w funkcji New(), po modules.NewBuilder().Build() + +// Rejestracja endpointów modułów +for id, module := range builtModules { + if ep, ok := module.(moduledeps.EndpointProvider); ok { + for _, endpoint := range ep.Endpoints() { + logger.Infof("Registering module endpoint: %s %s (module: %s)", endpoint.Method, endpoint.Path, id) + r.Handler(endpoint.Method, endpoint.Path, endpoint.Handler) + } + } +} +``` + +**4. Wstrzyknięcie AuctionFunc** + +Handler potrzebuje `AuctionFunc` do wywoływania aukcji. To jest najważniejszy element — trzeba przekazać referencję do exchange'a: + +```go +handler.WithAuctionFunc(func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { + // Użyj theExchange.HoldAuction() lub dedykowaną metodę + return theExchange.RunSimpleAuction(ctx, req) +}) +``` + +#### Diagram przepływu (Opcja A) + +``` +GET /ctv/vast?pod_id=123&duration=30&max_ads=3 + │ + ▼ +┌─────────────────────┐ +│ router/router.go │ ← rejestracja: r.Handler("GET", "/ctv/vast", handler) +│ httprouter │ +└─────────┬───────────┘ + ▼ +┌─────────────────────┐ +│ Handler.ServeHTTP │ ← handler.go (już istnieje) +│ │ +│ 1. Parse query │ ← buildBidRequest() - TODO do implementacji +│ 2. Build BidReq │ +│ 3. AuctionFunc() │ ← wstrzyknięty exchange +│ 4. Pipeline VAST │ ← BuildVastFromBidResponse() - już istnieje +│ 5. Return XML │ +└─────────────────────┘ +``` + +--- + +### Opcja B: Endpoint jako osobny pakiet w `endpoints/` (prostsze, bez nowego interfejsu) + +Bez tworzenia nowego interfejsu modułowego — po prostu dodajemy endpoint w `endpoints/` tak jak inne: + +#### Kroki + +**1. Nowy plik `endpoints/ctv_vast.go`** + +```go +package endpoints + +import ( + "net/http" + + "github.com/julienschmidt/httprouter" + ctv "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" +) + +func NewCTVVastEndpoint(cfg ctv.ReceiverConfig, auctionFn ctv.AuctionFunc) httprouter.Handle { + handler := ctv.NewHandler(). + WithConfig(cfg). + WithSelector(selectpkg.NewDefaultSelector()). + WithEnricher(enrichpkg.NewDefaultEnricher()). + WithFormatter(formatpkg.NewGAMSSUFormatter()). + WithAuctionFunc(auctionFn) + + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + handler.ServeHTTP(w, r) + } +} +``` + +**2. Rejestracja w `router/router.go`** + +```go +// W funkcji New(), obok istniejących endpointów: +if cfg.Hooks.Modules["prebid"]["ctv_vast_enrichment"] != nil { + r.GET("/ctv/vast", endpoints.NewCTVVastEndpoint(ctvConfig, auctionFunc)) +} +``` + +--- + +### Opcja C: Rejestracja przez Exitpoint Hook (bez zmian w routerze) + +Zamiast nowego endpointu, moduł mógłby "przejąć" istniejący endpoint `/openrtb2/auction` przez hook `Exitpoint` i zmienić format odpowiedzi na VAST XML gdy wykryje specjalny parametr. **Nie rekomendowane** — to hack, nie czyste rozwiązanie. + +--- + +## Rekomendacja + +**Opcja A** (interfejs `EndpointProvider`) jest najczystsza architektonicznie: + +| Kryterium | Opcja A | Opcja B | Opcja C | +|-----------|---------|---------|---------| +| Czystość architektury | ✅ Extensible | ⚠️ Hardcoded | ❌ Hack | +| Łatwość implementacji | ⚠️ Nowy interfejs | ✅ Proste | ✅ Proste | +| Reużywalność | ✅ Inne moduły też skorzystają | ❌ Jednorazowe | ❌ Jednorazowe | +| Zgodność z PBS | ⚠️ Wymaga zmiany w `modules/` | ✅ Istniejący wzorzec | ⚠️ Nadużycie hooków | +| Minimum zmian w core | ❌ 3 pliki core | ✅ 2 pliki core | ✅ 0 plików core | + +**Dla szybkiego MVP: Opcja B** — najmniej zmian, zgodna z istniejącymi wzorcami PBS. + +**Dla długoterminowej architektury: Opcja A** — tworzy mechanizm reużywalny dla przyszłych modułów. + +--- + +## Co jest do zrobienia w każdym podejściu + +Niezależnie od wybranej opcji, `handler.go` wymaga implementacji: + +### 1. Parsowanie query parameters (`buildBidRequest`) + +```go +func (h *Handler) buildBidRequest(r *http.Request) (*openrtb2.BidRequest, error) { + q := r.URL.Query() + + podID := q.Get("pod_id") // wymagany + duration := q.Get("duration") // max duration w sekundach + maxAds := q.Get("max_ads") // max reklam w podzie + publisherID := q.Get("pub_id") // ID publishera + siteURL := q.Get("url") // URL strony + // ... dalsze parametry +} +``` + +### 2. Wstrzyknięcie Selector/Enricher/Formatter + +Handler potrzebuje konkretnych implementacji interfejsów. Należy zdecydować: +- Czy tworzyć je w Builderze modułu? +- Czy w endpoincie w routerze? + +### 3. Wstrzyknięcie AuctionFunc + +Najważniejsza zależność — handler musi móc wywołać aukcję PBS. Wymaga dostępu do `exchange.Exchange`. + +### 4. Testy + +- Unit test `handler_test.go` z mockowanym `AuctionFunc` +- Integration test z pełnym pipeline (Postman collection częściowo istnieje) + +--- + +## Podsumowanie ścieżki implementacji (Opcja B — MVP) + +``` +1. endpoints/ctv_vast.go ← nowy plik: wrapper endpoint +2. router/router.go ← dodanie r.GET("/ctv/vast", ...) +3. handler.go ← implementacja buildBidRequest() +4. handler_test.go ← testy +5. config (opcjonalnie) ← feature flag w pbs.json +``` + +Łączna estymacja: ~5 plików do zmiany/utworzenia. + +--- + +## Jak moduły są uruchamiane przez endpoint GET — mechanizm Hook Executor + +Moduły **nie są uruchamiane "przez endpoint" bezpośrednio** — są uruchamiane przez **hook executor**, który endpoint tworzy i wywołuje na odpowiednich etapach. + +### Łańcuch wywołań (na przykładzie AMP) + +``` +GET /openrtb2/amp?tag_id=xyz + │ + ▼ +router.go: r.GET("/openrtb2/amp", ampEndpoint) + │ + ▼ +AmpAuction handler tworzy hook executor: + hookExecutor := hookexecution.NewHookExecutor(planBuilder, "/openrtb2/amp", metrics) + │ + ├─► hookExecutor.ExecuteEntrypointStage(r, nil) ← moduły dostają raw *http.Request + │ + ├─► exchange.HoldAuction(ctx, AuctionRequest{HookExecutor: hookExecutor, ...}) + │ wewnątrz exchange: + │ ├─► ExecuteProcessedAuctionStage() + │ ├─► ExecuteBidderRequestStage() ← per bidder + │ ├─► ExecuteRawBidderResponseStage() ← TU działa ctv_vast_enrichment! + │ └─► ExecuteAllProcessedBidResponsesStage() + │ + ├─► hookExecutor.ExecuteAuctionResponseStage(response) + └─► hookExecutor.ExecuteExitpointStage(ampResponse, w) +``` + +AMP uruchamia **7 z 8 etapów** — wszystkie oprócz `RawAuctionRequest` (bo GET nie ma body JSON). + +### Kluczowy mechanizm: Execution Plan + +Hook executor wie, które moduły uruchomić, bo **szuka endpointu w `host_execution_plan`** — to jest literalne wyszukiwanie w mapie (`hooks/plan.go`): + +```go +cfg.Endpoints[endpoint].Stages[stage].Groups +``` + +Jeśli dany endpoint **nie jest kluczem** w mapie `endpoints` — **żadne hooki nie zostaną odpalone**, nawet jeśli moduł implementuje interfejs. Struktura konfiguracji (`config/hooks.go`): + +```go +type HookExecutionPlan struct { + Endpoints map[string]struct { // klucz = "/openrtb2/auction", "/openrtb2/amp", itp. + Stages map[string]struct { // klucz = "entrypoint", "raw_bidder_response", itp. + Groups []HookExecutionGroup + } + } +} +``` + +### Co to oznacza dla nowego endpointu `/ctv/vast` + +Nowy endpoint GET musi zrobić **dokładnie to samo co AMP**: + +**1. Zdefiniować stałą endpointu** w `hooks/hookexecution/executor.go`: + +```go +const EndpointCtvVast = "/ctv/vast" +``` + +**2. Stworzyć hook executor** w handlerze: + +```go +hookExecutor := hookexecution.NewHookExecutor(planBuilder, EndpointCtvVast, metrics) +``` + +**3. Wywołać etapy hooków** w odpowiednich momentach: + +```go +// Na początku +hookExecutor.ExecuteEntrypointStage(r, nil) + +// Przekazać executor do exchange +exchange.HoldAuction(ctx, AuctionRequest{HookExecutor: hookExecutor, ...}) +// ↑ wewnątrz exchange automatycznie odpalą się: +// ProcessedAuctionRequest, BidderRequest, RawBidderResponse, AllProcessedBidResponses + +// Po aukcji +hookExecutor.ExecuteAuctionResponseStage(response) +hookExecutor.ExecuteExitpointStage(vastXML, w) +``` + +**4. Dodać endpoint do execution plan** w `pbs.json`: + +```json +"host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { ... }, + "/ctv/vast": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } +} +``` + +### Podsumowanie mechanizmu + +| Element | Rola | +|---------|------| +| `hookexecution.NewHookExecutor(planBuilder, endpoint, metrics)` | Tworzy executor powiązany z endpointem | +| `planBuilder.PlanForXxxStage(endpoint)` | Szuka hooków w execution plan dla danego endpointu | +| `host_execution_plan.endpoints["/ctv/vast"]` | **Konfiguracja decyduje**, które moduły się odpalą | +| `exchange.HoldAuction(req{HookExecutor})` | Wewnętrznie odpala BidderRequest, RawBidderResponse itd. | + +Moduł `ctv_vast_enrichment` na nowym endpoincie GET zadziała **automatycznie** — o ile: +- handler tworzy `hookExecutor` i przekazuje go do `exchange.HoldAuction` +- endpoint jest dodany do `host_execution_plan` w konfiguracji + +**Nie trzeba żadnego nowego interfejsu do samego uruchomienia hooków** — cały mechanizm już istnieje. Jedyne co trzeba zrobić to podłączyć handler do routera i dać mu dostęp do `exchange` + `planBuilder`. + +### Zaktualizowana ścieżka implementacji + +``` +1. hooks/hookexecution/executor.go ← nowa stała EndpointCtvVast +2. endpoints/ctv_vast.go ← nowy handler z hookExecutor +3. router/router.go ← r.GET("/ctv/vast", ...) +4. handler.go ← implementacja buildBidRequest() +5. pbs.json ← dodanie /ctv/vast do execution plan +6. handler_test.go ← testy +``` diff --git a/modules/prebid/ctv_vast_enrichment/endpointeng.md b/modules/prebid/ctv_vast_enrichment/endpointeng.md new file mode 100644 index 00000000000..a0d3729d2d1 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/endpointeng.md @@ -0,0 +1,370 @@ +# Proposal: GET Endpoint `/ctv/vast` for the CTV VAST Enrichment Module + +## Current State + +The `ctv_vast_enrichment` module currently works exclusively as a **PBS hook** at the `RawBidderResponse` stage — it enriches VAST XML in bidder responses during a standard POST `/openrtb2/auction` auction. + +A `handler.go` file already exists with a prepared `Handler struct` implementing `http.Handler` (via the `ServeHTTP` method), but **there is no mechanism to register** this handler in the PBS router. The handler is "orphaned" — it is not attached to any HTTP route. + +### Core Problem + +The PBS module system (`modules.NewBuilder().Build()`) only returns a `hooks.HookRepository` — a hook repository. **Modules have no access to the HTTP router** and cannot register endpoints on their own. All HTTP routes are hardcoded in `router/router.go` within the `New()` function. + +## Proposed Approaches + +### Option A: Register endpoint directly in the router (recommended) + +Same approach used by existing endpoints (`/openrtb2/amp`, `/openrtb2/video`, `/event`, etc.) — the endpoint is created in `router/router.go` and registered manually. + +#### Implementation Steps + +**1. New `ModuleEndpointProvider` interface (optional, but cleaner)** + +To avoid hardcoding everything in the router, the module can expose an interface that declares its endpoints: + +```go +// modules/moduledeps/endpoint.go +package moduledeps + +import "net/http" + +// ModuleEndpoint describes an HTTP endpoint provided by a module. +type ModuleEndpoint struct { + Method string // "GET", "POST" + Path string // e.g. "/ctv/vast" + Handler http.Handler +} + +// EndpointProvider is an optional interface that a module can implement +// to register its own HTTP endpoints. +type EndpointProvider interface { + Endpoints() []ModuleEndpoint +} +``` + +**2. Implement the interface in the module** + +```go +// modules/prebid/ctv_vast_enrichment/module.go + +func (m Module) Endpoints() []moduledeps.ModuleEndpoint { + handler := NewHandler(). + WithConfig(configToReceiverConfig(MergeCTVVastConfig(&m.hostConfig, nil, nil))) + // Selector, Enricher, Formatter will be injected by the router + // or wired with default implementations + + return []moduledeps.ModuleEndpoint{ + { + Method: "GET", + Path: "/ctv/vast", + Handler: handler, + }, + } +} +``` + +**3. Registration in the router** + +Extend `router/router.go` — after `Build()` of modules, check if modules implement `EndpointProvider`: + +```go +// router/router.go in the New() function, after modules.NewBuilder().Build() + +// Register module endpoints +for id, module := range builtModules { + if ep, ok := module.(moduledeps.EndpointProvider); ok { + for _, endpoint := range ep.Endpoints() { + logger.Infof("Registering module endpoint: %s %s (module: %s)", endpoint.Method, endpoint.Path, id) + r.Handler(endpoint.Method, endpoint.Path, endpoint.Handler) + } + } +} +``` + +**4. Inject AuctionFunc** + +The handler needs `AuctionFunc` to invoke auctions. This is the most critical element — a reference to the exchange must be passed: + +```go +handler.WithAuctionFunc(func(ctx context.Context, req *openrtb2.BidRequest) (*openrtb2.BidResponse, error) { + // Use theExchange.HoldAuction() or a dedicated method + return theExchange.RunSimpleAuction(ctx, req) +}) +``` + +#### Flow Diagram (Option A) + +``` +GET /ctv/vast?pod_id=123&duration=30&max_ads=3 + │ + ▼ +┌─────────────────────┐ +│ router/router.go │ ← registration: r.Handler("GET", "/ctv/vast", handler) +│ httprouter │ +└─────────┬───────────┘ + ▼ +┌─────────────────────┐ +│ Handler.ServeHTTP │ ← handler.go (already exists) +│ │ +│ 1. Parse query │ ← buildBidRequest() - TODO to implement +│ 2. Build BidReq │ +│ 3. AuctionFunc() │ ← injected exchange +│ 4. Pipeline VAST │ ← BuildVastFromBidResponse() - already exists +│ 5. Return XML │ +└─────────────────────┘ +``` + +--- + +### Option B: Endpoint as a separate package in `endpoints/` (simpler, no new interface) + +Without creating a new module interface — simply add the endpoint in `endpoints/` like the others: + +#### Steps + +**1. New file `endpoints/ctv_vast.go`** + +```go +package endpoints + +import ( + "net/http" + + "github.com/julienschmidt/httprouter" + ctv "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" +) + +func NewCTVVastEndpoint(cfg ctv.ReceiverConfig, auctionFn ctv.AuctionFunc) httprouter.Handle { + handler := ctv.NewHandler(). + WithConfig(cfg). + WithSelector(selectpkg.NewDefaultSelector()). + WithEnricher(enrichpkg.NewDefaultEnricher()). + WithFormatter(formatpkg.NewGAMSSUFormatter()). + WithAuctionFunc(auctionFn) + + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + handler.ServeHTTP(w, r) + } +} +``` + +**2. Registration in `router/router.go`** + +```go +// In the New() function, alongside existing endpoints: +if cfg.Hooks.Modules["prebid"]["ctv_vast_enrichment"] != nil { + r.GET("/ctv/vast", endpoints.NewCTVVastEndpoint(ctvConfig, auctionFunc)) +} +``` + +--- + +### Option C: Registration via Exitpoint Hook (no router changes) + +Instead of a new endpoint, the module could "hijack" the existing `/openrtb2/auction` endpoint via the `Exitpoint` hook and change the response format to VAST XML when it detects a special parameter. **Not recommended** — this is a hack, not a clean solution. + +--- + +## Recommendation + +**Option A** (the `EndpointProvider` interface) is the cleanest architecturally: + +| Criterion | Option A | Option B | Option C | +|-----------|----------|----------|----------| +| Architectural cleanliness | ✅ Extensible | ⚠️ Hardcoded | ❌ Hack | +| Ease of implementation | ⚠️ New interface | ✅ Simple | ✅ Simple | +| Reusability | ✅ Other modules benefit too | ❌ One-off | ❌ One-off | +| PBS compatibility | ⚠️ Requires changes in `modules/` | ✅ Existing pattern | ⚠️ Hook misuse | +| Minimum core changes | ❌ 3 core files | ✅ 2 core files | ✅ 0 core files | + +**For a quick MVP: Option B** — fewest changes, follows existing PBS patterns. + +**For long-term architecture: Option A** — creates a reusable mechanism for future modules. + +--- + +## Work Required Regardless of Approach + +No matter which option is chosen, `handler.go` needs implementation: + +### 1. Query parameter parsing (`buildBidRequest`) + +```go +func (h *Handler) buildBidRequest(r *http.Request) (*openrtb2.BidRequest, error) { + q := r.URL.Query() + + podID := q.Get("pod_id") // required + duration := q.Get("duration") // max duration in seconds + maxAds := q.Get("max_ads") // max ads in pod + publisherID := q.Get("pub_id") // publisher ID + siteURL := q.Get("url") // site URL + // ... additional parameters +} +``` + +### 2. Inject Selector/Enricher/Formatter + +The handler needs concrete implementations of the interfaces. Decisions to make: +- Create them in the module Builder? +- Create them in the endpoint within the router? + +### 3. Inject AuctionFunc + +The most critical dependency — the handler must be able to invoke a PBS auction. Requires access to `exchange.Exchange`. + +### 4. Tests + +- Unit test `handler_test.go` with mocked `AuctionFunc` +- Integration test with full pipeline (Postman collection partially exists) + +--- + +## Implementation Path Summary (Option B — MVP) + +``` +1. endpoints/ctv_vast.go ← new file: endpoint wrapper +2. router/router.go ← add r.GET("/ctv/vast", ...) +3. handler.go ← implement buildBidRequest() +4. handler_test.go ← tests +5. config (optional) ← feature flag in pbs.json +``` + +Total: ~5 files to change/create. + +--- + +## How Modules Are Triggered by a GET Endpoint — Hook Executor Mechanism + +Modules are **not triggered "by the endpoint" directly** — they are triggered by the **hook executor**, which the endpoint creates and invokes at the appropriate stages. + +### Call Chain (AMP Example) + +``` +GET /openrtb2/amp?tag_id=xyz + │ + ▼ +router.go: r.GET("/openrtb2/amp", ampEndpoint) + │ + ▼ +AmpAuction handler creates hook executor: + hookExecutor := hookexecution.NewHookExecutor(planBuilder, "/openrtb2/amp", metrics) + │ + ├─► hookExecutor.ExecuteEntrypointStage(r, nil) ← modules get raw *http.Request + │ + ├─► exchange.HoldAuction(ctx, AuctionRequest{HookExecutor: hookExecutor, ...}) + │ inside exchange: + │ ├─► ExecuteProcessedAuctionStage() + │ ├─► ExecuteBidderRequestStage() ← per bidder + │ ├─► ExecuteRawBidderResponseStage() ← ctv_vast_enrichment fires HERE! + │ └─► ExecuteAllProcessedBidResponsesStage() + │ + ├─► hookExecutor.ExecuteAuctionResponseStage(response) + └─► hookExecutor.ExecuteExitpointStage(ampResponse, w) +``` + +AMP runs **7 of 8 stages** — all except `RawAuctionRequest` (because GET has no JSON body). + +### Key Mechanism: Execution Plan + +The hook executor knows which modules to run because it **looks up the endpoint in the `host_execution_plan`** — a literal map lookup (`hooks/plan.go`): + +```go +cfg.Endpoints[endpoint].Stages[stage].Groups +``` + +If the endpoint **is not a key** in the `endpoints` map — **no hooks will fire**, even if the module implements the interface. Configuration structure (`config/hooks.go`): + +```go +type HookExecutionPlan struct { + Endpoints map[string]struct { // key = "/openrtb2/auction", "/openrtb2/amp", etc. + Stages map[string]struct { // key = "entrypoint", "raw_bidder_response", etc. + Groups []HookExecutionGroup + } + } +} +``` + +### What This Means for a New `/ctv/vast` Endpoint + +The new GET endpoint must do **exactly what AMP does**: + +**1. Define an endpoint constant** in `hooks/hookexecution/executor.go`: + +```go +const EndpointCtvVast = "/ctv/vast" +``` + +**2. Create a hook executor** in the handler: + +```go +hookExecutor := hookexecution.NewHookExecutor(planBuilder, EndpointCtvVast, metrics) +``` + +**3. Call hook stages** at the appropriate points: + +```go +// At the start +hookExecutor.ExecuteEntrypointStage(r, nil) + +// Pass executor to exchange +exchange.HoldAuction(ctx, AuctionRequest{HookExecutor: hookExecutor, ...}) +// ↑ inside exchange, these fire automatically: +// ProcessedAuctionRequest, BidderRequest, RawBidderResponse, AllProcessedBidResponses + +// After auction +hookExecutor.ExecuteAuctionResponseStage(response) +hookExecutor.ExecuteExitpointStage(vastXML, w) +``` + +**4. Add the endpoint to the execution plan** in `pbs.json`: + +```json +"host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { ... }, + "/ctv/vast": { + "stages": { + "raw_bidder_response": { + "groups": [ + { + "timeout": 1000, + "hook_sequence": [ + { + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + } + ] + } + ] + } + } + } + } +} +``` + +### Mechanism Summary + +| Element | Role | +|---------|------| +| `hookexecution.NewHookExecutor(planBuilder, endpoint, metrics)` | Creates executor bound to the endpoint | +| `planBuilder.PlanForXxxStage(endpoint)` | Looks up hooks in execution plan for the given endpoint | +| `host_execution_plan.endpoints["/ctv/vast"]` | **Configuration decides** which modules fire | +| `exchange.HoldAuction(req{HookExecutor})` | Internally fires BidderRequest, RawBidderResponse, etc. | + +The `ctv_vast_enrichment` module will work **automatically** on the new GET endpoint — as long as: +- the handler creates a `hookExecutor` and passes it to `exchange.HoldAuction` +- the endpoint is added to the `host_execution_plan` in configuration + +**No new interface is needed to trigger hooks** — the entire mechanism already exists. All that's needed is to wire the handler into the router and give it access to `exchange` + `planBuilder`. + +### Updated Implementation Path + +``` +1. hooks/hookexecution/executor.go ← new EndpointCtvVast constant +2. endpoints/ctv_vast.go ← new handler with hookExecutor +3. router/router.go ← r.GET("/ctv/vast", ...) +4. handler.go ← implement buildBidRequest() +5. pbs.json ← add /ctv/vast to execution plan +6. handler_test.go ← tests +``` diff --git a/modules/prebid/ctv_vast_enrichment/enrich/enrich.go b/modules/prebid/ctv_vast_enrichment/enrich/enrich.go new file mode 100644 index 00000000000..adf5a409a14 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/enrich/enrich.go @@ -0,0 +1,285 @@ +// Package enrich provides VAST ad enrichment capabilities. +package enrich + +import ( + "fmt" + "strings" + + vast "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v4/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, cfg.CollisionPolicy) + 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 according to the collision policy. +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: there is an existing Pricing value in the VAST + if inline.Pricing != nil && inline.Pricing.Value != "" { + overwrite, w := resolveCollision(cfg.CollisionPolicy, "pricing") + if w != "" { + warnings = append(warnings, w) + } + if !overwrite { + return warnings + } + } + + // Format the price value + priceStr := model.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 according to the collision policy. +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: there is an existing Advertiser value in the VAST + if strings.TrimSpace(inline.Advertiser) != "" { + overwrite, w := resolveCollision(cfg.CollisionPolicy, "advertiser") + if w != "" { + warnings = append(warnings, w) + } + if !overwrite { + 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 according to the collision policy. +func (e *VastEnricher) enrichDuration(inline *model.InLine, meta vast.CanonicalMeta, policy vast.CollisionPolicy) []string { + var warnings []string + + // Skip if no duration to add + 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: there is an existing Duration value in the VAST + if strings.TrimSpace(creative.Linear.Duration) != "" { + overwrite, w := resolveCollision(policy, "duration") + if w != "" { + warnings = append(warnings, w) + } + if !overwrite { + 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", model.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) +} + +// resolveCollision determines how to handle a field that already exists in the VAST. +// Returns overwrite=true when the enricher should replace the existing value, +// and a non-empty warning string when the caller should record the event. +// +// - CollisionIgnore → overwrite silently +// - CollisionWarn → overwrite, emit warning +// - CollisionReject → keep existing, emit warning +// - CollisionVastWins (default) → keep existing, emit warning +func resolveCollision(policy vast.CollisionPolicy, field string) (overwrite bool, warning string) { + switch policy { + case vast.CollisionIgnore: + return true, "" + case vast.CollisionWarn: + return true, field + ": collision warn - overwriting existing VAST value" + case vast.CollisionReject: + return false, field + ": collision reject - keeping existing VAST value" + default: // CollisionVastWins and any unrecognised value + return false, field + ": VAST_WINS - keeping existing VAST value" + } +} + +// escapeXML escapes special characters for XML content. +func escapeXML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + 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..1c602f0e791 --- /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/v4/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v4/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 := model.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/enrich/register.go b/modules/prebid/ctv_vast_enrichment/enrich/register.go new file mode 100644 index 00000000000..865de3ebd9a --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/enrich/register.go @@ -0,0 +1,15 @@ +package enrich + +import ( + vast "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" +) + +func init() { + // Register VastEnricher as the default enricher for the module hook. + // This breaks the potential import cycle: parent cannot import enrich directly, + // so enrich registers itself via EnricherFactory when its package is loaded. + // The modules/builder.go blank-imports this package to trigger the init. + vast.EnricherFactory = func() vast.Enricher { + return NewEnricher() + } +} diff --git a/modules/prebid/ctv_vast_enrichment/format/format.go b/modules/prebid/ctv_vast_enrichment/format/format.go new file mode 100644 index 00000000000..11601e075d1 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/format/format.go @@ -0,0 +1,103 @@ +// Package format provides VAST XML formatting capabilities. +package format + +import ( + vast "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v4/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 = vast.DefaultVastVersion + } + + // 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 + } + + // Deep-copy the ad so enrichment does not mutate the original parsed VAST. + ad := enriched.Ad.DeepCopy() + + // Set Ad.ID from meta (prefer AdID if tracked, else BidID) + ad.ID = deriveAdID(enriched.Meta) + + // 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 using Vast.Marshal() which clears InnerXML on Ad/InLine/Wrapper nodes + // before marshaling. This prevents duplicate content when structured fields + // (e.g. Pricing, Advertiser) were added by the enricher while InnerXML still + // holds the original raw XML from parsing. Consistent with the hook path. + output, err := vastDoc.Marshal() + if err != nil { + return nil, warnings, err + } + + 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 "" +} + +// 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..680aa11fc2b --- /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/v4/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v4/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/model/parser.go b/modules/prebid/ctv_vast_enrichment/model/parser.go new file mode 100644 index 00000000000..2a671432cbe --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/model/parser.go @@ -0,0 +1,159 @@ +package model + +import ( + "encoding/xml" + "errors" + "strconv" + "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 " + + + + 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..173d3ec8f5d --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/model/vast_xml.go @@ -0,0 +1,472 @@ +package model + +import ( + "encoding/xml" + "fmt" + "strings" +) + +// 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) +} + +// FormatPrice formats a bid price with up to 4 decimal places, trimming +// trailing zeros. Used by all enrichers to ensure consistent price +// representation in VAST XML (e.g. 5.5 not 5.5000, 1.25 not 1.2500). +func FormatPrice(price float64) string { + s := fmt.Sprintf("%.4f", price) + s = strings.TrimRight(s, "0") + s = strings.TrimRight(s, ".") + if s == "" { + return "0" + } + return s +} + +// BuildNoAdVast creates a VAST response indicating no ad is available. +// This is a valid VAST document with no Ad elements. +func BuildNoAdVast(version string) []byte { + 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 +} + +// DeepCopy returns a fully independent copy of the Ad and all its nested +// pointer fields. Modifying any field in the returned Ad (including InLine, +// Creatives, Pricing, Extensions, etc.) will not affect the original. +func (a *Ad) DeepCopy() *Ad { + if a == nil { + return nil + } + copy := *a + copy.InLine = deepCopyInLine(a.InLine) + copy.Wrapper = deepCopyWrapper(a.Wrapper) + return © +} + +// deepCopyInLine returns a deep copy of an InLine element. +func deepCopyInLine(src *InLine) *InLine { + if src == nil { + return nil + } + c := *src + c.AdSystem = deepCopyAdSystem(src.AdSystem) + if src.Impressions != nil { + c.Impressions = make([]Impression, len(src.Impressions)) + copy(c.Impressions, src.Impressions) + } + c.Pricing = deepCopyPricing(src.Pricing) + c.Creatives = deepCopyCreatives(src.Creatives) + c.Extensions = deepCopyExtensions(src.Extensions) + return &c +} + +// deepCopyWrapper returns a deep copy of a Wrapper element. +func deepCopyWrapper(src *Wrapper) *Wrapper { + if src == nil { + return nil + } + c := *src + c.AdSystem = deepCopyAdSystem(src.AdSystem) + if src.Impressions != nil { + c.Impressions = make([]Impression, len(src.Impressions)) + copy(c.Impressions, src.Impressions) + } + c.Creatives = deepCopyCreatives(src.Creatives) + c.Extensions = deepCopyExtensions(src.Extensions) + return &c +} + +func deepCopyAdSystem(src *AdSystem) *AdSystem { + if src == nil { + return nil + } + c := *src + return &c +} + +func deepCopyPricing(src *Pricing) *Pricing { + if src == nil { + return nil + } + c := *src + return &c +} + +func deepCopyCreatives(src *Creatives) *Creatives { + if src == nil { + return nil + } + c := Creatives{} + if src.Creative != nil { + c.Creative = make([]Creative, len(src.Creative)) + for i, cr := range src.Creative { + cc := cr + if cr.UniversalAdID != nil { + uaid := *cr.UniversalAdID + cc.UniversalAdID = &uaid + } + cc.Linear = deepCopyLinear(cr.Linear) + c.Creative[i] = cc + } + } + return &c +} + +func deepCopyLinear(src *Linear) *Linear { + if src == nil { + return nil + } + c := *src + c.MediaFiles = deepCopyMediaFiles(src.MediaFiles) + c.VideoClicks = deepCopyVideoClicks(src.VideoClicks) + c.TrackingEvents = deepCopyTrackingEvents(src.TrackingEvents) + if src.AdParameters != nil { + ap := *src.AdParameters + c.AdParameters = &ap + } + return &c +} + +func deepCopyMediaFiles(src *MediaFiles) *MediaFiles { + if src == nil { + return nil + } + c := MediaFiles{} + if src.MediaFile != nil { + c.MediaFile = make([]MediaFile, len(src.MediaFile)) + copy(c.MediaFile, src.MediaFile) + } + return &c +} + +func deepCopyVideoClicks(src *VideoClicks) *VideoClicks { + if src == nil { + return nil + } + c := VideoClicks{} + if src.ClickThrough != nil { + ct := *src.ClickThrough + c.ClickThrough = &ct + } + if src.ClickTracking != nil { + c.ClickTracking = make([]ClickTracking, len(src.ClickTracking)) + copy(c.ClickTracking, src.ClickTracking) + } + if src.CustomClick != nil { + c.CustomClick = make([]CustomClick, len(src.CustomClick)) + copy(c.CustomClick, src.CustomClick) + } + return &c +} + +func deepCopyTrackingEvents(src *TrackingEvents) *TrackingEvents { + if src == nil { + return nil + } + c := TrackingEvents{} + if src.Tracking != nil { + c.Tracking = make([]Tracking, len(src.Tracking)) + copy(c.Tracking, src.Tracking) + } + return &c +} + +func deepCopyExtensions(src *Extensions) *Extensions { + if src == nil { + return nil + } + c := Extensions{} + if src.Extension != nil { + c.Extension = make([]ExtensionXML, len(src.Extension)) + copy(c.Extension, src.Extension) + } + return &c +} + +// Marshal serializes the Vast struct to XML bytes with XML header. +func (v *Vast) Marshal() ([]byte, error) { + // Clear InnerXML fields to prevent duplicate content + 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 InnerXML only on nodes that are directly modified by enrichment. +// Nodes higher in the tree (Ad, InLine, Wrapper) get their InnerXML cleared so structured +// fields (Pricing, Advertiser, etc.) are serialized without duplication. +// Creative, Linear, MediaFiles, TrackingEvents and other leaf nodes are preserved — +// this keeps DSP-specific extensions and unknown elements intact (BUG 4 fix). +func (v *Vast) clearInnerXML() { + for i := range v.Ads { + v.Ads[i].InnerXML = "" + if v.Ads[i].InLine != nil { + v.Ads[i].InLine.InnerXML = "" + // Creative.InnerXML and Linear.InnerXML are intentionally preserved + // to keep MediaFiles, TrackingEvents, and DSP extensions intact. + } + if v.Ads[i].Wrapper != nil { + v.Ads[i].Wrapper.InnerXML = "" + } + } +} + +// 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..599b0ddbf36 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/module.go @@ -0,0 +1,255 @@ +package ctv_vast_enrichment + +import ( + "context" + "encoding/json" + "strings" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/adapters" + "github.com/prebid/prebid-server/v4/hooks/hookstage" + "github.com/prebid/prebid-server/v4/modules/moduledeps" + "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/model" + "github.com/prebid/prebid-server/v4/openrtb_ext" +) + +// Builder creates a new CTV VAST enrichment module instance. +// 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, + enricher: EnricherFactory(), + }, 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 + // enricher is the VAST enricher used by the hook. It is set in Builder via + // the EnricherFactory variable to avoid an import cycle with the enrich subpackage. + enricher Enricher +} + +// EnricherFactory is a package-level variable that provides the default Enricher +// implementation. It is overridden by the enrich subpackage via init() to avoid +// an import cycle (parent → enrich → parent). +// NOTE: Architectural TODO — move shared types (CanonicalMeta, ReceiverConfig) to a +// types subpackage so parent and enrich can both import it without a cycle. +var EnricherFactory func() Enricher = func() Enricher { + return &hookEnricher{} +} + +// HandleRawBidderResponseHook processes bidder responses to enrich VAST XML. +// 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; nil is treated as disabled (explicit opt-in required) + if !mergedCfg.IsEnabled() { + return result, nil + } + + // No bids to process + if payload.BidderResponse == nil || len(payload.BidderResponse.Bids) == 0 { + return result, nil + } + + // Convert config to ReceiverConfig (canonical method in config.go) + receiverCfg := mergedCfg.ReceiverConfig() + + // Use injected enricher (set via EnricherFactory in Builder). + // Fall back to hookEnricher for tests that construct Module{} directly. + enricher := m.enricher + if enricher == nil { + enricher = &hookEnricher{} + } + + // modifiedBids is allocated lazily — only when the first enrichment actually happens. + // Until then we track the index so we can back-fill originals if needed. + var modifiedBids []*adapters.TypedBid + + for i, typedBid := range payload.BidderResponse.Bids { + if typedBid == nil || typedBid.Bid == nil { + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } + continue + } + + bid := typedBid.Bid + + // Skip non-video bids — explicit type check prevents banner/native from being parsed as VAST + if typedBid.BidType != openrtb_ext.BidTypeVideo || bid.AdM == "" { + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } + continue + } + + // Try to parse as VAST + vastDoc, err := model.ParseVastAdm(bid.AdM) + if err != nil { + // Not valid VAST, skip enrichment + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } + continue + } + + // Resolve the actual DSP currency — BidderResponse.Currency is the authoritative source + bidCurrency := payload.BidderResponse.Currency + if bidCurrency == "" { + bidCurrency = receiverCfg.DefaultCurrency + } + if bidCurrency == "" { + bidCurrency = "USD" + } + + // Build bid context for enrichment + bidContext := CanonicalMeta{ + BidID: bid.ID, + Price: bid.Price, + Currency: bidCurrency, + Adomain: primaryDomain(bid.ADomain), + Cats: bid.Cat, + Seat: payload.Bidder, + } + + // Extract the first Ad element and delegate enrichment to the injected Enricher. + // The enricher (set via EnricherFactory) handles Duration, Categories, + // AdvertiserPlacement, PricingPlacement, and debug extensions — BUG 3 fix. + ad := model.ExtractFirstAd(vastDoc) + if ad == nil { + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } + continue + } + + if _, enrichErr := enricher.Enrich(ad, bidContext, receiverCfg); enrichErr != nil { + // Enrichment failed — keep original bid + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } + continue + } + + // Format back to XML + xmlBytes, err := vastDoc.Marshal() + if err != nil { + // Keep original bid on format error + if modifiedBids != nil { + modifiedBids = append(modifiedBids, typedBid) + } + continue + } + + // First enrichment: lazily allocate and back-fill all preceding original bids + if modifiedBids == nil { + modifiedBids = make([]*adapters.TypedBid, i, len(payload.BidderResponse.Bids)) + copy(modifiedBids, payload.BidderResponse.Bids[:i]) + } + + // Create new bid with enriched VAST + enrichedBid := &openrtb2.Bid{} + *enrichedBid = *bid + enrichedBid.AdM = string(xmlBytes) + + // Create new TypedBid with enriched bid — preserve BidMeta for analytics/targeting + enrichedTypedBid := &adapters.TypedBid{ + Bid: enrichedBid, + BidType: typedBid.BidType, + BidVideo: typedBid.BidVideo, + BidMeta: typedBid.BidMeta, + DealPriority: typedBid.DealPriority, + Seat: typedBid.Seat, + } + modifiedBids = append(modifiedBids, enrichedTypedBid) + } + + // If we made changes, set mutation via ChangeSet + if modifiedBids != nil { + changeSet := hookstage.ChangeSet[hookstage.RawBidderResponsePayload]{} + changeSet.RawBidderResponse().Bids().UpdateBids(modifiedBids) + result.ChangeSet = changeSet + } + + return result, nil +} + +// primaryDomain returns the first domain from a slice, or empty string if none. +// VAST is a single human-readable string — joining multiple domains is non-standard. +func primaryDomain(domains []string) string { + if len(domains) == 0 { + return "" + } + return domains[0] +} + +// hookEnricher is a minimal fallback enricher used when EnricherFactory is not overridden. +// In production the enrich subpackage registers its VastEnricher via init(). +// This fallback handles only Pricing and Advertiser to remain backward-compatible. +type hookEnricher struct{} + +func (h *hookEnricher) Enrich(ad *model.Ad, meta CanonicalMeta, cfg ReceiverConfig) ([]string, error) { + if ad == nil || ad.InLine == nil { + return nil, nil + } + inline := ad.InLine + + // Pricing + if inline.Pricing == nil && meta.Price > 0 { + currency := meta.Currency + if currency == "" { + currency = cfg.DefaultCurrency + } + if currency == "" { + currency = "USD" + } + inline.Pricing = &model.Pricing{ + Value: model.FormatPrice(meta.Price), + Model: "CPM", + Currency: currency, + } + } + + // Advertiser + if strings.TrimSpace(inline.Advertiser) == "" && meta.Adomain != "" { + inline.Advertiser = meta.Adomain + } + + return nil, nil +} diff --git a/modules/prebid/ctv_vast_enrichment/module_e2e_test.go b/modules/prebid/ctv_vast_enrichment/module_e2e_test.go new file mode 100644 index 00000000000..76803ea21e9 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/module_e2e_test.go @@ -0,0 +1,886 @@ +package ctv_vast_enrichment_test + +// End-to-end test suite for the ctv_vast_enrichment module. +// +// These tests exercise the full hook path — from HandleRawBidderResponseHook through +// config merging, enrichVastDocument, and model.Marshal — using real sub-package +// implementations (enrich.NewEnricher, format.NewFormatter, select.NewSelector) +// rather than mocks, so regressions in integration points are caught. +// +// Test matrix: +// A. Hook path correctness +// A1 Video bid enriched — and injected +// A2 Banner bid passes through untouched (BidType guard) +// A3 Native bid passes through untouched (BidType guard) +// A4 BidMeta preserved on enriched TypedBid +// A5 BidderResponse.Currency used in , not DefaultCurrency from config +// A6 Fallback to DefaultCurrency when BidderResponse.Currency is empty +// A7 VAST_WINS: existing not overwritten +// A8 Unknown/DSP-specific VAST extensions preserved after marshal +// A9 Mixed bid types — only video bids modified +// +// B. Config correctness +// B1 VAST_WINS CollisionPolicy round-trips through account config +// B2 Account config overrides host config +// +// C. Pipeline end-to-end (BuildVastFromBidResponse with real components) +// C1 Single video bid → enriched VAST with and +// C2 Ad pod — multiple bids → correct sequence attributes +// C3 No bids → NoAd VAST returned +// C4 All-invalid VAST, skeleton disabled → NoAd +// C5 All-invalid VAST, skeleton enabled → VAST with warnings +// C6 Duration from bid meta injected into +// C7 IAB categories injected as extension +// C8 Debug extension enabled → / present in output +// +// D. Regression — previously reported bugs (see CTV.md / ctv-bugs-and-resolve.md) +// D1 Non-USD DSP currency preserved in (BUG 1) +// D2 VAST_WINS policy not silently converted to Reject (BUG 2) +// D3 Hook uses enrich subpackage — debug extension added when debug=true (BUG 3) +// D4 clearInnerXML does not drop content (BUG 4) +// D5 BidMeta fields (NetworkID, AdvertiserID, BrandID) survive hook (BUG 6) +// D6 Only first ADomain used in , not comma-joined list (BUG 7) + +import ( + "context" + "encoding/json" + "encoding/xml" + "strings" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/adapters" + "github.com/prebid/prebid-server/v4/hooks/hookstage" + "github.com/prebid/prebid-server/v4/modules/moduledeps" + ctv "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment" + "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/enrich" + "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/format" + bidselect "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/select" + "github.com/prebid/prebid-server/v4/openrtb_ext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Shared VAST fixtures +// --------------------------------------------------------------------------- + +const ( + // minimalVAST is a well-formed VAST 3.0 with one InLine ad containing + // a video MediaFile. Used as a baseline "clean" input for most tests. + minimalVAST = `` + + `TestTest Ad` + + `` + + `00:00:30` + + `` + + `` + + `` + + `` + + // vastWithPricing already contains 3.00. + // Used to verify VAST_WINS collision policy. + vastWithPricing = `` + + `TestTest Ad` + + `3.00` + + `` + + `` + + // vastWithExtensions contains a DSP-specific . + // Used to verify clearInnerXML does not drop unknown elements. + vastWithExtensions = `` + + `TestTest Ad` + + `` + + `00:00:15` + + `` + + `` + + `` + + `` + + `https://tracker.dsp.example/ping` + + `` + + `` +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// hookHandler is the subset of the Module interface we need. +type hookHandler interface { + HandleRawBidderResponseHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.RawBidderResponsePayload, + ) (hookstage.HookResult[hookstage.RawBidderResponsePayload], error) +} + +// buildModule creates a Module via the public Builder with given JSON host config. +func buildModule(t *testing.T, hostCfgJSON string) hookHandler { + t.Helper() + m, err := ctv.Builder(json.RawMessage(hostCfgJSON), moduledeps.ModuleDeps{}) + require.NoError(t, err) + h, ok := m.(hookHandler) + require.True(t, ok, "Builder result must implement HandleRawBidderResponseHook") + return h +} + +// applyMutations replays all ChangeSet mutations onto payload and returns the result. +func applyMutations( + t *testing.T, + result hookstage.HookResult[hookstage.RawBidderResponsePayload], + payload hookstage.RawBidderResponsePayload, +) hookstage.RawBidderResponsePayload { + t.Helper() + for _, mut := range result.ChangeSet.Mutations() { + var err error + payload, err = mut.Apply(payload) + require.NoError(t, err) + } + return payload +} + +// enabledCtx returns a minimal account config that enables the module. +func enabledCtx() hookstage.ModuleInvocationContext { + return hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled":true}`), + } +} + +// videoTypedBid wraps a Bid in a TypedBid with BidTypeVideo. +func videoTypedBid(bid *openrtb2.Bid) *adapters.TypedBid { + return &adapters.TypedBid{Bid: bid, BidType: openrtb_ext.BidTypeVideo} +} + +// newRealComponents returns real (non-mock) selector/enricher/formatter. +func newRealComponents(strategy ctv.SelectionStrategy) (ctv.BidSelector, ctv.Enricher, ctv.Formatter) { + return bidselect.NewSelector(strategy), enrich.NewEnricher(), format.NewFormatter() +} + +// --------------------------------------------------------------------------- +// A. Hook path correctness +// --------------------------------------------------------------------------- + +// A1 — video bid is enriched; and injected. +func TestE2E_A1_VideoBidEnriched(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "rubicon", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ + ID: "b1", + Price: 2.50, + ADomain: []string{"brand.com"}, + AdM: minimalVAST, + }), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, " to be injected") + assert.Contains(t, adm, "2.5", "expected price value in VAST") + assert.Contains(t, adm, `currency="USD"`) + assert.Contains(t, adm, "brand.com", "expected with domain") +} + +// A2 — banner bid must pass through unchanged. +func TestE2E_A2_BannerBidNotTouched(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + bannerAdM := `banner content` + payload := hookstage.RawBidderResponsePayload{ + Bidder: "appnexus", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{{ + Bid: &openrtb2.Bid{ID: "b1", Price: 1.0, AdM: bannerAdM}, + BidType: openrtb_ext.BidTypeBanner, + }}, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + assert.Equal(t, bannerAdM, payload.BidderResponse.Bids[0].Bid.AdM, + "banner AdM must not be modified") +} + +// A3 — native bid must pass through unchanged. +func TestE2E_A3_NativeBidNotTouched(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + nativeAdM := `{"ver":"1.1","assets":[]}` + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{{ + Bid: &openrtb2.Bid{ID: "b1", Price: 0.5, AdM: nativeAdM}, + BidType: openrtb_ext.BidTypeNative, + }}, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + assert.Equal(t, nativeAdM, payload.BidderResponse.Bids[0].Bid.AdM, + "native AdM must not be modified") +} + +// A4 — BidMeta must be preserved on the enriched TypedBid. +func TestE2E_A4_BidMetaPreserved(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{{ + Bid: &openrtb2.Bid{ID: "b1", Price: 1.0, AdM: minimalVAST}, + BidType: openrtb_ext.BidTypeVideo, + BidMeta: &openrtb_ext.ExtBidPrebidMeta{ + NetworkID: 42, + AdvertiserID: 99, + BrandID: 7, + }, + }}, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + meta := payload.BidderResponse.Bids[0].BidMeta + require.NotNil(t, meta, "BidMeta must not be nil after hook") + assert.Equal(t, 42, meta.NetworkID) + assert.Equal(t, 99, meta.AdvertiserID) + assert.Equal(t, 7, meta.BrandID) +} + +// A5 — BidderResponse.Currency must be used in , not DefaultCurrency from host config. +func TestE2E_A5_BidderResponseCurrencyUsedInPricing(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) // host says USD + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder_eur", + BidderResponse: &adapters.BidderResponse{ + Currency: "EUR", // DSP responds in EUR + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 1.80, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, `currency="EUR"`, "EUR from BidderResponse must appear in ") + assert.NotContains(t, adm, `currency="USD"`, "USD from host config must NOT override DSP currency") +} + +// A6 — fallback to DefaultCurrency when BidderResponse.Currency is empty. +func TestE2E_A6_FallbackToDefaultCurrencyWhenBidderCurrencyEmpty(t *testing.T) { + module := buildModule(t, `{"default_currency":"GBP"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "", // DSP omits currency + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 3.00, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, `currency="GBP"`, "should fallback to host DefaultCurrency GBP") +} + +// A7 — VAST_WINS: existing in VAST must not be overwritten. +func TestE2E_A7_VastWinsPreservesExistingPricing(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD","collision_policy":"VAST_WINS"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ + ID: "b1", + Price: 9.99, // bidder price + AdM: vastWithPricing, // already has GBP 3.00 + }), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "GBP", "original currency GBP must be preserved") + assert.Contains(t, adm, "3.00", "original price 3.00 must be preserved") + assert.NotContains(t, adm, "9.99", "bidder price must NOT overwrite existing VAST pricing") +} + +// A8 — DSP-specific VAST extensions must survive the marshal round-trip. +func TestE2E_A8_UnknownVastExtensionsPreserved(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 2.00, AdM: vastWithExtensions}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "dsp_custom", "DSP extension type must survive marshal") + assert.Contains(t, adm, "https://tracker.dsp.example/ping", "DSP tracker URL must survive marshal") +} + +// A9 — mixed bid types: only video bids enriched, others unchanged. +func TestE2E_A9_MixedBidTypesOnlyVideoEnriched(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + bannerAdM := `banner` + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ID: "banner-bid", Price: 1.0, AdM: bannerAdM}, + BidType: openrtb_ext.BidTypeBanner, + }, + videoTypedBid(&openrtb2.Bid{ID: "video-bid", Price: 2.5, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + assert.Equal(t, bannerAdM, payload.BidderResponse.Bids[0].Bid.AdM, + "banner bid must not be modified") + assert.Contains(t, payload.BidderResponse.Bids[1].Bid.AdM, " in VAST is preserved when VAST_WINS is set. +func TestE2E_B1_VastWinsCollisionPolicyRoundTrip(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) // host has no collision_policy + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 9.99, AdM: vastWithPricing}), + }, + }, + } + + // Account overrides collision_policy to VAST_WINS + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled":true,"collision_policy":"VAST_WINS"}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "GBP", "VAST_WINS: original GBP currency must survive") + assert.Contains(t, adm, "3.00", "VAST_WINS: original price must survive") + assert.NotContains(t, adm, "9.99", "VAST_WINS: bidder price must not replace existing pricing") +} + +// B2 — account config overrides host config; host fields not in account are preserved. +func TestE2E_B2_AccountConfigOverridesHost(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD","receiver":"GENERIC"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "", // empty — falls back to config currency + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 1.0, AdM: minimalVAST}), + }, + }, + } + + // Account overrides currency to EUR + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled":true,"default_currency":"EUR"}`), + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, `currency="EUR"`, "account currency EUR must override host USD") +} + +// --------------------------------------------------------------------------- +// C. Pipeline end-to-end (BuildVastFromBidResponse with real components) +// --------------------------------------------------------------------------- + +// C1 — single video bid → enriched VAST with and . +func TestE2E_C1_PipelineSingleBidEnriched(t *testing.T) { + cfg := ctv.DefaultConfig() + cfg.SelectionStrategy = ctv.SelectionSingle + cfg.DefaultCurrency = "USD" + + req := &openrtb2.BidRequest{ID: "req-1"} + resp := &openrtb2.BidResponse{ + ID: "resp-1", + Cur: "USD", + SeatBid: []openrtb2.SeatBid{{ + Seat: "rubicon", + Bid: []openrtb2.Bid{{ + ID: "bid-1", + ImpID: "imp-1", + Price: 5.00, + AdM: minimalVAST, + ADomain: []string{"brand.example.com"}, + }}, + }}, + } + + selector, enricher, formatter := newRealComponents(cfg.SelectionStrategy) + result, err := ctv.BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + require.False(t, result.NoAd) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, ". +func TestE2E_C6_DurationInjectedFromMeta(t *testing.T) { + cfg := ctv.DefaultConfig() + cfg.DefaultCurrency = "USD" + + vastNoDuration := `` + + `TestNo Duration` + + `` + + `` + + `` + + `` + + req := &openrtb2.BidRequest{ID: "req-dur"} + resp := &openrtb2.BidResponse{ + ID: "resp-dur", + SeatBid: []openrtb2.SeatBid{{ + Seat: "bidder1", + Bid: []openrtb2.Bid{{ID: "b1", Price: 2.0, AdM: vastNoDuration}}, + }}, + } + + sel := &durationInjectingSelector{durSec: 45} + result, err := ctv.BuildVastFromBidResponse(context.Background(), req, resp, cfg, sel, enrich.NewEnricher(), format.NewFormatter()) + require.NoError(t, err) + require.False(t, result.NoAd) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, "00:00:45", "duration 45s must appear as 00:00:45") +} + +// C7 — IAB categories injected as VAST extension. +func TestE2E_C7_IABCategoriesInjectedAsExtension(t *testing.T) { + cfg := ctv.DefaultConfig() + cfg.DefaultCurrency = "USD" + + req := &openrtb2.BidRequest{ID: "req-cat"} + resp := &openrtb2.BidResponse{ + ID: "resp-cat", + SeatBid: []openrtb2.SeatBid{{ + Seat: "bidder1", + Bid: []openrtb2.Bid{{ + ID: "b1", + Price: 3.0, + AdM: minimalVAST, + Cat: []string{"IAB1", "IAB2-3"}, + }}, + }}, + } + + selector, enricher, formatter := newRealComponents(ctv.SelectionSingle) + result, err := ctv.BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + require.False(t, result.NoAd) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, "IAB1") + assert.Contains(t, xmlStr, "IAB2-3") + assert.Contains(t, xmlStr, "iab_category") +} + +// C8 — debug extension enabled → and in output. +func TestE2E_C8_DebugExtensionIncludesBidIDAndSeat(t *testing.T) { + cfg := ctv.DefaultConfig() + cfg.DefaultCurrency = "USD" + cfg.Debug = true + + req := &openrtb2.BidRequest{ID: "req-dbg"} + resp := &openrtb2.BidResponse{ + ID: "resp-dbg", + SeatBid: []openrtb2.SeatBid{{ + Seat: "rubicon", + Bid: []openrtb2.Bid{{ID: "debug-bid-123", Price: 4.0, AdM: minimalVAST}}, + }}, + } + + selector, enricher, formatter := newRealComponents(ctv.SelectionSingle) + result, err := ctv.BuildVastFromBidResponse(context.Background(), req, resp, cfg, selector, enricher, formatter) + require.NoError(t, err) + require.False(t, result.NoAd) + + xmlStr := string(result.VastXML) + assert.Contains(t, xmlStr, "debug-bid-123") + assert.Contains(t, xmlStr, "rubicon") +} + +// --------------------------------------------------------------------------- +// D. Regression tests — previously reported bugs +// --------------------------------------------------------------------------- + +// D1 — (BUG 1) Non-USD DSP currency preserved in . +func TestE2E_D1_NonUSDCurrencyPreserved(t *testing.T) { + for _, dspCurrency := range []string{"EUR", "JPY", "BRL", "AUD"} { + t.Run(dspCurrency, func(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder", + BidderResponse: &adapters.BidderResponse{ + Currency: dspCurrency, + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 1.0, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, `currency="`+dspCurrency+`"`, + "DSP currency %s must appear in ", dspCurrency) + assert.NotContains(t, adm, `currency="USD"`, + "host DefaultCurrency USD must NOT override DSP currency %s", dspCurrency) + }) + } +} + +// D2 — (BUG 2) VAST_WINS collision policy must not silently become CollisionReject. +// Observable effect: existing in VAST is preserved, not replaced. +func TestE2E_D2_VastWinsNotSilentlyDropped(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD","collision_policy":"VAST_WINS"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 9.99, AdM: vastWithPricing}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "3.00", "VAST_WINS: original price must not be overwritten") + assert.Contains(t, adm, "GBP", "VAST_WINS: original currency must not be overwritten") + assert.NotContains(t, adm, "9.99", "VAST_WINS: bidder price must not replace existing") +} + +// D3 — (BUG 3) Hook uses enrich subpackage: debug extension added when debug=true via account config. +// This verifies the hook path actually reaches enrich.VastEnricher, not a partial inline impl. +func TestE2E_D3_HookUsesEnrichSubpackage_DebugExtension(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + miCtx := hookstage.ModuleInvocationContext{ + AccountConfig: json.RawMessage(`{"enabled":true,"debug":true}`), + } + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "rubicon", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "hook-debug-bid", Price: 2.0, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), miCtx, payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + // enrich.VastEnricher adds with when debug=true + assert.Contains(t, adm, `type="openrtb"`, + "debug extension must be present — proves hook reached enrich subpackage") + assert.Contains(t, adm, "hook-debug-bid", + "BidID must appear in debug extension") +} + +// D4 — (BUG 4) clearInnerXML must not drop content. +func TestE2E_D4_MediaFilesPreservedAfterMarshal(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ID: "b1", Price: 1.0, AdM: minimalVAST}), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "https://example.com/video.mp4", + "MediaFile URL must survive clearInnerXML + marshal") + assert.Contains(t, adm, ", not all domains comma-joined. +func TestE2E_D6_OnlyFirstADomainUsedInAdvertiser(t *testing.T) { + module := buildModule(t, `{"default_currency":"USD"}`) + + payload := hookstage.RawBidderResponsePayload{ + Bidder: "bidder1", + BidderResponse: &adapters.BidderResponse{ + Currency: "USD", + Bids: []*adapters.TypedBid{ + videoTypedBid(&openrtb2.Bid{ + ID: "b1", + Price: 1.0, + ADomain: []string{"primary.com", "secondary.com", "tertiary.com"}, + AdM: minimalVAST, + }), + }, + }, + } + + result, err := module.HandleRawBidderResponseHook(context.Background(), enabledCtx(), payload) + require.NoError(t, err) + payload = applyMutations(t, result, payload) + + adm := payload.BidderResponse.Bids[0].Bid.AdM + assert.Contains(t, adm, "primary.com", "first domain must be in ") + assert.NotContains(t, adm, "primary.com,secondary.com", + "domains must NOT be comma-joined in ") + + // Parse XML to check value is exactly first domain + var vastParsed struct { + XMLName xml.Name `xml:"VAST"` + Ad struct { + InLine struct { + Advertiser string `xml:"Advertiser"` + } `xml:"InLine"` + } `xml:"Ad"` + } + err = xml.Unmarshal([]byte(adm), &vastParsed) + require.NoError(t, err) + assert.Equal(t, "primary.com", vastParsed.Ad.InLine.Advertiser, + " must contain exactly the first domain") +} + +// --------------------------------------------------------------------------- +// Test helpers — custom selector implementations +// --------------------------------------------------------------------------- + +// durationInjectingSelector wraps the default selector and injects DurSec into each CanonicalMeta. +type durationInjectingSelector struct { + durSec int +} + +func (s *durationInjectingSelector) Select( + req *openrtb2.BidRequest, + resp *openrtb2.BidResponse, + cfg ctv.ReceiverConfig, +) ([]ctv.SelectedBid, []string, error) { + base := bidselect.NewSelector(ctv.SelectionSingle) + selected, warnings, err := base.Select(req, resp, cfg) + if err != nil { + return selected, warnings, err + } + for i := range selected { + selected[i].Meta.DurSec = s.durSec + } + return selected, warnings, nil +} diff --git a/modules/prebid/ctv_vast_enrichment/module_test.go b/modules/prebid/ctv_vast_enrichment/module_test.go new file mode 100644 index 00000000000..99ab2f1dac1 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/module_test.go @@ -0,0 +1,613 @@ +package ctv_vast_enrichment + +import ( + "context" + "encoding/json" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/adapters" + "github.com/prebid/prebid-server/v4/hooks/hookstage" + "github.com/prebid/prebid-server/v4/modules/moduledeps" + "github.com/prebid/prebid-server/v4/modules/prebid/ctv_vast_enrichment/model" + "github.com/prebid/prebid-server/v4/openrtb_ext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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{ + { + BidType: openrtb_ext.BidTypeVideo, + 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.5") + 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{ + { + BidType: openrtb_ext.BidTypeVideo, + 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{ + { + BidType: openrtb_ext.BidTypeVideo, + Bid: &openrtb2.Bid{ + ID: "bid1", + Price: 1.50, + AdM: `TestTest Ad`, + }, + }, + { + BidType: openrtb_ext.BidTypeVideo, + 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.5") + assert.Contains(t, payload.BidderResponse.Bids[1].Bid.AdM, "2") +} + +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{ + { + BidType: openrtb_ext.BidTypeVideo, + 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: CTVVastConfig{}.ReceiverConfig(), + }, + { + name: "receiver GAM_SSU", + input: CTVVastConfig{ + Receiver: "GAM_SSU", + }, + expected: func() ReceiverConfig { + rc := CTVVastConfig{Receiver: "GAM_SSU"}.ReceiverConfig() + return rc + }(), + }, + { + name: "receiver GENERIC", + input: CTVVastConfig{ + Receiver: "GENERIC", + }, + expected: func() ReceiverConfig { + rc := CTVVastConfig{Receiver: "GENERIC"}.ReceiverConfig() + return rc + }(), + }, + { + name: "custom currency", + input: CTVVastConfig{ + DefaultCurrency: "EUR", + }, + expected: func() ReceiverConfig { + rc := CTVVastConfig{DefaultCurrency: "EUR"}.ReceiverConfig() + return rc + }(), + }, + { + name: "selection strategy max_revenue", + input: CTVVastConfig{ + SelectionStrategy: "max_revenue", + }, + expected: func() ReceiverConfig { + rc := CTVVastConfig{SelectionStrategy: "max_revenue"}.ReceiverConfig() + return rc + }(), + }, + { + name: "collision policy reject", + input: CTVVastConfig{ + CollisionPolicy: "reject", + }, + expected: func() ReceiverConfig { + rc := CTVVastConfig{CollisionPolicy: "reject"}.ReceiverConfig() + return rc + }(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // ReceiverConfig() is now the canonical conversion (BUG 8 fix) + result := tc.input.ReceiverConfig() + assert.Equal(t, tc.expected.Receiver, result.Receiver) + assert.Equal(t, tc.expected.DefaultCurrency, result.DefaultCurrency) + assert.Equal(t, tc.expected.SelectionStrategy, result.SelectionStrategy) + assert.Equal(t, tc.expected.CollisionPolicy, result.CollisionPolicy) + }) + } +} + +func TestEnrichVastDocument(t *testing.T) { + // enrichVastDocument was replaced by hookEnricher.Enrich() (BUG 3 + BUG 8 fix). + // These tests now exercise hookEnricher directly. + testCases := []struct { + name string + inputVast string + 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, + }, + } + + e := &hookEnricher{} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + vastDoc, err := parseTestVast(tc.inputVast) + require.NoError(t, err) + + ad := model.ExtractFirstAd(vastDoc) + require.NotNil(t, ad) + + _, enrichErr := e.Enrich(ad, tc.meta, tc.cfg) + require.NoError(t, enrichErr) + + xmlBytes, err := vastDoc.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) { + e := &hookEnricher{} + _, err := e.Enrich(nil, CanonicalMeta{}, ReceiverConfig{}) + assert.NoError(t, err) +} + +// 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/needChanges.md b/modules/prebid/ctv_vast_enrichment/needChanges.md new file mode 100644 index 00000000000..24a3addc1db --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/needChanges.md @@ -0,0 +1,251 @@ +# Analiza zmian wymaganych do zgodności z Tech Spec (issue #3726) + +## Kontekst + +Issue [#3726 — Support general GET interface](https://github.com/prebid/prebid-server/issues/3726) definiuje uogólniony interfejs GET dla Prebid Server, wspierający zarówno Audio jak i CTV. Techniczny dokument odpowiedzi (Tech Spec) określa architekturę składającą się z: + +1. **GET Interface** — PBS Core obsługuje GET na `/openrtb2/auction` +2. **Profiles** — nowy feature core'owy (fragmenty ORTB per-account) +3. **Exitpoint hook stage** — nowy etap hooków do modyfikacji formatu odpowiedzi +4. **Moduły community** — HTTP Header, Ranking, VAST Response, Mapping, VAST Unwrapping + +Moduł `ctv_vast_enrichment` musi zostać dostosowany do tej architektury. + +--- + +## Stan obecny modułu + +### Co jest zaimplementowane ✅ + +| Komponent | Status | Plik | +|-----------|--------|------| +| RawBidderResponse Hook | ✅ Działa | `module.go` | +| VAST Enrichment (Pricing, Advertiser, Categories, Duration) | ✅ Działa | `enrich/enrich.go` | +| Bid Selection (SINGLE, TOP_N, MAX_REVENUE) | ✅ Działa | `select/selector.go` | +| VAST Formatting (GAM SSU) | ✅ Działa | `format/format.go` | +| 3-warstwowa konfiguracja (host → account → profile) | ✅ Merge działa | `config.go` | +| Pipeline orchestration | ✅ Działa | `pipeline.go` | +| VAST XML parser + skeleton | ✅ Działa | `model/parser.go` | +| HTTP Handler (GET, builder pattern) | ⚠️ Częściowy | `handler.go` | +| Rejestracja w `modules/builder.go` | ✅ Zarejestrowany | `modules/builder.go` | + +### Co NIE istnieje w PBS Core ❌ + +| Feature z Tech Spec | Status w PBS | Implikacja | +|---------------------|--------------|------------| +| GET na `/openrtb2/auction` | ❌ Tylko POST | Moduł nie może działać przez GET bez zmian w core | +| `ext.prebid.profiles` | ❌ Nie istnieje | Profiles nie są parsowane ani mergowane | +| `ext.prebid.of` (output format) | ❌ Nie istnieje | Brak mechanizmu sygnalizacji formatu odpowiedzi | +| `ext.prebid.rank` (ranking) | ❌ Nie istnieje | Brak standardowego rankingu bidów | +| `ext.prebid.outputmodule` | ❌ Nie istnieje | Brak mechanizmu wyboru modułu wyjściowego | +| `RequestMethod` w AuctionContext | ❌ Nie dostępne | Moduły nie wiedzą czy request przyszedł GET vs POST | +| Exitpoint hook stage | ✅ Istnieje | `hooks/hookstage/exitpoint.go` — payload `{Response any, W http.ResponseWriter}` | + +--- + +## Analiza rozbieżności: Moduł vs Tech Spec + +### 1. Endpoint: `/ctv/vast` vs GET na `/openrtb2/auction` + +**Obecny plan (endpoint.md):** Dedykowany endpoint `/ctv/vast` w `handler.go`. + +**Tech Spec mówi:** GET powinien działać na istniejącym `/openrtb2/auction`. Nie definiuje osobnego endpointu `/ctv/vast`. Format odpowiedzi zależy od `ext.prebid.of` (np. `vast3`, `vast4`). Moduł exitpoint sprawdza ten parametr i formatuje odpowiednio. + +**Co trzeba zmienić:** +- ❌ **Porzucić** koncepcję osobnego endpointu `/ctv/vast` +- ✅ Handler HTTP (`handler.go`) staje się zbędny w obecnej formie +- ✅ Moduł powinien działać jako **exitpoint hook** formatujący VAST, NIE jako osobny endpoint +- ⚠️ Alternatywa: zachować `/ctv/vast` jako prosty redirector/convenience endpoint (ale nie jest to częścią spec) + +### 2. Parsowanie parametrów query → PBS Core odpowiada za to + +**Obecny plan:** `handler.go` → `buildBidRequest()` parsuje query params. + +**Tech Spec mówi:** PBS Core parsuje ~60 parametrów GET (spreadsheet), buduje `BidRequest`, merguje stored requests i profiles. Moduł dostaje gotowy `BidRequest` jak przy POST. + +**Co trzeba zmienić:** +- ❌ `buildBidRequest()` w handler.go jest zbędny +- ✅ Moduł nie musi parsować query params — PBS Core to robi +- ✅ Moduł dostaje standardowy `BidRequest` z hooków + +### 3. Selekcja zwycięzcy → Ranking Module (osobny moduł) + +**Obecny moduł:** `select/selector.go` implementuje selekcję bidów wewnętrznie. + +**Tech Spec mówi:** Ranking to **osobny moduł** na etapie `all-processed-bid-responses`. Ustawia `seatbid.bid.ext.prebid.rank`. VAST response module czyta rank i formatuje pod. + +**Co trzeba zmienić:** +- ⚠️ Selector w module jest **redundantny** z Ranking Module — ale ten moduł jeszcze nie istnieje +- ✅ Docelowo: moduł VAST powinien czytać `ext.prebid.rank` zamiast samodzielnie selekcjonować +- ✅ Na razie: zachować selector jako fallback dopóki ranking module nie powstanie +- ⚠️ Trzeba dodać logikę: "jeśli bidy mają `ext.prebid.rank`, użyj go; w przeciwnym razie użyj selektora" + +### 4. Format odpowiedzi → Exitpoint Hook + +**Obecny moduł:** Enrichment na `RawBidderResponse` + pipeline w `handler.go`. + +**Tech Spec mówi:** VAST response module działa na **exitpoint stage**. Sprawdza: +1. Czy request przyszedł przez GET (`ext.prebid.server.requestmethod`) +2. Czy imp[] zawiera obsługiwane media types +3. Czy `ext.prebid.of` to format obsługiwany przez ten moduł + +Następnie serializuje `BidResponse` do VAST XML. + +**Co trzeba zmienić:** +- ✅ **Nowy hook:** Implementacja `HandleExitpointHook()` w `module.go` +- ✅ Hook exitpoint buduje VAST z `BidResponse` (pipeline.go już to umie) +- ✅ Hook sprawdza `ext.prebid.of` (vast3/vast4) i decyduje czy formatować +- ⚠️ `RawBidderResponse` hook **nadal jest potrzebny** do enrichmentu (dodawanie Pricing/Advertiser) +- ✅ Rozdzielenie odpowiedzialności: + - `RawBidderResponse` → enrichment VAST w bidach + - `Exitpoint` → formatowanie końcowej odpowiedzi jako VAST + +### 5. Konfiguracja — `ext.prebid.of` i `ext.prebid.outputmodule` + +**Obecny moduł:** Nie reaguje na te pola (nie istnieją w PBS). + +**Tech Spec mówi:** `ext.prebid.of` = "vast3"/"vast4" sygnalizuje format. `ext.prebid.outputmodule` pozwala określić który moduł formatuje. + +**Co trzeba zmienić:** +- ⚠️ **Blokowane przez PBS Core** — te pola muszą być najpierw dodane do `openrtb_ext.ExtRequestPrebid` +- ✅ Moduł powinien czytać je w exitpoint hook +- ✅ Jeśli `of` = "vast3"/"vast4" → moduł formatuje VAST +- ✅ Jeśli `of` jest puste lub "ortb2" → moduł nie interweniuje + +### 6. Profiles — trzecie źródło konfiguracji + +**Obecny moduł:** `MergeCTVVastConfig(host, account, profile)` — merge 3-warstwowy zaimplementowany, ale `profile` zawsze `nil`. + +**Tech Spec mówi:** Profiles to ORTB fragments, nie module config. Ale koncepcja warstwowej konfiguracji jest zgodna. + +**Co trzeba zmienić:** +- ⚠️ **Blokowane przez PBS Core** — profiles muszą być najpierw zaimplementowane w core +- ✅ Struktura `MergeCTVVastConfig()` jest gotowa na profiles +- ✅ Trzeba podłączyć pobieranie profile config z kontekstu hooka gdy feature będzie dostępny + +--- + +## Plan implementacji — priorytety + +### Faza 1: Dostosowanie modułu (bez zmian w core) + +| # | Zadanie | Plik(i) | Priorytet | +|---|---------|---------|-----------| +| 1.1 | Implementacja `HandleExitpointHook()` | `module.go` | **P0** | +| 1.2 | Logika exitpoint: sprawdź `ext.prebid.of`, zbuduj VAST z BidResponse | `module.go` | **P0** | +| 1.3 | Reużycie `pipeline.go` w exitpoint hook | `pipeline.go` | **P0** | +| 1.4 | Dodanie exitpoint do `host_execution_plan` w `pbs.json` | `pbs.json` | **P0** | +| 1.5 | Testy unit dla exitpoint hook | `module_test.go` | **P0** | +| 1.6 | Fallback rankingu — czytaj `ext.prebid.rank` jeśli dostępny | `select/selector.go` | **P1** | +| 1.7 | Obsługa VAST version z konfiguracji | `format/format.go` | **P1** | + +### Faza 2: Zmiany w PBS Core (wymagają review core team) + +| # | Zadanie | Plik(i) | Priorytet | +|---|---------|---------|-----------| +| 2.1 | Dodanie GET na `/openrtb2/auction` | `router/router.go`, nowy handler | **P0** | +| 2.2 | Parser query parameters → BidRequest | nowy pakiet w `endpoints/` | **P0** | +| 2.3 | Dodanie `ext.prebid.of` do `ExtRequestPrebid` | `openrtb_ext/request.go` | **P0** | +| 2.4 | Dodanie `ext.prebid.outputmodule` do `ExtRequestPrebid` | `openrtb_ext/request.go` | **P1** | +| 2.5 | Dodanie `ext.prebid.profiles` do `ExtRequestPrebid` | `openrtb_ext/request.go` | **P1** | +| 2.6 | Implementacja Profile storage i merge | `stored_requests/`, `config/` | **P1** | +| 2.7 | Dodanie `RequestMethod` do `AuctionContext` / `ext.prebid.server` | `exchange/exchange.go` | **P1** | +| 2.8 | Stała `EndpointAuctionGET` w hookexecution | `hooks/hookexecution/executor.go` | **P1** | + +### Faza 3: Moduły Community (osobne issue) + +| # | Moduł | Hook Stage | Status | +|---|-------|------------|--------| +| 3.1 | HTTP Header Module | `RawAuctionRequest` | ❌ Nie istnieje | +| 3.2 | Bid Response Ranking Module | `AllProcessedBidResponses` | ❌ Nie istnieje | +| 3.3 | VAST Unwrapping & Validation Module | TBD | ❌ Nie istnieje | +| 3.4 | Category Mapping Module | `ProcessedAuctionRequest` | ❌ Nie istnieje | + +--- + +## Architektura docelowa — przepływ + +``` +GET /openrtb2/auction?srid=my-stored-req&of=vast4&pubid=pub-1&mindur=15&maxdur=60 + │ + ▼ +┌────────────────────────────────────────────────┐ +│ PBS Core: GET Handler │ +│ 1. Parse query params → partial BidRequest │ +│ 2. Load stored request (srid) │ +│ 3. Load & merge profiles (rprof, iprof) │ +│ 4. Merge all layers │ +│ 5. Set ext.prebid.of = "vast4" │ +│ 6. Set ext.prebid.server.requestmethod = "GET" │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: Entrypoint Stage │ +│ → HTTP Header Module (X-Device-IP, etc.) │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ exchange.HoldAuction() │ +│ ├─ ProcessedAuction → Mapping Module │ +│ ├─ BidderRequest → per bidder │ +│ ├─ RawBidderResponse │ +│ │ └─ ctv_vast_enrichment: ENRICHMENT │ +│ │ (dodaje Pricing, Advertiser, Categories) │ +│ └─ AllProcessedBidResponses │ +│ └─ Ranking Module: ustawia ext.prebid.rank │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: AuctionResponse Stage │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: Exitpoint Stage │ +│ → ctv_vast_enrichment: VAST FORMATTING │ +│ 1. Sprawdź ext.prebid.of == "vast4"? │ +│ 2. Czytaj ext.prebid.rank z bidów │ +│ 3. Wybierz bidy (rank lub selector fallback) │ +│ 4. Buduj VAST XML (pipeline.go) │ +│ 5. Ustaw Content-Type: application/xml │ +│ 6. Zwróć VAST zamiast JSON │ +└─────────────────┬──────────────────────────────┘ + ▼ + VAST XML Response +``` + +--- + +## Podsumowanie: co moduł robi dobrze a co wymaga zmiany + +### ✅ Do zachowania (zgodne z Tech Spec) + +1. **RawBidderResponse Hook** — enrichment VAST (Pricing, Advertiser, Categories, Duration) jest dokładnie tym czego wymaga Req8/Req9 z CTV/Audio +2. **Pipeline orchestration** — `BuildVastFromBidResponse()` dobrze komponuje VAST +3. **Konfiguracja 3-warstwowa** — gotowa na profiles +4. **VAST parser + skeleton** — solidna baza +5. **Enrich policy VAST_WINS** — zgodna z wymaganiami (nie nadpisuj istniejących wartości) + +### ❌ Do zmiany + +1. **Osobny endpoint `/ctv/vast`** → porzucić, użyć `/openrtb2/auction` GET + exitpoint +2. **Handler HTTP** → zastąpić exitpoint hookiem +3. **Wewnętrzna selekcja bidów** → adaptować na `ext.prebid.rank` (z fallbackiem) +4. **Brak exitpoint hook** → zaimplementować `HandleExitpointHook()` + +### ⚠️ Blokery (czekają na PBS Core) + +1. GET na `/openrtb2/auction` — **nie istnieje** +2. `ext.prebid.of` — **nie istnieje** w `ExtRequestPrebid` +3. `ext.prebid.profiles` — **nie istnieje** w core +4. `ext.prebid.rank` — **nie istnieje** (wymaga Ranking Module) +5. `ext.prebid.server.requestmethod` — **nie istnieje** + +--- + +## Rekomendacja: co robić teraz + +1. **Zaimplementować exitpoint hook** — jedyna zmiana, która nie wymaga modyfikacji PBS Core i jest zgodna z docelową architekturą +2. **Zachować RawBidderResponse hook** — enrichment to oddzielna odpowiedzialność od formatowania +3. **Zachować handler.go jako opcjonalny** — convenience endpoint na czas przejściowy +4. **Skontaktować się z core team** (jak sugeruje issue) w sprawie timeline dla GET interface i profiles +5. **Przygotować testy** dla exitpoint hook z mockami `ext.prebid.of` diff --git a/modules/prebid/ctv_vast_enrichment/neededChangesENG.md b/modules/prebid/ctv_vast_enrichment/neededChangesENG.md new file mode 100644 index 00000000000..d4f7a185637 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/neededChangesENG.md @@ -0,0 +1,251 @@ +# Gap Analysis: CTV VAST Enrichment Module vs Tech Spec (issue #3726) + +## Context + +Issue [#3726 — Support general GET interface](https://github.com/prebid/prebid-server/issues/3726) defines a generalized GET interface for Prebid Server supporting both Audio and CTV use cases. The Technical Response document specifies an architecture consisting of: + +1. **GET Interface** — PBS Core handles GET on `/openrtb2/auction` +2. **Profiles** — new core feature (per-account ORTB fragments) +3. **Exitpoint hook stage** — new hook stage for response format modification +4. **Community Modules** — HTTP Header, Ranking, VAST Response, Mapping, VAST Unwrapping + +The `ctv_vast_enrichment` module must be aligned with this architecture. + +--- + +## Current Module State + +### What Is Implemented ✅ + +| Component | Status | File | +|-----------|--------|------| +| RawBidderResponse Hook | ✅ Working | `module.go` | +| VAST Enrichment (Pricing, Advertiser, Categories, Duration) | ✅ Working | `enrich/enrich.go` | +| Bid Selection (SINGLE, TOP_N, MAX_REVENUE) | ✅ Working | `select/selector.go` | +| VAST Formatting (GAM SSU) | ✅ Working | `format/format.go` | +| 3-layer config merge (host → account → profile) | ✅ Merge works | `config.go` | +| Pipeline orchestration | ✅ Working | `pipeline.go` | +| VAST XML parser + skeleton | ✅ Working | `model/parser.go` | +| HTTP Handler (GET, builder pattern) | ⚠️ Partial | `handler.go` | +| Registration in `modules/builder.go` | ✅ Registered | `modules/builder.go` | + +### What Does NOT Exist in PBS Core ❌ + +| Feature from Tech Spec | Status in PBS | Implication | +|------------------------|---------------|-------------| +| GET on `/openrtb2/auction` | ❌ POST only | Module cannot serve GET without core changes | +| `ext.prebid.profiles` | ❌ Does not exist | Profiles are not parsed or merged | +| `ext.prebid.of` (output format) | ❌ Does not exist | No mechanism to signal response format | +| `ext.prebid.rank` (ranking) | ❌ Does not exist | No standardized bid ranking | +| `ext.prebid.outputmodule` | ❌ Does not exist | No mechanism to select output module | +| `RequestMethod` in AuctionContext | ❌ Not available | Modules don't know GET vs POST | +| Exitpoint hook stage | ✅ Exists | `hooks/hookstage/exitpoint.go` — payload `{Response any, W http.ResponseWriter}` | + +--- + +## Gap Analysis: Module vs Tech Spec + +### 1. Endpoint: `/ctv/vast` vs GET on `/openrtb2/auction` + +**Current plan (endpoint.md):** Dedicated `/ctv/vast` endpoint in `handler.go`. + +**Tech Spec says:** GET should work on the existing `/openrtb2/auction`. No separate `/ctv/vast` endpoint is defined. Response format depends on `ext.prebid.of` (e.g., `vast3`, `vast4`). An exitpoint module checks this parameter and formats accordingly. + +**Required changes:** +- ❌ **Abandon** the separate `/ctv/vast` endpoint concept +- ✅ HTTP handler (`handler.go`) becomes unnecessary in its current form +- ✅ Module should act as an **exitpoint hook** formatting VAST, NOT as a separate endpoint +- ⚠️ Alternative: keep `/ctv/vast` as a simple convenience redirect (but this is not part of the spec) + +### 2. Query Parameter Parsing → PBS Core's Responsibility + +**Current plan:** `handler.go` → `buildBidRequest()` parses query params. + +**Tech Spec says:** PBS Core parses ~60 GET parameters (see spreadsheet), builds `BidRequest`, merges stored requests and profiles. The module receives a ready-made `BidRequest` just like with POST. + +**Required changes:** +- ❌ `buildBidRequest()` in handler.go is redundant +- ✅ Module doesn't need to parse query params — PBS Core does it +- ✅ Module receives standard `BidRequest` from hooks + +### 3. Winner Selection → Ranking Module (Separate Module) + +**Current module:** `select/selector.go` implements bid selection internally. + +**Tech Spec says:** Ranking is a **separate module** at the `all-processed-bid-responses` stage. It sets `seatbid.bid.ext.prebid.rank`. The VAST response module reads rank and formats the pod. + +**Required changes:** +- ⚠️ Internal selector is **redundant** with the Ranking Module — but that module doesn't exist yet +- ✅ Target: VAST module should read `ext.prebid.rank` instead of self-selecting +- ✅ For now: keep selector as a fallback until ranking module is built +- ⚠️ Need to add logic: "if bids have `ext.prebid.rank`, use it; otherwise use internal selector" + +### 4. Response Formatting → Exitpoint Hook + +**Current module:** Enrichment at `RawBidderResponse` + pipeline in `handler.go`. + +**Tech Spec says:** VAST response module operates at the **exitpoint stage**. It checks: +1. Whether the request came via GET (`ext.prebid.server.requestmethod`) +2. Whether imp[] contains only media types it handles +3. Whether `ext.prebid.of` is a format this module handles + +Then it serializes `BidResponse` to VAST XML. + +**Required changes:** +- ✅ **New hook:** Implement `HandleExitpointHook()` in `module.go` +- ✅ Exitpoint hook builds VAST from `BidResponse` (pipeline.go already does this) +- ✅ Hook checks `ext.prebid.of` (vast3/vast4) to decide whether to format +- ⚠️ `RawBidderResponse` hook is **still needed** for enrichment (adding Pricing/Advertiser) +- ✅ Separation of concerns: + - `RawBidderResponse` → VAST enrichment in individual bids + - `Exitpoint` → formatting the final response as VAST + +### 5. Configuration — `ext.prebid.of` and `ext.prebid.outputmodule` + +**Current module:** Does not react to these fields (they don't exist in PBS). + +**Tech Spec says:** `ext.prebid.of` = "vast3"/"vast4" signals format. `ext.prebid.outputmodule` allows specifying which module formats. + +**Required changes:** +- ⚠️ **Blocked by PBS Core** — these fields must first be added to `openrtb_ext.ExtRequestPrebid` +- ✅ Module should read them in the exitpoint hook +- ✅ If `of` = "vast3"/"vast4" → module formats VAST +- ✅ If `of` is empty or "ortb2" → module does not intervene + +### 6. Profiles — Third Configuration Source + +**Current module:** `MergeCTVVastConfig(host, account, profile)` — 3-layer merge implemented, but `profile` always `nil`. + +**Tech Spec says:** Profiles are ORTB fragments, not module config. But the layered configuration concept is compatible. + +**Required changes:** +- ⚠️ **Blocked by PBS Core** — profiles must be implemented in core first +- ✅ `MergeCTVVastConfig()` structure is already ready for profiles +- ✅ Need to connect profile config retrieval from hook context when the feature becomes available + +--- + +## Implementation Plan — Priorities + +### Phase 1: Module Adaptation (No Core Changes) + +| # | Task | File(s) | Priority | +|---|------|---------|----------| +| 1.1 | Implement `HandleExitpointHook()` | `module.go` | **P0** | +| 1.2 | Exitpoint logic: check `ext.prebid.of`, build VAST from BidResponse | `module.go` | **P0** | +| 1.3 | Reuse `pipeline.go` in exitpoint hook | `pipeline.go` | **P0** | +| 1.4 | Add exitpoint to `host_execution_plan` in `pbs.json` | `pbs.json` | **P0** | +| 1.5 | Unit tests for exitpoint hook | `module_test.go` | **P0** | +| 1.6 | Ranking fallback — read `ext.prebid.rank` if available | `select/selector.go` | **P1** | +| 1.7 | VAST version handling from config | `format/format.go` | **P1** | + +### Phase 2: PBS Core Changes (Require Core Team Review) + +| # | Task | File(s) | Priority | +|---|------|---------|----------| +| 2.1 | Add GET support to `/openrtb2/auction` | `router/router.go`, new handler | **P0** | +| 2.2 | Query parameter parser → BidRequest | new package in `endpoints/` | **P0** | +| 2.3 | Add `ext.prebid.of` to `ExtRequestPrebid` | `openrtb_ext/request.go` | **P0** | +| 2.4 | Add `ext.prebid.outputmodule` to `ExtRequestPrebid` | `openrtb_ext/request.go` | **P1** | +| 2.5 | Add `ext.prebid.profiles` to `ExtRequestPrebid` | `openrtb_ext/request.go` | **P1** | +| 2.6 | Implement Profile storage and merge | `stored_requests/`, `config/` | **P1** | +| 2.7 | Add `RequestMethod` to AuctionContext / `ext.prebid.server` | `exchange/exchange.go` | **P1** | +| 2.8 | Add `EndpointAuctionGET` constant in hookexecution | `hooks/hookexecution/executor.go` | **P1** | + +### Phase 3: Community Modules (Separate Issues) + +| # | Module | Hook Stage | Status | +|---|--------|------------|--------| +| 3.1 | HTTP Header Module | `RawAuctionRequest` | ❌ Does not exist | +| 3.2 | Bid Response Ranking Module | `AllProcessedBidResponses` | ❌ Does not exist | +| 3.3 | VAST Unwrapping & Validation Module | TBD | ❌ Does not exist | +| 3.4 | Category Mapping Module | `ProcessedAuctionRequest` | ❌ Does not exist | + +--- + +## Target Architecture — Request Flow + +``` +GET /openrtb2/auction?srid=my-stored-req&of=vast4&pubid=pub-1&mindur=15&maxdur=60 + │ + ▼ +┌────────────────────────────────────────────────┐ +│ PBS Core: GET Handler │ +│ 1. Parse query params → partial BidRequest │ +│ 2. Load stored request (srid) │ +│ 3. Load & merge profiles (rprof, iprof) │ +│ 4. Merge all layers │ +│ 5. Set ext.prebid.of = "vast4" │ +│ 6. Set ext.prebid.server.requestmethod = "GET" │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: Entrypoint Stage │ +│ → HTTP Header Module (X-Device-IP, etc.) │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ exchange.HoldAuction() │ +│ ├─ ProcessedAuction → Mapping Module │ +│ ├─ BidderRequest → per bidder │ +│ ├─ RawBidderResponse │ +│ │ └─ ctv_vast_enrichment: ENRICHMENT │ +│ │ (adds Pricing, Advertiser, Categories) │ +│ └─ AllProcessedBidResponses │ +│ └─ Ranking Module: sets ext.prebid.rank │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: AuctionResponse Stage │ +└─────────────────┬──────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────┐ +│ Hook: Exitpoint Stage │ +│ → ctv_vast_enrichment: VAST FORMATTING │ +│ 1. Check ext.prebid.of == "vast4"? │ +│ 2. Read ext.prebid.rank from bids │ +│ 3. Select bids (rank or selector fallback) │ +│ 4. Build VAST XML (pipeline.go) │ +│ 5. Set Content-Type: application/xml │ +│ 6. Return VAST instead of JSON │ +└─────────────────┬──────────────────────────────┘ + ▼ + VAST XML Response +``` + +--- + +## Summary: What the Module Does Well vs What Needs Change + +### ✅ Keep (Aligned with Tech Spec) + +1. **RawBidderResponse Hook** — VAST enrichment (Pricing, Advertiser, Categories, Duration) is exactly what CTV/Audio Req8/Req9 require +2. **Pipeline orchestration** — `BuildVastFromBidResponse()` composes VAST well +3. **3-layer config merge** — ready for profiles +4. **VAST parser + skeleton** — solid foundation +5. **VAST_WINS enrichment policy** — aligned with requirements (don't overwrite existing values) + +### ❌ Needs Change + +1. **Separate `/ctv/vast` endpoint** → abandon, use `/openrtb2/auction` GET + exitpoint +2. **HTTP handler** → replace with exitpoint hook +3. **Internal bid selection** → adapt to read `ext.prebid.rank` (with fallback) +4. **No exitpoint hook** → implement `HandleExitpointHook()` + +### ⚠️ Blockers (Waiting on PBS Core) + +1. GET on `/openrtb2/auction` — **does not exist** +2. `ext.prebid.of` — **does not exist** in `ExtRequestPrebid` +3. `ext.prebid.profiles` — **does not exist** in core +4. `ext.prebid.rank` — **does not exist** (requires Ranking Module) +5. `ext.prebid.server.requestmethod` — **does not exist** + +--- + +## Recommendation: What To Do Now + +1. **Implement exitpoint hook** — the only change that doesn't require PBS Core modifications and aligns with the target architecture +2. **Keep RawBidderResponse hook** — enrichment is a separate responsibility from formatting +3. **Keep handler.go as optional** — convenience endpoint during transition period +4. **Contact core team** (as suggested in the issue) regarding timeline for GET interface and profiles +5. **Prepare tests** for the exitpoint hook with mocked `ext.prebid.of` diff --git a/modules/prebid/ctv_vast_enrichment/pipeline.go b/modules/prebid/ctv_vast_enrichment/pipeline.go new file mode 100644 index 00000000000..690c675a2fa --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/pipeline.go @@ -0,0 +1,210 @@ +// 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/golang/glog" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/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. +// If BuildVastFromBidResponse returns a fatal error it is logged and the +// result is returned as-is (NoAd=true with a no-ad VAST payload). +func (p *Processor) Process(ctx context.Context, req *openrtb2.BidRequest, resp *openrtb2.BidResponse) VastResult { + result, err := BuildVastFromBidResponse(ctx, req, resp, p.config, p.selector, p.enricher, p.formatter) + if err != nil { + glog.Errorf("ctv_vast_enrichment: pipeline error: %v", err) + } + return result +} + +// DefaultConfig returns a default ReceiverConfig for GAM SSU. +func DefaultConfig() ReceiverConfig { + return ReceiverConfig{ + Receiver: ReceiverGAMSSU, + DefaultCurrency: DefaultCurrency, + VastVersionDefault: DefaultVastVersion, // "3.0" — aligned with config.go constant (BUG 10) + MaxAdsInPod: DefaultMaxAdsInPod, + SelectionStrategy: SelectionMaxRevenue, + CollisionPolicy: CollisionVastWins, // "VAST_WINS" — aligned with DefaultCollisionPolicy (BUG 2) + Placement: PlacementRules{ + Pricing: PricingRules{ + FloorCPM: 0, + CeilingCPM: 0, + Currency: DefaultCurrency, + }, + 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..1cc771e7843 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/pipeline_test.go @@ -0,0 +1,431 @@ +package ctv_vast_enrichment + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/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") +} + +// 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..c4779eadd87 --- /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/v4/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: deal exists desc, then price desc, then bid.ID asc for stability +// 4. Return up to maxBids (or cfg.MaxAdsInPod if maxBids is 0) +// 5. Populate CanonicalMeta for each SelectedBid +func (s *PriceSelector) Select(req *openrtb2.BidRequest, resp *openrtb2.BidResponse, cfg vast.ReceiverConfig) ([]vast.SelectedBid, []string, error) { + 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: deal exists desc, then price desc, then bid.ID asc for stability + sort.Slice(filteredBids, func(i, j int) bool { + bi, bj := filteredBids[i].bid, filteredBids[j].bid + + // Primary: deal exists descending (deal bids win over open auction) + iHasDeal := bi.DealID != "" + jHasDeal := bj.DealID != "" + if iHasDeal != jHasDeal { + return iHasDeal + } + + // Secondary: price descending + if bi.Price != bj.Price { + return bi.Price > bj.Price + } + + // Tertiary: bid ID ascending for stability + return bi.ID < bj.ID + }) + + // 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..3badb19498b --- /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/v4/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 (deal > price, then price desc, then ID asc): + // 1. a (price 3.0, deal) - deal, highest price + // 2. c (price 2.0, deal) - deal, ID "c" < "d" + // 3. d (price 2.0, deal) - deal, ID "d" + // 4. b (price 3.0, no deal) - no deal, highest price + // 5. e (price 2.0, no deal) - no deal, same price, ID "e" > "b" + // 6. f (price 1.0, no deal) - no deal, lowest price + assert.Equal(t, "a", selected[0].Meta.BidID) + assert.Equal(t, "c", selected[1].Meta.BidID) + assert.Equal(t, "d", selected[2].Meta.BidID) + assert.Equal(t, "b", selected[3].Meta.BidID) + assert.Equal(t, "e", selected[4].Meta.BidID) + assert.Equal(t, "f", selected[5].Meta.BidID) +} + +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..e4a59fcef6b --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/select/selector.go @@ -0,0 +1,46 @@ +// Package bidselect provides bid selection logic for CTV VAST ad pods. +package bidselect + +import ( + vast "github.com/prebid/prebid-server/v4/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) +// +// The following strategies are defined as constants but not yet implemented; +// they fall back to TOP_N behaviour: +// - "max_revenue", "min_duration", "balanced" +func NewSelector(strategy vast.SelectionStrategy) Selector { + switch strategy { + case vast.SelectionSingle: + return NewPriceSelector(1) + case vast.SelectionTopN: + return NewPriceSelector(0) // 0 means use cfg.MaxAdsInPod + default: + // Strategies max_revenue / min_duration / balanced are reserved for future + // implementation. Until then, fall back to TOP_N (price-ranked, up to MaxAdsInPod). + return NewPriceSelector(0) + } +} + +// 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/testcases.md b/modules/prebid/ctv_vast_enrichment/testcases.md new file mode 100644 index 00000000000..73e26b678e5 --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/testcases.md @@ -0,0 +1,371 @@ +# CTV VAST Enrichment — Scenariusze Testów Manualnych (Postman / curl) + +## Jak uruchomić + +```bash +cd /workspaces/prebid-server +go build . +./prebid-server -v 1 -logtostderr +``` + +Serwer startuje na **http://localhost:8000** (auction) i **:6060** (admin). + +### Import do Postmana + +Zaimportuj plik `sample/ctv_vast_enrichment_postman_collection.json` do Postmana. +Zmienna `{{base_url}}` = `http://localhost:8000`. +Kolekcja ma wbudowane asserty JS — kliknij "Run Collection" żeby odpalić wszystko naraz. + +--- + +## Stworzone pliki danych + +| Plik | Opis | +|------|------| +| `data/stored_responses/bid-vast-1.json` | VAST bez Pricing/Advertiser, price=1.50, adomain=www.advertiser.com (istniejący) | +| `data/stored_responses/bid-vast-2.json` | VAST **z** istniejącym Pricing=9.99 EUR i Advertiser=OriginalAdvertiser, price=2.75 (istniejący) | +| `data/stored_responses/bid-vast-no-pricing.json` | VAST bez Pricing/Advertiser, price=3.50, adomain=www.basic-advertiser.com | +| `data/stored_responses/bid-vast-empty-adm.json` | Bid z pustym `"adm": ""` | +| `data/stored_responses/bid-vast-banner.json` | Bid z banner HTML (``) zamiast VAST XML | +| `data/stored_responses/bid-vast-no-adomain.json` | VAST bez Pricing, price=5.00, `"adomain": []` (pusta tablica) | +| `data/stored_responses/bid-vast-multiple-cats.json` | VAST bez Pricing, price=7.25, cat=["IAB1","IAB3-1","IAB10"] | +| `data/stored_responses/bid-vast-zero-price.json` | VAST bez Pricing, price=0, adomain=www.free-advertiser.com | +| `stored_requests/data/by_id/accounts/ctv-test.json` | Konto testowe z `default_currency: "EUR"` (nadpisuje host config) | + +### Konfiguracja modułu w `pbs.json` + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD", + "vast_version_default": "3.0", + "max_ads_in_pod": 5, + "selection_strategy": "max_revenue" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [{ + "timeout": 1000, + "hook_sequence": [{ + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + }] + }] + } + } + } + } + } + } +} +``` + +--- + +## Scenariusze testowe + +### Test 1: Podstawowe wzbogacanie — dodaje `` + `` + +**Stored response:** `bid-vast-no-pricing.json` +**Co zawiera:** VAST XML bez `` i bez ``, price=3.50, adomain=www.basic-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-1", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-pricing"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ AdM zawiera `3.5` +- ✅ AdM zawiera `www.basic-advertiser.com` +- ✅ Reszta VAST (AdSystem, Duration, MediaFiles) bez zmian + +--- + +### Test 2: Polityka VAST_WINS — istniejące wartości NIE nadpisane + +**Stored response:** `bid-vast-2.json` +**Co zawiera:** VAST XML **z** istniejącym `9.99` i `OriginalAdvertiser`. Bid ma price=2.75 i adomain=www.different-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-2", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 1920, "h": 1080}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-2"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ Pricing = **9.99 EUR** (oryginał z VAST, nie 2.75 USD z bida) +- ✅ Advertiser = **OriginalAdvertiser** (oryginał, nie www.different-advertiser.com) +- ❌ Moduł NIE nadpisuje istniejących wartości + +--- + +### Test 3: Pusty AdM — moduł pomija bid + +**Stored response:** `bid-vast-empty-adm.json` +**Co zawiera:** Bid z `"adm": ""` (pusty string) + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-3", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-empty-adm"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ Bid przechodzi bez zmian +- ✅ Brak `` w odpowiedzi +- ✅ Brak błędu HTTP + +--- + +### Test 4: Non-VAST AdM (banner HTML) — graceful skip + +**Stored response:** `bid-vast-banner.json` +**Co zawiera:** Bid z `"adm": ""` — HTML zamiast VAST XML + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-4", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-banner"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ XML parsing fail → bid przechodzi bez zmian +- ✅ AdM nie zawiera `` ani `` +- ✅ Oryginalny HTML nie jest zmieniony + +--- + +### Test 5: Brak adomain — Pricing dodany, Advertiser NIE + +**Stored response:** `bid-vast-no-adomain.json` +**Co zawiera:** VAST XML, price=5.00, `"adomain": []` (pusta tablica) + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-5", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-adomain"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ `5` — dodany (price > 0) +- ❌ Brak `` — nie dodany (pusta adomain) + +--- + +### Test 6: Price=0 — brak Pricing, ale Advertiser dodany + +**Stored response:** `bid-vast-zero-price.json` +**Co zawiera:** VAST XML, price=0, adomain=www.free-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-6", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-zero-price"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ❌ Brak `` — nie dodany (price musi być > 0) +- ✅ `www.free-advertiser.com` — dodany (adomain istnieje) + +--- + +### Test 7: Kategorie IAB + +**Stored response:** `bid-vast-multiple-cats.json` +**Co zawiera:** VAST XML, price=7.25, adomain=www.categorized-advertiser.com, cat=["IAB1","IAB3-1","IAB10"] + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-7", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 1280, "h": 720}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-multiple-cats"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ `7.25` — dodany +- ✅ `www.categorized-advertiser.com` — dodany +- ✅ Kategorie IAB mogą pojawić się jako VAST Extensions + +--- + +### Test 8: Nadpisanie konfiguracji z poziomu konta — waluta EUR + +**Stored response:** `bid-vast-no-pricing.json` (ten sam co Test 1) +**Konto:** `stored_requests/data/by_id/accounts/ctv-test.json` — ustawia `default_currency: "EUR"` +**Klucz:** `"publisher": {"id": "ctv-test"}` w request body mapuje serwer na konto `ctv-test.json` + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-8", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-pricing"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "ctv-test"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ `3.5` — waluta **EUR** zamiast USD +- ✅ Konto nadpisuje konfigurację hosta + +--- + +### Test 9: Podstawowy VAST (bid-vast-1) + +**Stored response:** `bid-vast-1.json` +**Co zawiera:** VAST XML bez Pricing/Advertiser, price=1.50, adomain=www.advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-9", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-1"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Oczekiwane:** +- ✅ `1.5` — dodany +- ✅ `www.advertiser.com` — dodany +- ✅ Cena bida (1.50) nie zmieniona w odpowiedzi + +--- + +### Test 10: Health check — serwer działa + +```bash +curl -s http://localhost:8000/status +``` + +**Oczekiwane:** +- ✅ HTTP 200 +- ✅ Odpowiedź potwierdza, że serwer działa + +--- + +## Matryca pokrycia + +| # | Scenariusz | Stored response | Pricing | Advertiser | Uwagi | +|---|-----------|----------------|---------|------------|-------| +| 1 | Podstawowe wzbogacanie | bid-vast-no-pricing | ✅ dodany | ✅ dodany | Happy path | +| 2 | VAST_WINS | bid-vast-2 | ❌ nie nadpisany | ❌ nie nadpisany | Collision policy | +| 3 | Pusty AdM | bid-vast-empty-adm | ❌ pominięty | ❌ pominięty | Edge case | +| 4 | Non-VAST (HTML) | bid-vast-banner | ❌ pominięty | ❌ pominięty | Graceful degradation | +| 5 | Brak adomain | bid-vast-no-adomain | ✅ dodany | ❌ brak danych | Partial enrichment | +| 6 | Price=0 | bid-vast-zero-price | ❌ price=0 | ✅ dodany | Partial enrichment | +| 7 | Kategorie IAB | bid-vast-multiple-cats | ✅ dodany | ✅ dodany | Extensions | +| 8 | Account override (EUR) | bid-vast-no-pricing + ctv-test account | ✅ EUR | ✅ dodany | Config layering | +| 9 | Podstawowy VAST | bid-vast-1 | ✅ dodany | ✅ dodany | Sanity check | +| 10 | Health check | — | — | — | Serwer dostępny | diff --git a/modules/prebid/ctv_vast_enrichment/testcasesENG.md b/modules/prebid/ctv_vast_enrichment/testcasesENG.md new file mode 100644 index 00000000000..4bf83f7b08f --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/testcasesENG.md @@ -0,0 +1,371 @@ +# CTV VAST Enrichment — Manual Test Scenarios (Postman / curl) + +## How to Run + +```bash +cd /workspaces/prebid-server +go build . +./prebid-server -v 1 -logtostderr +``` + +Server starts on **http://localhost:8000** (auction) and **:6060** (admin). + +### Import into Postman + +Import the file `sample/ctv_vast_enrichment_postman_collection.json` into Postman. +Variable `{{base_url}}` = `http://localhost:8000`. +The collection has built-in JS test assertions — click "Run Collection" to execute all tests at once. + +--- + +## Created Data Files + +| File | Description | +|------|-------------| +| `data/stored_responses/bid-vast-1.json` | VAST without Pricing/Advertiser, price=1.50, adomain=www.advertiser.com (pre-existing) | +| `data/stored_responses/bid-vast-2.json` | VAST **with** existing Pricing=9.99 EUR and Advertiser=OriginalAdvertiser, price=2.75 (pre-existing) | +| `data/stored_responses/bid-vast-no-pricing.json` | VAST without Pricing/Advertiser, price=3.50, adomain=www.basic-advertiser.com | +| `data/stored_responses/bid-vast-empty-adm.json` | Bid with empty `"adm": ""` | +| `data/stored_responses/bid-vast-banner.json` | Bid with banner HTML (``) instead of VAST XML | +| `data/stored_responses/bid-vast-no-adomain.json` | VAST without Pricing, price=5.00, `"adomain": []` (empty array) | +| `data/stored_responses/bid-vast-multiple-cats.json` | VAST without Pricing, price=7.25, cat=["IAB1","IAB3-1","IAB10"] | +| `data/stored_responses/bid-vast-zero-price.json` | VAST without Pricing, price=0, adomain=www.free-advertiser.com | +| `stored_requests/data/by_id/accounts/ctv-test.json` | Test account with `default_currency: "EUR"` (overrides host config) | + +### Module Configuration in `pbs.json` + +```json +{ + "hooks": { + "enabled": true, + "modules": { + "prebid": { + "ctv_vast_enrichment": { + "enabled": true, + "receiver": "GAM_SSU", + "default_currency": "USD", + "vast_version_default": "3.0", + "max_ads_in_pod": 5, + "selection_strategy": "max_revenue" + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "raw_bidder_response": { + "groups": [{ + "timeout": 1000, + "hook_sequence": [{ + "module_code": "prebid.ctv_vast_enrichment", + "hook_impl_code": "code123" + }] + }] + } + } + } + } + } + } +} +``` + +--- + +## Test Scenarios + +### Test 1: Basic Enrichment — Adds `` + `` + +**Stored response:** `bid-vast-no-pricing.json` +**Contents:** VAST XML without `` or ``, price=3.50, adomain=www.basic-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-1", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-pricing"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ AdM contains `3.5` +- ✅ AdM contains `www.basic-advertiser.com` +- ✅ Rest of VAST (AdSystem, Duration, MediaFiles) unchanged + +--- + +### Test 2: VAST_WINS Policy — Existing Values NOT Overwritten + +**Stored response:** `bid-vast-2.json` +**Contents:** VAST XML **with** existing `9.99` and `OriginalAdvertiser`. Bid has price=2.75 and adomain=www.different-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-2", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 1920, "h": 1080}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-2"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ Pricing = **9.99 EUR** (original from VAST, not 2.75 USD from bid) +- ✅ Advertiser = **OriginalAdvertiser** (original, not www.different-advertiser.com) +- ❌ Module does NOT overwrite existing values + +--- + +### Test 3: Empty AdM — Module Skips Bid + +**Stored response:** `bid-vast-empty-adm.json` +**Contents:** Bid with `"adm": ""` (empty string) + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-3", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-empty-adm"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ Bid passes through unchanged +- ✅ No `` in response +- ✅ No HTTP error + +--- + +### Test 4: Non-VAST AdM (Banner HTML) — Graceful Skip + +**Stored response:** `bid-vast-banner.json` +**Contents:** Bid with `"adm": ""` — HTML instead of VAST XML + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-4", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-banner"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ XML parsing fails → bid passes through unchanged +- ✅ AdM does not contain `` or `` +- ✅ Original HTML is not modified + +--- + +### Test 5: Missing Adomain — Pricing Added, No Advertiser + +**Stored response:** `bid-vast-no-adomain.json` +**Contents:** VAST XML, price=5.00, `"adomain": []` (empty array) + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-5", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-adomain"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ `5` — added (price > 0) +- ❌ No `` — not added (empty adomain) + +--- + +### Test 6: Zero Price — No Pricing, But Advertiser Added + +**Stored response:** `bid-vast-zero-price.json` +**Contents:** VAST XML, price=0, adomain=www.free-advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-6", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-zero-price"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ❌ No `` — not added (price must be > 0) +- ✅ `www.free-advertiser.com` — added (adomain exists) + +--- + +### Test 7: IAB Categories + +**Stored response:** `bid-vast-multiple-cats.json` +**Contents:** VAST XML, price=7.25, adomain=www.categorized-advertiser.com, cat=["IAB1","IAB3-1","IAB10"] + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-7", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 1280, "h": 720}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-multiple-cats"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ `7.25` — added +- ✅ `www.categorized-advertiser.com` — added +- ✅ IAB categories may appear as VAST Extensions + +--- + +### Test 8: Account-Level Config Override — EUR Currency + +**Stored response:** `bid-vast-no-pricing.json` (same as Test 1) +**Account:** `stored_requests/data/by_id/accounts/ctv-test.json` — sets `default_currency: "EUR"` +**Key:** `"publisher": {"id": "ctv-test"}` in request body maps the server to the `ctv-test.json` account + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-8", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-no-pricing"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "ctv-test"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ `3.5` — currency is **EUR** instead of USD +- ✅ Account config overrides host config + +--- + +### Test 9: Basic VAST (bid-vast-1) + +**Stored response:** `bid-vast-1.json` +**Contents:** VAST XML without Pricing/Advertiser, price=1.50, adomain=www.advertiser.com + +```bash +curl -s -X POST http://localhost:8000/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d '{ + "id": "test-9", + "imp": [{ + "id": "test-div-1", + "video": {"mimes": ["video/mp4"], "protocols": [1,2,5], "w": 640, "h": 360}, + "ext": {"prebid": { + "bidder": {"appnexus": {"placementId": 12345}}, + "storedbidresponse": [{"bidder": "appnexus", "id": "bid-vast-1"}] + }} + }], + "site": {"page": "https://example.com", "publisher": {"id": "pub-1"}}, + "regs": {"ext": {"gdpr": 0}} + }' | python3 -m json.tool +``` + +**Expected:** +- ✅ `1.5` — added +- ✅ `www.advertiser.com` — added +- ✅ Bid price (1.50) unchanged in response + +--- + +### Test 10: Health Check — Server Running + +```bash +curl -s http://localhost:8000/status +``` + +**Expected:** +- ✅ HTTP 200 +- ✅ Response confirms server is running + +--- + +## Coverage Matrix + +| # | Scenario | Stored Response | Pricing | Advertiser | Notes | +|---|----------|----------------|---------|------------|-------| +| 1 | Basic enrichment | bid-vast-no-pricing | ✅ added | ✅ added | Happy path | +| 2 | VAST_WINS policy | bid-vast-2 | ❌ not overwritten | ❌ not overwritten | Collision policy | +| 3 | Empty AdM | bid-vast-empty-adm | ❌ skipped | ❌ skipped | Edge case | +| 4 | Non-VAST (HTML) | bid-vast-banner | ❌ skipped | ❌ skipped | Graceful degradation | +| 5 | Missing adomain | bid-vast-no-adomain | ✅ added | ❌ no data | Partial enrichment | +| 6 | Zero price | bid-vast-zero-price | ❌ price=0 | ✅ added | Partial enrichment | +| 7 | IAB categories | bid-vast-multiple-cats | ✅ added | ✅ added | Extensions | +| 8 | Account override (EUR) | bid-vast-no-pricing + ctv-test account | ✅ EUR | ✅ added | Config layering | +| 9 | Basic VAST | bid-vast-1 | ✅ added | ✅ added | Sanity check | +| 10 | Health check | — | — | — | Server available | diff --git a/modules/prebid/ctv_vast_enrichment/types.go b/modules/prebid/ctv_vast_enrichment/types.go new file mode 100644 index 00000000000..d3af6e48dbc --- /dev/null +++ b/modules/prebid/ctv_vast_enrichment/types.go @@ -0,0 +1,196 @@ +// 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/v4/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 is reserved for future implementation. + // Currently not supported; configs using this value will fall back to TOP_N. + SelectionMaxRevenue SelectionStrategy = "max_revenue" + // SelectionMinDuration is reserved for future implementation. + // Currently not supported; configs using this value will fall back to TOP_N. + SelectionMinDuration SelectionStrategy = "min_duration" + // SelectionBalanced is reserved for future implementation. + // Currently not supported; configs using this value will fall back to TOP_N. + 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" + // CollisionVastWins allows ads and VAST takes precedence. + CollisionVastWins CollisionPolicy = "VAST_WINS" +) + +// 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) +}