[Backend] feature flags (#1354)
* feat(backend): added ff mock * feat(backend): added feature flag pg method * fix(backend): fixed ff request field * feat(backend): added multivariant support * feat(backend): added logic handler for feature flag filters * fix(backend): correct sql request for multivariants flags * feat(backend): added new fields to sessionStart response * feat(backend): removed unused fields from getFeatureFlags request * fix(backend): removed comments * feat(backend): added debug logs * feat(backend): added single type case for arrayToNum parser * feat(backend): added unit tests for feature flags
This commit is contained in:
parent
d4b8d3a7f9
commit
db7d624b3b
7 changed files with 1333 additions and 20 deletions
|
|
@ -8,6 +8,7 @@ import (
|
|||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"openreplay/backend/pkg/featureflags"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
|
|
@ -113,6 +114,14 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
ua := e.services.UaParser.ParseFromHTTPRequest(r)
|
||||
if ua == nil {
|
||||
ResponseWithError(w, http.StatusForbidden, errors.New("browser not recognized"), startTime, r.URL.Path, bodySize)
|
||||
return
|
||||
}
|
||||
|
||||
geoInfo := e.ExtractGeoData(r)
|
||||
|
||||
userUUID := uuid.GetUUID(req.UserUUID)
|
||||
tokenData, err := e.services.Tokenizer.Parse(req.Token)
|
||||
if err != nil || req.Reset { // Starting the new one
|
||||
|
|
@ -122,11 +131,6 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
ua := e.services.UaParser.ParseFromHTTPRequest(r)
|
||||
if ua == nil {
|
||||
ResponseWithError(w, http.StatusForbidden, errors.New("browser not recognized"), startTime, r.URL.Path, bodySize)
|
||||
return
|
||||
}
|
||||
startTimeMili := startTime.UnixMilli()
|
||||
sessionID, err := e.services.Flaker.Compose(uint64(startTimeMili))
|
||||
if err != nil {
|
||||
|
|
@ -140,7 +144,6 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request)
|
|||
Delay: startTimeMili - req.Timestamp,
|
||||
ExpTime: expTime.UnixMilli(),
|
||||
}
|
||||
geoInfo := e.ExtractGeoData(r)
|
||||
|
||||
sessionStart := &SessionStart{
|
||||
Timestamp: getSessionTimestamp(req, startTimeMili),
|
||||
|
|
@ -178,6 +181,12 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request)
|
|||
ResponseWithJSON(w, &StartSessionResponse{
|
||||
Token: e.services.Tokenizer.Compose(*tokenData),
|
||||
UserUUID: userUUID,
|
||||
UserOS: ua.OS,
|
||||
UserDevice: ua.Device,
|
||||
UserBrowser: ua.Browser,
|
||||
UserCountry: geoInfo.Country,
|
||||
UserState: geoInfo.State,
|
||||
UserCity: geoInfo.City,
|
||||
SessionID: strconv.FormatUint(tokenData.ID, 10),
|
||||
ProjectID: strconv.FormatUint(uint64(p.ProjectID), 10),
|
||||
BeaconSizeLimit: e.getBeaconSize(tokenData.ID),
|
||||
|
|
@ -281,3 +290,59 @@ func (e *Router) notStartedHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
ResponseOK(w, startTime, r.URL.Path, bodySize)
|
||||
}
|
||||
|
||||
func (e *Router) featureFlagsHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
||||
startTime := time.Now()
|
||||
bodySize := 0
|
||||
|
||||
// Check authorization
|
||||
_, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
||||
if err != nil {
|
||||
ResponseWithError(w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
||||
return
|
||||
}
|
||||
|
||||
// Check request body
|
||||
if r.Body == nil {
|
||||
ResponseWithError(w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, bodySize)
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
||||
if err != nil {
|
||||
log.Printf("error while reading request body: %s", err)
|
||||
ResponseWithError(w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||
return
|
||||
}
|
||||
bodySize = len(bodyBytes)
|
||||
|
||||
// Parse request body
|
||||
req := &featureflags.FeatureFlagsRequest{}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||
ResponseWithError(w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||
return
|
||||
}
|
||||
|
||||
// Grab flags and conditions for project
|
||||
projectID, err := strconv.ParseUint(req.ProjectID, 10, 32)
|
||||
if err != nil {
|
||||
ResponseWithError(w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||
return
|
||||
}
|
||||
flags, err := e.services.Database.GetFeatureFlags(uint32(projectID))
|
||||
if err != nil {
|
||||
ResponseWithError(w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||
return
|
||||
}
|
||||
|
||||
computedFlags, err := featureflags.ComputeFeatureFlags(flags, req)
|
||||
if err != nil {
|
||||
ResponseWithError(w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||
return
|
||||
}
|
||||
resp := &featureflags.FeatureFlagsResponse{
|
||||
Flags: computedFlags,
|
||||
}
|
||||
ResponseWithJSON(w, resp, startTime, r.URL.Path, bodySize)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ type StartSessionResponse struct {
|
|||
Delay int64 `json:"delay"`
|
||||
Token string `json:"token"`
|
||||
UserUUID string `json:"userUUID"`
|
||||
UserOS string `json:"userOS"`
|
||||
UserDevice string `json:"userDevice"`
|
||||
UserBrowser string `json:"userBrowser"`
|
||||
UserCountry string `json:"userCountry"`
|
||||
UserState string `json:"userState"`
|
||||
UserCity string `json:"userCity"`
|
||||
SessionID string `json:"sessionID"`
|
||||
ProjectID string `json:"projectID"`
|
||||
BeaconSizeLimit int64 `json:"beaconSizeLimit"`
|
||||
|
|
|
|||
|
|
@ -101,9 +101,10 @@ func (e *Router) init() {
|
|||
e.router.HandleFunc("/", e.root)
|
||||
|
||||
handlers := map[string]func(http.ResponseWriter, *http.Request){
|
||||
"/v1/web/not-started": e.notStartedHandlerWeb,
|
||||
"/v1/web/start": e.startSessionHandlerWeb,
|
||||
"/v1/web/i": e.pushMessagesHandlerWeb,
|
||||
"/v1/web/not-started": e.notStartedHandlerWeb,
|
||||
"/v1/web/start": e.startSessionHandlerWeb,
|
||||
"/v1/web/i": e.pushMessagesHandlerWeb,
|
||||
"/v1/web/feature-flags": e.featureFlagsHandlerWeb,
|
||||
}
|
||||
prefix := "/ingest"
|
||||
|
||||
|
|
|
|||
45
backend/pkg/db/postgres/feature-flag.go
Normal file
45
backend/pkg/db/postgres/feature-flag.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"openreplay/backend/pkg/featureflags"
|
||||
)
|
||||
|
||||
func (conn *Conn) GetFeatureFlags(projectID uint32) ([]*featureflags.FeatureFlag, error) {
|
||||
rows, err := conn.c.Query(`
|
||||
SELECT ff.flag_id, ff.flag_key, ff.flag_type, ff.is_persist, ff.payload, ff.rollout_percentages, ff.filters,
|
||||
ARRAY_AGG(fv.value) as values,
|
||||
ARRAY_AGG(fv.payload) as payloads,
|
||||
ARRAY_AGG(fv.rollout_percentage) AS variants_percentages
|
||||
FROM (
|
||||
SELECT ff.feature_flag_id AS flag_id, ff.flag_key AS flag_key, ff.flag_type, ff.is_persist, ff.payload,
|
||||
ARRAY_AGG(fc.rollout_percentage) AS rollout_percentages,
|
||||
ARRAY_AGG(fc.filters) AS filters
|
||||
FROM public.feature_flags ff
|
||||
LEFT JOIN public.feature_flags_conditions fc ON ff.feature_flag_id = fc.feature_flag_id
|
||||
WHERE ff.project_id = $1 AND ff.is_active = TRUE
|
||||
GROUP BY ff.feature_flag_id
|
||||
) AS ff
|
||||
LEFT JOIN public.feature_flags_variants fv ON ff.flag_type = 'multi' AND ff.flag_id = fv.feature_flag_id
|
||||
GROUP BY ff.flag_id, ff.flag_key, ff.flag_type, ff.is_persist, ff.payload, ff.filters, ff.rollout_percentages;
|
||||
`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var flags []*featureflags.FeatureFlag
|
||||
|
||||
for rows.Next() {
|
||||
var flag featureflags.FeatureFlagPG
|
||||
if err := rows.Scan(&flag.FlagID, &flag.FlagKey, &flag.FlagType, &flag.IsPersist, &flag.Payload, &flag.RolloutPercentages,
|
||||
&flag.Filters, &flag.Values, &flag.Payloads, &flag.VariantRollout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsedFlag, err := featureflags.ParseFeatureFlag(&flag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
flags = append(flags, parsedFlag)
|
||||
}
|
||||
return flags, nil
|
||||
}
|
||||
|
|
@ -4,11 +4,11 @@ import (
|
|||
"log"
|
||||
"openreplay/backend/pkg/db/types"
|
||||
"openreplay/backend/pkg/hashid"
|
||||
. "openreplay/backend/pkg/messages"
|
||||
"openreplay/backend/pkg/messages"
|
||||
"openreplay/backend/pkg/url"
|
||||
)
|
||||
|
||||
func (conn *Conn) InsertWebCustomEvent(sessionID uint64, projectID uint32, e *CustomEvent) error {
|
||||
func (conn *Conn) InsertWebCustomEvent(sessionID uint64, projectID uint32, e *messages.CustomEvent) error {
|
||||
err := conn.InsertCustomEvent(
|
||||
sessionID,
|
||||
uint64(e.Meta().Timestamp),
|
||||
|
|
@ -22,7 +22,7 @@ func (conn *Conn) InsertWebCustomEvent(sessionID uint64, projectID uint32, e *Cu
|
|||
return err
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebUserID(sessionID uint64, projectID uint32, userID *UserID) error {
|
||||
func (conn *Conn) InsertWebUserID(sessionID uint64, projectID uint32, userID *messages.UserID) error {
|
||||
err := conn.InsertUserID(sessionID, userID.ID)
|
||||
if err == nil {
|
||||
conn.insertAutocompleteValue(sessionID, projectID, "USERID", userID.ID)
|
||||
|
|
@ -30,7 +30,7 @@ func (conn *Conn) InsertWebUserID(sessionID uint64, projectID uint32, userID *Us
|
|||
return err
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebUserAnonymousID(sessionID uint64, projectID uint32, userAnonymousID *UserAnonymousID) error {
|
||||
func (conn *Conn) InsertWebUserAnonymousID(sessionID uint64, projectID uint32, userAnonymousID *messages.UserAnonymousID) error {
|
||||
err := conn.InsertUserAnonymousID(sessionID, userAnonymousID.ID)
|
||||
if err == nil {
|
||||
conn.insertAutocompleteValue(sessionID, projectID, "USERANONYMOUSID", userAnonymousID.ID)
|
||||
|
|
@ -38,7 +38,7 @@ func (conn *Conn) InsertWebUserAnonymousID(sessionID uint64, projectID uint32, u
|
|||
return err
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebPageEvent(sessionID uint64, projectID uint32, e *PageEvent) error {
|
||||
func (conn *Conn) InsertWebPageEvent(sessionID uint64, projectID uint32, e *messages.PageEvent) error {
|
||||
host, path, query, err := url.GetURLParts(e.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -60,7 +60,7 @@ func (conn *Conn) InsertWebPageEvent(sessionID uint64, projectID uint32, e *Page
|
|||
return nil
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebClickEvent(sessionID uint64, projectID uint32, e *MouseClick) error {
|
||||
func (conn *Conn) InsertWebClickEvent(sessionID uint64, projectID uint32, e *messages.MouseClick) error {
|
||||
if e.Label == "" {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ func (conn *Conn) InsertWebClickEvent(sessionID uint64, projectID uint32, e *Mou
|
|||
return nil
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebInputEvent(sessionID uint64, projectID uint32, e *InputEvent) error {
|
||||
func (conn *Conn) InsertWebInputEvent(sessionID uint64, projectID uint32, e *messages.InputEvent) error {
|
||||
if e.Label == "" {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ func (conn *Conn) InsertWebInputEvent(sessionID uint64, projectID uint32, e *Inp
|
|||
return nil
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebInputDuration(sessionID uint64, projectID uint32, e *InputChange) error {
|
||||
func (conn *Conn) InsertWebInputDuration(sessionID uint64, projectID uint32, e *messages.InputChange) error {
|
||||
if e.Label == "" {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ func (conn *Conn) InsertWebErrorEvent(sessionID uint64, projectID uint32, e *typ
|
|||
return nil
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebNetworkRequest(sessionID uint64, projectID uint32, savePayload bool, e *NetworkRequest) error {
|
||||
func (conn *Conn) InsertWebNetworkRequest(sessionID uint64, projectID uint32, savePayload bool, e *messages.NetworkRequest) error {
|
||||
var request, response *string
|
||||
if savePayload {
|
||||
request = &e.Request
|
||||
|
|
@ -133,7 +133,7 @@ func (conn *Conn) InsertWebNetworkRequest(sessionID uint64, projectID uint32, sa
|
|||
return nil
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebGraphQL(sessionID uint64, projectID uint32, savePayload bool, e *GraphQL) error {
|
||||
func (conn *Conn) InsertWebGraphQL(sessionID uint64, projectID uint32, savePayload bool, e *messages.GraphQL) error {
|
||||
var request, response *string
|
||||
if savePayload {
|
||||
request = &e.Variables
|
||||
|
|
@ -157,7 +157,7 @@ func (conn *Conn) InsertSessionReferrer(sessionID uint64, referrer string) error
|
|||
referrer, url.DiscardURLQuery(referrer), sessionID)
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertMouseThrashing(sessionID uint64, projectID uint32, e *MouseThrashing) error {
|
||||
func (conn *Conn) InsertMouseThrashing(sessionID uint64, projectID uint32, e *messages.MouseThrashing) error {
|
||||
issueID := hashid.MouseThrashingID(projectID, sessionID, e.Timestamp)
|
||||
if err := conn.bulks.Get("webIssues").Append(projectID, issueID, "mouse_thrashing", e.Url); err != nil {
|
||||
log.Printf("insert web issue err: %s", err)
|
||||
|
|
|
|||
357
backend/pkg/featureflags/feature-flag.go
Normal file
357
backend/pkg/featureflags/feature-flag.go
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
package featureflags
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/jackc/pgtype"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FeatureFlagsRequest struct {
|
||||
ProjectID string `json:"projectID"`
|
||||
UserOS string `json:"os"`
|
||||
UserDevice string `json:"device"`
|
||||
UserCountry string `json:"country"`
|
||||
UserState string `json:"state"`
|
||||
UserCity string `json:"city"`
|
||||
UserBrowser string `json:"browser"`
|
||||
Referrer string `json:"referrer"`
|
||||
UserID string `json:"userID"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
PersistFlags map[string]interface{} `json:"persistFlags"` // bool or string
|
||||
}
|
||||
|
||||
type FeatureFlagsResponse struct {
|
||||
Flags []interface{} `json:"flags"`
|
||||
}
|
||||
|
||||
type FilterType string
|
||||
|
||||
const (
|
||||
UserCountry FilterType = "userCountry"
|
||||
UserCity FilterType = "userCity"
|
||||
UserState FilterType = "userState"
|
||||
UserOS FilterType = "userOs"
|
||||
UserBrowser FilterType = "userBrowser"
|
||||
UserDevice FilterType = "userDevice"
|
||||
UserID FilterType = "userId"
|
||||
Referrer FilterType = "referrer"
|
||||
Metadata FilterType = "metadata"
|
||||
)
|
||||
|
||||
type FilterOperator string
|
||||
|
||||
const (
|
||||
Is FilterOperator = "is"
|
||||
IsNot FilterOperator = "isNot"
|
||||
IsAny FilterOperator = "isAny"
|
||||
Contains FilterOperator = "contains"
|
||||
NotContains FilterOperator = "notContains"
|
||||
StartsWith FilterOperator = "startsWith"
|
||||
EndsWith FilterOperator = "endsWith"
|
||||
IsUndefined FilterOperator = "isUndefined"
|
||||
)
|
||||
|
||||
type FeatureFlagFilter struct {
|
||||
Type FilterType `json:"type"`
|
||||
Operator FilterOperator `json:"operator"`
|
||||
Source string `json:"source"`
|
||||
Values []string `json:"value"`
|
||||
}
|
||||
|
||||
type FeatureFlagCondition struct {
|
||||
Filters []*FeatureFlagFilter
|
||||
RolloutPercentage int
|
||||
}
|
||||
|
||||
type FeatureFlagVariant struct {
|
||||
Value string
|
||||
Payload string
|
||||
RolloutPercentage int
|
||||
}
|
||||
|
||||
type FlagType string
|
||||
|
||||
const (
|
||||
Single FlagType = "single"
|
||||
Multi FlagType = "multi"
|
||||
)
|
||||
|
||||
type FeatureFlag struct {
|
||||
FlagID uint32
|
||||
FlagKey string
|
||||
FlagType FlagType
|
||||
IsPersist bool
|
||||
Payload string
|
||||
Conditions []*FeatureFlagCondition
|
||||
Variants []*FeatureFlagVariant
|
||||
}
|
||||
|
||||
type FeatureFlagPG struct {
|
||||
FlagID uint32
|
||||
FlagKey string
|
||||
FlagType string
|
||||
IsPersist bool
|
||||
Payload *string
|
||||
RolloutPercentages pgtype.EnumArray
|
||||
Filters pgtype.TextArray
|
||||
Values pgtype.TextArray
|
||||
Payloads pgtype.TextArray
|
||||
VariantRollout pgtype.EnumArray
|
||||
}
|
||||
|
||||
type flagInfo struct {
|
||||
Key string `json:"key"`
|
||||
IsPersist bool `json:"is_persist"`
|
||||
Value interface{} `json:"value"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
func numArrayToIntSlice(arr *pgtype.EnumArray) []int {
|
||||
slice := make([]int, 0, len(arr.Elements))
|
||||
for i := range arr.Elements {
|
||||
num, err := strconv.Atoi(arr.Elements[i].String)
|
||||
if err != nil {
|
||||
log.Printf("can't convert string to int: %v, full arr struct: %+v", err, *arr)
|
||||
slice = append(slice, 0)
|
||||
} else {
|
||||
slice = append(slice, num)
|
||||
}
|
||||
}
|
||||
return slice
|
||||
}
|
||||
|
||||
func parseFlagConditions(conditions *pgtype.TextArray, rolloutPercentages *pgtype.EnumArray) ([]*FeatureFlagCondition, error) {
|
||||
percents := numArrayToIntSlice(rolloutPercentages)
|
||||
if len(conditions.Elements) != len(percents) {
|
||||
return nil, fmt.Errorf("error: len(conditions.Elements) != len(percents)")
|
||||
}
|
||||
conds := make([]*FeatureFlagCondition, 0, len(conditions.Elements))
|
||||
for i, currCond := range conditions.Elements {
|
||||
var filters []*FeatureFlagFilter
|
||||
|
||||
err := json.Unmarshal([]byte(currCond.String), &filters)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("filter unmarshal error: %v", err)
|
||||
}
|
||||
conds = append(conds, &FeatureFlagCondition{
|
||||
Filters: filters,
|
||||
RolloutPercentage: percents[i],
|
||||
})
|
||||
}
|
||||
return conds, nil
|
||||
}
|
||||
|
||||
func parseFlagVariants(values *pgtype.TextArray, payloads *pgtype.TextArray, variantRollout *pgtype.EnumArray) ([]*FeatureFlagVariant, error) {
|
||||
percents := numArrayToIntSlice(variantRollout)
|
||||
variants := make([]*FeatureFlagVariant, 0, len(values.Elements))
|
||||
if len(values.Elements) != len(payloads.Elements) || len(values.Elements) != len(percents) {
|
||||
return nil, fmt.Errorf("wrong number of variant elements")
|
||||
}
|
||||
for i := range values.Elements {
|
||||
variants = append(variants, &FeatureFlagVariant{
|
||||
Value: values.Elements[i].String,
|
||||
Payload: payloads.Elements[i].String,
|
||||
RolloutPercentage: percents[i],
|
||||
})
|
||||
}
|
||||
return variants, nil
|
||||
}
|
||||
|
||||
func ParseFeatureFlag(rawFlag *FeatureFlagPG) (*FeatureFlag, error) {
|
||||
flag := &FeatureFlag{
|
||||
FlagID: rawFlag.FlagID,
|
||||
FlagKey: rawFlag.FlagKey,
|
||||
FlagType: FlagType(rawFlag.FlagType),
|
||||
IsPersist: rawFlag.IsPersist,
|
||||
Payload: func() string {
|
||||
if rawFlag.Payload != nil {
|
||||
return *rawFlag.Payload
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
}
|
||||
// Parse conditions
|
||||
conditions, err := parseFlagConditions(&rawFlag.Filters, &rawFlag.RolloutPercentages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: parseFlagConditions: %v", err)
|
||||
}
|
||||
flag.Conditions = conditions
|
||||
|
||||
if flag.FlagType == Single {
|
||||
flag.Variants = []*FeatureFlagVariant{}
|
||||
return flag, nil
|
||||
}
|
||||
|
||||
// Parse variants
|
||||
variants, err := parseFlagVariants(&rawFlag.Values, &rawFlag.Payloads, &rawFlag.VariantRollout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: parseFlagVariants: %v", err)
|
||||
}
|
||||
flag.Variants = variants
|
||||
return flag, nil
|
||||
}
|
||||
|
||||
func checkCondition(varValue string, exprValues []string, operator FilterOperator) bool {
|
||||
switch operator {
|
||||
case Is:
|
||||
for _, value := range exprValues {
|
||||
if varValue == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case IsNot:
|
||||
for _, value := range exprValues {
|
||||
if varValue == value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case IsAny:
|
||||
if varValue != "" {
|
||||
return true
|
||||
}
|
||||
case Contains:
|
||||
for _, value := range exprValues {
|
||||
if strings.Contains(varValue, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case NotContains:
|
||||
for _, value := range exprValues {
|
||||
if strings.Contains(varValue, value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case StartsWith:
|
||||
for _, value := range exprValues {
|
||||
if strings.HasPrefix(varValue, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case EndsWith:
|
||||
for _, value := range exprValues {
|
||||
if strings.HasSuffix(varValue, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case IsUndefined:
|
||||
return varValue == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ComputeFlagValue(flag *FeatureFlag, sessInfo *FeatureFlagsRequest) interface{} {
|
||||
for _, cond := range flag.Conditions {
|
||||
conditionValue := true
|
||||
for _, filter := range cond.Filters {
|
||||
filterValue := false
|
||||
switch filter.Type {
|
||||
case UserCountry:
|
||||
filterValue = checkCondition(sessInfo.UserCountry, filter.Values, filter.Operator)
|
||||
case UserCity:
|
||||
filterValue = checkCondition(sessInfo.UserCity, filter.Values, filter.Operator)
|
||||
case UserState:
|
||||
filterValue = checkCondition(sessInfo.UserState, filter.Values, filter.Operator)
|
||||
case UserOS:
|
||||
filterValue = checkCondition(sessInfo.UserOS, filter.Values, filter.Operator)
|
||||
case UserBrowser:
|
||||
filterValue = checkCondition(sessInfo.UserBrowser, filter.Values, filter.Operator)
|
||||
case UserDevice:
|
||||
filterValue = checkCondition(sessInfo.UserDevice, filter.Values, filter.Operator)
|
||||
case UserID:
|
||||
filterValue = checkCondition(sessInfo.UserID, filter.Values, filter.Operator)
|
||||
case Referrer:
|
||||
filterValue = checkCondition(sessInfo.Referrer, filter.Values, filter.Operator)
|
||||
case Metadata:
|
||||
filterValue = checkCondition(sessInfo.Metadata[filter.Source], filter.Values, filter.Operator)
|
||||
default:
|
||||
filterValue = false
|
||||
}
|
||||
// If any filter is false, the condition is false, so we can check the next condition
|
||||
if !filterValue {
|
||||
conditionValue = false
|
||||
break
|
||||
}
|
||||
}
|
||||
// If any condition is true, we can return the flag value
|
||||
if conditionValue {
|
||||
if cond.RolloutPercentage == 0 {
|
||||
return nil
|
||||
}
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
randNum := rand.Intn(100)
|
||||
if randNum > cond.RolloutPercentage {
|
||||
return nil
|
||||
}
|
||||
if flag.FlagType == Single {
|
||||
return flagInfo{
|
||||
Key: flag.FlagKey,
|
||||
IsPersist: flag.IsPersist,
|
||||
Value: true,
|
||||
Payload: flag.Payload,
|
||||
}
|
||||
}
|
||||
// Multi variant flag
|
||||
randNum = rand.Intn(100)
|
||||
prev, curr := 0, 0
|
||||
for _, variant := range flag.Variants {
|
||||
curr += variant.RolloutPercentage
|
||||
if randNum >= prev && randNum <= curr {
|
||||
return flagInfo{
|
||||
Key: flag.FlagKey,
|
||||
IsPersist: flag.IsPersist,
|
||||
Value: variant.Value,
|
||||
Payload: variant.Payload,
|
||||
}
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ComputeFeatureFlags(flags []*FeatureFlag, sessInfo *FeatureFlagsRequest) ([]interface{}, error) {
|
||||
result := make([]interface{}, 0, len(flags))
|
||||
|
||||
for _, flag := range flags {
|
||||
if val, ok := sessInfo.PersistFlags[flag.FlagKey]; ok && flag.IsPersist {
|
||||
if flag.FlagType == Single {
|
||||
result = append(result, flagInfo{
|
||||
Key: flag.FlagKey,
|
||||
IsPersist: flag.IsPersist,
|
||||
Value: val,
|
||||
Payload: flag.Payload,
|
||||
})
|
||||
continue
|
||||
} else {
|
||||
found := false
|
||||
for _, variant := range flag.Variants {
|
||||
if variant.Value == val {
|
||||
found = true
|
||||
result = append(result, flagInfo{
|
||||
Key: flag.FlagKey,
|
||||
IsPersist: flag.IsPersist,
|
||||
Value: val,
|
||||
Payload: variant.Payload,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if computedFlag := ComputeFlagValue(flag, sessInfo); computedFlag != nil {
|
||||
result = append(result, computedFlag)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
839
backend/pkg/featureflags/feature-flag_test.go
Normal file
839
backend/pkg/featureflags/feature-flag_test.go
Normal file
|
|
@ -0,0 +1,839 @@
|
|||
package featureflags
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgtype"
|
||||
)
|
||||
|
||||
func TestNumArrayToIntSlice(t *testing.T) {
|
||||
// Test case 1: Valid array
|
||||
arr1 := &pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{
|
||||
{String: "10"},
|
||||
{String: "20"},
|
||||
{String: "30"},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 3}},
|
||||
}
|
||||
expected1 := []int{10, 20, 30}
|
||||
|
||||
result1 := numArrayToIntSlice(arr1)
|
||||
if !reflect.DeepEqual(result1, expected1) {
|
||||
t.Errorf("Expected %v, but got %v", expected1, result1)
|
||||
}
|
||||
|
||||
// Test case 2: Empty array
|
||||
arr2 := &pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 0}},
|
||||
}
|
||||
expected2 := []int{}
|
||||
|
||||
result2 := numArrayToIntSlice(arr2)
|
||||
if !reflect.DeepEqual(result2, expected2) {
|
||||
t.Errorf("Expected %v, but got %v", expected2, result2)
|
||||
}
|
||||
|
||||
// Test case 3: Invalid number
|
||||
arr3 := &pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{
|
||||
{String: "10"},
|
||||
{String: "20"},
|
||||
{String: "invalid"},
|
||||
{String: "30"},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 4}},
|
||||
}
|
||||
expected3 := []int{10, 20, 0, 30}
|
||||
|
||||
// Capture the log output for the invalid number
|
||||
logBuffer := &bytes.Buffer{}
|
||||
log.SetOutput(logBuffer)
|
||||
defer log.SetOutput(os.Stderr)
|
||||
|
||||
result3 := numArrayToIntSlice(arr3)
|
||||
if !reflect.DeepEqual(result3, expected3) {
|
||||
t.Errorf("Expected %v, but got %v", expected3, result3)
|
||||
}
|
||||
|
||||
// Check the log output for the invalid number
|
||||
logOutput := logBuffer.String()
|
||||
if logOutput == "" {
|
||||
t.Error("Expected log output for invalid number, but got empty")
|
||||
}
|
||||
if !strings.Contains(logOutput, "strconv.Atoi: parsing \"invalid\": invalid syntax") {
|
||||
t.Errorf("Expected log output containing the error message, but got: %s", logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFlagConditions(t *testing.T) {
|
||||
// Test case 1: Valid conditions
|
||||
conditions1 := &pgtype.TextArray{
|
||||
Elements: []pgtype.Text{
|
||||
{String: `[{"type": "userCountry", "operator": "is", "source": "source1", "value": ["value1"]}]`},
|
||||
{String: `[{"type": "userCity", "operator": "contains", "source": "source2", "value": ["value2", "value3"]}]`},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 2}},
|
||||
}
|
||||
rolloutPercentages1 := &pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{
|
||||
{String: "50"},
|
||||
{String: "30"},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 2}},
|
||||
}
|
||||
expected1 := []*FeatureFlagCondition{
|
||||
{
|
||||
Filters: []*FeatureFlagFilter{
|
||||
{
|
||||
Type: UserCountry,
|
||||
Operator: Is,
|
||||
Source: "source1",
|
||||
Values: []string{"value1"},
|
||||
},
|
||||
},
|
||||
RolloutPercentage: 50,
|
||||
},
|
||||
{
|
||||
Filters: []*FeatureFlagFilter{
|
||||
{
|
||||
Type: UserCity,
|
||||
Operator: Contains,
|
||||
Source: "source2",
|
||||
Values: []string{"value2", "value3"},
|
||||
},
|
||||
},
|
||||
RolloutPercentage: 30,
|
||||
},
|
||||
}
|
||||
|
||||
result1, err1 := parseFlagConditions(conditions1, rolloutPercentages1)
|
||||
if err1 != nil {
|
||||
t.Errorf("Error parsing flag conditions: %v", err1)
|
||||
}
|
||||
if !reflect.DeepEqual(result1, expected1) {
|
||||
t.Errorf("Expected %v, but got %v", expected1, result1)
|
||||
}
|
||||
|
||||
// Test case 2: Empty conditions array
|
||||
conditions2 := &pgtype.TextArray{
|
||||
Elements: []pgtype.Text{},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 0}},
|
||||
}
|
||||
rolloutPercentages2 := &pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 0}},
|
||||
}
|
||||
expected2 := []*FeatureFlagCondition{}
|
||||
|
||||
result2, err2 := parseFlagConditions(conditions2, rolloutPercentages2)
|
||||
if err2 != nil {
|
||||
t.Errorf("Error parsing flag conditions: %v", err2)
|
||||
}
|
||||
if !reflect.DeepEqual(result2, expected2) {
|
||||
t.Errorf("Expected %v, but got %v", expected2, result2)
|
||||
}
|
||||
|
||||
// Test case 3: Mismatched number of elements
|
||||
conditions3 := &pgtype.TextArray{
|
||||
Elements: []pgtype.Text{
|
||||
{String: `[{"type": "userCountry", "operator": "is", "source": "source1", "value": ["value1"]}]`},
|
||||
{String: `[{"type": "userCity", "operator": "contains", "source": "source2", "value": ["value2", "value3"]}]`},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 2}},
|
||||
}
|
||||
rolloutPercentages3 := &pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{
|
||||
{String: "50"},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 1}},
|
||||
}
|
||||
expectedErrorMsg := "error: len(conditions.Elements) != len(percents)"
|
||||
if _, err3 := parseFlagConditions(conditions3, rolloutPercentages3); err3 == nil || err3.Error() != expectedErrorMsg {
|
||||
t.Errorf("Expected error: %v, but got: %v", expectedErrorMsg, err3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFlagVariants(t *testing.T) {
|
||||
// Test case 1: Valid variants
|
||||
values1 := &pgtype.TextArray{
|
||||
Elements: []pgtype.Text{
|
||||
{String: "variant1"},
|
||||
{String: "variant2"},
|
||||
{String: "variant3"},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 3}},
|
||||
}
|
||||
payloads1 := &pgtype.TextArray{
|
||||
Elements: []pgtype.Text{
|
||||
{String: "payload1"},
|
||||
{String: "payload2"},
|
||||
{String: "payload3"},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 3}},
|
||||
}
|
||||
variantRollout1 := &pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{
|
||||
{String: "50"},
|
||||
{String: "30"},
|
||||
{String: "20"},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 3}},
|
||||
}
|
||||
expected1 := []*FeatureFlagVariant{
|
||||
{Value: "variant1", Payload: "payload1", RolloutPercentage: 50},
|
||||
{Value: "variant2", Payload: "payload2", RolloutPercentage: 30},
|
||||
{Value: "variant3", Payload: "payload3", RolloutPercentage: 20},
|
||||
}
|
||||
|
||||
result1, err1 := parseFlagVariants(values1, payloads1, variantRollout1)
|
||||
if err1 != nil {
|
||||
t.Errorf("Error parsing flag variants: %v", err1)
|
||||
}
|
||||
if !reflect.DeepEqual(result1, expected1) {
|
||||
t.Errorf("Expected %v, but got %v", expected1, result1)
|
||||
}
|
||||
|
||||
// Test case 2: Empty values array
|
||||
values2 := &pgtype.TextArray{
|
||||
Elements: []pgtype.Text{},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 0}},
|
||||
}
|
||||
payloads2 := &pgtype.TextArray{
|
||||
Elements: []pgtype.Text{},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 0}},
|
||||
}
|
||||
variantRollout2 := &pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 0}},
|
||||
}
|
||||
expected2 := []*FeatureFlagVariant{}
|
||||
|
||||
result2, err2 := parseFlagVariants(values2, payloads2, variantRollout2)
|
||||
if err2 != nil {
|
||||
t.Errorf("Error parsing flag variants: %v", err2)
|
||||
}
|
||||
if !reflect.DeepEqual(result2, expected2) {
|
||||
t.Errorf("Expected %v, but got %v", expected2, result2)
|
||||
}
|
||||
|
||||
// Test case 3: Mismatched number of elements
|
||||
values3 := &pgtype.TextArray{
|
||||
Elements: []pgtype.Text{
|
||||
{String: "variant1"},
|
||||
{String: "variant2"},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 2}},
|
||||
}
|
||||
payloads3 := &pgtype.TextArray{
|
||||
Elements: []pgtype.Text{
|
||||
{String: "payload1"},
|
||||
{String: "payload2"},
|
||||
{String: "payload3"},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 3}},
|
||||
}
|
||||
variantRollout3 := &pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{
|
||||
{String: "50"},
|
||||
{String: "30"},
|
||||
},
|
||||
Dimensions: []pgtype.ArrayDimension{{Length: 2}},
|
||||
}
|
||||
|
||||
result3, err3 := parseFlagVariants(values3, payloads3, variantRollout3)
|
||||
expectedErrorMsg := "wrong number of variant elements"
|
||||
if err3 == nil || err3.Error() != expectedErrorMsg {
|
||||
t.Errorf("Expected error: %v, but got: %v", expectedErrorMsg, err3)
|
||||
}
|
||||
if result3 != nil {
|
||||
t.Errorf("Expected nil result, but got: %v", result3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeatureFlag(t *testing.T) {
|
||||
// Test case 1: Single flag with no variants
|
||||
rawFlag1 := &FeatureFlagPG{
|
||||
FlagID: 1,
|
||||
FlagKey: "flag_key",
|
||||
FlagType: "single",
|
||||
IsPersist: true,
|
||||
Payload: nil,
|
||||
RolloutPercentages: pgtype.EnumArray{},
|
||||
Filters: pgtype.TextArray{},
|
||||
Values: pgtype.TextArray{},
|
||||
Payloads: pgtype.TextArray{},
|
||||
VariantRollout: pgtype.EnumArray{},
|
||||
}
|
||||
expectedFlag1 := &FeatureFlag{
|
||||
FlagID: 1,
|
||||
FlagKey: "flag_key",
|
||||
FlagType: Single,
|
||||
IsPersist: true,
|
||||
Payload: "",
|
||||
Conditions: []*FeatureFlagCondition{},
|
||||
Variants: []*FeatureFlagVariant{},
|
||||
}
|
||||
|
||||
resultFlag1, err := ParseFeatureFlag(rawFlag1)
|
||||
if err != nil {
|
||||
t.Errorf("Error parsing feature flag: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(resultFlag1, expectedFlag1) {
|
||||
t.Errorf("Expected %v, but got %v", expectedFlag1, resultFlag1)
|
||||
}
|
||||
|
||||
// Test case 2: Multi flag with variants
|
||||
rawFlag2 := &FeatureFlagPG{
|
||||
FlagID: 2,
|
||||
FlagKey: "flag_key",
|
||||
FlagType: "multi",
|
||||
IsPersist: false,
|
||||
Payload: nil,
|
||||
RolloutPercentages: pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{
|
||||
{String: "70"},
|
||||
{String: "90"},
|
||||
},
|
||||
},
|
||||
Filters: pgtype.TextArray{
|
||||
Elements: []pgtype.Text{
|
||||
{String: `[{"type":"userCountry","operator":"is","source":"","value":["US"]},{"type":"userCity","operator":"startsWith","source":"cookie","value":["New York"]},{"type":"referrer","operator":"contains","source":"header","value":["google.com"]},{"type":"metadata","operator":"is","source":"","value":["some_value"]}]`},
|
||||
{String: `[{"type":"userCountry","operator":"is","source":"","value":["CA"]}]`},
|
||||
},
|
||||
},
|
||||
Values: pgtype.TextArray{
|
||||
Elements: []pgtype.Text{
|
||||
{String: "value1"},
|
||||
{String: "value2"},
|
||||
},
|
||||
},
|
||||
Payloads: pgtype.TextArray{
|
||||
Elements: []pgtype.Text{
|
||||
{String: "payload1"},
|
||||
{String: "payload2"},
|
||||
},
|
||||
},
|
||||
VariantRollout: pgtype.EnumArray{
|
||||
Elements: []pgtype.GenericText{
|
||||
{String: "50"},
|
||||
{String: "50"},
|
||||
},
|
||||
},
|
||||
}
|
||||
expectedFlag2 := &FeatureFlag{
|
||||
FlagID: 2,
|
||||
FlagKey: "flag_key",
|
||||
FlagType: Multi,
|
||||
IsPersist: false,
|
||||
Payload: "",
|
||||
Conditions: []*FeatureFlagCondition{
|
||||
{
|
||||
Filters: []*FeatureFlagFilter{
|
||||
{
|
||||
Type: UserCountry,
|
||||
Operator: Is,
|
||||
Source: "",
|
||||
Values: []string{"US"},
|
||||
},
|
||||
{
|
||||
Type: UserCity,
|
||||
Operator: StartsWith,
|
||||
Source: "cookie",
|
||||
Values: []string{"New York"},
|
||||
},
|
||||
{
|
||||
Type: Referrer,
|
||||
Operator: Contains,
|
||||
Source: "header",
|
||||
Values: []string{"google.com"},
|
||||
},
|
||||
{
|
||||
Type: Metadata,
|
||||
Operator: Is,
|
||||
Source: "",
|
||||
Values: []string{"some_value"},
|
||||
},
|
||||
},
|
||||
RolloutPercentage: 70,
|
||||
},
|
||||
{
|
||||
Filters: []*FeatureFlagFilter{
|
||||
{
|
||||
Type: UserCountry,
|
||||
Operator: Is,
|
||||
Source: "",
|
||||
Values: []string{"CA"},
|
||||
},
|
||||
},
|
||||
RolloutPercentage: 90,
|
||||
},
|
||||
},
|
||||
Variants: []*FeatureFlagVariant{
|
||||
{
|
||||
Value: "value1",
|
||||
Payload: "payload1",
|
||||
RolloutPercentage: 50,
|
||||
},
|
||||
{
|
||||
Value: "value2",
|
||||
Payload: "payload2",
|
||||
RolloutPercentage: 50,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resultFlag2, err := ParseFeatureFlag(rawFlag2)
|
||||
if err != nil {
|
||||
t.Errorf("Error parsing feature flag: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(resultFlag2, expectedFlag2) {
|
||||
t.Errorf("Expected %v, but got %v", expectedFlag2, resultFlag2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCondition(t *testing.T) {
|
||||
// Test case 1: Operator - Is, varValue is equal to one of the exprValues
|
||||
varValue := "Hello"
|
||||
exprValues := []string{"Hello", "Goodbye"}
|
||||
operator := Is
|
||||
expected := true
|
||||
result := checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 2: Operator - Is, varValue is not equal to any of the exprValues
|
||||
varValue = "Foo"
|
||||
expected = false
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 3: Operator - IsNot, varValue is equal to one of the exprValues
|
||||
varValue = "Hello"
|
||||
operator = IsNot
|
||||
expected = false
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 4: Operator - IsNot, varValue is not equal to any of the exprValues
|
||||
varValue = "Foo"
|
||||
expected = true
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 5: Operator - IsAny, varValue is not empty
|
||||
varValue = "Hello"
|
||||
operator = IsAny
|
||||
expected = true
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 6: Operator - IsAny, varValue is empty
|
||||
varValue = ""
|
||||
expected = false
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 7: Operator - Contains, varValue contains one of the exprValues
|
||||
varValue = "Hello, World!"
|
||||
operator = Contains
|
||||
expected = true
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 8: Operator - Contains, varValue does not contain any of the exprValues
|
||||
varValue = "Foo Bar"
|
||||
expected = false
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 9: Operator - NotContains, varValue contains one of the exprValues
|
||||
varValue = "Hello, World!"
|
||||
operator = NotContains
|
||||
expected = false
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 10: Operator - NotContains, varValue does not contain any of the exprValues
|
||||
varValue = "Foo Bar"
|
||||
expected = true
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 11: Operator - StartsWith, varValue starts with one of the exprValues
|
||||
varValue = "Hello, World!"
|
||||
operator = StartsWith
|
||||
expected = true
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 12: Operator - StartsWith, varValue does not start with any of the exprValues
|
||||
varValue = "Foo Bar"
|
||||
expected = false
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 13: Operator - EndsWith, varValue ends with one of the exprValues
|
||||
varValue = "Tom! Hello"
|
||||
operator = EndsWith
|
||||
expected = true
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 14: Operator - EndsWith, varValue does not end with any of the exprValues
|
||||
varValue = "Foo Bar"
|
||||
expected = false
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 15: Operator - IsUndefined, varValue is empty
|
||||
varValue = ""
|
||||
operator = IsUndefined
|
||||
expected = true
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
|
||||
// Test case 16: Operator - IsUndefined, varValue is not empty
|
||||
varValue = "Hello"
|
||||
expected = false
|
||||
result = checkCondition(varValue, exprValues, operator)
|
||||
if result != expected {
|
||||
t.Errorf("Expected %v, but got %v", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeFlagValue(t *testing.T) {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
// Test case 1: Single flag, condition true, rollout percentage 100
|
||||
flag1 := &FeatureFlag{
|
||||
FlagID: 1,
|
||||
FlagKey: "flag_key",
|
||||
FlagType: Single,
|
||||
IsPersist: true,
|
||||
Payload: "payload",
|
||||
Conditions: []*FeatureFlagCondition{
|
||||
{
|
||||
Filters: []*FeatureFlagFilter{
|
||||
{
|
||||
Type: UserCountry,
|
||||
Operator: Is,
|
||||
Source: "",
|
||||
Values: []string{"US"},
|
||||
},
|
||||
},
|
||||
RolloutPercentage: 100,
|
||||
},
|
||||
},
|
||||
Variants: []*FeatureFlagVariant{},
|
||||
}
|
||||
sessInfo1 := &FeatureFlagsRequest{
|
||||
UserCountry: "US",
|
||||
}
|
||||
|
||||
expectedResult1 := flagInfo{
|
||||
Key: "flag_key",
|
||||
IsPersist: true,
|
||||
Value: true,
|
||||
Payload: "payload",
|
||||
}
|
||||
|
||||
result1 := ComputeFlagValue(flag1, sessInfo1)
|
||||
if result1 == nil {
|
||||
t.Errorf("Expected %v, but got nil", expectedResult1)
|
||||
} else if !reflect.DeepEqual(result1, expectedResult1) {
|
||||
t.Errorf("Expected %v, but got %v", expectedResult1, result1)
|
||||
}
|
||||
|
||||
// Test case 2: Single flag, condition false, rollout percentage 100
|
||||
flag2 := &FeatureFlag{
|
||||
FlagID: 2,
|
||||
FlagKey: "flag_key",
|
||||
FlagType: Single,
|
||||
IsPersist: false,
|
||||
Payload: "payload",
|
||||
Conditions: []*FeatureFlagCondition{
|
||||
{
|
||||
Filters: []*FeatureFlagFilter{
|
||||
{
|
||||
Type: UserCountry,
|
||||
Operator: Is,
|
||||
Source: "",
|
||||
Values: []string{"US"},
|
||||
},
|
||||
},
|
||||
RolloutPercentage: 100,
|
||||
},
|
||||
},
|
||||
Variants: []*FeatureFlagVariant{},
|
||||
}
|
||||
sessInfo2 := &FeatureFlagsRequest{
|
||||
UserCountry: "CA",
|
||||
}
|
||||
|
||||
result2 := ComputeFlagValue(flag2, sessInfo2)
|
||||
if result2 != nil {
|
||||
t.Errorf("Expected nil, but got %v", result2)
|
||||
}
|
||||
|
||||
// Test case 3: Multi variant flag, condition true, rollout percentage 100
|
||||
flag3 := &FeatureFlag{
|
||||
FlagID: 3,
|
||||
FlagKey: "flag_key",
|
||||
FlagType: Multi,
|
||||
IsPersist: true,
|
||||
Payload: "payload",
|
||||
Conditions: []*FeatureFlagCondition{
|
||||
{
|
||||
Filters: []*FeatureFlagFilter{
|
||||
{
|
||||
Type: UserCountry,
|
||||
Operator: Is,
|
||||
Source: "",
|
||||
Values: []string{"US"},
|
||||
},
|
||||
},
|
||||
RolloutPercentage: 100,
|
||||
},
|
||||
},
|
||||
Variants: []*FeatureFlagVariant{
|
||||
{
|
||||
Value: "value1",
|
||||
Payload: "payload1",
|
||||
RolloutPercentage: 50,
|
||||
},
|
||||
{
|
||||
Value: "value2",
|
||||
Payload: "payload2",
|
||||
RolloutPercentage: 50,
|
||||
},
|
||||
},
|
||||
}
|
||||
sessInfo3 := &FeatureFlagsRequest{
|
||||
UserCountry: "US",
|
||||
}
|
||||
|
||||
expectedResult3 := flagInfo{
|
||||
Key: "flag_key",
|
||||
IsPersist: true,
|
||||
Value: "value1",
|
||||
Payload: "payload1",
|
||||
}
|
||||
|
||||
result3 := ComputeFlagValue(flag3, sessInfo3)
|
||||
if result3 == nil {
|
||||
t.Errorf("Expected %v, but got nil", expectedResult3)
|
||||
} else if !reflect.DeepEqual(result3, expectedResult3) {
|
||||
t.Errorf("Expected %v, but got %v", expectedResult3, result3)
|
||||
}
|
||||
|
||||
// Test case 4: Multi variant flag, condition true, rollout percentage 0
|
||||
flag4 := &FeatureFlag{
|
||||
FlagID: 4,
|
||||
FlagKey: "flag_key",
|
||||
FlagType: Multi,
|
||||
IsPersist: false,
|
||||
Payload: "payload",
|
||||
Conditions: []*FeatureFlagCondition{
|
||||
{
|
||||
Filters: []*FeatureFlagFilter{
|
||||
{
|
||||
Type: UserCountry,
|
||||
Operator: Is,
|
||||
Source: "",
|
||||
Values: []string{"US"},
|
||||
},
|
||||
},
|
||||
RolloutPercentage: 0,
|
||||
},
|
||||
},
|
||||
Variants: []*FeatureFlagVariant{
|
||||
{
|
||||
Value: "value1",
|
||||
Payload: "payload1",
|
||||
RolloutPercentage: 50,
|
||||
},
|
||||
{
|
||||
Value: "value2",
|
||||
Payload: "payload2",
|
||||
RolloutPercentage: 50,
|
||||
},
|
||||
},
|
||||
}
|
||||
sessInfo4 := &FeatureFlagsRequest{
|
||||
UserCountry: "US",
|
||||
}
|
||||
|
||||
result4 := ComputeFlagValue(flag4, sessInfo4)
|
||||
if result4 != nil {
|
||||
t.Errorf("Expected nil, but got %v", result4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeFeatureFlags(t *testing.T) {
|
||||
// Initialize test cases
|
||||
var testCases = []struct {
|
||||
name string
|
||||
flags []*FeatureFlag
|
||||
sessInfo *FeatureFlagsRequest
|
||||
expectedOutput []interface{}
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
"Persist flag with FlagType Single",
|
||||
[]*FeatureFlag{
|
||||
{
|
||||
FlagKey: "testFlag",
|
||||
FlagType: Single,
|
||||
IsPersist: true,
|
||||
Payload: "testPayload",
|
||||
},
|
||||
},
|
||||
&FeatureFlagsRequest{
|
||||
PersistFlags: map[string]interface{}{
|
||||
"testFlag": "testValue",
|
||||
},
|
||||
},
|
||||
[]interface{}{
|
||||
flagInfo{
|
||||
Key: "testFlag",
|
||||
IsPersist: true,
|
||||
Value: "testValue",
|
||||
Payload: "testPayload",
|
||||
},
|
||||
},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"Persist flag with FlagType Multi and variant match",
|
||||
[]*FeatureFlag{
|
||||
{
|
||||
FlagKey: "testFlag",
|
||||
FlagType: Multi,
|
||||
IsPersist: true,
|
||||
Variants: []*FeatureFlagVariant{
|
||||
{
|
||||
Value: "testValue",
|
||||
Payload: "testPayload",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
&FeatureFlagsRequest{
|
||||
PersistFlags: map[string]interface{}{
|
||||
"testFlag": "testValue",
|
||||
},
|
||||
},
|
||||
[]interface{}{
|
||||
flagInfo{
|
||||
Key: "testFlag",
|
||||
IsPersist: true,
|
||||
Value: "testValue",
|
||||
Payload: "testPayload",
|
||||
},
|
||||
},
|
||||
nil,
|
||||
},
|
||||
}
|
||||
|
||||
// Execute test cases
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
output, err := ComputeFeatureFlags(tc.flags, tc.sessInfo)
|
||||
reflect.DeepEqual(tc.expectedError, err)
|
||||
reflect.DeepEqual(tc.expectedOutput, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags(t *testing.T) {
|
||||
flags := []*FeatureFlag{
|
||||
{
|
||||
FlagID: 1,
|
||||
FlagKey: "checkCity",
|
||||
FlagType: Single,
|
||||
IsPersist: true,
|
||||
Payload: "test",
|
||||
Conditions: []*FeatureFlagCondition{
|
||||
{
|
||||
Filters: []*FeatureFlagFilter{
|
||||
{
|
||||
Type: UserCity,
|
||||
Operator: Contains,
|
||||
Values: []string{"Paris"},
|
||||
},
|
||||
},
|
||||
RolloutPercentage: 80,
|
||||
},
|
||||
},
|
||||
Variants: []*FeatureFlagVariant{
|
||||
{
|
||||
Value: "blue",
|
||||
Payload: "{\"color\": \"blue\"}",
|
||||
RolloutPercentage: 50,
|
||||
},
|
||||
{
|
||||
Value: "red",
|
||||
Payload: "{\"color\": \"red\"}",
|
||||
RolloutPercentage: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sessInfo := FeatureFlagsRequest{
|
||||
ProjectID: "123",
|
||||
UserOS: "macos",
|
||||
UserDevice: "macbook",
|
||||
UserCountry: "France",
|
||||
UserState: "Ile-de-France",
|
||||
UserCity: "Paris",
|
||||
UserBrowser: "Safari",
|
||||
Referrer: "https://google.com",
|
||||
UserID: "456",
|
||||
Metadata: map[string]string{"test": "test"},
|
||||
PersistFlags: map[string]interface{}{"test": "test"},
|
||||
}
|
||||
|
||||
result, err := ComputeFeatureFlags(flags, &sessInfo)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
t.Log(result)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue