diff --git a/currency/aggregate_conversions.go b/currency/aggregate_conversions.go new file mode 100644 index 00000000000..53c5ebff4b6 --- /dev/null +++ b/currency/aggregate_conversions.go @@ -0,0 +1,41 @@ +package currency + +// AggregateConversions contains both the request-defined currency rate +// map found in request.ext.prebid.currency and the currencies conversion +// rates fetched with the RateConverter object defined in rate_converter.go +// It implements the Conversions interface. +type AggregateConversions struct { + customRates, serverRates Conversions +} + +// NewAggregateConversions expects both customRates and pbsRates to not be nil +func NewAggregateConversions(customRates, pbsRates Conversions) *AggregateConversions { + return &AggregateConversions{ + customRates: customRates, + serverRates: pbsRates, + } +} + +// GetRate returns the conversion rate between two currencies prioritizing +// the customRates currency rate over that of the PBS currency rate service +// returns an error if both Conversions objects return error. +func (re *AggregateConversions) GetRate(from string, to string) (float64, error) { + rate, err := re.customRates.GetRate(from, to) + if err == nil { + return rate, nil + } else if _, isMissingRateErr := err.(ConversionRateNotFound); !isMissingRateErr { + // other error, return the error + return 0, err + } + + // because the custom rates' GetRate() call returned an error other than "conversion + // rate not found", there's nothing wrong with the 3 letter currency code so let's + // try the PBS rates instead + return re.serverRates.GetRate(from, to) +} + +// GetRates is not implemented for AggregateConversions . There is no need to call +// this function for this scenario. +func (r *AggregateConversions) GetRates() *map[string]map[string]float64 { + return nil +} diff --git a/currency/aggregate_conversions_test.go b/currency/aggregate_conversions_test.go new file mode 100644 index 00000000000..35ca51a1fe7 --- /dev/null +++ b/currency/aggregate_conversions_test.go @@ -0,0 +1,89 @@ +package currency + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGroupedGetRate(t *testing.T) { + + // Setup: + customRates := NewRates(time.Now(), map[string]map[string]float64{ + "USD": { + "GBP": 3.00, + "EUR": 2.00, + }, + }) + + pbsRates := NewRates(time.Now(), map[string]map[string]float64{ + "USD": { + "GBP": 4.00, + "MXN": 10.00, + }, + }) + aggregateConversions := NewAggregateConversions(customRates, pbsRates) + + // Test cases: + type aTest struct { + desc string + from string + to string + expectedRate float64 + } + + testGroups := []struct { + expectedError error + testCases []aTest + }{ + { + expectedError: nil, + testCases: []aTest{ + {"Found in both, return custom rate", "USD", "GBP", 3.00}, + {"Found in both, return inverse custom rate", "GBP", "USD", 1 / 3.00}, + {"Found in custom rates only", "USD", "EUR", 2.00}, + {"Found in PBS rates only", "USD", "MXN", 10.00}, + {"Found in PBS rates only, return inverse", "MXN", "USD", 1 / 10.00}, + {"Same currency, return unitary rate", "USD", "USD", 1}, + }, + }, + { + expectedError: errors.New("currency: tag is not well-formed"), + testCases: []aTest{ + {"From-currency three-digit code malformed", "XX", "EUR", 0}, + {"To-currency three-digit code malformed", "GBP", "", 0}, + {"Both currencies malformed", "", "", 0}, + }, + }, + { + expectedError: errors.New("currency: tag is not a recognized currency"), + testCases: []aTest{ + {"From-currency three-digit code not found", "FOO", "EUR", 0}, + {"To-currency three-digit code not found", "GBP", "BAR", 0}, + }, + }, + { + expectedError: ConversionRateNotFound{"GBP", "EUR"}, + testCases: []aTest{ + {"Valid three-digit currency codes, but conversion rate not found", "GBP", "EUR", 0}, + }, + }, + } + + for _, group := range testGroups { + for _, tc := range group.testCases { + // Execute: + rate, err := aggregateConversions.GetRate(tc.from, tc.to) + + // Verify: + assert.Equal(t, tc.expectedRate, rate, "conversion rate doesn't match the expected rate: %s\n", tc.desc) + if group.expectedError != nil { + assert.Error(t, err, "error doesn't match expected: %s\n", tc.desc) + } else { + assert.NoError(t, err, "err should be nil: %s\n", tc.desc) + } + } + } +} diff --git a/currency/constant_rates.go b/currency/constant_rates.go index 26471a966a5..dde317d809e 100644 --- a/currency/constant_rates.go +++ b/currency/constant_rates.go @@ -1,8 +1,6 @@ package currency import ( - "fmt" - "golang.org/x/text/currency" ) @@ -29,7 +27,7 @@ func (r *ConstantRates) GetRate(from string, to string) (float64, error) { } if fromUnit.String() != toUnit.String() { - return 0, fmt.Errorf("Constant rates doesn't proceed to any conversions, cannot convert '%s' => '%s'", fromUnit.String(), toUnit.String()) + return 0, ConversionRateNotFound{fromUnit.String(), toUnit.String()} } return 1, nil diff --git a/currency/errors.go b/currency/errors.go new file mode 100644 index 00000000000..d764c15b984 --- /dev/null +++ b/currency/errors.go @@ -0,0 +1,13 @@ +package currency + +import "fmt" + +// ConversionRateNotFound is thrown by the currency.Conversions GetRate(from string, to string) method +// when the conversion rate between the two currencies, nor its reciprocal, can be found. +type ConversionRateNotFound struct { + FromCur, ToCur string +} + +func (err ConversionRateNotFound) Error() string { + return fmt.Sprintf("Currency conversion rate not found: '%s' => '%s'", err.FromCur, err.ToCur) +} diff --git a/currency/rates.go b/currency/rates.go index a3ae5f30fd5..62914c4b2e2 100644 --- a/currency/rates.go +++ b/currency/rates.go @@ -3,7 +3,6 @@ package currency import ( "encoding/json" "errors" - "fmt" "time" "golang.org/x/text/currency" @@ -45,8 +44,11 @@ func (r *Rates) UnmarshalJSON(b []byte) error { return nil } -// GetRate returns the conversion rate between two currencies -// returns an error in case the conversion rate between the two given currencies is not in the currencies rates map +// GetRate returns the conversion rate between two currencies or: +// - An error if one of the currency strings is not well-formed +// - An error if any of the currency strings is not a recognized currency code. +// - A MissingConversionRate error in case the conversion rate between the two +// given currencies is not in the currencies rates map func (r *Rates) GetRate(from string, to string) (float64, error) { var err error fromUnit, err := currency.ParseISO(from) @@ -63,12 +65,12 @@ func (r *Rates) GetRate(from string, to string) (float64, error) { if r.Conversions != nil { if conversion, present := r.Conversions[fromUnit.String()][toUnit.String()]; present { // In case we have an entry FROM -> TO - return conversion, err + return conversion, nil } else if conversion, present := r.Conversions[toUnit.String()][fromUnit.String()]; present { // In case we have an entry TO -> FROM - return 1 / conversion, err + return 1 / conversion, nil } - return 0, fmt.Errorf("Currency conversion rate not found: '%s' => '%s'", fromUnit.String(), toUnit.String()) + return 0, ConversionRateNotFound{fromUnit.String(), toUnit.String()} } return 0, errors.New("rates are nil") } diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go index 8913e90791d..d8a7fa689b9 100644 --- a/endpoints/openrtb2/auction.go +++ b/endpoints/openrtb2/auction.go @@ -37,6 +37,7 @@ import ( "github.com/prebid/prebid-server/util/httputil" "github.com/prebid/prebid-server/util/iputil" "golang.org/x/net/publicsuffix" + "golang.org/x/text/currency" ) const storedRequestTimeoutMillis = 50 @@ -343,6 +344,10 @@ func (deps *endpointDeps) validateRequest(req *openrtb2.BidRequest) []error { if err := deps.validateEidPermissions(bidExt, aliases); err != nil { return []error{err} } + + if err := validateCustomRates(bidExt.Prebid.CurrencyConversions); err != nil { + return []error{err} + } } if (req.Site == nil && req.App == nil) || (req.Site != nil && req.App != nil) { @@ -437,6 +442,30 @@ func validateSChains(req *openrtb_ext.ExtRequest) error { return err } +// validateCustomRates throws a bad input error if any of the 3-digit currency codes found in +// the bidRequest.ext.prebid.currency field is invalid, malfomed or does not represent any actual +// currency. No error is thrown if bidRequest.ext.prebid.currency is invalid or empty. +func validateCustomRates(bidReqCurrencyRates *openrtb_ext.ExtRequestCurrency) error { + if bidReqCurrencyRates == nil { + return nil + } + + for fromCurrency, rates := range bidReqCurrencyRates.ConversionRates { + // Check if fromCurrency is a valid 3-letter currency code + if _, err := currency.ParseISO(fromCurrency); err != nil { + return &errortypes.BadInput{Message: fmt.Sprintf("currency code %s is not recognized or malformed", fromCurrency)} + } + + // Check if currencies mapped to fromCurrency are valid 3-letter currency codes + for toCurrency := range rates { + if _, err := currency.ParseISO(toCurrency); err != nil { + return &errortypes.BadInput{Message: fmt.Sprintf("currency code %s is not recognized or malformed", toCurrency)} + } + } + } + return nil +} + func (deps *endpointDeps) validateEidPermissions(req *openrtb_ext.ExtRequest, aliases map[string]string) error { if req == nil || req.Prebid.Data == nil { return nil diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go index 3d40d35b068..bcdac13dc06 100644 --- a/endpoints/openrtb2/auction_test.go +++ b/endpoints/openrtb2/auction_test.go @@ -24,6 +24,7 @@ import ( "github.com/mxmCherry/openrtb/v15/openrtb2" analyticsConf "github.com/prebid/prebid-server/analytics/config" "github.com/prebid/prebid-server/config" + "github.com/prebid/prebid-server/currency" "github.com/prebid/prebid-server/errortypes" "github.com/prebid/prebid-server/exchange" "github.com/prebid/prebid-server/metrics" @@ -45,11 +46,13 @@ type testCase struct { } type testConfigValues struct { - AccountRequired bool `json:"accountRequired"` - AliasJSON string `json:"aliases"` - BlacklistedAccounts []string `json:"blacklistedAccts"` - BlacklistedApps []string `json:"blacklistedApps"` - DisabledAdapters []string `json:"disabledAdapters"` + AccountRequired bool `json:"accountRequired"` + AliasJSON string `json:"aliases"` + BlacklistedAccounts []string `json:"blacklistedAccts"` + BlacklistedApps []string `json:"blacklistedApps"` + DisabledAdapters []string `json:"disabledAdapters"` + CurrencyRates map[string]map[string]float64 `json:"currencyRates"` + MockBidder mockBidExchangeBidder `json:"mockBidder"` } func TestJsonSampleRequests(t *testing.T) { @@ -105,6 +108,22 @@ func TestJsonSampleRequests(t *testing.T) { "Requests with first party data context info found in imp[i].ext.prebid.bidder,context", "first-party-data", }, + { + "Assert we correctly use the server conversion rates when needed", + "currency-conversion/server-rates/valid", + }, + { + "Assert we correctly throw an error when no conversion rate was found in the server conversions map", + "currency-conversion/server-rates/errors", + }, + { + "Assert we correctly use request-defined custom currency rates when present in root.ext", + "currency-conversion/custom-rates/valid", + }, + { + "Assert we correctly validate request-defined custom currency rates when present in root.ext", + "currency-conversion/custom-rates/errors", + }, } for _, test := range testSuites { testCaseFiles, err := getTestFiles(filepath.Join("sample-requests", test.sampleRequestsSubDir)) @@ -248,6 +267,7 @@ func assertBidResponseEqual(t *testing.T, testFile string, expectedBidResponse o assert.Equalf(t, expectedBidResponse.ID, actualBidResponse.ID, "BidResponse.ID doesn't match expected. Test: %s\n", testFile) assert.Equalf(t, expectedBidResponse.BidID, actualBidResponse.BidID, "BidResponse.BidID doesn't match expected. Test: %s\n", testFile) assert.Equalf(t, expectedBidResponse.NBR, actualBidResponse.NBR, "BidResponse.NBR doesn't match expected. Test: %s\n", testFile) + assert.Equalf(t, expectedBidResponse.Cur, actualBidResponse.Cur, "BidResponse.Cur doesn't match expected. Test: %s\n", testFile) //Assert []SeatBid and their Bid elements independently of their order assert.Len(t, actualBidResponse.SeatBid, len(expectedBidResponse.SeatBid), "BidResponse.SeatBid array doesn't match expected. Test: %s\n", testFile) @@ -441,8 +461,10 @@ func doRequest(t *testing.T, test testCase) (int, string) { bidderMap := exchange.GetActiveBidders(bidderInfos) disabledBidders := exchange.GetDisabledBiddersErrorMessages(bidderInfos) + mockExchange := newMockBidExchange(test.Config.MockBidder, test.Config.CurrencyRates) + endpoint, _ := NewEndpoint( - &mockBidExchange{}, + mockExchange, newParamsValidator(t), &mockStoredReqFetcher{}, empty_fetcher.EmptyFetcher{}, @@ -1184,6 +1206,113 @@ func TestContentType(t *testing.T) { } } +func TestValidateCustomRates(t *testing.T) { + boolTrue := true + boolFalse := false + + testCases := []struct { + desc string + inBidReqCurrencies *openrtb_ext.ExtRequestCurrency + outCurrencyError error + }{ + { + desc: "nil input, no errors expected", + inBidReqCurrencies: nil, + outCurrencyError: nil, + }, + { + desc: "empty custom currency rates but UsePBSRates is set to false, we don't return error nor warning", + inBidReqCurrencies: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: map[string]map[string]float64{}, + UsePBSRates: &boolFalse, + }, + outCurrencyError: nil, + }, + { + desc: "empty custom currency rates but UsePBSRates is set to true, no need to return error because we can use PBS rates", + inBidReqCurrencies: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: map[string]map[string]float64{}, + UsePBSRates: &boolTrue, + }, + outCurrencyError: nil, + }, + { + desc: "UsePBSRates is nil and defaults to true, bidExt fromCurrency is invalid, expect bad input error", + inBidReqCurrencies: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: map[string]map[string]float64{ + "FOO": { + "GBP": 1.2, + "MXN": 0.05, + "JPY": 0.95, + }, + }, + }, + outCurrencyError: &errortypes.BadInput{Message: "currency code FOO is not recognized or malformed"}, + }, + { + desc: "UsePBSRates set to false, bidExt fromCurrency is invalid, expect bad input error", + inBidReqCurrencies: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: map[string]map[string]float64{ + "FOO": { + "GBP": 1.2, + "MXN": 0.05, + "JPY": 0.95, + }, + }, + UsePBSRates: &boolFalse, + }, + outCurrencyError: &errortypes.BadInput{Message: "currency code FOO is not recognized or malformed"}, + }, + { + desc: "UsePBSRates set to false, some of the bidExt 'to' Currencies are invalid, expect bad input error when parsing the first invalid currency code", + inBidReqCurrencies: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: map[string]map[string]float64{ + "USD": { + "FOO": 10.0, + "MXN": 0.05, + }, + }, + UsePBSRates: &boolFalse, + }, + outCurrencyError: &errortypes.BadInput{Message: "currency code FOO is not recognized or malformed"}, + }, + { + desc: "UsePBSRates set to false, some of the bidExt 'from' and 'to' currencies are invalid, expect bad input error when parsing the first invalid currency code", + inBidReqCurrencies: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: map[string]map[string]float64{ + "FOO": { + "MXN": 0.05, + "CAD": 0.95, + }, + }, + UsePBSRates: &boolFalse, + }, + outCurrencyError: &errortypes.BadInput{Message: "currency code FOO is not recognized or malformed"}, + }, + { + desc: "All 3-digit currency codes exist, expect no error", + inBidReqCurrencies: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: map[string]map[string]float64{ + "USD": { + "MXN": 0.05, + }, + "MXN": { + "JPY": 10.0, + "EUR": 10.95, + }, + }, + UsePBSRates: &boolFalse, + }, + }, + } + + for _, tc := range testCases { + actualErr := validateCustomRates(tc.inBidReqCurrencies) + + assert.Equal(t, tc.outCurrencyError, actualErr, tc.desc) + } +} + func TestValidateImpExt(t *testing.T) { type testCase struct { description string @@ -2590,7 +2719,49 @@ func (e *nobidExchange) HoldAuction(ctx context.Context, r exchange.AuctionReque } type mockBidExchange struct { - gotRequest *openrtb2.BidRequest + mockBidder mockBidExchangeBidder + pbsRates map[string]map[string]float64 +} + +func newMockBidExchange(bidder mockBidExchangeBidder, mockCurrencyConversionRates map[string]map[string]float64) *mockBidExchange { + if bidder.BidCurrency == "" { + bidder.BidCurrency = "USD" + } + + return &mockBidExchange{ + mockBidder: bidder, + pbsRates: mockCurrencyConversionRates, + } +} + +// getAuctionCurrencyRates copies the logic of the exchange package for testing purposes +func (e *mockBidExchange) getAuctionCurrencyRates(customRates *openrtb_ext.ExtRequestCurrency) currency.Conversions { + if customRates == nil { + // The timestamp is required for the function signature, but is not used and its + // value has no significance in the tests + return currency.NewRates(time.Now(), e.pbsRates) + } + + usePbsRates := true + if customRates.UsePBSRates != nil { + usePbsRates = *customRates.UsePBSRates + } + + if !usePbsRates { + // The timestamp is required for the function signature, but is not used and its + // value has no significance in the tests + return currency.NewRates(time.Now(), customRates.ConversionRates) + } + + // Both PBS and custom rates can be used, check if ConversionRates is not empty + if len(customRates.ConversionRates) == 0 { + // Custom rates map is empty, use PBS rates only + return currency.NewRates(time.Now(), e.pbsRates) + } + + // Return an AggregateConversions object that includes both custom and PBS currency rates but will + // prioritize custom rates over PBS rates whenever a currency rate is found in both + return currency.NewAggregateConversions(currency.NewRates(time.Time{}, customRates.ConversionRates), currency.NewRates(time.Now(), e.pbsRates)) } // mockBidExchange is a well-behaved exchange that lists the bidders found in every bidRequest.Imp[i].Ext @@ -2601,6 +2772,36 @@ func (e *mockBidExchange) HoldAuction(ctx context.Context, r exchange.AuctionReq BidID: "test bid id", NBR: openrtb2.NoBidReasonCodeUnknownError.Ptr(), } + + // Use currencies inside r.BidRequest.Cur, if any, and convert currencies if needed + if len(r.BidRequest.Cur) == 0 { + r.BidRequest.Cur = []string{"USD"} + } + + var currencyFrom string = e.mockBidder.getBidCurrency() + var conversionRate float64 = 0.00 + var err error + + var requestExt openrtb_ext.ExtRequest + if len(r.BidRequest.Ext) > 0 { + if err := json.Unmarshal(r.BidRequest.Ext, &requestExt); err != nil { + return nil, fmt.Errorf("request.ext is invalid: %v", err) + } + } + + conversions := e.getAuctionCurrencyRates(requestExt.Prebid.CurrencyConversions) + for _, bidReqCur := range r.BidRequest.Cur { + if conversionRate, err = conversions.GetRate(currencyFrom, bidReqCur); err == nil { + bidResponse.Cur = bidReqCur + break + } + } + + if conversionRate == 0 { + // Can't have bids if there's not even a 1 USD to 1 USD conversion rate + return nil, errors.New("Can't produce bid with no valid currency to use or currency conversion to convert to.") + } + if len(r.BidRequest.Imp) > 0 { var SeatBidMap = make(map[string]openrtb2.SeatBid, 0) for _, imp := range r.BidRequest.Imp { @@ -2625,9 +2826,17 @@ func (e *mockBidExchange) HoldAuction(ctx context.Context, r exchange.AuctionReq for bidderNameOrAlias := range bidderExts { if isBidderToValidate(bidderNameOrAlias) { if val, ok := SeatBidMap[bidderNameOrAlias]; ok { - val.Bid = append(val.Bid, openrtb2.Bid{ID: fmt.Sprintf("%s-bid", bidderNameOrAlias)}) + val.Bid = append(val.Bid, openrtb2.Bid{ID: e.mockBidder.getBidId(bidderNameOrAlias)}) } else { - SeatBidMap[bidderNameOrAlias] = openrtb2.SeatBid{Seat: fmt.Sprintf("%s-bids", bidderNameOrAlias), Bid: []openrtb2.Bid{{ID: fmt.Sprintf("%s-bid", bidderNameOrAlias)}}} + SeatBidMap[bidderNameOrAlias] = openrtb2.SeatBid{ + Seat: e.mockBidder.getSeatName(bidderNameOrAlias), + Bid: []openrtb2.Bid{ + { + ID: e.mockBidder.getBidId(bidderNameOrAlias), + Price: e.mockBidder.getBidPrice() * conversionRate, + }, + }, + } } } } @@ -2640,6 +2849,24 @@ func (e *mockBidExchange) HoldAuction(ctx context.Context, r exchange.AuctionReq return bidResponse, nil } +type mockBidExchangeBidder struct { + BidCurrency string `json:"currency"` + BidPrice float64 `json:"price"` +} + +func (bidder mockBidExchangeBidder) getBidCurrency() string { + return bidder.BidCurrency +} +func (bidder mockBidExchangeBidder) getBidPrice() float64 { + return bidder.BidPrice +} +func (bidder mockBidExchangeBidder) getSeatName(bidderNameOrAlias string) string { + return fmt.Sprintf("%s-bids", bidderNameOrAlias) +} +func (bidder mockBidExchangeBidder) getBidId(bidderNameOrAlias string) string { + return fmt.Sprintf("%s-bid", bidderNameOrAlias) +} + type brokenExchange struct{} func (e *brokenExchange) HoldAuction(ctx context.Context, r exchange.AuctionRequest, debugLog *exchange.DebugLog) (*openrtb2.BidResponse, error) { diff --git a/endpoints/openrtb2/sample-requests/account-required/no-account/not-required-no-acct.json b/endpoints/openrtb2/sample-requests/account-required/no-account/not-required-no-acct.json index c3ab09d4883..75c859d212b 100644 --- a/endpoints/openrtb2/sample-requests/account-required/no-account/not-required-no-acct.json +++ b/endpoints/openrtb2/sample-requests/account-required/no-account/not-required-no-acct.json @@ -66,6 +66,7 @@ "expectedBidResponse": { "id":"some-request-id", "bidid":"test bid id", + "cur": "USD", "nbr":0, "seatbid": [ { diff --git a/endpoints/openrtb2/sample-requests/account-required/with-account/required-with-acct.json b/endpoints/openrtb2/sample-requests/account-required/with-account/required-with-acct.json index a72d184c81c..ae930384499 100644 --- a/endpoints/openrtb2/sample-requests/account-required/with-account/required-with-acct.json +++ b/endpoints/openrtb2/sample-requests/account-required/with-account/required-with-acct.json @@ -68,6 +68,7 @@ "expectedBidResponse": { "id":"some-request-id", "bidid":"test bid id", + "cur": "USD", "nbr":0, "seatbid": [ { diff --git a/endpoints/openrtb2/sample-requests/aliased/multiple-alias.json b/endpoints/openrtb2/sample-requests/aliased/multiple-alias.json index 55e45041e6e..00906c89772 100644 --- a/endpoints/openrtb2/sample-requests/aliased/multiple-alias.json +++ b/endpoints/openrtb2/sample-requests/aliased/multiple-alias.json @@ -87,6 +87,7 @@ } ], "bidid": "test bid id", + "cur": "USD", "nbr": 0 }, "expectedReturnCode": 200 diff --git a/endpoints/openrtb2/sample-requests/aliased/simple.json b/endpoints/openrtb2/sample-requests/aliased/simple.json index a99907ab370..677d3d8cf53 100644 --- a/endpoints/openrtb2/sample-requests/aliased/simple.json +++ b/endpoints/openrtb2/sample-requests/aliased/simple.json @@ -27,19 +27,20 @@ "expectedBidResponse": { "id":"some-request-id", "bidid":"test bid id", + "cur": "USD", "nbr":0, - "seatbid": [ - { - "bid": [ - { - "id": "alias1-bid", - "impid": "", - "price": 0 - } - ], - "seat": "alias1-bids" - } - ] + "seatbid": [ + { + "bid": [ + { + "id": "alias1-bid", + "impid": "", + "price": 0 + } + ], + "seat": "alias1-bids" + } + ] }, "expectedReturnCode": 200 } diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/conversion-disabled.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/conversion-disabled.json new file mode 100644 index 00000000000..03877031294 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/conversion-disabled.json @@ -0,0 +1,46 @@ +{ + "description": "request.ext.prebid.currency.rates empty, usepbsrates is false, a conversion is needed but conversions are disabled", + "config": { + "currencyRates":{ + "USD": { + "MXN": 2.00 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "cur": ["MXN"], + "ext": { + "prebid": { + "currency": { + "rates": {}, + "usepbsrates": false + } + } + } + }, + "expectedReturnCode": 500, + "expectedErrorMessage": "Critical error while running the auction: Can't produce bid with no valid currency to use or currency conversion to convert to." +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-currency-missing-usepbs-default.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-currency-missing-usepbs-default.json new file mode 100644 index 00000000000..6a727e9615c --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-currency-missing-usepbs-default.json @@ -0,0 +1,52 @@ +{ + "description": "currency in request.cur cannot be converted because conversion rate not found in either custom currency rates nor server rates. usepbsrates defaults to true", + "config": { + "currencyRates":{ + "USD": { + "MXN": 2.00 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "cur": ["GBP"], + "ext": { + "prebid": { + "aliases": { + "unknown": "appnexus" + }, + "currency": { + "rates": { + "USD": { + "MXN": 5.00 + } + } + } + } + } + }, + "expectedReturnCode": 500, + "expectedErrorMessage": "Critical error while running the auction: Can't produce bid with no valid currency to use or currency conversion to convert to." +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-currency-missing-usepbs-false.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-currency-missing-usepbs-false.json new file mode 100644 index 00000000000..5549fa9b688 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-currency-missing-usepbs-false.json @@ -0,0 +1,53 @@ +{ + "description": "currency in request.cur cannot be converted because usepbsrates set to false not allowing for PBS to use its rates. Default to price of 0", + "config": { + "currencyRates":{ + "USD": { + "MXN": 5.09 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "cur": ["MXN"], + "ext": { + "prebid": { + "aliases": { + "unknown": "appnexus" + }, + "currency": { + "rates": { + "USD": { + "JPY": 2.00 + } + }, + "usepbsrates": false + } + } + } + }, + "expectedReturnCode": 500, + "expectedErrorMessage": "Critical error while running the auction: Can't produce bid with no valid currency to use or currency conversion to convert to." +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-empty-usepbs-false.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-empty-usepbs-false.json new file mode 100644 index 00000000000..f4e19f3a4c5 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-empty-usepbs-false.json @@ -0,0 +1,49 @@ +{ + "description": "usepbsrates set to false forces BidRequest to use custom currency rates but bidRequest.ext.prebid.currency.rates field is empty", + "config": { + "currencyRates":{ + "USD": { + "MXN": 5.09 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "cur": ["MXN"], + "ext": { + "prebid": { + "aliases": { + "unknown": "appnexus" + }, + "currency": { + "rates": {}, + "usepbsrates": false + } + } + } + }, + "expectedReturnCode": 500, + "expectedErrorMessage": "Critical error while running the auction: Can't produce bid with no valid currency to use or currency conversion to convert to." +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-invalid-usepbs-false.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-invalid-usepbs-false.json new file mode 100644 index 00000000000..39857650f12 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/errors/custom-rates-invalid-usepbs-false.json @@ -0,0 +1,53 @@ +{ + "description": "False usepbsrates forces BidRequest use custom currency rates but bidRequest.ext.prebid.currency.rates field comes with invalid currency codes", + "config": { + "currencyRates":{ + "USD": { + "MXN": 5.09 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "cur": ["MXN"], + "ext": { + "prebid": { + "aliases": { + "unknown": "appnexus" + }, + "currency": { + "rates": { + "USD": { + "FOO": 10.0 + } + }, + "usepbsrates": false + } + } + } + }, + "expectedReturnCode": 400, + "expectedErrorMessage": "Invalid request: currency code " +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/conversion-disabled.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/conversion-disabled.json new file mode 100644 index 00000000000..0741ea4d315 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/conversion-disabled.json @@ -0,0 +1,62 @@ +{ + "description": "request.ext.prebid.currency.rates empty, usepbsrates set to false, request succeeded because no conversion was needed", + "config": { + "currencyRates":{ + "USD": { + "MXN": 2.00 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "ext": { + "prebid": { + "currency": { + "rates": {}, + "usepbsrates": false + } + } + } + }, + "expectedBidResponse": { + "id":"some-request-id", + "bidid":"test bid id", + "cur": "USD", + "nbr":0, + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 1.00 + } + ], + "seat": "appnexus-bids" + } + ] + }, + "expectedReturnCode": 200 +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rate-not-found-usepbsrates-default.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rate-not-found-usepbsrates-default.json new file mode 100644 index 00000000000..fb65a852355 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rate-not-found-usepbsrates-default.json @@ -0,0 +1,70 @@ +{ + "description": "request comes with custom rates but request.cur currency is only found in the server rates. Error wasn't thrown because usepbsrates defaults to true", + "config": { + "currencyRates":{ + "USD": { + "MXN": 2.00 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "cur": ["MXN"], + "ext": { + "prebid": { + "aliases": { + "unknown": "appnexus" + }, + "currency": { + "rates": { + "USD": { + "JPY": 15.00, + "EUR": 0.85 + } + } + } + } + } + }, + "expectedBidResponse": { + "id":"some-request-id", + "bidid":"test bid id", + "cur": "MXN", + "nbr":0, + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 2.00 + } + ], + "seat": "appnexus-bids" + } + ] + }, + "expectedReturnCode": 200 +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rates-override-usepbsrates-default.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rates-override-usepbsrates-default.json new file mode 100644 index 00000000000..80790a52543 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rates-override-usepbsrates-default.json @@ -0,0 +1,69 @@ +{ + "description": "Despite being more expensive, custom conversion rate overrides that of the currency conversion service. usepbsrates defaults to true", + "config": { + "currencyRates":{ + "USD": { + "MXN": 2.00 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "cur": ["MXN"], + "ext": { + "prebid": { + "aliases": { + "unknown": "appnexus" + }, + "currency": { + "rates": { + "USD": { + "MXN": 5.00 + } + } + } + } + } + }, + "expectedBidResponse": { + "id":"some-request-id", + "bidid":"test bid id", + "cur": "MXN", + "nbr":0, + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 5.00 + } + ], + "seat": "appnexus-bids" + } + ] + }, + "expectedReturnCode": 200 +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rates-override-usepbsrates-false.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rates-override-usepbsrates-false.json new file mode 100644 index 00000000000..ef372c1cf66 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rates-override-usepbsrates-false.json @@ -0,0 +1,70 @@ +{ + "description": "request.ext.prebid.currency substitutes those of the currency conversion server because usepbsrates is false", + "config": { + "currencyRates":{ + "USD": { + "MXN": 2.00 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "cur": ["MXN"], + "ext": { + "prebid": { + "aliases": { + "unknown": "appnexus" + }, + "currency": { + "rates": { + "USD": { + "MXN": 5.00 + } + }, + "usepbsrates": false + } + } + } + }, + "expectedBidResponse": { + "id":"some-request-id", + "bidid":"test bid id", + "cur": "MXN", + "nbr":0, + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 5.00 + } + ], + "seat": "appnexus-bids" + } + ] + }, + "expectedReturnCode": 200 +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rates-override-usepbsrates-true.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rates-override-usepbsrates-true.json new file mode 100644 index 00000000000..276e8da43c2 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/custom-rates-override-usepbsrates-true.json @@ -0,0 +1,70 @@ +{ + "description": "Despite being more expensive, custom conversion rate overrides that of the currency conversion service. usepbsrates was true", + "config": { + "currencyRates":{ + "USD": { + "MXN": 2.00 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "cur": ["MXN"], + "ext": { + "prebid": { + "aliases": { + "unknown": "appnexus" + }, + "currency": { + "rates": { + "USD": { + "MXN": 5.00 + } + }, + "usepbsrates": true + } + } + } + }, + "expectedBidResponse": { + "id":"some-request-id", + "bidid":"test bid id", + "cur": "MXN", + "nbr":0, + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 5.00 + } + ], + "seat": "appnexus-bids" + } + ] + }, + "expectedReturnCode": 200 +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/reverse-currency-conversion.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/reverse-currency-conversion.json new file mode 100644 index 00000000000..624f0784dac --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/reverse-currency-conversion.json @@ -0,0 +1,66 @@ +{ + "description": "USD BidRequest gets converted because mockbidder bids in foreign currency, custom conversion rate is used", + "config": { + "currencyRates":{ + "USD": { + "MXN": 8.00 + } + }, + "mockBidder": { + "currency": "MXN", + "price": 20.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "ext": { + "prebid": { + "currency": { + "rates": { + "USD": { + "MXN": 10.00 + } + }, + "usepbsrates": true + } + } + } + }, + "expectedBidResponse": { + "id":"some-request-id", + "bidid":"test bid id", + "nbr":0, + "cur": "USD", + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 2 + } + ], + "seat": "appnexus-bids" + } + ] + }, + "expectedReturnCode": 200 +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/server-rates-usepbsrates-true.json b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/server-rates-usepbsrates-true.json new file mode 100644 index 00000000000..929c2e0cbd5 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/custom-rates/valid/server-rates-usepbsrates-true.json @@ -0,0 +1,70 @@ +{ + "description": "Despite being more expensive, custom conversion rate overrides that of the currency conversion service. usepbsrates was true", + "config": { + "currencyRates":{ + "USD": { + "MXN": 2.00 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ], + "cur": ["MXN"], + "ext": { + "prebid": { + "aliases": { + "unknown": "appnexus" + }, + "currency": { + "rates": { + "USD": { + "CAD": 5.00 + } + }, + "usepbsrates": true + } + } + } + }, + "expectedBidResponse": { + "id":"some-request-id", + "bidid":"test bid id", + "cur": "MXN", + "nbr":0, + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 2.00 + } + ], + "seat": "appnexus-bids" + } + ] + }, + "expectedReturnCode": 200 +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/server-rates/errors/no-conversion-found.json b/endpoints/openrtb2/sample-requests/currency-conversion/server-rates/errors/no-conversion-found.json new file mode 100644 index 00000000000..dc0d7ce6042 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/server-rates/errors/no-conversion-found.json @@ -0,0 +1,38 @@ +{ + "description": "bid request calls for a bid in foreign currency MXN but conversion rate is not found in the currency conversion service.", + "config": { + "currencyRates":{ + "USD": { + "GBP": 0.80 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "cur": ["MXN"], + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ] + }, + "expectedReturnCode": 500, + "expectedErrorMessage": "Critical error while running the auction: Can't produce bid with no valid currency to use or currency conversion to convert to." +} diff --git a/endpoints/openrtb2/sample-requests/currency-conversion/server-rates/valid/simple-conversion.json b/endpoints/openrtb2/sample-requests/currency-conversion/server-rates/valid/simple-conversion.json new file mode 100644 index 00000000000..84788d5ada1 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/currency-conversion/server-rates/valid/simple-conversion.json @@ -0,0 +1,55 @@ +{ + "description": "bid request calls for a bid in foreign currency but mockbidder bids in USD. Conversion rate is applied", + "config": { + "currencyRates":{ + "USD": { + "MXN": 2.00 + } + }, + "mockBidder": { + "currency": "USD", + "price": 1.00 + } + }, + "mockBidRequest": { + "id": "some-request-id", + "site": { + "page": "test.somepage.com" + }, + "cur": ["MXN"], + "imp": [ + { + "id": "my-imp-id", + "video": { + "mimes": [ + "video/mp4" + ] + }, + "ext": { + "appnexus": { + "placementId": 12883451 + } + } + } + ] + }, + "expectedBidResponse": { + "id":"some-request-id", + "bidid":"test bid id", + "cur": "MXN", + "nbr":0, + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 2.00 + } + ], + "seat": "appnexus-bids" + } + ] + }, + "expectedReturnCode": 200 +} diff --git a/endpoints/openrtb2/sample-requests/disabled/good/partial.json b/endpoints/openrtb2/sample-requests/disabled/good/partial.json index 3549abaa934..735e7c5ede1 100644 --- a/endpoints/openrtb2/sample-requests/disabled/good/partial.json +++ b/endpoints/openrtb2/sample-requests/disabled/good/partial.json @@ -58,6 +58,7 @@ "expectedBidResponse": { "id":"some-request-id", "bidid":"test bid id", + "cur": "USD", "nbr":0, "seatbid": [ { diff --git a/endpoints/openrtb2/sample-requests/first-party-data/valid-fpd-allowed-with-ext-bidder.json b/endpoints/openrtb2/sample-requests/first-party-data/valid-fpd-allowed-with-ext-bidder.json index c36ae0cd41d..a4b716b2040 100644 --- a/endpoints/openrtb2/sample-requests/first-party-data/valid-fpd-allowed-with-ext-bidder.json +++ b/endpoints/openrtb2/sample-requests/first-party-data/valid-fpd-allowed-with-ext-bidder.json @@ -43,7 +43,8 @@ "seat": "appnexus-bids" }], "bidid": "test bid id", + "cur": "USD", "nbr": 0 }, "expectedReturnCode": 200 -} \ No newline at end of file +} diff --git a/endpoints/openrtb2/sample-requests/first-party-data/valid-fpd-allowed-with-prebid-bidder.json b/endpoints/openrtb2/sample-requests/first-party-data/valid-fpd-allowed-with-prebid-bidder.json index ad6298db39a..27e8c46d9d7 100644 --- a/endpoints/openrtb2/sample-requests/first-party-data/valid-fpd-allowed-with-prebid-bidder.json +++ b/endpoints/openrtb2/sample-requests/first-party-data/valid-fpd-allowed-with-prebid-bidder.json @@ -47,7 +47,8 @@ "seat": "appnexus-bids" }], "bidid": "test bid id", + "cur": "USD", "nbr": 0 }, "expectedReturnCode": 200 -} \ No newline at end of file +} diff --git a/endpoints/openrtb2/sample-requests/valid-native/asset-img-no-hmin.json b/endpoints/openrtb2/sample-requests/valid-native/asset-img-no-hmin.json index 15af8551da6..e556b15d4f2 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/asset-img-no-hmin.json +++ b/endpoints/openrtb2/sample-requests/valid-native/asset-img-no-hmin.json @@ -23,19 +23,20 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur":"USD", "nbr": 0, - "seatbid": [ - { - "bid": [ - { - "id": "appnexus-bid", - "impid": "", - "price": 0 - } - ], - "seat": "appnexus-bids" - } - ] + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 0 + } + ], + "seat": "appnexus-bids" + } + ] }, "expectedReturnCode": 200 } diff --git a/endpoints/openrtb2/sample-requests/valid-native/asset-img-no-wmin.json b/endpoints/openrtb2/sample-requests/valid-native/asset-img-no-wmin.json index 5d986bcf755..06673bcdf32 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/asset-img-no-wmin.json +++ b/endpoints/openrtb2/sample-requests/valid-native/asset-img-no-wmin.json @@ -23,19 +23,20 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur":"USD", "nbr": 0, - "seatbid": [ - { - "bid": [ - { - "id": "appnexus-bid", - "impid": "", - "price": 0 - } - ], - "seat": "appnexus-bids" - } - ] + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 0 + } + ], + "seat": "appnexus-bids" + } + ] }, "expectedReturnCode": 200 } diff --git a/endpoints/openrtb2/sample-requests/valid-native/asset-with-id.json b/endpoints/openrtb2/sample-requests/valid-native/asset-with-id.json index 1e55cdda63f..9b8763491a3 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/asset-with-id.json +++ b/endpoints/openrtb2/sample-requests/valid-native/asset-with-id.json @@ -23,6 +23,7 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur": "USD", "nbr": 0, "seatbid": [ { diff --git a/endpoints/openrtb2/sample-requests/valid-native/asset-with-no-id.json b/endpoints/openrtb2/sample-requests/valid-native/asset-with-no-id.json index 36a1745cb19..22ffc7f50d8 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/asset-with-no-id.json +++ b/endpoints/openrtb2/sample-requests/valid-native/asset-with-no-id.json @@ -23,6 +23,7 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur": "USD", "nbr": 0, "seatbid": [ { diff --git a/endpoints/openrtb2/sample-requests/valid-native/assets-with-unique-ids.json b/endpoints/openrtb2/sample-requests/valid-native/assets-with-unique-ids.json index 98cdeedadbe..e60e2028637 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/assets-with-unique-ids.json +++ b/endpoints/openrtb2/sample-requests/valid-native/assets-with-unique-ids.json @@ -23,6 +23,7 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur": "USD", "nbr": 0, "seatbid": [ { diff --git a/endpoints/openrtb2/sample-requests/valid-native/context-product-compatible-subtype.json b/endpoints/openrtb2/sample-requests/valid-native/context-product-compatible-subtype.json index dbf7b9c5e0d..a3b7101d8d5 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/context-product-compatible-subtype.json +++ b/endpoints/openrtb2/sample-requests/valid-native/context-product-compatible-subtype.json @@ -23,19 +23,20 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur": "USD", "nbr": 0, - "seatbid": [ - { - "bid": [ - { - "id": "appnexus-bid", - "impid": "", - "price": 0 - } - ], - "seat": "appnexus-bids" - } - ] + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 0 + } + ], + "seat": "appnexus-bids" + } + ] }, "expectedReturnCode": 200 } diff --git a/endpoints/openrtb2/sample-requests/valid-native/context-social-compatible-subtype.json b/endpoints/openrtb2/sample-requests/valid-native/context-social-compatible-subtype.json index 41fb833d770..77e8ce10a41 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/context-social-compatible-subtype.json +++ b/endpoints/openrtb2/sample-requests/valid-native/context-social-compatible-subtype.json @@ -23,19 +23,20 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur": "USD", "nbr": 0, - "seatbid": [ - { - "bid": [ - { - "id": "appnexus-bid", - "impid": "", - "price": 0 - } - ], - "seat": "appnexus-bids" - } - ] + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid", + "impid": "", + "price": 0 + } + ], + "seat": "appnexus-bids" + } + ] }, "expectedReturnCode": 200 } diff --git a/endpoints/openrtb2/sample-requests/valid-native/eventtracker-exchange-specific.json b/endpoints/openrtb2/sample-requests/valid-native/eventtracker-exchange-specific.json index 501e7ef5016..214031177ca 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/eventtracker-exchange-specific.json +++ b/endpoints/openrtb2/sample-requests/valid-native/eventtracker-exchange-specific.json @@ -22,6 +22,7 @@ "id": "req-id", "bidid": "test bid id", "nbr": 0, + "cur": "USD", "seatbid": [{ "bid": [{ "id": "appnexus-bid", @@ -32,4 +33,4 @@ }] }, "expectedReturnCode": 200 -} \ No newline at end of file +} diff --git a/endpoints/openrtb2/sample-requests/valid-native/request-no-context.json b/endpoints/openrtb2/sample-requests/valid-native/request-no-context.json index 1ad97c8ff8f..5ebc4e697e4 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/request-no-context.json +++ b/endpoints/openrtb2/sample-requests/valid-native/request-no-context.json @@ -23,6 +23,7 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur": "USD", "nbr": 0, "seatbid": [ { diff --git a/endpoints/openrtb2/sample-requests/valid-native/request-plcmttype-empty.json b/endpoints/openrtb2/sample-requests/valid-native/request-plcmttype-empty.json index 88af803684d..5518b7a06bc 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/request-plcmttype-empty.json +++ b/endpoints/openrtb2/sample-requests/valid-native/request-plcmttype-empty.json @@ -23,6 +23,7 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur": "USD", "nbr": 0, "seatbid": [ { diff --git a/endpoints/openrtb2/sample-requests/valid-native/video-asset-event-tracker.json b/endpoints/openrtb2/sample-requests/valid-native/video-asset-event-tracker.json index ab192e14881..fcc7b72d62a 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/video-asset-event-tracker.json +++ b/endpoints/openrtb2/sample-requests/valid-native/video-asset-event-tracker.json @@ -23,6 +23,7 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur": "USD", "nbr": 0, "seatbid": [ { diff --git a/endpoints/openrtb2/sample-requests/valid-native/with-video-asset.json b/endpoints/openrtb2/sample-requests/valid-native/with-video-asset.json index 0ec3c993251..f920c52a591 100644 --- a/endpoints/openrtb2/sample-requests/valid-native/with-video-asset.json +++ b/endpoints/openrtb2/sample-requests/valid-native/with-video-asset.json @@ -23,6 +23,7 @@ "expectedBidResponse": { "id": "req-id", "bidid": "test bid id", + "cur": "USD", "nbr": 0, "seatbid": [ { diff --git a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/all-ext.json b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/all-ext.json index f875fa880bc..46af51635f9 100644 --- a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/all-ext.json +++ b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/all-ext.json @@ -115,6 +115,7 @@ } ], "bidid":"test bid id", + "cur":"USD", "nbr":0 }, "expectedReturnCode": 200 diff --git a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/prebid-test-ad.json b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/prebid-test-ad.json index 2c6a34f569e..d592cb66fcb 100644 --- a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/prebid-test-ad.json +++ b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/prebid-test-ad.json @@ -44,6 +44,7 @@ } ], "bidid": "test bid id", + "cur":"USD", "nbr": 0 }, "expectedReturnCode": 200 diff --git a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/skadn.json b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/skadn.json index e238f3c07c7..cb2cec992fe 100644 --- a/endpoints/openrtb2/sample-requests/valid-whole/exemplary/skadn.json +++ b/endpoints/openrtb2/sample-requests/valid-whole/exemplary/skadn.json @@ -42,7 +42,8 @@ "seat": "appnexus-bids" }], "bidid": "test bid id", + "cur":"USD", "nbr": 0 }, "expectedReturnCode": 200 -} \ No newline at end of file +} diff --git a/errortypes/code.go b/errortypes/code.go index 2749b978006..869e7d541a4 100644 --- a/errortypes/code.go +++ b/errortypes/code.go @@ -11,6 +11,7 @@ const ( BidderTemporarilyDisabledErrorCode BlacklistedAcctErrorCode AcctRequiredErrorCode + NoConversionRateErrorCode ) // Defines numeric codes for well-known warnings. @@ -19,6 +20,7 @@ const ( InvalidPrivacyConsentWarningCode = iota + 10000 AccountLevelDebugDisabledWarningCode BidderLevelDebugDisabledWarningCode + DisabledCurrencyConversionWarningCode ) // Coder provides an error or warning code with severity. diff --git a/exchange/bidder_test.go b/exchange/bidder_test.go index 3140930d8e6..5fdfe445206 100644 --- a/exchange/bidder_test.go +++ b/exchange/bidder_test.go @@ -564,7 +564,7 @@ func TestMultiCurrencies(t *testing.T) { {currency: "USD", price: 1.3 * 1.3050530256}, }, expectedBadCurrencyErrors: []error{ - errors.New("Currency conversion rate not found: 'JPY' => 'USD'"), + currency.ConversionRateNotFound{"JPY", "USD"}, }, description: "Case 6 - Bidder respond with a mix of currencies and one unknown on all HTTP responses", }, @@ -587,9 +587,9 @@ func TestMultiCurrencies(t *testing.T) { }, expectedBids: []bid{}, expectedBadCurrencyErrors: []error{ - errors.New("Currency conversion rate not found: 'JPY' => 'USD'"), - errors.New("Currency conversion rate not found: 'BZD' => 'USD'"), - errors.New("Currency conversion rate not found: 'DKK' => 'USD'"), + currency.ConversionRateNotFound{"JPY", "USD"}, + currency.ConversionRateNotFound{"BZD", "USD"}, + currency.ConversionRateNotFound{"DKK", "USD"}, }, description: "Case 7 - Bidder respond with currencies not having any rate on all HTTP responses", }, @@ -719,9 +719,9 @@ func TestMultiCurrencies_RateConverterNotSet(t *testing.T) { bidCurrency: []string{"EUR", "EUR", "EUR"}, expectedBidsCount: 0, expectedBadCurrencyErrors: []error{ - fmt.Errorf("Constant rates doesn't proceed to any conversions, cannot convert 'EUR' => 'USD'"), - fmt.Errorf("Constant rates doesn't proceed to any conversions, cannot convert 'EUR' => 'USD'"), - fmt.Errorf("Constant rates doesn't proceed to any conversions, cannot convert 'EUR' => 'USD'"), + currency.ConversionRateNotFound{"EUR", "USD"}, + currency.ConversionRateNotFound{"EUR", "USD"}, + currency.ConversionRateNotFound{"EUR", "USD"}, }, description: "Case 2 - Bidder respond with the same currency (not default one) on all HTTP responses", }, @@ -753,7 +753,7 @@ func TestMultiCurrencies_RateConverterNotSet(t *testing.T) { bidCurrency: []string{"EUR", "", "USD"}, expectedBidsCount: 2, expectedBadCurrencyErrors: []error{ - fmt.Errorf("Constant rates doesn't proceed to any conversions, cannot convert 'EUR' => 'USD'"), + currency.ConversionRateNotFound{"EUR", "USD"}, }, description: "Case 7 - Bidder responds with a mix of not set, non default currency and default currency in HTTP responses", }, @@ -761,7 +761,7 @@ func TestMultiCurrencies_RateConverterNotSet(t *testing.T) { bidCurrency: []string{"GBP", "", "USD"}, expectedBidsCount: 2, expectedBadCurrencyErrors: []error{ - fmt.Errorf("Constant rates doesn't proceed to any conversions, cannot convert 'GBP' => 'USD'"), + currency.ConversionRateNotFound{"GBP", "USD"}, }, description: "Case 8 - Bidder responds with a mix of not set, non default currency and default currency in HTTP responses", }, @@ -769,7 +769,7 @@ func TestMultiCurrencies_RateConverterNotSet(t *testing.T) { bidCurrency: []string{"GBP", "", ""}, expectedBidsCount: 2, expectedBadCurrencyErrors: []error{ - fmt.Errorf("Constant rates doesn't proceed to any conversions, cannot convert 'GBP' => 'USD'"), + currency.ConversionRateNotFound{"GBP", "USD"}, }, description: "Case 9 - Bidder responds with a mix of not set and empty currencies (default currency) in HTTP responses", }, diff --git a/exchange/exchange.go b/exchange/exchange.go index 0d3d93cf0a7..f9d33ac1982 100644 --- a/exchange/exchange.go +++ b/exchange/exchange.go @@ -192,7 +192,7 @@ func (e *exchange) HoldAuction(ctx context.Context, r AuctionRequest, debugLog * defer cancel() // Get currency rates conversions for the auction - conversions := e.currencyConverter.Rates() + conversions := e.getAuctionCurrencyRates(requestExt.Prebid.CurrencyConversions) adapterBids, adapterExtra, anyBidsReturned := e.getAllBids(auctionCtx, bidderRequests, bidAdjustmentFactors, conversions, r.Account.DebugAllow, r.GlobalPrivacyControlHeader) @@ -939,6 +939,34 @@ func (e *exchange) getBidCacheInfo(bid *pbsOrtbBid, auction *auction) (cacheInfo return } +func (e *exchange) getAuctionCurrencyRates(requestRates *openrtb_ext.ExtRequestCurrency) currency.Conversions { + if requestRates == nil { + // No bidRequest.ext.currency field was found, use PBS rates as usual + return e.currencyConverter.Rates() + } + + // If bidRequest.ext.currency.usepbsrates is nil, we understand its value as true. It will be false + // only if it's explicitly set to false + usePbsRates := requestRates.UsePBSRates == nil || *requestRates.UsePBSRates + + if !usePbsRates { + // At this point, we can safely assume the ConversionRates map is not empty because + // validateCustomRates(bidReqCurrencyRates *openrtb_ext.ExtRequestCurrency) would have + // thrown an error under such conditions. + return currency.NewRates(time.Time{}, requestRates.ConversionRates) + } + + // Both PBS and custom rates can be used, check if ConversionRates is not empty + if len(requestRates.ConversionRates) == 0 { + // Custom rates map is empty, use PBS rates only + return e.currencyConverter.Rates() + } + + // Return an AggregateConversions object that includes both custom and PBS currency rates but will + // prioritize custom rates over PBS rates whenever a currency rate is found in both + return currency.NewAggregateConversions(currency.NewRates(time.Time{}, requestRates.ConversionRates), e.currencyConverter.Rates()) +} + func findCacheID(bid *pbsOrtbBid, auction *auction) (string, bool) { if bid != nil && bid.bid != nil && auction != nil { if id, found := auction.cacheIds[bid.bid]; found { diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go index c64b3935513..a7fd7c390c8 100644 --- a/exchange/exchange_test.go +++ b/exchange/exchange_test.go @@ -86,8 +86,8 @@ func TestNewExchange(t *testing.T) { // 4) Build a BidResponse struct using exchange.buildBidResponse(ctx.Background(), liveA... ) // 5) Assert we have no '&' characters in the response that exchange.buildBidResponse returns func TestCharacterEscape(t *testing.T) { - /* 1) Adapter with a '& char in its endpoint property */ - /* https://github.com/prebid/prebid-server/issues/465 */ + // 1) Adapter with a '& char in its endpoint property + // https://github.com/prebid/prebid-server/issues/465 cfg := &config.Configuration{ Adapters: make(map[string]config.Adapter, 1), } @@ -95,7 +95,7 @@ func TestCharacterEscape(t *testing.T) { Endpoint: "http://ib.adnxs.com/openrtb2?query1&query2", //Note the '&' character in there } - /* 2) Init new exchange with said configuration */ + // 2) Init new exchange with said configuration //Other parameters also needed to create exchange handlerNoBidServer := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } server := httptest.NewServer(http.HandlerFunc(handlerNoBidServer)) @@ -114,7 +114,7 @@ func TestCharacterEscape(t *testing.T) { currencyConverter := currency.NewRateConverter(&http.Client{}, "", time.Duration(0)) e := NewExchange(adapters, nil, cfg, &metricsConf.DummyMetricsEngine{}, biddersInfo, gdpr.AlwaysAllow{}, currencyConverter, nilCategoryFetcher{}).(*exchange) - /* 3) Build all the parameters e.buildBidResponse(ctx.Background(), liveA... ) needs */ + // 3) Build all the parameters e.buildBidResponse(ctx.Background(), liveA... ) needs //liveAdapters []openrtb_ext.BidderName, liveAdapters := make([]openrtb_ext.BidderName, 1) liveAdapters[0] = "appnexus" @@ -147,10 +147,10 @@ func TestCharacterEscape(t *testing.T) { var errList []error - /* 4) Build bid response */ + // 4) Build bid response bidResp, err := e.buildBidResponse(context.Background(), liveAdapters, adapterBids, bidRequest, adapterExtra, nil, nil, true, errList) - /* 5) Assert we have no errors and one '&' character as we are supposed to */ + // 5) Assert we have no errors and one '&' character as we are supposed to if err != nil { t.Errorf("exchange.buildBidResponse returned unexpected error: %v", err) } @@ -507,6 +507,366 @@ func TestTwoBiddersDebugDisabledAndEnabled(t *testing.T) { } +func TestOverrideWithCustomCurrency(t *testing.T) { + + mockCurrencyClient := &mockCurrencyRatesClient{ + responseBody: `{"dataAsOf":"2018-09-12","conversions":{"USD":{"MXN":10.00}}}`, + } + mockCurrencyConverter := currency.NewRateConverter( + mockCurrencyClient, + "currency.fake.com", + 24*time.Hour, + ) + + type testIn struct { + customCurrencyRates json.RawMessage + bidRequestCurrency string + } + type testResults struct { + numBids int + bidRespPrice float64 + bidRespCurrency string + } + + testCases := []struct { + desc string + in testIn + expected testResults + }{ + { + desc: "Blank currency field in ext. bidRequest comes with a valid currency but conversion rate was not found in PBS. Return no bids", + in: testIn{ + customCurrencyRates: json.RawMessage(`{ "prebid": { "currency": {} } } `), + bidRequestCurrency: "GBP", + }, + expected: testResults{}, + }, + { + desc: "valid request.ext.prebid.currency, expect custom rates to override those of the currency rate server", + in: testIn{ + customCurrencyRates: json.RawMessage(`{ + "prebid": { + "currency": { + "rates": { + "USD": { + "MXN": 20.00, + "EUR": 10.95 + } + } + } + } + }`), + bidRequestCurrency: "MXN", + }, + expected: testResults{ + numBids: 1, + bidRespPrice: 20.00, + bidRespCurrency: "MXN", + }, + }, + } + + // Init mock currency conversion service + mockCurrencyConverter.Run() + + // Init an exchange to run an auction from + noBidServer := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } + mockAppnexusBidService := httptest.NewServer(http.HandlerFunc(noBidServer)) + defer mockAppnexusBidService.Close() + + categoriesFetcher, error := newCategoryFetcher("./test/category-mapping") + if error != nil { + t.Errorf("Failed to create a category Fetcher: %v", error) + } + + oneDollarBidBidder := &goodSingleBidder{ + httpRequest: &adapters.RequestData{ + Method: "POST", + Uri: mockAppnexusBidService.URL, + Body: []byte("{\"key\":\"val\"}"), + Headers: http.Header{}, + }, + } + + e := new(exchange) + e.cache = &wellBehavedCache{} + e.me = &metricsConf.DummyMetricsEngine{} + e.gDPR = gdpr.AlwaysAllow{} + e.currencyConverter = mockCurrencyConverter + e.categoriesFetcher = categoriesFetcher + e.bidIDGenerator = &mockBidIDGenerator{false, false} + + // Define mock incoming bid requeset + mockBidRequest := &openrtb2.BidRequest{ + ID: "some-request-id", + Imp: []openrtb2.Imp{{ + ID: "some-impression-id", + Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}, {W: 300, H: 600}}}, + Ext: json.RawMessage(`{"appnexus": {"placementId": 1}}`), + }}, + Site: &openrtb2.Site{Page: "prebid.org", Ext: json.RawMessage(`{"amp":0}`)}, + } + + // Run tests + for _, test := range testCases { + + oneDollarBidBidder.bidResponse = &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{Price: 1.00}, + }, + }, + Currency: "USD", + } + + e.adapterMap = map[openrtb_ext.BidderName]adaptedBidder{ + openrtb_ext.BidderAppnexus: adaptBidder(oneDollarBidBidder, mockAppnexusBidService.Client(), &config.Configuration{}, &metricsConfig.DummyMetricsEngine{}, openrtb_ext.BidderAppnexus, nil), + } + + // Set custom rates in extension + mockBidRequest.Ext = test.in.customCurrencyRates + + // Set bidRequest currency list + mockBidRequest.Cur = []string{test.in.bidRequestCurrency} + + auctionRequest := AuctionRequest{ + BidRequest: mockBidRequest, + Account: config.Account{}, + UserSyncs: &emptyUsersync{}, + } + + // Run test + outBidResponse, err := e.HoldAuction(context.Background(), auctionRequest, &DebugLog{}) + + // Assertions + assert.NoErrorf(t, err, "%s. HoldAuction error: %v \n", test.desc, err) + + if test.expected.numBids > 0 { + // Assert out currency + assert.Equal(t, test.expected.bidRespCurrency, outBidResponse.Cur, "Bid response currency is wrong: %s \n", test.desc) + + // Assert returned bid + if !assert.NotNil(t, outBidResponse, "outBidResponse is nil: %s \n", test.desc) { + return + } + if !assert.NotEmpty(t, outBidResponse.SeatBid, "outBidResponse.SeatBid is empty: %s", test.desc) { + return + } + if !assert.NotEmpty(t, outBidResponse.SeatBid[0].Bid, "outBidResponse.SeatBid[0].Bid is empty: %s", test.desc) { + return + } + + // Assert returned bid price matches the currency conversion + assert.Equal(t, test.expected.bidRespPrice, outBidResponse.SeatBid[0].Bid[0].Price, "Bid response seatBid price is wrong: %s", test.desc) + } else { + assert.Len(t, outBidResponse.SeatBid, 0, "outBidResponse.SeatBid should be empty: %s", test.desc) + } + } +} + +func TestGetAuctionCurrencyRates(t *testing.T) { + + pbsRates := map[string]map[string]float64{ + "MXN": { + "USD": 20.13, + "EUR": 27.82, + "JPY": 5.09, // "MXN" to "JPY" rate not found in customRates + }, + } + + customRates := map[string]map[string]float64{ + "MXN": { + "USD": 25.00, // different rate than in pbsRates + "EUR": 27.82, // same as in pbsRates + "GBP": 31.12, // not found in pbsRates at all + }, + } + + expectedRateEngineRates := map[string]map[string]float64{ + "MXN": { + "USD": 25.00, // rates engine will prioritize the value found in custom rates + "EUR": 27.82, // same value in both the engine reads the custom entry first + "JPY": 5.09, // the engine will find it in the pbsRates conversions + "GBP": 31.12, // the engine will find it in the custom conversions + }, + } + + boolTrue := true + boolFalse := false + + type testInput struct { + pbsRates map[string]map[string]float64 + bidExtCurrency *openrtb_ext.ExtRequestCurrency + } + type testOutput struct { + constantRates bool + resultingRates map[string]map[string]float64 + } + testCases := []struct { + desc string + given testInput + expected testOutput + }{ + { + "valid pbsRates, valid ConversionRates, false UsePBSRates. Resulting rates identical to customRates", + testInput{ + pbsRates: pbsRates, + bidExtCurrency: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: customRates, + UsePBSRates: &boolFalse, + }, + }, + testOutput{ + resultingRates: customRates, + }, + }, + { + "valid pbsRates, valid ConversionRates, true UsePBSRates. Resulting rates are a mix but customRates gets priority", + testInput{ + pbsRates: pbsRates, + bidExtCurrency: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: customRates, + UsePBSRates: &boolTrue, + }, + }, + testOutput{ + resultingRates: expectedRateEngineRates, + }, + }, + { + "nil pbsRates, valid ConversionRates, false UsePBSRates. Resulting rates identical to customRates", + testInput{ + pbsRates: nil, + bidExtCurrency: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: customRates, + UsePBSRates: &boolFalse, + }, + }, + testOutput{ + resultingRates: customRates, + }, + }, + { + "nil pbsRates, valid ConversionRates, true UsePBSRates. Resulting rates identical to customRates", + testInput{ + pbsRates: nil, + bidExtCurrency: &openrtb_ext.ExtRequestCurrency{ + ConversionRates: customRates, + UsePBSRates: &boolTrue, + }, + }, + testOutput{ + resultingRates: customRates, + }, + }, + { + "valid pbsRates, empty ConversionRates, false UsePBSRates. Because pbsRates cannot be used, default to constant rates", + testInput{ + pbsRates: pbsRates, + bidExtCurrency: &openrtb_ext.ExtRequestCurrency{ + // ConversionRates inCustomRates not initialized makes for a zero-length map + UsePBSRates: &boolFalse, + }, + }, + testOutput{ + constantRates: true, + }, + }, + { + "valid pbsRates, nil ConversionRates, UsePBSRates defaults to true. Resulting rates will be identical to pbsRates", + testInput{ + pbsRates: pbsRates, + bidExtCurrency: nil, + }, + testOutput{ + resultingRates: pbsRates, + }, + }, + { + "nil pbsRates, empty ConversionRates, false UsePBSRates. Default to constant rates", + testInput{ + pbsRates: nil, + bidExtCurrency: &openrtb_ext.ExtRequestCurrency{ + // ConversionRates inCustomRates not initialized makes for a zero-length map + UsePBSRates: &boolFalse, + }, + }, + testOutput{ + constantRates: true, + }, + }, + { + "customRates empty, UsePBSRates set to true, pbsRates are nil. Return default constant rates converter", + testInput{ + pbsRates: nil, + bidExtCurrency: &openrtb_ext.ExtRequestCurrency{ + // ConversionRates inCustomRates not initialized makes for a zero-length map + UsePBSRates: &boolTrue, + }, + }, + testOutput{ + constantRates: true, + }, + }, + { + "nil customRates, nil pbsRates, UsePBSRates defaults to true. Return default constant rates converter", + testInput{ + pbsRates: nil, + bidExtCurrency: nil, + }, + testOutput{ + constantRates: true, + }, + }, + } + + for _, tc := range testCases { + + // Test setup: + jsonPbsRates, err := json.Marshal(tc.given.pbsRates) + if err != nil { + t.Fatalf("Failed to marshal PBS rates: %v", err) + } + + // Init mock currency conversion service + mockCurrencyClient := &mockCurrencyRatesClient{ + responseBody: `{"dataAsOf":"2018-09-12","conversions":` + string(jsonPbsRates) + `}`, + } + mockCurrencyConverter := currency.NewRateConverter( + mockCurrencyClient, + "currency.fake.com", + 24*time.Hour, + ) + mockCurrencyConverter.Run() + + e := new(exchange) + e.currencyConverter = mockCurrencyConverter + + // Run test + auctionRates := e.getAuctionCurrencyRates(tc.given.bidExtCurrency) + + // When fromCurrency and toCurrency are the same, a rate of 1.00 is always expected + rate, err := auctionRates.GetRate("USD", "USD") + assert.NoError(t, err, tc.desc) + assert.Equal(t, float64(1), rate, tc.desc) + + // If we expect an error, assert we have one along with a conversion rate of zero + if tc.expected.constantRates { + rate, err := auctionRates.GetRate("USD", "MXN") + assert.Error(t, err, tc.desc) + assert.Equal(t, float64(0), rate, tc.desc) + } else { + for fromCurrency, rates := range tc.expected.resultingRates { + for toCurrency, expectedRate := range rates { + actualRate, err := auctionRates.GetRate(fromCurrency, toCurrency) + assert.NoError(t, err, tc.desc) + assert.Equal(t, expectedRate, actualRate, tc.desc) + } + } + } + } +} + func TestReturnCreativeEndToEnd(t *testing.T) { sampleAd := "" @@ -709,7 +1069,7 @@ func TestGetBidCacheInfoEndToEnd(t *testing.T) { testExternalCacheHost := "www.externalprebidcache.net" testExternalCachePath := "endpoints/cache" - /* 1) An adapter */ + // 1) An adapter bidderName := openrtb_ext.BidderName("appnexus") cfg := &config.Configuration{ @@ -730,7 +1090,7 @@ func TestGetBidCacheInfoEndToEnd(t *testing.T) { adapterList := make([]openrtb_ext.BidderName, 0, 2) testEngine := metricsConf.NewMetricsEngine(cfg, adapterList) - /* 2) Init new exchange with said configuration */ + // 2) Init new exchange with said configuration handlerNoBidServer := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } server := httptest.NewServer(http.HandlerFunc(handlerNoBidServer)) defer server.Close() @@ -747,7 +1107,7 @@ func TestGetBidCacheInfoEndToEnd(t *testing.T) { currencyConverter := currency.NewRateConverter(&http.Client{}, "", time.Duration(0)) pbc := pbc.NewClient(&http.Client{}, &cfg.CacheURL, &cfg.ExtCacheURL, testEngine) e := NewExchange(adapters, pbc, cfg, &metricsConf.DummyMetricsEngine{}, biddersInfo, gdpr.AlwaysAllow{}, currencyConverter, nilCategoryFetcher{}).(*exchange) - /* 3) Build all the parameters e.buildBidResponse(ctx.Background(), liveA... ) needs */ + // 3) Build all the parameters e.buildBidResponse(ctx.Background(), liveA... ) needs liveAdapters := []openrtb_ext.BidderName{bidderName} //adapterBids map[openrtb_ext.BidderName]*pbsOrtbSeatBid, @@ -849,10 +1209,10 @@ func TestGetBidCacheInfoEndToEnd(t *testing.T) { var errList []error - /* 4) Build bid response */ + // 4) Build bid response bid_resp, err := e.buildBidResponse(context.Background(), liveAdapters, adapterBids, bidRequest, adapterExtra, auc, nil, true, errList) - /* 5) Assert we have no errors and the bid response we expected*/ + // 5) Assert we have no errors and the bid response we expected assert.NoError(t, err, "[TestGetBidCacheInfo] buildBidResponse() threw an error") expectedBidResponse := &openrtb2.BidResponse{ @@ -3082,3 +3442,16 @@ type nilCategoryFetcher struct{} func (nilCategoryFetcher) FetchCategories(ctx context.Context, primaryAdServer, publisherId, iabCategory string) (string, error) { return "", nil } + +// mockCurrencyRatesClient is a simple http client mock returning a constant response body +type mockCurrencyRatesClient struct { + responseBody string +} + +func (m *mockCurrencyRatesClient) Do(req *http.Request) (*http.Response, error) { + return &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(m.responseBody)), + }, nil +} diff --git a/go.mod b/go.mod index e3b9ff556d5..6d1fe334390 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,6 @@ require ( github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect github.com/yudai/pp v2.0.1+incompatible // indirect golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb - golang.org/x/text v0.3.3 + golang.org/x/text v0.3.6 gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index 215f5e68e28..78b21ae139c 100644 --- a/go.sum +++ b/go.sum @@ -169,8 +169,9 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210112080510-489259a85091 h1:DMyOG0U+gKfu8JZzg2UQe9MeaC1X+xQWlAKcRnjxjCw= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= diff --git a/openrtb_ext/request.go b/openrtb_ext/request.go index f14cf196366..606874f196a 100644 --- a/openrtb_ext/request.go +++ b/openrtb_ext/request.go @@ -41,6 +41,13 @@ type ExtRequestPrebid struct { // passing of personally identifiable information doesn't constitute a sale per CCPA law. // The array may contain a single sstar ('*') entry to represent all bidders. NoSale []string `json:"nosale,omitempty"` + + CurrencyConversions *ExtRequestCurrency `json:"currency,omitempty"` +} + +type ExtRequestCurrency struct { + ConversionRates map[string]map[string]float64 `json:"rates"` + UsePBSRates *bool `json:"usepbsrates"` } // ExtRequestPrebid defines the contract for bidrequest.ext.prebid.schains