From 9bb56cc9c7e16537e53a17a19a0c38cd0560256c Mon Sep 17 00:00:00 2001 From: Sebastian Machuca Date: Sat, 3 Jan 2026 06:54:34 +1100 Subject: [PATCH] Making domain entities richer --- internal/flags/evaluator.go | 81 -------- internal/flags/evaluator_test.go | 336 ------------------------------- internal/flags/service.go | 37 +--- internal/flags/service_test.go | 30 --- internal/flags/types.go | 98 ++++++++- internal/flags/types_test.go | 247 +++++++++++++++++++++++ 6 files changed, 347 insertions(+), 482 deletions(-) delete mode 100644 internal/flags/evaluator.go delete mode 100644 internal/flags/evaluator_test.go create mode 100644 internal/flags/types_test.go diff --git a/internal/flags/evaluator.go b/internal/flags/evaluator.go deleted file mode 100644 index e135831..0000000 --- a/internal/flags/evaluator.go +++ /dev/null @@ -1,81 +0,0 @@ -package flags - -import "strings" - -type RuleMatcher func(rules []Rule, evalCtx EvalContext) (Rule, bool) - -func DefaultRuleMatcher() RuleMatcher { - return func(rules []Rule, evalCtx EvalContext) (Rule, bool) { - for _, rule := range rules { - if matchesRule(rule, evalCtx) { - return rule, true - } - } - - return Rule{}, false - } -} - -func matchesRule(rule Rule, evalCtx EvalContext) bool { - for _, cond := range rule.Conditions { - if !matchesCondition(cond, evalCtx) { - return false - } - } - - return true -} - -func matchesCondition(cond Condition, evalCtx EvalContext) bool { - attrValue := getAttrValue(cond.Attr, evalCtx) - - switch cond.Op { - case OpEquals: - return attrValue == cond.Value - case OpNotEquals: - return attrValue != cond.Value - case OpIn: - return valueIn(attrValue, cond.Value) - case OpNotIn: - return !valueIn(attrValue, cond.Value) - case OpExists: - return attrValue != nil - case OpStartsWith: - str, ok := attrValue.(string) - prefix, prefixOk := cond.Value.(string) - - return ok && prefixOk && strings.HasPrefix(str, prefix) - default: - return false - } -} - -func getAttrValue(attr string, evalCtx EvalContext) any { - switch attr { - case "user_id": - return evalCtx.UserID - case "tenant_id": - return evalCtx.TenantID - default: - if evalCtx.Attrs != nil { - return evalCtx.Attrs[attr] - } - - return nil - } -} - -func valueIn(value any, list any) bool { - slice, ok := list.([]any) - if !ok { - return false - } - - for _, item := range slice { - if value == item { - return true - } - } - - return false -} diff --git a/internal/flags/evaluator_test.go b/internal/flags/evaluator_test.go deleted file mode 100644 index 3045128..0000000 --- a/internal/flags/evaluator_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package flags_test - -import ( - "testing" - - "github.com/serroba/features/internal/flags" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRuleMatcher_ReturnsFirstMatch(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "rule-1", - Conditions: []flags.Condition{{Attr: "plan", Op: flags.OpEquals, Value: "enterprise"}}, - Value: flags.StringValue("first"), - }, - { - ID: "rule-2", - Conditions: []flags.Condition{{Attr: "plan", Op: flags.OpEquals, Value: "premium"}}, - Value: flags.StringValue("second"), - }, - } - - result, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"plan": "premium"}, - }) - - require.True(t, ok) - assert.Equal(t, "rule-2", result.ID) -} - -func TestRuleMatcher_NoMatch(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "rule-1", - Conditions: []flags.Condition{{Attr: "plan", Op: flags.OpEquals, Value: "enterprise"}}, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"plan": "free"}, - }) - - assert.False(t, ok) -} - -func TestRuleMatcher_EmptyRules(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - _, ok := matcher([]flags.Rule{}, flags.EvalContext{}) - - assert.False(t, ok) -} - -func TestRuleMatcher_OpEquals(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "equals-rule", - Conditions: []flags.Condition{{Attr: "country", Op: flags.OpEquals, Value: "US"}}, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"country": "US"}, - }) - require.True(t, ok) - - _, ok = matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"country": "UK"}, - }) - assert.False(t, ok) -} - -func TestRuleMatcher_OpNotEquals(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "not-equals-rule", - Conditions: []flags.Condition{{Attr: "env", Op: flags.OpNotEquals, Value: "production"}}, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"env": "staging"}, - }) - require.True(t, ok) - - _, ok = matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"env": "production"}, - }) - assert.False(t, ok) -} - -func TestRuleMatcher_OpIn(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "in-rule", - Conditions: []flags.Condition{{Attr: "region", Op: flags.OpIn, Value: []any{"us-east", "us-west"}}}, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"region": "us-east"}, - }) - require.True(t, ok) - - _, ok = matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"region": "eu-west"}, - }) - assert.False(t, ok) -} - -func TestRuleMatcher_OpNotIn(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "not-in-rule", - Conditions: []flags.Condition{{Attr: "country", Op: flags.OpNotIn, Value: []any{"CN", "RU"}}}, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"country": "US"}, - }) - require.True(t, ok) - - _, ok = matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"country": "CN"}, - }) - assert.False(t, ok) -} - -func TestRuleMatcher_OpExists(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "exists-rule", - Conditions: []flags.Condition{{Attr: "beta_enabled", Op: flags.OpExists}}, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"beta_enabled": true}, - }) - require.True(t, ok) - - _, ok = matcher(rules, flags.EvalContext{ - Attrs: map[string]any{}, - }) - assert.False(t, ok) -} - -func TestRuleMatcher_OpStartsWith(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "starts-with-rule", - Conditions: []flags.Condition{{Attr: "email", Op: flags.OpStartsWith, Value: "@internal."}}, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"email": "@internal.company.com"}, - }) - require.True(t, ok) - - _, ok = matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"email": "user@external.com"}, - }) - assert.False(t, ok) -} - -func TestRuleMatcher_MultipleConditions_AllMustMatch(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "multi-condition", - Conditions: []flags.Condition{ - {Attr: "plan", Op: flags.OpEquals, Value: "premium"}, - {Attr: "country", Op: flags.OpEquals, Value: "US"}, - {Attr: "verified", Op: flags.OpExists}, - }, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"plan": "premium", "country": "US", "verified": true}, - }) - require.True(t, ok) - - _, ok = matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"plan": "premium", "country": "US"}, - }) - assert.False(t, ok) - - _, ok = matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"plan": "free", "country": "US", "verified": true}, - }) - assert.False(t, ok) -} - -func TestRuleMatcher_BuiltInAttrs(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "user-rule", - Conditions: []flags.Condition{{Attr: "user_id", Op: flags.OpEquals, Value: "user-123"}}, - }, - { - ID: "tenant-rule", - Conditions: []flags.Condition{{Attr: "tenant_id", Op: flags.OpEquals, Value: "tenant-456"}}, - }, - } - - result, ok := matcher(rules, flags.EvalContext{ - UserID: "user-123", - }) - require.True(t, ok) - assert.Equal(t, "user-rule", result.ID) - - result, ok = matcher(rules, flags.EvalContext{ - TenantID: "tenant-456", - }) - require.True(t, ok) - assert.Equal(t, "tenant-rule", result.ID) -} - -func TestRuleMatcher_UnknownOp(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "unknown-op", - Conditions: []flags.Condition{{Attr: "x", Op: "unknown_op", Value: "y"}}, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"x": "y"}, - }) - assert.False(t, ok) -} - -func TestRuleMatcher_NilAttrs(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "custom-attr-rule", - Conditions: []flags.Condition{{Attr: "custom", Op: flags.OpEquals, Value: "value"}}, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: nil, - }) - assert.False(t, ok) -} - -func TestRuleMatcher_OpIn_InvalidListType(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "invalid-in", - Conditions: []flags.Condition{{Attr: "plan", Op: flags.OpIn, Value: "not-a-slice"}}, - }, - } - - _, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"plan": "premium"}, - }) - assert.False(t, ok) -} - -func TestRuleMatcher_OpNotIn_InvalidListType(t *testing.T) { - t.Parallel() - - matcher := flags.DefaultRuleMatcher() - - rules := []flags.Rule{ - { - ID: "invalid-not-in", - Conditions: []flags.Condition{{Attr: "plan", Op: flags.OpNotIn, Value: "not-a-slice"}}, - }, - } - - result, ok := matcher(rules, flags.EvalContext{ - Attrs: map[string]any{"plan": "premium"}, - }) - require.True(t, ok) - assert.Equal(t, "invalid-not-in", result.ID) -} diff --git a/internal/flags/service.go b/internal/flags/service.go index fbd006f..ac1e7c5 100644 --- a/internal/flags/service.go +++ b/internal/flags/service.go @@ -6,19 +6,11 @@ import ( ) type Service struct { - repo Repository - ruleMatcher RuleMatcher + repo Repository } func NewService(repo Repository) *Service { - return NewServiceWithMatcher(repo, DefaultRuleMatcher()) -} - -func NewServiceWithMatcher(repo Repository, matcher RuleMatcher) *Service { - return &Service{ - repo: repo, - ruleMatcher: matcher, - } + return &Service{repo: repo} } func (s *Service) Create(ctx context.Context, flag Flag) (Flag, error) { @@ -37,28 +29,5 @@ func (s *Service) Evaluate(ctx context.Context, key FlagKey, evalCtx EvalContext return EvalResult{}, err } - result := EvalResult{ - FlagKey: key, - EvaluatedAt: time.Now(), - } - - if !flag.Enabled { - result.Value = flag.DefaultValue - result.Reason = ReasonDisabled - - return result, nil - } - - if rule, ok := s.ruleMatcher(flag.Rules, evalCtx); ok { - result.Value = rule.Value - result.Reason = ReasonRuleMatch - result.RuleID = rule.ID - - return result, nil - } - - result.Value = flag.DefaultValue - result.Reason = ReasonDefault - - return result, nil + return flag.Evaluate(evalCtx), nil } diff --git a/internal/flags/service_test.go b/internal/flags/service_test.go index 60afb6a..edccc4e 100644 --- a/internal/flags/service_test.go +++ b/internal/flags/service_test.go @@ -372,36 +372,6 @@ func TestService_Evaluate_StringValue(t *testing.T) { assert.Equal(t, "Hello, user!", *result.Value.String) } -func TestService_WithCustomMatcher(t *testing.T) { - t.Parallel() - - repo := flags.NewMemoryRepository() - customMatcher := func(_ []flags.Rule, _ flags.EvalContext) (flags.Rule, bool) { - return flags.Rule{ - ID: "custom-rule", - Value: flags.StringValue("custom-value"), - }, true - } - svc := flags.NewServiceWithMatcher(repo, customMatcher) - ctx := context.Background() - - flag := flags.Flag{ - Key: "test-flag", - Type: flags.FlagString, - Enabled: true, - DefaultValue: flags.StringValue("default"), - } - _, err := svc.Create(ctx, flag) - require.NoError(t, err) - - result, err := svc.Evaluate(ctx, "test-flag", flags.EvalContext{}) - require.NoError(t, err) - - assert.Equal(t, flags.ReasonRuleMatch, result.Reason) - assert.Equal(t, "custom-rule", result.RuleID) - assert.Equal(t, "custom-value", *result.Value.String) -} - func TestService_Evaluate_NumberValue(t *testing.T) { t.Parallel() diff --git a/internal/flags/types.go b/internal/flags/types.go index 9533096..d6b5c01 100644 --- a/internal/flags/types.go +++ b/internal/flags/types.go @@ -1,6 +1,9 @@ package flags -import "time" +import ( + "strings" + "time" +) type FlagKey string @@ -21,12 +24,51 @@ type Flag struct { UpdatedAt time.Time } +func (f Flag) Evaluate(evalCtx EvalContext) EvalResult { + result := EvalResult{ + FlagKey: f.Key, + EvaluatedAt: time.Now(), + } + + if !f.Enabled { + result.Value = f.DefaultValue + result.Reason = ReasonDisabled + + return result + } + + for _, rule := range f.Rules { + if rule.Matches(evalCtx) { + result.Value = rule.Value + result.Reason = ReasonRuleMatch + result.RuleID = rule.ID + + return result + } + } + + result.Value = f.DefaultValue + result.Reason = ReasonDefault + + return result +} + type Rule struct { ID string Conditions []Condition // AND across conditions Value Value } +func (r Rule) Matches(evalCtx EvalContext) bool { + for _, cond := range r.Conditions { + if !cond.Matches(evalCtx) { + return false + } + } + + return true +} + type ConditionOp string const ( @@ -44,6 +86,45 @@ type Condition struct { Value any // string | float64 | bool | []any depending on Op } +func (c Condition) Matches(evalCtx EvalContext) bool { + attrValue := evalCtx.GetAttr(c.Attr) + + switch c.Op { + case OpEquals: + return attrValue == c.Value + case OpNotEquals: + return attrValue != c.Value + case OpIn: + return c.containsValue(attrValue) + case OpNotIn: + return !c.containsValue(attrValue) + case OpExists: + return attrValue != nil + case OpStartsWith: + str, ok := attrValue.(string) + prefix, prefixOk := c.Value.(string) + + return ok && prefixOk && strings.HasPrefix(str, prefix) + default: + return false + } +} + +func (c Condition) containsValue(attrValue any) bool { + slice, ok := c.Value.([]any) + if !ok { + return false + } + + for _, item := range slice { + if attrValue == item { + return true + } + } + + return false +} + type Value struct { Kind FlagType Bool *bool @@ -69,6 +150,21 @@ type EvalContext struct { Attrs map[string]any // arbitrary attributes for rule conditions } +func (e EvalContext) GetAttr(attr string) any { + switch attr { + case "user_id": + return e.UserID + case "tenant_id": + return e.TenantID + default: + if e.Attrs != nil { + return e.Attrs[attr] + } + + return nil + } +} + type EvalReason string const ( diff --git a/internal/flags/types_test.go b/internal/flags/types_test.go new file mode 100644 index 0000000..e271125 --- /dev/null +++ b/internal/flags/types_test.go @@ -0,0 +1,247 @@ +package flags_test + +import ( + "testing" + + "github.com/serroba/features/internal/flags" + "github.com/stretchr/testify/assert" +) + +func TestEvalContext_GetAttr(t *testing.T) { + t.Parallel() + + t.Run("returns UserID for user_id", func(t *testing.T) { + t.Parallel() + + ctx := flags.EvalContext{UserID: "user-123"} + assert.Equal(t, "user-123", ctx.GetAttr("user_id")) + }) + + t.Run("returns TenantID for tenant_id", func(t *testing.T) { + t.Parallel() + + ctx := flags.EvalContext{TenantID: "tenant-456"} + assert.Equal(t, "tenant-456", ctx.GetAttr("tenant_id")) + }) + + t.Run("returns custom attr from Attrs map", func(t *testing.T) { + t.Parallel() + + ctx := flags.EvalContext{Attrs: map[string]any{"plan": "premium"}} + assert.Equal(t, "premium", ctx.GetAttr("plan")) + }) + + t.Run("returns nil for missing attr", func(t *testing.T) { + t.Parallel() + + ctx := flags.EvalContext{Attrs: map[string]any{}} + assert.Nil(t, ctx.GetAttr("missing")) + }) + + t.Run("returns nil when Attrs is nil", func(t *testing.T) { + t.Parallel() + + ctx := flags.EvalContext{} + assert.Nil(t, ctx.GetAttr("anything")) + }) +} + +func TestCondition_Matches(t *testing.T) { + t.Parallel() + + ctx := func(attr string, value any) flags.EvalContext { + return flags.EvalContext{Attrs: map[string]any{attr: value}} + } + + t.Run("OpEquals", func(t *testing.T) { + t.Parallel() + + cond := flags.Condition{Attr: "plan", Op: flags.OpEquals, Value: "premium"} + assert.True(t, cond.Matches(ctx("plan", "premium"))) + assert.False(t, cond.Matches(ctx("plan", "free"))) + }) + + t.Run("OpNotEquals", func(t *testing.T) { + t.Parallel() + + cond := flags.Condition{Attr: "env", Op: flags.OpNotEquals, Value: "production"} + assert.True(t, cond.Matches(ctx("env", "staging"))) + assert.False(t, cond.Matches(ctx("env", "production"))) + }) + + t.Run("OpIn", func(t *testing.T) { + t.Parallel() + + cond := flags.Condition{Attr: "region", Op: flags.OpIn, Value: []any{"us-east", "us-west"}} + assert.True(t, cond.Matches(ctx("region", "us-east"))) + assert.False(t, cond.Matches(ctx("region", "eu-west"))) + }) + + t.Run("OpIn with invalid list type", func(t *testing.T) { + t.Parallel() + + cond := flags.Condition{Attr: "plan", Op: flags.OpIn, Value: "not-a-slice"} + assert.False(t, cond.Matches(ctx("plan", "premium"))) + }) + + t.Run("OpNotIn", func(t *testing.T) { + t.Parallel() + + cond := flags.Condition{Attr: "country", Op: flags.OpNotIn, Value: []any{"CN", "RU"}} + assert.True(t, cond.Matches(ctx("country", "US"))) + assert.False(t, cond.Matches(ctx("country", "CN"))) + }) + + t.Run("OpNotIn with invalid list type", func(t *testing.T) { + t.Parallel() + + cond := flags.Condition{Attr: "plan", Op: flags.OpNotIn, Value: "not-a-slice"} + assert.True(t, cond.Matches(ctx("plan", "premium"))) + }) + + t.Run("OpExists", func(t *testing.T) { + t.Parallel() + + cond := flags.Condition{Attr: "beta", Op: flags.OpExists} + assert.True(t, cond.Matches(ctx("beta", true))) + assert.False(t, cond.Matches(flags.EvalContext{Attrs: map[string]any{}})) + }) + + t.Run("OpStartsWith", func(t *testing.T) { + t.Parallel() + + cond := flags.Condition{Attr: "email", Op: flags.OpStartsWith, Value: "admin@"} + assert.True(t, cond.Matches(ctx("email", "admin@company.com"))) + assert.False(t, cond.Matches(ctx("email", "user@company.com"))) + }) + + t.Run("OpStartsWith with non-string types", func(t *testing.T) { + t.Parallel() + + cond := flags.Condition{Attr: "num", Op: flags.OpStartsWith, Value: "prefix"} + assert.False(t, cond.Matches(ctx("num", 123))) + }) + + t.Run("unknown operator", func(t *testing.T) { + t.Parallel() + + cond := flags.Condition{Attr: "x", Op: "unknown", Value: "y"} + assert.False(t, cond.Matches(ctx("x", "y"))) + }) +} + +func TestRule_Matches(t *testing.T) { + t.Parallel() + + t.Run("matches when all conditions match", func(t *testing.T) { + t.Parallel() + + rule := flags.Rule{ + Conditions: []flags.Condition{ + {Attr: "plan", Op: flags.OpEquals, Value: "premium"}, + {Attr: "country", Op: flags.OpEquals, Value: "US"}, + }, + } + ctx := flags.EvalContext{Attrs: map[string]any{"plan": "premium", "country": "US"}} + assert.True(t, rule.Matches(ctx)) + }) + + t.Run("fails when any condition fails", func(t *testing.T) { + t.Parallel() + + rule := flags.Rule{ + Conditions: []flags.Condition{ + {Attr: "plan", Op: flags.OpEquals, Value: "premium"}, + {Attr: "country", Op: flags.OpEquals, Value: "US"}, + }, + } + ctx := flags.EvalContext{Attrs: map[string]any{"plan": "premium", "country": "UK"}} + assert.False(t, rule.Matches(ctx)) + }) + + t.Run("matches with empty conditions", func(t *testing.T) { + t.Parallel() + + rule := flags.Rule{Conditions: []flags.Condition{}} + assert.True(t, rule.Matches(flags.EvalContext{})) + }) +} + +func TestFlag_Evaluate(t *testing.T) { + t.Parallel() + + t.Run("returns disabled when flag is disabled", func(t *testing.T) { + t.Parallel() + + flag := flags.Flag{ + Key: "test", + Enabled: false, + DefaultValue: flags.BoolValue(false), + Rules: []flags.Rule{ + {ID: "rule-1", Conditions: []flags.Condition{}, Value: flags.BoolValue(true)}, + }, + } + result := flag.Evaluate(flags.EvalContext{}) + assert.Equal(t, flags.ReasonDisabled, result.Reason) + assert.False(t, *result.Value.Bool) + }) + + t.Run("returns first matching rule", func(t *testing.T) { + t.Parallel() + + flag := flags.Flag{ + Key: "test", + Enabled: true, + DefaultValue: flags.StringValue("default"), + Rules: []flags.Rule{ + { + ID: "rule-1", + Conditions: []flags.Condition{{Attr: "plan", Op: flags.OpEquals, Value: "enterprise"}}, + Value: flags.StringValue("first"), + }, + { + ID: "rule-2", + Conditions: []flags.Condition{{Attr: "plan", Op: flags.OpEquals, Value: "premium"}}, + Value: flags.StringValue("second"), + }, + }, + } + result := flag.Evaluate(flags.EvalContext{Attrs: map[string]any{"plan": "premium"}}) + assert.Equal(t, flags.ReasonRuleMatch, result.Reason) + assert.Equal(t, "rule-2", result.RuleID) + assert.Equal(t, "second", *result.Value.String) + }) + + t.Run("returns default when no rules match", func(t *testing.T) { + t.Parallel() + + flag := flags.Flag{ + Key: "test", + Enabled: true, + DefaultValue: flags.StringValue("default"), + Rules: []flags.Rule{ + { + ID: "rule-1", + Conditions: []flags.Condition{{Attr: "plan", Op: flags.OpEquals, Value: "enterprise"}}, + Value: flags.StringValue("enterprise"), + }, + }, + } + result := flag.Evaluate(flags.EvalContext{Attrs: map[string]any{"plan": "free"}}) + assert.Equal(t, flags.ReasonDefault, result.Reason) + assert.Equal(t, "default", *result.Value.String) + }) + + t.Run("sets metadata correctly", func(t *testing.T) { + t.Parallel() + + flag := flags.Flag{ + Key: "my-flag", + Enabled: true, + DefaultValue: flags.BoolValue(true), + } + result := flag.Evaluate(flags.EvalContext{}) + assert.Equal(t, flags.FlagKey("my-flag"), result.FlagKey) + assert.False(t, result.EvaluatedAt.IsZero()) + }) +}