diff --git a/config/account.go b/config/account.go index f1de54f79a2..ee131873e35 100644 --- a/config/account.go +++ b/config/account.go @@ -58,17 +58,30 @@ type AccountCCPA struct { } type AccountPriceFloors struct { - Enabled bool `mapstructure:"enabled" json:"enabled"` - EnforceFloorsRate int `mapstructure:"enforce_floors_rate" json:"enforce_floors_rate"` - AdjustForBidAdjustment bool `mapstructure:"adjust_for_bid_adjustment" json:"adjust_for_bid_adjustment"` - EnforceDealFloors bool `mapstructure:"enforce_deal_floors" json:"enforce_deal_floors"` - UseDynamicData bool `mapstructure:"use_dynamic_data" json:"use_dynamic_data"` - MaxRule int `mapstructure:"max_rules" json:"max_rules"` - MaxSchemaDims int `mapstructure:"max_schema_dims" json:"max_schema_dims"` + Enabled bool `mapstructure:"enabled" json:"enabled"` + EnforceFloorsRate int `mapstructure:"enforce_floors_rate" json:"enforce_floors_rate"` + AdjustForBidAdjustment bool `mapstructure:"adjust_for_bid_adjustment" json:"adjust_for_bid_adjustment"` + EnforceDealFloors bool `mapstructure:"enforce_deal_floors" json:"enforce_deal_floors"` + UseDynamicData bool `mapstructure:"use_dynamic_data" json:"use_dynamic_data"` + MaxRule int `mapstructure:"max_rules" json:"max_rules"` + MaxSchemaDims int `mapstructure:"max_schema_dims" json:"max_schema_dims"` + Fetcher AccountFloorFetch `mapstructure:"fetch" json:"fetch"` } -func (pf *AccountPriceFloors) validate(errs []error) []error { +// AccountFloorFetch defines the configuration for dynamic floors fetching. +type AccountFloorFetch struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + URL string `mapstructure:"url" json:"url"` + Timeout int `mapstructure:"timeout_ms" json:"timeout_ms"` + MaxFileSizeKB int `mapstructure:"max_file_size_kb" json:"max_file_size_kb"` + MaxRules int `mapstructure:"max_rules" json:"max_rules"` + MaxAge int `mapstructure:"max_age_sec" json:"max_age_sec"` + Period int `mapstructure:"period_sec" json:"period_sec"` + MaxSchemaDims int `mapstructure:"max_schema_dims" json:"max_schema_dims"` + AccountID string `mapstructure:"accountID" json:"accountID"` +} +func (pf *AccountPriceFloors) validate(errs []error) []error { if pf.EnforceFloorsRate < 0 || pf.EnforceFloorsRate > 100 { errs = append(errs, fmt.Errorf(`account_defaults.price_floors.enforce_floors_rate should be between 0 and 100`)) } @@ -81,6 +94,34 @@ func (pf *AccountPriceFloors) validate(errs []error) []error { errs = append(errs, fmt.Errorf(`account_defaults.price_floors.max_schema_dims should be between 0 and 20`)) } + if pf.Fetcher.Period > pf.Fetcher.MaxAge { + errs = append(errs, fmt.Errorf(`account_defaults.price_floors.fetch.period_sec should be less than account_defaults.price_floors.fetch.max_age_sec`)) + } + + if pf.Fetcher.Period < 300 { + errs = append(errs, fmt.Errorf(`account_defaults.price_floors.fetch.period_sec should not be less than 300 seconds`)) + } + + if pf.Fetcher.MaxAge < 600 { + errs = append(errs, fmt.Errorf(`account_defaults.price_floors.fetch.max_age_sec should not be less than 600 seconds and greater than maximum integer value`)) + } + + if !(pf.Fetcher.Timeout > 10 && pf.Fetcher.Timeout < 10000) { + errs = append(errs, fmt.Errorf(`account_defaults.price_floors.fetch.timeout_ms should be between 10 to 10,000 miliseconds`)) + } + + if pf.Fetcher.MaxRules < 0 { + errs = append(errs, fmt.Errorf(`account_defaults.price_floors.fetch.max_rules should be greater than or equal to 0`)) + } + + if pf.Fetcher.MaxFileSizeKB < 0 { + errs = append(errs, fmt.Errorf(`account_defaults.price_floors.fetch.max_file_size_kb should be greater than or equal to 0`)) + } + + if !(pf.Fetcher.MaxSchemaDims >= 0 && pf.Fetcher.MaxSchemaDims < 20) { + errs = append(errs, fmt.Errorf(`account_defaults.price_floors.fetch.max_schema_dims should not be less than 0 and greater than 20`)) + } + return errs } diff --git a/config/account_test.go b/config/account_test.go index 648618f2706..65e7f3e8716 100644 --- a/config/account_test.go +++ b/config/account_test.go @@ -795,7 +795,6 @@ func TestModulesGetConfig(t *testing.T) { } func TestAccountPriceFloorsValidate(t *testing.T) { - tests := []struct { description string pf *AccountPriceFloors @@ -807,12 +806,22 @@ func TestAccountPriceFloorsValidate(t *testing.T) { EnforceFloorsRate: 100, MaxRule: 200, MaxSchemaDims: 10, + Fetcher: AccountFloorFetch{ + Period: 300, + MaxAge: 600, + Timeout: 12, + }, }, }, { description: "Invalid configuration: EnforceFloorRate:110", pf: &AccountPriceFloors{ EnforceFloorsRate: 110, + Fetcher: AccountFloorFetch{ + Period: 300, + MaxAge: 600, + Timeout: 12, + }, }, want: []error{errors.New("account_defaults.price_floors.enforce_floors_rate should be between 0 and 100")}, }, @@ -820,6 +829,11 @@ func TestAccountPriceFloorsValidate(t *testing.T) { description: "Invalid configuration: EnforceFloorRate:-10", pf: &AccountPriceFloors{ EnforceFloorsRate: -10, + Fetcher: AccountFloorFetch{ + Period: 300, + MaxAge: 600, + Timeout: 12, + }, }, want: []error{errors.New("account_defaults.price_floors.enforce_floors_rate should be between 0 and 100")}, }, @@ -827,6 +841,11 @@ func TestAccountPriceFloorsValidate(t *testing.T) { description: "Invalid configuration: MaxRule:-20", pf: &AccountPriceFloors{ MaxRule: -20, + Fetcher: AccountFloorFetch{ + Period: 300, + MaxAge: 600, + Timeout: 12, + }, }, want: []error{errors.New("account_defaults.price_floors.max_rules should be between 0 and 2147483647")}, }, @@ -834,9 +853,116 @@ func TestAccountPriceFloorsValidate(t *testing.T) { description: "Invalid configuration: MaxSchemaDims:100", pf: &AccountPriceFloors{ MaxSchemaDims: 100, + Fetcher: AccountFloorFetch{ + Period: 300, + MaxAge: 600, + Timeout: 12, + }, }, want: []error{errors.New("account_defaults.price_floors.max_schema_dims should be between 0 and 20")}, }, + { + description: "Invalid period for fetch", + pf: &AccountPriceFloors{ + EnforceFloorsRate: 100, + MaxRule: 200, + MaxSchemaDims: 10, + Fetcher: AccountFloorFetch{ + Period: 100, + MaxAge: 600, + Timeout: 12, + }, + }, + want: []error{errors.New("account_defaults.price_floors.fetch.period_sec should not be less than 300 seconds")}, + }, + { + description: "Invalid max age for fetch", + pf: &AccountPriceFloors{ + EnforceFloorsRate: 100, + MaxRule: 200, + MaxSchemaDims: 10, + Fetcher: AccountFloorFetch{ + Period: 300, + MaxAge: 500, + Timeout: 12, + }, + }, + want: []error{errors.New("account_defaults.price_floors.fetch.max_age_sec should not be less than 600 seconds and greater than maximum integer value")}, + }, + { + description: "Period is greater than max age", + pf: &AccountPriceFloors{ + EnforceFloorsRate: 100, + MaxRule: 200, + MaxSchemaDims: 10, + Fetcher: AccountFloorFetch{ + Period: 700, + MaxAge: 600, + Timeout: 12, + }, + }, + want: []error{errors.New("account_defaults.price_floors.fetch.period_sec should be less than account_defaults.price_floors.fetch.max_age_sec")}, + }, + { + description: "Invalid timeout", + pf: &AccountPriceFloors{ + EnforceFloorsRate: 100, + MaxRule: 200, + MaxSchemaDims: 10, + Fetcher: AccountFloorFetch{ + Period: 300, + MaxAge: 600, + Timeout: 4, + }, + }, + want: []error{errors.New("account_defaults.price_floors.fetch.timeout_ms should be between 10 to 10,000 miliseconds")}, + }, + { + description: "Invalid Max Rules", + pf: &AccountPriceFloors{ + EnforceFloorsRate: 100, + MaxRule: 200, + MaxSchemaDims: 10, + Fetcher: AccountFloorFetch{ + Period: 300, + MaxAge: 600, + Timeout: 12, + MaxRules: -2, + }, + }, + want: []error{errors.New("account_defaults.price_floors.fetch.max_rules should be greater than or equal to 0")}, + }, + { + description: "Invalid Max File size", + pf: &AccountPriceFloors{ + EnforceFloorsRate: 100, + MaxRule: 200, + MaxSchemaDims: 10, + Fetcher: AccountFloorFetch{ + Period: 300, + MaxAge: 600, + Timeout: 12, + MaxFileSizeKB: -1, + }, + }, + want: []error{errors.New("account_defaults.price_floors.fetch.max_file_size_kb should be greater than or equal to 0")}, + }, + { + description: "Invalid max_schema_dims", + pf: &AccountPriceFloors{ + EnforceFloorsRate: 100, + MaxRule: 200, + MaxSchemaDims: 10, + Fetcher: AccountFloorFetch{ + Period: 300, + MaxAge: 600, + Timeout: 12, + MaxFileSizeKB: 10, + MaxSchemaDims: 40, + }, + }, + want: []error{errors.New("account_defaults.price_floors.fetch.max_schema_dims should not be less than 0 and greater than 20")}, + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { diff --git a/config/config.go b/config/config.go index 11b7eb5841e..fc1a6b40be2 100644 --- a/config/config.go +++ b/config/config.go @@ -103,7 +103,16 @@ type Configuration struct { } type PriceFloors struct { - Enabled bool `mapstructure:"enabled"` + Enabled bool `mapstructure:"enabled"` + Fetcher PriceFloorFetcher `mapstructure:"fetcher"` +} + +type PriceFloorFetcher struct { + HttpClient HTTPClient `mapstructure:"http_client"` + CacheSize int `mapstructure:"cache_size_mb"` + Worker int `mapstructure:"worker"` + Capacity int `mapstructure:"capacity"` + MaxRetries int `mapstructure:"max_retries"` } const MIN_COOKIE_SIZE_BYTES = 500 @@ -140,10 +149,6 @@ func (cfg *Configuration) validate(v *viper.Viper) []error { glog.Warning(`account_defaults.events has no effect as the feature is under development.`) } - if cfg.PriceFloors.Enabled { - glog.Warning(`cfg.PriceFloors.Enabled will currently not do anything as price floors feature is still under development.`) - } - errs = cfg.Experiment.validate(errs) errs = cfg.BidderInfos.validate(errs) errs = cfg.AccountDefaults.Privacy.IPv6Config.Validate(errs) @@ -1060,9 +1065,30 @@ func SetupViper(v *viper.Viper, filename string, bidderInfos BidderInfos) { v.SetDefault("account_defaults.price_floors.use_dynamic_data", false) v.SetDefault("account_defaults.price_floors.max_rules", 100) v.SetDefault("account_defaults.price_floors.max_schema_dims", 3) + v.SetDefault("account_defaults.price_floors.fetch.enabled", false) + v.SetDefault("account_defaults.price_floors.fetch.url", "") + v.SetDefault("account_defaults.price_floors.fetch.timeout_ms", 3000) + v.SetDefault("account_defaults.price_floors.fetch.max_file_size_kb", 100) + v.SetDefault("account_defaults.price_floors.fetch.max_rules", 1000) + v.SetDefault("account_defaults.price_floors.fetch.max_age_sec", 86400) + v.SetDefault("account_defaults.price_floors.fetch.period_sec", 3600) + v.SetDefault("account_defaults.price_floors.fetch.max_schema_dims", 0) + + v.SetDefault("account_defaults.events_enabled", false) v.SetDefault("account_defaults.privacy.ipv6.anon_keep_bits", 56) v.SetDefault("account_defaults.privacy.ipv4.anon_keep_bits", 24) + //Defaults for Price floor fetcher + v.SetDefault("price_floors.fetcher.worker", 20) + v.SetDefault("price_floors.fetcher.capacity", 20000) + v.SetDefault("price_floors.fetcher.cache_size_mb", 64) + v.SetDefault("price_floors.fetcher.http_client.max_connections_per_host", 0) // unlimited + v.SetDefault("price_floors.fetcher.http_client.max_idle_connections", 40) + v.SetDefault("price_floors.fetcher.http_client.max_idle_connections_per_host", 2) + v.SetDefault("price_floors.fetcher.http_client.idle_connection_timeout_seconds", 60) + v.SetDefault("price_floors.fetcher.max_retries", 10) + + v.SetDefault("account_defaults.events_enabled", false) v.SetDefault("compression.response.enable_gzip", false) v.SetDefault("compression.request.enable_gzip", false) diff --git a/config/config_test.go b/config/config_test.go index 535b4e7b099..cb9801ef018 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -174,6 +174,14 @@ func TestDefaults(t *testing.T) { //Assert the price floor default values cmpBools(t, "price_floors.enabled", false, cfg.PriceFloors.Enabled) + cmpInts(t, "price_floors.fetcher.worker", 20, cfg.PriceFloors.Fetcher.Worker) + cmpInts(t, "price_floors.fetcher.capacity", 20000, cfg.PriceFloors.Fetcher.Capacity) + cmpInts(t, "price_floors.fetcher.cache_size_mb", 64, cfg.PriceFloors.Fetcher.CacheSize) + cmpInts(t, "price_floors.fetcher.http_client.max_connections_per_host", 0, cfg.PriceFloors.Fetcher.HttpClient.MaxConnsPerHost) + cmpInts(t, "price_floors.fetcher.http_client.max_idle_connections", 40, cfg.PriceFloors.Fetcher.HttpClient.MaxIdleConns) + cmpInts(t, "price_floors.fetcher.http_client.max_idle_connections_per_host", 2, cfg.PriceFloors.Fetcher.HttpClient.MaxIdleConnsPerHost) + cmpInts(t, "price_floors.fetcher.http_client.idle_connection_timeout_seconds", 60, cfg.PriceFloors.Fetcher.HttpClient.IdleConnTimeout) + cmpInts(t, "price_floors.fetcher.max_retries", 10, cfg.PriceFloors.Fetcher.MaxRetries) // Assert compression related defaults cmpBools(t, "compression.request.enable_gzip", false, cfg.Compression.Request.GZIP) @@ -186,6 +194,15 @@ func TestDefaults(t *testing.T) { cmpBools(t, "account_defaults.price_floors.use_dynamic_data", false, cfg.AccountDefaults.PriceFloors.UseDynamicData) cmpInts(t, "account_defaults.price_floors.max_rules", 100, cfg.AccountDefaults.PriceFloors.MaxRule) cmpInts(t, "account_defaults.price_floors.max_schema_dims", 3, cfg.AccountDefaults.PriceFloors.MaxSchemaDims) + cmpBools(t, "account_defaults.price_floors.fetch.enabled", false, cfg.AccountDefaults.PriceFloors.Fetcher.Enabled) + cmpStrings(t, "account_defaults.price_floors.fetch.url", "", cfg.AccountDefaults.PriceFloors.Fetcher.URL) + cmpInts(t, "account_defaults.price_floors.fetch.timeout_ms", 3000, cfg.AccountDefaults.PriceFloors.Fetcher.Timeout) + cmpInts(t, "account_defaults.price_floors.fetch.max_file_size_kb", 100, cfg.AccountDefaults.PriceFloors.Fetcher.MaxFileSizeKB) + cmpInts(t, "account_defaults.price_floors.fetch.max_rules", 1000, cfg.AccountDefaults.PriceFloors.Fetcher.MaxRules) + cmpInts(t, "account_defaults.price_floors.fetch.period_sec", 3600, cfg.AccountDefaults.PriceFloors.Fetcher.Period) + cmpInts(t, "account_defaults.price_floors.fetch.max_age_sec", 86400, cfg.AccountDefaults.PriceFloors.Fetcher.MaxAge) + cmpInts(t, "account_defaults.price_floors.fetch.max_schema_dims", 0, cfg.AccountDefaults.PriceFloors.Fetcher.MaxSchemaDims) + cmpBools(t, "account_defaults.events.enabled", false, cfg.AccountDefaults.Events.Enabled) cmpBools(t, "hooks.enabled", false, cfg.Hooks.Enabled) @@ -450,6 +467,16 @@ hooks: enabled: true price_floors: enabled: true + fetcher: + worker: 20 + capacity: 20000 + cache_size_mb: 8 + http_client: + max_connections_per_host: 5 + max_idle_connections: 1 + max_idle_connections_per_host: 2 + idle_connection_timeout_seconds: 10 + max_retries: 5 account_defaults: events: enabled: true @@ -461,6 +488,15 @@ account_defaults: use_dynamic_data: true max_rules: 120 max_schema_dims: 5 + fetch: + enabled: true + url: http://test.com/floors + timeout_ms: 500 + max_file_size_kb: 200 + max_rules: 500 + period_sec: 2000 + max_age_sec: 6000 + max_schema_dims: 10 privacy: ipv6: anon_keep_bits: 50 @@ -556,6 +592,14 @@ func TestFullConfig(t *testing.T) { //Assert the price floor values cmpBools(t, "price_floors.enabled", true, cfg.PriceFloors.Enabled) + cmpInts(t, "price_floors.fetcher.worker", 20, cfg.PriceFloors.Fetcher.Worker) + cmpInts(t, "price_floors.fetcher.capacity", 20000, cfg.PriceFloors.Fetcher.Capacity) + cmpInts(t, "price_floors.fetcher.cache_size_mb", 8, cfg.PriceFloors.Fetcher.CacheSize) + cmpInts(t, "price_floors.fetcher.http_client.max_connections_per_host", 5, cfg.PriceFloors.Fetcher.HttpClient.MaxConnsPerHost) + cmpInts(t, "price_floors.fetcher.http_client.max_idle_connections", 1, cfg.PriceFloors.Fetcher.HttpClient.MaxIdleConns) + cmpInts(t, "price_floors.fetcher.http_client.max_idle_connections_per_host", 2, cfg.PriceFloors.Fetcher.HttpClient.MaxIdleConnsPerHost) + cmpInts(t, "price_floors.fetcher.http_client.idle_connection_timeout_seconds", 10, cfg.PriceFloors.Fetcher.HttpClient.IdleConnTimeout) + cmpInts(t, "price_floors.fetcher.max_retries", 5, cfg.PriceFloors.Fetcher.MaxRetries) cmpBools(t, "account_defaults.price_floors.enabled", true, cfg.AccountDefaults.PriceFloors.Enabled) cmpInts(t, "account_defaults.price_floors.enforce_floors_rate", 50, cfg.AccountDefaults.PriceFloors.EnforceFloorsRate) cmpBools(t, "account_defaults.price_floors.adjust_for_bid_adjustment", false, cfg.AccountDefaults.PriceFloors.AdjustForBidAdjustment) @@ -563,6 +607,15 @@ func TestFullConfig(t *testing.T) { cmpBools(t, "account_defaults.price_floors.use_dynamic_data", true, cfg.AccountDefaults.PriceFloors.UseDynamicData) cmpInts(t, "account_defaults.price_floors.max_rules", 120, cfg.AccountDefaults.PriceFloors.MaxRule) cmpInts(t, "account_defaults.price_floors.max_schema_dims", 5, cfg.AccountDefaults.PriceFloors.MaxSchemaDims) + cmpBools(t, "account_defaults.price_floors.fetch.enabled", true, cfg.AccountDefaults.PriceFloors.Fetcher.Enabled) + cmpStrings(t, "account_defaults.price_floors.fetch.url", "http://test.com/floors", cfg.AccountDefaults.PriceFloors.Fetcher.URL) + cmpInts(t, "account_defaults.price_floors.fetch.timeout_ms", 500, cfg.AccountDefaults.PriceFloors.Fetcher.Timeout) + cmpInts(t, "account_defaults.price_floors.fetch.max_file_size_kb", 200, cfg.AccountDefaults.PriceFloors.Fetcher.MaxFileSizeKB) + cmpInts(t, "account_defaults.price_floors.fetch.max_rules", 500, cfg.AccountDefaults.PriceFloors.Fetcher.MaxRules) + cmpInts(t, "account_defaults.price_floors.fetch.period_sec", 2000, cfg.AccountDefaults.PriceFloors.Fetcher.Period) + cmpInts(t, "account_defaults.price_floors.fetch.max_age_sec", 6000, cfg.AccountDefaults.PriceFloors.Fetcher.MaxAge) + cmpInts(t, "account_defaults.price_floors.fetch.max_schema_dims", 10, cfg.AccountDefaults.PriceFloors.Fetcher.MaxSchemaDims) + cmpBools(t, "account_defaults.events.enabled", true, cfg.AccountDefaults.Events.Enabled) cmpInts(t, "account_defaults.privacy.ipv6.anon_keep_bits", 50, cfg.AccountDefaults.Privacy.IPv6Config.AnonKeepBits) @@ -771,6 +824,15 @@ func TestValidateConfig(t *testing.T) { Files: FileFetcherConfig{Enabled: true}, InMemoryCache: InMemoryCache{Type: "none"}, }, + AccountDefaults: Account{ + PriceFloors: AccountPriceFloors{ + Fetcher: AccountFloorFetch{ + Timeout: 100, + Period: 300, + MaxAge: 600, + }, + }, + }, } v := viper.New() diff --git a/endpoints/openrtb2/auction_benchmark_test.go b/endpoints/openrtb2/auction_benchmark_test.go index 88a55e627e7..835d2920af4 100644 --- a/endpoints/openrtb2/auction_benchmark_test.go +++ b/endpoints/openrtb2/auction_benchmark_test.go @@ -95,6 +95,7 @@ func BenchmarkOpenrtbEndpoint(b *testing.B) { empty_fetcher.EmptyFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), + nil, ) endpoint, _ := NewEndpoint( diff --git a/endpoints/openrtb2/test_utils.go b/endpoints/openrtb2/test_utils.go index cfc8f5d8dac..e3e7803e551 100644 --- a/endpoints/openrtb2/test_utils.go +++ b/endpoints/openrtb2/test_utils.go @@ -1204,6 +1204,7 @@ func buildTestExchange(testCfg *testConfigValues, adapterMap map[openrtb_ext.Bid mockFetcher, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), + nil, ) testExchange = &exchangeTestWrapper{ diff --git a/exchange/exchange.go b/exchange/exchange.go index b07c4bdf2a5..bb59fe90d43 100644 --- a/exchange/exchange.go +++ b/exchange/exchange.go @@ -81,7 +81,8 @@ type exchange struct { bidValidationEnforcement config.Validations requestSplitter requestSplitter macroReplacer macros.Replacer - floor config.PriceFloors + priceFloorEnabled bool + priceFloorFetcher floors.FloorFetcher } // Container to pass out response ext data from the GetAllBids goroutines back into the main thread @@ -130,7 +131,7 @@ func (randomDeduplicateBidBooleanGenerator) Generate() bool { return rand.Intn(100) < 50 } -func NewExchange(adapters map[openrtb_ext.BidderName]AdaptedBidder, cache prebid_cache_client.Client, cfg *config.Configuration, syncersByBidder map[string]usersync.Syncer, metricsEngine metrics.MetricsEngine, infos config.BidderInfos, gdprPermsBuilder gdpr.PermissionsBuilder, currencyConverter *currency.RateConverter, categoriesFetcher stored_requests.CategoryFetcher, adsCertSigner adscert.Signer, macroReplacer macros.Replacer) Exchange { +func NewExchange(adapters map[openrtb_ext.BidderName]AdaptedBidder, cache prebid_cache_client.Client, cfg *config.Configuration, syncersByBidder map[string]usersync.Syncer, metricsEngine metrics.MetricsEngine, infos config.BidderInfos, gdprPermsBuilder gdpr.PermissionsBuilder, currencyConverter *currency.RateConverter, categoriesFetcher stored_requests.CategoryFetcher, adsCertSigner adscert.Signer, macroReplacer macros.Replacer, priceFloorFetcher floors.FloorFetcher) Exchange { bidderToSyncerKey := map[string]string{} for bidder, syncer := range syncersByBidder { bidderToSyncerKey[bidder] = syncer.Key() @@ -175,7 +176,8 @@ func NewExchange(adapters map[openrtb_ext.BidderName]AdaptedBidder, cache prebid bidValidationEnforcement: cfg.Validations, requestSplitter: requestSplitter, macroReplacer: macroReplacer, - floor: cfg.PriceFloors, + priceFloorEnabled: cfg.PriceFloors.Enabled, + priceFloorFetcher: priceFloorFetcher, } } @@ -233,7 +235,6 @@ func (e *exchange) HoldAuction(ctx context.Context, r *AuctionRequest, debugLog return nil, nil } - var floorErrs []error err := r.HookExecutor.ExecuteProcessedAuctionStage(r.BidRequestWrapper) if err != nil { return nil, err @@ -268,8 +269,9 @@ func (e *exchange) HoldAuction(ctx context.Context, r *AuctionRequest, debugLog // Get currency rates conversions for the auction conversions := e.getAuctionCurrencyRates(requestExtPrebid.CurrencyConversions) - if e.floor.Enabled { - floorErrs = floors.EnrichWithPriceFloors(r.BidRequestWrapper, r.Account, conversions) + var floorErrs []error + if e.priceFloorEnabled { + floorErrs = floors.EnrichWithPriceFloors(r.BidRequestWrapper, r.Account, conversions, e.priceFloorFetcher) } responseDebugAllow, accountDebugAllow, debugLog := getDebugInfo(r.BidRequestWrapper.Test, requestExtPrebid, r.Account.DebugAllow, debugLog) @@ -386,7 +388,7 @@ func (e *exchange) HoldAuction(ctx context.Context, r *AuctionRequest, debugLog ) if anyBidsReturned { - if e.floor.Enabled { + if e.priceFloorEnabled { var rejectedBids []*entities.PbsOrtbSeatBid var enforceErrs []error diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go index bfeb9374af3..a537054cbce 100644 --- a/exchange/exchange_test.go +++ b/exchange/exchange_test.go @@ -83,7 +83,7 @@ func TestNewExchange(t *testing.T) { }, }.Builder - e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer()).(*exchange) + e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange) for _, bidderName := range knownAdapters { if _, ok := e.adapterMap[bidderName]; !ok { if biddersInfo[string(bidderName)].IsEnabled() { @@ -133,7 +133,7 @@ func TestCharacterEscape(t *testing.T) { }, }.Builder - e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer()).(*exchange) + e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange) // 3) Build all the parameters e.buildBidResponse(ctx.Background(), liveA... ) needs //liveAdapters []openrtb_ext.BidderName, @@ -816,6 +816,14 @@ func TestAdapterCurrency(t *testing.T) { } } +type mockPriceFloorFetcher struct{} + +func (mpf *mockPriceFloorFetcher) Fetch(configs config.AccountPriceFloors) (*openrtb_ext.PriceFloorRules, string) { + return nil, openrtb_ext.FetchNone +} + +func (mpf *mockPriceFloorFetcher) Stop() {} + func TestFloorsSignalling(t *testing.T) { fakeCurrencyClient := &fakeCurrencyRatesHttpClient{ @@ -840,7 +848,8 @@ func TestFloorsSignalling(t *testing.T) { currencyConverter: currencyConverter, categoriesFetcher: nilCategoryFetcher{}, bidIDGenerator: &mockBidIDGenerator{false, false}, - floor: config.PriceFloors{Enabled: true}, + priceFloorEnabled: true, + priceFloorFetcher: &mockPriceFloorFetcher{}, } e.requestSplitter = requestSplitter{ me: e.me, @@ -1428,7 +1437,7 @@ func TestGetBidCacheInfoEndToEnd(t *testing.T) { }, }.Builder - e := NewExchange(adapters, pbc, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer()).(*exchange) + e := NewExchange(adapters, pbc, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange) // 3) Build all the parameters e.buildBidResponse(ctx.Background(), liveA... ) needs liveAdapters := []openrtb_ext.BidderName{bidderName} @@ -1785,7 +1794,7 @@ func TestBidResponseCurrency(t *testing.T) { }, }.Builder - e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer()).(*exchange) + e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange) liveAdapters := make([]openrtb_ext.BidderName, 1) liveAdapters[0] = "appnexus" @@ -1931,7 +1940,7 @@ func TestBidResponseImpExtInfo(t *testing.T) { t.Fatalf("Error intializing adapters: %v", adaptersErr) } - e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, nil, gdprPermsBuilder, nil, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer()).(*exchange) + e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, nil, gdprPermsBuilder, nil, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange) liveAdapters := make([]openrtb_ext.BidderName, 1) liveAdapters[0] = "appnexus" @@ -2023,7 +2032,7 @@ func TestRaceIntegration(t *testing.T) { }, }.Builder - ex := NewExchange(adapters, &wellBehavedCache{}, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, &nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer()).(*exchange) + ex := NewExchange(adapters, &wellBehavedCache{}, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, &nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange) _, err = ex.HoldAuction(context.Background(), auctionRequest, &debugLog) if err != nil { t.Errorf("HoldAuction returned unexpected error: %v", err) @@ -2121,7 +2130,7 @@ func TestPanicRecovery(t *testing.T) { }, }.Builder - e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer()).(*exchange) + e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange) chBids := make(chan *bidResponseWrapper, 1) panicker := func(bidderRequest BidderRequest, conversions currency.Conversions) { @@ -2191,7 +2200,7 @@ func TestPanicRecoveryHighLevel(t *testing.T) { allowAllBidders: true, }, }.Builder - e := NewExchange(adapters, &mockCache{}, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, categoriesFetcher, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer()).(*exchange) + e := NewExchange(adapters, &mockCache{}, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, categoriesFetcher, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange) e.adapterMap[openrtb_ext.BidderBeachfront] = panicingAdapter{} e.adapterMap[openrtb_ext.BidderAppnexus] = panicingAdapter{} @@ -2634,7 +2643,8 @@ func newExchangeForTests(t *testing.T, filename string, expectations map[string] server: config.Server{ExternalUrl: server.ExternalUrl, GvlID: server.GvlID, DataCenter: server.DataCenter}, bidValidationEnforcement: hostBidValidation, requestSplitter: requestSplitter, - floor: config.PriceFloors{Enabled: floorsFlag}, + priceFloorEnabled: floorsFlag, + priceFloorFetcher: &mockPriceFloorFetcher{}, } } @@ -4660,7 +4670,7 @@ func TestPassExperimentConfigsToHoldAuction(t *testing.T) { }, }.Builder - e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &signer, macros.NewStringIndexBasedReplacer()).(*exchange) + e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &signer, macros.NewStringIndexBasedReplacer(), nil).(*exchange) // Define mock incoming bid requeset mockBidRequest := &openrtb2.BidRequest{ diff --git a/floors/fetcher.go b/floors/fetcher.go new file mode 100644 index 00000000000..6df43445010 --- /dev/null +++ b/floors/fetcher.go @@ -0,0 +1,331 @@ +package floors + +import ( + "container/heap" + "context" + "encoding/json" + "errors" + "io" + "math" + "net/http" + "strconv" + "time" + + "github.com/alitto/pond" + validator "github.com/asaskevich/govalidator" + "github.com/coocood/freecache" + "github.com/golang/glog" + "github.com/prebid/prebid-server/v2/config" + "github.com/prebid/prebid-server/v2/metrics" + "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/prebid/prebid-server/v2/util/timeutil" +) + +var refetchCheckInterval = 300 + +type fetchInfo struct { + config.AccountFloorFetch + fetchTime int64 + refetchRequest bool + retryCount int +} + +type WorkerPool interface { + TrySubmit(task func()) bool + Stop() +} + +type FloorFetcher interface { + Fetch(configs config.AccountPriceFloors) (*openrtb_ext.PriceFloorRules, string) + Stop() +} + +type PriceFloorFetcher struct { + pool WorkerPool // Goroutines worker pool + fetchQueue FetchQueue // Priority Queue to fetch floor data + fetchInProgress map[string]bool // Map of URL with fetch status + configReceiver chan fetchInfo // Channel which recieves URLs to be fetched + done chan struct{} // Channel to close fetcher + cache *freecache.Cache // cache + httpClient *http.Client // http client to fetch data from url + time timeutil.Time // time interface to record request timings + metricEngine metrics.MetricsEngine // Records malfunctions in dynamic fetch + maxRetries int // Max number of retries for failing URLs +} + +type FetchQueue []*fetchInfo + +func (fq FetchQueue) Len() int { + return len(fq) +} + +func (fq FetchQueue) Less(i, j int) bool { + return fq[i].fetchTime < fq[j].fetchTime +} + +func (fq FetchQueue) Swap(i, j int) { + fq[i], fq[j] = fq[j], fq[i] +} + +func (fq *FetchQueue) Push(element interface{}) { + fetchInfo := element.(*fetchInfo) + *fq = append(*fq, fetchInfo) +} + +func (fq *FetchQueue) Pop() interface{} { + old := *fq + n := len(old) + fetchInfo := old[n-1] + old[n-1] = nil + *fq = old[0 : n-1] + return fetchInfo +} + +func (fq *FetchQueue) Top() *fetchInfo { + old := *fq + if len(old) == 0 { + return nil + } + return old[0] +} + +func workerPanicHandler(p interface{}) { + glog.Errorf("floor fetcher worker panicked: %v", p) +} + +func NewPriceFloorFetcher(config config.PriceFloors, httpClient *http.Client, metricEngine metrics.MetricsEngine) *PriceFloorFetcher { + if !config.Enabled { + return nil + } + + floorFetcher := PriceFloorFetcher{ + pool: pond.New(config.Fetcher.Worker, config.Fetcher.Capacity, pond.PanicHandler(workerPanicHandler)), + fetchQueue: make(FetchQueue, 0, 100), + fetchInProgress: make(map[string]bool), + configReceiver: make(chan fetchInfo, config.Fetcher.Capacity), + done: make(chan struct{}), + cache: freecache.NewCache(config.Fetcher.CacheSize * 1024 * 1024), + httpClient: httpClient, + time: &timeutil.RealTime{}, + metricEngine: metricEngine, + maxRetries: config.Fetcher.MaxRetries, + } + + go floorFetcher.Fetcher() + + return &floorFetcher +} + +func (f *PriceFloorFetcher) SetWithExpiry(key string, value json.RawMessage, cacheExpiry int) { + f.cache.Set([]byte(key), value, cacheExpiry) +} + +func (f *PriceFloorFetcher) Get(key string) (json.RawMessage, bool) { + data, err := f.cache.Get([]byte(key)) + if err != nil { + return nil, false + } + + return data, true +} + +func (f *PriceFloorFetcher) Fetch(config config.AccountPriceFloors) (*openrtb_ext.PriceFloorRules, string) { + if f == nil || !config.UseDynamicData || len(config.Fetcher.URL) == 0 || !validator.IsURL(config.Fetcher.URL) { + return nil, openrtb_ext.FetchNone + } + + // Check for floors JSON in cache + if result, found := f.Get(config.Fetcher.URL); found { + var fetchedFloorData openrtb_ext.PriceFloorRules + if err := json.Unmarshal(result, &fetchedFloorData); err != nil || fetchedFloorData.Data == nil { + return nil, openrtb_ext.FetchError + } + return &fetchedFloorData, openrtb_ext.FetchSuccess + } + + //miss: push to channel to fetch and return empty response + if config.Enabled && config.Fetcher.Enabled && config.Fetcher.Timeout > 0 { + fetchConfig := fetchInfo{AccountFloorFetch: config.Fetcher, fetchTime: f.time.Now().Unix(), refetchRequest: false, retryCount: 0} + f.configReceiver <- fetchConfig + } + + return nil, openrtb_ext.FetchInprogress +} + +func (f *PriceFloorFetcher) worker(fetchConfig fetchInfo) { + floorData, fetchedMaxAge := f.fetchAndValidate(fetchConfig.AccountFloorFetch) + if floorData != nil { + // Reset retry count when data is successfully fetched + fetchConfig.retryCount = 0 + + // Update cache with new floor rules + cacheExpiry := fetchConfig.AccountFloorFetch.MaxAge + if fetchedMaxAge != 0 { + cacheExpiry = fetchedMaxAge + } + floorData, err := json.Marshal(floorData) + if err != nil { + glog.Errorf("Error while marshaling fetched floor data for url %s", fetchConfig.AccountFloorFetch.URL) + } else { + f.SetWithExpiry(fetchConfig.AccountFloorFetch.URL, floorData, cacheExpiry) + } + } else { + fetchConfig.retryCount++ + } + + // Send to refetch channel + if fetchConfig.retryCount < f.maxRetries { + fetchConfig.fetchTime = f.time.Now().Add(time.Duration(fetchConfig.AccountFloorFetch.Period) * time.Second).Unix() + fetchConfig.refetchRequest = true + f.configReceiver <- fetchConfig + } +} + +// Stop terminates price floor fetcher +func (f *PriceFloorFetcher) Stop() { + if f == nil { + return + } + + close(f.done) + f.pool.Stop() + close(f.configReceiver) +} + +func (f *PriceFloorFetcher) submit(fetchConfig *fetchInfo) { + status := f.pool.TrySubmit(func() { + f.worker(*fetchConfig) + }) + if !status { + heap.Push(&f.fetchQueue, fetchConfig) + } +} + +func (f *PriceFloorFetcher) Fetcher() { + //Create Ticker of 5 minutes + ticker := time.NewTicker(time.Duration(refetchCheckInterval) * time.Second) + + for { + select { + case fetchConfig := <-f.configReceiver: + if fetchConfig.refetchRequest { + heap.Push(&f.fetchQueue, &fetchConfig) + } else { + if _, ok := f.fetchInProgress[fetchConfig.URL]; !ok { + f.fetchInProgress[fetchConfig.URL] = true + f.submit(&fetchConfig) + } + } + case <-ticker.C: + currentTime := f.time.Now().Unix() + for top := f.fetchQueue.Top(); top != nil && top.fetchTime <= currentTime; top = f.fetchQueue.Top() { + nextFetch := heap.Pop(&f.fetchQueue) + f.submit(nextFetch.(*fetchInfo)) + } + case <-f.done: + ticker.Stop() + glog.Info("Price Floor fetcher terminated") + return + } + } +} + +func (f *PriceFloorFetcher) fetchAndValidate(config config.AccountFloorFetch) (*openrtb_ext.PriceFloorRules, int) { + floorResp, maxAge, err := f.fetchFloorRulesFromURL(config) + if floorResp == nil || err != nil { + glog.Errorf("Error while fetching floor data from URL: %s, reason : %s", config.URL, err.Error()) + return nil, 0 + } + + if len(floorResp) > (config.MaxFileSizeKB * 1024) { + glog.Errorf("Recieved invalid floor data from URL: %s, reason : floor file size is greater than MaxFileSize", config.URL) + return nil, 0 + } + + var priceFloors openrtb_ext.PriceFloorRules + if err = json.Unmarshal(floorResp, &priceFloors.Data); err != nil { + glog.Errorf("Recieved invalid price floor json from URL: %s", config.URL) + return nil, 0 + } + + if err := validateRules(config, &priceFloors); err != nil { + glog.Errorf("Validation failed for floor JSON from URL: %s, reason: %s", config.URL, err.Error()) + return nil, 0 + } + + return &priceFloors, maxAge +} + +// fetchFloorRulesFromURL returns a price floor JSON and time for which this JSON is valid +// from provided URL with timeout constraints +func (f *PriceFloorFetcher) fetchFloorRulesFromURL(config config.AccountFloorFetch) ([]byte, int, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Timeout)*time.Millisecond) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, config.URL, nil) + if err != nil { + return nil, 0, errors.New("error while forming http fetch request : " + err.Error()) + } + + httpResp, err := f.httpClient.Do(httpReq) + if err != nil { + return nil, 0, errors.New("error while getting response from url : " + err.Error()) + } + + if httpResp.StatusCode != http.StatusOK { + return nil, 0, errors.New("no response from server") + } + + var maxAge int + if maxAgeStr := httpResp.Header.Get("max-age"); maxAgeStr != "" { + maxAge, err = strconv.Atoi(maxAgeStr) + if err != nil { + glog.Errorf("max-age in header is malformed for url %s", config.URL) + } + if maxAge <= config.Period || maxAge > math.MaxInt32 { + glog.Errorf("Invalid max-age = %s provided, value should be valid integer and should be within (%v, %v)", maxAgeStr, config.Period, math.MaxInt32) + } + } + + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, 0, errors.New("unable to read response") + } + defer httpResp.Body.Close() + + return respBody, maxAge, nil +} + +func validateRules(config config.AccountFloorFetch, priceFloors *openrtb_ext.PriceFloorRules) error { + if priceFloors.Data == nil { + return errors.New("empty data in floor JSON") + } + + if len(priceFloors.Data.ModelGroups) == 0 { + return errors.New("no model groups found in price floor data") + } + + if priceFloors.Data.SkipRate < 0 || priceFloors.Data.SkipRate > 100 { + return errors.New("skip rate should be greater than or equal to 0 and less than 100") + } + + for _, modelGroup := range priceFloors.Data.ModelGroups { + if len(modelGroup.Values) == 0 || len(modelGroup.Values) > config.MaxRules { + return errors.New("invalid number of floor rules, floor rules should be greater than zero and less than MaxRules specified in account config") + } + + if modelGroup.ModelWeight != nil && (*modelGroup.ModelWeight < 1 || *modelGroup.ModelWeight > 100) { + return errors.New("modelGroup[].modelWeight should be greater than or equal to 1 and less than 100") + } + + if modelGroup.SkipRate < 0 || modelGroup.SkipRate > 100 { + return errors.New("model group skip rate should be greater than or equal to 0 and less than 100") + } + + if modelGroup.Default < 0 { + return errors.New("modelGroup.Default should be greater than 0") + } + } + + return nil +} diff --git a/floors/fetcher_test.go b/floors/fetcher_test.go new file mode 100644 index 00000000000..085fd3edd1b --- /dev/null +++ b/floors/fetcher_test.go @@ -0,0 +1,1279 @@ +package floors + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/alitto/pond" + "github.com/coocood/freecache" + "github.com/prebid/prebid-server/v2/config" + "github.com/prebid/prebid-server/v2/metrics" + metricsConf "github.com/prebid/prebid-server/v2/metrics/config" + "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/prebid/prebid-server/v2/util/timeutil" + "github.com/stretchr/testify/assert" +) + +const MaxAge = "max-age" + +func TestFetchQueueLen(t *testing.T) { + tests := []struct { + name string + fq FetchQueue + want int + }{ + { + name: "Queue is empty", + fq: make(FetchQueue, 0), + want: 0, + }, + { + name: "Queue is of lenght 1", + fq: make(FetchQueue, 1), + want: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.fq.Len(); got != tt.want { + t.Errorf("FetchQueue.Len() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFetchQueueLess(t *testing.T) { + type args struct { + i int + j int + } + tests := []struct { + name string + fq FetchQueue + args args + want bool + }{ + { + name: "first fetchperiod is less than second", + fq: FetchQueue{&fetchInfo{fetchTime: 10}, &fetchInfo{fetchTime: 20}}, + args: args{i: 0, j: 1}, + want: true, + }, + { + name: "first fetchperiod is greater than second", + fq: FetchQueue{&fetchInfo{fetchTime: 30}, &fetchInfo{fetchTime: 10}}, + args: args{i: 0, j: 1}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.fq.Less(tt.args.i, tt.args.j); got != tt.want { + t.Errorf("FetchQueue.Less() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFetchQueueSwap(t *testing.T) { + type args struct { + i int + j int + } + tests := []struct { + name string + fq FetchQueue + args args + }{ + { + name: "Swap two elements at index i and j", + fq: FetchQueue{&fetchInfo{fetchTime: 30}, &fetchInfo{fetchTime: 10}}, + args: args{i: 0, j: 1}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fInfo1, fInfo2 := tt.fq[0], tt.fq[1] + tt.fq.Swap(tt.args.i, tt.args.j) + assert.Equal(t, fInfo1, tt.fq[1], "elements are not swapped") + assert.Equal(t, fInfo2, tt.fq[0], "elements are not swapped") + }) + } +} + +func TestFetchQueuePush(t *testing.T) { + type args struct { + element interface{} + } + tests := []struct { + name string + fq *FetchQueue + args args + }{ + { + name: "Push element to queue", + fq: &FetchQueue{}, + args: args{element: &fetchInfo{fetchTime: 10}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.fq.Push(tt.args.element) + q := *tt.fq + assert.Equal(t, q[0], &fetchInfo{fetchTime: 10}) + }) + } +} + +func TestFetchQueuePop(t *testing.T) { + tests := []struct { + name string + fq *FetchQueue + want interface{} + }{ + { + name: "Pop element from queue", + fq: &FetchQueue{&fetchInfo{fetchTime: 10}}, + want: &fetchInfo{fetchTime: 10}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.fq.Pop(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("FetchQueue.Pop() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFetchQueueTop(t *testing.T) { + tests := []struct { + name string + fq *FetchQueue + want *fetchInfo + }{ + { + name: "Get top element from queue", + fq: &FetchQueue{&fetchInfo{fetchTime: 20}}, + want: &fetchInfo{fetchTime: 20}, + }, + { + name: "Queue is empty", + fq: &FetchQueue{}, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.fq.Top(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("FetchQueue.Top() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidatePriceFloorRules(t *testing.T) { + var zero = 0 + var one_o_one = 101 + var testURL = "abc.com" + type args struct { + configs config.AccountFloorFetch + priceFloors *openrtb_ext.PriceFloorRules + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Price floor data is empty", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + URL: testURL, + Timeout: 5, + MaxFileSizeKB: 20, + MaxRules: 5, + MaxAge: 20, + Period: 10, + }, + priceFloors: &openrtb_ext.PriceFloorRules{}, + }, + wantErr: true, + }, + { + name: "Model group array is empty", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + URL: testURL, + Timeout: 5, + MaxFileSizeKB: 20, + MaxRules: 5, + MaxAge: 20, + Period: 10, + }, + priceFloors: &openrtb_ext.PriceFloorRules{ + Data: &openrtb_ext.PriceFloorData{}, + }, + }, + wantErr: true, + }, + { + name: "floor rules is empty", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + URL: testURL, + Timeout: 5, + MaxFileSizeKB: 20, + MaxRules: 5, + MaxAge: 20, + Period: 10, + }, + priceFloors: &openrtb_ext.PriceFloorRules{ + Data: &openrtb_ext.PriceFloorData{ + ModelGroups: []openrtb_ext.PriceFloorModelGroup{{ + Values: map[string]float64{}, + }}, + }, + }, + }, + wantErr: true, + }, + { + name: "floor rules is grater than max floor rules", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + URL: testURL, + Timeout: 5, + MaxFileSizeKB: 20, + MaxRules: 0, + MaxAge: 20, + Period: 10, + }, + priceFloors: &openrtb_ext.PriceFloorRules{ + Data: &openrtb_ext.PriceFloorData{ + ModelGroups: []openrtb_ext.PriceFloorModelGroup{{ + Values: map[string]float64{ + "*|*|www.website.com": 15.01, + }, + }}, + }, + }, + }, + wantErr: true, + }, + { + name: "Modelweight is zero", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + URL: testURL, + Timeout: 5, + MaxFileSizeKB: 20, + MaxRules: 1, + MaxAge: 20, + Period: 10, + }, + priceFloors: &openrtb_ext.PriceFloorRules{ + Data: &openrtb_ext.PriceFloorData{ + ModelGroups: []openrtb_ext.PriceFloorModelGroup{{ + Values: map[string]float64{ + "*|*|www.website.com": 15.01, + }, + ModelWeight: &zero, + }}, + }, + }, + }, + wantErr: true, + }, + { + name: "Modelweight is 101", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + URL: testURL, + Timeout: 5, + MaxFileSizeKB: 20, + MaxRules: 1, + MaxAge: 20, + Period: 10, + }, + priceFloors: &openrtb_ext.PriceFloorRules{ + Data: &openrtb_ext.PriceFloorData{ + ModelGroups: []openrtb_ext.PriceFloorModelGroup{{ + Values: map[string]float64{ + "*|*|www.website.com": 15.01, + }, + ModelWeight: &one_o_one, + }}, + }, + }, + }, + wantErr: true, + }, + { + name: "skiprate is 101", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + URL: testURL, + Timeout: 5, + MaxFileSizeKB: 20, + MaxRules: 1, + MaxAge: 20, + Period: 10, + }, + priceFloors: &openrtb_ext.PriceFloorRules{ + Data: &openrtb_ext.PriceFloorData{ + ModelGroups: []openrtb_ext.PriceFloorModelGroup{{ + Values: map[string]float64{ + "*|*|www.website.com": 15.01, + }, + SkipRate: 101, + }}, + }, + }, + }, + wantErr: true, + }, + { + name: "Default is -1", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + URL: testURL, + Timeout: 5, + MaxFileSizeKB: 20, + MaxRules: 1, + MaxAge: 20, + Period: 10, + }, + priceFloors: &openrtb_ext.PriceFloorRules{ + Data: &openrtb_ext.PriceFloorData{ + ModelGroups: []openrtb_ext.PriceFloorModelGroup{{ + Values: map[string]float64{ + "*|*|www.website.com": 15.01, + }, + Default: -1, + }}, + }, + }, + }, + wantErr: true, + }, + { + name: "Invalid skip rate in data", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + URL: testURL, + Timeout: 5, + MaxFileSizeKB: 20, + MaxRules: 1, + MaxAge: 20, + Period: 10, + }, + priceFloors: &openrtb_ext.PriceFloorRules{ + Data: &openrtb_ext.PriceFloorData{ + SkipRate: -44, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{{ + Values: map[string]float64{ + "*|*|www.website.com": 15.01, + }, + }}, + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateRules(tt.args.configs, tt.args.priceFloors); (err != nil) != tt.wantErr { + t.Errorf("validatePriceFloorRules() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFetchFloorRulesFromURL(t *testing.T) { + mockHandler := func(mockResponse []byte, mockStatus int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Add("Content-Length", "645") + w.Header().Add(MaxAge, "20") + w.WriteHeader(mockStatus) + w.Write(mockResponse) + }) + } + + type args struct { + configs config.AccountFloorFetch + } + tests := []struct { + name string + args args + response []byte + responseStatus int + want []byte + want1 int + wantErr bool + }{ + { + name: "Floor data is successfully returned", + args: args{ + configs: config.AccountFloorFetch{ + URL: "", + Timeout: 60, + Period: 300, + }, + }, + response: func() []byte { + data := `{"data":{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]},"enabled":true,"floormin":1,"enforcement":{"enforcepbs":false,"floordeals":true}}` + return []byte(data) + }(), + responseStatus: 200, + want: func() []byte { + data := `{"data":{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]},"enabled":true,"floormin":1,"enforcement":{"enforcepbs":false,"floordeals":true}}` + return []byte(data) + }(), + want1: 20, + wantErr: false, + }, + { + name: "Time out occured", + args: args{ + configs: config.AccountFloorFetch{ + URL: "", + Timeout: 0, + Period: 300, + }, + }, + want1: 0, + responseStatus: 200, + wantErr: true, + }, + { + name: "Invalid URL", + args: args{ + configs: config.AccountFloorFetch{ + URL: "%%", + Timeout: 10, + Period: 300, + }, + }, + want1: 0, + responseStatus: 200, + wantErr: true, + }, + { + name: "No response from server", + args: args{ + configs: config.AccountFloorFetch{ + URL: "", + Timeout: 10, + Period: 300, + }, + }, + want1: 0, + responseStatus: 500, + wantErr: true, + }, + { + name: "Invalid response", + args: args{ + configs: config.AccountFloorFetch{ + URL: "", + Timeout: 10, + Period: 300, + }, + }, + want1: 0, + response: []byte("1"), + responseStatus: 200, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockHttpServer := httptest.NewServer(mockHandler(tt.response, tt.responseStatus)) + defer mockHttpServer.Close() + + if tt.args.configs.URL == "" { + tt.args.configs.URL = mockHttpServer.URL + } + pff := PriceFloorFetcher{ + httpClient: mockHttpServer.Client(), + } + got, got1, err := pff.fetchFloorRulesFromURL(tt.args.configs) + if (err != nil) != tt.wantErr { + t.Errorf("fetchFloorRulesFromURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("fetchFloorRulesFromURL() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("fetchFloorRulesFromURL() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} + +func TestFetchFloorRulesFromURLInvalidMaxAge(t *testing.T) { + mockHandler := func(mockResponse []byte, mockStatus int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Add("Content-Length", "645") + w.Header().Add(MaxAge, "abc") + w.WriteHeader(mockStatus) + w.Write(mockResponse) + }) + } + + type args struct { + configs config.AccountFloorFetch + } + tests := []struct { + name string + args args + response []byte + responseStatus int + want []byte + want1 int + wantErr bool + }{ + { + name: "Floor data is successfully returned", + args: args{ + configs: config.AccountFloorFetch{ + URL: "", + Timeout: 60, + Period: 300, + }, + }, + response: func() []byte { + data := `{"data":{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]},"enabled":true,"floormin":1,"enforcement":{"enforcepbs":false,"floordeals":true}}` + return []byte(data) + }(), + responseStatus: 200, + want: func() []byte { + data := `{"data":{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]},"enabled":true,"floormin":1,"enforcement":{"enforcepbs":false,"floordeals":true}}` + return []byte(data) + }(), + want1: 0, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockHttpServer := httptest.NewServer(mockHandler(tt.response, tt.responseStatus)) + defer mockHttpServer.Close() + + if tt.args.configs.URL == "" { + tt.args.configs.URL = mockHttpServer.URL + } + + ppf := PriceFloorFetcher{ + httpClient: mockHttpServer.Client(), + } + got, got1, err := ppf.fetchFloorRulesFromURL(tt.args.configs) + if (err != nil) != tt.wantErr { + t.Errorf("fetchFloorRulesFromURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("fetchFloorRulesFromURL() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("fetchFloorRulesFromURL() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} + +func TestFetchAndValidate(t *testing.T) { + mockHandler := func(mockResponse []byte, mockStatus int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Add(MaxAge, "30") + w.WriteHeader(mockStatus) + w.Write(mockResponse) + }) + } + + type args struct { + configs config.AccountFloorFetch + } + tests := []struct { + name string + args args + response []byte + responseStatus int + want *openrtb_ext.PriceFloorRules + want1 int + }{ + { + name: "Recieved valid price floor rules response", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + Timeout: 30, + MaxFileSizeKB: 700, + MaxRules: 30, + MaxAge: 60, + Period: 40, + }, + }, + response: func() []byte { + data := `{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]}` + return []byte(data) + }(), + responseStatus: 200, + want: func() *openrtb_ext.PriceFloorRules { + var res openrtb_ext.PriceFloorRules + data := `{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]}` + _ = json.Unmarshal([]byte(data), &res.Data) + return &res + }(), + want1: 30, + }, + { + name: "No response from server", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + Timeout: 30, + MaxFileSizeKB: 700, + MaxRules: 30, + MaxAge: 60, + Period: 40, + }, + }, + response: []byte{}, + responseStatus: 500, + want: nil, + want1: 0, + }, + { + name: "File is greater than MaxFileSize", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + Timeout: 30, + MaxFileSizeKB: 1, + MaxRules: 30, + MaxAge: 60, + Period: 40, + }, + }, + response: func() []byte { + data := `{"currency":"USD","floorProvider":"PM","floorsSchemaVersion":2,"modelGroups":[{"modelVersion":"M_0","modelWeight":1,"schema":{"fields":["domain"]},"values":{"missyusa.com":0.85,"www.missyusa.com":0.7}},{"modelVersion":"M_1","modelWeight":1,"schema":{"fields":["domain"]},"values":{"missyusa.com":1,"www.missyusa.com":1.85}},{"modelVersion":"M_2","modelWeight":5,"schema":{"fields":["domain"]},"values":{"missyusa.com":1.6,"www.missyusa.com":0.7}},{"modelVersion":"M_3","modelWeight":2,"schema":{"fields":["domain"]},"values":{"missyusa.com":1.9,"www.missyusa.com":0.75}},{"modelVersion":"M_4","modelWeight":1,"schema":{"fields":["domain"]},"values":{"www.missyusa.com":1.35,"missyusa.com":1.75}},{"modelVersion":"M_5","modelWeight":2,"schema":{"fields":["domain"]},"values":{"missyusa.com":1.4,"www.missyusa.com":0.9}},{"modelVersion":"M_6","modelWeight":43,"schema":{"fields":["domain"]},"values":{"www.missyusa.com":2,"missyusa.com":2}},{"modelVersion":"M_7","modelWeight":1,"schema":{"fields":["domain"]},"values":{"missyusa.com":1.4,"www.missyusa.com":1.85}},{"modelVersion":"M_8","modelWeight":3,"schema":{"fields":["domain"]},"values":{"www.missyusa.com":1.7,"missyusa.com":0.1}},{"modelVersion":"M_9","modelWeight":7,"schema":{"fields":["domain"]},"values":{"missyusa.com":1.9,"www.missyusa.com":1.05}},{"modelVersion":"M_10","modelWeight":9,"schema":{"fields":["domain"]},"values":{"www.missyusa.com":2,"missyusa.com":0.1}},{"modelVersion":"M_11","modelWeight":1,"schema":{"fields":["domain"]},"values":{"missyusa.com":0.45,"www.missyusa.com":1.5}},{"modelVersion":"M_12","modelWeight":8,"schema":{"fields":["domain"]},"values":{"missyusa.com":1.2,"www.missyusa.com":1.7}},{"modelVersion":"M_13","modelWeight":8,"schema":{"fields":["domain"]},"values":{"missyusa.com":0.85,"www.missyusa.com":0.75}},{"modelVersion":"M_14","modelWeight":1,"schema":{"fields":["domain"]},"values":{"missyusa.com":1.8,"www.missyusa.com":1}},{"modelVersion":"M_15","modelWeight":1,"schema":{"fields":["domain"]},"values":{"www.missyusa.com":1.2,"missyusa.com":1.75}},{"modelVersion":"M_16","modelWeight":2,"schema":{"fields":["domain"]},"values":{"missyusa.com":1,"www.missyusa.com":0.7}},{"modelVersion":"M_17","modelWeight":1,"schema":{"fields":["domain"]},"values":{"missyusa.com":0.45,"www.missyusa.com":0.35}},{"modelVersion":"M_18","modelWeight":3,"schema":{"fields":["domain"]},"values":{"missyusa.com":1.2,"www.missyusa.com":1.05}}],"skipRate":10}` + return []byte(data) + }(), + responseStatus: 200, + want: nil, + want1: 0, + }, + { + name: "Malformed response : json unmarshalling failed", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + Timeout: 30, + MaxFileSizeKB: 800, + MaxRules: 30, + MaxAge: 60, + Period: 40, + }, + }, + response: func() []byte { + data := `{"data":nil?}` + return []byte(data) + }(), + responseStatus: 200, + want: nil, + want1: 0, + }, + { + name: "Validations failed for price floor rules response", + args: args{ + configs: config.AccountFloorFetch{ + Enabled: true, + Timeout: 30, + MaxFileSizeKB: 700, + MaxRules: 30, + MaxAge: 60, + Period: 40, + }, + }, + response: func() []byte { + data := `{"data":{"currency":"USD","modelgroups":[]},"enabled":true,"floormin":1,"enforcement":{"enforcepbs":false,"floordeals":true}}` + return []byte(data) + }(), + responseStatus: 200, + want: nil, + want1: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockHttpServer := httptest.NewServer(mockHandler(tt.response, tt.responseStatus)) + defer mockHttpServer.Close() + ppf := PriceFloorFetcher{ + httpClient: mockHttpServer.Client(), + } + tt.args.configs.URL = mockHttpServer.URL + got, got1 := ppf.fetchAndValidate(tt.args.configs) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("fetchAndValidate() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("fetchAndValidate() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} + +func mockFetcherInstance(config config.PriceFloors, httpClient *http.Client, metricEngine metrics.MetricsEngine) *PriceFloorFetcher { + if !config.Enabled { + return nil + } + + floorFetcher := PriceFloorFetcher{ + pool: pond.New(config.Fetcher.Worker, config.Fetcher.Capacity, pond.PanicHandler(workerPanicHandler)), + fetchQueue: make(FetchQueue, 0, 100), + fetchInProgress: make(map[string]bool), + configReceiver: make(chan fetchInfo, config.Fetcher.Capacity), + done: make(chan struct{}), + cache: freecache.NewCache(config.Fetcher.CacheSize * 1024 * 1024), + httpClient: httpClient, + time: &timeutil.RealTime{}, + metricEngine: metricEngine, + maxRetries: 10, + } + + go floorFetcher.Fetcher() + + return &floorFetcher +} + +func TestFetcherWhenRequestGetSameURLInrequest(t *testing.T) { + refetchCheckInterval = 1 + response := []byte(`{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]}`) + mockHandler := func(mockResponse []byte, mockStatus int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(mockStatus) + w.Write(mockResponse) + }) + } + + mockHttpServer := httptest.NewServer(mockHandler(response, 200)) + defer mockHttpServer.Close() + + floorConfig := config.PriceFloors{ + Enabled: true, + Fetcher: config.PriceFloorFetcher{ + CacheSize: 1, + Worker: 5, + Capacity: 10, + }, + } + fetcherInstance := mockFetcherInstance(floorConfig, mockHttpServer.Client(), &metricsConf.NilMetricsEngine{}) + defer fetcherInstance.Stop() + + fetchConfig := config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + Fetcher: config.AccountFloorFetch{ + Enabled: true, + URL: mockHttpServer.URL, + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 20, + Period: 1, + }, + } + + for i := 0; i < 50; i++ { + fetcherInstance.Fetch(fetchConfig) + } + + assert.Never(t, func() bool { return len(fetcherInstance.fetchQueue) > 1 }, time.Duration(2*time.Second), 100*time.Millisecond, "Queue Got more than one entry") + assert.Never(t, func() bool { return len(fetcherInstance.fetchInProgress) > 1 }, time.Duration(2*time.Second), 100*time.Millisecond, "Map Got more than one entry") + +} + +func TestFetcherDataPresentInCache(t *testing.T) { + floorConfig := config.PriceFloors{ + Enabled: true, + Fetcher: config.PriceFloorFetcher{ + CacheSize: 1, + Worker: 2, + Capacity: 5, + }, + } + + fetcherInstance := mockFetcherInstance(floorConfig, http.DefaultClient, &metricsConf.NilMetricsEngine{}) + defer fetcherInstance.Stop() + + fetchConfig := config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + Fetcher: config.AccountFloorFetch{ + Enabled: true, + URL: "http://test.com/floor", + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 20, + Period: 5, + }, + } + var res *openrtb_ext.PriceFloorRules + data := `{"data":{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]},"enabled":true,"floormin":1,"enforcement":{"enforcepbs":false,"floordeals":true}}` + _ = json.Unmarshal([]byte(data), &res) + fetcherInstance.SetWithExpiry("http://test.com/floor", []byte(data), fetchConfig.Fetcher.MaxAge) + + val, status := fetcherInstance.Fetch(fetchConfig) + assert.Equal(t, res, val, "Invalid value in cache or cache is empty") + assert.Equal(t, "success", status, "Floor fetch should be success") +} + +func TestFetcherDataNotPresentInCache(t *testing.T) { + floorConfig := config.PriceFloors{ + Enabled: true, + Fetcher: config.PriceFloorFetcher{ + CacheSize: 1, + Worker: 2, + Capacity: 5, + }, + } + + fetcherInstance := mockFetcherInstance(floorConfig, http.DefaultClient, &metricsConf.NilMetricsEngine{}) + defer fetcherInstance.Stop() + + fetchConfig := config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + Fetcher: config.AccountFloorFetch{ + Enabled: true, + URL: "http://test.com/floor", + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 20, + Period: 5, + }, + } + fetcherInstance.SetWithExpiry("http://test.com/floor", nil, fetchConfig.Fetcher.MaxAge) + + val, status := fetcherInstance.Fetch(fetchConfig) + + assert.Equal(t, (*openrtb_ext.PriceFloorRules)(nil), val, "Floor data should be nil") + assert.Equal(t, "error", status, "Floor fetch should be error") +} + +func TestFetcherEntryNotPresentInCache(t *testing.T) { + floorConfig := config.PriceFloors{ + Enabled: true, + Fetcher: config.PriceFloorFetcher{ + CacheSize: 1, + Worker: 2, + Capacity: 5, + MaxRetries: 10, + }, + } + + fetcherInstance := NewPriceFloorFetcher(floorConfig, http.DefaultClient, &metricsConf.NilMetricsEngine{}) + defer fetcherInstance.Stop() + + fetchConfig := config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + Fetcher: config.AccountFloorFetch{ + Enabled: true, + URL: "http://test.com/floor", + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 20, + Period: 5, + }, + } + + val, status := fetcherInstance.Fetch(fetchConfig) + + assert.Equal(t, (*openrtb_ext.PriceFloorRules)(nil), val, "Floor data should be nil") + assert.Equal(t, openrtb_ext.FetchInprogress, status, "Floor fetch should be error") +} + +func TestFetcherDynamicFetchDisable(t *testing.T) { + floorConfig := config.PriceFloors{ + Enabled: true, + Fetcher: config.PriceFloorFetcher{ + CacheSize: 1, + Worker: 2, + Capacity: 5, + MaxRetries: 5, + }, + } + + fetcherInstance := NewPriceFloorFetcher(floorConfig, http.DefaultClient, &metricsConf.NilMetricsEngine{}) + defer fetcherInstance.Stop() + + fetchConfig := config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: false, + Fetcher: config.AccountFloorFetch{ + Enabled: true, + URL: "http://test.com/floor", + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 20, + Period: 5, + }, + } + + val, status := fetcherInstance.Fetch(fetchConfig) + + assert.Equal(t, (*openrtb_ext.PriceFloorRules)(nil), val, "Floor data should be nil") + assert.Equal(t, openrtb_ext.FetchNone, status, "Floor fetch should be error") +} + +func TestPriceFloorFetcherWorker(t *testing.T) { + var floorData openrtb_ext.PriceFloorData + response := []byte(`{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]}`) + _ = json.Unmarshal(response, &floorData) + floorResp := &openrtb_ext.PriceFloorRules{ + Data: &floorData, + } + + mockHandler := func(mockResponse []byte, mockStatus int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Add(MaxAge, "5") + w.WriteHeader(mockStatus) + w.Write(mockResponse) + }) + } + + mockHttpServer := httptest.NewServer(mockHandler(response, 200)) + defer mockHttpServer.Close() + + fetcherInstance := PriceFloorFetcher{ + pool: nil, + fetchQueue: nil, + fetchInProgress: nil, + configReceiver: make(chan fetchInfo, 1), + done: nil, + cache: freecache.NewCache(1 * 1024 * 1024), + httpClient: mockHttpServer.Client(), + time: &timeutil.RealTime{}, + metricEngine: &metricsConf.NilMetricsEngine{}, + maxRetries: 10, + } + defer close(fetcherInstance.configReceiver) + + fetchConfig := fetchInfo{ + AccountFloorFetch: config.AccountFloorFetch{ + Enabled: true, + URL: mockHttpServer.URL, + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 20, + Period: 1, + }, + } + + fetcherInstance.worker(fetchConfig) + dataInCache, _ := fetcherInstance.Get(mockHttpServer.URL) + var gotFloorData *openrtb_ext.PriceFloorRules + json.Unmarshal(dataInCache, &gotFloorData) + assert.Equal(t, floorResp, gotFloorData, "Data should be stored in cache") + + info := <-fetcherInstance.configReceiver + assert.Equal(t, true, info.refetchRequest, "Recieved request is not refetch request") + assert.Equal(t, mockHttpServer.URL, info.AccountFloorFetch.URL, "Recieved request with different url") + +} + +func TestPriceFloorFetcherWorkerRetry(t *testing.T) { + mockHandler := func(mockResponse []byte, mockStatus int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(mockStatus) + w.Write(mockResponse) + }) + } + + mockHttpServer := httptest.NewServer(mockHandler(nil, 500)) + defer mockHttpServer.Close() + + fetcherInstance := PriceFloorFetcher{ + pool: nil, + fetchQueue: nil, + fetchInProgress: nil, + configReceiver: make(chan fetchInfo, 1), + done: nil, + cache: nil, + httpClient: mockHttpServer.Client(), + time: &timeutil.RealTime{}, + metricEngine: &metricsConf.NilMetricsEngine{}, + maxRetries: 5, + } + defer close(fetcherInstance.configReceiver) + + fetchConfig := fetchInfo{ + AccountFloorFetch: config.AccountFloorFetch{ + Enabled: true, + URL: mockHttpServer.URL, + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 20, + Period: 1, + }, + } + + fetcherInstance.worker(fetchConfig) + + info := <-fetcherInstance.configReceiver + assert.Equal(t, 1, info.retryCount, "Retry Count is not 1") +} + +func TestPriceFloorFetcherWorkerDefaultCacheExpiry(t *testing.T) { + var floorData openrtb_ext.PriceFloorData + response := []byte(`{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]}`) + _ = json.Unmarshal(response, &floorData) + floorResp := &openrtb_ext.PriceFloorRules{ + Data: &floorData, + } + + mockHandler := func(mockResponse []byte, mockStatus int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(mockStatus) + w.Write(mockResponse) + }) + } + + mockHttpServer := httptest.NewServer(mockHandler(response, 200)) + defer mockHttpServer.Close() + + fetcherInstance := &PriceFloorFetcher{ + pool: nil, + fetchQueue: nil, + fetchInProgress: nil, + configReceiver: make(chan fetchInfo, 1), + done: nil, + cache: freecache.NewCache(1 * 1024 * 1024), + httpClient: mockHttpServer.Client(), + time: &timeutil.RealTime{}, + metricEngine: &metricsConf.NilMetricsEngine{}, + maxRetries: 5, + } + + fetchConfig := fetchInfo{ + AccountFloorFetch: config.AccountFloorFetch{ + Enabled: true, + URL: mockHttpServer.URL, + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 20, + Period: 1, + }, + } + + fetcherInstance.worker(fetchConfig) + dataInCache, _ := fetcherInstance.Get(mockHttpServer.URL) + var gotFloorData *openrtb_ext.PriceFloorRules + json.Unmarshal(dataInCache, &gotFloorData) + assert.Equal(t, floorResp, gotFloorData, "Data should be stored in cache") + + info := <-fetcherInstance.configReceiver + defer close(fetcherInstance.configReceiver) + assert.Equal(t, true, info.refetchRequest, "Recieved request is not refetch request") + assert.Equal(t, mockHttpServer.URL, info.AccountFloorFetch.URL, "Recieved request with different url") + +} + +func TestPriceFloorFetcherSubmit(t *testing.T) { + response := []byte(`{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]}`) + mockHandler := func(mockResponse []byte, mockStatus int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(mockStatus) + w.Write(mockResponse) + }) + } + + mockHttpServer := httptest.NewServer(mockHandler(response, 200)) + defer mockHttpServer.Close() + + fetcherInstance := &PriceFloorFetcher{ + pool: pond.New(1, 1), + fetchQueue: make(FetchQueue, 0), + fetchInProgress: nil, + configReceiver: make(chan fetchInfo, 1), + done: make(chan struct{}), + cache: freecache.NewCache(1 * 1024 * 1024), + httpClient: mockHttpServer.Client(), + time: &timeutil.RealTime{}, + metricEngine: &metricsConf.NilMetricsEngine{}, + maxRetries: 5, + } + defer fetcherInstance.Stop() + + fetchInfo := fetchInfo{ + refetchRequest: false, + fetchTime: time.Now().Unix(), + AccountFloorFetch: config.AccountFloorFetch{ + Enabled: true, + URL: mockHttpServer.URL, + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 2, + Period: 1, + }, + } + + fetcherInstance.submit(&fetchInfo) + + info := <-fetcherInstance.configReceiver + assert.Equal(t, true, info.refetchRequest, "Recieved request is not refetch request") + assert.Equal(t, mockHttpServer.URL, info.AccountFloorFetch.URL, "Recieved request with different url") + +} + +type testPool struct{} + +func (t *testPool) TrySubmit(task func()) bool { + return false +} + +func (t *testPool) Stop() {} + +func TestPriceFloorFetcherSubmitFailed(t *testing.T) { + fetcherInstance := &PriceFloorFetcher{ + pool: &testPool{}, + fetchQueue: make(FetchQueue, 0), + fetchInProgress: nil, + configReceiver: nil, + done: nil, + cache: nil, + } + defer fetcherInstance.pool.Stop() + + fetchInfo := fetchInfo{ + refetchRequest: false, + fetchTime: time.Now().Unix(), + AccountFloorFetch: config.AccountFloorFetch{ + Enabled: true, + URL: "http://test.com", + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 2, + Period: 1, + }, + } + + fetcherInstance.submit(&fetchInfo) + assert.Equal(t, 1, len(fetcherInstance.fetchQueue), "Unable to submit the task") +} + +func getRandomNumber() int { + rand.Seed(time.Now().UnixNano()) + min := 1 + max := 10 + return rand.Intn(max-min+1) + min +} + +func TestFetcherWhenRequestGetDifferentURLInrequest(t *testing.T) { + refetchCheckInterval = 1 + response := []byte(`{"currency":"USD","modelgroups":[{"modelweight":40,"modelversion":"version1","default":5,"values":{"banner|300x600|www.website.com":3,"banner|728x90|www.website.com":5,"banner|300x600|*":4,"banner|300x250|*":2,"*|*|*":16,"*|300x250|*":10,"*|300x600|*":12,"*|300x600|www.website.com":11,"banner|*|*":8,"banner|300x250|www.website.com":1,"*|728x90|www.website.com":13,"*|300x250|www.website.com":9,"*|728x90|*":14,"banner|728x90|*":6,"banner|*|www.website.com":7,"*|*|www.website.com":15},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]}`) + mockHandler := func(mockResponse []byte, mockStatus int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(mockStatus) + w.Write(mockResponse) + }) + } + + mockHttpServer := httptest.NewServer(mockHandler(response, 200)) + defer mockHttpServer.Close() + + floorConfig := config.PriceFloors{ + Enabled: true, + Fetcher: config.PriceFloorFetcher{ + CacheSize: 1, + Worker: 5, + Capacity: 10, + MaxRetries: 5, + }, + } + fetcherInstance := mockFetcherInstance(floorConfig, mockHttpServer.Client(), &metricsConf.NilMetricsEngine{}) + defer fetcherInstance.Stop() + + fetchConfig := config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + Fetcher: config.AccountFloorFetch{ + Enabled: true, + URL: mockHttpServer.URL, + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 5, + Period: 1, + }, + } + + for i := 0; i < 50; i++ { + fetchConfig.Fetcher.URL = fmt.Sprintf("%s?id=%d", mockHttpServer.URL, getRandomNumber()) + fetcherInstance.Fetch(fetchConfig) + } + + assert.Never(t, func() bool { return len(fetcherInstance.fetchQueue) > 10 }, time.Duration(2*time.Second), 100*time.Millisecond, "Queue Got more than one entry") + assert.Never(t, func() bool { return len(fetcherInstance.fetchInProgress) > 10 }, time.Duration(2*time.Second), 100*time.Millisecond, "Map Got more than one entry") +} + +func TestFetchWhenPriceFloorsDisabled(t *testing.T) { + floorConfig := config.PriceFloors{ + Enabled: false, + Fetcher: config.PriceFloorFetcher{ + CacheSize: 1, + Worker: 5, + Capacity: 10, + }, + } + fetcherInstance := NewPriceFloorFetcher(floorConfig, http.DefaultClient, &metricsConf.NilMetricsEngine{}) + defer fetcherInstance.Stop() + + fetchConfig := config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + Fetcher: config.AccountFloorFetch{ + Enabled: true, + URL: "http://test.com/floors", + Timeout: 100, + MaxFileSizeKB: 1000, + MaxRules: 100, + MaxAge: 5, + Period: 1, + }, + } + + data, status := fetcherInstance.Fetch(fetchConfig) + + assert.Equal(t, (*openrtb_ext.PriceFloorRules)(nil), data, "floor data should be nil as fetcher instance does not created") + assert.Equal(t, openrtb_ext.FetchNone, status, "floor status should be none as fetcher instance does not created") +} diff --git a/floors/floors.go b/floors/floors.go index c1e930298ac..3fdeb4c55ae 100644 --- a/floors/floors.go +++ b/floors/floors.go @@ -4,10 +4,12 @@ import ( "errors" "math" "math/rand" + "strings" "github.com/prebid/prebid-server/v2/config" "github.com/prebid/prebid-server/v2/currency" "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/prebid/prebid-server/v2/util/ptrutil" ) type Price struct { @@ -30,8 +32,7 @@ const ( // EnrichWithPriceFloors checks for floors enabled in account and request and selects floors data from dynamic fetched if present // else selects floors data from req.ext.prebid.floors and update request with selected floors details -func EnrichWithPriceFloors(bidRequestWrapper *openrtb_ext.RequestWrapper, account config.Account, conversions currency.Conversions) []error { - +func EnrichWithPriceFloors(bidRequestWrapper *openrtb_ext.RequestWrapper, account config.Account, conversions currency.Conversions, priceFloorFetcher FloorFetcher) []error { if bidRequestWrapper == nil || bidRequestWrapper.BidRequest == nil { return []error{errors.New("Empty bidrequest")} } @@ -40,7 +41,7 @@ func EnrichWithPriceFloors(bidRequestWrapper *openrtb_ext.RequestWrapper, accoun return []error{errors.New("Floors feature is disabled at account or in the request")} } - floors, err := resolveFloors(account, bidRequestWrapper, conversions) + floors, err := resolveFloors(account, bidRequestWrapper, conversions, priceFloorFetcher) updateReqErrs := updateBidRequestWithFloors(floors, bidRequestWrapper, conversions) updateFloorsInRequest(bidRequestWrapper, floors) @@ -136,12 +137,26 @@ func isPriceFloorsEnabledForRequest(bidRequestWrapper *openrtb_ext.RequestWrappe } // resolveFloors does selection of floors fields from request data and dynamic fetched data if dynamic fetch is enabled -func resolveFloors(account config.Account, bidRequestWrapper *openrtb_ext.RequestWrapper, conversions currency.Conversions) (*openrtb_ext.PriceFloorRules, []error) { +func resolveFloors(account config.Account, bidRequestWrapper *openrtb_ext.RequestWrapper, conversions currency.Conversions, priceFloorFetcher FloorFetcher) (*openrtb_ext.PriceFloorRules, []error) { var errList []error var floorRules *openrtb_ext.PriceFloorRules reqFloor := extractFloorsFromRequest(bidRequestWrapper) - if reqFloor != nil { + if reqFloor != nil && reqFloor.Location != nil && len(reqFloor.Location.URL) > 0 { + account.PriceFloors.Fetcher.URL = reqFloor.Location.URL + } + account.PriceFloors.Fetcher.AccountID = account.ID + + var fetchResult *openrtb_ext.PriceFloorRules + var fetchStatus string + if priceFloorFetcher != nil && account.PriceFloors.UseDynamicData { + fetchResult, fetchStatus = priceFloorFetcher.Fetch(account.PriceFloors) + } + + if fetchResult != nil && fetchStatus == openrtb_ext.FetchSuccess { + mergedFloor := mergeFloors(reqFloor, fetchResult, conversions) + floorRules, errList = createFloorsFrom(mergedFloor, account, fetchStatus, openrtb_ext.FetchLocation) + } else if reqFloor != nil { floorRules, errList = createFloorsFrom(reqFloor, account, openrtb_ext.FetchNone, openrtb_ext.RequestLocation) } else { floorRules, errList = createFloorsFrom(nil, account, openrtb_ext.FetchNone, openrtb_ext.NoDataLocation) @@ -210,3 +225,83 @@ func updateFloorsInRequest(bidRequestWrapper *openrtb_ext.RequestWrapper, priceF bidRequestWrapper.RebuildRequest() } } + +// resolveFloorMin gets floorMin value from request and dynamic fetched data +func resolveFloorMin(reqFloors *openrtb_ext.PriceFloorRules, fetchFloors *openrtb_ext.PriceFloorRules, conversions currency.Conversions) Price { + var requestFloorMinCur, providerFloorMinCur string + var requestFloorMin, providerFloorMin float64 + + if reqFloors != nil { + requestFloorMin = reqFloors.FloorMin + requestFloorMinCur = reqFloors.FloorMinCur + if len(requestFloorMinCur) == 0 && reqFloors.Data != nil { + requestFloorMinCur = reqFloors.Data.Currency + } + } + + if fetchFloors != nil { + providerFloorMin = fetchFloors.FloorMin + providerFloorMinCur = fetchFloors.FloorMinCur + if len(providerFloorMinCur) == 0 && fetchFloors.Data != nil { + providerFloorMinCur = fetchFloors.Data.Currency + } + } + + if len(requestFloorMinCur) > 0 { + if requestFloorMin > 0 { + return Price{FloorMin: requestFloorMin, FloorMinCur: requestFloorMinCur} + } + + if providerFloorMin > 0 { + if strings.Compare(providerFloorMinCur, requestFloorMinCur) == 0 || len(providerFloorMinCur) == 0 { + return Price{FloorMin: providerFloorMin, FloorMinCur: requestFloorMinCur} + } + rate, err := conversions.GetRate(providerFloorMinCur, requestFloorMinCur) + if err != nil { + return Price{FloorMin: 0, FloorMinCur: requestFloorMinCur} + } + return Price{FloorMin: roundToFourDecimals(rate * providerFloorMin), FloorMinCur: requestFloorMinCur} + } + } + + if len(providerFloorMinCur) > 0 { + if providerFloorMin > 0 { + return Price{FloorMin: providerFloorMin, FloorMinCur: providerFloorMinCur} + } + if requestFloorMin > 0 { + return Price{FloorMin: requestFloorMin, FloorMinCur: providerFloorMinCur} + } + } + + return Price{FloorMin: requestFloorMin, FloorMinCur: requestFloorMinCur} + +} + +// mergeFloors does merging for floors data from request and dynamic fetch +func mergeFloors(reqFloors *openrtb_ext.PriceFloorRules, fetchFloors *openrtb_ext.PriceFloorRules, conversions currency.Conversions) *openrtb_ext.PriceFloorRules { + mergedFloors := fetchFloors.DeepCopy() + if mergedFloors.Enabled == nil { + mergedFloors.Enabled = new(bool) + } + *mergedFloors.Enabled = fetchFloors.GetEnabled() && reqFloors.GetEnabled() + + if reqFloors == nil { + return mergedFloors + } + + if reqFloors.Enforcement != nil { + mergedFloors.Enforcement = reqFloors.Enforcement.DeepCopy() + } + + floorMinPrice := resolveFloorMin(reqFloors, fetchFloors, conversions) + if floorMinPrice.FloorMin > 0 { + mergedFloors.FloorMin = floorMinPrice.FloorMin + mergedFloors.FloorMinCur = floorMinPrice.FloorMinCur + } + + if reqFloors != nil && reqFloors.Location != nil && reqFloors.Location.URL != "" { + mergedFloors.Location = ptrutil.Clone(reqFloors.Location) + } + + return mergedFloors +} diff --git a/floors/floors_test.go b/floors/floors_test.go index 56b21a1a209..047b6738cd3 100644 --- a/floors/floors_test.go +++ b/floors/floors_test.go @@ -3,6 +3,7 @@ package floors import ( "encoding/json" "errors" + "reflect" "testing" "github.com/prebid/openrtb/v19/openrtb2" @@ -50,6 +51,14 @@ func getCurrencyRates(rates map[string]map[string]float64) currency.Conversions return currency.NewRates(rates) } +type mockPriceFloorFetcher struct{} + +func (mpf *mockPriceFloorFetcher) Fetch(configs config.AccountPriceFloors) (*openrtb_ext.PriceFloorRules, string) { + return nil, openrtb_ext.FetchNone +} + +func (mpf *mockPriceFloorFetcher) Stop() {} + func TestEnrichWithPriceFloors(t *testing.T) { rates := map[string]map[string]float64{ "USD": { @@ -365,7 +374,7 @@ func TestEnrichWithPriceFloors(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ErrList := EnrichWithPriceFloors(tc.bidRequestWrapper, tc.account, getCurrencyRates(rates)) + ErrList := EnrichWithPriceFloors(tc.bidRequestWrapper, tc.account, getCurrencyRates(rates), &mockPriceFloorFetcher{}) if tc.bidRequestWrapper != nil { assert.Equal(t, tc.bidRequestWrapper.Imp[0].BidFloor, tc.expFloorVal, tc.name) assert.Equal(t, tc.bidRequestWrapper.Imp[0].BidFloorCur, tc.expFloorCur, tc.name) @@ -393,6 +402,51 @@ func getTrue() *bool { return &trueFlag } +func getFalse() *bool { + falseFlag := false + return &falseFlag +} + +type MockFetch struct { + FakeFetch func(configs config.AccountPriceFloors) (*openrtb_ext.PriceFloorRules, string) +} + +func (m *MockFetch) Stop() {} + +func (m *MockFetch) Fetch(configs config.AccountPriceFloors) (*openrtb_ext.PriceFloorRules, string) { + + if !configs.UseDynamicData { + return nil, openrtb_ext.FetchNone + } + priceFloors := openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + PriceFloorLocation: openrtb_ext.RequestLocation, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforcePBS: getTrue(), + EnforceRate: 100, + FloorDeals: getTrue(), + }, + Data: &openrtb_ext.PriceFloorData{ + Currency: "USD", + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 101", + Currency: "USD", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 15, + "*|*|*": 25, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + } + return &priceFloors, openrtb_ext.FetchSuccess +} + func TestResolveFloors(t *testing.T) { rates := map[string]map[string]float64{ "USD": { @@ -407,9 +461,149 @@ func TestResolveFloors(t *testing.T) { bidRequestWrapper *openrtb_ext.RequestWrapper account config.Account conversions currency.Conversions + fetcher FloorFetcher expErr []error expFloors *openrtb_ext.PriceFloorRules }{ + { + name: "Dynamic fetch disabled, floors from request selected", + bidRequestWrapper: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Publisher: &openrtb2.Publisher{Domain: "www.website.com"}, + }, + Imp: []openrtb2.Imp{{ID: "1234", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}}}, + Ext: json.RawMessage(`{"prebid":{"floors":{"data":{"currency":"USD","modelgroups":[{"modelversion":"model 1 from req","currency":"USD","values":{"banner|300x600|www.website5.com":5,"*|*|*":7},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]},"enabled":true,"enforcement":{"enforcepbs":true,"floordeals":true,"enforcerate":100}}}}`), + }, + }, + account: config.Account{ + PriceFloors: config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: false, + }, + }, + fetcher: &MockFetch{}, + expFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + FetchStatus: openrtb_ext.FetchNone, + PriceFloorLocation: openrtb_ext.RequestLocation, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforcePBS: getTrue(), + EnforceRate: 100, + FloorDeals: getTrue(), + }, + Data: &openrtb_ext.PriceFloorData{ + Currency: "USD", + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "model 1 from req", + Currency: "USD", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 5, + "*|*|*": 7, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + { + name: "Dynamic fetch enabled, floors from fetched selected", + bidRequestWrapper: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Publisher: &openrtb2.Publisher{Domain: "www.website.com"}, + }, + Imp: []openrtb2.Imp{{ID: "1234", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}}}, + }, + }, + account: config.Account{ + PriceFloors: config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + }, + }, + fetcher: &MockFetch{}, + expFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + FetchStatus: openrtb_ext.FetchSuccess, + PriceFloorLocation: openrtb_ext.FetchLocation, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforcePBS: getTrue(), + FloorDeals: getTrue(), + EnforceRate: 100, + }, + Data: &openrtb_ext.PriceFloorData{ + Currency: "USD", + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 101", + Currency: "USD", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 15, + "*|*|*": 25, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + { + name: "Dynamic fetch enabled, floors formed after merging", + bidRequestWrapper: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Publisher: &openrtb2.Publisher{Domain: "www.website.com"}, + }, + Imp: []openrtb2.Imp{{ID: "1234", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}}}, + Ext: json.RawMessage(`{"prebid":{"floors":{"floormincur":"EUR","enabled":true,"data":{"currency":"USD","modelgroups":[{"modelversion":"model 1 from req","currency":"USD","values":{"banner|300x600|www.website5.com":5,"*|*|*":7},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]},"floormin":10.11,"enforcement":{"enforcepbs":true,"floordeals":true,"enforcerate":100}}}}`), + }, + }, + account: config.Account{ + PriceFloors: config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + }, + }, + fetcher: &MockFetch{}, + expFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + FloorMin: 10.11, + FloorMinCur: "EUR", + FetchStatus: openrtb_ext.FetchSuccess, + PriceFloorLocation: openrtb_ext.FetchLocation, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforcePBS: getTrue(), + EnforceRate: 100, + FloorDeals: getTrue(), + }, + Data: &openrtb_ext.PriceFloorData{ + Currency: "USD", + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 101", + Currency: "USD", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 15, + "*|*|*": 25, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, { name: "Dynamic fetch disabled, only enforcement object present in req.ext", bidRequestWrapper: &openrtb_ext.RequestWrapper{ @@ -427,6 +621,7 @@ func TestResolveFloors(t *testing.T) { UseDynamicData: false, }, }, + fetcher: &MockFetch{}, expFloors: &openrtb_ext.PriceFloorRules{ Enforcement: &openrtb_ext.PriceFloorEnforcement{ EnforcePBS: getTrue(), @@ -437,11 +632,129 @@ func TestResolveFloors(t *testing.T) { PriceFloorLocation: openrtb_ext.RequestLocation, }, }, + { + name: "Dynamic fetch enabled, floors from fetched selected and new URL is updated", + bidRequestWrapper: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Publisher: &openrtb2.Publisher{Domain: "www.website.com"}, + }, + Imp: []openrtb2.Imp{{ID: "1234", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}}}, + Ext: json.RawMessage(`{"prebid":{"floors":{"floorendpoint":{"url":"http://test.com/floor"},"enabled":true}}}`), + }, + }, + account: config.Account{ + PriceFloors: config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + }, + }, + fetcher: &MockFetch{}, + expFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + FetchStatus: openrtb_ext.FetchSuccess, + PriceFloorLocation: openrtb_ext.FetchLocation, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforcePBS: getTrue(), + FloorDeals: getTrue(), + EnforceRate: 100, + }, + Location: &openrtb_ext.PriceFloorEndpoint{ + URL: "http://test.com/floor", + }, + Data: &openrtb_ext.PriceFloorData{ + Currency: "USD", + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 101", + Currency: "USD", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 15, + "*|*|*": 25, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + { + name: "Dynamic Fetch Enabled but price floor fetcher is nil, floors from request is selected", + bidRequestWrapper: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Publisher: &openrtb2.Publisher{Domain: "www.website.com"}, + }, + Imp: []openrtb2.Imp{{ID: "1234", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}}}, + Ext: json.RawMessage(`{"prebid":{"floors":{"data":{"currency":"USD","modelgroups":[{"modelversion":"model 1 from req","currency":"USD","values":{"banner|300x600|www.website5.com":5,"*|*|*":7},"schema":{"fields":["mediaType","size","domain"],"delimiter":"|"}}]},"enabled":true,"enforcement":{"enforcepbs":true,"floordeals":true,"enforcerate":100}}}}`), + }, + }, + account: config.Account{ + PriceFloors: config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + }, + }, + fetcher: nil, + expFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + FetchStatus: openrtb_ext.FetchNone, + PriceFloorLocation: openrtb_ext.RequestLocation, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforcePBS: getTrue(), + EnforceRate: 100, + FloorDeals: getTrue(), + }, + Data: &openrtb_ext.PriceFloorData{ + Currency: "USD", + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "model 1 from req", + Currency: "USD", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 5, + "*|*|*": 7, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + { + name: "Dynamic Fetch Enabled but price floor fetcher is nil and request has no floors", + bidRequestWrapper: &openrtb_ext.RequestWrapper{ + BidRequest: &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Publisher: &openrtb2.Publisher{Domain: "www.website.com"}, + }, + Imp: []openrtb2.Imp{{ID: "1234", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}}}, + Ext: json.RawMessage(`{"prebid":{}}`), + }, + }, + account: config.Account{ + PriceFloors: config.AccountPriceFloors{ + Enabled: true, + UseDynamicData: true, + }, + }, + fetcher: nil, + expFloors: &openrtb_ext.PriceFloorRules{ + FetchStatus: openrtb_ext.FetchNone, + PriceFloorLocation: openrtb_ext.NoDataLocation, + }, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - resolvedFloors, _ := resolveFloors(tc.account, tc.bidRequestWrapper, getCurrencyRates(rates)) + resolvedFloors, _ := resolveFloors(tc.account, tc.bidRequestWrapper, getCurrencyRates(rates), tc.fetcher) assert.Equal(t, resolvedFloors, tc.expFloors, tc.name) }) } @@ -738,3 +1051,605 @@ func TestIsPriceFloorsEnabled(t *testing.T) { }) } } + +func TestResolveFloorMin(t *testing.T) { + rates := map[string]map[string]float64{ + "USD": { + "INR": 70, + "EUR": 0.9, + "JPY": 5.09, + }, + } + + tt := []struct { + name string + reqFloors openrtb_ext.PriceFloorRules + fetchFloors openrtb_ext.PriceFloorRules + conversions currency.Conversions + expPrice Price + }{ + { + name: "FloorsMin present in request Floors only", + reqFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 10, + FloorMinCur: "JPY", + }, + fetchFloors: openrtb_ext.PriceFloorRules{}, + expPrice: Price{FloorMin: 10, FloorMinCur: "JPY"}, + }, + { + name: "FloorsMin, FloorMinCur and data currency present in request Floors", + reqFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 10, + FloorMinCur: "JPY", + Data: &openrtb_ext.PriceFloorData{ + Currency: "USD", + }, + }, + fetchFloors: openrtb_ext.PriceFloorRules{}, + expPrice: Price{FloorMin: 10, FloorMinCur: "JPY"}, + }, + { + name: "FloorsMin and data currency present in request Floors", + reqFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 10, + Data: &openrtb_ext.PriceFloorData{ + Currency: "USD", + }, + }, + fetchFloors: openrtb_ext.PriceFloorRules{}, + expPrice: Price{FloorMin: 10, FloorMinCur: "USD"}, + }, + { + name: "FloorsMin and FloorMinCur present in request Floors and fetched floors", + reqFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 10, + FloorMinCur: "USD", + }, + fetchFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 15, + FloorMinCur: "INR", + }, + expPrice: Price{FloorMin: 10, FloorMinCur: "USD"}, + }, + { + name: "FloorsMin present fetched floors only", + reqFloors: openrtb_ext.PriceFloorRules{}, + fetchFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 15, + FloorMinCur: "EUR", + }, + expPrice: Price{FloorMin: 15, FloorMinCur: "EUR"}, + }, + { + name: "FloorMinCur present in reqFloors And FloorsMin, FloorMinCur present in fetched floors (Same Currency)", + reqFloors: openrtb_ext.PriceFloorRules{ + FloorMinCur: "EUR", + }, + fetchFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 15, + FloorMinCur: "EUR", + }, + expPrice: Price{FloorMin: 15, FloorMinCur: "EUR"}, + }, + { + name: "FloorMinCur present in reqFloors And FloorsMin, FloorMinCur present in fetched floors (Different Currency)", + reqFloors: openrtb_ext.PriceFloorRules{ + FloorMinCur: "USD", + }, + fetchFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 15, + FloorMinCur: "EUR", + }, + expPrice: Price{FloorMin: 16.6667, FloorMinCur: "USD"}, + }, + { + name: "FloorMin present in reqFloors And FloorMinCur present in fetched floors", + reqFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 11, + }, + fetchFloors: openrtb_ext.PriceFloorRules{ + FloorMinCur: "EUR", + }, + expPrice: Price{FloorMin: 11, FloorMinCur: "EUR"}, + }, + { + name: "FloorMinCur present in reqFloors And FloorMin present in fetched floors", + reqFloors: openrtb_ext.PriceFloorRules{ + FloorMinCur: "INR", + }, + fetchFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 12, + }, + expPrice: Price{FloorMin: 12, FloorMinCur: "INR"}, + }, + { + name: "FloorMinCur present in reqFloors And FloorMin, data currency present in fetched floors", + reqFloors: openrtb_ext.PriceFloorRules{ + FloorMinCur: "INR", + }, + fetchFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 1, + Data: &openrtb_ext.PriceFloorData{Currency: "USD"}, + }, + expPrice: Price{FloorMin: 70, FloorMinCur: "INR"}, + }, + { + name: "FloorMinCur present in fetched Floors And data currency present in reqFloors", + reqFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 2, + }, + fetchFloors: openrtb_ext.PriceFloorRules{ + Data: &openrtb_ext.PriceFloorData{Currency: "USD"}, + }, + expPrice: Price{FloorMin: 2, FloorMinCur: "USD"}, + }, + { + name: "Data currency and FloorMin present in fetched floors", + reqFloors: openrtb_ext.PriceFloorRules{}, + fetchFloors: openrtb_ext.PriceFloorRules{ + FloorMin: 12, + Data: &openrtb_ext.PriceFloorData{Currency: "USD"}, + }, + expPrice: Price{FloorMin: 12, FloorMinCur: "USD"}, + }, + { + name: "Empty reqFloors And Empty fetched floors", + reqFloors: openrtb_ext.PriceFloorRules{}, + fetchFloors: openrtb_ext.PriceFloorRules{}, + expPrice: Price{FloorMin: 0.0, FloorMinCur: ""}, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + price := resolveFloorMin(&tc.reqFloors, &tc.fetchFloors, getCurrencyRates(rates)) + if !reflect.DeepEqual(price.FloorMin, tc.expPrice.FloorMin) { + t.Errorf("Floor Value error: \nreturn:\t%v\nwant:\t%v", price.FloorMin, tc.expPrice.FloorMin) + } + if !reflect.DeepEqual(price.FloorMinCur, tc.expPrice.FloorMinCur) { + t.Errorf("Floor Currency error: \nreturn:\t%v\nwant:\t%v", price.FloorMinCur, tc.expPrice.FloorMinCur) + } + + }) + } +} + +func TestMergeFloors(t *testing.T) { + + rates := map[string]map[string]float64{ + "USD": { + "INR": 70, + "EUR": 0.9, + "JPY": 5.09, + }, + } + + type args struct { + reqFloors *openrtb_ext.PriceFloorRules + fetchFloors *openrtb_ext.PriceFloorRules + } + tests := []struct { + name string + args args + want *openrtb_ext.PriceFloorRules + }{ + { + name: "Fetched Floors are present and request Floors are empty", + args: args{ + reqFloors: nil, + fetchFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + want: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + { + name: "Fetched Floors are present and request Floors has floors disabled", + args: args{ + reqFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getFalse(), + }, + fetchFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + want: &openrtb_ext.PriceFloorRules{ + Enabled: getFalse(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + { + name: "Fetched Floors are present and request Floors has enforcement (enforcepbs = true)", + args: args{ + reqFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforceRate: 50, + EnforcePBS: getTrue(), + }, + }, + fetchFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + want: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforceRate: 50, + EnforcePBS: getTrue(), + }, + }, + }, + { + name: "Fetched Floors are present and request Floors has enforcement (enforcepbs = false)", + args: args{ + reqFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforceRate: 50, + EnforcePBS: getFalse(), + }, + }, + fetchFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + want: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforceRate: 50, + EnforcePBS: getFalse(), + }, + }, + }, + { + name: "Fetched Floors are present and request Floors has Floormin", + args: args{ + reqFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforceRate: 50, + EnforcePBS: getFalse(), + }, + FloorMin: 5, + FloorMinCur: "INR", + }, + fetchFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + want: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforceRate: 50, + EnforcePBS: getFalse(), + }, + FloorMin: 5, + FloorMinCur: "INR", + }, + }, + { + name: "Fetched Floors are present and request Floors has URL", + args: args{ + reqFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforceRate: 50, + EnforcePBS: getFalse(), + }, + FloorMin: 5, + FloorMinCur: "INR", + Location: &openrtb_ext.PriceFloorEndpoint{ + URL: "https://test.com/floors", + }, + }, + fetchFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + want: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforceRate: 50, + EnforcePBS: getFalse(), + }, + FloorMin: 5, + FloorMinCur: "INR", + Location: &openrtb_ext.PriceFloorEndpoint{ + URL: "https://test.com/floors", + }, + }, + }, + { + name: "Fetched Floors has no enable atrribute are present and request Floors has URL", + args: args{ + reqFloors: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforceRate: 50, + EnforcePBS: getFalse(), + }, + FloorMin: 5, + FloorMinCur: "INR", + Location: &openrtb_ext.PriceFloorEndpoint{ + URL: "https://test.com/floors", + }, + }, + fetchFloors: &openrtb_ext.PriceFloorRules{ + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + want: &openrtb_ext.PriceFloorRules{ + Enabled: getTrue(), + Data: &openrtb_ext.PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []openrtb_ext.PriceFloorModelGroup{ + { + ModelVersion: "Version 1", + Currency: "INR", + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: openrtb_ext.PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + Enforcement: &openrtb_ext.PriceFloorEnforcement{ + EnforceRate: 50, + EnforcePBS: getFalse(), + }, + FloorMin: 5, + FloorMinCur: "INR", + Location: &openrtb_ext.PriceFloorEndpoint{ + URL: "https://test.com/floors", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mergeFloors(tt.args.reqFloors, tt.args.fetchFloors, getCurrencyRates(rates)); !reflect.DeepEqual(got, tt.want) { + t.Errorf("mergeFloors() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/floors/rule.go b/floors/rule.go index db12719c337..50c12462d28 100644 --- a/floors/rule.go +++ b/floors/rule.go @@ -38,8 +38,7 @@ const ( // getFloorCurrency returns floors currency provided in floors JSON, // if currency is not provided then defaults to USD func getFloorCurrency(floorExt *openrtb_ext.PriceFloorRules) string { - var floorCur string - + floorCur := defaultCurrency if floorExt != nil && floorExt.Data != nil { if floorExt.Data.Currency != "" { floorCur = floorExt.Data.Currency @@ -50,10 +49,6 @@ func getFloorCurrency(floorExt *openrtb_ext.PriceFloorRules) string { } } - if len(floorCur) == 0 { - floorCur = defaultCurrency - } - return floorCur } diff --git a/go.mod b/go.mod index 912cdaa2aad..da46dbbaa27 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/IABTechLab/adscert v0.34.0 github.com/NYTimes/gziphandler v1.1.1 + github.com/alitto/pond v1.8.3 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/benbjohnson/clock v1.3.0 github.com/buger/jsonparser v1.1.1 diff --git a/go.sum b/go.sum index eefe0228261..e4f9da5d6fa 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alitto/pond v1.8.3 h1:ydIqygCLVPqIX/USe5EaV/aSRXTRXDEI9JwuDdu+/xs= +github.com/alitto/pond v1.8.3/go.mod h1:CmvIIGd5jKLasGI3D87qDkQxjzChdKMmnXMg3fG6M6Q= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= diff --git a/openrtb_ext/floors.go b/openrtb_ext/floors.go index 3553946cc2e..0e773c65899 100644 --- a/openrtb_ext/floors.go +++ b/openrtb_ext/floors.go @@ -1,5 +1,11 @@ package openrtb_ext +import ( + "github.com/prebid/prebid-server/v2/util/maputil" + "github.com/prebid/prebid-server/v2/util/ptrutil" + "github.com/prebid/prebid-server/v2/util/sliceutil" +) + // Defines strings for FetchStatus const ( FetchSuccess = "success" @@ -145,3 +151,59 @@ type ExtImp struct { type ImpExtPrebid struct { Floors Price `json:"floors,omitempty"` } + +func (pf *PriceFloorRules) DeepCopy() *PriceFloorRules { + if pf == nil { + return nil + } + + newRules := *pf + newRules.Enabled = ptrutil.Clone(pf.Enabled) + newRules.Skipped = ptrutil.Clone(pf.Skipped) + newRules.Location = ptrutil.Clone(pf.Location) + newRules.Data = pf.Data.DeepCopy() + newRules.Enforcement = pf.Enforcement.DeepCopy() + + return &newRules +} + +func (data *PriceFloorData) DeepCopy() *PriceFloorData { + if data == nil { + return nil + } + + newData := *data + newModelGroups := make([]PriceFloorModelGroup, len(data.ModelGroups)) + + for i := range data.ModelGroups { + var eachGroup PriceFloorModelGroup + eachGroup.Currency = data.ModelGroups[i].Currency + eachGroup.ModelWeight = ptrutil.Clone(data.ModelGroups[i].ModelWeight) + eachGroup.ModelVersion = data.ModelGroups[i].ModelVersion + eachGroup.SkipRate = data.ModelGroups[i].SkipRate + eachGroup.Values = maputil.Clone(data.ModelGroups[i].Values) + eachGroup.Default = data.ModelGroups[i].Default + eachGroup.Schema = PriceFloorSchema{ + Fields: sliceutil.Clone(data.ModelGroups[i].Schema.Fields), + Delimiter: data.ModelGroups[i].Schema.Delimiter, + } + newModelGroups[i] = eachGroup + } + newData.ModelGroups = newModelGroups + + return &newData +} + +func (enforcement *PriceFloorEnforcement) DeepCopy() *PriceFloorEnforcement { + if enforcement == nil { + return nil + } + + newEnforcement := *enforcement + newEnforcement.EnforceJS = ptrutil.Clone(enforcement.EnforceJS) + newEnforcement.EnforcePBS = ptrutil.Clone(enforcement.EnforcePBS) + newEnforcement.FloorDeals = ptrutil.Clone(enforcement.FloorDeals) + newEnforcement.BidAdjustment = ptrutil.Clone(enforcement.BidAdjustment) + + return &newEnforcement +} diff --git a/openrtb_ext/floors_test.go b/openrtb_ext/floors_test.go index 20247736768..d6c35b9f7ef 100644 --- a/openrtb_ext/floors_test.go +++ b/openrtb_ext/floors_test.go @@ -1,8 +1,10 @@ package openrtb_ext import ( + "reflect" "testing" + "github.com/prebid/prebid-server/v2/util/ptrutil" "github.com/stretchr/testify/assert" ) @@ -218,3 +220,263 @@ func TestPriceFloorRulesGetEnabled(t *testing.T) { }) } } + +func TestPriceFloorRulesDeepCopy(t *testing.T) { + type fields struct { + FloorMin float64 + FloorMinCur string + SkipRate int + Location *PriceFloorEndpoint + Data *PriceFloorData + Enforcement *PriceFloorEnforcement + Enabled *bool + Skipped *bool + FloorProvider string + FetchStatus string + PriceFloorLocation string + } + tests := []struct { + name string + fields fields + }{ + { + name: "DeepCopy does not share same reference", + fields: fields{ + FloorMin: 10, + FloorMinCur: "INR", + SkipRate: 0, + Location: &PriceFloorEndpoint{ + URL: "https://test/floors", + }, + Data: &PriceFloorData{ + Currency: "INR", + SkipRate: 0, + ModelGroups: []PriceFloorModelGroup{ + { + Currency: "INR", + ModelWeight: ptrutil.ToPtr(1), + SkipRate: 0, + Values: map[string]float64{ + "banner|300x600|www.website5.com": 20, + "*|*|*": 50, + }, + Schema: PriceFloorSchema{ + Fields: []string{"mediaType", "size", "domain"}, + Delimiter: "|", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pf := &PriceFloorRules{ + FloorMin: tt.fields.FloorMin, + FloorMinCur: tt.fields.FloorMinCur, + SkipRate: tt.fields.SkipRate, + Location: tt.fields.Location, + Data: tt.fields.Data, + Enforcement: tt.fields.Enforcement, + Enabled: tt.fields.Enabled, + Skipped: tt.fields.Skipped, + FloorProvider: tt.fields.FloorProvider, + FetchStatus: tt.fields.FetchStatus, + PriceFloorLocation: tt.fields.PriceFloorLocation, + } + got := pf.DeepCopy() + if got == pf { + t.Errorf("Rules reference are same") + } + if got.Data == pf.Data { + t.Errorf("Floor data reference is same") + } + }) + } +} + +func TestFloorRulesDeepCopy(t *testing.T) { + type fields struct { + FloorMin float64 + FloorMinCur string + SkipRate int + Location *PriceFloorEndpoint + Data *PriceFloorData + Enforcement *PriceFloorEnforcement + Enabled *bool + Skipped *bool + FloorProvider string + FetchStatus string + PriceFloorLocation string + } + tests := []struct { + name string + fields fields + want *PriceFloorRules + }{ + { + name: "Copy entire floors object", + fields: fields{ + FloorMin: 10, + FloorMinCur: "INR", + SkipRate: 0, + Location: &PriceFloorEndpoint{ + URL: "http://prebid.com/floor", + }, + Data: &PriceFloorData{ + Currency: "INR", + SkipRate: 0, + FloorsSchemaVersion: "2", + ModelTimestamp: 123, + ModelGroups: []PriceFloorModelGroup{ + { + Currency: "INR", + ModelWeight: ptrutil.ToPtr(50), + ModelVersion: "version 1", + SkipRate: 0, + Schema: PriceFloorSchema{ + Fields: []string{"a", "b", "c"}, + Delimiter: "|", + }, + Values: map[string]float64{ + "*|*|*": 20, + }, + Default: 1, + }, + }, + FloorProvider: "prebid", + }, + Enforcement: &PriceFloorEnforcement{ + EnforceJS: ptrutil.ToPtr(true), + EnforcePBS: ptrutil.ToPtr(true), + FloorDeals: ptrutil.ToPtr(true), + BidAdjustment: ptrutil.ToPtr(true), + EnforceRate: 100, + }, + Enabled: ptrutil.ToPtr(true), + Skipped: ptrutil.ToPtr(false), + FloorProvider: "Prebid", + FetchStatus: "success", + PriceFloorLocation: "fetch", + }, + want: &PriceFloorRules{ + FloorMin: 10, + FloorMinCur: "INR", + SkipRate: 0, + Location: &PriceFloorEndpoint{ + URL: "http://prebid.com/floor", + }, + Data: &PriceFloorData{ + Currency: "INR", + SkipRate: 0, + FloorsSchemaVersion: "2", + ModelTimestamp: 123, + ModelGroups: []PriceFloorModelGroup{ + { + Currency: "INR", + ModelWeight: ptrutil.ToPtr(50), + ModelVersion: "version 1", + SkipRate: 0, + Schema: PriceFloorSchema{ + Fields: []string{"a", "b", "c"}, + Delimiter: "|", + }, + Values: map[string]float64{ + "*|*|*": 20, + }, + Default: 1, + }, + }, + FloorProvider: "prebid", + }, + Enforcement: &PriceFloorEnforcement{ + EnforceJS: ptrutil.ToPtr(true), + EnforcePBS: ptrutil.ToPtr(true), + FloorDeals: ptrutil.ToPtr(true), + BidAdjustment: ptrutil.ToPtr(true), + EnforceRate: 100, + }, + Enabled: ptrutil.ToPtr(true), + Skipped: ptrutil.ToPtr(false), + FloorProvider: "Prebid", + FetchStatus: "success", + PriceFloorLocation: "fetch", + }, + }, + { + name: "Copy entire floors object", + fields: fields{ + FloorMin: 10, + FloorMinCur: "INR", + SkipRate: 0, + Location: &PriceFloorEndpoint{ + URL: "http://prebid.com/floor", + }, + Data: nil, + Enforcement: &PriceFloorEnforcement{ + EnforceJS: ptrutil.ToPtr(true), + EnforcePBS: ptrutil.ToPtr(true), + FloorDeals: ptrutil.ToPtr(true), + BidAdjustment: ptrutil.ToPtr(true), + EnforceRate: 100, + }, + Enabled: ptrutil.ToPtr(true), + Skipped: ptrutil.ToPtr(false), + FloorProvider: "Prebid", + FetchStatus: "success", + PriceFloorLocation: "fetch", + }, + want: &PriceFloorRules{ + FloorMin: 10, + FloorMinCur: "INR", + SkipRate: 0, + Location: &PriceFloorEndpoint{ + URL: "http://prebid.com/floor", + }, + Data: nil, + Enforcement: &PriceFloorEnforcement{ + EnforceJS: ptrutil.ToPtr(true), + EnforcePBS: ptrutil.ToPtr(true), + FloorDeals: ptrutil.ToPtr(true), + BidAdjustment: ptrutil.ToPtr(true), + EnforceRate: 100, + }, + Enabled: ptrutil.ToPtr(true), + Skipped: ptrutil.ToPtr(false), + FloorProvider: "Prebid", + FetchStatus: "success", + PriceFloorLocation: "fetch", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pf := &PriceFloorRules{ + FloorMin: tt.fields.FloorMin, + FloorMinCur: tt.fields.FloorMinCur, + SkipRate: tt.fields.SkipRate, + Location: tt.fields.Location, + Data: tt.fields.Data, + Enforcement: tt.fields.Enforcement, + Enabled: tt.fields.Enabled, + Skipped: tt.fields.Skipped, + FloorProvider: tt.fields.FloorProvider, + FetchStatus: tt.fields.FetchStatus, + PriceFloorLocation: tt.fields.PriceFloorLocation, + } + if got := pf.DeepCopy(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("PriceFloorRules.DeepCopy() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFloorRuleDeepCopyNil(t *testing.T) { + var priceFloorRule *PriceFloorRules + got := priceFloorRule.DeepCopy() + + if got != nil { + t.Errorf("PriceFloorRules.DeepCopy() = %v, want %v", got, nil) + } +} diff --git a/router/router.go b/router/router.go index 0ee9fc7c30e..3d61c6601cd 100644 --- a/router/router.go +++ b/router/router.go @@ -20,6 +20,7 @@ import ( "github.com/prebid/prebid-server/v2/errortypes" "github.com/prebid/prebid-server/v2/exchange" "github.com/prebid/prebid-server/v2/experiment/adscert" + "github.com/prebid/prebid-server/v2/floors" "github.com/prebid/prebid-server/v2/gdpr" "github.com/prebid/prebid-server/v2/hooks" "github.com/prebid/prebid-server/v2/macros" @@ -162,6 +163,16 @@ func New(cfg *config.Configuration, rateConvertor *currency.RateConverter) (r *R }, } + floorFechterHttpClient := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxConnsPerHost: cfg.PriceFloors.Fetcher.HttpClient.MaxConnsPerHost, + MaxIdleConns: cfg.PriceFloors.Fetcher.HttpClient.MaxIdleConns, + MaxIdleConnsPerHost: cfg.PriceFloors.Fetcher.HttpClient.MaxIdleConnsPerHost, + IdleConnTimeout: time.Duration(cfg.PriceFloors.Fetcher.HttpClient.IdleConnTimeout) * time.Second, + }, + } + if err := checkSupportedUserSyncEndpoints(cfg.BidderInfos); err != nil { return nil, err } @@ -224,10 +235,12 @@ func New(cfg *config.Configuration, rateConvertor *currency.RateConverter) (r *R glog.Fatalf("Failed to create ads cert signer: %v", err) } + priceFloorFetcher := floors.NewPriceFloorFetcher(cfg.PriceFloors, floorFechterHttpClient, r.MetricsEngine) + tmaxAdjustments := exchange.ProcessTMaxAdjustments(cfg.TmaxAdjustments) planBuilder := hooks.NewExecutionPlanBuilder(cfg.Hooks, repo) macroReplacer := macros.NewStringIndexBasedReplacer() - theExchange := exchange.NewExchange(adapters, cacheClient, cfg, syncersByBidder, r.MetricsEngine, cfg.BidderInfos, gdprPermsBuilder, rateConvertor, categoriesFetcher, adsCertSigner, macroReplacer) + theExchange := exchange.NewExchange(adapters, cacheClient, cfg, syncersByBidder, r.MetricsEngine, cfg.BidderInfos, gdprPermsBuilder, rateConvertor, categoriesFetcher, adsCertSigner, macroReplacer, priceFloorFetcher) var uuidGenerator uuidutil.UUIDRandomGenerator openrtbEndpoint, err := openrtb2.NewEndpoint(uuidGenerator, theExchange, paramsValidator, fetcher, accounts, cfg, r.MetricsEngine, analyticsRunner, disabledBidders, defReqJSON, activeBidders, storedRespFetcher, planBuilder, tmaxAdjustments) if err != nil {