diff --git a/.gitmodules b/.gitmodules index 5e406b4..d6b6751 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "flagengine/engine-test-data"] path = flagengine/engine-test-data url = git@github.com:Flagsmith/engine-test-data.git - branch = v3.10.0 + branch = v3.11.0 diff --git a/client_test.go b/client_test.go index 2ed4888..f69548c 100644 --- a/client_test.go +++ b/client_test.go @@ -483,6 +483,34 @@ func TestGetIdentityFlagsAppliesSegmentOverridesWithReason(t *testing.T) { assert.Equal(t, "TARGETING_MATCH; segment=Test Segment", flag.Reason) } +func TestGetIdentityFlagsAppliesSegmentOverrideDependingOnAnotherFlag(t *testing.T) { + // Given + ctx := context.Background() + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(http.StatusOK) + _, _ = io.WriteString(rw, fixtures.EnvironmentJsonWithFlagDependency) + })) + defer server.Close() + + client := flagsmith.NewClient(fixtures.EnvironmentAPIKey, + flagsmith.WithLocalEvaluation(ctx), + flagsmith.WithBaseURL(server.URL+"/api/v1/")) + err := client.UpdateEnvironment(ctx) + assert.NoError(t, err) + + // When + flags, err := client.GetIdentityFlags(ctx, "test_identity", nil) + + // Then + assert.NoError(t, err) + flag, err := flags.GetFlag("dependent") + assert.NoError(t, err) + assert.True(t, flag.Enabled) + assert.Equal(t, "dependent_override", flag.Value) + assert.Equal(t, "TARGETING_MATCH; segment=Prerequisite Enabled", flag.Reason) +} + func TestGetIdentityFlagsSetsVariantForMultivariateFeature(t *testing.T) { // Given ctx := context.Background() diff --git a/fixtures/fixture.go b/fixtures/fixture.go index 0892e16..42c22ac 100644 --- a/fixtures/fixture.go +++ b/fixtures/fixture.go @@ -221,6 +221,82 @@ const EnvironmentJsonWithMultivariateFeature = ` } ` +// EnvironmentJsonWithFlagDependency contains a feature overridden by a segment +// that matches when another feature, the prerequisite, is enabled. +const EnvironmentJsonWithFlagDependency = ` +{ + "api_key": "B62qaMZNwfiqT76p38ggrQ", + "name": "Test Environment", + "updated_at": "2023-12-06T10:21:54.079725Z", + "project": { + "name": "Test project", + "organisation": { + "feature_analytics": false, + "name": "Test Org", + "id": 1, + "persist_trait_data": true, + "stop_serving_flags": false + }, + "id": 1, + "hide_disabled_flags": false, + "segments": [{ + "id": 1, + "name": "Prerequisite Enabled", + "feature_states": [{ + "multivariate_feature_state_values": [], + "feature_state_value": "dependent_override", + "id": 3, + "featurestate_uuid": "dependent-override-uuid", + "feature": { + "name": "dependent", + "type": "STANDARD", + "id": 2 + }, + "segment_id": 1, + "enabled": true + }], + "rules": [{ + "type": "ALL", + "conditions": [{ + "operator": "EQUAL", + "property_": "$.flags.prerequisite.enabled", + "value": "true" + }], + "rules": [] + }] + }] + }, + "segment_overrides": [], + "id": 1, + "feature_states": [{ + "multivariate_feature_state_values": [], + "feature_state_value": "prerequisite_value", + "id": 1, + "featurestate_uuid": "prerequisite-uuid", + "feature": { + "name": "prerequisite", + "type": "STANDARD", + "id": 1 + }, + "segment_id": null, + "enabled": true + }, { + "multivariate_feature_state_values": [], + "feature_state_value": "dependent_default", + "id": 2, + "featurestate_uuid": "dependent-uuid", + "feature": { + "name": "dependent", + "type": "STANDARD", + "id": 2 + }, + "segment_id": null, + "enabled": false + }], + "identity_overrides": [] +} +` + const FlagsJson = ` [{ "id": 1, diff --git a/flagengine/dependencies.go b/flagengine/dependencies.go new file mode 100644 index 0000000..19acb71 --- /dev/null +++ b/flagengine/dependencies.go @@ -0,0 +1,133 @@ +package flagengine + +import ( + "fmt" + "slices" + + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" +) + +const circularDependencyReason = "ERROR; code=CIRCULAR_DEPENDENCY" + +// flagDependencyResolver resolves the flags read by segment conditions on `$.flags`, +// following dependencies transitively and memoising the results for one evaluation. +// Its state is only allocated once a condition reads a flag. +type flagDependencyResolver struct { + ec *engine_eval.EngineEvaluationContext + sortedSegmentKeys []string + + identityKey *string + segmentKeysByFeatureName map[string][]string + segmentMatches map[string]bool + resolving []string + + // Flag results resolved so far, mapped by feature name. + resolved map[string]*engine_eval.FlagResult + // Names of the features found to be in a dependency cycle. + cyclic map[string]struct{} + // Incremented whenever a cycle is cut. A segment whose evaluation increments it + // read a flag that could not be resolved, so it must not match. + cycleHits int +} + +func newFlagDependencyResolver(ec *engine_eval.EngineEvaluationContext, sortedSegmentKeys []string) *flagDependencyResolver { + return &flagDependencyResolver{ec: ec, sortedSegmentKeys: sortedSegmentKeys} +} + +func (r *flagDependencyResolver) init() { + r.identityKey = getIdentityKey(r.ec) + r.segmentKeysByFeatureName = make(map[string][]string) + for _, segmentKey := range r.sortedSegmentKeys { + for _, override := range r.ec.Segments[segmentKey].Overrides { + r.segmentKeysByFeatureName[override.Name] = append(r.segmentKeysByFeatureName[override.Name], segmentKey) + } + } + r.segmentMatches = make(map[string]bool) + r.resolved = make(map[string]*engine_eval.FlagResult) + r.cyclic = make(map[string]struct{}) +} + +// ResolveFlag implements engine_eval.FlagResolver. +func (r *flagDependencyResolver) ResolveFlag(featureName string) *engine_eval.FlagResult { + if r.resolved == nil { + r.init() + } + if _, ok := r.resolved[featureName]; !ok { + r.resolveFeature(featureName) + } + return r.resolved[featureName] +} + +func (r *flagDependencyResolver) resolveFeature(featureName string) { + if cycleStart := slices.Index(r.resolving, featureName); cycleStart >= 0 { + for _, name := range r.resolving[cycleStart:] { + r.cyclic[name] = struct{}{} + } + r.cycleHits++ + return + } + featureContext, ok := r.ec.Features[featureName] + if !ok { + return + } + + r.resolving = append(r.resolving, featureName) + override, hasOverride := r.getSegmentOverride(featureName) + r.resolving = r.resolving[:len(r.resolving)-1] + + if _, ok := r.cyclic[featureName]; ok { + return + } + + reason := "DEFAULT" + if hasOverride { + featureContext = *override.featureContext + reason = fmt.Sprintf("TARGETING_MATCH; segment=%s", override.segmentName) + } + flagResult := getFlagResultFromFeatureContext(featureName, &featureContext, r.identityKey, reason) + r.resolved[featureName] = &flagResult +} + +// matchesSegment reports whether the context matches a segment, resolving the flags it depends on. +func (r *flagDependencyResolver) matchesSegment(segmentKey string) bool { + if matches, ok := r.segmentMatches[segmentKey]; ok { + return matches + } + + cycleHits := r.cycleHits + segmentContext := r.ec.Segments[segmentKey] + matches := engine_eval.IsContextInSegmentWithFlags(r.ec, &segmentContext, r) + if r.cycleHits != cycleHits { + return false + } + + if r.segmentMatches != nil { + r.segmentMatches[segmentKey] = matches + } + return matches +} + +func (r *flagDependencyResolver) getSegmentOverride(featureName string) (featureContextWithSegmentName, bool) { + var segmentOverride featureContextWithSegmentName + found := false + + for _, segmentKey := range r.segmentKeysByFeatureName[featureName] { + if !r.matchesSegment(segmentKey) { + continue + } + segmentContext := r.ec.Segments[segmentKey] + for i := range segmentContext.Overrides { + override := &segmentContext.Overrides[i] + if override.Name != featureName { + continue + } + if found && !overrideTakesPrecedence(override, segmentOverride.featureContext) { + continue + } + segmentOverride = featureContextWithSegmentName{featureContext: override, segmentName: segmentContext.Name} + found = true + } + } + + return segmentOverride, found +} diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index 2e710ea..28363b3 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit 2e710ea7c6994110d17d1adb40b2ec2e90e7cf44 +Subproject commit 28363b388de2850c7c3f1b6e7a8bdd41d8da433c diff --git a/flagengine/engine.go b/flagengine/engine.go index 645b9d7..55b48a7 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -23,15 +23,16 @@ func getPriorityOrDefault(priority *float64) float64 { return math.Inf(1) // Weakest possible priority } -func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) { +func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext, sortedSegmentKeys []string, resolver *flagDependencyResolver) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) { segmentResults := []engine_eval.SegmentResult{} featureOverrides := make(map[string]featureContextWithSegmentName) // Process segments in deterministic order (sorted by key) - for _, segmentContext := range getSortedSegments(ec.Segments) { - if !engine_eval.IsContextInSegment(ec, &segmentContext) { + for _, segmentKey := range sortedSegmentKeys { + if !resolver.matchesSegment(segmentKey) { continue } + segmentContext := ec.Segments[segmentKey] // Record matched segment segmentResults = append(segmentResults, engine_eval.SegmentResult{ @@ -46,19 +47,14 @@ func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext) ([ return segmentResults, featureOverrides } -// getSortedSegments returns segments sorted by their keys for deterministic ordering. -func getSortedSegments(segments map[string]engine_eval.SegmentContext) []engine_eval.SegmentContext { +// getSortedSegmentKeys returns segment keys sorted for deterministic ordering. +func getSortedSegmentKeys(segments map[string]engine_eval.SegmentContext) []string { keys := make([]string, 0, len(segments)) for key := range segments { keys = append(keys, key) } sort.Strings(keys) - - sorted := make([]engine_eval.SegmentContext, 0, len(keys)) - for _, key := range keys { - sorted = append(sorted, segments[key]) - } - return sorted + return keys } // applySegmentOverrides updates the feature overrides map with this segment's overrides, @@ -66,17 +62,9 @@ func getSortedSegments(segments map[string]engine_eval.SegmentContext) []engine_ func applySegmentOverrides(segment *engine_eval.SegmentContext, featureOverrides map[string]featureContextWithSegmentName) { for i := range segment.Overrides { override := &segment.Overrides[i] - newPriority := getPriorityOrDefault(override.Priority) - - // Check if we should use this override - if existing, exists := featureOverrides[override.Name]; exists { - existingPriority := getPriorityOrDefault(existing.featureContext.Priority) - if newPriority > existingPriority { - continue // Existing override has higher priority - } + if existing, exists := featureOverrides[override.Name]; exists && !overrideTakesPrecedence(override, existing.featureContext) { + continue } - - // Use this override (either it's new or has equal/higher priority) featureOverrides[override.Name] = featureContextWithSegmentName{ featureContext: override, segmentName: segment.Name, @@ -84,23 +72,36 @@ func applySegmentOverrides(segment *engine_eval.SegmentContext, featureOverrides } } -func getFlagResults(ec *engine_eval.EngineEvaluationContext, featureOverrides map[string]featureContextWithSegmentName) map[string]*engine_eval.FlagResult { - flags := make(map[string]*engine_eval.FlagResult) - - // Get identity key if identity exists - var identityKey *string - if ec.Identity != nil { - // If identity key is not provided, construct it from environment key and identifier - if ec.Identity.Key == "" { - constructedKey := ec.Environment.Key + "_" + ec.Identity.Identifier - identityKey = &constructedKey - } else { - identityKey = &ec.Identity.Key - } +// overrideTakesPrecedence reports whether a segment override replaces the one held so far, +// i.e. whether it has equal or higher priority. +func overrideTakesPrecedence(candidate, incumbent *engine_eval.FeatureContext) bool { + return getPriorityOrDefault(candidate.Priority) <= getPriorityOrDefault(incumbent.Priority) +} + +// getIdentityKey returns the identity key used for multivariate bucketing, or nil without an identity. +func getIdentityKey(ec *engine_eval.EngineEvaluationContext) *string { + if ec.Identity == nil { + return nil } + // If identity key is not provided, construct it from environment key and identifier + if ec.Identity.Key == "" { + constructedKey := ec.Environment.Key + "_" + ec.Identity.Identifier + return &constructedKey + } + return &ec.Identity.Key +} + +// getFlagResults evaluates every feature, reusing the flags already resolved as dependencies. +func getFlagResults(ec *engine_eval.EngineEvaluationContext, featureOverrides map[string]featureContextWithSegmentName, resolved map[string]*engine_eval.FlagResult) map[string]*engine_eval.FlagResult { + flags := make(map[string]*engine_eval.FlagResult, len(ec.Features)) + identityKey := getIdentityKey(ec) if ec.Features != nil { for featureName, featureContext := range ec.Features { + if flagResult, ok := resolved[featureName]; ok { + flags[featureName] = flagResult + continue + } reason := "DEFAULT" // Check if there's an override for this feature if override, ok := featureOverrides[featureName]; ok { @@ -117,11 +118,20 @@ func getFlagResults(ec *engine_eval.EngineEvaluationContext, featureOverrides ma // GetEvaluationResult computes flags and matched segments. func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult { + sortedSegmentKeys := getSortedSegmentKeys(ec.Segments) + resolver := newFlagDependencyResolver(ec, sortedSegmentKeys) + // Process segments and get overrides - segmentResults, featureOverrides := getMatchingSegmentsAndOverrides(ec) + segmentResults, featureOverrides := getMatchingSegmentsAndOverrides(ec, sortedSegmentKeys, resolver) // Get flag results - flags := getFlagResults(ec, featureOverrides) + flags := getFlagResults(ec, featureOverrides, resolver.resolved) + + for featureName := range resolver.cyclic { + if flagResult, ok := flags[featureName]; ok { + flagResult.Reason = circularDependencyReason + } + } return engine_eval.EvaluationResult{ Flags: flags, diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 8a1802d..27eb964 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -11,13 +11,28 @@ import ( "github.com/ohler55/ojg/jp" ) +const flagsContextKey = "flags" + +// FlagResolver resolves the flags read by segment conditions on `$.flags.`. +type FlagResolver interface { + // ResolveFlag returns the flag result for the named feature, or nil if it cannot be resolved. + ResolveFlag(featureName string) *FlagResult +} + // IsContextInSegment determines if the given evaluation context matches the segment rules. +// Conditions on `$.flags` never resolve; use IsContextInSegmentWithFlags to evaluate them. func IsContextInSegment(ec *EngineEvaluationContext, segmentContext *SegmentContext) bool { + return IsContextInSegmentWithFlags(ec, segmentContext, nil) +} + +// IsContextInSegmentWithFlags determines if the given evaluation context matches the segment rules, +// reading conditions on `$.flags.` from flags. +func IsContextInSegmentWithFlags(ec *EngineEvaluationContext, segmentContext *SegmentContext, flags FlagResolver) bool { if len(segmentContext.Rules) == 0 { return false } for i := range segmentContext.Rules { - if !contextMatchesSegmentRule(ec, &segmentContext.Rules[i], segmentContext.Key) { + if !contextMatchesSegmentRule(ec, flags, &segmentContext.Rules[i], segmentContext.Key) { return false } } @@ -25,9 +40,9 @@ func IsContextInSegment(ec *EngineEvaluationContext, segmentContext *SegmentCont } // Returns true if conditions match according to the rule type. -func matchesConditionsByRuleType(ec *EngineEvaluationContext, conditions []Condition, ruleType Type, segmentKey string) bool { +func matchesConditionsByRuleType(ec *EngineEvaluationContext, flags FlagResolver, conditions []Condition, ruleType Type, segmentKey string) bool { for i := range conditions { - conditionMatches := contextMatchesCondition(ec, &conditions[i], segmentKey) + conditionMatches := contextMatchesCondition(ec, flags, &conditions[i], segmentKey) switch ruleType { case All: @@ -51,23 +66,23 @@ func matchesConditionsByRuleType(ec *EngineEvaluationContext, conditions []Condi return ruleType != Any } -func contextMatchesSegmentRule(ec *EngineEvaluationContext, segmentRule *SegmentRule, segmentKey string) bool { +func contextMatchesSegmentRule(ec *EngineEvaluationContext, flags FlagResolver, segmentRule *SegmentRule, segmentKey string) bool { if len(segmentRule.Conditions) > 0 { - if !matchesConditionsByRuleType(ec, segmentRule.Conditions, segmentRule.Type, segmentKey) { + if !matchesConditionsByRuleType(ec, flags, segmentRule.Conditions, segmentRule.Type, segmentKey) { return false } } - return matchesSubRulesByRuleType(ec, segmentRule.Rules, segmentRule.Type, segmentKey) + return matchesSubRulesByRuleType(ec, flags, segmentRule.Rules, segmentRule.Type, segmentKey) } // matchesSubRulesByRuleType evaluates sub-rules using the parent rule's type (ALL/ANY/NONE). -func matchesSubRulesByRuleType(ec *EngineEvaluationContext, subRules []SegmentRule, ruleType Type, segmentKey string) bool { +func matchesSubRulesByRuleType(ec *EngineEvaluationContext, flags FlagResolver, subRules []SegmentRule, ruleType Type, segmentKey string) bool { if len(subRules) == 0 { return true } for i := range subRules { - subRuleMatches := contextMatchesSegmentRule(ec, &subRules[i], segmentKey) + subRuleMatches := contextMatchesSegmentRule(ec, flags, &subRules[i], segmentKey) switch ruleType { case All: if !subRuleMatches { @@ -112,10 +127,10 @@ func matchPercentageSplit(ec *EngineEvaluationContext, segmentCondition *Conditi return false } -func contextMatchesCondition(ec *EngineEvaluationContext, segmentCondition *Condition, segmentKey string) bool { +func contextMatchesCondition(ec *EngineEvaluationContext, flags FlagResolver, segmentCondition *Condition, segmentKey string) bool { var contextValue ContextValue if segmentCondition.Property != "" { - contextValue = getContextValue(ec, segmentCondition.Property) + contextValue = getContextValue(ec, flags, segmentCondition.Property) } if segmentCondition.Operator == PercentageSplit { return matchPercentageSplit(ec, segmentCondition, segmentKey, contextValue) @@ -180,9 +195,9 @@ func matchInOperator(segmentCondition *Condition, contextValue ContextValue) boo return false } -func getContextValue(ec *EngineEvaluationContext, property string) ContextValue { +func getContextValue(ec *EngineEvaluationContext, flags FlagResolver, property string) ContextValue { if strings.HasPrefix(property, "$.") { - value := getContextValueGetter(property)(ec) + value := getContextValueGetter(property, flags)(ec) // Only use JSONPath result if it's a primitive value (not an object/array/map) if value != nil && isPrimitive(value) { return value @@ -218,9 +233,26 @@ func isPrimitive(value any) bool { // getContextValueGetter returns a function to retrieve a value from the evaluation context // using either a JSONPath expression or returning nil if the property is not a valid JSONPath. -func getContextValueGetter(property string) func(ec *EngineEvaluationContext) any { +// Expressions selecting `$.flags.` read the flag result resolved by flags. +func getContextValueGetter(property string, flags FlagResolver) func(ec *EngineEvaluationContext) any { // First, try to parse the property as a JSONPath expression. p, err := jp.ParseString(property) + if featureName, ok := getFlagDependency(p); err == nil && ok { + return func(*EngineEvaluationContext) any { + if flags == nil { + return nil + } + field, ok := p[len(p)-1].(jp.Child) + if len(p) != 4 || !ok { + return nil + } + flag := flags.ResolveFlag(featureName) + if flag == nil { + return nil + } + return getFlagResultField(flag, string(field)) + } + } if err == nil { // If successful, create a getter for the JSONPath. getter := func(evalCtx *EngineEvaluationContext) any { @@ -239,6 +271,34 @@ func getContextValueGetter(property string) func(ec *EngineEvaluationContext) an } } +// getFlagDependency returns the feature name a JSONPath expression selects under `$.flags`, if any. +func getFlagDependency(p jp.Expr) (string, bool) { + if len(p) < 3 || p[1] != jp.Child(flagsContextKey) { + return "", false + } + featureName, ok := p[2].(jp.Child) + return string(featureName), ok +} + +// getFlagResultField returns a field of a flag result by its JSON name, as read by `$.flags` conditions. +func getFlagResultField(flag *FlagResult, field string) any { + switch field { + case "enabled": + return flag.Enabled + case "name": + return flag.Name + case "reason": + return flag.Reason + case "value": + return flag.Value + case "variant": + if flag.Variant != "" { + return flag.Variant + } + } + return nil +} + func ToString(contextValue ContextValue) string { if s, ok := contextValue.(string); ok { return s diff --git a/flagengine/engine_eval/evaluator_test.go b/flagengine/engine_eval/evaluator_test.go index 7a02c0a..5d45696 100644 --- a/flagengine/engine_eval/evaluator_test.go +++ b/flagengine/engine_eval/evaluator_test.go @@ -978,3 +978,131 @@ func TestMatchWithModuloOperator(t *testing.T) { result := engine_eval.IsContextInSegment(evalContext, segmentContext) assert.True(t, result) } + +type flagResolverStub map[string]*engine_eval.FlagResult + +func (s flagResolverStub) ResolveFlag(featureName string) *engine_eval.FlagResult { + return s[featureName] +} + +func TestIsContextInSegmentWithFlagsMatchesOnAnotherFlag(t *testing.T) { + t.Parallel() + + flags := flagResolverStub{ + "prerequisite": {Name: "prerequisite", Enabled: true, Value: "blue", Reason: "DEFAULT"}, + "my feature": {Name: "my feature", Enabled: true, Variant: "treatment", Reason: "SPLIT; weight=100"}, + } + + cases := []struct { + name string + condition engine_eval.Condition + flags engine_eval.FlagResolver + expected bool + }{ + { + name: "enabled flag matches", + condition: engine_eval.Condition{Operator: engine_eval.Equal, Property: "$.flags.prerequisite.enabled", Value: "true"}, + flags: flags, + expected: true, + }, + { + name: "flag value matches", + condition: engine_eval.Condition{Operator: engine_eval.Equal, Property: "$.flags.prerequisite.value", Value: "blue"}, + flags: flags, + expected: true, + }, + { + name: "flag variant of a quoted feature name matches", + condition: engine_eval.Condition{Operator: engine_eval.Equal, Property: "$.flags['my feature'].variant", Value: "treatment"}, + flags: flags, + expected: true, + }, + { + name: "flag without a variant has none set", + condition: engine_eval.Condition{Operator: engine_eval.IsNotSet, Property: "$.flags.prerequisite.variant"}, + flags: flags, + expected: true, + }, + { + name: "flag reason matches", + condition: engine_eval.Condition{Operator: engine_eval.Equal, Property: "$.flags.prerequisite.reason", Value: "DEFAULT"}, + flags: flags, + expected: true, + }, + { + name: "flag name matches", + condition: engine_eval.Condition{Operator: engine_eval.Equal, Property: "$.flags.prerequisite.name", Value: "prerequisite"}, + flags: flags, + expected: true, + }, + { + name: "wildcard flag field has no value", + condition: engine_eval.Condition{Operator: engine_eval.IsSet, Property: "$.flags.prerequisite.*"}, + flags: flags, + expected: false, + }, + { + name: "flag object is not a condition value", + condition: engine_eval.Condition{Operator: engine_eval.IsSet, Property: "$.flags.prerequisite"}, + flags: flags, + expected: false, + }, + { + name: "unknown flag field has no value", + condition: engine_eval.Condition{Operator: engine_eval.IsSet, Property: "$.flags.prerequisite.colour"}, + flags: flags, + expected: false, + }, + { + name: "unresolvable flag has no value", + condition: engine_eval.Condition{Operator: engine_eval.IsSet, Property: "$.flags.nonexistent.enabled"}, + flags: flags, + expected: false, + }, + { + name: "flag is not read without flags", + condition: engine_eval.Condition{Operator: engine_eval.IsSet, Property: "$.flags.prerequisite.enabled"}, + flags: nil, + expected: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + // Given + evalContext := createEvaluationContext(nil) + segmentContext := createSegmentContext("1", "dependent_segment", []engine_eval.SegmentRule{ + {Type: engine_eval.All, Conditions: []engine_eval.Condition{c.condition}}, + }) + + // When + result := engine_eval.IsContextInSegmentWithFlags(evalContext, segmentContext, c.flags) + + // Then + assert.Equal(t, c.expected, result) + }) + } +} + +func TestIsContextInSegmentTreatsOtherFlagsAsUnset(t *testing.T) { + t.Parallel() + + // Given + evalContext := createEvaluationContext(nil) + segmentContext := createSegmentContext("1", "dependent_segment", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + {Operator: engine_eval.IsNotSet, Property: "$.flags.prerequisite.enabled"}, + }, + }, + }) + + // When + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + + // Then + assert.True(t, result) +} diff --git a/flagengine/engine_test.go b/flagengine/engine_test.go new file mode 100644 index 0000000..58fcc43 --- /dev/null +++ b/flagengine/engine_test.go @@ -0,0 +1,58 @@ +package flagengine_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" +) + +func TestGetEvaluationResultDependsOnTheHighestPriorityOverrideOfAnotherFlag(t *testing.T) { + t.Parallel() + + // Given + strongPriority, weakPriority := 1.0, 10.0 + prerequisiteEnabled := []engine_eval.SegmentRule{{ + Type: engine_eval.All, + Conditions: []engine_eval.Condition{{Operator: engine_eval.Equal, Property: "$.flags.prerequisite.enabled", Value: "true"}}, + }} + ec := &engine_eval.EngineEvaluationContext{ + Environment: engine_eval.EnvironmentContext{Key: "key", Name: "Environment"}, + Features: map[string]engine_eval.FeatureContext{ + "prerequisite": {Key: "1", Name: "prerequisite", Enabled: true, Value: "default"}, + "colour": {Key: "2", Name: "colour", Value: "default"}, + "dependent": {Key: "3", Name: "dependent", Value: "default"}, + }, + Segments: map[string]engine_eval.SegmentContext{ + "1": { + Key: "1", Name: "strong_colour", Rules: prerequisiteEnabled, + Overrides: []engine_eval.FeatureContext{{Key: "2", Name: "colour", Enabled: true, Value: "blue", Priority: &strongPriority}}, + }, + "2": { + Key: "2", Name: "weak_colour", Rules: prerequisiteEnabled, + Overrides: []engine_eval.FeatureContext{{Key: "2", Name: "colour", Enabled: true, Value: "red", Priority: &weakPriority}}, + }, + "3": { + Key: "3", Name: "blue_colour", + Rules: []engine_eval.SegmentRule{{ + Type: engine_eval.All, + Conditions: []engine_eval.Condition{{Operator: engine_eval.Equal, Property: "$.flags.colour.value", Value: "blue"}}, + }}, + Overrides: []engine_eval.FeatureContext{{Key: "3", Name: "dependent", Enabled: true, Value: "overridden"}}, + }, + }, + } + + // When + result := flagengine.GetEvaluationResult(ec) + + // Then + assert.Equal(t, &engine_eval.FlagResult{ + Name: "colour", Enabled: true, Value: "blue", Reason: "TARGETING_MATCH; segment=strong_colour", + }, result.Flags["colour"]) + assert.Equal(t, &engine_eval.FlagResult{ + Name: "dependent", Enabled: true, Value: "overridden", Reason: "TARGETING_MATCH; segment=blue_colour", + }, result.Flags["dependent"]) +} diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index 9926e0e..d484956 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -69,13 +69,9 @@ func TestEngine(t *testing.T) { // Compare the results assert.Equal(t, expected.Flags, actual.Flags, "Flags should match") - // Compare segments + // Compare segments, regardless of order since context segments are unordered if len(expected.Segments) > 0 { - require.Len(t, actual.Segments, len(expected.Segments), "Segment count should match") - for i, expectedSeg := range expected.Segments { - assert.Equal(t, expectedSeg.Name, actual.Segments[i].Name, "Segment name should match") - assert.Equal(t, expectedSeg.Metadata, actual.Segments[i].Metadata, "Segment metadata should match") - } + assert.ElementsMatch(t, expected.Segments, actual.Segments, "Segments should match") } }) }