@@ -0,0 +1,283 @@
|
||||
package vipcardexperimentmod
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"91porn-server/common/db"
|
||||
"91porn-server/models"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
var mdb *db.MongoDB
|
||||
|
||||
func Init() {
|
||||
mdb = db.Init(models.VIPCardExperiment)
|
||||
initIndexes()
|
||||
}
|
||||
|
||||
func experimentColl() *db.MongoTool {
|
||||
return mdb.Coll(models.VIPCardExperiment)
|
||||
}
|
||||
|
||||
func eventColl() *db.MongoTool {
|
||||
return mdb.Coll(models.VIPCardAnalyticsEvent)
|
||||
}
|
||||
|
||||
func initIndexes() {
|
||||
if _, err := experimentColl().CreateIndex([]mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "experimentId", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "activeSlot", Value: 1}},
|
||||
Options: options.Index().
|
||||
SetName("uniq_vip_card_active_slot").
|
||||
SetUnique(true).
|
||||
SetPartialFilterExpression(bson.M{"activeSlot": bson.M{"$gt": ""}}),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{
|
||||
{Key: "status", Value: 1},
|
||||
{Key: "startAt", Value: 1},
|
||||
{Key: "endAt", Value: 1},
|
||||
{Key: "publishedAt", Value: -1},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
panic(fmt.Sprintf("%s model set index err ==>[%+v]", models.VIPCardExperiment, err))
|
||||
}
|
||||
if _, err := eventColl().CreateIndex([]mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "eventId", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{
|
||||
{Key: "experimentId", Value: 1},
|
||||
{Key: "variant", Value: 1},
|
||||
{Key: "eventName", Value: 1},
|
||||
{Key: "occurredAt", Value: 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
Keys: bson.D{
|
||||
{Key: "experimentId", Value: 1},
|
||||
{Key: "productId", Value: 1},
|
||||
{Key: "eventName", Value: 1},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
panic(fmt.Sprintf("%s model set index err ==>[%+v]", models.VIPCardAnalyticsEvent, err))
|
||||
}
|
||||
}
|
||||
|
||||
func Publish(experiment *Experiment, operator string) error {
|
||||
experiment.Normalize()
|
||||
if err := experiment.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
experiment.ID = primitive.NewObjectID()
|
||||
experiment.Status = StatusActive
|
||||
experiment.PublishedAt = now
|
||||
experiment.CreatedAt = now
|
||||
experiment.UpdatedAt = now
|
||||
experiment.CreatedBy = operator
|
||||
experiment.DisabledAt = nil
|
||||
experiment.DisabledBy = ""
|
||||
experiment.ActiveSlot = activeSlot
|
||||
|
||||
return mdb.Trans(func(t *db.MongoTool) error {
|
||||
coll := t.Coll(models.VIPCardExperiment)
|
||||
if _, err := coll.UpdateMany(
|
||||
bson.M{"status": StatusActive},
|
||||
bson.M{
|
||||
"$set": bson.M{
|
||||
"status": StatusDisabled,
|
||||
"disabledAt": now,
|
||||
"disabledBy": operator,
|
||||
"updatedAt": now,
|
||||
},
|
||||
"$unset": bson.M{"activeSlot": ""},
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := coll.InsertOne(experiment)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func Current(now time.Time) (*Experiment, error) {
|
||||
filter := bson.M{
|
||||
"status": StatusActive,
|
||||
"$and": bson.A{
|
||||
bson.M{"$or": bson.A{
|
||||
bson.M{"startAt": bson.M{"$exists": false}},
|
||||
bson.M{"startAt": nil},
|
||||
bson.M{"startAt": bson.M{"$lte": now}},
|
||||
}},
|
||||
bson.M{"$or": bson.A{
|
||||
bson.M{"endAt": bson.M{"$exists": false}},
|
||||
bson.M{"endAt": nil},
|
||||
bson.M{"endAt": bson.M{"$gt": now}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
var out Experiment
|
||||
if err := experimentColl().FindOne(
|
||||
&out,
|
||||
filter,
|
||||
options.FindOne().SetSort(bson.D{{Key: "publishedAt", Value: -1}, {Key: "_id", Value: -1}}),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.ID.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func FindByExperimentID(experimentID string) (*Experiment, error) {
|
||||
var out Experiment
|
||||
if err := experimentColl().FindOne(&out, bson.M{"experimentId": experimentID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.ID.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func Disable(experimentID, operator string) (bool, error) {
|
||||
now := time.Now()
|
||||
result, err := experimentColl().UpdateMany(
|
||||
bson.M{"experimentId": experimentID, "status": StatusActive},
|
||||
bson.M{
|
||||
"$set": bson.M{
|
||||
"status": StatusDisabled,
|
||||
"disabledAt": now,
|
||||
"disabledBy": operator,
|
||||
"updatedAt": now,
|
||||
},
|
||||
"$unset": bson.M{"activeSlot": ""},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.ModifiedCount > 0, nil
|
||||
}
|
||||
|
||||
func InsertEvent(event *AnalyticsEvent) (bool, error) {
|
||||
event.EventID = strings.TrimSpace(event.EventID)
|
||||
event.EventName = strings.ToUpper(strings.TrimSpace(event.EventName))
|
||||
event.SessionID = strings.TrimSpace(event.SessionID)
|
||||
event.ExperimentID = strings.TrimSpace(event.ExperimentID)
|
||||
event.Variant = strings.ToUpper(strings.TrimSpace(event.Variant))
|
||||
event.ID = primitive.NewObjectID()
|
||||
event.ReceivedAt = time.Now()
|
||||
result, err := eventColl().UpsertOne(
|
||||
bson.M{"eventId": event.EventID},
|
||||
bson.M{"$setOnInsert": event},
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.UpsertedCount > 0, nil
|
||||
}
|
||||
|
||||
func EventStatistics(experimentID string) ([]EventStat, []ProductImpressionStat, error) {
|
||||
var eventRaw []struct {
|
||||
ID struct {
|
||||
Variant string `bson:"variant"`
|
||||
EventName string `bson:"eventName"`
|
||||
} `bson:"_id"`
|
||||
People int `bson:"people"`
|
||||
Times int `bson:"times"`
|
||||
}
|
||||
err := eventColl().Aggregate(&eventRaw, []bson.M{
|
||||
{"$match": bson.M{"experimentId": experimentID}},
|
||||
{"$group": bson.M{
|
||||
"_id": bson.M{
|
||||
"variant": "$variant",
|
||||
"eventName": "$eventName",
|
||||
"uid": "$uid",
|
||||
},
|
||||
"userTimes": bson.M{"$sum": 1},
|
||||
}},
|
||||
{"$group": bson.M{
|
||||
"_id": bson.M{
|
||||
"variant": "$_id.variant",
|
||||
"eventName": "$_id.eventName",
|
||||
},
|
||||
"people": bson.M{"$sum": 1},
|
||||
"times": bson.M{"$sum": "$userTimes"},
|
||||
}},
|
||||
{"$sort": bson.D{{Key: "_id.variant", Value: 1}, {Key: "_id.eventName", Value: 1}}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
eventStats := make([]EventStat, 0, len(eventRaw))
|
||||
for _, item := range eventRaw {
|
||||
eventStats = append(eventStats, EventStat{
|
||||
Variant: item.ID.Variant,
|
||||
EventName: item.ID.EventName,
|
||||
People: item.People,
|
||||
Times: item.Times,
|
||||
})
|
||||
}
|
||||
|
||||
var productRaw []struct {
|
||||
ID struct {
|
||||
Variant string `bson:"variant"`
|
||||
ProductID primitive.ObjectID `bson:"productId"`
|
||||
} `bson:"_id"`
|
||||
People int `bson:"people"`
|
||||
Times int `bson:"times"`
|
||||
}
|
||||
err = eventColl().Aggregate(&productRaw, []bson.M{
|
||||
{"$match": bson.M{
|
||||
"experimentId": experimentID,
|
||||
"eventName": EventProductImpression,
|
||||
}},
|
||||
{"$group": bson.M{
|
||||
"_id": bson.M{
|
||||
"variant": "$variant",
|
||||
"productId": "$productId",
|
||||
"uid": "$uid",
|
||||
},
|
||||
"userTimes": bson.M{"$sum": 1},
|
||||
}},
|
||||
{"$group": bson.M{
|
||||
"_id": bson.M{
|
||||
"variant": "$_id.variant",
|
||||
"productId": "$_id.productId",
|
||||
},
|
||||
"people": bson.M{"$sum": 1},
|
||||
"times": bson.M{"$sum": "$userTimes"},
|
||||
}},
|
||||
{"$sort": bson.D{{Key: "_id.variant", Value: 1}, {Key: "times", Value: -1}}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
productStats := make([]ProductImpressionStat, 0, len(productRaw))
|
||||
for _, item := range productRaw {
|
||||
productStats = append(productStats, ProductImpressionStat{
|
||||
Variant: item.ID.Variant,
|
||||
ProductID: item.ID.ProductID,
|
||||
People: item.People,
|
||||
Times: item.Times,
|
||||
})
|
||||
}
|
||||
return eventStats, productStats, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package vipcardexperimentmod
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/bsontype"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// OrderedProductIDs exposes productIds as an order-number-to-product-ID map
|
||||
// while keeping a slice internally so every backend consumer has deterministic
|
||||
// numeric order. BSON is stored as an embedded document with keys 1..n.
|
||||
// Legacy JSON/BSON arrays remain readable for existing experiment records.
|
||||
type OrderedProductIDs []primitive.ObjectID
|
||||
|
||||
func (ids OrderedProductIDs) MarshalJSON() ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteByte('{')
|
||||
for index, productID := range ids {
|
||||
if index > 0 {
|
||||
buffer.WriteByte(',')
|
||||
}
|
||||
key, _ := json.Marshal(strconv.Itoa(index + 1))
|
||||
value, err := json.Marshal(productID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buffer.Write(key)
|
||||
buffer.WriteByte(':')
|
||||
buffer.Write(value)
|
||||
}
|
||||
buffer.WriteByte('}')
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func (ids *OrderedProductIDs) UnmarshalJSON(data []byte) error {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 {
|
||||
return fmt.Errorf("productIds is required")
|
||||
}
|
||||
if bytes.Equal(data, []byte("null")) {
|
||||
*ids = nil
|
||||
return nil
|
||||
}
|
||||
switch data[0] {
|
||||
case '[':
|
||||
var legacy []primitive.ObjectID
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return fmt.Errorf("invalid legacy productIds: %w", err)
|
||||
}
|
||||
*ids = legacy
|
||||
return nil
|
||||
case '{':
|
||||
var values map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &values); err != nil {
|
||||
return fmt.Errorf("invalid productIds map: %w", err)
|
||||
}
|
||||
ordered := make(OrderedProductIDs, len(values))
|
||||
occupied := make([]bool, len(values))
|
||||
seenProducts := make(map[primitive.ObjectID]struct{}, len(values))
|
||||
for key, rawProductID := range values {
|
||||
index, err := parseProductOrder(key, len(values))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var productID primitive.ObjectID
|
||||
if err = json.Unmarshal(rawProductID, &productID); err != nil || productID.IsZero() {
|
||||
return fmt.Errorf("productIds.%s must be a valid product ID", key)
|
||||
}
|
||||
if occupied[index] {
|
||||
return fmt.Errorf("productIds order %s is duplicated", key)
|
||||
}
|
||||
if _, exists := seenProducts[productID]; exists {
|
||||
return fmt.Errorf("productIds contains duplicate product ID: %s", productID.Hex())
|
||||
}
|
||||
occupied[index] = true
|
||||
seenProducts[productID] = struct{}{}
|
||||
ordered[index] = productID
|
||||
}
|
||||
*ids = ordered
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("productIds must be an order map")
|
||||
}
|
||||
}
|
||||
|
||||
func (ids OrderedProductIDs) MarshalBSONValue() (bsontype.Type, []byte, error) {
|
||||
document := make(bson.D, 0, len(ids))
|
||||
for index, productID := range ids {
|
||||
document = append(document, bson.E{
|
||||
Key: strconv.Itoa(index + 1),
|
||||
Value: productID,
|
||||
})
|
||||
}
|
||||
return bson.MarshalValue(document)
|
||||
}
|
||||
|
||||
func (ids *OrderedProductIDs) UnmarshalBSONValue(valueType bsontype.Type, data []byte) error {
|
||||
rawValue := bson.RawValue{Type: valueType, Value: data}
|
||||
switch valueType {
|
||||
case bsontype.Array:
|
||||
var legacy []primitive.ObjectID
|
||||
if err := rawValue.Unmarshal(&legacy); err != nil {
|
||||
return fmt.Errorf("invalid legacy productIds: %w", err)
|
||||
}
|
||||
*ids = legacy
|
||||
return nil
|
||||
case bsontype.EmbeddedDocument:
|
||||
var document bson.Raw
|
||||
if err := rawValue.Unmarshal(&document); err != nil {
|
||||
return fmt.Errorf("invalid productIds document: %w", err)
|
||||
}
|
||||
elements, err := document.Elements()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid productIds document: %w", err)
|
||||
}
|
||||
ordered := make(OrderedProductIDs, len(elements))
|
||||
occupied := make([]bool, len(elements))
|
||||
seenProducts := make(map[primitive.ObjectID]struct{}, len(elements))
|
||||
for _, element := range elements {
|
||||
key := element.Key()
|
||||
index, parseErr := parseProductOrder(key, len(elements))
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
productID, ok := element.Value().ObjectIDOK()
|
||||
if !ok || productID.IsZero() {
|
||||
return fmt.Errorf("productIds.%s must be a valid product ID", key)
|
||||
}
|
||||
if occupied[index] {
|
||||
return fmt.Errorf("productIds order %s is duplicated", key)
|
||||
}
|
||||
if _, exists := seenProducts[productID]; exists {
|
||||
return fmt.Errorf("productIds contains duplicate product ID: %s", productID.Hex())
|
||||
}
|
||||
occupied[index] = true
|
||||
seenProducts[productID] = struct{}{}
|
||||
ordered[index] = productID
|
||||
}
|
||||
*ids = ordered
|
||||
return nil
|
||||
case bsontype.Null, bsontype.Undefined:
|
||||
*ids = nil
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("productIds must be an ordered document")
|
||||
}
|
||||
}
|
||||
|
||||
func parseProductOrder(key string, size int) (int, error) {
|
||||
order, err := strconv.Atoi(key)
|
||||
if err != nil || order <= 0 || strconv.Itoa(order) != key {
|
||||
return 0, fmt.Errorf("productIds order must be a positive integer: %s", key)
|
||||
}
|
||||
if order > size {
|
||||
return 0, fmt.Errorf("productIds order must be continuous from 1")
|
||||
}
|
||||
return order - 1, nil
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package vipcardexperimentmod
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusActive = "ACTIVE"
|
||||
StatusDisabled = "DISABLED"
|
||||
activeSlot = "VIP_CARD_CURRENT"
|
||||
|
||||
VariantA = "A"
|
||||
VariantB = "B"
|
||||
|
||||
BadgeMostPopular = "MOST_POPULAR"
|
||||
BadgeNewUser = "NEW_USER_OFFER"
|
||||
|
||||
EventCardPageView = "VIP_CARD_PAGE_VIEW"
|
||||
EventProductImpression = "VIP_PRODUCT_IMPRESSION"
|
||||
EventCloseWithoutPay = "VIP_CARD_CLOSE_WITHOUT_PURCHASE"
|
||||
)
|
||||
|
||||
var validBadgeTypes = map[string]struct{}{
|
||||
BadgeMostPopular: {},
|
||||
BadgeNewUser: {},
|
||||
}
|
||||
|
||||
var validEventNames = map[string]struct{}{
|
||||
EventCardPageView: {},
|
||||
EventProductImpression: {},
|
||||
EventCloseWithoutPay: {},
|
||||
}
|
||||
|
||||
var hexColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$`)
|
||||
|
||||
type ProductBadge struct {
|
||||
ProductID primitive.ObjectID `json:"productId" bson:"productId"`
|
||||
BadgeType string `json:"badgeType" bson:"badgeType"`
|
||||
BadgeText string `json:"badgeText" bson:"badgeText"`
|
||||
}
|
||||
|
||||
type BadgeStyle struct {
|
||||
BadgeType string `json:"badgeType" bson:"badgeType"`
|
||||
BackgroundColor string `json:"backgroundColor" bson:"backgroundColor"`
|
||||
TextColor string `json:"textColor" bson:"textColor"`
|
||||
}
|
||||
|
||||
type UIConfig struct {
|
||||
BackgroundImage string `json:"backgroundImage" bson:"backgroundImage"`
|
||||
BadgeStyles []BadgeStyle `json:"badgeStyles" bson:"badgeStyles"`
|
||||
}
|
||||
|
||||
type VariantConfig struct {
|
||||
Name string `json:"name" bson:"name"`
|
||||
SkinKey string `json:"skinKey" bson:"skinKey"`
|
||||
DefaultProductID primitive.ObjectID `json:"defaultProductId,omitempty" bson:"defaultProductId,omitempty"`
|
||||
ProductIDs OrderedProductIDs `json:"productIds" bson:"productIds"`
|
||||
UIConfig *UIConfig `json:"uiConfig,omitempty" bson:"uiConfig,omitempty"`
|
||||
ProductBadges []ProductBadge `json:"productBadges" bson:"productBadges"`
|
||||
}
|
||||
|
||||
type Experiment struct {
|
||||
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
|
||||
ExperimentID string `json:"experimentId" bson:"experimentId"`
|
||||
Name string `json:"name" bson:"name"`
|
||||
Status string `json:"status" bson:"status"`
|
||||
TrafficA int `json:"trafficA" bson:"trafficA"`
|
||||
TrafficB int `json:"trafficB" bson:"trafficB"`
|
||||
VariantA VariantConfig `json:"variantA" bson:"variantA"`
|
||||
VariantB VariantConfig `json:"variantB" bson:"variantB"`
|
||||
StartAt *time.Time `json:"startAt,omitempty" bson:"startAt,omitempty"`
|
||||
EndAt *time.Time `json:"endAt,omitempty" bson:"endAt,omitempty"`
|
||||
PublishedAt time.Time `json:"publishedAt" bson:"publishedAt"`
|
||||
DisabledAt *time.Time `json:"disabledAt,omitempty" bson:"disabledAt,omitempty"`
|
||||
CreatedBy string `json:"createdBy" bson:"createdBy"`
|
||||
DisabledBy string `json:"disabledBy,omitempty" bson:"disabledBy,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"`
|
||||
ActiveSlot string `json:"-" bson:"activeSlot,omitempty"`
|
||||
}
|
||||
|
||||
type AnalyticsEvent struct {
|
||||
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
|
||||
EventID string `json:"eventId" bson:"eventId"`
|
||||
EventName string `json:"eventName" bson:"eventName"`
|
||||
UID uint64 `json:"uid" bson:"uid"`
|
||||
SessionID string `json:"sessionId" bson:"sessionId"`
|
||||
OccurredAt time.Time `json:"occurredAt" bson:"occurredAt"`
|
||||
ExperimentID string `json:"experimentId" bson:"experimentId"`
|
||||
Variant string `json:"variant" bson:"variant"`
|
||||
ProductID primitive.ObjectID `json:"productId,omitempty" bson:"productId,omitempty"`
|
||||
ReceivedAt time.Time `json:"receivedAt" bson:"receivedAt"`
|
||||
}
|
||||
|
||||
type EventStat struct {
|
||||
Variant string `json:"variant" bson:"variant"`
|
||||
EventName string `json:"eventName" bson:"eventName"`
|
||||
People int `json:"people" bson:"people"`
|
||||
Times int `json:"times" bson:"times"`
|
||||
}
|
||||
|
||||
type ProductImpressionStat struct {
|
||||
Variant string `json:"variant" bson:"variant"`
|
||||
ProductID primitive.ObjectID `json:"productId" bson:"productId"`
|
||||
People int `json:"people" bson:"people"`
|
||||
Times int `json:"times" bson:"times"`
|
||||
}
|
||||
|
||||
func (c *UIConfig) Normalize() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.BackgroundImage = strings.TrimSpace(c.BackgroundImage)
|
||||
if c.BadgeStyles == nil {
|
||||
c.BadgeStyles = make([]BadgeStyle, 0)
|
||||
return
|
||||
}
|
||||
for i := range c.BadgeStyles {
|
||||
c.BadgeStyles[i].BadgeType = strings.ToUpper(strings.TrimSpace(c.BadgeStyles[i].BadgeType))
|
||||
c.BadgeStyles[i].BackgroundColor = strings.ToUpper(strings.TrimSpace(c.BadgeStyles[i].BackgroundColor))
|
||||
c.BadgeStyles[i].TextColor = strings.ToUpper(strings.TrimSpace(c.BadgeStyles[i].TextColor))
|
||||
}
|
||||
}
|
||||
|
||||
func (c UIConfig) Validate(name string) error {
|
||||
if len(c.BackgroundImage) > 2048 {
|
||||
return fmt.Errorf("%s.backgroundImage is too long", name)
|
||||
}
|
||||
seenTypes := make(map[string]struct{}, len(c.BadgeStyles))
|
||||
for _, style := range c.BadgeStyles {
|
||||
if _, ok := validBadgeTypes[style.BadgeType]; !ok {
|
||||
return fmt.Errorf("%s unsupported badgeType: %s", name, style.BadgeType)
|
||||
}
|
||||
if _, exists := seenTypes[style.BadgeType]; exists {
|
||||
return fmt.Errorf("%s allows only one style per badgeType", name)
|
||||
}
|
||||
seenTypes[style.BadgeType] = struct{}{}
|
||||
if !hexColorPattern.MatchString(style.BackgroundColor) {
|
||||
return fmt.Errorf("%s invalid backgroundColor for %s", name, style.BadgeType)
|
||||
}
|
||||
if !hexColorPattern.MatchString(style.TextColor) {
|
||||
return fmt.Errorf("%s invalid textColor for %s", name, style.BadgeType)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VariantConfig) Normalize() {
|
||||
v.Name = strings.TrimSpace(v.Name)
|
||||
v.SkinKey = strings.TrimSpace(v.SkinKey)
|
||||
if v.UIConfig != nil {
|
||||
v.UIConfig.Normalize()
|
||||
if v.UIConfig.BackgroundImage == "" && len(v.UIConfig.BadgeStyles) == 0 {
|
||||
v.UIConfig = nil
|
||||
}
|
||||
}
|
||||
seenProducts := make(map[primitive.ObjectID]struct{}, len(v.ProductIDs))
|
||||
productIDs := make(OrderedProductIDs, 0, len(v.ProductIDs))
|
||||
for _, id := range v.ProductIDs {
|
||||
if id.IsZero() {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenProducts[id]; exists {
|
||||
continue
|
||||
}
|
||||
seenProducts[id] = struct{}{}
|
||||
productIDs = append(productIDs, id)
|
||||
}
|
||||
v.ProductIDs = productIDs
|
||||
|
||||
badges := make([]ProductBadge, 0, len(v.ProductBadges))
|
||||
for _, badge := range v.ProductBadges {
|
||||
badge.BadgeType = strings.ToUpper(strings.TrimSpace(badge.BadgeType))
|
||||
badge.BadgeText = strings.TrimSpace(badge.BadgeText)
|
||||
if badge.BadgeText == "" {
|
||||
switch badge.BadgeType {
|
||||
case BadgeMostPopular:
|
||||
badge.BadgeText = "最受欢迎"
|
||||
case BadgeNewUser:
|
||||
badge.BadgeText = "新人特惠"
|
||||
}
|
||||
}
|
||||
badges = append(badges, badge)
|
||||
}
|
||||
v.ProductBadges = badges
|
||||
}
|
||||
|
||||
func (v VariantConfig) Validate(name string) error {
|
||||
if len(v.ProductIDs) == 0 {
|
||||
return fmt.Errorf("%s.productIds is required", name)
|
||||
}
|
||||
productSet := make(map[primitive.ObjectID]struct{}, len(v.ProductIDs))
|
||||
for _, id := range v.ProductIDs {
|
||||
productSet[id] = struct{}{}
|
||||
}
|
||||
if !v.DefaultProductID.IsZero() {
|
||||
if _, ok := productSet[v.DefaultProductID]; !ok {
|
||||
return fmt.Errorf("%s.defaultProductId must belong to productIds", name)
|
||||
}
|
||||
}
|
||||
if v.UIConfig != nil {
|
||||
if err := v.UIConfig.Validate(name + ".uiConfig"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
badgeProducts := make(map[primitive.ObjectID]struct{}, len(v.ProductBadges))
|
||||
for _, badge := range v.ProductBadges {
|
||||
if badge.ProductID.IsZero() {
|
||||
return fmt.Errorf("%s badge productId is required", name)
|
||||
}
|
||||
if _, exists := badgeProducts[badge.ProductID]; exists {
|
||||
return fmt.Errorf("%s allows only one badge per product", name)
|
||||
}
|
||||
badgeProducts[badge.ProductID] = struct{}{}
|
||||
if _, ok := productSet[badge.ProductID]; !ok {
|
||||
return fmt.Errorf("%s badge productId must belong to productIds", name)
|
||||
}
|
||||
if _, ok := validBadgeTypes[badge.BadgeType]; !ok {
|
||||
return fmt.Errorf("%s unsupported badgeType: %s", name, badge.BadgeType)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Experiment) Normalize() {
|
||||
e.ExperimentID = strings.TrimSpace(e.ExperimentID)
|
||||
e.Name = strings.TrimSpace(e.Name)
|
||||
e.Status = strings.ToUpper(strings.TrimSpace(e.Status))
|
||||
e.VariantA.Normalize()
|
||||
e.VariantB.Normalize()
|
||||
}
|
||||
|
||||
func (e Experiment) Validate() error {
|
||||
if e.ExperimentID == "" {
|
||||
return fmt.Errorf("experimentId is required")
|
||||
}
|
||||
if len(e.ExperimentID) > 128 {
|
||||
return fmt.Errorf("experimentId is too long")
|
||||
}
|
||||
if e.TrafficA < 0 || e.TrafficB < 0 || e.TrafficA+e.TrafficB != 100 {
|
||||
return fmt.Errorf("trafficA and trafficB must add up to 100")
|
||||
}
|
||||
if e.StartAt != nil && e.EndAt != nil && !e.EndAt.After(*e.StartAt) {
|
||||
return fmt.Errorf("endAt must be later than startAt")
|
||||
}
|
||||
if err := e.VariantA.Validate("variantA"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.VariantB.Validate("variantB"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e Experiment) ActiveAt(now time.Time) bool {
|
||||
if e.Status != StatusActive {
|
||||
return false
|
||||
}
|
||||
if e.StartAt != nil && e.StartAt.After(now) {
|
||||
return false
|
||||
}
|
||||
return e.EndAt == nil || e.EndAt.After(now)
|
||||
}
|
||||
|
||||
func (e Experiment) Assign(uid uint64) string {
|
||||
hasher := fnv.New32a()
|
||||
_, _ = hasher.Write([]byte(fmt.Sprintf("%s:%d", e.ExperimentID, uid)))
|
||||
if int(hasher.Sum32()%100) < e.TrafficA {
|
||||
return VariantA
|
||||
}
|
||||
return VariantB
|
||||
}
|
||||
|
||||
func (e Experiment) ConfigFor(variant string) (VariantConfig, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(variant)) {
|
||||
case VariantA:
|
||||
return e.VariantA, true
|
||||
case VariantB:
|
||||
return e.VariantB, true
|
||||
default:
|
||||
return VariantConfig{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidEventName(name string) bool {
|
||||
_, ok := validEventNames[strings.ToUpper(strings.TrimSpace(name))]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package vipcardexperimentmod
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
func TestExperimentNormalizeValidateAndAssign(t *testing.T) {
|
||||
first := primitive.NewObjectID()
|
||||
second := primitive.NewObjectID()
|
||||
experiment := Experiment{
|
||||
ExperimentID: " vip-card-test ",
|
||||
TrafficA: 50,
|
||||
TrafficB: 50,
|
||||
VariantA: VariantConfig{
|
||||
DefaultProductID: first,
|
||||
ProductIDs: []primitive.ObjectID{first, first, second},
|
||||
UIConfig: &UIConfig{
|
||||
BackgroundImage: " card-skin-a.png ",
|
||||
BadgeStyles: []BadgeStyle{{
|
||||
BadgeType: " most_popular ",
|
||||
BackgroundColor: " #f04432 ",
|
||||
TextColor: " #ffffff ",
|
||||
}},
|
||||
},
|
||||
ProductBadges: []ProductBadge{{
|
||||
ProductID: first,
|
||||
BadgeType: " most_popular ",
|
||||
}},
|
||||
},
|
||||
VariantB: VariantConfig{
|
||||
DefaultProductID: second,
|
||||
ProductIDs: []primitive.ObjectID{second, first},
|
||||
},
|
||||
}
|
||||
experiment.Normalize()
|
||||
if err := experiment.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
if experiment.ExperimentID != "vip-card-test" {
|
||||
t.Fatalf("ExperimentID = %q", experiment.ExperimentID)
|
||||
}
|
||||
if len(experiment.VariantA.ProductIDs) != 2 {
|
||||
t.Fatalf("duplicate product IDs were not removed: %#v", experiment.VariantA.ProductIDs)
|
||||
}
|
||||
if got := experiment.VariantA.ProductBadges[0].BadgeText; got != "最受欢迎" {
|
||||
t.Fatalf("default badge text = %q", got)
|
||||
}
|
||||
if got := experiment.VariantA.UIConfig.BackgroundImage; got != "card-skin-a.png" {
|
||||
t.Fatalf("background image = %q", got)
|
||||
}
|
||||
style := experiment.VariantA.UIConfig.BadgeStyles[0]
|
||||
if style.BadgeType != BadgeMostPopular ||
|
||||
style.BackgroundColor != "#F04432" ||
|
||||
style.TextColor != "#FFFFFF" {
|
||||
t.Fatalf("normalized badge style = %#v", style)
|
||||
}
|
||||
firstAssignment := experiment.Assign(12345)
|
||||
for i := 0; i < 10; i++ {
|
||||
if got := experiment.Assign(12345); got != firstAssignment {
|
||||
t.Fatalf("assignment changed: first=%s got=%s", firstAssignment, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExperimentValidateRejectsInvalidConfigurations(t *testing.T) {
|
||||
productID := primitive.NewObjectID()
|
||||
base := Experiment{
|
||||
ExperimentID: "experiment",
|
||||
TrafficA: 50,
|
||||
TrafficB: 50,
|
||||
VariantA: VariantConfig{
|
||||
DefaultProductID: productID,
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
},
|
||||
VariantB: VariantConfig{
|
||||
DefaultProductID: productID,
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
},
|
||||
}
|
||||
|
||||
invalidTraffic := base
|
||||
invalidTraffic.TrafficA = 90
|
||||
if err := invalidTraffic.Validate(); err == nil {
|
||||
t.Fatal("expected invalid traffic to fail")
|
||||
}
|
||||
|
||||
invalidDefault := base
|
||||
invalidDefault.VariantA.DefaultProductID = primitive.NewObjectID()
|
||||
if err := invalidDefault.Validate(); err == nil {
|
||||
t.Fatal("expected a default product outside productIds to fail")
|
||||
}
|
||||
|
||||
duplicateMostPopular := base
|
||||
duplicateMostPopular.VariantA.ProductIDs = append(
|
||||
duplicateMostPopular.VariantA.ProductIDs,
|
||||
primitive.NewObjectID(),
|
||||
)
|
||||
duplicateMostPopular.VariantA.ProductBadges = []ProductBadge{
|
||||
{ProductID: duplicateMostPopular.VariantA.ProductIDs[0], BadgeType: BadgeMostPopular},
|
||||
{ProductID: duplicateMostPopular.VariantA.ProductIDs[1], BadgeType: BadgeMostPopular},
|
||||
}
|
||||
if err := duplicateMostPopular.Validate(); err == nil {
|
||||
t.Fatal("expected multiple MOST_POPULAR badges to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExperimentValidateAllowsMissingDefaultProduct(t *testing.T) {
|
||||
productID := primitive.NewObjectID()
|
||||
experiment := Experiment{
|
||||
ExperimentID: "optional-default-product",
|
||||
TrafficA: 50,
|
||||
TrafficB: 50,
|
||||
VariantA: VariantConfig{
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
},
|
||||
VariantB: VariantConfig{
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
},
|
||||
}
|
||||
if err := experiment.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIConfigValidate(t *testing.T) {
|
||||
valid := UIConfig{
|
||||
BackgroundImage: "vip-card-skin.png",
|
||||
BadgeStyles: []BadgeStyle{
|
||||
{
|
||||
BadgeType: BadgeNewUser,
|
||||
BackgroundColor: "#F04432",
|
||||
TextColor: "#FFFFFFFF",
|
||||
},
|
||||
{
|
||||
BadgeType: BadgeMostPopular,
|
||||
BackgroundColor: "#2B251A",
|
||||
TextColor: "#F7D98C",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := valid.Validate("uiConfig"); err != nil {
|
||||
t.Fatalf("valid UI config failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
styles []BadgeStyle
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unsupported badge type",
|
||||
styles: []BadgeStyle{{
|
||||
BadgeType: "UNKNOWN",
|
||||
BackgroundColor: "#F04432",
|
||||
TextColor: "#FFFFFF",
|
||||
}},
|
||||
want: "unsupported badgeType",
|
||||
},
|
||||
{
|
||||
name: "invalid background color",
|
||||
styles: []BadgeStyle{{
|
||||
BadgeType: BadgeNewUser,
|
||||
BackgroundColor: "#FFF",
|
||||
TextColor: "#FFFFFF",
|
||||
}},
|
||||
want: "invalid backgroundColor",
|
||||
},
|
||||
{
|
||||
name: "invalid text color",
|
||||
styles: []BadgeStyle{{
|
||||
BadgeType: BadgeNewUser,
|
||||
BackgroundColor: "#F04432",
|
||||
TextColor: "white",
|
||||
}},
|
||||
want: "invalid textColor",
|
||||
},
|
||||
{
|
||||
name: "duplicate badge type",
|
||||
styles: []BadgeStyle{
|
||||
{
|
||||
BadgeType: BadgeNewUser,
|
||||
BackgroundColor: "#F04432",
|
||||
TextColor: "#FFFFFF",
|
||||
},
|
||||
{
|
||||
BadgeType: BadgeNewUser,
|
||||
BackgroundColor: "#2B251A",
|
||||
TextColor: "#F7D98C",
|
||||
},
|
||||
},
|
||||
want: "only one style per badgeType",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := UIConfig{BadgeStyles: tt.styles}
|
||||
err := config.Validate("uiConfig")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIConfigNormalizeDropsEmptyConfig(t *testing.T) {
|
||||
productID := primitive.NewObjectID()
|
||||
variant := VariantConfig{
|
||||
DefaultProductID: productID,
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
UIConfig: &UIConfig{},
|
||||
}
|
||||
variant.Normalize()
|
||||
if variant.UIConfig != nil {
|
||||
t.Fatalf("empty UI config = %#v, want nil", variant.UIConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExperimentValidateChecksVariantBUIConfig(t *testing.T) {
|
||||
productID := primitive.NewObjectID()
|
||||
experiment := Experiment{
|
||||
ExperimentID: "variant-b-ui-check",
|
||||
TrafficA: 50,
|
||||
TrafficB: 50,
|
||||
VariantA: VariantConfig{
|
||||
DefaultProductID: productID,
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
},
|
||||
VariantB: VariantConfig{
|
||||
DefaultProductID: productID,
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
UIConfig: &UIConfig{BadgeStyles: []BadgeStyle{{
|
||||
BadgeType: BadgeNewUser,
|
||||
BackgroundColor: "#FFF",
|
||||
TextColor: "#FFFFFF",
|
||||
}}},
|
||||
},
|
||||
}
|
||||
err := experiment.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "variantB.uiConfig") {
|
||||
t.Fatalf("Validate() error = %v, want variantB.uiConfig error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIConfigValidateBackgroundImageLength(t *testing.T) {
|
||||
valid := UIConfig{BackgroundImage: strings.Repeat("a", 2048)}
|
||||
if err := valid.Validate("uiConfig"); err != nil {
|
||||
t.Fatalf("2048-character background image failed: %v", err)
|
||||
}
|
||||
invalid := UIConfig{BackgroundImage: strings.Repeat("a", 2049)}
|
||||
if err := invalid.Validate("uiConfig"); err == nil {
|
||||
t.Fatal("expected overlong background image to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExperimentUIConfigJSONAndBSONRoundTrip(t *testing.T) {
|
||||
productID := primitive.NewObjectID()
|
||||
experiment := Experiment{
|
||||
ExperimentID: "vip-card-contract",
|
||||
TrafficA: 100,
|
||||
TrafficB: 0,
|
||||
VariantA: VariantConfig{
|
||||
DefaultProductID: productID,
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
UIConfig: &UIConfig{
|
||||
BackgroundImage: "vip-card-skin-a.png",
|
||||
BadgeStyles: []BadgeStyle{{
|
||||
BadgeType: BadgeMostPopular,
|
||||
BackgroundColor: "#2B251A",
|
||||
TextColor: "#F7D98C",
|
||||
}},
|
||||
},
|
||||
},
|
||||
VariantB: VariantConfig{
|
||||
DefaultProductID: productID,
|
||||
ProductIDs: []primitive.ObjectID{productID},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(experiment)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
if !bytes.Contains(jsonData, []byte(`"uiConfig"`)) ||
|
||||
!bytes.Contains(jsonData, []byte(`"backgroundColor":"#2B251A"`)) {
|
||||
t.Fatalf("JSON contract missing UI config: %s", jsonData)
|
||||
}
|
||||
var fromJSON Experiment
|
||||
if err = json.Unmarshal(jsonData, &fromJSON); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if fromJSON.VariantA.UIConfig == nil ||
|
||||
fromJSON.VariantA.UIConfig.BackgroundImage != "vip-card-skin-a.png" ||
|
||||
fromJSON.VariantB.UIConfig != nil {
|
||||
t.Fatalf("JSON round trip = %#v", fromJSON)
|
||||
}
|
||||
|
||||
bsonData, err := bson.Marshal(experiment)
|
||||
if err != nil {
|
||||
t.Fatalf("bson.Marshal() error = %v", err)
|
||||
}
|
||||
var fromBSON Experiment
|
||||
if err = bson.Unmarshal(bsonData, &fromBSON); err != nil {
|
||||
t.Fatalf("bson.Unmarshal() error = %v", err)
|
||||
}
|
||||
if fromBSON.VariantA.UIConfig == nil ||
|
||||
fromBSON.VariantA.UIConfig.BadgeStyles[0].TextColor != "#F7D98C" ||
|
||||
fromBSON.VariantB.UIConfig != nil {
|
||||
t.Fatalf("BSON round trip = %#v", fromBSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedProductIDsJSONMapAndLegacyArray(t *testing.T) {
|
||||
first := primitive.NewObjectID()
|
||||
second := primitive.NewObjectID()
|
||||
ids := OrderedProductIDs{first, second}
|
||||
|
||||
encoded, err := json.Marshal(ids)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
want := `{"1":"` + first.Hex() + `","2":"` + second.Hex() + `"}`
|
||||
if string(encoded) != want {
|
||||
t.Fatalf("JSON = %s, want %s", encoded, want)
|
||||
}
|
||||
|
||||
var fromMap OrderedProductIDs
|
||||
input := []byte(`{"2":"` + second.Hex() + `","1":"` + first.Hex() + `"}`)
|
||||
if err = json.Unmarshal(input, &fromMap); err != nil {
|
||||
t.Fatalf("map json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(fromMap) != 2 || fromMap[0] != first || fromMap[1] != second {
|
||||
t.Fatalf("map JSON order = %#v", fromMap)
|
||||
}
|
||||
|
||||
var fromLegacy OrderedProductIDs
|
||||
legacy := []byte(`["` + second.Hex() + `","` + first.Hex() + `"]`)
|
||||
if err = json.Unmarshal(legacy, &fromLegacy); err != nil {
|
||||
t.Fatalf("legacy json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(fromLegacy) != 2 || fromLegacy[0] != second || fromLegacy[1] != first {
|
||||
t.Fatalf("legacy JSON order = %#v", fromLegacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedProductIDsRejectsInvalidMap(t *testing.T) {
|
||||
productID := primitive.NewObjectID().Hex()
|
||||
tests := []string{
|
||||
`{"2":"` + productID + `"}`,
|
||||
`{"01":"` + productID + `"}`,
|
||||
`{"1":"` + productID + `","2":"` + productID + `"}`,
|
||||
}
|
||||
for _, input := range tests {
|
||||
var ids OrderedProductIDs
|
||||
if err := json.Unmarshal([]byte(input), &ids); err == nil {
|
||||
t.Fatalf("json.Unmarshal(%s) succeeded, want error", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedProductIDsBSONDocumentAndLegacyArray(t *testing.T) {
|
||||
first := primitive.NewObjectID()
|
||||
second := primitive.NewObjectID()
|
||||
type orderedDocument struct {
|
||||
ProductIDs OrderedProductIDs `bson:"productIds"`
|
||||
}
|
||||
|
||||
encoded, err := bson.Marshal(orderedDocument{ProductIDs: OrderedProductIDs{first, second}})
|
||||
if err != nil {
|
||||
t.Fatalf("bson.Marshal() error = %v", err)
|
||||
}
|
||||
rawValue := bson.Raw(encoded).Lookup("productIds")
|
||||
if rawValue.Type != bson.TypeEmbeddedDocument {
|
||||
t.Fatalf("productIds BSON type = %s, want document", rawValue.Type)
|
||||
}
|
||||
elements, err := rawValue.Document().Elements()
|
||||
if err != nil {
|
||||
t.Fatalf("productIds document error = %v", err)
|
||||
}
|
||||
if len(elements) != 2 || elements[0].Key() != "1" || elements[1].Key() != "2" ||
|
||||
elements[0].Value().ObjectID() != first || elements[1].Value().ObjectID() != second {
|
||||
t.Fatalf("productIds BSON document = %#v", elements)
|
||||
}
|
||||
|
||||
var fromDocument orderedDocument
|
||||
if err = bson.Unmarshal(encoded, &fromDocument); err != nil {
|
||||
t.Fatalf("document bson.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(fromDocument.ProductIDs) != 2 ||
|
||||
fromDocument.ProductIDs[0] != first || fromDocument.ProductIDs[1] != second {
|
||||
t.Fatalf("document BSON order = %#v", fromDocument.ProductIDs)
|
||||
}
|
||||
|
||||
legacy, err := bson.Marshal(struct {
|
||||
ProductIDs []primitive.ObjectID `bson:"productIds"`
|
||||
}{ProductIDs: []primitive.ObjectID{second, first}})
|
||||
if err != nil {
|
||||
t.Fatalf("legacy bson.Marshal() error = %v", err)
|
||||
}
|
||||
var fromLegacy orderedDocument
|
||||
if err = bson.Unmarshal(legacy, &fromLegacy); err != nil {
|
||||
t.Fatalf("legacy bson.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(fromLegacy.ProductIDs) != 2 ||
|
||||
fromLegacy.ProductIDs[0] != second || fromLegacy.ProductIDs[1] != first {
|
||||
t.Fatalf("legacy BSON order = %#v", fromLegacy.ProductIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExperimentActiveAt(t *testing.T) {
|
||||
now := time.Now()
|
||||
start := now.Add(-time.Minute)
|
||||
end := now.Add(time.Minute)
|
||||
experiment := Experiment{Status: StatusActive, StartAt: &start, EndAt: &end}
|
||||
if !experiment.ActiveAt(now) {
|
||||
t.Fatal("expected active experiment")
|
||||
}
|
||||
past := now.Add(-2 * time.Minute)
|
||||
experiment.EndAt = &past
|
||||
if experiment.ActiveAt(now) {
|
||||
t.Fatal("expired experiment must not be active")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user