@@ -0,0 +1,348 @@
|
||||
package vipcardexperimentser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/proto"
|
||||
"91porn-server/models/v/vipcardexperimentmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
const maxEventsPerRequest = 50
|
||||
|
||||
type EventInput struct {
|
||||
EventID string `json:"eventId"`
|
||||
EventName string `json:"eventName"`
|
||||
SessionID string `json:"sessionId"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
ExperimentID string `json:"experimentId"`
|
||||
Variant string `json:"variant"`
|
||||
ProductID primitive.ObjectID `json:"productId"`
|
||||
}
|
||||
|
||||
type EventsRequest struct {
|
||||
Events []EventInput `json:"events" binding:"required"`
|
||||
}
|
||||
|
||||
type EventsResponse struct {
|
||||
Accepted int `json:"accepted"`
|
||||
Duplicated int `json:"duplicated"`
|
||||
}
|
||||
|
||||
// ApplyToProductResponse applies the active experiment to the normal product response.
|
||||
// No experiment or an unusable configuration keeps the legacy response unchanged.
|
||||
func ApplyToProductResponse(uid uint64, response *proto.ProductRes, now time.Time) error {
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
response.ExperimentStatus = vipcardexperimentmod.StatusDisabled
|
||||
if uid == 0 {
|
||||
return nil
|
||||
}
|
||||
experiment, err := vipcardexperimentmod.Current(now)
|
||||
if err != nil || experiment == nil {
|
||||
return err
|
||||
}
|
||||
variant := experiment.Assign(uid)
|
||||
config, ok := experiment.ConfigFor(variant)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !applyExperiment(response, experiment.ExperimentID, variant, config) {
|
||||
return nil
|
||||
}
|
||||
otherConfig := experiment.VariantA
|
||||
if variant == vipcardexperimentmod.VariantA {
|
||||
otherConfig = experiment.VariantB
|
||||
}
|
||||
filterOtherVariantProducts(response, config.ProductIDs, otherConfig.ProductIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyExperiment(
|
||||
response *proto.ProductRes,
|
||||
experimentID string,
|
||||
variant string,
|
||||
config vipcardexperimentmod.VariantConfig,
|
||||
) bool {
|
||||
if !applyVariant(response, config) {
|
||||
return false
|
||||
}
|
||||
response.ExperimentID = experimentID
|
||||
response.ExperimentStatus = vipcardexperimentmod.StatusActive
|
||||
response.Variant = variant
|
||||
response.SkinKey = config.SkinKey
|
||||
if !config.DefaultProductID.IsZero() {
|
||||
response.DefaultProductID = config.DefaultProductID.Hex()
|
||||
}
|
||||
response.UIConfig = toResponseUIConfig(config.UIConfig)
|
||||
return true
|
||||
}
|
||||
|
||||
func toResponseUIConfig(config *vipcardexperimentmod.UIConfig) *proto.VIPCardUIConfig {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
response := &proto.VIPCardUIConfig{
|
||||
BackgroundImage: config.BackgroundImage,
|
||||
BadgeStyles: make([]proto.VIPCardBadgeStyle, len(config.BadgeStyles)),
|
||||
}
|
||||
for i, style := range config.BadgeStyles {
|
||||
response.BadgeStyles[i] = proto.VIPCardBadgeStyle{
|
||||
BadgeType: style.BadgeType,
|
||||
BackgroundColor: style.BackgroundColor,
|
||||
TextColor: style.TextColor,
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func applyVariant(response *proto.ProductRes, config vipcardexperimentmod.VariantConfig) bool {
|
||||
productByID := make(map[primitive.ObjectID]proto.VIPListRes)
|
||||
positionByID := make(map[primitive.ObjectID]int)
|
||||
for positionIndex := range response.List {
|
||||
for _, product := range response.List[positionIndex].List {
|
||||
productByID[product.ID] = product
|
||||
positionByID[product.ID] = positionIndex
|
||||
}
|
||||
}
|
||||
|
||||
badges := make(map[primitive.ObjectID]vipcardexperimentmod.ProductBadge, len(config.ProductBadges))
|
||||
for _, badge := range config.ProductBadges {
|
||||
badges[badge.ProductID] = badge
|
||||
}
|
||||
affectedPositions := make(map[int]struct{})
|
||||
orderedPositionIndexes := make([]int, 0, len(response.List))
|
||||
selectedByPosition := make(map[int][]proto.VIPListRes)
|
||||
defaultVisible := config.DefaultProductID.IsZero()
|
||||
for productOrder, productID := range config.ProductIDs {
|
||||
product, exists := productByID[productID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
product.Sort = productOrder + 1
|
||||
positionIndex := positionByID[productID]
|
||||
if _, affected := affectedPositions[positionIndex]; !affected {
|
||||
affectedPositions[positionIndex] = struct{}{}
|
||||
orderedPositionIndexes = append(orderedPositionIndexes, positionIndex)
|
||||
}
|
||||
if badge, exists := badges[productID]; exists {
|
||||
product.BadgeType = badge.BadgeType
|
||||
product.BadgeText = badge.BadgeText
|
||||
}
|
||||
if productID == config.DefaultProductID {
|
||||
defaultVisible = true
|
||||
}
|
||||
selectedByPosition[positionIndex] = append(selectedByPosition[positionIndex], product)
|
||||
}
|
||||
if len(affectedPositions) == 0 {
|
||||
return false
|
||||
}
|
||||
if !defaultVisible {
|
||||
return false
|
||||
}
|
||||
firstAffectedPosition := len(response.List)
|
||||
for positionIndex := range affectedPositions {
|
||||
if positionIndex < firstAffectedPosition {
|
||||
firstAffectedPosition = positionIndex
|
||||
}
|
||||
}
|
||||
orderedPositions := make([]proto.ProductList, 0, len(orderedPositionIndexes))
|
||||
for _, positionIndex := range orderedPositionIndexes {
|
||||
position := response.List[positionIndex]
|
||||
position.List = selectedByPosition[positionIndex]
|
||||
orderedPositions = append(orderedPositions, position)
|
||||
}
|
||||
positions := make([]proto.ProductList, 0, len(response.List))
|
||||
for positionIndex, position := range response.List {
|
||||
if positionIndex == firstAffectedPosition {
|
||||
positions = append(positions, orderedPositions...)
|
||||
}
|
||||
if _, affected := affectedPositions[positionIndex]; affected {
|
||||
continue
|
||||
}
|
||||
positions = append(positions, position)
|
||||
}
|
||||
response.List = positions
|
||||
return true
|
||||
}
|
||||
|
||||
// filterOtherVariantProducts removes products configured exclusively for the
|
||||
// other experiment variant while preserving positions that do not participate
|
||||
// in the experiment. This keeps the product response consistent with the
|
||||
// attribution validation performed when an order is created.
|
||||
func filterOtherVariantProducts(
|
||||
response *proto.ProductRes,
|
||||
currentProductIDs, otherProductIDs []primitive.ObjectID,
|
||||
) {
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
current := make(map[primitive.ObjectID]struct{}, len(currentProductIDs))
|
||||
for _, productID := range currentProductIDs {
|
||||
current[productID] = struct{}{}
|
||||
}
|
||||
blocked := make(map[primitive.ObjectID]struct{}, len(otherProductIDs))
|
||||
for _, productID := range otherProductIDs {
|
||||
if _, shared := current[productID]; !shared {
|
||||
blocked[productID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(blocked) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
positions := make([]proto.ProductList, 0, len(response.List))
|
||||
for _, position := range response.List {
|
||||
products := make([]proto.VIPListRes, 0, len(position.List))
|
||||
for _, product := range position.List {
|
||||
if _, excluded := blocked[product.ID]; excluded {
|
||||
continue
|
||||
}
|
||||
products = append(products, product)
|
||||
}
|
||||
if len(products) == 0 {
|
||||
continue
|
||||
}
|
||||
position.List = products
|
||||
positions = append(positions, position)
|
||||
}
|
||||
response.List = positions
|
||||
}
|
||||
|
||||
func RecordEvents(uid uint64, request EventsRequest, now time.Time) (EventsResponse, error) {
|
||||
response := EventsResponse{}
|
||||
if uid == 0 {
|
||||
return response, fmt.Errorf("authenticated user is required")
|
||||
}
|
||||
if len(request.Events) == 0 || len(request.Events) > maxEventsPerRequest {
|
||||
return response, fmt.Errorf("events must contain 1-%d items", maxEventsPerRequest)
|
||||
}
|
||||
experiments := make(map[string]*vipcardexperimentmod.Experiment)
|
||||
events := make([]*vipcardexperimentmod.AnalyticsEvent, 0, len(request.Events))
|
||||
for _, input := range request.Events {
|
||||
event, experiment, err := validateEvent(uid, input, now, experiments)
|
||||
if err != nil {
|
||||
return EventsResponse{}, err
|
||||
}
|
||||
events = append(events, event)
|
||||
experiments[experiment.ExperimentID] = experiment
|
||||
}
|
||||
for _, event := range events {
|
||||
inserted, err := vipcardexperimentmod.InsertEvent(event)
|
||||
if err != nil {
|
||||
return EventsResponse{}, err
|
||||
}
|
||||
if inserted {
|
||||
response.Accepted++
|
||||
} else {
|
||||
response.Duplicated++
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func validateEvent(
|
||||
uid uint64,
|
||||
input EventInput,
|
||||
now time.Time,
|
||||
experiments map[string]*vipcardexperimentmod.Experiment,
|
||||
) (*vipcardexperimentmod.AnalyticsEvent, *vipcardexperimentmod.Experiment, error) {
|
||||
input.EventID = strings.TrimSpace(input.EventID)
|
||||
input.EventName = strings.ToUpper(strings.TrimSpace(input.EventName))
|
||||
input.SessionID = strings.TrimSpace(input.SessionID)
|
||||
input.ExperimentID = strings.TrimSpace(input.ExperimentID)
|
||||
input.Variant = strings.ToUpper(strings.TrimSpace(input.Variant))
|
||||
if input.EventID == "" || len(input.EventID) > 128 {
|
||||
return nil, nil, fmt.Errorf("eventId is required and must not exceed 128 characters")
|
||||
}
|
||||
if !vipcardexperimentmod.ValidEventName(input.EventName) {
|
||||
return nil, nil, fmt.Errorf("unsupported eventName: %s", input.EventName)
|
||||
}
|
||||
if input.SessionID == "" || len(input.SessionID) > 128 {
|
||||
return nil, nil, fmt.Errorf("sessionId is required and must not exceed 128 characters")
|
||||
}
|
||||
if input.ExperimentID == "" {
|
||||
return nil, nil, fmt.Errorf("experimentId is required")
|
||||
}
|
||||
if len(input.ExperimentID) > 128 {
|
||||
return nil, nil, fmt.Errorf("experimentId must not exceed 128 characters")
|
||||
}
|
||||
if input.OccurredAt.IsZero() || input.OccurredAt.After(now.Add(5*time.Minute)) {
|
||||
return nil, nil, fmt.Errorf("occurredAt is invalid")
|
||||
}
|
||||
experiment := experiments[input.ExperimentID]
|
||||
var err error
|
||||
if experiment == nil {
|
||||
experiment, err = vipcardexperimentmod.FindByExperimentID(input.ExperimentID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if experiment == nil {
|
||||
return nil, nil, fmt.Errorf("experiment does not exist")
|
||||
}
|
||||
}
|
||||
if !eventOccurredWithinExperiment(experiment, input.OccurredAt) {
|
||||
return nil, nil, fmt.Errorf("occurredAt is outside the experiment period")
|
||||
}
|
||||
config, ok := experiment.ConfigFor(input.Variant)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("variant must be A or B")
|
||||
}
|
||||
if assigned := experiment.Assign(uid); assigned != input.Variant {
|
||||
return nil, nil, fmt.Errorf("variant does not match user assignment")
|
||||
}
|
||||
if input.EventName == vipcardexperimentmod.EventProductImpression {
|
||||
if input.ProductID.IsZero() {
|
||||
return nil, nil, fmt.Errorf("productId is required for VIP_PRODUCT_IMPRESSION")
|
||||
}
|
||||
if !containsProduct(config.ProductIDs, input.ProductID) {
|
||||
return nil, nil, fmt.Errorf("productId does not belong to variant")
|
||||
}
|
||||
} else {
|
||||
input.ProductID = primitive.NilObjectID
|
||||
}
|
||||
return &vipcardexperimentmod.AnalyticsEvent{
|
||||
EventID: input.EventID,
|
||||
EventName: input.EventName,
|
||||
UID: uid,
|
||||
SessionID: input.SessionID,
|
||||
OccurredAt: input.OccurredAt,
|
||||
ExperimentID: input.ExperimentID,
|
||||
Variant: input.Variant,
|
||||
ProductID: input.ProductID,
|
||||
}, experiment, nil
|
||||
}
|
||||
|
||||
func eventOccurredWithinExperiment(experiment *vipcardexperimentmod.Experiment, occurredAt time.Time) bool {
|
||||
const clockSkew = 5 * time.Minute
|
||||
startAt := experiment.PublishedAt
|
||||
if experiment.StartAt != nil && (startAt.IsZero() || experiment.StartAt.After(startAt)) {
|
||||
startAt = *experiment.StartAt
|
||||
}
|
||||
if !startAt.IsZero() && occurredAt.Before(startAt.Add(-clockSkew)) {
|
||||
return false
|
||||
}
|
||||
var endAt *time.Time
|
||||
if experiment.EndAt != nil {
|
||||
end := *experiment.EndAt
|
||||
endAt = &end
|
||||
}
|
||||
if experiment.DisabledAt != nil && (endAt == nil || experiment.DisabledAt.Before(*endAt)) {
|
||||
end := *experiment.DisabledAt
|
||||
endAt = &end
|
||||
}
|
||||
return endAt == nil || !occurredAt.After(endAt.Add(clockSkew))
|
||||
}
|
||||
|
||||
func containsProduct(ids []primitive.ObjectID, target primitive.ObjectID) bool {
|
||||
for _, id := range ids {
|
||||
if id == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package vipcardexperimentser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/proto"
|
||||
"91porn-server/models/v/productmod"
|
||||
"91porn-server/models/v/vipcardexperimentmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
func TestApplyVariantOrdersFiltersAndBadgesProducts(t *testing.T) {
|
||||
first := primitive.NewObjectID()
|
||||
second := primitive.NewObjectID()
|
||||
unaffected := primitive.NewObjectID()
|
||||
response := proto.ProductRes{List: []proto.ProductList{
|
||||
{
|
||||
Position: "vip",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: first}},
|
||||
{Product: productmod.Product{ID: second}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Position: "coin",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: unaffected}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
config := vipcardexperimentmod.VariantConfig{
|
||||
DefaultProductID: second,
|
||||
ProductIDs: []primitive.ObjectID{second, first},
|
||||
ProductBadges: []vipcardexperimentmod.ProductBadge{{
|
||||
ProductID: second,
|
||||
BadgeType: vipcardexperimentmod.BadgeMostPopular,
|
||||
BadgeText: "最受欢迎",
|
||||
}},
|
||||
}
|
||||
if !applyVariant(&response, config) {
|
||||
t.Fatal("applyVariant() = false, want true")
|
||||
}
|
||||
vipProducts := response.List[0].List
|
||||
if len(vipProducts) != 2 || vipProducts[0].ID != second || vipProducts[1].ID != first {
|
||||
t.Fatalf("VIP product order = %#v", vipProducts)
|
||||
}
|
||||
if vipProducts[0].Sort != 1 || vipProducts[1].Sort != 2 {
|
||||
t.Fatalf("VIP product sort values = %#v", vipProducts)
|
||||
}
|
||||
if vipProducts[0].BadgeType != vipcardexperimentmod.BadgeMostPopular ||
|
||||
vipProducts[0].BadgeText != "最受欢迎" {
|
||||
t.Fatalf("badge not applied: %#v", vipProducts[0])
|
||||
}
|
||||
if len(response.List[1].List) != 1 || response.List[1].List[0].ID != unaffected {
|
||||
t.Fatalf("unaffected position changed: %#v", response.List[1].List)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVariantOrdersIndependentPositionsByFirstConfiguredProduct(t *testing.T) {
|
||||
firstVIP := primitive.NewObjectID()
|
||||
secondVIP := primitive.NewObjectID()
|
||||
presale := primitive.NewObjectID()
|
||||
unaffected := primitive.NewObjectID()
|
||||
response := proto.ProductRes{List: []proto.ProductList{
|
||||
{
|
||||
Position: "会员卡",
|
||||
PositionID: "vip",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: firstVIP}},
|
||||
{Product: productmod.Product{ID: secondVIP}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Position: "预售卡",
|
||||
PositionID: "presale",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: presale}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Position: "金币",
|
||||
PositionID: "coin",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: unaffected}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
config := vipcardexperimentmod.VariantConfig{
|
||||
ProductIDs: []primitive.ObjectID{presale, secondVIP, firstVIP},
|
||||
}
|
||||
|
||||
if !applyVariant(&response, config) {
|
||||
t.Fatal("applyVariant() = false, want true")
|
||||
}
|
||||
if len(response.List) != 3 {
|
||||
t.Fatalf("positions = %#v, want member, presale and coin positions", response.List)
|
||||
}
|
||||
if response.List[0].Position != "预售卡" || response.List[0].PositionID != "presale" ||
|
||||
len(response.List[0].List) != 1 || response.List[0].List[0].ID != presale ||
|
||||
response.List[0].List[0].Sort != 1 {
|
||||
t.Fatalf("presale position = %#v", response.List[0])
|
||||
}
|
||||
wantVIPProducts := []primitive.ObjectID{secondVIP, firstVIP}
|
||||
if response.List[1].Position != "会员卡" || response.List[1].PositionID != "vip" {
|
||||
t.Fatalf("member position = %#v", response.List[1])
|
||||
}
|
||||
if len(response.List[1].List) != len(wantVIPProducts) {
|
||||
t.Fatalf("ordered member products = %#v", response.List[1].List)
|
||||
}
|
||||
for index, productID := range wantVIPProducts {
|
||||
if response.List[1].List[index].ID != productID {
|
||||
t.Fatalf("ordered member product %d = %s, want %s", index, response.List[1].List[index].ID, productID)
|
||||
}
|
||||
if response.List[1].List[index].Sort != index+2 {
|
||||
t.Fatalf("ordered member product %d sort = %d, want %d", index, response.List[1].List[index].Sort, index+2)
|
||||
}
|
||||
}
|
||||
if response.List[2].Position != "金币" || len(response.List[2].List) != 1 ||
|
||||
response.List[2].List[0].ID != unaffected {
|
||||
t.Fatalf("unaffected position = %#v", response.List[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVariantKeepsInterleavedProductsInOnePosition(t *testing.T) {
|
||||
firstVIP := primitive.NewObjectID()
|
||||
secondVIP := primitive.NewObjectID()
|
||||
presale := primitive.NewObjectID()
|
||||
response := proto.ProductRes{List: []proto.ProductList{
|
||||
{
|
||||
Position: "会员卡",
|
||||
PositionID: "vip",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: firstVIP}},
|
||||
{Product: productmod.Product{ID: secondVIP}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Position: "预售卡",
|
||||
PositionID: "presale",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: presale}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
config := vipcardexperimentmod.VariantConfig{
|
||||
ProductIDs: []primitive.ObjectID{secondVIP, presale, firstVIP},
|
||||
}
|
||||
|
||||
if !applyVariant(&response, config) {
|
||||
t.Fatal("applyVariant() = false, want true")
|
||||
}
|
||||
if len(response.List) != 2 {
|
||||
t.Fatalf("positions = %#v, want one member and one presale position", response.List)
|
||||
}
|
||||
if response.List[0].PositionID != "vip" || len(response.List[0].List) != 2 ||
|
||||
response.List[0].List[0].ID != secondVIP || response.List[0].List[0].Sort != 1 ||
|
||||
response.List[0].List[1].ID != firstVIP || response.List[0].List[1].Sort != 3 {
|
||||
t.Fatalf("member position = %#v", response.List[0])
|
||||
}
|
||||
if response.List[1].PositionID != "presale" || len(response.List[1].List) != 1 ||
|
||||
response.List[1].List[0].ID != presale || response.List[1].List[0].Sort != 2 {
|
||||
t.Fatalf("presale position = %#v", response.List[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOtherVariantProductsRemovesExclusivePresalePosition(t *testing.T) {
|
||||
shared := primitive.NewObjectID()
|
||||
currentOnly := primitive.NewObjectID()
|
||||
otherOnlyPresale := primitive.NewObjectID()
|
||||
unaffected := primitive.NewObjectID()
|
||||
response := proto.ProductRes{List: []proto.ProductList{
|
||||
{
|
||||
Position: "vip",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: shared}},
|
||||
{Product: productmod.Product{ID: currentOnly}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Position: "advance-card",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: otherOnlyPresale}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Position: "coin",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: unaffected}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
filterOtherVariantProducts(
|
||||
&response,
|
||||
[]primitive.ObjectID{shared, currentOnly},
|
||||
[]primitive.ObjectID{shared, otherOnlyPresale},
|
||||
)
|
||||
|
||||
if len(response.List) != 2 {
|
||||
t.Fatalf("positions = %#v, want vip and unaffected coin positions", response.List)
|
||||
}
|
||||
if response.List[0].Position != "vip" || len(response.List[0].List) != 2 {
|
||||
t.Fatalf("current variant products changed: %#v", response.List[0])
|
||||
}
|
||||
if response.List[1].Position != "coin" || len(response.List[1].List) != 1 ||
|
||||
response.List[1].List[0].ID != unaffected {
|
||||
t.Fatalf("unaffected position changed: %#v", response.List[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVariantFallsBackWhenDefaultProductIsUnavailable(t *testing.T) {
|
||||
available := primitive.NewObjectID()
|
||||
missingDefault := primitive.NewObjectID()
|
||||
response := proto.ProductRes{List: []proto.ProductList{{
|
||||
Position: "vip",
|
||||
List: []proto.VIPListRes{{
|
||||
Product: productmod.Product{ID: available},
|
||||
}},
|
||||
}}}
|
||||
original := response.List[0].List
|
||||
config := vipcardexperimentmod.VariantConfig{
|
||||
DefaultProductID: missingDefault,
|
||||
ProductIDs: []primitive.ObjectID{available, missingDefault},
|
||||
}
|
||||
if applyVariant(&response, config) {
|
||||
t.Fatal("applyVariant() = true, want false")
|
||||
}
|
||||
if len(response.List[0].List) != len(original) {
|
||||
t.Fatalf("legacy response must remain unchanged: %#v", response.List[0].List)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVariantAllowsMissingDefaultProduct(t *testing.T) {
|
||||
first := primitive.NewObjectID()
|
||||
second := primitive.NewObjectID()
|
||||
response := proto.ProductRes{List: []proto.ProductList{{
|
||||
Position: "vip",
|
||||
List: []proto.VIPListRes{
|
||||
{Product: productmod.Product{ID: first}},
|
||||
{Product: productmod.Product{ID: second}},
|
||||
},
|
||||
}}}
|
||||
config := vipcardexperimentmod.VariantConfig{
|
||||
ProductIDs: []primitive.ObjectID{second, first},
|
||||
}
|
||||
if !applyVariant(&response, config) {
|
||||
t.Fatal("applyVariant() = false, want true")
|
||||
}
|
||||
products := response.List[0].List
|
||||
if len(products) != 2 || products[0].ID != second || products[1].ID != first {
|
||||
t.Fatalf("VIP product order = %#v", products)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExperimentOmitsMissingDefaultProduct(t *testing.T) {
|
||||
productID := primitive.NewObjectID()
|
||||
response := proto.ProductRes{List: []proto.ProductList{{
|
||||
Position: "vip",
|
||||
List: []proto.VIPListRes{{
|
||||
Product: productmod.Product{ID: productID},
|
||||
}},
|
||||
}}}
|
||||
config := vipcardexperimentmod.VariantConfig{
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
}
|
||||
|
||||
if !applyExperiment(
|
||||
&response,
|
||||
"optional-default-product",
|
||||
vipcardexperimentmod.VariantA,
|
||||
config,
|
||||
) {
|
||||
t.Fatal("applyExperiment() = false, want true")
|
||||
}
|
||||
if response.DefaultProductID != "" {
|
||||
t.Fatalf("DefaultProductID = %q, want empty", response.DefaultProductID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExperimentIncludesUIConfig(t *testing.T) {
|
||||
productID := primitive.NewObjectID()
|
||||
uiConfig := &vipcardexperimentmod.UIConfig{
|
||||
BackgroundImage: "vip-card-skin-a.png",
|
||||
BadgeStyles: []vipcardexperimentmod.BadgeStyle{{
|
||||
BadgeType: vipcardexperimentmod.BadgeNewUser,
|
||||
BackgroundColor: "#F04432",
|
||||
TextColor: "#FFFFFF",
|
||||
}},
|
||||
}
|
||||
config := vipcardexperimentmod.VariantConfig{
|
||||
SkinKey: "vip-card-skin-a",
|
||||
DefaultProductID: productID,
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
UIConfig: uiConfig,
|
||||
}
|
||||
response := proto.ProductRes{List: []proto.ProductList{{
|
||||
Position: "vip",
|
||||
List: []proto.VIPListRes{{
|
||||
Product: productmod.Product{ID: productID},
|
||||
}},
|
||||
}}}
|
||||
|
||||
if !applyExperiment(
|
||||
&response,
|
||||
"vip-card-test",
|
||||
vipcardexperimentmod.VariantA,
|
||||
config,
|
||||
) {
|
||||
t.Fatal("applyExperiment() = false, want true")
|
||||
}
|
||||
if response.ExperimentID != "vip-card-test" ||
|
||||
response.ExperimentStatus != vipcardexperimentmod.StatusActive ||
|
||||
response.Variant != vipcardexperimentmod.VariantA ||
|
||||
response.SkinKey != "vip-card-skin-a" ||
|
||||
response.DefaultProductID != productID.Hex() {
|
||||
t.Fatalf("experiment metadata = %#v", response)
|
||||
}
|
||||
if response.UIConfig == nil ||
|
||||
response.UIConfig.BackgroundImage != "vip-card-skin-a.png" ||
|
||||
len(response.UIConfig.BadgeStyles) != 1 {
|
||||
t.Fatalf("UI config = %#v", response.UIConfig)
|
||||
}
|
||||
|
||||
uiConfig.BackgroundImage = "changed.png"
|
||||
uiConfig.BadgeStyles[0].BackgroundColor = "#000000"
|
||||
if response.UIConfig.BackgroundImage != "vip-card-skin-a.png" ||
|
||||
response.UIConfig.BadgeStyles[0].BackgroundColor != "#F04432" {
|
||||
t.Fatalf("response UI config aliases stored config: %#v", response.UIConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExperimentOmitsUIConfigForLegacyExperiment(t *testing.T) {
|
||||
productID := primitive.NewObjectID()
|
||||
response := proto.ProductRes{List: []proto.ProductList{{
|
||||
Position: "vip",
|
||||
List: []proto.VIPListRes{{
|
||||
Product: productmod.Product{ID: productID},
|
||||
}},
|
||||
}}}
|
||||
config := vipcardexperimentmod.VariantConfig{
|
||||
DefaultProductID: productID,
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
}
|
||||
|
||||
if !applyExperiment(
|
||||
&response,
|
||||
"legacy-experiment",
|
||||
vipcardexperimentmod.VariantA,
|
||||
config,
|
||||
) {
|
||||
t.Fatal("applyExperiment() = false, want true")
|
||||
}
|
||||
if response.UIConfig != nil {
|
||||
t.Fatalf("legacy UI config = %#v, want nil", response.UIConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExperimentFailureDoesNotExposeExperimentUI(t *testing.T) {
|
||||
available := primitive.NewObjectID()
|
||||
missingDefault := primitive.NewObjectID()
|
||||
response := proto.ProductRes{List: []proto.ProductList{{
|
||||
Position: "vip",
|
||||
List: []proto.VIPListRes{{
|
||||
Product: productmod.Product{ID: available},
|
||||
}},
|
||||
}}}
|
||||
config := vipcardexperimentmod.VariantConfig{
|
||||
SkinKey: "vip-card-skin-a",
|
||||
DefaultProductID: missingDefault,
|
||||
ProductIDs: []primitive.ObjectID{available, missingDefault},
|
||||
UIConfig: &vipcardexperimentmod.UIConfig{
|
||||
BackgroundImage: "vip-card-skin-a.png",
|
||||
},
|
||||
}
|
||||
|
||||
if applyExperiment(
|
||||
&response,
|
||||
"unusable-experiment",
|
||||
vipcardexperimentmod.VariantA,
|
||||
config,
|
||||
) {
|
||||
t.Fatal("applyExperiment() = true, want false")
|
||||
}
|
||||
if response.ExperimentID != "" ||
|
||||
response.Variant != "" ||
|
||||
response.SkinKey != "" ||
|
||||
response.DefaultProductID != "" ||
|
||||
response.UIConfig != nil {
|
||||
t.Fatalf("failed experiment leaked metadata: %#v", response)
|
||||
}
|
||||
if len(response.List) != 1 ||
|
||||
len(response.List[0].List) != 1 ||
|
||||
response.List[0].List[0].ID != available {
|
||||
t.Fatalf("failed experiment changed product list: %#v", response.List)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyToProductResponseSkipsAnonymousUser(t *testing.T) {
|
||||
response := proto.ProductRes{}
|
||||
if err := ApplyToProductResponse(0, &response, time.Now()); err != nil {
|
||||
t.Fatalf("ApplyToProductResponse() error = %v", err)
|
||||
}
|
||||
if response.ExperimentStatus != vipcardexperimentmod.StatusDisabled ||
|
||||
response.ExperimentID != "" || response.UIConfig != nil {
|
||||
t.Fatalf("anonymous response contains experiment config: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventChecksStableAssignmentAndProduct(t *testing.T) {
|
||||
productID := primitive.NewObjectID()
|
||||
experiment := &vipcardexperimentmod.Experiment{
|
||||
ExperimentID: "experiment",
|
||||
TrafficA: 100,
|
||||
TrafficB: 0,
|
||||
VariantA: vipcardexperimentmod.VariantConfig{
|
||||
DefaultProductID: productID,
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
},
|
||||
}
|
||||
now := time.Now()
|
||||
input := EventInput{
|
||||
EventID: "event-1",
|
||||
EventName: vipcardexperimentmod.EventProductImpression,
|
||||
SessionID: "session-1",
|
||||
OccurredAt: now,
|
||||
ExperimentID: experiment.ExperimentID,
|
||||
Variant: vipcardexperimentmod.VariantA,
|
||||
ProductID: productID,
|
||||
}
|
||||
event, _, err := validateEvent(
|
||||
123,
|
||||
input,
|
||||
now,
|
||||
map[string]*vipcardexperimentmod.Experiment{experiment.ExperimentID: experiment},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("validateEvent() error = %v", err)
|
||||
}
|
||||
if event.UID != 123 || event.ProductID != productID {
|
||||
t.Fatalf("event = %#v", event)
|
||||
}
|
||||
|
||||
input.Variant = vipcardexperimentmod.VariantB
|
||||
if _, _, err = validateEvent(
|
||||
123,
|
||||
input,
|
||||
now,
|
||||
map[string]*vipcardexperimentmod.Experiment{experiment.ExperimentID: experiment},
|
||||
); err == nil {
|
||||
t.Fatal("expected mismatched assignment to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventOccurredWithinExperiment(t *testing.T) {
|
||||
now := time.Now()
|
||||
start := now.Add(-time.Hour)
|
||||
end := now.Add(time.Hour)
|
||||
experiment := &vipcardexperimentmod.Experiment{
|
||||
PublishedAt: start,
|
||||
EndAt: &end,
|
||||
}
|
||||
if !eventOccurredWithinExperiment(experiment, now) {
|
||||
t.Fatal("event during experiment must be accepted")
|
||||
}
|
||||
if eventOccurredWithinExperiment(experiment, start.Add(-10*time.Minute)) {
|
||||
t.Fatal("event before experiment must be rejected")
|
||||
}
|
||||
if eventOccurredWithinExperiment(experiment, end.Add(10*time.Minute)) {
|
||||
t.Fatal("event after experiment must be rejected")
|
||||
}
|
||||
|
||||
disabled := now.Add(-time.Minute)
|
||||
experiment.DisabledAt = &disabled
|
||||
if eventOccurredWithinExperiment(experiment, now.Add(10*time.Minute)) {
|
||||
t.Fatal("event after disable must be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user