Repository: prebid/prebid-server
Pull request #1553 (2020-10-22 22:46:01)

Add Accounts to http cache events
I just added Accounts alongside Requests and Imps in the events for minimal impact change.

We should consider replacing the event with a more generic `{dataType, data}` message in the future though, to support other data types through the same mechanism.

laurb9: test coverage before
> ok  	github.com/prebid/prebid-server/stored_requests/events	0.018s	coverage: 92.9% of statements
> ok  	github.com/prebid/prebid-server/stored_requests/events/http	0.075s	coverage: 93.2% of statements
after
> ok  	github.com/prebid/prebid-server/stored_requests/events	0.130s	coverage: 93.8% of statements
> ok  	github.com/prebid/prebid-server/stored_requests/events/http	0.041s	coverage: 93.2% of statements


Patch:
diff --git a/config/config.go b/config/config.go
index b2187f75..c08031c4 100755
--- a/config/config.go
+++ b/config/config.go
@@ -730,7 +730,7 @@ func (cfg *Configuration) setDerivedDefaults() {
 	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderGumGum, "https://rtb.gumgum.com/usync/prbds2s?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&r="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dgumgum%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D")
 	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderImprovedigital, "https://ad.360yield.com/server_match?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&r="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dimprovedigital%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%7BPUB_USER_ID%7D")
 	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderIx, "https://ssum.casalemedia.com/usermatchredir?s=184932&cb="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dix%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D")
-	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderInvibes, "https://u2.videostep.com/User/getLid?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&redirectUri="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dinvibes%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%24UID")
+	// openrtb_ext.BidderInvibes doesn't have a good default.
 	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderKrushmedia, "https://cs.krushmedia.com/4e4abdd5ecc661643458a730b1aa927d.gif?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&redir="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dkrushmedia%26uid%3D%5BUID%5D")
 	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderLifestreet, "https://ads.lfstmedia.com/idsync/137062?synced=1&ttl=1s&rurl="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dlifestreet%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%24%24visitor_cookie%24%24")
 	setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderLockerDome, "https://lockerdome.com/usync/prebidserver?pid="+cfg.Adapters["lockerdome"].PlatformID+"&gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&us_privacy={{.USPrivacy}}&redirect="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dlockerdome%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%7B%7Buid%7D%7D")
diff --git a/exchange/exchange.go b/exchange/exchange.go
index 5c424769..c1acc0d5 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -238,34 +238,19 @@ func recordImpMetrics(bidRequest *openrtb.BidRequest, metricsEngine pbsmetrics.M
 	}
 }
 
-type DealTierInfo struct {
-	Prefix      string `json:"prefix"`
-	MinDealTier int    `json:"minDealTier"`
-}
-
-type DealTier struct {
-	Info *DealTierInfo `json:"dealTier,omitempty"`
-}
-
-type BidderDealTier struct {
-	DealInfo map[string]*DealTier
-}
-
 // applyDealSupport updates targeting keys with deal prefixes if minimum deal tier exceeded
 func applyDealSupport(bidRequest *openrtb.BidRequest, auc *auction, bidCategory map[string]string) []error {
 	errs := []error{}
 	impDealMap := getDealTiers(bidRequest)
 
 	for impID, topBidsPerImp := range auc.winningBidsByBidder {
-		impDeal := impDealMap[impID].DealInfo
+		impDeal := impDealMap[impID]
 		for bidder, topBidPerBidder := range topBidsPerImp {
-			bidderString := bidder.String()
-
 			if topBidPerBidder.dealPriority > 0 {
-				if validateAndNormalizeDealTier(impDeal[bidderString]) {
-					updateHbPbCatDur(topBidPerBidder, impDeal[bidderString].Info, bidCategory)
+				if validateDealTier(impDeal[bidder]) {
+					updateHbPbCatDur(topBidPerBidder, impDeal[bidder], bidCategory)
 				} else {
-					errs = append(errs, fmt.Errorf("dealTier configuration invalid for bidder '%s', imp ID '%s'", bidderString, impID))
+					errs = append(errs, fmt.Errorf("dealTier configuration invalid for bidder '%s', imp ID '%s'", string(bidder), impID))
 				}
 			}
 		}
@@ -275,34 +260,27 @@ func applyDealSupport(bidRequest *openrtb.BidRequest, auc *auction, bidCategory
 }
 
 // getDealTiers creates map of impression to bidder deal tier configuration
-func getDealTiers(bidRequest *openrtb.BidRequest) map[string]*BidderDealTier {
-	impDealMap := make(map[string]*BidderDealTier)
+func getDealTiers(bidRequest *openrtb.BidRequest) map[string]openrtb_ext.DealTierBidderMap {
+	impDealMap := make(map[string]openrtb_ext.DealTierBidderMap)
 
 	for _, imp := range bidRequest.Imp {
-		var bidderDealTier BidderDealTier
-		err := json.Unmarshal(imp.Ext, &bidderDealTier.DealInfo)
+		dealTierBidderMap, err := openrtb_ext.ReadDealTiersFromImp(imp)
 		if err != nil {
 			continue
 		}
-
-		impDealMap[imp.ID] = &bidderDealTier
+		impDealMap[imp.ID] = dealTierBidderMap
 	}
 
 	return impDealMap
 }
 
-func validateAndNormalizeDealTier(impDeal *DealTier) bool {
-	if impDeal == nil || impDeal.Info == nil {
-		return false
-	}
-	// Remove whitespace from prefix before checking if it can be used
-	impDeal.Info.Prefix = strings.ReplaceAll(impDeal.Info.Prefix, " ", "")
-	return len(impDeal.Info.Prefix) > 0 && impDeal.Info.MinDealTier > 0
+func validateDealTier(dealTier openrtb_ext.DealTier) bool {
+	return len(dealTier.Prefix) > 0 && dealTier.MinDealTier > 0
 }
 
-func updateHbPbCatDur(bid *pbsOrtbBid, dealTierInfo *DealTierInfo, bidCategory map[string]string) {
-	if bid.dealPriority >= dealTierInfo.MinDealTier {
-		prefixTier := fmt.Sprintf("%s%d_", dealTierInfo.Prefix, bid.dealPriority)
+func updateHbPbCatDur(bid *pbsOrtbBid, dealTier openrtb_ext.DealTier, bidCategory map[string]string) {
+	if bid.dealPriority >= dealTier.MinDealTier {
+		prefixTier := fmt.Sprintf("%s%d_", dealTier.Prefix, bid.dealPriority)
 
 		if oldCatDur, ok := bidCategory[bid.bid.ID]; ok {
 			oldCatDurSplit := strings.SplitAfterN(oldCatDur, "_", 2)
diff --git a/openrtb_ext/deal_tier.go b/openrtb_ext/deal_tier.go
new file mode 100644
index 00000000..8583c053
--- /dev/null
+++ b/openrtb_ext/deal_tier.go
@@ -0,0 +1,61 @@
+package openrtb_ext
+
+import (
+	"encoding/json"
+
+	"github.com/mxmCherry/openrtb"
+)
+
+// DealTier defines the configuration of a deal tier.
+type DealTier struct {
+	// Prefix specifies the beginning of the hb_pb_cat_dur targeting key value. Must be non-empty.
+	Prefix string `json:"prefix"`
+
+	// MinDealTier specifies the minimum deal priority value (inclusive) that must be met for the targeting
+	// key value to be modified. Must be greater than 0.
+	MinDealTier int `json:"minDealTier"`
+}
+
+// DealTierBidderMap defines a correlation between bidders and deal tiers.
+type DealTierBidderMap map[BidderName]DealTier
+
+// ReadDealTiersFromImp returns a map of bidder deal tiers read from the impression of an original request (not split / cleaned).
+func ReadDealTiersFromImp(imp openrtb.Imp) (DealTierBidderMap, error) {
+	dealTiers := make(DealTierBidderMap)
+
+	if len(imp.Ext) == 0 {
+		return dealTiers, nil
+	}
+
+	// imp.ext.{bidder}
+	var impExt map[string]struct {
+		DealTier *DealTier `json:"dealTier"`
+	}
+	if err := json.Unmarshal(imp.Ext, &impExt); err != nil {
+		return nil, err
+	}
+	for bidder, param := range impExt {
+		if param.DealTier != nil {
+			dealTiers[BidderName(bidder)] = *param.DealTier
+		}
+	}
+
+	// imp.ext.prebid.{bidder}
+	var impPrebidExt struct {
+		Prebid struct {
+			Bidders map[string]struct {
+				DealTier *DealTier `json:"dealTier"`
+			} `json:"bidder"`
+		} `json:"prebid"`
+	}
+	if err := json.Unmarshal(imp.Ext, &impPrebidExt); err != nil {
+		return nil, err
+	}
+	for bidder, param := range impPrebidExt.Prebid.Bidders {
+		if param.DealTier != nil {
+			dealTiers[BidderName(bidder)] = *param.DealTier
+		}
+	}
+
+	return dealTiers, nil
+}
diff --git a/openrtb_ext/imp.go b/openrtb_ext/imp.go
index 0e8f224b..670087ad 100644
--- a/openrtb_ext/imp.go
+++ b/openrtb_ext/imp.go
@@ -4,29 +4,15 @@ import (
 	"encoding/json"
 )
 
-// ExtImp defines the contract for bidrequest.imp[i].ext
-type ExtImp struct {
-	Prebid     *ExtImpPrebid     `json:"prebid"`
-	Appnexus   *ExtImpAppnexus   `json:"appnexus"`
-	Consumable *ExtImpConsumable `json:"consumable"`
-	Rubicon    *ExtImpRubicon    `json:"rubicon"`
-	Adform     *ExtImpAdform     `json:"adform"`
-	Rhythmone  *ExtImpRhythmone  `json:"rhythmone"`
-	Unruly     *ExtImpUnruly     `json:"unruly"`
-	EmxDigital *ExtImpEmxDigital `json:"emx_digital"`
-}
-
 // ExtImpPrebid defines the contract for bidrequest.imp[i].ext.prebid
 type ExtImpPrebid struct {
+	// StoredRequest specifies which stored impression to use, if any.
 	StoredRequest *ExtStoredRequest `json:"storedrequest"`
 
-	// Rewarded inventory signal, can be 0 or 1
+	// IsRewardedInventory is a signal intended for video impressions. Must be 0 or 1.
 	IsRewardedInventory int8 `json:"is_rewarded_inventory"`
 
-	// NOTE: This is not part of the official API, we are not expecting clients
-	// migrate from imp[...].ext.${BIDDER} to imp[...].ext.prebid.bidder.${BIDDER}
-	// at this time
-	// https://github.com/prebid/prebid-server/pull/846#issuecomment-476352224
+	// Bidder is the preferred approach for providing paramters to be interepreted by the bidder's adapter.
 	Bidder map[string]json.RawMessage `json:"bidder"`
 }
 
diff --git a/stored_requests/events/events.go b/stored_requests/events/events.go
index ba08f13d..5b899435 100644
--- a/stored_requests/events/events.go
+++ b/stored_requests/events/events.go
@@ -11,12 +11,14 @@ import (
 type Save struct {
 	Requests map[string]json.RawMessage `json:"requests"`
 	Imps     map[string]json.RawMessage `json:"imps"`
+	Accounts map[string]json.RawMessage `json:"accounts"`
 }
 
 // Invalidation represents a bulk invalidation
 type Invalidation struct {
 	Requests []string `json:"requests"`
 	Imps     []string `json:"imps"`
+	Accounts []string `json:"accounts"`
 }
 
 // EventProducer will produce cache update and invalidation events on its channels
@@ -63,12 +65,14 @@ func (e *EventListener) Listen(cache stored_requests.Cache, events EventProducer
 		case save := <-events.Saves():
 			cache.Requests.Save(context.Background(), save.Requests)
 			cache.Imps.Save(context.Background(), save.Imps)
+			cache.Accounts.Save(context.Background(), save.Accounts)
 			if e.onSave != nil {
 				e.onSave()
 			}
 		case invalidation := <-events.Invalidations():
 			cache.Requests.Invalidate(context.Background(), invalidation.Requests)
 			cache.Imps.Invalidate(context.Background(), invalidation.Imps)
+			cache.Accounts.Invalidate(context.Background(), invalidation.Accounts)
 			if e.onInvalidate != nil {
 				e.onInvalidate()
 			}
diff --git a/stored_requests/events/http/http.go b/stored_requests/events/http/http.go
index a6a129ee..4615183f 100644
--- a/stored_requests/events/http/http.go
+++ b/stored_requests/events/http/http.go
@@ -42,6 +42,13 @@ import (
 //     "imp2": { ... stored data for imp2 ... },
 //   }
 // }
+// or
+// {
+//   "accounts": {
+//     "acc1": { ... config data for acc1 ... },
+//     "acc2": { ... config data for acc2 ... },
+//   },
+// }
 //
 // To signal deletions, the endpoint may return { "deleted": true }
 // in place of the Stored Data if the "last-modified" param existed.
@@ -82,10 +89,11 @@ func (e *HTTPEvents) fetchAll() {
 	defer cancel()
 	resp, err := ctxhttp.Get(ctx, e.client, e.Endpoint)
 	if respObj, ok := e.parse(e.Endpoint, resp, err); ok &&
-		(len(respObj.StoredRequests) > 0 || len(respObj.StoredImps) > 0) {
+		(len(respObj.StoredRequests) > 0 || len(respObj.StoredImps) > 0 || len(respObj.Accounts) > 0) {
 		e.saves <- events.Save{
 			Requests: respObj.StoredRequests,
 			Imps:     respObj.StoredImps,
+			Accounts: respObj.Accounts,
 		}
 	}
 }
@@ -125,14 +133,16 @@ func (e *HTTPEvents) refresh(ticker <-chan time.Time) {
 				invalidations := events.Invalidation{
 					Requests: extractInvalidations(respObj.StoredRequests),
 					Imps:     extractInvalidations(respObj.StoredImps),
+					Accounts: extractInvalidations(respObj.Accounts),
 				}
-				if len(respObj.StoredRequests) > 0 || len(respObj.StoredImps) > 0 {
+				if len(respObj.StoredRequests) > 0 || len(respObj.StoredImps) > 0 || len(respObj.Accounts) > 0 {
 					e.saves <- events.Save{
 						Requests: respObj.StoredRequests,
 						Imps:     respObj.StoredImps,
+						Accounts: respObj.Accounts,
 					}
 				}
-				if len(invalidations.Requests) > 0 || len(invalidations.Imps) > 0 {
+				if len(invalidations.Requests) > 0 || len(invalidations.Imps) > 0 || len(invalidations.Accounts) > 0 {
 					e.invalidations <- invalidations
 				}
 				e.lastUpdate = thisTimeInUTC
@@ -193,4 +203,5 @@ func (e *HTTPEvents) Invalidations() <-chan events.Invalidation {
 type responseContract struct {
 	StoredRequests map[string]json.RawMessage `json:"requests"`
 	StoredImps     map[string]json.RawMessage `json:"imps"`
+	Accounts       map[string]json.RawMessage `json:"accounts"`
 }


Test patch:
diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go
index ee7e27d0..09d1af91 100644
--- a/exchange/exchange_test.go
+++ b/exchange/exchange_test.go
@@ -2278,119 +2278,91 @@ func TestApplyDealSupport(t *testing.T) {
 
 func TestGetDealTiers(t *testing.T) {
 	testCases := []struct {
-		impExt       json.RawMessage
-		bidderResult map[string]bool // true indicates bidder had valid config, false indicates invalid
+		description string
+		request     openrtb.BidRequest
+		expected    map[string]openrtb_ext.DealTierBidderMap
 	}{
 		{
-			impExt: json.RawMessage(`{"validbase": {"dealTier": {"minDealTier": 5, "prefix": "tier"}, "placementId": 10433394}}`),
-			bidderResult: map[string]bool{
-				"validbase": true,
+			description: "None",
+			request: openrtb.BidRequest{
+				Imp: []openrtb.Imp{},
 			},
+			expected: map[string]openrtb_ext.DealTierBidderMap{},
 		},
 		{
-			impExt: json.RawMessage(`{"validmultiple1": {"dealTier": {"minDealTier": 5, "prefix": "tier"}, "placementId": 10433394}, "validmultiple2": {"dealTier": {"minDealTier": 5, "prefix": "tier"}, "placementId": 10433394}}`),
-			bidderResult: map[string]bool{
-				"validmultiple1": true,
-				"validmultiple2": true,
+			description: "One",
+			request: openrtb.BidRequest{
+				Imp: []openrtb.Imp{
+					{ID: "imp1", Ext: json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "tier"}}}`)},
+				},
+			},
+			expected: map[string]openrtb_ext.DealTierBidderMap{
+				"imp1": {openrtb_ext.BidderAppnexus: {Prefix: "tier", MinDealTier: 5}},
 			},
 		},
 		{
-			impExt: json.RawMessage(`{"nodealtier": {"placementId": 10433394}}`),
-			bidderResult: map[string]bool{
-				"nodealtier": false,
+			description: "Many",
+			request: openrtb.BidRequest{
+				Imp: []openrtb.Imp{
+					{ID: "imp1", Ext: json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "tier1"}}}`)},
+					{ID: "imp2", Ext: json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 8, "prefix": "tier2"}}}`)},
+				},
+			},
+			expected: map[string]openrtb_ext.DealTierBidderMap{
+				"imp1": {openrtb_ext.BidderAppnexus: {Prefix: "tier1", MinDealTier: 5}},
+				"imp2": {openrtb_ext.BidderAppnexus: {Prefix: "tier2", MinDealTier: 8}},
 			},
 		},
 		{
-			impExt: json.RawMessage(`{"validbase": {"placementId": 10433394}, "onedealTier2": {"dealTier": {"minDealTier": 5, "prefix": "tier"}, "placementId": 10433394}}`),
-			bidderResult: map[string]bool{
-				"onedealTier2": true,
-				"validbase":    false,
+			description: "Many - Skips Malformed",
+			request: openrtb.BidRequest{
+				Imp: []openrtb.Imp{
+					{ID: "imp1", Ext: json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "tier1"}}}`)},
+					{ID: "imp2", Ext: json.RawMessage(`{"appnexus": {"dealTier": "wrong type"}}`)},
+				},
+			},
+			expected: map[string]openrtb_ext.DealTierBidderMap{
+				"imp1": {openrtb_ext.BidderAppnexus: {Prefix: "tier1", MinDealTier: 5}},
 			},
 		},
 	}
 
-	filledDealTier := DealTier{
-		Info: &DealTierInfo{
-			Prefix:      "tier",
-			MinDealTier: 5,
-		},
-	}
-	emptyDealTier := DealTier{}
-
 	for _, test := range testCases {
-		bidRequest := &openrtb.BidRequest{
-			ID: "some-request-id",
-			Imp: []openrtb.Imp{
-				{
-					ID:  "imp_id1",
-					Ext: test.impExt,
-				},
-			},
-		}
-
-		impDealMap := getDealTiers(bidRequest)
-
-		for bidder, valid := range test.bidderResult {
-			if valid {
-				assert.Equal(t, &filledDealTier, impDealMap["imp_id1"].DealInfo[bidder], "DealTier should be filled with config data")
-			} else {
-				assert.Equal(t, &emptyDealTier, impDealMap["imp_id1"].DealInfo[bidder], "DealTier should be empty")
-			}
-		}
+		result := getDealTiers(&test.request)
+		assert.Equal(t, test.expected, result, test.description)
 	}
 }
 
-func TestValidateAndNormalizeDealTier(t *testing.T) {
+func TestValidateDealTier(t *testing.T) {
 	testCases := []struct {
 		description    string
-		params         json.RawMessage
+		dealTier       openrtb_ext.DealTier
 		expectedResult bool
 	}{
 		{
-			description:    "BidderDealTier should be valid",
-			params:         json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "tier"}, "placementId": 10433394}}`),
+			description:    "Valid",
+			dealTier:       openrtb_ext.DealTier{Prefix: "prefix", MinDealTier: 5},
 			expectedResult: true,
 		},
 		{
-			description:    "BidderDealTier should be invalid due to empty prefix",
-			params:         json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": ""}, "placementId": 10433394}}`),
+			description:    "Invalid - Empty",
+			dealTier:       openrtb_ext.DealTier{},
 			expectedResult: false,
 		},
 		{
-			description:    "BidderDealTier should be invalid due to empty dealTier",
-			params:         json.RawMessage(`{"appnexus": {"dealTier": {}, "placementId": 10433394}}`),
+			description:    "Invalid - Empty Prefix",
+			dealTier:       openrtb_ext.DealTier{MinDealTier: 5},
 			expectedResult: false,
 		},
 		{
-			description:    "BidderDealTier should be invalid due to missing minDealTier",
-			params:         json.RawMessage(`{"appnexus": {"dealTier": {"prefix": "tier"}, "placementId": 10433394}}`),
+			description:    "Invalid - Empty Deal Tier",
+			dealTier:       openrtb_ext.DealTier{Prefix: "prefix"},
 			expectedResult: false,
 		},
-		{
-			description:    "BidderDealTier should be invalid due to missing dealTier",
-			params:         json.RawMessage(`{"appnexus": {"placementId": 10433394}}`),
-			expectedResult: false,
-		},
-		{
-			description:    "BidderDealTier should be invalid due to prefix containing all whitespace",
-			params:         json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "    "}, "placementId": 10433394}}`),
-			expectedResult: false,
-		},
-		{
-			description:    "BidderDealTier should be valid after removing whitespace",
-			params:         json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "    prefixwith  sp aces "}, "placementId": 10433394}}`),
-			expectedResult: true,
-		},
 	}
 
 	for _, test := range testCases {
-		var bidderDealTier BidderDealTier
-		err := json.Unmarshal(test.params, &bidderDealTier.DealInfo)
-		if err != nil {
-			assert.Fail(t, "Unable to unmarshal JSON data for testing BidderDealTier")
-		}
-
-		assert.Equal(t, test.expectedResult, validateAndNormalizeDealTier(bidderDealTier.DealInfo["appnexus"]), test.description)
+		assert.Equal(t, test.expectedResult, validateDealTier(test.dealTier), test.description)
 	}
 }
 
@@ -2398,7 +2370,7 @@ func TestUpdateHbPbCatDur(t *testing.T) {
 	testCases := []struct {
 		description        string
 		targ               map[string]string
-		dealTier           *DealTierInfo
+		dealTier           openrtb_ext.DealTier
 		dealPriority       int
 		expectedHbPbCatDur string
 	}{
@@ -2408,7 +2380,7 @@ func TestUpdateHbPbCatDur(t *testing.T) {
 				"hb_pb":         "12.00",
 				"hb_pb_cat_dur": "12.00_movies_30s",
 			},
-			dealTier: &DealTierInfo{
+			dealTier: openrtb_ext.DealTier{
 				Prefix:      "tier",
 				MinDealTier: 5,
 			},
@@ -2421,7 +2393,7 @@ func TestUpdateHbPbCatDur(t *testing.T) {
 				"hb_pb":         "12.00",
 				"hb_pb_cat_dur": "12.00_auto_30s",
 			},
-			dealTier: &DealTierInfo{
+			dealTier: openrtb_ext.DealTier{
 				Prefix:      "tier",
 				MinDealTier: 10,
 			},
@@ -2434,7 +2406,7 @@ func TestUpdateHbPbCatDur(t *testing.T) {
 				"hb_pb":         "12.00",
 				"hb_pb_cat_dur": "12.00_medicine_30s",
 			},
-			dealTier: &DealTierInfo{
+			dealTier: openrtb_ext.DealTier{
 				Prefix:      "tier",
 				MinDealTier: 1,
 			},
diff --git a/openrtb_ext/deal_tier_test.go b/openrtb_ext/deal_tier_test.go
new file mode 100644
index 00000000..1bb4d564
--- /dev/null
+++ b/openrtb_ext/deal_tier_test.go
@@ -0,0 +1,98 @@
+package openrtb_ext
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mxmCherry/openrtb"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestReadDealTiersFromImp(t *testing.T) {
+	testCases := []struct {
+		description    string
+		impExt         json.RawMessage
+		expectedResult DealTierBidderMap
+		expectedError  string
+	}{
+		{
+			description:    "Nil",
+			impExt:         nil,
+			expectedResult: DealTierBidderMap{},
+		},
+		{
+			description:    "None",
+			impExt:         json.RawMessage(``),
+			expectedResult: DealTierBidderMap{},
+		},
+		{
+			description:    "Empty Object",
+			impExt:         json.RawMessage(`{}`),
+			expectedResult: DealTierBidderMap{},
+		},
+		{
+			description:    "imp.ext - with other params",
+			impExt:         json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "anyPrefix"}, "placementId": 12345}}`),
+			expectedResult: DealTierBidderMap{BidderAppnexus: {Prefix: "anyPrefix", MinDealTier: 5}},
+		},
+		{
+			description:    "imp.ext - multiple",
+			impExt:         json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "appnexusPrefix"}, "placementId": 12345}, "rubicon": {"dealTier": {"minDealTier": 8, "prefix": "rubiconPrefix"}, "placementId": 12345}}`),
+			expectedResult: DealTierBidderMap{BidderAppnexus: {Prefix: "appnexusPrefix", MinDealTier: 5}, BidderRubicon: {Prefix: "rubiconPrefix", MinDealTier: 8}},
+		},
+		{
+			description:    "imp.ext - no deal tier",
+			impExt:         json.RawMessage(`{"appnexus": {"placementId": 12345}}`),
+			expectedResult: DealTierBidderMap{},
+		},
+		{
+			description:   "imp.ext - error",
+			impExt:        json.RawMessage(`{"appnexus": {"dealTier": "wrong type", "placementId": 12345}}`),
+			expectedError: "json: cannot unmarshal string into Go struct field .dealTier of type openrtb_ext.DealTier",
+		},
+		{
+			description:    "imp.ext.prebid",
+			impExt:         json.RawMessage(`{"prebid": {"bidder": {"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "anyPrefix"}, "placementId": 12345}}}}`),
+			expectedResult: DealTierBidderMap{BidderAppnexus: {Prefix: "anyPrefix", MinDealTier: 5}},
+		},
+		{
+			description:    "imp.ext.prebid- multiple",
+			impExt:         json.RawMessage(`{"prebid": {"bidder": {"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "appnexusPrefix"}, "placementId": 12345}, "rubicon": {"dealTier": {"minDealTier": 8, "prefix": "rubiconPrefix"}, "placementId": 12345}}}}`),
+			expectedResult: DealTierBidderMap{BidderAppnexus: {Prefix: "appnexusPrefix", MinDealTier: 5}, BidderRubicon: {Prefix: "rubiconPrefix", MinDealTier: 8}},
+		},
+		{
+			description:    "imp.ext.prebid - no deal tier",
+			impExt:         json.RawMessage(`{"prebid": {"bidder": {"appnexus": {"placementId": 12345}}}}`),
+			expectedResult: DealTierBidderMap{},
+		},
+		{
+			description:   "imp.ext.prebid - error",
+			impExt:        json.RawMessage(`{"prebid": {"bidder": {"appnexus": {"dealTier": "wrong type", "placementId": 12345}}}}`),
+			expectedError: "json: cannot unmarshal string into Go struct field .prebid.bidder.dealTier of type openrtb_ext.DealTier",
+		},
+		{
+			description:    "imp.ext.prebid wins over imp.ext",
+			impExt:         json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "impExt"}, "placementId": 12345}, "prebid": {"bidder": {"appnexus": {"dealTier": {"minDealTier": 8, "prefix": "impExtPrebid"}, "placementId": 12345}}}}`),
+			expectedResult: DealTierBidderMap{BidderAppnexus: {Prefix: "impExtPrebid", MinDealTier: 8}},
+		},
+		{
+			description:    "imp.ext.prebid coexists with imp.ext",
+			impExt:         json.RawMessage(`{"appnexus": {"dealTier": {"minDealTier": 5, "prefix": "impExt"}, "placementId": 12345}, "prebid": {"bidder": {"rubicon": {"dealTier": {"minDealTier": 8, "prefix": "impExtPrebid"}, "placementId": 12345}}}}`),
+			expectedResult: DealTierBidderMap{BidderAppnexus: {Prefix: "impExt", MinDealTier: 5}, BidderRubicon: {Prefix: "impExtPrebid", MinDealTier: 8}},
+		},
+	}
+
+	for _, test := range testCases {
+		imp := openrtb.Imp{Ext: test.impExt}
+
+		result, err := ReadDealTiersFromImp(imp)
+
+		assert.Equal(t, test.expectedResult, result, test.description+":result")
+
+		if len(test.expectedError) == 0 {
+			assert.NoError(t, err, test.description+":error")
+		} else {
+			assert.EqualError(t, err, test.expectedError, test.description+":error")
+		}
+	}
+}
diff --git a/stored_requests/events/api/api_test.go b/stored_requests/events/api/api_test.go
index d2db4557..cd3af77b 100644
--- a/stored_requests/events/api/api_test.go
+++ b/stored_requests/events/api/api_test.go
@@ -16,8 +16,9 @@ import (
 
 func TestGoodRequests(t *testing.T) {
 	cache := stored_requests.Cache{
-		Requests: memory.NewCache(256*1024, -1, "Requests"),
-		Imps:     memory.NewCache(256*1024, -1, "Imps"),
+		Requests: memory.NewCache(256*1024, -1, "Request"),
+		Imps:     memory.NewCache(256*1024, -1, "Imp"),
+		Accounts: memory.NewCache(256*1024, -1, "Account"),
 	}
 	id := "1"
 	config := fmt.Sprintf(`{"id": "%s"}`, id)
diff --git a/stored_requests/events/events_test.go b/stored_requests/events/events_test.go
index 74263d14..f3483705 100644
--- a/stored_requests/events/events_test.go
+++ b/stored_requests/events/events_test.go
@@ -19,6 +19,7 @@ func TestListen(t *testing.T) {
 	cache := stored_requests.Cache{
 		Requests: memory.NewCache(256*1024, -1, "Requests"),
 		Imps:     memory.NewCache(256*1024, -1, "Imps"),
+		Accounts: memory.NewCache(256*1024, -1, "Account"),
 	}
 
 	// create channels to synchronize
@@ -39,15 +40,18 @@ func TestListen(t *testing.T) {
 	save := Save{
 		Requests: data,
 		Imps:     data,
+		Accounts: data,
 	}
 	cache.Requests.Save(context.Background(), save.Requests)
-	cache.Requests.Save(context.Background(), save.Imps)
+	cache.Imps.Save(context.Background(), save.Imps)
+	cache.Accounts.Save(context.Background(), save.Accounts)
 
 	config = fmt.Sprintf(`{"id": "%s", "updated": true}`, id)
 	data = map[string]json.RawMessage{id: json.RawMessage(config)}
 	save = Save{
 		Requests: data,
 		Imps:     data,
+		Accounts: data,
 	}
 
 	ep.saves <- save
@@ -55,13 +59,15 @@ func TestListen(t *testing.T) {
 
 	requestData := cache.Requests.Get(context.Background(), idSlice)
 	impData := cache.Imps.Get(context.Background(), idSlice)
-	if !reflect.DeepEqual(requestData, data) || !reflect.DeepEqual(impData, data) {
+	accountData := cache.Accounts.Get(context.Background(), idSlice)
+	if !reflect.DeepEqual(requestData, data) || !reflect.DeepEqual(impData, data) || !reflect.DeepEqual(accountData, data) {
 		t.Error("Update failed")
 	}
 
 	invalidation := Invalidation{
 		Requests: idSlice,
 		Imps:     idSlice,
+		Accounts: idSlice,
 	}
 
 	ep.invalidations <- invalidation
@@ -69,7 +75,8 @@ func TestListen(t *testing.T) {
 
 	requestData = cache.Requests.Get(context.Background(), idSlice)
 	impData = cache.Imps.Get(context.Background(), idSlice)
-	if len(requestData) > 0 || len(impData) > 0 {
+	accountData = cache.Accounts.Get(context.Background(), idSlice)
+	if len(requestData) > 0 || len(impData) > 0 || len(accountData) > 0 {
 		t.Error("Invalidate failed")
 	}
 }
diff --git a/stored_requests/events/http/http_test.go b/stored_requests/events/http/http_test.go
index fdba84cd..2a1aa5d8 100644
--- a/stored_requests/events/http/http_test.go
+++ b/stored_requests/events/http/http_test.go
@@ -1,145 +1,161 @@
 package http
 
 import (
-	"bytes"
 	"context"
 	"encoding/json"
+	"fmt"
 	httpCore "net/http"
 	"net/http/httptest"
 	"testing"
 	"time"
-)
-
-func TestStartupReqsOnly(t *testing.T) {
-	server := httptest.NewServer(&mockResponseHandler{
-		statusCode: httpCore.StatusOK,
-		response:   `{"requests":{"request1":{"value":1}, "request2":{"value":2}}}`,
-	})
-	defer server.Close()
-
-	ev := NewHTTPEvents(server.Client(), server.URL, nil, -1)
-	theSave := <-ev.Saves()
-
-	assertLen(t, theSave.Requests, 2)
-	assertHasValue(t, theSave.Requests, "request1", `{"value":1}`)
-	assertHasValue(t, theSave.Requests, "request2", `{"value":2}`)
-
-	assertLen(t, theSave.Imps, 0)
-}
-
-func TestStartupImpsOnly(t *testing.T) {
-	server := httptest.NewServer(&mockResponseHandler{
-		statusCode: httpCore.StatusOK,
-		response:   `{"imps":{"imp1":{"value":1}}}`,
-	})
-	defer server.Close()
-
-	ev := NewHTTPEvents(server.Client(), server.URL, nil, -1)
-	theSave := <-ev.Saves()
-
-	assertLen(t, theSave.Requests, 0)
-
-	assertLen(t, theSave.Imps, 1)
-	assertHasValue(t, theSave.Imps, "imp1", `{"value":1}`)
-}
-
-func TestStartupBothTypes(t *testing.T) {
-	server := httptest.NewServer(&mockResponseHandler{
-		statusCode: httpCore.StatusOK,
-		response:   `{"requests":{"request1":{"value":1}, "request2":{"value":2}},"imps":{"imp1":{"value":1}}}`,
-	})
-	defer server.Close()
-
-	ev := NewHTTPEvents(server.Client(), server.URL, nil, -1)
-	theSave := <-ev.Saves()
-
-	assertLen(t, theSave.Requests, 2)
-	assertHasValue(t, theSave.Requests, "request1", `{"value":1}`)
-	assertHasValue(t, theSave.Requests, "request2", `{"value":2}`)
-
-	assertLen(t, theSave.Imps, 1)
-	assertHasValue(t, theSave.Imps, "imp1", `{"value":1}`)
-}
-
-func TestUpdates(t *testing.T) {
-	handler := &mockResponseHandler{
-		statusCode: httpCore.StatusOK,
-		response:   `{"requests":{"request1":{"value":1}, "request2":{"value":2}},"imps":{"imp1":{"value":3},"imp2":{"value":4}}}`,
-	}
-	server := httptest.NewServer(handler)
-	defer server.Close()
-
-	ev := NewHTTPEvents(server.Client(), server.URL, nil, -1)
-
-	handler.response = `{"requests":{"request1":{"value":5}, "request2":{"deleted":true}},"imps":{"imp1":{"deleted":true},"imp2":{"value":6}}}`
-	timeChan := make(chan time.Time, 1)
-	timeChan <- time.Now()
-	go ev.refresh(timeChan)
-	firstSave := <-ev.Saves()
-	secondSave := <-ev.Saves()
-	inv := <-ev.Invalidations()
-
-	assertLen(t, firstSave.Requests, 2)
-	assertHasValue(t, firstSave.Requests, "request1", `{"value":1}`)
-	assertHasValue(t, firstSave.Requests, "request2", `{"value":2}`)
-	assertLen(t, firstSave.Imps, 2)
-	assertHasValue(t, firstSave.Imps, "imp1", `{"value":3}`)
-	assertHasValue(t, firstSave.Imps, "imp2", `{"value":4}`)
-
-	assertLen(t, secondSave.Requests, 1)
-	assertHasValue(t, secondSave.Requests, "request1", `{"value":5}`)
-	assertLen(t, secondSave.Imps, 1)
-	assertHasValue(t, secondSave.Imps, "imp2", `{"value":6}`)
-
-	assertArrLen(t, inv.Requests, 1)
-	assertArrContains(t, inv.Requests, "request2")
-	assertArrLen(t, inv.Imps, 1)
-	assertArrContains(t, inv.Imps, "imp1")
-}
 
-func TestErrorResponse(t *testing.T) {
-	handler := &mockResponseHandler{
-		statusCode: httpCore.StatusInternalServerError,
-		response:   "Something horrible happened.",
-	}
-	server := httptest.NewServer(handler)
-	defer server.Close()
+	"github.com/stretchr/testify/assert"
+)
 
-	ev := NewHTTPEvents(server.Client(), server.URL, nil, -1)
-	if len(ev.Saves()) != 0 {
-		t.Errorf("No saves should be emitted if the HTTP call fails. Got %d", len(ev.Saves()))
-	}
+func ctxProducer() (context.Context, func()) {
+	return context.WithTimeout(context.Background(), -1)
 }
 
-func TestExpiredContext(t *testing.T) {
-	handler := &mockResponseHandler{
-		statusCode: httpCore.StatusInternalServerError,
-		response:   "Something horrible happened.",
-	}
-	server := httptest.NewServer(handler)
-	defer server.Close()
-
-	ctxProducer := func() (context.Context, func()) {
-		return context.WithTimeout(context.Background(), -1)
+func TestStartup(t *testing.T) {
+	type testStep struct {
+		statusCode    int
+		response      string
+		timeout       bool
+		saves         string
+		invalidations string
 	}
-
-	ev := NewHTTPEvents(server.Client(), server.URL, ctxProducer, -1)
-	if len(ev.Saves()) != 0 {
-		t.Errorf("No saves should be emitted if the HTTP call is cancelled. Got %d", len(ev.Saves()))
-	}
-}
-
-func TestMalformedResponse(t *testing.T) {
-	handler := &mockResponseHandler{
-		statusCode: httpCore.StatusOK,
-		response:   "This isn't JSON.",
+	testCases := []struct {
+		description string
+		tests       []testStep
+	}{
+		{
+			description: "Load requests at startup",
+			tests: []testStep{
+				{
+					statusCode: httpCore.StatusOK,
+					response:   `{"requests": {"request1": {"value":1}, "request2": {"value":2}}}`,
+					saves:      `{"requests": {"request1": {"value":1}, "request2": {"value":2}}, "imps": null, "accounts": null}`,
+				},
+			},
+		},
+		{
+			description: "Load imps at startup",
+			tests: []testStep{
+				{
+					statusCode: httpCore.StatusOK,
+					response:   `{"imps": {"imp1": {"value":1}}}`,
+					saves:      `{"imps": {"imp1": {"value":1}}, "requests": null, "accounts": null}`,
+				},
+			},
+		},
+		{
+			description: "Load requests and imps then update",
+			tests: []testStep{
+				{
+					statusCode: httpCore.StatusOK,
+					response:   `{"requests": {"request1": {"value":1}, "request2": {"value":2}}, "imps": {"imp1": {"value":3}, "imp2": {"value":4}}}`,
+					saves:      `{"requests": {"request1": {"value":1}, "request2": {"value":2}}, "imps": {"imp1": {"value":3}, "imp2": {"value":4}}, "accounts":null}`,
+				},
+				{
+					statusCode:    httpCore.StatusOK,
+					response:      `{"requests": {"request1": {"value":5}, "request2": {"deleted":true}}, "imps": {"imp1": {"deleted":true}, "imp2": {"value":6}}}`,
+					saves:         `{"requests": {"request1": {"value":5}}, "imps": {"imp2": {"value":6}}, "accounts":null}`,
+					invalidations: `{"requests": ["request2"], "imps": ["imp1"], "accounts": []}`,
+				},
+			},
+		},
+		{
+			description: "Load accounts then update",
+			tests: []testStep{
+				{
+					statusCode: httpCore.StatusOK,
+					response:   `{"accounts":{"account1":{"value":1}, "account2":{"value":2}}}`,
+					saves:      `{"accounts":{"account1":{"value":1}, "account2":{"value":2}}, "imps": null, "requests": null}`,
+				},
+				{
+					statusCode:    httpCore.StatusOK,
+					response:      `{"accounts":{"account1":{"value":5}, "account2":{"deleted": true}}}`,
+					saves:         `{"accounts":{"account1":{"value":5}}, "imps": null, "requests": null}`,
+					invalidations: `{"accounts":["account2"], "requests": [], "imps": []}`,
+				},
+			},
+		},
+		{
+			description: "Load nothing at startup",
+			tests: []testStep{
+				{
+					statusCode: httpCore.StatusOK,
+					response:   `{}`,
+				},
+			},
+		},
+		{
+			description: "Malformed response at startup",
+			tests: []testStep{
+				{
+					statusCode: httpCore.StatusOK,
+					response:   `{some bad json`,
+				},
+			},
+		},
+		{
+			description: "Server error at startup",
+			tests: []testStep{
+				{
+					statusCode: httpCore.StatusInternalServerError,
+					response:   ``,
+				},
+			},
+		},
+		{
+			description: "HTTP timeout error at startup",
+			tests: []testStep{
+				{
+					timeout: true,
+				},
+			},
+		},
 	}
-	server := httptest.NewServer(handler)
-	defer server.Close()
-
-	ev := NewHTTPEvents(server.Client(), server.URL, nil, -1)
-	if len(ev.Saves()) != 0 {
-		t.Errorf("No updates should be emitted if the HTTP call fails. Got %d", len(ev.Saves()))
+	for _, tests := range testCases {
+		t.Run(tests.description, func(t *testing.T) {
+			handler := &mockResponseHandler{}
+			server := httptest.NewServer(handler)
+			defer server.Close()
+
+			var ev *HTTPEvents
+
+			for i, test := range tests.tests {
+				handler.statusCode = test.statusCode
+				handler.response = test.response
+				if i == 0 { // NewHTTPEvents() calls the API immediately
+					if test.timeout {
+						ev = NewHTTPEvents(server.Client(), server.URL, ctxProducer, -1) // force timeout
+					} else {
+						ev = NewHTTPEvents(server.Client(), server.URL, nil, -1)
+					}
+				} else { // Second test triggers API call by initiating a 1s refresh loop
+					timeChan := make(chan time.Time, 1)
+					timeChan <- time.Now()
+					go ev.refresh(timeChan)
+				}
+				t.Run(fmt.Sprintf("Step %d", i+1), func(t *testing.T) {
+					// Check expected Saves
+					if len(test.saves) > 0 {
+						saves, err := json.Marshal(<-ev.Saves())
+						assert.NoError(t, err, `Failed to marshal event.Save object: %v`, err)
+						assert.JSONEq(t, test.saves, string(saves))
+					}
+					assert.Empty(t, ev.Saves(), "Unexpected additional messages in save channel")
+					// Check expected Invalidations
+					if len(test.invalidations) > 0 {
+						invalidations, err := json.Marshal(<-ev.Invalidations())
+						assert.NoError(t, err, `Failed to marshal event.Invalidation object: %v`, err)
+						assert.JSONEq(t, test.invalidations, string(invalidations))
+					}
+					assert.Empty(t, ev.Invalidations(), "Unexpected additional messages in invalidations channel")
+				})
+			}
+		})
 	}
 }
 
@@ -152,38 +168,3 @@ func (m *mockResponseHandler) ServeHTTP(rw httpCore.ResponseWriter, r *httpCore.
 	rw.WriteHeader(m.statusCode)
 	rw.Write([]byte(m.response))
 }
-
-func assertLen(t *testing.T, m map[string]json.RawMessage, length int) {
-	t.Helper()
-	if len(m) != length {
-		t.Errorf("Expected map with %d elements, but got %v", length, m)
-	}
-}
-
-func assertArrLen(t *testing.T, list []string, length int) {
-	t.Helper()
-	if len(list) != length {
-		t.Errorf("Expected list with %d elements, but got %v", length, list)
-	}
-}
-
-func assertArrContains(t *testing.T, haystack []string, needle string) {
-	t.Helper()
-	for _, elm := range haystack {
-		if elm == needle {
-			return
-		}
-	}
-	t.Errorf("expected element %s to be in list %v", needle, haystack)
-}
-
-func assertHasValue(t *testing.T, m map[string]json.RawMessage, key string, val string) {
-	t.Helper()
-	if mapVal, ok := m[key]; ok {
-		if !bytes.Equal(mapVal, []byte(val)) {
-			t.Errorf("expected map[%s] to be %s, but got %s", key, val, string(mapVal))
-		}
-	} else {
-		t.Errorf("map missing expected key: %s", key)
-	}
-}