From 13b862cf60449c9362f417144e2d9a006810879e Mon Sep 17 00:00:00 2001 From: Gabriel Gravel Date: Thu, 5 Feb 2026 15:44:43 -0500 Subject: [PATCH] Scope3: Add rtd targeting in seatbid (#4606) --- modules/scope3/rtd/README.md | 19 +- modules/scope3/rtd/async_request.go | 2 +- modules/scope3/rtd/module.go | 205 +++++------ modules/scope3/rtd/module_test.go | 553 ++++++++++++++++++++++------ 4 files changed, 534 insertions(+), 245 deletions(-) diff --git a/modules/scope3/rtd/README.md b/modules/scope3/rtd/README.md index 3db4d447..bdaa5e58 100644 --- a/modules/scope3/rtd/README.md +++ b/modules/scope3/rtd/README.md @@ -19,10 +19,12 @@ hooks: auth_key: ${SCOPE3_API_KEY} # Set SCOPE3_API_KEY environment variable endpoint: https://rtdp.scope3.com/prebid/prebid timeout_ms: 1000 - cache_ttl_seconds: 60 # Cache segments for 60 seconds (default) - add_to_targeting: false # Set to true to add segments as individual targeting keys for GAM - masking: # Optional privacy masking configuration - enabled: true # Enable field masking before sending to Scope3 + cache_ttl_seconds: 60 # Cache segments for 60 seconds (default) + add_to_targeting: false # Set to true to add segments as individual targeting keys for GAM + add_scope3_targeting_section: false # Also set targeting in dedicated scope3 section + single_segment_key: "" # When set, adds all segments as a comma separated value under a single targeting key + masking: # Optional privacy masking configuration + enabled: true # Enable field masking before sending to Scope3 geo: preserve_metro: true # Preserve DMA code (default: true) preserve_zip: true # Preserve postal code (default: true) @@ -46,12 +48,12 @@ hooks: hook_sequence: - module_code: "scope3.rtd" hook_impl_code: "HandleEntrypointHook" - raw_auction_request: + auction_processed: groups: - timeout: 2000 hook_sequence: - module_code: "scope3.rtd" - hook_impl_code: "HandleRawAuctionHook" + hook_impl_code: "HandleAuctionProcessedHook" auction_response: groups: - timeout: 5 @@ -73,6 +75,7 @@ hooks: "timeout_ms": 1000, "cache_ttl_seconds": 60, "add_to_targeting": false, + "add_scope3_targeting_section": false, "masking": { "enabled": true, "geo": { @@ -306,7 +309,7 @@ The module forwards any fields that are not masked from the bid request to the S ### Auction Response Data The module adds audience segments to the auction response, giving publishers full control over how to use them: -1. **Publisher Flexibility**: Segments are always returned in `ext.scope3.segments` for the publisher to decide where to send +1. **Publisher Flexibility**: Segments are returned in `ext.scope3.segments` when configured for the publisher to decide where to send 2. **Google Ad Manager (GAM)**: Individual targeting keys are added when `add_to_targeting: true` (e.g., `gmp_eligible=true`) 3. **Other Ad Servers**: Publisher can forward segments to any ad server or system 4. **Analytics**: Segment data is available for reporting and analysis @@ -314,7 +317,7 @@ The module adds audience segments to the auction response, giving publishers ful ### Response Format Options The module provides segments in two formats: -**Always available:** +**When `add_scope3_targeting_section: true`:** ```json { "ext": { diff --git a/modules/scope3/rtd/async_request.go b/modules/scope3/rtd/async_request.go index 38c46632..23f44b2d 100644 --- a/modules/scope3/rtd/async_request.go +++ b/modules/scope3/rtd/async_request.go @@ -48,6 +48,6 @@ func (ar *AsyncRequest) fetchScope3SegmentsAsync(request *openrtb2.BidRequest) { } close(ar.Done) }() - ar.Segments, ar.Err = ar.Module.fetchScope3Segments(ar.Context, request) + ar.Segments, ar.Err = ar.fetchScope3Segments(ar.Context, request) }() } diff --git a/modules/scope3/rtd/module.go b/modules/scope3/rtd/module.go index 15fab4fc..9b80534a 100644 --- a/modules/scope3/rtd/module.go +++ b/modules/scope3/rtd/module.go @@ -10,9 +10,7 @@ import ( "errors" "fmt" "hash" - "maps" "net/http" - "slices" "strings" "sync" "time" @@ -26,6 +24,7 @@ import ( "github.com/prebid/prebid-server/v3/modules/moduledeps" "github.com/prebid/prebid-server/v3/util/iterutil" "github.com/prebid/prebid-server/v3/util/jsonutil" + "github.com/tidwall/sjson" ) // Builder is the entry point for the module @@ -90,31 +89,35 @@ func Builder(config json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, e const ( // keys for miCtx - asyncRequestKey = "scope3.AsyncRequest" - scope3MacroKey = "scope3_macro" - scope3MacroSeparator = ";" + asyncRequestKey = "scope3.AsyncRequest" + scope3MacroKey = "scope3_macro" + scope3IncludeKey = "scope3_include" + scope3Separator = ";" + scope3CacheKeySeparator = "|" ) -var scope3MacroKeyPlusSeparator = scope3MacroKey + scope3MacroSeparator +var scope3MacroKeyPlusSeparator = scope3MacroKey + scope3Separator +var scope3IncludeKeyPlusSeparator = scope3IncludeKey + scope3Separator const DefaultScope3RTDURL = "https://rtdp.scope3.com/prebid/prebid" var ( // Declare hooks - _ hookstage.Entrypoint = (*Module)(nil) - _ hookstage.RawAuctionRequest = (*Module)(nil) - _ hookstage.AuctionResponse = (*Module)(nil) + _ hookstage.Entrypoint = (*Module)(nil) + _ hookstage.ProcessedAuctionRequest = (*Module)(nil) + _ hookstage.AuctionResponse = (*Module)(nil) ) // Config holds module configuration type Config struct { - Endpoint string `json:"endpoint"` - AuthKey string `json:"auth_key"` - Timeout int `json:"timeout_ms"` - CacheTTL int `json:"cache_ttl_seconds"` // Cache segments for this many seconds - CacheSize int `json:"cache_size"` // Maximum size of segment cache in bytes - AddToTargeting bool `json:"add_to_targeting"` // Add segments as individual targeting keys - Masking MaskingConfig `json:"masking"` // Privacy masking configuration + Endpoint string `json:"endpoint"` + AuthKey string `json:"auth_key"` + Timeout int `json:"timeout_ms"` + CacheTTL int `json:"cache_ttl_seconds"` // Cache segments for this many seconds + CacheSize int `json:"cache_size"` // Maximum size of segment cache in bytes + AddToTargeting bool `json:"add_to_targeting"` // Add segments as individual targeting keys + AddScope3TargetingSection bool `json:"add_scope3_targeting_section"` // Add segments as individual targeting keys in Scope3 targeting section + Masking MaskingConfig `json:"masking"` // Privacy masking configuration } // MaskingConfig controls what user data is masked before sending to Scope3 @@ -153,30 +156,19 @@ type userExt struct { // Response types for Scope3 API type Scope3Response struct { - Data []Scope3Data `json:"data"` + AEESignals `json:"aee_signals"` } -type Scope3Data struct { - Destination string `json:"destination"` - Imp []Scope3ImpData `json:"imp"` +type AEESignals struct { + Include []string `json:"include,omitempty"` + Exclude []string `json:"exclude,omitempty"` + Macro string `json:"macro,omitempty"` // base64 + Bidders []map[string]Bidder `json:"bidders,omitempty"` } -type Scope3ImpData struct { - ID string `json:"id"` - Ext *Scope3Ext `json:"ext,omitempty"` -} - -type Scope3Ext struct { - Scope3 *Scope3ExtData `json:"scope3"` -} - -type Scope3ExtData struct { - Segments []Scope3Segment `json:"segments"` - Macro string `json:"macro"` -} - -type Scope3Segment struct { - ID string `json:"id"` +type Bidder struct { + Segments []string `json:"segments,omitempty"` + Deals []string `json:"deals,omitempty"` } // Module implements the Scope3 RTD module @@ -203,13 +195,13 @@ func (m *Module) HandleEntrypointHook( } // HandleRawAuctionHook is called early in the auction to fetch Scope3 data -func (m *Module) HandleRawAuctionHook( +func (m *Module) HandleProcessedAuctionHook( ctx context.Context, miCtx hookstage.ModuleInvocationContext, - payload hookstage.RawAuctionRequestPayload, -) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) { - var ret hookstage.HookResult[hookstage.RawAuctionRequestPayload] - analyticsNamePrefix := "HandleRawAuctionHook." + payload hookstage.ProcessedAuctionRequestPayload, +) (hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload], error) { + var ret hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload] + analyticsNamePrefix := "HandleProcessedAuctionHook." asyncRequest, ok := miCtx.ModuleContext[asyncRequestKey].(*AsyncRequest) if !ok { @@ -227,25 +219,8 @@ func (m *Module) HandleRawAuctionHook( return ret, nil } - // Parse OpenRTB request here rather than HandleProcessedAuctionHook to get a copy to avoid parallel mutation issues - var bidRequest openrtb2.BidRequest - if err := jsonutil.Unmarshal(payload, &bidRequest); err != nil { - // Log error but don't fail the auction - ret.AnalyticsTags = hookanalytics.Analytics{ - Activities: []hookanalytics.Activity{{ - Name: analyticsNamePrefix + "bidRequest.unmarshal", - Status: hookanalytics.ActivityStatusError, - Results: []hookanalytics.Result{{ - Status: hookanalytics.ResultStatusError, - Values: map[string]interface{}{"error": err.Error()}, - }}, - }}, - } - return ret, nil - } - // Start async request to Scope3 - asyncRequest.fetchScope3SegmentsAsync(&bidRequest) + asyncRequest.fetchScope3SegmentsAsync(payload.Request.BidRequest) return ret, nil } @@ -313,50 +288,64 @@ func (m *Module) HandleAuctionResponseHook( // Add segments to the auction response ret.ChangeSet.AddMutation( func(payload hookstage.AuctionResponsePayload) (hookstage.AuctionResponsePayload, error) { - // Add Scope3 segments to the response ext so publisher can use them - if payload.BidResponse.Ext == nil { - payload.BidResponse.Ext = json.RawMessage("{}") - } - - var extMap map[string]interface{} - if err = jsonutil.Unmarshal(payload.BidResponse.Ext, &extMap); err != nil { - extMap = make(map[string]interface{}) - } - // Add segments as individual targeting keys for GAM integration if m.cfg.AddToTargeting { - prebidMap, ok := extMap["prebid"].(map[string]interface{}) - if !ok { - prebidMap = make(map[string]interface{}) - extMap["prebid"] = prebidMap - } - targetingMap, ok := prebidMap["targeting"].(map[string]interface{}) - if !ok { - targetingMap = make(map[string]interface{}) - prebidMap["targeting"] = targetingMap - } // Add each segment as individual targeting key for _, segment := range segments { - if strings.HasPrefix(segment, scope3MacroKeyPlusSeparator) { - macroKeyVal := strings.Split(segment, scope3MacroSeparator) - if len(macroKeyVal) != 2 { - continue - } - targetingMap[macroKeyVal[0]] = macroKeyVal[1] - } else { - targetingMap[segment] = "true" + segmentKeyVal := strings.Split(segment, scope3Separator) + if len(segmentKeyVal) != 2 { + logger.Infof("Skipping malformed segment: %s", segment) + continue + } + newPayload, err := sjson.SetBytes(payload.BidResponse.Ext, "prebid.targeting."+segmentKeyVal[0], segmentKeyVal[1]) + if err != nil { + logger.Errorf("Failed to add targeting to bid: %v", err) + continue } + payload.BidResponse.Ext = newPayload } } - // Always add to a dedicated scope3 section for publisher flexibility - extMap["scope3"] = map[string]interface{}{ - "segments": segments, + // Add to a dedicated scope3 section for publisher flexibility when configured + if m.cfg.AddScope3TargetingSection { + newPayload, err := sjson.SetBytes(payload.BidResponse.Ext, "scope3.segments", segments) + if err != nil { + logger.Errorf("Failed to add scope3 section to bid response ext: %v", err) + } else { + payload.BidResponse.Ext = newPayload + } } - extResp, err := jsonutil.Marshal(extMap) - if err == nil { - payload.BidResponse.Ext = extResp + // also add to seatbid[].bid[] + for seatBid := range iterutil.SlicePointerValues(payload.BidResponse.SeatBid) { + for bid := range iterutil.SlicePointerValues(seatBid.Bid) { + // Add segments as individual targeting keys for GAM integration + if m.cfg.AddToTargeting { + for _, segment := range segments { + segmentKeyVal := strings.Split(segment, scope3Separator) + if len(segmentKeyVal) != 2 { + logger.Infof("Skipping malformed segment in bid targeting: %s", segment) + continue + } + newPayload, err := sjson.SetBytes(bid.Ext, "prebid.targeting."+segmentKeyVal[0], segmentKeyVal[1]) + if err != nil { + logger.Errorf("Failed to add targeting to bid: %v", err) + continue + } + bid.Ext = newPayload + } + } + + // Always add to a dedicated scope3 section for publisher flexibility + if m.cfg.AddScope3TargetingSection { + newPayload, err := sjson.SetBytes(bid.Ext, "scope3.segments", segments) + if err != nil { + logger.Errorf("Failed to add scope3 section to bid response ext: %v", err) + } else { + bid.Ext = newPayload + } + } + } } return payload, nil @@ -375,7 +364,7 @@ func (m *Module) fetchScope3Segments(ctx context.Context, bidRequest *openrtb2.B // Check cache first if segments, err := m.cache.Get(cacheKey); err == nil { - return strings.Split(string(segments), ","), nil + return strings.Split(string(segments), scope3CacheKeySeparator), nil } // Apply privacy masking before sending to Scope3 @@ -421,31 +410,17 @@ func (m *Module) fetchScope3Segments(ctx context.Context, bidRequest *openrtb2.B return nil, err } - // Extract unique segments (exclude destination) - segmentMap := make(map[string]bool) - var macro string - for data := range iterutil.SlicePointerValues(scope3Resp.Data) { - // Extract actual segments from impression-level data - for imp := range iterutil.SlicePointerValues(data.Imp) { - if imp.Ext != nil && imp.Ext.Scope3 != nil { - if imp.Ext.Scope3.Macro != "" { - macro = imp.Ext.Scope3.Macro - } - for segment := range iterutil.SlicePointerValues(imp.Ext.Scope3.Segments) { - segmentMap[segment.ID] = true - } - } - } + segments := []string{} + if len(scope3Resp.Include) > 0 { + segmentsStr := scope3IncludeKeyPlusSeparator + strings.Join(scope3Resp.Include, ",") + segments = append(segments, segmentsStr) } - - // Convert to slice - segments := slices.AppendSeq(make([]string, 0, len(segmentMap)), maps.Keys(segmentMap)) - if macro != "" { - segments = append(segments, scope3MacroKeyPlusSeparator+macro) + if scope3Resp.Macro != "" { + segments = append(segments, scope3MacroKeyPlusSeparator+scope3Resp.Macro) } // Cache the result - err = m.cache.Set(cacheKey, []byte(strings.Join(segments, ",")), m.cfg.CacheTTL) + err = m.cache.Set(cacheKey, []byte(strings.Join(segments, scope3CacheKeySeparator)), m.cfg.CacheTTL) if err != nil { logger.Infof("could not set segments in cache: %v", err) } diff --git a/modules/scope3/rtd/module_test.go b/modules/scope3/rtd/module_test.go index 8b46d3bb..041d549f 100644 --- a/modules/scope3/rtd/module_test.go +++ b/modules/scope3/rtd/module_test.go @@ -17,6 +17,7 @@ import ( "github.com/prebid/openrtb/v20/openrtb2" "github.com/prebid/prebid-server/v3/hooks/hookstage" "github.com/prebid/prebid-server/v3/modules/moduledeps" + "github.com/prebid/prebid-server/v3/openrtb_ext" "github.com/prebid/prebid-server/v3/util/jsonutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -128,25 +129,13 @@ func TestScope3APIIntegration(t *testing.T) { // Return mock Scope3 response with segments response := `{ - "data": [ - { - "destination": "triplelift.com", - "imp": [ - { - "id": "test-imp-1", - "ext": { - "scope3": { - "macro": "test-macro", - "segments": [ - {"id": "gmp_eligible"}, - {"id": "gmp_plus_eligible"} - ] - } - } - } - ] - } - ] + "aee_signals": { + "include": [ + "gmp_eligible", + "gmp_plus_eligible" + ], + "macro": "test-macro" + } }` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -197,34 +186,153 @@ func TestScope3APIIntegration(t *testing.T) { ctx := context.Background() segments, err := module.fetchScope3Segments(ctx, bidRequest) require.NoError(t, err) - assert.Len(t, segments, 3) - assert.ElementsMatch(t, segments, []string{"gmp_eligible", "gmp_plus_eligible", "scope3_macro;test-macro"}) - assert.NotContains(t, segments, "triplelift.com") // Should not include destination + assert.Len(t, segments, 2) + assert.ElementsMatch(t, segments, []string{"scope3_include;gmp_eligible,gmp_plus_eligible", "scope3_macro;test-macro"}) } -func TestScope3APIIntegrationWithTargeting(t *testing.T) { - // Create mock server that returns segments +func TestScope3APIIntegrationNoSegments(t *testing.T) { + // Create mock Scope3 API server mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and headers + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "test-auth-key", r.Header.Get("x-scope3-auth")) + + // Return mock Scope3 response with segments response := `{ - "data": [ + "aee_signals": {} + }` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer mockServer.Close() + + // Create module with mock server endpoint + config := json.RawMessage(`{ + "endpoint": "` + mockServer.URL + `", + "auth_key": "test-auth-key", + "timeout_ms": 1000, + "cache_ttl_seconds": 60, + "add_to_targeting": false + }`) + + moduleInterface, err := Builder(config, getTestModuleDeps(t)) + require.NoError(t, err) + module := moduleInterface.(*Module) + + // Create test bid request + width := int64(300) + height := int64(250) + bidRequest := &openrtb2.BidRequest{ + ID: "test-auction", + Imp: []openrtb2.Imp{{ + ID: "test-imp-1", + Banner: &openrtb2.Banner{W: &width, H: &height}, + }}, + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test-page", + }, + User: &openrtb2.User{ + ID: "test-user", + Ext: json.RawMessage(`{ + "eids": [ + { + "source": "liveramp.com", + "uids": [{"id": "test-ramp-id"}] + } + ] + }`), + }, + } + + // Test fetchScope3Segments + ctx := context.Background() + segments, err := module.fetchScope3Segments(ctx, bidRequest) + require.NoError(t, err) + assert.Len(t, segments, 0) + + // Test entrypoint hook + entrypointResult, err := module.HandleEntrypointHook(ctx, hookstage.ModuleInvocationContext{}, getTestEntrypointPayload(t)) + require.NoError(t, err) + assert.NotNil(t, entrypointResult.ModuleContext[asyncRequestKey]) + + payload := hookstage.ProcessedAuctionRequestPayload{ + Request: &openrtb_ext.RequestWrapper{ + BidRequest: bidRequest, + }, + } + + // Test raw auction hook + miCtx := hookstage.ModuleInvocationContext{ + ModuleContext: entrypointResult.ModuleContext, + } + _, err = module.HandleProcessedAuctionHook(ctx, miCtx, payload) + require.NoError(t, err) + + // Test auction response hook + responsePayload := hookstage.AuctionResponsePayload{ + BidResponse: &openrtb2.BidResponse{ + ID: "test-response", + Ext: json.RawMessage(`{}`), + SeatBid: []openrtb2.SeatBid{ { - "destination": "triplelift.com", - "imp": [ + Seat: "test-seat", + Bid: []openrtb2.Bid{ { - "id": "test-imp-1", - "ext": { - "scope3": { - "macro": "test-macro", - "segments": [ - {"id": "test_segment_1"}, - {"id": "test_segment_2"} - ] - } - } - } - ] - } - ] + ID: "test-bid-1", + ImpID: "test-imp-1", + Price: 1.0, + Ext: json.RawMessage(`{}`), + }, + { + ID: "test-bid-2", + ImpID: "test-imp-2", + Price: 2.0, + Ext: json.RawMessage(`{}`), + }, + }, + }, + }, + }, + } + + responseResult, err := module.HandleAuctionResponseHook(ctx, miCtx, responsePayload) + require.NoError(t, err) + + // Verify the response was modified + assert.True(t, len(responseResult.ChangeSet.Mutations()) > 0) + + // Apply the mutations and check the result + modifiedPayload := responsePayload + for _, mutation := range responseResult.ChangeSet.Mutations() { + var err error + modifiedPayload, err = mutation.Apply(modifiedPayload) + require.NoError(t, err) + } + + // Parse the modified response + var extMap map[string]interface{} + err = json.Unmarshal(modifiedPayload.BidResponse.Ext, &extMap) + require.NoError(t, err) + + // Verify scope3 section exists + _, exists := extMap["scope3"].(map[string]interface{}) + require.False(t, exists) +} + +func TestScope3APIIntegrationWithTargeting(t *testing.T) { + // Create mock server that returns segments + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "aee_signals": { + "include": [ + "test_segment_1", + "test_segment_2" + ], + "macro": "test-macro" + } }` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -237,7 +345,8 @@ func TestScope3APIIntegrationWithTargeting(t *testing.T) { "endpoint": "` + mockServer.URL + `", "auth_key": "test-auth-key", "timeout_ms": 1000, - "add_to_targeting": true + "add_to_targeting": true, + "add_scope3_targeting_section": true }`) moduleInterface, err := Builder(config, getTestModuleDeps(t)) @@ -255,24 +364,32 @@ func TestScope3APIIntegrationWithTargeting(t *testing.T) { // Create test request payload width := int64(300) height := int64(250) - bidRequest := openrtb2.BidRequest{ - ID: "test-auction", - Imp: []openrtb2.Imp{{ - ID: "test-imp-1", - Banner: &openrtb2.Banner{W: &width, H: &height}, - }}, - Site: &openrtb2.Site{ - Domain: "example.com", - Page: "https://example.com/test", + payload := hookstage.ProcessedAuctionRequestPayload{ + Request: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + ID: "test-auction", + Imp: []openrtb2.Imp{ + { + ID: "test-imp-1", + Banner: &openrtb2.Banner{W: &width, H: &height}, + }, + { + ID: "test-imp-2", + Banner: &openrtb2.Banner{W: &width, H: &height}, + }}, + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + }, }, } - requestPayload, _ := json.Marshal(bidRequest) // Test raw auction hook miCtx := hookstage.ModuleInvocationContext{ ModuleContext: entrypointResult.ModuleContext, } - _, err = module.HandleRawAuctionHook(ctx, miCtx, requestPayload) + _, err = module.HandleProcessedAuctionHook(ctx, miCtx, payload) require.NoError(t, err) // Test auction response hook @@ -280,6 +397,42 @@ func TestScope3APIIntegrationWithTargeting(t *testing.T) { BidResponse: &openrtb2.BidResponse{ ID: "test-response", Ext: json.RawMessage(`{}`), + SeatBid: []openrtb2.SeatBid{ + { + Seat: "test-seat", + Bid: []openrtb2.Bid{ + { + ID: "test-bid-1", + ImpID: "test-imp-1", + Price: 1.0, + Ext: json.RawMessage(`{}`), + }, + { + ID: "test-bid-2", + ImpID: "test-imp-2", + Price: 2.0, + Ext: json.RawMessage(`{}`), + }, + }, + }, + { + Seat: "test-seat2", + Bid: []openrtb2.Bid{ + { + ID: "test-bid-3", + ImpID: "test-imp-3", + Price: 1.0, + Ext: json.RawMessage(`{}`), + }, + { + ID: "test-bid-4", + ImpID: "test-imp-4", + Price: 2.0, + Ext: json.RawMessage(`{}`), + }, + }, + }, + }, }, } @@ -307,7 +460,158 @@ func TestScope3APIIntegrationWithTargeting(t *testing.T) { require.True(t, exists) segments, exists := scope3Data["segments"].([]interface{}) require.True(t, exists) - assert.Len(t, segments, 3) + assert.Len(t, segments, 2) + + // Verify targeting section exists (add_to_targeting: true) + prebidData, exists := extMap["prebid"].(map[string]interface{}) + require.True(t, exists, "prebid section missing") + targetingData, exists := prebidData["targeting"].(map[string]interface{}) + require.True(t, exists, "targeting section missing") + + // Check individual targeting keys + assert.Equal(t, "test_segment_1,test_segment_2", targetingData["scope3_include"]) + assert.Equal(t, "test-macro", targetingData["scope3_macro"]) + + // check seatbid + assert.Len(t, modifiedPayload.BidResponse.SeatBid, 2) + assert.Len(t, modifiedPayload.BidResponse.SeatBid[0].Bid, 2) + assert.Len(t, modifiedPayload.BidResponse.SeatBid[1].Bid, 2) + + for _, seatbid := range modifiedPayload.BidResponse.SeatBid { + for _, bid := range seatbid.Bid { + // Parse the modified response + var extBidMap map[string]interface{} + err = json.Unmarshal(bid.Ext, &extBidMap) + require.NoError(t, err) + + // Verify scope3 section exists + scope3DataSeatBid, exists := extBidMap["scope3"].(map[string]interface{}) + require.True(t, exists, "scope3 section missing") + segmentsSeatBid, exists := scope3DataSeatBid["segments"].([]interface{}) + require.True(t, exists, "segments section missing") + assert.Len(t, segmentsSeatBid, 2) + + // Verify targeting section exists (add_to_targeting: true) + prebidDataSeatBid, exists := extBidMap["prebid"].(map[string]interface{}) + require.True(t, exists, "prebid section missing") + targetingDataSeatBid, exists := prebidDataSeatBid["targeting"].(map[string]interface{}) + require.True(t, exists, "targeting section missing") + + // Check individual targeting keys + assert.Equal(t, "test_segment_1,test_segment_2", targetingDataSeatBid["scope3_include"]) + assert.Equal(t, "test-macro", targetingDataSeatBid["scope3_macro"]) + } + } +} + +func TestScope3APIIntegrationWithTargetingNoScope3Section(t *testing.T) { + // Create mock server that returns segments + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "aee_signals": { + "include": [ + "test_segment_1", + "test_segment_2" + ], + "macro": "test-macro" + } + }` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer mockServer.Close() + + // Create module with targeting enabled + config := json.RawMessage(`{ + "endpoint": "` + mockServer.URL + `", + "auth_key": "test-auth-key", + "timeout_ms": 1000, + "add_to_targeting": true, + "add_scope3_targeting_section": false + }`) + + moduleInterface, err := Builder(config, getTestModuleDeps(t)) + require.NoError(t, err) + module := moduleInterface.(*Module) + + // Test full hook workflow + ctx := context.Background() + + // Test entrypoint hook + entrypointResult, err := module.HandleEntrypointHook(ctx, hookstage.ModuleInvocationContext{}, getTestEntrypointPayload(t)) + require.NoError(t, err) + assert.NotNil(t, entrypointResult.ModuleContext[asyncRequestKey]) + + // Create test request payload + width := int64(300) + height := int64(250) + payload := hookstage.ProcessedAuctionRequestPayload{ + Request: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + ID: "test-auction", + Imp: []openrtb2.Imp{{ + ID: "test-imp-1", + Banner: &openrtb2.Banner{W: &width, H: &height}, + }}, + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + }, + }, + } + + // Test raw auction hook + miCtx := hookstage.ModuleInvocationContext{ + ModuleContext: entrypointResult.ModuleContext, + } + _, err = module.HandleProcessedAuctionHook(ctx, miCtx, payload) + require.NoError(t, err) + + // Test auction response hook + responsePayload := hookstage.AuctionResponsePayload{ + BidResponse: &openrtb2.BidResponse{ + ID: "test-response", + Ext: json.RawMessage(`{}`), + SeatBid: []openrtb2.SeatBid{ + { + Seat: "test-seat", + Bid: []openrtb2.Bid{ + { + ID: "test-bid-1", + ImpID: "test-imp-1", + Price: 1.0, + Ext: json.RawMessage(`{}`), + }, + }, + }, + }, + }, + } + + responseResult, err := module.HandleAuctionResponseHook(ctx, miCtx, responsePayload) + require.NoError(t, err) + + // Verify the response was modified + assert.True(t, len(responseResult.ChangeSet.Mutations()) > 0) + + // Apply the mutations and check the result + modifiedPayload := responsePayload + for _, mutation := range responseResult.ChangeSet.Mutations() { + var err error + modifiedPayload, err = mutation.Apply(modifiedPayload) + require.NoError(t, err) + } + + // Parse the modified response + var extMap map[string]interface{} + err = json.Unmarshal(modifiedPayload.BidResponse.Ext, &extMap) + require.NoError(t, err) + + // Verify scope3 section exists + _, exists := extMap["scope3"].(map[string]interface{}) + require.False(t, exists) // Verify targeting section exists (add_to_targeting: true) prebidData, exists := extMap["prebid"].(map[string]interface{}) @@ -316,33 +620,44 @@ func TestScope3APIIntegrationWithTargeting(t *testing.T) { require.True(t, exists) // Check individual targeting keys - assert.Equal(t, "true", targetingData["test_segment_1"]) - assert.Equal(t, "true", targetingData["test_segment_2"]) + assert.Equal(t, "test_segment_1,test_segment_2", targetingData["scope3_include"]) assert.Equal(t, "test-macro", targetingData["scope3_macro"]) + + // check seatbid + assert.Len(t, modifiedPayload.BidResponse.SeatBid, 1) + assert.Len(t, modifiedPayload.BidResponse.SeatBid[0].Bid, 1) + + // Parse the modified response + var extBidMap map[string]interface{} + err = json.Unmarshal(modifiedPayload.BidResponse.SeatBid[0].Bid[0].Ext, &extBidMap) + require.NoError(t, err) + + // Verify scope3 section exists + _, exists = extBidMap["scope3"].(map[string]interface{}) + require.False(t, exists) + + // Verify targeting section exists (add_to_targeting: true) + prebidDataSeatBid, exists := extBidMap["prebid"].(map[string]interface{}) + require.True(t, exists, "prebid section missing") + targetingDataSeatBid, exists := prebidDataSeatBid["targeting"].(map[string]interface{}) + require.True(t, exists, "targeting section missing") + + // Check individual targeting keys + assert.Equal(t, "test_segment_1,test_segment_2", targetingDataSeatBid["scope3_include"]) + assert.Equal(t, "test-macro", targetingDataSeatBid["scope3_macro"]) } func TestScope3APIIntegrationWithExistingPrebidTargeting(t *testing.T) { // Create mock server that returns segments mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response := `{ - "data": [ - { - "destination": "triplelift.com", - "imp": [ - { - "id": "test-imp-1", - "ext": { - "scope3": { - "segments": [ - {"id": "test_segment_1"}, - {"id": "test_segment_2"} - ] - } - } - } - ] - } - ] + "aee_signals": { + "include": [ + "test_segment_1", + "test_segment_2" + ], + "macro": "test-macro" + } }` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -355,7 +670,8 @@ func TestScope3APIIntegrationWithExistingPrebidTargeting(t *testing.T) { "endpoint": "` + mockServer.URL + `", "auth_key": "test-auth-key", "timeout_ms": 1000, - "add_to_targeting": true + "add_to_targeting": true, + "add_scope3_targeting_section": true }`) moduleInterface, err := Builder(config, getTestModuleDeps(t)) @@ -373,24 +689,27 @@ func TestScope3APIIntegrationWithExistingPrebidTargeting(t *testing.T) { // Create test request payload width := int64(300) height := int64(250) - bidRequest := openrtb2.BidRequest{ - ID: "test-auction", - Imp: []openrtb2.Imp{{ - ID: "test-imp-1", - Banner: &openrtb2.Banner{W: &width, H: &height}, - }}, - Site: &openrtb2.Site{ - Domain: "example.com", - Page: "https://example.com/test", + payload := hookstage.ProcessedAuctionRequestPayload{ + Request: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + ID: "test-auction", + Imp: []openrtb2.Imp{{ + ID: "test-imp-1", + Banner: &openrtb2.Banner{W: &width, H: &height}, + }}, + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + }, }, } - requestPayload, _ := json.Marshal(bidRequest) // Test raw auction hook miCtx := hookstage.ModuleInvocationContext{ ModuleContext: entrypointResult.ModuleContext, } - _, err = module.HandleRawAuctionHook(ctx, miCtx, requestPayload) + _, err = module.HandleProcessedAuctionHook(ctx, miCtx, payload) require.NoError(t, err) // Test auction response hook @@ -434,33 +753,21 @@ func TestScope3APIIntegrationWithExistingPrebidTargeting(t *testing.T) { require.True(t, exists) // Check individual targeting keys - assert.Equal(t, "true", targetingData["segment_existing"]) - assert.Equal(t, "true", targetingData["test_segment_1"]) - assert.Equal(t, "true", targetingData["test_segment_2"]) + assert.Equal(t, "test_segment_1,test_segment_2", targetingData["scope3_include"]) + assert.Equal(t, "test-macro", targetingData["scope3_macro"]) } func TestScope3APIIntegrationWithExistingPrebidNoTargeting(t *testing.T) { // Create mock server that returns segments mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { response := `{ - "data": [ - { - "destination": "triplelift.com", - "imp": [ - { - "id": "test-imp-1", - "ext": { - "scope3": { - "segments": [ - {"id": "test_segment_1"}, - {"id": "test_segment_2"} - ] - } - } - } - ] - } - ] + "aee_signals": { + "include": [ + "test_segment_1", + "test_segment_2" + ], + "macro": "test-macro" + } }` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -473,7 +780,8 @@ func TestScope3APIIntegrationWithExistingPrebidNoTargeting(t *testing.T) { "endpoint": "` + mockServer.URL + `", "auth_key": "test-auth-key", "timeout_ms": 1000, - "add_to_targeting": true + "add_to_targeting": true, + "add_scope3_targeting_section": true }`) moduleInterface, err := Builder(config, getTestModuleDeps(t)) @@ -491,24 +799,27 @@ func TestScope3APIIntegrationWithExistingPrebidNoTargeting(t *testing.T) { // Create test request payload width := int64(300) height := int64(250) - bidRequest := openrtb2.BidRequest{ - ID: "test-auction", - Imp: []openrtb2.Imp{{ - ID: "test-imp-1", - Banner: &openrtb2.Banner{W: &width, H: &height}, - }}, - Site: &openrtb2.Site{ - Domain: "example.com", - Page: "https://example.com/test", + payload := hookstage.ProcessedAuctionRequestPayload{ + Request: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + ID: "test-auction", + Imp: []openrtb2.Imp{{ + ID: "test-imp-1", + Banner: &openrtb2.Banner{W: &width, H: &height}, + }}, + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + }, }, } - requestPayload, _ := json.Marshal(bidRequest) // Test raw auction hook miCtx := hookstage.ModuleInvocationContext{ ModuleContext: entrypointResult.ModuleContext, } - _, err = module.HandleRawAuctionHook(ctx, miCtx, requestPayload) + _, err = module.HandleProcessedAuctionHook(ctx, miCtx, payload) require.NoError(t, err) // Test auction response hook @@ -552,8 +863,8 @@ func TestScope3APIIntegrationWithExistingPrebidNoTargeting(t *testing.T) { require.True(t, exists) // Check individual targeting keys - assert.Equal(t, "true", targetingData["test_segment_1"]) - assert.Equal(t, "true", targetingData["test_segment_2"]) + assert.Equal(t, "test_segment_1,test_segment_2", targetingData["scope3_include"]) + assert.Equal(t, "test-macro", targetingData["scope3_macro"]) } func TestScope3APIError(t *testing.T) {