diff --git a/adapters/adgeneration/adgeneration.go b/adapters/adgeneration/adgeneration.go index 89492eb7e06..a19d7856cea 100644 --- a/adapters/adgeneration/adgeneration.go +++ b/adapters/adgeneration/adgeneration.go @@ -1,12 +1,12 @@ package adgeneration import ( + "encoding/json" "errors" "fmt" "net/http" "net/url" "regexp" - "strconv" "strings" "github.com/prebid/openrtb/v20/openrtb2" @@ -15,41 +15,71 @@ import ( "github.com/prebid/prebid-server/v4/errortypes" "github.com/prebid/prebid-server/v4/openrtb_ext" "github.com/prebid/prebid-server/v4/util/jsonutil" + "github.com/prebid/prebid-server/v4/version" ) -type AdgenerationAdapter struct { +// To keep the request/response format in parity with Prebid.js v1.6.6 +// (modules/adgenerationBidAdapter.js), this adapter targets the /adgen/prebid +// endpoint (POST with a JSON body). Only id / posall / sdktype are sent as URL +// query parameters; everything else travels in the ortb body. + +type adapter struct { endpoint string version string defaultCurrency string } -// Server Responses +// adgRequestBody is the JSON structure of the POST body. It mirrors the +// Prebid.js `data` object (currency / pbver / sdkname / adapterver / ortb / imark). +type adgRequestBody struct { + Currency string `json:"currency"` + Pbver string `json:"pbver"` + Sdkname string `json:"sdkname"` + Adapterver string `json:"adapterver"` + Ortb openrtb2.BidRequest `json:"ortb"` + // imark is set to 1 only for non-native (i.e. banner) requests. This mirrors + // the Prebid.js adapter, whose comment notes it must be revisited if support + // for other media types such as video is added. + Imark int `json:"imark,omitempty"` +} + +// adgServerResponse is the response format from the backend +// (d.socdm.com/adgen/prebid). Prebid.js reads body.results[0], so results is +// treated as the primary source. type adgServerResponse struct { - Locationid string `json:"locationid"` - Dealid string `json:"dealid"` - Ad string `json:"ad"` - Beacon string `json:"beacon"` - Beaconurl string `json:"beaconurl"` - Cpm float64 `jsons:"cpm"` - Creativeid string `json:"creativeid"` - H uint64 `json:"h"` - W uint64 `json:"w"` - Ttl uint64 `json:"ttl"` - Vastxml string `json:"vastxml,omitempty"` - LandingUrl string `json:"landing_url"` - Scheduleid string `json:"scheduleid"` - Results []interface{} `json:"results"` + Locationid string `json:"locationid"` + LocationParams *adgLocationParams `json:"location_params,omitempty"` + Results []adgResult `json:"results"` } -func (adg *AdgenerationAdapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) { - numRequests := len(request.Imp) - var errs []error +type adgLocationParams struct { + Option *adgLocationOption `json:"option,omitempty"` +} + +type adgLocationOption struct { + AdType string `json:"ad_type,omitempty"` +} - if numRequests == 0 { - errs = append(errs, &errortypes.BadInput{ - Message: "No impression in the bid request", - }) - return nil, errs +type adgResult struct { + Ad string `json:"ad"` + Beacon string `json:"beacon"` + Beaconurl string `json:"beaconurl"` + Cpm float64 `json:"cpm"` + Creativeid string `json:"creativeid"` + Dealid string `json:"dealid"` + H uint64 `json:"h"` + W uint64 `json:"w"` + Ttl uint64 `json:"ttl"` + Vastxml string `json:"vastxml,omitempty"` + LandingUrl string `json:"landing_url"` + Scheduleid string `json:"scheduleid"` + Adomain []string `json:"adomain,omitempty"` + Native json.RawMessage `json:"native,omitempty"` +} + +func (adg *adapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) { + if len(request.Imp) == 0 { + return nil, []error{&errortypes.BadInput{Message: "No impression in the bid request"}} } headers := http.Header{} @@ -64,86 +94,140 @@ func (adg *AdgenerationAdapter) MakeRequests(request *openrtb2.BidRequest, reqIn } } - bidRequestArray := make([]*adapters.RequestData, 0, numRequests) + bidRequestArray := make([]*adapters.RequestData, 0, len(request.Imp)) + var errs []error - for index := 0; index < numRequests; index++ { - bidRequestUri, err := adg.getRequestUri(request, index) + // Prebid.js issues one request per imp; Prebid Server does the same. + for index := range request.Imp { + req, err := adg.buildRequest(request, index, headers) if err != nil { errs = append(errs, err) - return nil, errs + continue } - bidRequest := &adapters.RequestData{ - Method: "GET", - Uri: bidRequestUri, - Body: nil, - Headers: headers, - ImpIDs: []string{request.Imp[index].ID}, - } - bidRequestArray = append(bidRequestArray, bidRequest) + bidRequestArray = append(bidRequestArray, req) } return bidRequestArray, errs } -func (adg *AdgenerationAdapter) getRequestUri(request *openrtb2.BidRequest, index int) (string, error) { +func (adg *adapter) buildRequest(request *openrtb2.BidRequest, index int, headers http.Header) (*adapters.RequestData, error) { imp := request.Imp[index] adgExt, err := unmarshalExtImpAdgeneration(&imp) if err != nil { - return "", &errortypes.BadInput{ - Message: err.Error(), - } + return nil, &errortypes.BadInput{Message: err.Error()} } - uriObj, err := url.Parse(adg.endpoint) + + uri, err := adg.buildUri(adgExt.Id, request) if err != nil { - return "", &errortypes.BadInput{ - Message: err.Error(), - } + return nil, &errortypes.BadInput{Message: err.Error()} } - v := adg.getRawQuery(adgExt.Id, request, &imp) - uriObj.RawQuery = v.Encode() - return uriObj.String(), err + + body, err := adg.buildBody(request, imp) + if err != nil { + return nil, err + } + + return &adapters.RequestData{ + Method: http.MethodPost, + Uri: uri, + Body: body, + Headers: headers, + ImpIDs: []string{imp.ID}, + }, nil } -func (adg *AdgenerationAdapter) getRawQuery(id string, request *openrtb2.BidRequest, imp *openrtb2.Imp) *url.Values { +func (adg *adapter) buildUri(id string, request *openrtb2.BidRequest) (string, error) { + uriObj, err := url.Parse(adg.endpoint) + if err != nil { + return "", err + } v := url.Values{} - v.Set("posall", "SSPLOC") v.Set("id", id) - v.Set("hb", "true") - v.Set("t", "json3") - v.Set("currency", adg.getCurrency(request)) - v.Set("sdkname", "prebidserver") - v.Set("adapterver", adg.version) - adSize := getSizes(imp) - if adSize != "" { - v.Set("sizes", adSize) - } - if request.Device != nil && request.Device.OS == "android" { - v.Set("sdktype", "1") - } else if request.Device != nil && request.Device.OS == "ios" { - v.Set("sdktype", "2") - } else { - v.Set("sdktype", "0") + v.Set("posall", "SSPLOC") + v.Set("sdktype", detectSdkType(request)) + uriObj.RawQuery = v.Encode() + return uriObj.String(), nil +} + +// detectSdkType derives the sdktype from the request origin (channel) and +// device.os. The backend `/adgen/prebid` switches its delivery logic on +// sdktype, so web traffic gets "0", Prebid Mobile (Android) gets "1", and +// Prebid Mobile (iOS) gets "2". Prebid.js (client-side header bidding) always +// sends "0", but PBS is reached through multiple paths (PBJS+PBS, or PBS-only +// via a Mobile SDK / AMP), so it must detect the origin. +// +// Resolution order: +// 1. ext.prebid.channel.name == "app" -> mobile SDK +// 2. no channel: fall back to the presence of BidRequest.App (App means mobile) +// 3. otherwise -> web (sdktype "0") +// +// When 1 or 2 matches, device.os selects 1/2; an unknown OS yields "0". +func detectSdkType(request *openrtb2.BidRequest) string { + if !isAppContext(request) { + return "0" } - if request.Site != nil && request.Site.Page != "" { - v.Set("tp", request.Site.Page) + if request.Device != nil { + switch strings.ToLower(request.Device.OS) { + case "android": + return "1" + case "ios": + return "2" + } } - if request.Source != nil && request.Source.TID != "" { - v.Set("transactionid", request.Source.TID) + return "0" +} + +func isAppContext(request *openrtb2.BidRequest) bool { + if name := requestChannelName(request); name != "" { + return strings.EqualFold(name, "app") } - if request.App != nil && request.App.Bundle != "" { - v.Set("appbundle", request.App.Bundle) + // Fallback when no channel is present: treat it as a mobile app if + // BidRequest.App is set. (AMP / web rarely populate App, whereas the Prebid + // Mobile SDK does.) + return request.App != nil +} + +func requestChannelName(request *openrtb2.BidRequest) string { + if request == nil || len(request.Ext) == 0 { + return "" } - if request.App != nil && request.App.Name != "" { - v.Set("appname", request.App.Name) + var reqExt openrtb_ext.ExtRequest + if err := jsonutil.Unmarshal(request.Ext, &reqExt); err != nil { + return "" } - if request.Device != nil && request.Device.OS == "ios" && request.Device.IFA != "" { - v.Set("idfa", request.Device.IFA) + if reqExt.Prebid.Channel == nil { + return "" + } + return reqExt.Prebid.Channel.Name +} + +func (adg *adapter) buildBody(request *openrtb2.BidRequest, imp openrtb2.Imp) ([]byte, error) { + // ortb carries a BidRequest reduced to a single imp (same as Prebid.js). The + // other fields of the original request (site/app/device/user/source/regs/ext, + // etc.) are preserved as-is so that FPD/UserID/schain/SUA and the like reach + // the backend naturally. + ortbReq := *request + ortbReq.Imp = []openrtb2.Imp{imp} + + pbver := version.Ver + if pbver == "" { + pbver = version.VerUnknown + } + + body := adgRequestBody{ + Currency: adg.getCurrency(request), + Pbver: pbver, + Sdkname: "prebidserver", + Adapterver: adg.version, + Ortb: ortbReq, } - if request.Device != nil && request.Device.OS == "android" && request.Device.IFA != "" { - v.Set("advertising_id", request.Device.IFA) + // imark: set to 1 for non-native (assumed banner) requests. This flag + // originates from Prebid.js; its exact meaning on the backend is unverified. + if imp.Native == nil { + body.Imark = 1 } - return &v + return json.Marshal(body) } func unmarshalExtImpAdgeneration(imp *openrtb2.Imp) (*openrtb_ext.ExtImpAdgeneration, error) { @@ -161,34 +245,20 @@ func unmarshalExtImpAdgeneration(imp *openrtb2.Imp) (*openrtb_ext.ExtImpAdgenera return &adgExt, nil } -func getSizes(imp *openrtb2.Imp) string { - if imp.Banner == nil || len(imp.Banner.Format) == 0 { - return "" - } - var sizeStr string - for _, v := range imp.Banner.Format { - sizeStr += strconv.FormatInt(v.W, 10) + "x" + strconv.FormatInt(v.H, 10) + "," - } - if len(sizeStr) > 0 && strings.LastIndex(sizeStr, ",") == len(sizeStr)-1 { - sizeStr = sizeStr[:len(sizeStr)-1] - } - return sizeStr -} - -func (adg *AdgenerationAdapter) getCurrency(request *openrtb2.BidRequest) string { - if len(request.Cur) <= 0 { - return adg.defaultCurrency - } else { - for _, c := range request.Cur { - if adg.defaultCurrency == c { - return c - } +// getCurrency follows the same either/or logic as Prebid.js +// (adgenerationBidAdapter.js: getCurrencyType): return "USD" if request.Cur +// contains USD, otherwise "JPY". Falling back to the first listed currency is +// intentionally not supported (passing EUR/GBP etc. through is out of spec). +func (adg *adapter) getCurrency(request *openrtb2.BidRequest) string { + for _, c := range request.Cur { + if strings.EqualFold(c, "USD") { + return "USD" } - return request.Cur[0] } + return adg.defaultCurrency } -func (adg *AdgenerationAdapter) MakeBids(internalRequest *openrtb2.BidRequest, externalRequest *adapters.RequestData, response *adapters.ResponseData) (*adapters.BidderResponse, []error) { +func (adg *adapter) MakeBids(internalRequest *openrtb2.BidRequest, externalRequest *adapters.RequestData, response *adapters.ResponseData) (*adapters.BidderResponse, []error) { if response.StatusCode == http.StatusNoContent { return nil, nil } @@ -202,70 +272,251 @@ func (adg *AdgenerationAdapter) MakeBids(internalRequest *openrtb2.BidRequest, e Message: fmt.Sprintf("Unexpected status code: %d. Run with request.debug = 1 for more info", response.StatusCode), }} } + var bidResp adgServerResponse - err := jsonutil.Unmarshal(response.Body, &bidResp) - if err != nil { + if err := jsonutil.Unmarshal(response.Body, &bidResp); err != nil { return nil, []error{err} } - if len(bidResp.Results) <= 0 { + if len(bidResp.Results) == 0 { return nil, nil } + // Like Prebid.js, only results[0] is used (one imp per request). + adResult := bidResp.Results[0] + + // Prebid.js references bidRequests.data.ortb.imp[0] directly, so we do the + // same: take imp[0].id from the sent body and look up the matching imp. This + // avoids a silent no-bid when the backend omits locationid or returns a + // mismatched value. + if externalRequest == nil || len(externalRequest.Body) == 0 { + return nil, nil + } + var sentBody adgRequestBody + if err := jsonutil.Unmarshal(externalRequest.Body, &sentBody); err != nil { + return nil, []error{err} + } + if len(sentBody.Ortb.Imp) == 0 { + return nil, nil + } + targetImpID := sentBody.Ortb.Imp[0].ID + var matchedImp *openrtb2.Imp + for i := range internalRequest.Imp { + if internalRequest.Imp[i].ID == targetImpID { + matchedImp = &internalRequest.Imp[i] + break + } + } + if matchedImp == nil { + return nil, nil + } + + bidType, adm, err := buildAdMarkup(&adResult, bidResp.LocationParams, matchedImp) + if err != nil { + return nil, []error{err} + } + + bid := openrtb2.Bid{ + ID: bidResp.Locationid, + ImpID: matchedImp.ID, + AdM: adm, + Price: adResult.Cpm, + W: int64(adResult.W), + H: int64(adResult.H), + CrID: adResult.Creativeid, + DealID: adResult.Dealid, + } + if len(adResult.Adomain) > 0 { + bid.ADomain = adResult.Adomain + } + bidResponse := adapters.NewBidderResponseWithBidsCapacity(1) - var impId string - var bitType openrtb_ext.BidType - var adm string - for _, v := range internalRequest.Imp { - adgExt, err := unmarshalExtImpAdgeneration(&v) + bidResponse.Currency = adg.getCurrency(internalRequest) + bidResponse.Bids = append(bidResponse.Bids, &adapters.TypedBid{ + Bid: &bid, + BidType: bidType, + }) + return bidResponse, nil +} + +// buildAdMarkup builds the AdM from results[0]. A native response takes +// precedence; otherwise it is returned as a banner (injecting a video tag when +// vastxml is present). +func buildAdMarkup(adResult *adgResult, locationParams *adgLocationParams, imp *openrtb2.Imp) (openrtb_ext.BidType, string, error) { + // Native: assumes the native object returned by the backend is compatible + // with an OpenRTB native response ({"native": {...assets, link, + // imptrackers...}}). Like Prebid.js (isNative), it is treated as native only + // when assets is non-empty. + if len(adResult.Native) > 0 && imp.Native != nil && hasNativeAssets(adResult.Native) { + // AdM is the JSON string of the OpenRTB native admarkup. Whether the + // backend returns {"native": {...}} or the assets at the top level, it is + // normalized to {"native":{...}} and beaconurl is appended to imptrackers + // (matching Prebid.js createNativeAd, which pushes beaconurl onto + // impressionTrackers). + admBytes, err := wrapNativeAdm(adResult.Native, adResult.Beaconurl) if err != nil { - return nil, []error{&errortypes.BadServerResponse{ - Message: err.Error(), - }, + return "", "", err + } + return openrtb_ext.BidTypeNative, string(admBytes), nil + } + + // Banner / Video-in-Banner + ad := adResult.Ad + if adResult.Vastxml != "" { + // Prebid.js injects the ADGBrowserM tag when + // location_params.option.ad_type === "upper_billboard"; otherwise it uses + // the APV tag. + if isUpperBillboard(locationParams) { + ad = wrapWithADGBrowserM(adResult.Vastxml, extractMarginTop(imp)) + } else { + ad = wrapWithAPV(imp.ID, adResult.Vastxml) + } + } + ad = appendChildToBody(ad, adResult.Beacon) + if unwrapped := removeWrapper(ad); unwrapped != "" { + ad = unwrapped + } + return openrtb_ext.BidTypeBanner, ad, nil +} + +// hasNativeAssets reports whether the raw JSON of results[0].native contains at +// least one entry in assets[]. This matches Prebid.js isNative() +// (adResult.native.assets.length > 0) and accepts both the {"native":{...}} and +// top-level {assets:...} shapes. +func hasNativeAssets(raw json.RawMessage) bool { + var top map[string]json.RawMessage + if err := jsonutil.Unmarshal(raw, &top); err != nil { + return false + } + var assets json.RawMessage + if inner, ok := top["native"]; ok { + var nat map[string]json.RawMessage + if err := jsonutil.Unmarshal(inner, &nat); err != nil { + return false + } + assets = nat["assets"] + } else { + assets = top["assets"] + } + if len(assets) == 0 { + return false + } + var arr []json.RawMessage + if err := jsonutil.Unmarshal(assets, &arr); err != nil { + return false + } + return len(arr) > 0 +} + +// wrapNativeAdm wraps the raw JSON of results[0].native for use as AdM and +// appends beaconUrl to native.imptrackers. It absorbs both the case where the +// backend already returns {"native":{...}} and the case where it returns the +// assets at the top level. +func wrapNativeAdm(raw json.RawMessage, beaconUrl string) ([]byte, error) { + var top map[string]json.RawMessage + if err := jsonutil.Unmarshal(raw, &top); err != nil { + return nil, err + } + var native map[string]json.RawMessage + if inner, ok := top["native"]; ok { + if err := jsonutil.Unmarshal(inner, &native); err != nil { + return nil, err + } + } else { + native = top + } + + if beaconUrl != "" { + var trackers []string + if rawTrackers, ok := native["imptrackers"]; ok { + if err := jsonutil.Unmarshal(rawTrackers, &trackers); err != nil { + return nil, err } } - if adgExt.Id == bidResp.Locationid { - impId = v.ID - bitType = openrtb_ext.BidTypeBanner - adm = createAd(&bidResp, impId) - bid := openrtb2.Bid{ - ID: bidResp.Locationid, - ImpID: impId, - AdM: adm, - Price: bidResp.Cpm, - W: int64(bidResp.W), - H: int64(bidResp.H), - CrID: bidResp.Creativeid, - DealID: bidResp.Dealid, + duplicate := false + for _, t := range trackers { + if t == beaconUrl { + duplicate = true + break } - - bidResponse.Bids = append(bidResponse.Bids, &adapters.TypedBid{ - Bid: &bid, - BidType: bitType, - }) - bidResponse.Currency = adg.getCurrency(internalRequest) - return bidResponse, nil } + if !duplicate { + trackers = append(trackers, beaconUrl) + encoded, err := json.Marshal(trackers) + if err != nil { + return nil, err + } + native["imptrackers"] = encoded + } + } + + nativeBytes, err := json.Marshal(native) + if err != nil { + return nil, err } - return nil, nil + return []byte(`{"native":` + string(nativeBytes) + `}`), nil } -func createAd(body *adgServerResponse, impId string) string { - ad := body.Ad - if body.Vastxml != "" { - ad = "
" + insertVASTMethod(impId, body.Vastxml) + "" +func isUpperBillboard(p *adgLocationParams) bool { + if p == nil || p.Option == nil { + return false } - ad = appendChildToBody(ad, body.Beacon) - unwrappedAd := removeWrapper(ad) - if unwrappedAd != "" { - return unwrappedAd + return p.Option.AdType == "upper_billboard" +} + +// encodeVastForJS percent-encodes VAST XML (every non-unreserved byte becomes +// %XX) so that the JS side can restore it with decodeURIComponent. If the adm +// contains a raw "= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~' { + b.WriteByte(c) + } else { + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} + +func wrapWithAPV(impID, vastxml string) string { + rep := regexp.MustCompile(`\r?\n`) + replaced := rep.ReplaceAllString(vastxml, "") + return "
" + + "" + + "" + + "" +} + +func wrapWithADGBrowserM(vastxml, marginTop string) string { + // Prebid.js passes bidder params.marginTop to ADGBrowserM.init({marginTop}). + // In Prebid Server it lives at imp.ext.bidder.marginTop + // (ExtImpAdgeneration.MarginTop). When unset it defaults to '0', same as + // Prebid.js. + if marginTop == "" { + marginTop = "0" } - return ad + rep := regexp.MustCompile(`\r?\n`) + replaced := rep.ReplaceAllString(vastxml, "") + return "" + + "" + + "" + + "" } -func insertVASTMethod(bidId string, vastxml string) string { - rep := regexp.MustCompile(`/\r?\n/g`) - var replacedVastxml = rep.ReplaceAllString(vastxml, "") - return "" +// extractMarginTop extracts imp.ext.bidder.marginTop. It returns an empty string on failure. +func extractMarginTop(imp *openrtb2.Imp) string { + if imp == nil || len(imp.Ext) == 0 { + return "" + } + adgExt, err := unmarshalExtImpAdgeneration(imp) + if err != nil { + return "" + } + return adgExt.MarginTop } func appendChildToBody(ad string, data string) string { @@ -279,16 +530,16 @@ func removeWrapper(ad string) string { if bodyIndex == -1 || lastBodyIndex == -1 { return "" } - str := strings.TrimSpace(strings.Replace(strings.Replace(ad[bodyIndex:lastBodyIndex], "", "", 1), "", "", 1)) return str } // Builder builds a new instance of the Adgeneration adapter for the given bidder with the given config. func Builder(bidderName openrtb_ext.BidderName, config config.Adapter, server config.Server) (adapters.Bidder, error) { - bidder := &AdgenerationAdapter{ + bidder := &adapter{ config.Endpoint, - "1.0.3", + // Aligned with Prebid.js v1.6.6 (ADGENE_PREBID_VERSION); managed as the shared ADG protocol version. + "1.6.6", "JPY", } return bidder, nil diff --git a/adapters/adgeneration/adgeneration_test.go b/adapters/adgeneration/adgeneration_test.go index 14161750c7a..68db2a32303 100644 --- a/adapters/adgeneration/adgeneration_test.go +++ b/adapters/adgeneration/adgeneration_test.go @@ -2,272 +2,661 @@ package adgeneration import ( "encoding/json" + "net/http" + "net/url" + "strings" "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" ) -func TestJsonSamples(t *testing.T) { - bidder, buildErr := Builder(openrtb_ext.BidderAdgeneration, config.Adapter{ - Endpoint: "https://d.socdm.com/adsv/v1"}, config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"}) +const testEndpoint = "https://d.socdm.com/adgen/prebid" - if buildErr != nil { - t.Fatalf("Builder returned unexpected error %v", buildErr) +func newTestAdapter(t *testing.T) *adapter { + t.Helper() + bidder, err := Builder(openrtb_ext.BidderAdgeneration, config.Adapter{Endpoint: testEndpoint}, + config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"}) + if err != nil { + t.Fatalf("Builder returned unexpected error: %v", err) } - - adapterstest.RunJSONBidderTest(t, "adgenerationtest", bidder) + return bidder.(*adapter) } -func TestGetRequestUri(t *testing.T) { - bidder, buildErr := Builder(openrtb_ext.BidderAdgeneration, config.Adapter{ - Endpoint: "https://d.socdm.com/adsv/v1"}, config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"}) - - if buildErr != nil { - t.Fatalf("Builder returned unexpected error %v", buildErr) +func TestJsonSamples(t *testing.T) { + bidder, err := Builder(openrtb_ext.BidderAdgeneration, config.Adapter{Endpoint: testEndpoint}, + config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"}) + if err != nil { + t.Fatalf("Builder returned unexpected error: %v", err) } + adapterstest.RunJSONBidderTest(t, "adgenerationtest", bidder) +} - bidderAdgeneration, _ := bidder.(*AdgenerationAdapter) - - // Test items - failedRequest := &openrtb2.BidRequest{ - ID: "test-failed-bid-request", +func TestBuildRequestPostsToAdgenPrebid(t *testing.T) { + adg := newTestAdapter(t) + req := &openrtb2.BidRequest{ + ID: "test", Imp: []openrtb2.Imp{ - {ID: "extImpBidder-failed-test", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, Ext: json.RawMessage(`{{ "id": "58278" }}`)}, - {ID: "extImpBidder-failed-test", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, Ext: json.RawMessage(`{"_bidder": { "id": "58278" }}`)}, - {ID: "extImpAdgeneration-failed-test", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, Ext: json.RawMessage(`{"bidder": { "_id": "58278" }}`)}, + { + ID: "imp-1", + Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, + Ext: json.RawMessage(`{"bidder":{"id":"58278"}}`), + }, }, - Source: &openrtb2.Source{TID: "SourceTID"}, - Device: &openrtb2.Device{UA: "testUA", IP: "testIP"}, + Source: &openrtb2.Source{TID: "src-tid"}, + Device: &openrtb2.Device{UA: "testUA", IP: "1.2.3.4"}, Site: &openrtb2.Site{Page: "https://supership.com"}, User: &openrtb2.User{BuyerUID: "buyerID"}, } - successRequest := &openrtb2.BidRequest{ - ID: "test-success-bid-request", + + requests, errs := adg.MakeRequests(req, &adapters.ExtraRequestInfo{}) + assert.Empty(t, errs) + assert.Len(t, requests, 1) + + r := requests[0] + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, []string{"imp-1"}, r.ImpIDs) + assert.Equal(t, "testUA", r.Headers.Get("User-Agent")) + assert.Equal(t, "1.2.3.4", r.Headers.Get("X-Forwarded-For")) + + parsed, err := url.Parse(r.Uri) + assert.NoError(t, err) + assert.Equal(t, "d.socdm.com", parsed.Host) + assert.Equal(t, "/adgen/prebid", parsed.Path) + q := parsed.Query() + assert.Equal(t, "58278", q.Get("id")) + assert.Equal(t, "SSPLOC", q.Get("posall")) + assert.Equal(t, "0", q.Get("sdktype")) + // Parity check: the following query params, which the old upstream sent, must not be sent. + for _, key := range []string{"hb", "t", "currency", "sdkname", "adapterver", "sizes", "tp", "transactionid", "appbundle", "appname", "idfa", "advertising_id"} { + assert.False(t, q.Has(key), "query %q should not be set", key) + } + + var body adgRequestBody + assert.NoError(t, json.Unmarshal(r.Body, &body)) + assert.Equal(t, "JPY", body.Currency) + assert.Equal(t, "prebidserver", body.Sdkname) + assert.Equal(t, "1.6.6", body.Adapterver) + assert.Equal(t, 1, body.Imark, "banner request should set imark=1") + assert.NotEmpty(t, body.Pbver) + assert.Len(t, body.Ortb.Imp, 1) + assert.Equal(t, "imp-1", body.Ortb.Imp[0].ID) + assert.Equal(t, "https://supership.com", body.Ortb.Site.Page) + assert.Equal(t, "src-tid", body.Ortb.Source.TID) +} + +func TestBuildRequestForNativeOmitsImark(t *testing.T) { + adg := newTestAdapter(t) + req := &openrtb2.BidRequest{ + ID: "test", Imp: []openrtb2.Imp{ - {ID: "bidRequest-success-test", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, Ext: json.RawMessage(`{"bidder": { "id": "58278" }}`)}, + { + ID: "imp-native", + Native: &openrtb2.Native{Request: `{}`}, + Ext: json.RawMessage(`{"bidder":{"id":"58278"}}`), + }, }, - Source: &openrtb2.Source{TID: "SourceTID"}, - Device: &openrtb2.Device{UA: "testUA", IP: "testIP"}, - Site: &openrtb2.Site{Page: "https://supership.com"}, - User: &openrtb2.User{BuyerUID: "buyerID"}, } + requests, errs := adg.MakeRequests(req, &adapters.ExtraRequestInfo{}) + assert.Empty(t, errs) + assert.Len(t, requests, 1) - numRequests := len(failedRequest.Imp) - for index := 0; index < numRequests; index++ { - httpRequests, err := bidderAdgeneration.getRequestUri(failedRequest, index) - if err == nil { - t.Errorf("getRequestUri: %v did not throw an error", failedRequest.Imp[index]) - } - if httpRequests != "" { - t.Errorf("getRequestUri: %v did return Request: %s", failedRequest.Imp[index], httpRequests) - } - } - numRequests = len(successRequest.Imp) - for index := 0; index < numRequests; index++ { - // getRawQuery Test. - adgExt, err := unmarshalExtImpAdgeneration(&successRequest.Imp[index]) - if err != nil { - t.Errorf("unmarshalExtImpAdgeneration: %v did throw an error: %v", successRequest.Imp[index], err) - } - rawQuery := bidderAdgeneration.getRawQuery(adgExt.Id, successRequest, &successRequest.Imp[index]) - expectQueries := map[string]string{ - "posall": "SSPLOC", - "id": adgExt.Id, - "sdktype": "0", - "hb": "true", - "currency": bidderAdgeneration.getCurrency(successRequest), - "sdkname": "prebidserver", - "adapterver": bidderAdgeneration.version, - "sizes": getSizes(&successRequest.Imp[index]), - "tp": successRequest.Site.Page, - "transactionid": successRequest.Source.TID, - } - for key, expectedValue := range expectQueries { - actualValue := rawQuery.Get(key) - if actualValue != expectedValue { - t.Errorf("getRawQuery: %s value does not match expected %s, actual %s", key, expectedValue, actualValue) - } - } + var body adgRequestBody + assert.NoError(t, json.Unmarshal(requests[0].Body, &body)) + assert.Equal(t, 0, body.Imark, "native request must not set imark") +} - // RequestUri Test. - actualUri, err := bidderAdgeneration.getRequestUri(successRequest, index) - if err != nil { - t.Errorf("getRequestUri: %v did throw an error: %v", successRequest.Imp[index], err) - } - expectedUri := "https://d.socdm.com/adsv/v1?adapterver=" + bidderAdgeneration.version + "¤cy=JPY&hb=true&id=58278&posall=SSPLOC&sdkname=prebidserver&sdktype=0&sizes=300x250&t=json3&tp=https%3A%2F%2Fsupership.com&transactionid=SourceTID" - if actualUri != expectedUri { - t.Errorf("getRequestUri: does not match expected %s, actual %s", expectedUri, actualUri) - } +func TestBuildRequestRejectsBadExt(t *testing.T) { + adg := newTestAdapter(t) + req := &openrtb2.BidRequest{ + ID: "test", + Imp: []openrtb2.Imp{ + {ID: "imp-bad", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, Ext: json.RawMessage(`{"bidder":{"_id":"58278"}}`)}, + {ID: "imp-ok", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, Ext: json.RawMessage(`{"bidder":{"id":"58278"}}`)}, + }, } + requests, errs := adg.MakeRequests(req, &adapters.ExtraRequestInfo{}) + assert.Len(t, errs, 1) + assert.Len(t, requests, 1, "valid imp should still produce a request") } -func TestGetSizes(t *testing.T) { - // Test items - var request *openrtb2.Imp - var size string - multiFormatBanner := &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}, {W: 320, H: 50}}} - noFormatBanner := &openrtb2.Banner{Format: []openrtb2.Format{}} - nativeFormat := &openrtb2.Native{} - - request = &openrtb2.Imp{Banner: multiFormatBanner} - size = getSizes(request) - if size != "300x250,320x50" { - t.Errorf("%v does not match size.", multiFormatBanner) - } - request = &openrtb2.Imp{Banner: noFormatBanner} - size = getSizes(request) - if size != "" { - t.Errorf("%v does not match size.", noFormatBanner) +// TestDetectSdkType covers deriving sdktype from channel + device.os, on the +// assumption that the backend `/adgen/prebid` switches delivery logic on sdktype. +func TestDetectSdkType(t *testing.T) { + cases := []struct { + name string + req *openrtb2.BidRequest + want string + }{ + { + name: "web: channel=pbjs / site only", + req: &openrtb2.BidRequest{ + Ext: json.RawMessage(`{"prebid":{"channel":{"name":"pbjs","version":"9"}}}`), + Site: &openrtb2.Site{Page: "https://example.com/"}, + }, + want: "0", + }, + { + name: "web: channel=amp", + req: &openrtb2.BidRequest{ + Ext: json.RawMessage(`{"prebid":{"channel":{"name":"amp"}}}`), + Site: &openrtb2.Site{Page: "https://example.com/"}, + }, + want: "0", + }, + { + name: "mobile app android via channel", + req: &openrtb2.BidRequest{ + Ext: json.RawMessage(`{"prebid":{"channel":{"name":"app"}}}`), + App: &openrtb2.App{Bundle: "com.example.app"}, + Device: &openrtb2.Device{OS: "android"}, + }, + want: "1", + }, + { + name: "mobile app ios via channel (case insensitive)", + req: &openrtb2.BidRequest{ + Ext: json.RawMessage(`{"prebid":{"channel":{"name":"APP"}}}`), + App: &openrtb2.App{Bundle: "com.example.app"}, + Device: &openrtb2.Device{OS: "iOS"}, + }, + want: "2", + }, + { + name: "fallback: no channel, BidRequest.App only (android)", + req: &openrtb2.BidRequest{ + App: &openrtb2.App{Bundle: "com.example.app"}, + Device: &openrtb2.Device{OS: "android"}, + }, + want: "1", + }, + { + name: "fallback: no channel, BidRequest.App only (ios)", + req: &openrtb2.BidRequest{ + App: &openrtb2.App{Bundle: "com.example.app"}, + Device: &openrtb2.Device{OS: "ios"}, + }, + want: "2", + }, + { + name: "app context but unknown device.os -> 0", + req: &openrtb2.BidRequest{ + Ext: json.RawMessage(`{"prebid":{"channel":{"name":"app"}}}`), + App: &openrtb2.App{Bundle: "com.example.app"}, + Device: &openrtb2.Device{OS: "tvos"}, + }, + want: "0", + }, + { + name: "both app and site nil -> treated as web", + req: &openrtb2.BidRequest{}, + want: "0", + }, + { + name: "channel wins when both app and site present (channel=app)", + req: &openrtb2.BidRequest{ + Ext: json.RawMessage(`{"prebid":{"channel":{"name":"app"}}}`), + App: &openrtb2.App{Bundle: "com.example.app"}, + Site: &openrtb2.Site{Page: "https://example.com/"}, + Device: &openrtb2.Device{OS: "android"}, + }, + want: "1", + }, + { + name: "malformed ext is treated as no channel (= web when site present)", + req: &openrtb2.BidRequest{ + Ext: json.RawMessage(`{not-json`), + Site: &openrtb2.Site{Page: "https://example.com/"}, + }, + want: "0", + }, + { + name: "ext.prebid present but no channel (= web when site present)", + req: &openrtb2.BidRequest{ + Ext: json.RawMessage(`{"prebid":{}}`), + Site: &openrtb2.Site{Page: "https://example.com/"}, + }, + want: "0", + }, } - request = &openrtb2.Imp{Native: nativeFormat} - size = getSizes(request) - if size != "" { - t.Errorf("%v does not match size.", nativeFormat) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, detectSdkType(c.req)) + }) } } +// TestGetCurrency covers the same either/or behavior as Prebid.js +// (adgenerationBidAdapter.js: getCurrencyType): USD if USD is present, otherwise JPY. func TestGetCurrency(t *testing.T) { - bidder, buildErr := Builder(openrtb_ext.BidderAdgeneration, config.Adapter{ - Endpoint: "https://d.socdm.com/adsv/v1"}, config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"}) - - if buildErr != nil { - t.Fatalf("Builder returned unexpected error %v", buildErr) + adg := newTestAdapter(t) + cases := []struct { + name string + cur []string + want string + }{ + {"default JPY when empty", nil, "JPY"}, + {"USD wins over JPY", []string{"USD", "JPY"}, "USD"}, + {"USD only", []string{"USD"}, "USD"}, + {"unrelated currency falls back to JPY", []string{"EUR"}, "JPY"}, + {"JPY only", []string{"JPY"}, "JPY"}, + {"case-insensitive usd", []string{"usd"}, "USD"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := adg.getCurrency(&openrtb2.BidRequest{Cur: c.cur}) + assert.Equal(t, c.want, got) + }) } +} - bidderAdgeneration, _ := bidder.(*AdgenerationAdapter) +func TestBuildAdMarkupBanner(t *testing.T) { + adResult := &adgResult{ + Ad: "
", + Beacon: "", + Beaconurl: "https://b.example/", + Cpm: 50, + } + imp := &openrtb2.Imp{ID: "imp-1", Banner: &openrtb2.Banner{}} - // Test items - var request *openrtb2.BidRequest - var currency string - innerDefaultCur := []string{"USD", "JPY"} - usdCur := []string{"USD", "EUR"} + bidType, adm, err := buildAdMarkup(adResult, nil, imp) + assert.NoError(t, err) + assert.Equal(t, openrtb_ext.BidTypeBanner, bidType) + assert.Equal(t, "
", adm) +} - request = &openrtb2.BidRequest{Cur: innerDefaultCur} - currency = bidderAdgeneration.getCurrency(request) - if currency != "JPY" { - t.Errorf("%v does not match currency.", innerDefaultCur) +func TestBuildAdMarkupVastUsesAPV(t *testing.T) { + adResult := &adgResult{ + Ad: "", + Beacon: "", + Vastxml: "", } - request = &openrtb2.BidRequest{Cur: usdCur} - currency = bidderAdgeneration.getCurrency(request) - if currency != "USD" { - t.Errorf("%v does not match currency.", usdCur) + imp := &openrtb2.Imp{ID: "imp-vast", Banner: &openrtb2.Banner{}} + bidType, adm, err := buildAdMarkup(adResult, nil, imp) + assert.NoError(t, err) + assert.Equal(t, openrtb_ext.BidTypeBanner, bidType) + assert.Contains(t, adm, "apvad-imp-vast") + assert.Contains(t, adm, "cdn.apvdr.com/js/VideoAd.min.js") +} + +// Newlines contained in vastxml must not remain inside the JS string literal (equivalent to Prebid.js: /\r?\n/g). +func TestBuildAdMarkupVastStripsNewlinesInsideJsLiteral(t *testing.T) { + adResult := &adgResult{ + Ad: "", + Vastxml: "\r\nfoo\nbar\n", } + imp := &openrtb2.Imp{ID: "imp-vast", Banner: &openrtb2.Banner{}} + _, adm, err := buildAdMarkup(adResult, nil, imp) + assert.NoError(t, err) + // A newline left inside the APV.VideoAd(...).load('...') argument would break the JS string. + assert.NotContains(t, adm, "load('\r\nfoo") + assert.NotContains(t, adm, "load('\nfoo") + // VAST is percent-encoded and restored with decodeURIComponent + // (a raw "") } -func TestCreateAd(t *testing.T) { - // Test items - adgBannerImpId := "test-banner-imp" - adgBannerResponse := adgServerResponse{ - Ad: "\n\n\n\n\n
\n\n
\n\n", - Beacon: "", - Beaconurl: "https://dummy-beacon.com", - Cpm: 50, - Creativeid: "DummyDsp_SdkTeam_supership.jp", - H: 300, - W: 250, - Ttl: 10, - LandingUrl: "", - Scheduleid: "111111", +func TestBuildAdMarkupADGBrowserMStripsNewlines(t *testing.T) { + adResult := &adgResult{ + Ad: "", + Vastxml: "\r\nfoo\n", } - matchBannerTag := "
\n\n
\n" - - adgVastImpId := "test-vast-imp" - adgVastResponse := adgServerResponse{ - Ad: "\n\n\n\n\n
\n\n
\n\n", - Beacon: "", - Beaconurl: "https://dummy-beacon.com", - Cpm: 50, - Creativeid: "DummyDsp_SdkTeam_supership.jp", - H: 300, - W: 250, - Ttl: 10, - LandingUrl: "", - Vastxml: "", - Scheduleid: "111111", + loc := &adgLocationParams{Option: &adgLocationOption{AdType: "upper_billboard"}} + imp := &openrtb2.Imp{ID: "imp-ub", Banner: &openrtb2.Banner{}} + _, adm, err := buildAdMarkup(adResult, loc, imp) + assert.NoError(t, err) + assert.NotContains(t, adm, "vastXml: '\r\nfoo") + // VAST is percent-encoded and restored with decodeURIComponent + // (a raw "") +} + +func TestBuildAdMarkupVastUsesADGBrowserMOnUpperBillboard(t *testing.T) { + adResult := &adgResult{ + Ad: "", + Beacon: "", + Vastxml: "", } - matchVastTag := "
" + loc := &adgLocationParams{Option: &adgLocationOption{AdType: "upper_billboard"}} + imp := &openrtb2.Imp{ID: "imp-ub", Banner: &openrtb2.Banner{}} + bidType, adm, err := buildAdMarkup(adResult, loc, imp) + assert.NoError(t, err) + assert.Equal(t, openrtb_ext.BidTypeBanner, bidType) + assert.Contains(t, adm, "adg-browser-m.js") + assert.NotContains(t, adm, "apvad-") + // When marginTop is unset, fill in '0' just like Prebid.js. + assert.Contains(t, adm, "marginTop: '0'") +} - bannerAd := createAd(&adgBannerResponse, adgBannerImpId) - if bannerAd != matchBannerTag { - t.Errorf("%v does not match createAd.", adgBannerResponse) +func TestBuildAdMarkupVastADGBrowserMUsesBidderMarginTop(t *testing.T) { + adResult := &adgResult{ + Ad: "", + Vastxml: "", } - vastAd := createAd(&adgVastResponse, adgVastImpId) - if vastAd != matchVastTag { - t.Errorf("%v does not match createAd.", adgVastResponse) + loc := &adgLocationParams{Option: &adgLocationOption{AdType: "upper_billboard"}} + imp := &openrtb2.Imp{ + ID: "imp-ub", + Banner: &openrtb2.Banner{}, + Ext: json.RawMessage(`{"bidder":{"id":"58278","marginTop":"42"}}`), } + _, adm, err := buildAdMarkup(adResult, loc, imp) + assert.NoError(t, err) + assert.Contains(t, adm, "marginTop: '42'") } -func TestMakeBids(t *testing.T) { - bidder, buildErr := Builder(openrtb_ext.BidderAdgeneration, config.Adapter{ - Endpoint: "https://d.socdm.com/adsv/v1"}, config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"}) +func TestBuildAdMarkupNative(t *testing.T) { + rawNative := json.RawMessage(`{"assets":[{"id":1,"title":{"text":"hello"}}],"link":{"url":"https://l.example/"}}`) + adResult := &adgResult{Native: rawNative} + imp := &openrtb2.Imp{ID: "imp-native", Native: &openrtb2.Native{Request: `{}`}} + + bidType, adm, err := buildAdMarkup(adResult, nil, imp) + assert.NoError(t, err) + assert.Equal(t, openrtb_ext.BidTypeNative, bidType) + assert.True(t, strings.HasPrefix(adm, `{"native":`)) + assert.Contains(t, adm, `"assets"`) +} + +func TestBuildAdMarkupNativeAppendsBeaconUrlToImptrackers(t *testing.T) { + rawNative := json.RawMessage(`{"assets":[{"id":1,"title":{"text":"hello"}}],"link":{"url":"https://l.example/"},"imptrackers":["https://existing.example/imp"]}`) + adResult := &adgResult{Native: rawNative, Beaconurl: "https://tg.example/bc"} + imp := &openrtb2.Imp{ID: "imp-native", Native: &openrtb2.Native{Request: `{}`}} + + _, adm, err := buildAdMarkup(adResult, nil, imp) + assert.NoError(t, err) + assert.Contains(t, adm, "https://existing.example/imp") + assert.Contains(t, adm, "https://tg.example/bc", "beaconurl must be appended to imptrackers") +} - if buildErr != nil { - t.Fatalf("Builder returned unexpected error %v", buildErr) +func TestBuildAdMarkupNativeBeaconUrlDeduplicated(t *testing.T) { + rawNative := json.RawMessage(`{"assets":[{"id":1,"title":{"text":"hi"}}],"imptrackers":["https://tg.example/bc"]}`) + adResult := &adgResult{Native: rawNative, Beaconurl: "https://tg.example/bc"} + imp := &openrtb2.Imp{ID: "imp-native", Native: &openrtb2.Native{Request: `{}`}} + + _, adm, err := buildAdMarkup(adResult, nil, imp) + assert.NoError(t, err) + // Do not add a duplicate when it is already present in imptrackers. + assert.Equal(t, 1, strings.Count(adm, "https://tg.example/bc")) +} + +// Prebid.js isNative() compatible: when assets is empty/missing, treat it as banner rather than native. +func TestBuildAdMarkupFallsBackToBannerWhenNativeAssetsMissing(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"empty assets", `{"assets":[],"link":{"url":"https://l.example/"}}`}, + {"no assets key", `{"link":{"url":"https://l.example/"}}`}, + {"wrapped empty assets", `{"native":{"assets":[]}}`}, } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + adResult := &adgResult{ + Native: json.RawMessage(c.raw), + Ad: "fallback banner", + } + imp := &openrtb2.Imp{ID: "imp-native", Native: &openrtb2.Native{Request: `{}`}} + bidType, adm, err := buildAdMarkup(adResult, nil, imp) + assert.NoError(t, err) + assert.Equal(t, openrtb_ext.BidTypeBanner, bidType) + assert.Equal(t, "fallback banner", adm) + }) + } +} - bidderAdgeneration, _ := bidder.(*AdgenerationAdapter) +func TestBuildAdMarkupNativeAcceptsWrappedInput(t *testing.T) { + rawNative := json.RawMessage(`{"native":{"assets":[{"id":1,"title":{"text":"hi"}}]}}`) + adResult := &adgResult{Native: rawNative, Beaconurl: "https://tg.example/bc"} + imp := &openrtb2.Imp{ID: "imp-native", Native: &openrtb2.Native{Request: `{}`}} + + _, adm, err := buildAdMarkup(adResult, nil, imp) + assert.NoError(t, err) + assert.True(t, strings.HasPrefix(adm, `{"native":`)) + // Do not double-wrap a nested native (= "native" appears only once in the output). + assert.Equal(t, 1, strings.Count(adm, `"native"`)) + assert.Contains(t, adm, "https://tg.example/bc") +} + +func TestMakeBidsReadsResultsAndAdomain(t *testing.T) { + adg := newTestAdapter(t) internalRequest := &openrtb2.BidRequest{ - ID: "test-success-bid-request", + ID: "test", Imp: []openrtb2.Imp{ - {ID: "bidRequest-success-test", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, Ext: json.RawMessage(`{"bidder": { "id": "58278" }}`)}, + {ID: "imp-1", Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}}}, Ext: json.RawMessage(`{"bidder":{"id":"58278"}}`)}, }, - Device: &openrtb2.Device{UA: "testUA", IP: "testIP"}, - Site: &openrtb2.Site{Page: "https://supership.com"}, - User: &openrtb2.User{BuyerUID: "buyerID"}, } - externalRequest := adapters.RequestData{} - response := adapters.ResponseData{ - StatusCode: 200, - Body: ([]byte)("{\n \"ad\": \"testAd\",\n \"cpm\": 30,\n \"creativeid\": \"Dummy_supership.jp\",\n \"h\": 250,\n \"locationid\": \"58278\",\n \"results\": [{}],\n \"dealid\": \"test-deal-id\",\n \"w\": 300\n }"), + respBody := `{ + "locationid": "58278", + "results": [{ + "ad": "testAd", + "beacon": "", + "cpm": 30, + "creativeid": "Dummy_supership.jp", + "dealid": "test-deal", + "h": 250, + "w": 300, + "adomain": ["advertiser.example"] + }] + }` + resp := &adapters.ResponseData{StatusCode: 200, Body: []byte(respBody)} + + sentBody, _ := json.Marshal(adgRequestBody{Ortb: openrtb2.BidRequest{Imp: []openrtb2.Imp{{ID: "imp-1"}}}}) + bidderResp, errs := adg.MakeBids(internalRequest, &adapters.RequestData{Body: sentBody}, resp) + assert.Empty(t, errs) + assert.NotNil(t, bidderResp) + assert.Equal(t, "JPY", bidderResp.Currency) + assert.Len(t, bidderResp.Bids, 1) + + bid := bidderResp.Bids[0] + assert.Equal(t, openrtb_ext.BidTypeBanner, bid.BidType) + assert.Equal(t, "58278", bid.Bid.ID) + assert.Equal(t, "imp-1", bid.Bid.ImpID) + assert.Equal(t, "testAd", bid.Bid.AdM) + assert.Equal(t, 30.0, bid.Bid.Price) + assert.Equal(t, int64(300), bid.Bid.W) + assert.Equal(t, int64(250), bid.Bid.H) + assert.Equal(t, "Dummy_supership.jp", bid.Bid.CrID) + assert.Equal(t, "test-deal", bid.Bid.DealID) + assert.Equal(t, []string{"advertiser.example"}, bid.Bid.ADomain) +} + +func TestMakeBidsReturnsNilOnNoContent(t *testing.T) { + adg := newTestAdapter(t) + resp := &adapters.ResponseData{StatusCode: http.StatusNoContent} + bidderResp, errs := adg.MakeBids(&openrtb2.BidRequest{}, &adapters.RequestData{}, resp) + assert.Nil(t, bidderResp) + assert.Empty(t, errs) +} + +func TestMakeBidsReturnsErrorOn400(t *testing.T) { + adg := newTestAdapter(t) + resp := &adapters.ResponseData{StatusCode: http.StatusBadRequest} + bidderResp, errs := adg.MakeBids(&openrtb2.BidRequest{}, &adapters.RequestData{}, resp) + assert.Nil(t, bidderResp) + assert.Len(t, errs, 1) + assert.IsType(t, &errortypes.BadInput{}, errs[0]) +} + +func TestMakeRequestsReturnsErrorWhenNoImp(t *testing.T) { + adg := newTestAdapter(t) + requests, errs := adg.MakeRequests(&openrtb2.BidRequest{ID: "test"}, &adapters.ExtraRequestInfo{}) + assert.Nil(t, requests) + assert.Len(t, errs, 1) + assert.IsType(t, &errortypes.BadInput{}, errs[0]) +} + +// Covers each error branch of unmarshalExtImpAdgeneration. +func TestUnmarshalExtImpAdgenerationErrors(t *testing.T) { + cases := []struct { + name string + ext json.RawMessage + wantMsg string // empty means any error is acceptable + }{ + {"invalid imp.ext JSON", json.RawMessage(`not-json`), ""}, + {"bidder is not an object", json.RawMessage(`{"bidder":"not-an-object"}`), ""}, + {"id is empty string", json.RawMessage(`{"bidder":{"id":""}}`), "No Location ID in ExtImpAdgeneration."}, + {"id key missing", json.RawMessage(`{"bidder":{"marginTop":"10"}}`), "No Location ID in ExtImpAdgeneration."}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + imp := &openrtb2.Imp{ID: "imp-x", Ext: c.ext} + adgExt, err := unmarshalExtImpAdgeneration(imp) + assert.Nil(t, adgExt) + assert.Error(t, err) + if c.wantMsg != "" { + assert.Equal(t, c.wantMsg, err.Error()) + } + }) } - // default Currency InternalRequest - defaultCurBidderResponse, errs := bidder.MakeBids(internalRequest, &externalRequest, &response) - if len(errs) > 0 { - t.Errorf("MakeBids return errors. errors: %v", errs) +} + +// hasNativeAssets: covers wrapped/unwrapped detection plus invalid input (Prebid.js isNative compatible). +func TestHasNativeAssets(t *testing.T) { + cases := []struct { + name string + raw string + want bool + }{ + {"unwrapped with assets", `{"assets":[{"id":1}]}`, true}, + {"wrapped with assets", `{"native":{"assets":[{"id":1}]}}`, true}, + {"empty assets array", `{"assets":[]}`, false}, + {"no assets key", `{"link":{"url":"https://l.example/"}}`, false}, + {"invalid JSON", `not-json`, false}, + {"wrapped native is not an object", `{"native":123}`, false}, + {"assets is not an array", `{"assets":"foo"}`, false}, + {"wrapped assets is not an array", `{"native":{"assets":"foo"}}`, false}, } - checkBidResponse(t, defaultCurBidderResponse, bidderAdgeneration.defaultCurrency) - - // Specified Currency InternalRequest - usdCur := "USD" - internalRequest.Cur = []string{usdCur} - specifiedCurBidderResponse, errs := bidder.MakeBids(internalRequest, &externalRequest, &response) - if len(errs) > 0 { - t.Errorf("MakeBids return errors. errors: %v", errs) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, hasNativeAssets(json.RawMessage(c.raw))) + }) } - checkBidResponse(t, specifiedCurBidderResponse, usdCur) +} +// Covers the error branches of wrapNativeAdm (unmarshal failures for top/native/imptrackers). +func TestWrapNativeAdmErrors(t *testing.T) { + // top-level unmarshal failure + _, err := wrapNativeAdm(json.RawMessage(`not-json`), "") + assert.Error(t, err) + // wrapped native is not an object + _, err = wrapNativeAdm(json.RawMessage(`{"native":123}`), "") + assert.Error(t, err) + // imptrackers is not a string array (only reached when appending beaconUrl) + _, err = wrapNativeAdm(json.RawMessage(`{"assets":[{"id":1}],"imptrackers":"not-array"}`), "https://b.example/bc") + assert.Error(t, err) } -func checkBidResponse(t *testing.T, bidderResponse *adapters.BidderResponse, expectedCurrency string) { - if bidderResponse == nil { - t.Errorf("actual bidResponse is nil.") +// buildAdMarkup: the path where wrapNativeAdm returns an error while assembling the native adm. +func TestBuildAdMarkupNativeWrapError(t *testing.T) { + adResult := &adgResult{ + Native: json.RawMessage(`{"assets":[{"id":1}],"imptrackers":"not-array"}`), + Beaconurl: "https://b.example/bc", } + imp := &openrtb2.Imp{ID: "imp-native", Native: &openrtb2.Native{Request: `{}`}} + _, _, err := buildAdMarkup(adResult, nil, imp) + assert.Error(t, err) +} + +// removeWrapper: an ad without is returned as-is (not unwrapped). +func TestBuildAdMarkupBannerWithoutBodyTags(t *testing.T) { + adResult := &adgResult{Ad: "plain-ad-no-body"} + imp := &openrtb2.Imp{ID: "imp-1", Banner: &openrtb2.Banner{}} + bidType, adm, err := buildAdMarkup(adResult, nil, imp) + assert.NoError(t, err) + assert.Equal(t, openrtb_ext.BidTypeBanner, bidType) + assert.Equal(t, "plain-ad-no-body", adm) +} + +// extractMarginTop: even with a malformed imp.ext, marginTop is treated as empty (defaults to '0'). +func TestBuildAdMarkupUpperBillboardHandlesBadExt(t *testing.T) { + adResult := &adgResult{Ad: "", Vastxml: ""} + loc := &adgLocationParams{Option: &adgLocationOption{AdType: "upper_billboard"}} + imp := &openrtb2.Imp{ID: "imp-ub", Banner: &openrtb2.Banner{}, Ext: json.RawMessage(`not-json`)} + _, adm, err := buildAdMarkup(adResult, loc, imp) + assert.NoError(t, err) + assert.Contains(t, adm, "marginTop: '0'") +} - // AdM is assured by TestCreateAd and JSON tests - var expectedAdM string = "testAd" - var expectedID string = "58278" - var expectedImpID = "bidRequest-success-test" - var expectedPrice float64 = 30.0 - var expectedW int64 = 300 - var expectedH int64 = 250 - var expectedCrID string = "Dummy_supership.jp" - var extectedDealID string = "test-deal-id" - - //nolint: staticcheck // false positive SA5011: possible nil pointer dereference - assert.Equal(t, expectedCurrency, bidderResponse.Currency) - assert.Equal(t, 1, len(bidderResponse.Bids)) - assert.Equal(t, expectedID, bidderResponse.Bids[0].Bid.ID) - assert.Equal(t, expectedImpID, bidderResponse.Bids[0].Bid.ImpID) - assert.Equal(t, expectedAdM, bidderResponse.Bids[0].Bid.AdM) - assert.Equal(t, expectedPrice, bidderResponse.Bids[0].Bid.Price) - assert.Equal(t, expectedW, bidderResponse.Bids[0].Bid.W) - assert.Equal(t, expectedH, bidderResponse.Bids[0].Bid.H) - assert.Equal(t, expectedCrID, bidderResponse.Bids[0].Bid.CrID) - assert.Equal(t, extectedDealID, bidderResponse.Bids[0].Bid.DealID) +// MakeBids: a 500 returns BadServerResponse. +func TestMakeBidsReturnsServerErrorOn500(t *testing.T) { + adg := newTestAdapter(t) + resp := &adapters.ResponseData{StatusCode: http.StatusInternalServerError} + bidderResp, errs := adg.MakeBids(&openrtb2.BidRequest{}, &adapters.RequestData{}, resp) + assert.Nil(t, bidderResp) + assert.Len(t, errs, 1) + assert.IsType(t, &errortypes.BadServerResponse{}, errs[0]) +} + +// MakeBids: a 200 with an invalid JSON body returns an error. +func TestMakeBidsReturnsErrorOnInvalidBody(t *testing.T) { + adg := newTestAdapter(t) + resp := &adapters.ResponseData{StatusCode: http.StatusOK, Body: []byte(`not-json`)} + bidderResp, errs := adg.MakeBids(&openrtb2.BidRequest{}, &adapters.RequestData{Body: []byte(`{}`)}, resp) + assert.Nil(t, bidderResp) + assert.Len(t, errs, 1) +} + +// MakeBids: covers the guard branches around externalRequest / sentBody. +func TestMakeBidsExternalRequestGuards(t *testing.T) { + adg := newTestAdapter(t) + internal := &openrtb2.BidRequest{ + Imp: []openrtb2.Imp{{ID: "imp-1", Banner: &openrtb2.Banner{}, Ext: json.RawMessage(`{"bidder":{"id":"58278"}}`)}}, + } + goodResp := func() *adapters.ResponseData { + return &adapters.ResponseData{ + StatusCode: http.StatusOK, + Body: []byte(`{"locationid":"58278","results":[{"ad":"x","cpm":1}]}`), + } + } + + t.Run("externalRequest is nil", func(t *testing.T) { + bidderResp, errs := adg.MakeBids(internal, nil, goodResp()) + assert.Nil(t, bidderResp) + assert.Empty(t, errs) + }) + t.Run("externalRequest.Body is empty", func(t *testing.T) { + bidderResp, errs := adg.MakeBids(internal, &adapters.RequestData{}, goodResp()) + assert.Nil(t, bidderResp) + assert.Empty(t, errs) + }) + t.Run("sentBody is invalid JSON", func(t *testing.T) { + bidderResp, errs := adg.MakeBids(internal, &adapters.RequestData{Body: []byte(`not-json`)}, goodResp()) + assert.Nil(t, bidderResp) + assert.Len(t, errs, 1) + }) + t.Run("sentBody.Ortb has no imp", func(t *testing.T) { + sentBody, _ := json.Marshal(adgRequestBody{}) + bidderResp, errs := adg.MakeBids(internal, &adapters.RequestData{Body: sentBody}, goodResp()) + assert.Nil(t, bidderResp) + assert.Empty(t, errs) + }) + t.Run("sentBody imp ID not found in internalRequest", func(t *testing.T) { + sentBody, _ := json.Marshal(adgRequestBody{Ortb: openrtb2.BidRequest{Imp: []openrtb2.Imp{{ID: "no-such-imp"}}}}) + bidderResp, errs := adg.MakeBids(internal, &adapters.RequestData{Body: sentBody}, goodResp()) + assert.Nil(t, bidderResp) + assert.Empty(t, errs) + }) +} + +// MakeBids: returns an error when assembling the native adm fails (via buildAdMarkup). +func TestMakeBidsReturnsErrorWhenNativeAdmWrapFails(t *testing.T) { + adg := newTestAdapter(t) + internal := &openrtb2.BidRequest{ + Imp: []openrtb2.Imp{{ID: "imp-1", Native: &openrtb2.Native{Request: `{}`}, Ext: json.RawMessage(`{"bidder":{"id":"58278"}}`)}}, + } + resp := &adapters.ResponseData{ + StatusCode: http.StatusOK, + Body: []byte(`{"locationid":"58278","results":[{"native":{"assets":[{"id":1}],"imptrackers":"not-array"},"beaconurl":"https://b.example/bc","cpm":10}]}`), + } + sentBody, _ := json.Marshal(adgRequestBody{Ortb: openrtb2.BidRequest{Imp: []openrtb2.Imp{{ID: "imp-1"}}}}) + bidderResp, errs := adg.MakeBids(internal, &adapters.RequestData{Body: sentBody}, resp) + assert.Nil(t, bidderResp) + assert.Len(t, errs, 1) } diff --git a/adapters/adgeneration/adgenerationtest/exemplary/single-banner-android.json b/adapters/adgeneration/adgenerationtest/exemplary/single-banner-android.json index 1753abc7127..aaff417599c 100644 --- a/adapters/adgeneration/adgenerationtest/exemplary/single-banner-android.json +++ b/adapters/adgeneration/adgenerationtest/exemplary/single-banner-android.json @@ -1,5 +1,5 @@ { - "mockBidRequest":{ + "mockBidRequest": { "id": "some-request-id", "site": { "page": "http://example.com/test.html" @@ -35,33 +35,49 @@ }, "httpCalls": [ { - "internalRequest": { - "id": "some-request-id", - "site": { - "page": "http://example.com/test.html" - }, - "imp": [ - { - "id": "some-impression-id", - "banner": { - "format": [ - { - "w": 300, - "h": 250 + "expectedRequest": { + "uri": "https://d.socdm.com/adgen/prebid?id=58278&posall=SSPLOC&sdktype=1", + "body": { + "currency": "JPY", + "pbver": "unknown", + "sdkname": "prebidserver", + "adapterver": "1.6.6", + "ortb": { + "id": "some-request-id", + "imp": [ + { + "id": "some-impression-id", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "ext": { + "bidder": { + "id": "58278" + } } - ] - }, - "ext": { - "bidder": { - "id": "58278" } - } - } - ], - "tmax": 500 - }, - "expectedRequest":{ - "uri": "https://d.socdm.com/adsv/v1?adapterver=1.0.3&advertising_id=advertising_id&appname=adgneration¤cy=JPY&hb=true&id=58278&posall=SSPLOC&sdkname=prebidserver&sdktype=1&sizes=300x250&t=json3&tp=http%3A%2F%2Fexample.com%2Ftest.html", + ], + "site": { + "page": "http://example.com/test.html" + }, + "app": { + "name": "adgneration" + }, + "device": { + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36", + "ip": "0.0.0.0", + "os": "android", + "ifa": "advertising_id" + }, + "tmax": 500 + }, + "imark": 1 + }, "headers": { "Accept": [ "application/json" @@ -76,92 +92,49 @@ "0.0.0.0" ] }, - "impIDs":["some-impression-id"] + "impIDs": ["some-impression-id"] }, - "mockResponse":{ + "mockResponse": { "status": 200, "body": { - "ad": "\n \n \n