diff --git a/adapters/teal/teal.go b/adapters/teal/teal.go new file mode 100644 index 00000000..3cdbd115 --- /dev/null +++ b/adapters/teal/teal.go @@ -0,0 +1,375 @@ +package teal + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/adapters" + "github.com/prebid/prebid-server/v4/config" + "github.com/prebid/prebid-server/v4/errortypes" + "github.com/prebid/prebid-server/v4/openrtb_ext" + "github.com/prebid/prebid-server/v4/util/jsonutil" +) + +const ( + msgAccountValidation = "account parameter failed validation" + msgPlacementValidation = "placement parameter failed validation" + msgImpExtParseFmt = "Error parsing imp.ext for impression %s" + msgImpExtBidderParseFmt = "Error parsing imp.ext.bidder for impression %s" + msgMixedAccountFmt = "mixed-account requests are not supported: imp %q account %q differs from request account %q" +) + +// adapter is the Teal openrtb2 bidder. +type adapter struct { + endpoint string +} + +// Builder builds a new instance of the Teal adapter for the given bidder with +// the given config. +func Builder(_ openrtb_ext.BidderName, cfg config.Adapter, _ config.Server) (adapters.Bidder, error) { + return &adapter{endpoint: cfg.Endpoint}, nil +} + +// MakeRequests transforms the openrtb2.BidRequest into a single Teal-bound HTTP +// request body: +// +// 1. Each imp's bidder-slot is decoded into ExtImpTeal and validated for +// non-blank account and (when present) non-blank placement. +// 2. Failed imps are dropped; their parse / validation errors are collected. +// 3. The first surviving imp's account is propagated to Site.Publisher.ID and +// App.Publisher.ID. Later imps must use that same account; an imp with a +// divergent account is rejected (BadInput) and dropped. +// 4. Each surviving imp gets imp.ext.prebid.storedrequest.id = placement when +// placement is set. +// 5. Request.Ext.bids is stamped with {"pbs": 1}. +// +// If no imp survives validation, returns (nil, errs) without dispatching. +func (a *adapter) MakeRequests(request *openrtb2.BidRequest, _ *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) { + if len(request.Imp) == 0 { + return nil, nil + } + + modifiedImps := make([]openrtb2.Imp, 0, len(request.Imp)) + var errs []error + var account string + + for i := range request.Imp { + imp := request.Imp[i] + ext, err := parseImpExt(&imp) + if err != nil { + errs = append(errs, &errortypes.BadInput{Message: err.Error()}) + continue + } + if err := validateImpExt(ext); err != nil { + errs = append(errs, &errortypes.BadInput{Message: err.Error()}) + continue + } + + // All imps in a request must agree on account, which drives the single + // request-level publisher.id. The first valid imp establishes it; a later + // imp with a different account is rejected and dropped. + if account == "" { + account = ext.Account + } else if ext.Account != account { + errs = append(errs, &errortypes.BadInput{ + Message: fmt.Sprintf(msgMixedAccountFmt, imp.ID, ext.Account, account), + }) + continue + } + + modified, err := modifyImp(&imp, ext.Placement) + if err != nil { + errs = append(errs, &errortypes.BadInput{Message: err.Error()}) + continue + } + modifiedImps = append(modifiedImps, *modified) + } + + if len(modifiedImps) == 0 { + return nil, errs + } + + modifiedRequest, err := modifyBidRequest(request, account, modifiedImps) + if err != nil { + return nil, append(errs, err) + } + + body, err := jsonutil.Marshal(modifiedRequest) + if err != nil { + return nil, append(errs, err) + } + + return []*adapters.RequestData{{ + Method: http.MethodPost, + Uri: a.endpoint, + Body: body, + Headers: standardHeaders(), + ImpIDs: openrtb_ext.GetImpIDs(modifiedRequest.Imp), + }}, errs +} + +// parseImpExt decodes imp.ext.bidder into ExtImpTeal. +func parseImpExt(imp *openrtb2.Imp) (*openrtb_ext.ExtImpTeal, error) { + var bidderExt adapters.ExtImpBidder + if err := jsonutil.Unmarshal(imp.Ext, &bidderExt); err != nil { + return nil, fmt.Errorf(msgImpExtParseFmt, imp.ID) + } + var ext openrtb_ext.ExtImpTeal + if err := jsonutil.Unmarshal(bidderExt.Bidder, &ext); err != nil { + return nil, fmt.Errorf(msgImpExtBidderParseFmt, imp.ID) + } + return &ext, nil +} + +// validateImpExt requires a non-blank account and, when placement is present +// (non-nil), a non-blank placement. Absent placement is allowed. +func validateImpExt(ext *openrtb_ext.ExtImpTeal) error { + if isBlank(ext.Account) { + return errors.New(msgAccountValidation) + } + if ext.Placement != nil && isBlank(*ext.Placement) { + return errors.New(msgPlacementValidation) + } + return nil +} + +// isBlank reports whether s is empty or contains only whitespace. +func isBlank(s string) bool { + return strings.TrimSpace(s) == "" +} + +// modifyImp returns a copy of imp with imp.ext.prebid.storedrequest.id set to +// *placement. Returns the imp unchanged when placement is nil. Existing +// prebid or storedrequest sub-keys that are not JSON objects are tolerated by +// replacing them with fresh objects. +func modifyImp(imp *openrtb2.Imp, placement *string) (*openrtb2.Imp, error) { + if placement == nil { + return imp, nil + } + + ext, err := decodeJSONObject(imp.Ext) + if err != nil { + return nil, fmt.Errorf(msgImpExtParseFmt, imp.ID) + } + + prebid := decodeOrEmptyObject(ext["prebid"]) + storedRequest := decodeOrEmptyObject(prebid["storedrequest"]) + + placementJSON, err := jsonutil.Marshal(*placement) + if err != nil { + return nil, err + } + storedRequest["id"] = placementJSON + + storedRequestJSON, err := jsonutil.Marshal(storedRequest) + if err != nil { + return nil, err + } + prebid["storedrequest"] = storedRequestJSON + + prebidJSON, err := jsonutil.Marshal(prebid) + if err != nil { + return nil, err + } + ext["prebid"] = prebidJSON + + extJSON, err := jsonutil.Marshal(ext) + if err != nil { + return nil, err + } + + modified := *imp + modified.Ext = extJSON + return &modified, nil +} + +// decodeOrEmptyObject decodes raw as a JSON object map. Returns an empty map +// when raw is absent / "null" / not a JSON object. The returned map is NEVER +// nil — it is always safe to assign into. +func decodeOrEmptyObject(raw json.RawMessage) map[string]json.RawMessage { + out, _ := decodeJSONObject(raw) + return out +} + +// decodeJSONObject decodes raw into a JSON object map. Treats absent input +// AND the JSON literal `null` as "empty object". Returns the parse error +// untouched on invalid JSON or non-object root types so callers can surface a +// meaningful failure. The returned map is NEVER nil, even on error — callers +// can safely assign into it. +func decodeJSONObject(raw json.RawMessage) (map[string]json.RawMessage, error) { + if len(raw) == 0 { + return make(map[string]json.RawMessage), nil + } + var parsed map[string]json.RawMessage + if err := jsonutil.Unmarshal(raw, &parsed); err != nil { + return make(map[string]json.RawMessage), err + } + if parsed == nil { + return make(map[string]json.RawMessage), nil + } + return parsed, nil +} + +// modifyBidRequest applies the request-level mutations: +// +// - Site.Publisher.ID is overwritten with account when site is non-nil +// - App.Publisher.ID is overwritten with account when app is non-nil +// - Request.Ext.bids is stamped with {"pbs":1} +// +// Returns a value-copy with mutated fields; the caller's request is untouched. +func modifyBidRequest(request *openrtb2.BidRequest, account string, modifiedImps []openrtb2.Imp) (*openrtb2.BidRequest, error) { + modified := *request + modified.Imp = modifiedImps + + if request.Site != nil { + site := *request.Site + site.Publisher = clonePublisherWithID(site.Publisher, account) + modified.Site = &site + } + if request.App != nil { + app := *request.App + app.Publisher = clonePublisherWithID(app.Publisher, account) + modified.App = &app + } + + extJSON, err := mergeBidsPBSFlag(request.Ext) + if err != nil { + return nil, err + } + modified.Ext = extJSON + return &modified, nil +} + +// clonePublisherWithID returns a copy of publisher with ID overwritten, +// creating a fresh Publisher when publisher is nil. +func clonePublisherWithID(publisher *openrtb2.Publisher, id string) *openrtb2.Publisher { + if publisher == nil { + return &openrtb2.Publisher{ID: id} + } + pub := *publisher + pub.ID = id + return &pub +} + +// mergeBidsPBSFlag returns existingExt with the "bids" property set to +// {"pbs":1}; other ext fields are preserved. The "pbs":1 marker is a Teal-side +// reporting/billing signal — it tells Teal's exchange the request is being +// routed via prebid-server, distinguishing it from direct integrations. +func mergeBidsPBSFlag(existingExt json.RawMessage) (json.RawMessage, error) { + ext, err := decodeJSONObject(existingExt) + if err != nil { + return nil, fmt.Errorf("teal: failed parsing request.ext: %w", err) + } + // Overwrite any caller-provided ext.bids: the {"pbs":1} marker must be + // authoritative for Teal's reporting/billing pipeline, so a value set by the + // publisher or upstream middleware is intentionally replaced, not merged. + ext["bids"] = json.RawMessage(`{"pbs":1}`) + return jsonutil.Marshal(ext) +} + +// MakeBids parses the Teal bid response body and packages the bids into a +// BidderResponse. Status handling follows the canonical adapters helpers: +// 204 → no-content shortcut, 4xx/5xx → BadServerResponse, 200 → parse body. +// Bids whose media type cannot be resolved from the matching imp are skipped +// and their error collected, rather than silently mis-typed as banner. +func (a *adapter) MakeBids(request *openrtb2.BidRequest, _ *adapters.RequestData, responseData *adapters.ResponseData) (*adapters.BidderResponse, []error) { + if adapters.IsResponseStatusCodeNoContent(responseData) { + return nil, nil + } + if err := adapters.CheckResponseStatusCodeForErrors(responseData); err != nil { + return nil, []error{err} + } + + var bidResponse openrtb2.BidResponse + if err := jsonutil.Unmarshal(responseData.Body, &bidResponse); err != nil { + return nil, []error{&errortypes.BadServerResponse{Message: err.Error()}} + } + + impsByID := make(map[string]openrtb2.Imp, len(request.Imp)) + for _, imp := range request.Imp { + impsByID[imp.ID] = imp + } + + bidderResponse := adapters.NewBidderResponseWithBidsCapacity(len(request.Imp)) + if bidResponse.Cur != "" { + bidderResponse.Currency = bidResponse.Cur + } + var errs []error + for _, seatBid := range bidResponse.SeatBid { + for i := range seatBid.Bid { + bid := &seatBid.Bid[i] + bidType, err := getBidType(bid, impsByID) + if err != nil { + errs = append(errs, err) + continue + } + bidderResponse.Bids = append(bidderResponse.Bids, &adapters.TypedBid{ + Bid: bid, + BidType: bidType, + }) + } + } + return bidderResponse, errs +} + +// getBidType resolves the bid's media type. Teal declares multiformat-supported, +// so a single impression can carry more than one media type (e.g. banner+video); +// introspecting the imp alone cannot disambiguate which format a given bid is for. +// The authoritative signal is the response's own bid.ext.prebid.type, so it is +// consulted first. +// +// Only when the response omits bid.ext.prebid.type does getBidType fall back to introspecting the +// matching impression, looked up by ID via impsByID (built once in MakeBids to avoid +// a per-bid scan of request.Imp), in banner > video > native order. Audio is omitted +// because it is not in Teal's declared capabilities. +// +// When neither bid.ext.prebid.type nor a matching imp yields a recognized media type, +// getBidType returns an error so MakeBids can skip the bid and surface the problem in +// logs rather than silently mis-typing it. +func getBidType(bid *openrtb2.Bid, impsByID map[string]openrtb2.Imp) (openrtb_ext.BidType, error) { + // 1. Check if the bid type exists in bid.ext.prebid.type + if len(bid.Ext) > 0 { + var bidExt openrtb_ext.ExtBid + if err := jsonutil.Unmarshal(bid.Ext, &bidExt); err == nil && bidExt.Prebid != nil && bidExt.Prebid.Type != "" { + if bidType, err := openrtb_ext.ParseBidType(string(bidExt.Prebid.Type)); err == nil { + return bidType, nil + } + } + } + + switch bid.MType { + case openrtb2.MarkupBanner: + return openrtb_ext.BidTypeBanner, nil + case openrtb2.MarkupVideo: + return openrtb_ext.BidTypeVideo, nil + case openrtb2.MarkupNative: + return openrtb_ext.BidTypeNative, nil + } + + // 2. Fall back to inferring from the impression + if imp, ok := impsByID[bid.ImpID]; ok { + switch { + case imp.Banner != nil: + return openrtb_ext.BidTypeBanner, nil + case imp.Video != nil: + return openrtb_ext.BidTypeVideo, nil + case imp.Native != nil: + return openrtb_ext.BidTypeNative, nil + } + } + return "", &errortypes.BadServerResponse{ + Message: fmt.Sprintf("failed to determine bid type for imp %q", bid.ImpID), + } +} + +// standardHeaders returns the headers Teal expects on every outbound request. +func standardHeaders() http.Header { + headers := http.Header{} + headers.Add("Content-Type", "application/json;charset=utf-8") + headers.Add("Accept", "application/json") + return headers +} diff --git a/adapters/teal/teal_test.go b/adapters/teal/teal_test.go new file mode 100644 index 00000000..59d125c7 --- /dev/null +++ b/adapters/teal/teal_test.go @@ -0,0 +1,333 @@ +package teal + +import ( + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/adapters" + "github.com/prebid/prebid-server/v4/adapters/adapterstest" + "github.com/prebid/prebid-server/v4/config" + "github.com/prebid/prebid-server/v4/errortypes" + "github.com/prebid/prebid-server/v4/openrtb_ext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testEndpoint = "https://test.example.com/bid" + testAccount = "test-account" + testRequestID = "test-request-banner" +) + +// End-to-end MakeRequests/MakeBids behavior is exercised by the JSON fixtures +// under tealtest/exemplary and tealtest/supplemental. The Go tests below cover +// only what fixtures cannot: the Go error-TYPE contract (BadInput vs +// BadServerResponse), short-circuit edges, and error branches of unexported +// helpers that are unreachable through MakeRequests. + +func TestJsonSamples(t *testing.T) { + bidder, err := Builder(openrtb_ext.BidderTeal, config.Adapter{Endpoint: testEndpoint}, + config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"}) + require.NoError(t, err, "Builder returned unexpected error") + adapterstest.RunJSONBidderTest(t, "tealtest", bidder) +} + +// TestErrorClassification pins the error-type contract, which the JSON fixtures +// cannot verify (they compare error messages only): MakeRequests parse / +// validation / account-divergence errors are *errortypes.BadInput; MakeBids +// transport errors are BadInput for 4xx and *errortypes.BadServerResponse for +// 5xx and unparseable bodies. +func TestErrorClassification(t *testing.T) { + bidder := newTealBidder(t) + + t.Run("imp.ext parse error is BadInput", func(t *testing.T) { + imp := openrtb2.Imp{ + ID: "impId", + Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, + Ext: json.RawMessage(`[]`), + } + requests, errs := bidder.MakeRequests(givenBidRequest(imp), &adapters.ExtraRequestInfo{}) + assert.Nil(t, requests) + require.Len(t, errs, 1) + assert.True(t, isBadInputErr(errs[0]), "got %T", errs[0]) + assert.Contains(t, errs[0].Error(), "Error parsing imp.ext for impression impId") + }) + + t.Run("imp.ext bidder field malformed is BadInput", func(t *testing.T) { + imp := givenBannerImp("impId", json.RawMessage(`{"bidder":[]}`)) + requests, errs := bidder.MakeRequests(givenBidRequest(imp), &adapters.ExtraRequestInfo{}) + assert.Nil(t, requests) + require.Len(t, errs, 1) + assert.True(t, isBadInputErr(errs[0]), "got %T", errs[0]) + assert.Contains(t, errs[0].Error(), "Error parsing imp.ext.bidder for impression impId") + }) + + t.Run("blank account is BadInput", func(t *testing.T) { + imp := givenBannerImp("imp1", givenImpExt(t, " ", strPtr("p"))) + requests, errs := bidder.MakeRequests(givenBidRequest(imp), &adapters.ExtraRequestInfo{}) + assert.Nil(t, requests) + require.Len(t, errs, 1) + assert.True(t, isBadInputErr(errs[0]), "got %T", errs[0]) + assert.Equal(t, msgAccountValidation, errs[0].Error()) + }) + + t.Run("blank placement is BadInput", func(t *testing.T) { + imp := givenBannerImp("imp1", givenImpExt(t, "acct", strPtr(" "))) + requests, errs := bidder.MakeRequests(givenBidRequest(imp), &adapters.ExtraRequestInfo{}) + assert.Nil(t, requests) + require.Len(t, errs, 1) + assert.True(t, isBadInputErr(errs[0]), "got %T", errs[0]) + assert.Equal(t, msgPlacementValidation, errs[0].Error()) + }) + + t.Run("divergent account is BadInput", func(t *testing.T) { + first := givenBannerImp("imp1", givenImpExt(t, "acct-a", strPtr("p1"))) + second := givenBannerImp("imp2", givenImpExt(t, "acct-b", strPtr("p2"))) + requests, errs := bidder.MakeRequests(givenBidRequest(first, second), &adapters.ExtraRequestInfo{}) + require.Len(t, requests, 1, "first imp still ships") + require.Len(t, errs, 1) + assert.True(t, isBadInputErr(errs[0]), "got %T", errs[0]) + assert.Contains(t, errs[0].Error(), "mixed-account requests are not supported") + }) + + t.Run("malformed request.ext propagates parse error", func(t *testing.T) { + imp := givenBannerImp("imp1", givenImpExt(t, testAccount, nil)) + req := givenBidRequest(imp) + req.Ext = json.RawMessage(`{"prebid":}`) // syntactically invalid + requests, errs := bidder.MakeRequests(req, &adapters.ExtraRequestInfo{}) + assert.Nil(t, requests) + require.NotEmpty(t, errs) + assert.Contains(t, errs[len(errs)-1].Error(), "failed parsing request.ext") + }) + + t.Run("MakeBids 4xx is BadInput", func(t *testing.T) { + resp, errs := makeBidsCall(t, bidder, &openrtb2.BidRequest{}, http.StatusBadRequest, []byte(`{}`)) + assert.Nil(t, resp) + require.Len(t, errs, 1) + assert.True(t, isBadInputErr(errs[0]), "got %T", errs[0]) + }) + + t.Run("MakeBids 5xx is BadServerResponse", func(t *testing.T) { + resp, errs := makeBidsCall(t, bidder, &openrtb2.BidRequest{}, http.StatusInternalServerError, []byte(`{}`)) + assert.Nil(t, resp) + require.Len(t, errs, 1) + assert.True(t, isBadServerErr(errs[0]), "got %T", errs[0]) + }) + + t.Run("MakeBids unparseable body is BadServerResponse", func(t *testing.T) { + resp, errs := makeBidsCall(t, bidder, &openrtb2.BidRequest{}, http.StatusOK, []byte("not-json")) + assert.Nil(t, resp) + require.Len(t, errs, 1) + assert.True(t, isBadServerErr(errs[0]), "got %T", errs[0]) + }) +} + +// TestMakeRequests_NoImps — empty Imp slice returns (nil, nil) before any work +// happens. +func TestMakeRequests_NoImps(t *testing.T) { + bidder := newTealBidder(t) + requests, errs := bidder.MakeRequests(&openrtb2.BidRequest{ID: "req"}, &adapters.ExtraRequestInfo{}) + assert.Nil(t, requests) + assert.Nil(t, errs) +} + +// TestGetBidType pins media type resolution priorities and error pathways. +func TestGetBidType(t *testing.T) { + t.Run("explicit prebid type overrides impression fallback", func(t *testing.T) { + bid := &openrtb2.Bid{ImpID: "imp1", Ext: json.RawMessage(`{"prebid":{"type":"video"}}`)} + imp := openrtb2.Imp{ID: "imp1", Banner: &openrtb2.Banner{}} + + bidType, err := getBidType(bid, map[string]openrtb2.Imp{imp.ID: imp}) + require.NoError(t, err) + assert.Equal(t, openrtb_ext.BidTypeVideo, bidType) + }) + + t.Run("invalid explicit prebid type falls back to bid media type", func(t *testing.T) { + bid := &openrtb2.Bid{ + ImpID: "imp1", + MType: openrtb2.MarkupVideo, + Ext: json.RawMessage(`{"prebid":{"type":"banana"}}`), + } + + bidType, err := getBidType(bid, map[string]openrtb2.Imp{}) + require.NoError(t, err) + assert.Equal(t, openrtb_ext.BidTypeVideo, bidType) + }) + + t.Run("invalid explicit prebid type falls back to impression type", func(t *testing.T) { + bid := &openrtb2.Bid{ + ImpID: "imp1", + Ext: json.RawMessage(`{"prebid":{"type":"banana"}}`), + } + imp := openrtb2.Imp{ID: "imp1", Banner: &openrtb2.Banner{}} + + bidType, err := getBidType(bid, map[string]openrtb2.Imp{imp.ID: imp}) + require.NoError(t, err) + assert.Equal(t, openrtb_ext.BidTypeBanner, bidType) + }) + + t.Run("falls back to impression type when bid.ext.prebid.type is absent", func(t *testing.T) { + testCases := []struct { + name string + imp openrtb2.Imp + want openrtb_ext.BidType + }{ + {name: "banner", imp: openrtb2.Imp{ID: "imp1", Banner: &openrtb2.Banner{}}, want: openrtb_ext.BidTypeBanner}, + {name: "video", imp: openrtb2.Imp{ID: "imp1", Video: &openrtb2.Video{}}, want: openrtb_ext.BidTypeVideo}, + {name: "native", imp: openrtb2.Imp{ID: "imp1", Native: &openrtb2.Native{}}, want: openrtb_ext.BidTypeNative}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + bid := &openrtb2.Bid{ImpID: "imp1"} + bidType, err := getBidType(bid, map[string]openrtb2.Imp{tc.imp.ID: tc.imp}) + require.NoError(t, err) + assert.Equal(t, tc.want, bidType) + }) + } + }) + + t.Run("imp not found", func(t *testing.T) { + bid := &openrtb2.Bid{ImpID: "imp1"} + _, err := getBidType(bid, map[string]openrtb2.Imp{}) + require.Error(t, err) + assert.Contains(t, err.Error(), `failed to determine bid type for imp "imp1"`) + }) + + t.Run("bid and imp have no type", func(t *testing.T) { + bid := &openrtb2.Bid{ImpID: "imp1"} + imp := openrtb2.Imp{ID: "imp1"} + _, err := getBidType(bid, map[string]openrtb2.Imp{imp.ID: imp}) + require.Error(t, err) + assert.Contains(t, err.Error(), `failed to determine bid type for imp "imp1"`) + }) +} + +// TestModifyImp_MalformedExt — modifyImp's parse-error branch. Unreachable via +// MakeRequests (parseImpExt rejects non-object imp.ext first), so it cannot be +// a fixture. +func TestModifyImp_MalformedExt(t *testing.T) { + imp := &openrtb2.Imp{ID: "broken", Ext: json.RawMessage(`not-json`)} + got, err := modifyImp(imp, strPtr("p")) + assert.Nil(t, got) + require.Error(t, err) + assert.Contains(t, err.Error(), "Error parsing imp.ext for impression broken") +} + +// TestDecodeOrEmptyObject — the non-object branch is unreachable via +// MakeRequests (parseImpExt rejects those shapes first); the returned map must +// never be nil so callers can assign into it. +func TestDecodeOrEmptyObject(t *testing.T) { + t.Run("empty raw returns empty map", func(t *testing.T) { + out := decodeOrEmptyObject(nil) + assert.NotNil(t, out) + assert.Empty(t, out) + }) + + t.Run("non-object input returns empty map", func(t *testing.T) { + out := decodeOrEmptyObject(json.RawMessage(`[1,2,3]`)) + assert.NotNil(t, out) + assert.Empty(t, out) + }) + + t.Run("valid object input returns populated map", func(t *testing.T) { + out := decodeOrEmptyObject(json.RawMessage(`{"a":1,"b":"two"}`)) + assert.Len(t, out, 2) + assert.JSONEq(t, `1`, string(out["a"])) + assert.JSONEq(t, `"two"`, string(out["b"])) + }) +} + +// TestMergeBidsPBSFlag — pins the null-handling contract: a JSON literal `null` +// (or absent) ext must route through decodeJSONObject so the receiver map is +// non-nil before `ext["bids"] = ...`, which would otherwise panic with +// "assignment to entry in nil map". +func TestMergeBidsPBSFlag(t *testing.T) { + t.Run("null ext treated as empty object", func(t *testing.T) { + out, err := mergeBidsPBSFlag(json.RawMessage(`null`)) + require.NoError(t, err) + require.NotNil(t, out) + + var decoded map[string]json.RawMessage + require.NoError(t, json.Unmarshal(out, &decoded)) + assert.Len(t, decoded, 1, "null input should produce just the bids stamp") + assert.JSONEq(t, `{"pbs":1}`, string(decoded["bids"])) + }) + + t.Run("malformed ext returns wrapped error", func(t *testing.T) { + out, err := mergeBidsPBSFlag(json.RawMessage(`{"bids":`)) + assert.Nil(t, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed parsing request.ext") + }) +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// strPtr returns a pointer to s. Used for the *string Placement field. +func strPtr(s string) *string { + return &s +} + +// newTealBidder builds the adapter via the canonical Builder. +func newTealBidder(t *testing.T) adapters.Bidder { + t.Helper() + bidder, err := Builder(openrtb_ext.BidderTeal, config.Adapter{Endpoint: testEndpoint}, + config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"}) + require.NoError(t, err) + require.NotNil(t, bidder) + return bidder +} + +// givenImpExt produces the imp.ext shape Teal expects: {"bidder": ExtImpTeal}. +func givenImpExt(t *testing.T, account string, placement *string) json.RawMessage { + t.Helper() + inner := openrtb_ext.ExtImpTeal{Account: account, Placement: placement} + innerJSON, err := json.Marshal(inner) + require.NoError(t, err) + wrapper := map[string]json.RawMessage{"bidder": innerJSON} + out, err := json.Marshal(wrapper) + require.NoError(t, err) + return out +} + +// givenBannerImp returns a single banner imp with the supplied id and ext. +func givenBannerImp(id string, ext json.RawMessage) openrtb2.Imp { + return openrtb2.Imp{ + ID: id, + Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, + Ext: ext, + } +} + +// givenBidRequest returns a minimal site-rooted BidRequest with the given imps. +func givenBidRequest(imps ...openrtb2.Imp) *openrtb2.BidRequest { + return &openrtb2.BidRequest{ + ID: testRequestID, + Imp: imps, + Site: &openrtb2.Site{ID: "demo-site", Publisher: &openrtb2.Publisher{ID: "demo-publisher"}}, + } +} + +// makeBidsCall wires a ResponseData and invokes MakeBids. +func makeBidsCall(t *testing.T, bidder adapters.Bidder, request *openrtb2.BidRequest, status int, body []byte) (*adapters.BidderResponse, []error) { + t.Helper() + return bidder.MakeBids(request, &adapters.RequestData{Method: http.MethodPost, Uri: testEndpoint}, + &adapters.ResponseData{StatusCode: status, Body: body}) +} + +// isBadInputErr returns true if err is or wraps an *errortypes.BadInput. +func isBadInputErr(err error) bool { + var target *errortypes.BadInput + return errors.As(err, &target) +} + +// isBadServerErr returns true if err is or wraps an *errortypes.BadServerResponse. +func isBadServerErr(err error) bool { + var target *errortypes.BadServerResponse + return errors.As(err, &target) +} diff --git a/adapters/teal/tealtest/exemplary/multi-format-imp-bid-type.json b/adapters/teal/tealtest/exemplary/multi-format-imp-bid-type.json new file mode 100644 index 00000000..5e015ef3 --- /dev/null +++ b/adapters/teal/tealtest/exemplary/multi-format-imp-bid-type.json @@ -0,0 +1,197 @@ +{ + "mockBidRequest": { + "id": "test-request-multi-format-mtype", + "imp": [ + { + "id": "test-imp-multi-format", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "video": { + "mimes": [ + "video/mp4" + ], + "w": 640, + "h": 480 + }, + "native": { + "request": "{\"ver\":\"1.2\"}" + }, + "ext": { + "bidder": { + "account": "test-account", + "placement": "test-placement-multi" + } + } + } + ], + "site": { + "id": "demo-site", + "publisher": { + "id": "demo-publisher" + } + } + }, + "httpCalls": [ + { + "expectedRequest": { + "uri": "https://test.example.com/bid", + "body": { + "id": "test-request-multi-format-mtype", + "imp": [ + { + "id": "test-imp-multi-format", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "video": { + "mimes": [ + "video/mp4" + ], + "w": 640, + "h": 480 + }, + "native": { + "request": "{\"ver\":\"1.2\"}" + }, + "ext": { + "bidder": { + "account": "test-account", + "placement": "test-placement-multi" + }, + "prebid": { + "storedrequest": { + "id": "test-placement-multi" + } + } + } + } + ], + "site": { + "id": "demo-site", + "publisher": { + "id": "test-account" + } + }, + "ext": { + "bids": { + "pbs": 1 + } + } + }, + "impIDs": ["test-imp-multi-format"] + }, + "mockResponse": { + "status": 200, + "body": { + "id": "test-request-multi-format-mtype", + "cur": "USD", + "seatbid": [ + { + "bid": [ + { + "id": "bid-banner", + "impid": "test-imp-multi-format", + "price": 1.75, + "adm": "