github.com/prebid/prebid-server/v2@v2.18.0/adapters/adapterstest/test_json.go (about) 1 package adapterstest 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "io/fs" 7 "net/http" 8 "os" 9 "path/filepath" 10 "regexp" 11 "testing" 12 13 "github.com/google/go-cmp/cmp" 14 "github.com/google/go-cmp/cmp/cmpopts" 15 "github.com/mitchellh/copystructure" 16 "github.com/prebid/openrtb/v20/openrtb2" 17 "github.com/prebid/prebid-server/v2/adapters" 18 "github.com/prebid/prebid-server/v2/currency" 19 "github.com/prebid/prebid-server/v2/openrtb_ext" 20 "github.com/stretchr/testify/assert" 21 "github.com/yudai/gojsondiff" 22 "github.com/yudai/gojsondiff/formatter" 23 ) 24 25 const jsonFileExtension string = ".json" 26 27 var supportedDirs = map[string]struct{}{ 28 "exemplary": {}, 29 "supplemental": {}, 30 "amp": {}, 31 "video": {}, 32 "videosupplemental": {}, 33 } 34 35 // RunJSONBidderTest is a helper method intended to unit test Bidders' adapters. 36 // It requires that: 37 // 38 // 1. Bidders communicate with external servers over HTTP. 39 // 2. The HTTP request bodies are legal JSON. 40 // 41 // Although the project does not require it, we _strongly_ recommend that all Bidders write tests using this. 42 // Doing so has the following benefits: 43 // 44 // 1. This includes some basic tests which confirm that your Bidder is "well-behaved" for all the input samples. 45 // For example, "no nil bids are allowed in the returned array". 46 // These tests are tedious to write, but help prevent bugs during auctions. 47 // 48 // 2. In the future, we plan to auto-generate documentation from the "exemplary" test files. 49 // Those docs will teach publishers how to use your Bidder, which should encourage adoption. 50 // 51 // To use this method, create *.json files in the following directories: 52 // 53 // adapters/{bidder}/{bidder}test/exemplary: 54 // 55 // These show "ideal" BidRequests for your Bidder. If possible, configure your servers to return the same 56 // expected responses forever. If your server responds appropriately, our future auto-generated documentation 57 // can guarantee Publishers that your adapter works as documented. 58 // 59 // adapters/{bidder}/{bidder}test/supplemental: 60 // 61 // Fill this with *.json files which are useful test cases, but are not appropriate for public example docs. 62 // For example, a file in this directory might make sure that a mobile-only Bidder returns errors on non-mobile requests. 63 // 64 // Then create a test in your adapters/{bidder}/{bidder}_test.go file like so: 65 // 66 // func TestJsonSamples(t *testing.T) { 67 // adapterstest.RunJSONBidderTest(t, "{bidder}test", instanceOfYourBidder) 68 // } 69 func RunJSONBidderTest(t *testing.T, rootDir string, bidder adapters.Bidder) { 70 err := filepath.WalkDir(rootDir, func(path string, info fs.DirEntry, err error) error { 71 if err != nil { 72 return err 73 } 74 75 isJsonFile := !info.IsDir() && filepath.Ext(info.Name()) == jsonFileExtension 76 RunSingleJSONBidderTest(t, bidder, path, isJsonFile) 77 return nil 78 }) 79 assert.NoError(t, err, "Error reading files from directory %s \n", rootDir) 80 } 81 82 func RunSingleJSONBidderTest(t *testing.T, bidder adapters.Bidder, path string, isJsonFile bool) { 83 base := filepath.Base(filepath.Dir(path)) 84 if _, ok := supportedDirs[base]; !ok { 85 return 86 } 87 88 allowErrors := base != "exemplary" && base != "video" 89 if isJsonFile { 90 specData, err := loadFile(path) 91 if err != nil { 92 t.Fatalf("Failed to load contents of file %s: %v", path, err) 93 } 94 95 if !allowErrors && specData.expectsErrors() { 96 t.Fatalf("Exemplary spec %s must not expect errors.", path) 97 } 98 99 runSpec(t, path, specData, bidder, base == "amp", base == "videosupplemental" || base == "video") 100 } 101 } 102 103 // LoadFile reads and parses a file as a test case. If something goes wrong, it returns an error. 104 func loadFile(filename string) (*testSpec, error) { 105 specData, err := os.ReadFile(filename) 106 if err != nil { 107 return nil, fmt.Errorf("Failed to read file %s: %v", filename, err) 108 } 109 110 var spec testSpec 111 if err := json.Unmarshal(specData, &spec); err != nil { 112 return nil, fmt.Errorf("Failed to unmarshal JSON from file: %v", err) 113 } 114 115 return &spec, nil 116 } 117 118 // runSpec runs a single test case. It will make sure: 119 // 120 // - That the Bidder does not return nil HTTP requests, bids, or errors inside their lists 121 // - That the Bidder's HTTP calls match the spec's expectations. 122 // - That the Bidder's Bids match the spec's expectations 123 // - That the Bidder's errors match the spec's expectations 124 // 125 // More assertions will almost certainly be added in the future, as bugs come up. 126 func runSpec(t *testing.T, filename string, spec *testSpec, bidder adapters.Bidder, isAmpTest, isVideoTest bool) { 127 reqInfo := getTestExtraRequestInfo(t, filename, spec, isAmpTest, isVideoTest) 128 requests := testMakeRequestsImpl(t, filename, spec, bidder, reqInfo) 129 130 testMakeBidsImpl(t, filename, spec, bidder, requests) 131 } 132 133 // getTestExtraRequestInfo builds the ExtraRequestInfo object that will be passed to testMakeRequestsImpl 134 func getTestExtraRequestInfo(t *testing.T, filename string, spec *testSpec, isAmpTest, isVideoTest bool) *adapters.ExtraRequestInfo { 135 t.Helper() 136 137 var reqInfo adapters.ExtraRequestInfo 138 139 // If test request.ext defines its own currency rates, add currency conversion to reqInfo 140 reqWrapper := &openrtb_ext.RequestWrapper{} 141 reqWrapper.BidRequest = &spec.BidRequest 142 143 reqExt, err := reqWrapper.GetRequestExt() 144 assert.NoError(t, err, "Could not unmarshall test request ext. %s", filename) 145 146 reqPrebid := reqExt.GetPrebid() 147 if reqPrebid != nil && reqPrebid.CurrencyConversions != nil && len(reqPrebid.CurrencyConversions.ConversionRates) > 0 { 148 err = currency.ValidateCustomRates(reqPrebid.CurrencyConversions) 149 assert.NoError(t, err, "Error validating currency rates in the test request: %s", filename) 150 151 // Get currency rates conversions from the test request.ext 152 conversions := currency.NewRates(reqPrebid.CurrencyConversions.ConversionRates) 153 154 // Create return adapters.ExtraRequestInfo object 155 reqInfo = adapters.NewExtraRequestInfo(conversions) 156 } else { 157 reqInfo = adapters.ExtraRequestInfo{} 158 } 159 160 // Set PbsEntryPoint if either isAmpTest or isVideoTest is true 161 if isAmpTest { 162 // simulates AMP entry point 163 reqInfo.PbsEntryPoint = "amp" 164 } else if isVideoTest { 165 reqInfo.PbsEntryPoint = "video" 166 } 167 168 return &reqInfo 169 } 170 171 type testSpec struct { 172 BidRequest openrtb2.BidRequest `json:"mockBidRequest"` 173 HttpCalls []httpCall `json:"httpCalls"` 174 BidResponses []expectedBidResponse `json:"expectedBidResponses"` 175 MakeRequestErrors []testSpecExpectedError `json:"expectedMakeRequestsErrors"` 176 MakeBidsErrors []testSpecExpectedError `json:"expectedMakeBidsErrors"` 177 } 178 179 type testSpecExpectedError struct { 180 Value string `json:"value"` 181 Comparison string `json:"comparison"` 182 } 183 184 func (spec *testSpec) expectsErrors() bool { 185 return len(spec.MakeRequestErrors) > 0 || len(spec.MakeBidsErrors) > 0 186 } 187 188 type httpCall struct { 189 Request httpRequest `json:"expectedRequest"` 190 Response httpResponse `json:"mockResponse"` 191 } 192 193 func (req *httpRequest) ToRequestData(t *testing.T) *adapters.RequestData { 194 return &adapters.RequestData{ 195 Method: "POST", 196 Uri: req.Uri, 197 Body: req.Body, 198 } 199 } 200 201 type httpRequest struct { 202 Body json.RawMessage `json:"body"` 203 Uri string `json:"uri"` 204 Headers http.Header `json:"headers"` 205 ImpIDs []string `json:"impIDs"` 206 } 207 208 type httpResponse struct { 209 Status int `json:"status"` 210 Body json.RawMessage `json:"body"` 211 Headers http.Header `json:"headers"` 212 } 213 214 func (resp *httpResponse) ToResponseData(t *testing.T) *adapters.ResponseData { 215 return &adapters.ResponseData{ 216 StatusCode: resp.Status, 217 Body: resp.Body, 218 Headers: resp.Headers, 219 } 220 } 221 222 type expectedBidResponse struct { 223 Bids []expectedBid `json:"bids"` 224 Currency string `json:"currency"` 225 FledgeAuctionConfigs json.RawMessage `json:"fledgeauctionconfigs,omitempty"` 226 } 227 228 type expectedBid struct { 229 Bid json.RawMessage `json:"bid"` 230 Type string `json:"type"` 231 Seat string `json:"seat"` 232 } 233 234 // --------------------------------------- 235 // Lots of ugly, repetitive code below here. 236 // 237 // reflect.DeepEquals doesn't work because each OpenRTB field has an `ext []byte`, but we really care if those are JSON-equal 238 // 239 // Marshalling the structs and then using a JSON-diff library isn't great either, since 240 241 // assertMakeRequestsOutput compares the actual http requests to the expected ones. 242 func assertMakeRequestsOutput(t *testing.T, filename string, actual []*adapters.RequestData, expected []httpCall) { 243 t.Helper() 244 245 if len(expected) != len(actual) { 246 t.Fatalf("%s: MakeRequests had wrong request count. Expected %d, got %d", filename, len(expected), len(actual)) 247 } 248 249 for i := 0; i < len(expected); i++ { 250 var err error 251 for j := 0; j < len(actual); j++ { 252 if err = diffHttpRequests(fmt.Sprintf("%s: httpRequest[%d]", filename, i), actual[j], &(expected[i].Request)); err == nil { 253 break 254 } 255 } 256 assert.NoError(t, err, fmt.Sprintf("%s Expected RequestData was not returned by adapters' MakeRequests() implementation: httpRequest[%d]", filename, i)) 257 } 258 } 259 260 func assertErrorList(t *testing.T, description string, actual []error, expected []testSpecExpectedError) { 261 t.Helper() 262 263 if len(expected) != len(actual) { 264 t.Fatalf("%s had wrong error count. Expected %d, got %d (%v)", description, len(expected), len(actual), actual) 265 } 266 for i := 0; i < len(actual); i++ { 267 if expected[i].Comparison == "literal" { 268 if expected[i].Value != actual[i].Error() { 269 t.Errorf(`%s error[%d] had wrong message. Expected "%s", got "%s"`, description, i, expected[i].Value, actual[i].Error()) 270 } 271 } else if expected[i].Comparison == "regex" { 272 if matched, _ := regexp.MatchString(expected[i].Value, actual[i].Error()); !matched { 273 t.Errorf(`%s error[%d] had wrong message. Expected match with regex "%s", got "%s"`, description, i, expected[i].Value, actual[i].Error()) 274 } 275 } else { 276 t.Fatalf(`invalid comparison type "%s"`, expected[i].Comparison) 277 } 278 } 279 } 280 281 func assertMakeBidsOutput(t *testing.T, filename string, bidderResponse *adapters.BidderResponse, expected expectedBidResponse) { 282 t.Helper() 283 if !assert.Len(t, bidderResponse.Bids, len(expected.Bids), "%s: Wrong MakeBids bidderResponse.Bids count. len(bidderResponse.Bids) = %d vs len(spec.BidResponses.Bids) = %d", filename, len(bidderResponse.Bids), len(expected.Bids)) { 284 return 285 } 286 for i := 0; i < len(bidderResponse.Bids); i++ { 287 diffBids(t, fmt.Sprintf("%s: typedBid[%d]", filename, i), bidderResponse.Bids[i], &(expected.Bids[i])) 288 } 289 if expected.FledgeAuctionConfigs != nil { 290 assert.NotNilf(t, bidderResponse.FledgeAuctionConfigs, "%s: expected fledgeauctionconfigs in bidderResponse", filename) 291 fledgeAuctionConfigsJson, err := json.Marshal(bidderResponse.FledgeAuctionConfigs) 292 assert.NoErrorf(t, err, "%s: failed to marshal actual FledgeAuctionConfig response into JSON.", filename) 293 assert.JSONEqf(t, string(expected.FledgeAuctionConfigs), string(fledgeAuctionConfigsJson), "%s: incorrect fledgeauctionconfig", filename) 294 } else { 295 assert.Nilf(t, bidderResponse.FledgeAuctionConfigs, "%s: unexpected fledgeauctionconfigs in bidderResponse", filename) 296 } 297 } 298 299 // diffHttpRequests compares the actual HTTP request data to the expected one. 300 // It assumes that the request bodies are JSON 301 func diffHttpRequests(description string, actual *adapters.RequestData, expected *httpRequest) error { 302 303 if actual == nil { 304 return fmt.Errorf("Bidders cannot return nil HTTP calls. %s was nil.", description) 305 } 306 307 if expected.Uri != actual.Uri { 308 return fmt.Errorf(`%s.uri "%s" does not match expected "%s."`, description, actual.Uri, expected.Uri) 309 } 310 311 if expected.Headers != nil { 312 actualHeader, err := json.Marshal(actual.Headers) 313 if err != nil { 314 return fmt.Errorf(`%s actual.Headers could not be marshalled. Error: %s"`, description, err.Error()) 315 } 316 expectedHeader, err := json.Marshal(expected.Headers) 317 if err != nil { 318 return fmt.Errorf(`%s expected.Headers could not be marshalled. Error: %s"`, description, err.Error()) 319 } 320 if err := diffJson(description, actualHeader, expectedHeader); err != nil { 321 return err 322 } 323 } 324 325 if len(expected.ImpIDs) < 1 { 326 return fmt.Errorf(`expected.ImpIDs must contain at least one imp ID`) 327 } 328 329 opt := cmpopts.SortSlices(func(a, b string) bool { return a < b }) 330 if !cmp.Equal(expected.ImpIDs, actual.ImpIDs, opt) { 331 return fmt.Errorf(`%s actual.ImpIDs "%q" do not match expected "%q"`, description, actual.ImpIDs, expected.ImpIDs) 332 } 333 return diffJson(description, actual.Body, expected.Body) 334 } 335 336 func diffBids(t *testing.T, description string, actual *adapters.TypedBid, expected *expectedBid) { 337 if actual == nil { 338 t.Errorf("Bidders cannot return nil TypedBids. %s was nil.", description) 339 return 340 } 341 342 assert.Equal(t, string(expected.Seat), string(actual.Seat), fmt.Sprintf(`%s.seat "%s" does not match expected "%s."`, description, string(actual.Seat), string(expected.Seat))) 343 assert.Equal(t, string(expected.Type), string(actual.BidType), fmt.Sprintf(`%s.type "%s" does not match expected "%s."`, description, string(actual.BidType), string(expected.Type))) 344 assert.NoError(t, diffOrtbBids(fmt.Sprintf("%s.bid", description), actual.Bid, expected.Bid)) 345 } 346 347 // diffOrtbBids compares the actual Bid made by the adapter to the expectation from the JSON file. 348 func diffOrtbBids(description string, actual *openrtb2.Bid, expected json.RawMessage) error { 349 if actual == nil { 350 return fmt.Errorf("Bidders cannot return nil Bids. %s was nil.", description) 351 } 352 353 actualJson, err := json.Marshal(actual) 354 if err != nil { 355 return fmt.Errorf("%s failed to marshal actual Bid into JSON. %v", description, err) 356 } 357 358 return diffJson(description, actualJson, expected) 359 } 360 361 // diffJson compares two JSON byte arrays for structural equality. It will produce an error if either 362 // byte array is not actually JSON. 363 func diffJson(description string, actual []byte, expected []byte) error { 364 if len(actual) == 0 && len(expected) == 0 { 365 return nil 366 } 367 if len(actual) == 0 || len(expected) == 0 { 368 return fmt.Errorf("%s json diff failed. Expected %d bytes in body, but got %d.", description, len(expected), len(actual)) 369 } 370 diff, err := gojsondiff.New().Compare(actual, expected) 371 if err != nil { 372 return fmt.Errorf("%s json diff failed. %v", description, err) 373 } 374 375 if diff.Modified() { 376 var left interface{} 377 if err := json.Unmarshal(actual, &left); err != nil { 378 return fmt.Errorf("%s json did not match, but unmarshalling failed. %v", description, err) 379 } 380 printer := formatter.NewAsciiFormatter(left, formatter.AsciiFormatterConfig{ 381 ShowArrayIndex: true, 382 }) 383 output, err := printer.Format(diff) 384 if err != nil { 385 return fmt.Errorf("%s did not match, but diff formatting failed. %v", description, err) 386 } else { 387 return fmt.Errorf("%s json did not match expected.\n\n%s", description, output) 388 } 389 } 390 return nil 391 } 392 393 // testMakeRequestsImpl asserts the resulting values of the bidder's `MakeRequests()` implementation 394 // against the expected JSON-defined results and ensures we do not encounter data races in the process. 395 // To assert no data races happen we make use of: 396 // 1. A shallow copy of the unmarshalled openrtb2.BidRequest that will provide reference values to 397 // shared memory that we don't want the adapters' implementation of `MakeRequests()` to modify. 398 // 2. A deep copy that will preserve the original values of all the fields. This copy remains untouched 399 // by the adapters' processes and serves as reference of what the shared memory values should still 400 // be after the `MakeRequests()` call. 401 func testMakeRequestsImpl(t *testing.T, filename string, spec *testSpec, bidder adapters.Bidder, reqInfo *adapters.ExtraRequestInfo) []*adapters.RequestData { 402 t.Helper() 403 404 deepBidReqCopy, shallowBidReqCopy, err := getDataRaceTestCopies(&spec.BidRequest) 405 assert.NoError(t, err, "Could not create request copies. %s", filename) 406 407 // Run MakeRequests 408 requests, errs := bidder.MakeRequests(&spec.BidRequest, reqInfo) 409 410 // Compare MakeRequests actual output versus expected values found in JSON file 411 assertErrorList(t, fmt.Sprintf("%s: MakeRequests", filename), errs, spec.MakeRequestErrors) 412 assertMakeRequestsOutput(t, filename, requests, spec.HttpCalls) 413 414 // Assert no data races occur using original bidRequest copies of references and values 415 assert.Equal(t, deepBidReqCopy, shallowBidReqCopy, "Data race found. Test: %s", filename) 416 417 return requests 418 } 419 420 // getDataRaceTestCopies returns a deep copy and a shallow copy of the original bidRequest that will get 421 // compared to verify no data races occur. 422 func getDataRaceTestCopies(original *openrtb2.BidRequest) (*openrtb2.BidRequest, *openrtb2.BidRequest, error) { 423 cpy, err := copystructure.Copy(original) 424 if err != nil { 425 return nil, nil, err 426 } 427 deepReqCopy := cpy.(*openrtb2.BidRequest) 428 429 shallowReqCopy := *original 430 431 // Prebid Server core makes shallow copies of imp elements and adapters are allowed to make changes 432 // to them. Therefore, we need shallow copies of Imp elements here so our test replicates that 433 // functionality and only fail when actual shared momory gets modified. 434 if original.Imp != nil { 435 shallowReqCopy.Imp = make([]openrtb2.Imp, len(original.Imp)) 436 copy(shallowReqCopy.Imp, original.Imp) 437 } 438 439 return deepReqCopy, &shallowReqCopy, nil 440 } 441 442 // testMakeBidsImpl asserts the results of the bidder MakeBids implementation against the expected JSON-defined results 443 func testMakeBidsImpl(t *testing.T, filename string, spec *testSpec, bidder adapters.Bidder, makeRequestsOut []*adapters.RequestData) { 444 t.Helper() 445 446 bidResponses := make([]*adapters.BidderResponse, 0) 447 var bidsErrs = make([]error, 0, len(spec.MakeBidsErrors)) 448 449 // We should have as many bids as number of adapters.RequestData found in MakeRequests output 450 for i := 0; i < len(makeRequestsOut); i++ { 451 // Run MakeBids with JSON refined spec.HttpCalls info that was asserted to match MakeRequests 452 // output inside testMakeRequestsImpl 453 thisBidResponse, theseErrs := bidder.MakeBids(&spec.BidRequest, spec.HttpCalls[i].Request.ToRequestData(t), spec.HttpCalls[i].Response.ToResponseData(t)) 454 455 if theseErrs != nil { 456 bidsErrs = append(bidsErrs, theseErrs...) 457 } 458 if thisBidResponse != nil { 459 bidResponses = append(bidResponses, thisBidResponse) 460 } 461 } 462 463 // Assert actual errors thrown by MakeBids implementation versus expected JSON-defined spec.MakeBidsErrors 464 assertErrorList(t, fmt.Sprintf("%s: MakeBids", filename), bidsErrs, spec.MakeBidsErrors) 465 466 // Assert MakeBids implementation BidResponses with expected JSON-defined spec.BidResponses[i].Bids 467 if assert.Len(t, bidResponses, len(spec.BidResponses), "%s: MakeBids len(bidResponses) = %d vs len(spec.BidResponses) = %d", filename, len(bidResponses), len(spec.BidResponses)) { 468 for i := 0; i < len(spec.BidResponses); i++ { 469 assertMakeBidsOutput(t, filename, bidResponses[i], spec.BidResponses[i]) 470 } 471 } 472 }