From efd4b6856f1744fbbc7900e6f97be8e781a1dc5e Mon Sep 17 00:00:00 2001 From: Casey Waldren Date: Mon, 14 Oct 2024 16:43:08 -0700 Subject: [PATCH 1/3] feat: Add support for client-side prerequisite events --- interfaces/flagstate/flags_state.go | 11 ++ interfaces/flagstate/flags_state_test.go | 63 ++++++- ldclient.go | 8 +- ldclient_evaluation_all_flags_test.go | 217 ++++++++++++++++++++++- testservice/service.go | 1 + testservice/servicedef/service_params.go | 1 + 6 files changed, 297 insertions(+), 4 deletions(-) diff --git a/interfaces/flagstate/flags_state.go b/interfaces/flagstate/flags_state.go index dc1290f6..23dedec0 100644 --- a/interfaces/flagstate/flags_state.go +++ b/interfaces/flagstate/flags_state.go @@ -67,6 +67,10 @@ type FlagState struct { // OmitDetails is true if, based on the options passed to AllFlagsState and the flag state, some of the // metadata can be left out of the JSON representation. OmitDetails bool + + // Prerequisites is an ordered list of direct prerequisites that were evaluated in the process of evaluating this + // flag. + Prerequisites []string } // Option is the interface for optional parameters that can be passed to LDClient.AllFlagsState. @@ -156,6 +160,13 @@ func (a AllFlags) MarshalJSON() ([]byte, error) { flagObj.Maybe("trackEvents", flag.TrackEvents).Bool(flag.TrackEvents) flagObj.Maybe("trackReason", flag.TrackReason).Bool(flag.TrackReason) flagObj.Maybe("debugEventsUntilDate", flag.DebugEventsUntilDate > 0).Float64(float64(flag.DebugEventsUntilDate)) + if len(flag.Prerequisites) > 0 { + prerequisites := flagObj.Name("prerequisites").Array() + for _, p := range flag.Prerequisites { + prerequisites.String(p) + } + prerequisites.End() + } flagObj.End() } stateObj.End() diff --git a/interfaces/flagstate/flags_state_test.go b/interfaces/flagstate/flags_state_test.go index 1178f595..3e02f2e5 100644 --- a/interfaces/flagstate/flags_state_test.go +++ b/interfaces/flagstate/flags_state_test.go @@ -99,6 +99,7 @@ func TestAllFlagsJSON(t *testing.T) { Reason: ldreason.NewEvalReasonFallthrough(), TrackEvents: true, DebugEventsUntilDate: ldtime.UnixMillisecondTime(100000), + Prerequisites: []string{"flag2", "flag3", "flag4"}, }, }, } @@ -109,7 +110,8 @@ func TestAllFlagsJSON(t *testing.T) { "$valid":true, "flag1": "value1", "$flagsState":{ - "flag1": {"variation":1,"version":1000,"reason":{"kind":"FALLTHROUGH"},"trackEvents":true,"debugEventsUntilDate":100000} + "flag1": {"variation":1,"version":1000,"reason":{"kind":"FALLTHROUGH"},"trackEvents":true,"debugEventsUntilDate":100000, + "prerequisites": ["flag2","flag3","flag4"]} } }`, string(bytes)) }) @@ -140,7 +142,7 @@ func TestAllFlagsJSON(t *testing.T) { }`, string(bytes)) }) - t.Run("omitting details", func(t *testing.T) { + t.Run("omitting details, no prerequisites present", func(t *testing.T) { a := AllFlags{ valid: true, flags: map[string]FlagState{ @@ -162,6 +164,32 @@ func TestAllFlagsJSON(t *testing.T) { "$flagsState":{ "flag1": {"variation":1} } +}`, string(bytes)) + }) + + t.Run("omitting details, prerequisites present", func(t *testing.T) { + a := AllFlags{ + valid: true, + flags: map[string]FlagState{ + "flag1": { + Value: ldvalue.String("value1"), + Variation: ldvalue.NewOptionalInt(1), + Version: 1000, + Reason: ldreason.NewEvalReasonFallthrough(), + OmitDetails: true, + Prerequisites: []string{"flag2", "flag3", "flag4"}, + }, + }, + } + bytes, err := a.MarshalJSON() + assert.NoError(t, err) + assert.JSONEq(t, + `{ + "$valid":true, + "flag1": "value1", + "$flagsState":{ + "flag1": {"variation":1, "prerequisites": ["flag2","flag3","flag4"]} + } }`, string(bytes)) }) } @@ -295,6 +323,37 @@ func TestAllFlagsBuilder(t *testing.T) { "flag5": flag5, }, a.flags) }) + + t.Run("add flags with prerequisites", func(t *testing.T) { + b := NewAllFlagsBuilder() + + flag1 := FlagState{ + Value: ldvalue.String("value1"), + Variation: ldvalue.NewOptionalInt(1), + Version: 1000, + Prerequisites: []string{"flag2"}, + } + flag2 := FlagState{ + Value: ldvalue.String("value2"), + Version: 2000, + Prerequisites: []string{"flag3"}, + } + flag3 := FlagState{ + Value: ldvalue.String("value3"), + Version: 3000, + } + + b.AddFlag("flag1", flag1) + b.AddFlag("flag2", flag2) + b.AddFlag("flag3", flag3) + + a := b.Build() + assert.Equal(t, map[string]FlagState{ + "flag1": flag1, + "flag2": flag2, + "flag3": flag3, + }, a.flags) + }) } func TestAllFlagsOptions(t *testing.T) { diff --git a/ldclient.go b/ldclient.go index 2dabf92b..b40c88b0 100644 --- a/ldclient.go +++ b/ldclient.go @@ -684,7 +684,12 @@ func (client *LDClient) AllFlagsState(context ldcontext.Context, options ...flag continue } - result := client.evaluator.Evaluate(flag, context, nil) + var prerequisites []string + result := client.evaluator.Evaluate(flag, context, func(event ldeval.PrerequisiteFlagEvent) { + if event.TargetFlagKey == flag.Key { + prerequisites = append(prerequisites, event.PrerequisiteFlag.Key) + } + }) state.AddFlag( item.Key, @@ -696,6 +701,7 @@ func (client *LDClient) AllFlagsState(context ldcontext.Context, options ...flag TrackEvents: flag.TrackEvents || result.IsExperiment, TrackReason: result.IsExperiment, DebugEventsUntilDate: flag.DebugEventsUntilDate, + Prerequisites: prerequisites, }, ) } diff --git a/ldclient_evaluation_all_flags_test.go b/ldclient_evaluation_all_flags_test.go index 869055a6..30855d3a 100644 --- a/ldclient_evaluation_all_flags_test.go +++ b/ldclient_evaluation_all_flags_test.go @@ -128,7 +128,7 @@ func TestAllFlagsStateCanFilterForOnlyClientSideFlags(t *testing.T) { func TestAllFlagsStateCanOmitDetailForUntrackedFlags(t *testing.T) { futureTime := ldtime.UnixMillisNow() + 100000 - // flag1 does not get full detials because neither event tracking nor debugging is on and there's no experiment + // flag1 does not get full details because neither event tracking nor debugging is on and there's no experiment flag1 := ldbuilders.NewFlagBuilder("key1").Version(100).OffVariation(0).Variations(ldvalue.String("value1")).Build() // flag2 gets full details because event tracking is on @@ -246,3 +246,218 @@ func TestAllFlagsStateReturnsInvalidStateIfStoreReturnsError(t *testing.T) { assert.Len(t, mockLoggers.GetOutput(ldlog.Warn), 1) assert.Contains(t, mockLoggers.GetOutput(ldlog.Warn)[0], "Unable to fetch flags") } + +type test struct { + name string + options []flagstate.Option +} + +func optionPermutations() []test { + type option struct { + name string + option flagstate.Option + } + options := []option{ + {name: "with reasons", option: flagstate.OptionWithReasons()}, + {name: "client-side only", option: flagstate.OptionClientSideOnly()}, + {name: "details only for tracked flags", option: flagstate.OptionDetailsOnlyForTrackedFlags()}, + } + tests := []test{ + {name: "no options", options: []flagstate.Option{}}, + } + for i, opt := range options { + tests = append(tests, test{name: opt.name, options: []flagstate.Option{opt.option}}) + for j := i + 1; j < len(options); j++ { + tests = append(tests, test{name: opt.name + " and " + options[j].name, options: []flagstate.Option{opt.option, options[j].option}}) + for k := j + 1; k < len(options); k++ { + tests = append(tests, test{name: opt.name + " and " + options[j].name + " and " + options[k].name, + options: []flagstate.Option{opt.option, options[j].option, options[k].option}}) + } + } + } + + return tests +} + +func TestAllFlagsStateReturnsPrerequisites(t *testing.T) { + + t.Run("when flag is visible to clients", func(t *testing.T) { + flag1 := ldbuilders.NewFlagBuilder("key1").Version(100).On(true).OffVariation(0). + Variations(ldvalue.String("value1")).AddPrerequisite("key2", 0).ClientSideUsingEnvironmentID(true).Build() + + flag2 := ldbuilders.NewFlagBuilder("key2").Version(100).OffVariation(0). + Variations(ldvalue.String("value1")).ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + for _, test := range optionPermutations() { + t.Run(test.name, func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(flag1) + p.data.UsePreconfiguredFlag(flag2) + + state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) + assert.True(t, state.IsValid()) + + flag1state, ok := state.GetFlag("key1") + assert.True(t, ok) + assert.Equal(t, []string{"key2"}, flag1state.Prerequisites) + }) + }) + } + }) + + t.Run("when flag is not visible to clients", func(t *testing.T) { + flag1 := ldbuilders.NewFlagBuilder("key1").Version(100).On(true).OffVariation(0). + Variations(ldvalue.String("value1")).AddPrerequisite("key2", 0). + ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + flag2 := ldbuilders.NewFlagBuilder("key2").Version(100).OffVariation(0). + Variations(ldvalue.String("value1")).ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + for _, test := range optionPermutations() { + t.Run(test.name, func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(flag1) + p.data.UsePreconfiguredFlag(flag2) + + state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) + assert.True(t, state.IsValid()) + + // If the flag was visible, then we should see its prerequisites. + fs1, ok := state.GetFlag("key1") + if ok { + assert.Equal(t, []string{"key2"}, fs1.Prerequisites) + } + }) + }) + } + }) + + t.Run("when flag is off, no prerequisites are returned", func(t *testing.T) { + flag1 := ldbuilders.NewFlagBuilder("key1").Version(100).On(false).OffVariation(0). + Variations(ldvalue.String("value1")).AddPrerequisite("key2", 0). + ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + flag2 := ldbuilders.NewFlagBuilder("key2").Version(100).OffVariation(0). + Variations(ldvalue.String("value1")).ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + for _, test := range optionPermutations() { + t.Run(test.name, func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(flag1) + p.data.UsePreconfiguredFlag(flag2) + + state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) + assert.True(t, state.IsValid()) + + // If the flag was visible, then we should see that it had no prerequisites evaluated + // since the flag was off. + fs1, ok := state.GetFlag("key1") + if ok { + assert.Empty(t, fs1.Prerequisites) + } + }) + }) + } + }) + + t.Run("only returns top-level prerequisites", func(t *testing.T) { + flag1 := ldbuilders.NewFlagBuilder("key1").Version(100).On(true).OffVariation(0). + Variations(ldvalue.String("value1")).AddPrerequisite("key2", 0). + ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + flag2 := ldbuilders.NewFlagBuilder("key2").Version(100).On(true).OffVariation(0). + Variations(ldvalue.String("value1")).AddPrerequisite("key3", 0). + ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + flag3 := ldbuilders.NewFlagBuilder("key3").Version(100).On(false).OffVariation(0). + Variations(ldvalue.String("value1")).ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + for _, test := range optionPermutations() { + t.Run(test.name, func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(flag1) + p.data.UsePreconfiguredFlag(flag2) + p.data.UsePreconfiguredFlag(flag3) + + state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) + assert.True(t, state.IsValid()) + + // If the flag was visible, then we should see that it had no prerequisites evaluated + // since the flag was off. + fs1, ok := state.GetFlag("key1") + if ok { + assert.Equal(t, []string{"key2"}, fs1.Prerequisites) + } + + fs2, ok := state.GetFlag("key2") + if ok { + assert.Equal(t, []string{"key3"}, fs2.Prerequisites) + } + + fs3, ok := state.GetFlag("key3") + if ok { + assert.Empty(t, fs3.Prerequisites) + } + }) + }) + } + }) + + t.Run("prerequisites are in evaluation order", func(t *testing.T) { + ascending := ldbuilders.NewFlagBuilder("key1").Version(100).On(true).OffVariation(0). + Variations(ldvalue.String("value1")).AddPrerequisite("key2", 0).AddPrerequisite("key3", 0). + ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + descending := ldbuilders.NewFlagBuilder("key1").Version(100).On(true).OffVariation(0). + Variations(ldvalue.String("value1")).AddPrerequisite("key3", 0).AddPrerequisite("key2", 0). + ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + flag2 := ldbuilders.NewFlagBuilder("key2").Version(100).On(true).OffVariation(0).FallthroughVariation(0). + Variations(ldvalue.String("value1")).AddPrerequisite("key3", 0). + ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + flag3 := ldbuilders.NewFlagBuilder("key3").Version(100).On(true).OffVariation(0).FallthroughVariation(0). + Variations(ldvalue.String("value1")).ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + + t.Run("ascending", func(t *testing.T) { + for _, test := range optionPermutations() { + t.Run(test.name, func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(ascending) + p.data.UsePreconfiguredFlag(flag2) + p.data.UsePreconfiguredFlag(flag3) + + state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) + assert.True(t, state.IsValid()) + + fs1, ok := state.GetFlag("key1") + if ok { + assert.Equal(t, []string{"key2", "key3"}, fs1.Prerequisites) + } + }) + }) + } + }) + + t.Run("descending", func(t *testing.T) { + for _, test := range optionPermutations() { + t.Run(test.name, func(t *testing.T) { + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(descending) + p.data.UsePreconfiguredFlag(flag2) + p.data.UsePreconfiguredFlag(flag3) + + state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) + assert.True(t, state.IsValid()) + + fs1, ok := state.GetFlag("key1") + if ok { + assert.Equal(t, []string{"key3", "key2"}, fs1.Prerequisites) + } + }) + }) + } + }) + }) + +} diff --git a/testservice/service.go b/testservice/service.go index b74ae4aa..2b348f89 100644 --- a/testservice/service.go +++ b/testservice/service.go @@ -43,6 +43,7 @@ var capabilities = []string{ servicedef.CapabilityOmitAnonymousContexts, servicedef.CapabilityEventGzip, servicedef.CapabilityOptionalEventGzip, + servicedef.CapabilityClientPrereqEvents, } // gets the specified environment variable, or the default if not set diff --git a/testservice/servicedef/service_params.go b/testservice/servicedef/service_params.go index 5e9d6313..b82d6e18 100644 --- a/testservice/servicedef/service_params.go +++ b/testservice/servicedef/service_params.go @@ -24,6 +24,7 @@ const ( CapabilityOmitAnonymousContexts = "omit-anonymous-contexts" CapabilityEventGzip = "event-gzip" CapabilityOptionalEventGzip = "optional-event-gzip" + CapabilityClientPrereqEvents = "client-prereq-events" ) type StatusRep struct { From 5e7ce2577c311160c551e957288ba6bb2a14e68d Mon Sep 17 00:00:00 2001 From: Casey Waldren Date: Mon, 14 Oct 2024 16:49:12 -0700 Subject: [PATCH 2/3] bump contract test action --- .github/workflows/common_ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/common_ci.yml b/.github/workflows/common_ci.yml index 0b078aa9..1cd93a70 100644 --- a/.github/workflows/common_ci.yml +++ b/.github/workflows/common_ci.yml @@ -42,7 +42,7 @@ jobs: run: make workspace - name: Start test service in background run: make start-contract-test-service-bg - - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.0.0 + - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.1.0 continue-on-error: true with: test_service_port: ${{ env.TEST_SERVICE_PORT }} From a6bf91cb3ada420f9f066a8459bdad5a860849e7 Mon Sep 17 00:00:00 2001 From: Casey Waldren Date: Fri, 18 Oct 2024 13:32:27 -0700 Subject: [PATCH 3/3] refactor tests --- ldclient_evaluation_all_flags_test.go | 412 +++++++++++++++----------- 1 file changed, 240 insertions(+), 172 deletions(-) diff --git a/ldclient_evaluation_all_flags_test.go b/ldclient_evaluation_all_flags_test.go index 30855d3a..4ead0168 100644 --- a/ldclient_evaluation_all_flags_test.go +++ b/ldclient_evaluation_all_flags_test.go @@ -2,6 +2,7 @@ package ldclient import ( "errors" + "github.com/stretchr/testify/require" "testing" "github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest/mocks" @@ -247,217 +248,284 @@ func TestAllFlagsStateReturnsInvalidStateIfStoreReturnsError(t *testing.T) { assert.Contains(t, mockLoggers.GetOutput(ldlog.Warn)[0], "Unable to fetch flags") } -type test struct { - name string - options []flagstate.Option -} +func TestAllFlagsStateReturnsPrerequisites(t *testing.T) { -func optionPermutations() []test { - type option struct { - name string - option flagstate.Option - } - options := []option{ - {name: "with reasons", option: flagstate.OptionWithReasons()}, - {name: "client-side only", option: flagstate.OptionClientSideOnly()}, - {name: "details only for tracked flags", option: flagstate.OptionDetailsOnlyForTrackedFlags()}, - } - tests := []test{ - {name: "no options", options: []flagstate.Option{}}, - } - for i, opt := range options { - tests = append(tests, test{name: opt.name, options: []flagstate.Option{opt.option}}) - for j := i + 1; j < len(options); j++ { - tests = append(tests, test{name: opt.name + " and " + options[j].name, options: []flagstate.Option{opt.option, options[j].option}}) - for k := j + 1; k < len(options); k++ { - tests = append(tests, test{name: opt.name + " and " + options[j].name + " and " + options[k].name, - options: []flagstate.Option{opt.option, options[j].option, options[k].option}}) - } - } + // Creates a boolean flag that is on (true). + booleanFlag := func(key string) *ldbuilders.FlagBuilder { + return ldbuilders.NewFlagBuilder(key). + Version(100). + On(true). + OffVariation(0). + FallthroughVariation(1). + Variations(ldvalue.Bool(false), ldvalue.Bool(true)) } - return tests -} + t.Run("only top-level (direct) prerequisites are included in the prerequisites field", func(t *testing.T) { -func TestAllFlagsStateReturnsPrerequisites(t *testing.T) { + // Toplevel has one direct prerequisite. + toplevel := booleanFlag("toplevel"). + AddPrerequisite("prereq1", 1).Build() - t.Run("when flag is visible to clients", func(t *testing.T) { - flag1 := ldbuilders.NewFlagBuilder("key1").Version(100).On(true).OffVariation(0). - Variations(ldvalue.String("value1")).AddPrerequisite("key2", 0).ClientSideUsingEnvironmentID(true).Build() + // Prereq1 also has one direct prerequisite. + prereq1 := booleanFlag("prereq1"). + AddPrerequisite("prereq2", 1).Build() - flag2 := ldbuilders.NewFlagBuilder("key2").Version(100).OffVariation(0). - Variations(ldvalue.String("value1")).ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + // Prereq2 has no prerequisites itself. + prereq2 := booleanFlag("prereq2").Build() - for _, test := range optionPermutations() { - t.Run(test.name, func(t *testing.T) { - withClientEvalTestParams(func(p clientEvalTestParams) { - p.data.UsePreconfiguredFlag(flag1) - p.data.UsePreconfiguredFlag(flag2) + // We expect that toplevel and prereq1 should list only their direct prerequisites. That is, + // toplevel should not list [prereq1, prereq2], but only [prereq1]. - state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) - assert.True(t, state.IsValid()) + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(toplevel) + p.data.UsePreconfiguredFlag(prereq1) + p.data.UsePreconfiguredFlag(prereq2) - flag1state, ok := state.GetFlag("key1") - assert.True(t, ok) - assert.Equal(t, []string{"key2"}, flag1state.Prerequisites) - }) - }) - } + state := p.client.AllFlagsState(lduser.NewUser("userkey")) + require.True(t, state.IsValid()) + + toplevelState, ok := state.GetFlag("toplevel") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq1"}, toplevelState.Prerequisites) + } + + prereq1State, ok := state.GetFlag("prereq1") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq2"}, prereq1State.Prerequisites) + } + + prereq2State, ok := state.GetFlag("prereq2") + if assert.True(t, ok) { + assert.Empty(t, prereq2State.Prerequisites) + } + }) }) - t.Run("when flag is not visible to clients", func(t *testing.T) { - flag1 := ldbuilders.NewFlagBuilder("key1").Version(100).On(true).OffVariation(0). - Variations(ldvalue.String("value1")).AddPrerequisite("key2", 0). - ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + t.Run("the prerequisites field should hold prerequisites in evaluation order", func(t *testing.T) { + + // These tests ensure all direct prerequisites of a flag (that is on) are included in the + // prerequisites field. The sub-tests are a sanity check to make sure we're not sorting the array + // accidentally - the array should follow eval order. + t.Run("depth one, all on", func(t *testing.T) { + t.Run("ascending alphabetic", func(t *testing.T) { + toplevel := booleanFlag("toplevel"). + AddPrerequisite("prereq1", 1). + AddPrerequisite("prereq2", 1). + AddPrerequisite("prereq3", 1).Build() - flag2 := ldbuilders.NewFlagBuilder("key2").Version(100).OffVariation(0). - Variations(ldvalue.String("value1")).ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + prereq1 := booleanFlag("prereq1").Build() + prereq2 := booleanFlag("prereq2").Build() + prereq3 := booleanFlag("prereq3").Build() - for _, test := range optionPermutations() { - t.Run(test.name, func(t *testing.T) { withClientEvalTestParams(func(p clientEvalTestParams) { - p.data.UsePreconfiguredFlag(flag1) - p.data.UsePreconfiguredFlag(flag2) + p.data.UsePreconfiguredFlag(toplevel) + p.data.UsePreconfiguredFlag(prereq1) + p.data.UsePreconfiguredFlag(prereq2) + p.data.UsePreconfiguredFlag(prereq3) - state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) - assert.True(t, state.IsValid()) + state := p.client.AllFlagsState(lduser.NewUser("userkey")) + require.True(t, state.IsValid()) - // If the flag was visible, then we should see its prerequisites. - fs1, ok := state.GetFlag("key1") - if ok { - assert.Equal(t, []string{"key2"}, fs1.Prerequisites) + toplevelState, ok := state.GetFlag("toplevel") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq1", "prereq2", "prereq3"}, toplevelState.Prerequisites) } }) }) - } - }) - t.Run("when flag is off, no prerequisites are returned", func(t *testing.T) { - flag1 := ldbuilders.NewFlagBuilder("key1").Version(100).On(false).OffVariation(0). - Variations(ldvalue.String("value1")).AddPrerequisite("key2", 0). - ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + t.Run("descending alphabetic", func(t *testing.T) { + toplevel := booleanFlag("toplevel"). + AddPrerequisite("prereq3", 1). + AddPrerequisite("prereq2", 1). + AddPrerequisite("prereq1", 1).Build() - flag2 := ldbuilders.NewFlagBuilder("key2").Version(100).OffVariation(0). - Variations(ldvalue.String("value1")).ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + prereq1 := booleanFlag("prereq1").Build() + prereq2 := booleanFlag("prereq2").Build() + prereq3 := booleanFlag("prereq3").Build() - for _, test := range optionPermutations() { - t.Run(test.name, func(t *testing.T) { withClientEvalTestParams(func(p clientEvalTestParams) { - p.data.UsePreconfiguredFlag(flag1) - p.data.UsePreconfiguredFlag(flag2) + p.data.UsePreconfiguredFlag(toplevel) + p.data.UsePreconfiguredFlag(prereq1) + p.data.UsePreconfiguredFlag(prereq2) + p.data.UsePreconfiguredFlag(prereq3) - state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) - assert.True(t, state.IsValid()) + state := p.client.AllFlagsState(lduser.NewUser("userkey")) + require.True(t, state.IsValid()) - // If the flag was visible, then we should see that it had no prerequisites evaluated - // since the flag was off. - fs1, ok := state.GetFlag("key1") - if ok { - assert.Empty(t, fs1.Prerequisites) + toplevelState, ok := state.GetFlag("toplevel") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq3", "prereq2", "prereq1"}, toplevelState.Prerequisites) } }) }) - } - }) + }) - t.Run("only returns top-level prerequisites", func(t *testing.T) { - flag1 := ldbuilders.NewFlagBuilder("key1").Version(100).On(true).OffVariation(0). - Variations(ldvalue.String("value1")).AddPrerequisite("key2", 0). - ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + t.Run("depth three, toplevel flag is off", func(t *testing.T) { + toplevel := booleanFlag("toplevel"). + AddPrerequisite("prereq1", 1).On(false).Build() + + prereq1 := booleanFlag("prereq1"). + AddPrerequisite("prereq2", 1).Build() + + prereq2 := booleanFlag("prereq2"). + AddPrerequisite("prereq3", 1).Build() + + prereq3 := booleanFlag("prereq3").Build() + + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(toplevel) + p.data.UsePreconfiguredFlag(prereq1) + p.data.UsePreconfiguredFlag(prereq2) + p.data.UsePreconfiguredFlag(prereq3) + + state := p.client.AllFlagsState(lduser.NewUser("userkey")) + require.True(t, state.IsValid()) + + // If toplevel were on, then we'd expect to see the single prerequisite. Since it's not, we shouldn't + // see any. + toplevelState, ok := state.GetFlag("toplevel") + if assert.True(t, ok) { + assert.Empty(t, toplevelState.Prerequisites) + } + + // The other prerequisites are themselves top-level flags when evaluated, so they should have their + // own prerequisites included since they're all on. + prereq1State, ok := state.GetFlag("prereq1") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq2"}, prereq1State.Prerequisites) + } + + prereq2State, ok := state.GetFlag("prereq2") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq3"}, prereq2State.Prerequisites) + } + + // Prereq1 had no prereqs. + prereq3State, ok := state.GetFlag("prereq3") + if assert.True(t, ok) { + assert.Empty(t, prereq3State.Prerequisites) + } + }) + }) - flag2 := ldbuilders.NewFlagBuilder("key2").Version(100).On(true).OffVariation(0). - Variations(ldvalue.String("value1")).AddPrerequisite("key3", 0). - ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + t.Run("depth three, first prerequisite is off", func(t *testing.T) { + toplevel := booleanFlag("toplevel"). + AddPrerequisite("prereq1", 1).Build() - flag3 := ldbuilders.NewFlagBuilder("key3").Version(100).On(false).OffVariation(0). - Variations(ldvalue.String("value1")).ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() + prereq1 := booleanFlag("prereq1"). + AddPrerequisite("prereq2", 1).On(false).Build() - for _, test := range optionPermutations() { - t.Run(test.name, func(t *testing.T) { - withClientEvalTestParams(func(p clientEvalTestParams) { - p.data.UsePreconfiguredFlag(flag1) - p.data.UsePreconfiguredFlag(flag2) - p.data.UsePreconfiguredFlag(flag3) - - state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) - assert.True(t, state.IsValid()) - - // If the flag was visible, then we should see that it had no prerequisites evaluated - // since the flag was off. - fs1, ok := state.GetFlag("key1") - if ok { - assert.Equal(t, []string{"key2"}, fs1.Prerequisites) - } + prereq2 := booleanFlag("prereq2"). + AddPrerequisite("prereq3", 1).Build() - fs2, ok := state.GetFlag("key2") - if ok { - assert.Equal(t, []string{"key3"}, fs2.Prerequisites) - } + prereq3 := booleanFlag("prereq3").Build() - fs3, ok := state.GetFlag("key3") - if ok { - assert.Empty(t, fs3.Prerequisites) - } - }) + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(toplevel) + p.data.UsePreconfiguredFlag(prereq1) + p.data.UsePreconfiguredFlag(prereq2) + p.data.UsePreconfiguredFlag(prereq3) + + state := p.client.AllFlagsState(lduser.NewUser("userkey")) + require.True(t, state.IsValid()) + + toplevelState, ok := state.GetFlag("toplevel") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq1"}, toplevelState.Prerequisites) + } + + prereq1State, ok := state.GetFlag("prereq1") + if assert.True(t, ok) { + assert.Empty(t, prereq1State.Prerequisites) + } + + prereq2State, ok := state.GetFlag("prereq2") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq3"}, prereq2State.Prerequisites) + } + + prereq3State, ok := state.GetFlag("prereq3") + if assert.True(t, ok) { + assert.Empty(t, prereq3State.Prerequisites) + } }) - } - }) + }) - t.Run("prerequisites are in evaluation order", func(t *testing.T) { - ascending := ldbuilders.NewFlagBuilder("key1").Version(100).On(true).OffVariation(0). - Variations(ldvalue.String("value1")).AddPrerequisite("key2", 0).AddPrerequisite("key3", 0). - ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() - - descending := ldbuilders.NewFlagBuilder("key1").Version(100).On(true).OffVariation(0). - Variations(ldvalue.String("value1")).AddPrerequisite("key3", 0).AddPrerequisite("key2", 0). - ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() - - flag2 := ldbuilders.NewFlagBuilder("key2").Version(100).On(true).OffVariation(0).FallthroughVariation(0). - Variations(ldvalue.String("value1")).AddPrerequisite("key3", 0). - ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() - - flag3 := ldbuilders.NewFlagBuilder("key3").Version(100).On(true).OffVariation(0).FallthroughVariation(0). - Variations(ldvalue.String("value1")).ClientSideUsingEnvironmentID(false).ClientSideUsingMobileKey(false).Build() - - t.Run("ascending", func(t *testing.T) { - for _, test := range optionPermutations() { - t.Run(test.name, func(t *testing.T) { - withClientEvalTestParams(func(p clientEvalTestParams) { - p.data.UsePreconfiguredFlag(ascending) - p.data.UsePreconfiguredFlag(flag2) - p.data.UsePreconfiguredFlag(flag3) - - state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) - assert.True(t, state.IsValid()) - - fs1, ok := state.GetFlag("key1") - if ok { - assert.Equal(t, []string{"key2", "key3"}, fs1.Prerequisites) - } - }) - }) - } + t.Run("depth three, second prerequisite is off", func(t *testing.T) { + toplevel := booleanFlag("toplevel"). + AddPrerequisite("prereq1", 1).Build() + + prereq1 := booleanFlag("prereq1"). + AddPrerequisite("prereq2", 1).Build() + + prereq2 := booleanFlag("prereq2"). + AddPrerequisite("prereq3", 1).On(false).Build() + + prereq3 := booleanFlag("prereq3").Build() + + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(toplevel) + p.data.UsePreconfiguredFlag(prereq1) + p.data.UsePreconfiguredFlag(prereq2) + p.data.UsePreconfiguredFlag(prereq3) + + state := p.client.AllFlagsState(lduser.NewUser("userkey")) + require.True(t, state.IsValid()) + + toplevelState, ok := state.GetFlag("toplevel") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq1"}, toplevelState.Prerequisites) + } + + prereq1State, ok := state.GetFlag("prereq1") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq2"}, prereq1State.Prerequisites) + } + + prereq2State, ok := state.GetFlag("prereq2") + if assert.True(t, ok) { + assert.Empty(t, prereq2State.Prerequisites) + } + + prereq3State, ok := state.GetFlag("prereq3") + if assert.True(t, ok) { + assert.Empty(t, prereq3State.Prerequisites) + } + }) }) - t.Run("descending", func(t *testing.T) { - for _, test := range optionPermutations() { - t.Run(test.name, func(t *testing.T) { - withClientEvalTestParams(func(p clientEvalTestParams) { - p.data.UsePreconfiguredFlag(descending) - p.data.UsePreconfiguredFlag(flag2) - p.data.UsePreconfiguredFlag(flag3) - - state := p.client.AllFlagsState(lduser.NewUser("userkey"), test.options...) - assert.True(t, state.IsValid()) - - fs1, ok := state.GetFlag("key1") - if ok { - assert.Equal(t, []string{"key3", "key2"}, fs1.Prerequisites) - } - }) - }) + }) + + t.Run("prerequisite flags not visible to client SDKs should still be referenced in visible flags", func(t *testing.T) { + toplevel := booleanFlag("toplevel"). + ClientSideUsingEnvironmentID(true). + AddPrerequisite("prereq1", 1). + AddPrerequisite("prereq2", 1).Build() + + prereq1 := booleanFlag("prereq1"). + ClientSideUsingEnvironmentID(false).Build() + + prereq2 := booleanFlag("prereq2"). + ClientSideUsingEnvironmentID(false).Build() + + withClientEvalTestParams(func(p clientEvalTestParams) { + p.data.UsePreconfiguredFlag(toplevel) + p.data.UsePreconfiguredFlag(prereq1) + p.data.UsePreconfiguredFlag(prereq2) + + state := p.client.AllFlagsState(lduser.NewUser("userkey"), flagstate.OptionClientSideOnly()) + require.True(t, state.IsValid()) + + toplevelState, ok := state.GetFlag("toplevel") + if assert.True(t, ok) { + assert.Equal(t, []string{"prereq1", "prereq2"}, toplevelState.Prerequisites) } + + _, ok = state.GetFlag("prereq1") + assert.False(t, ok) + + _, ok = state.GetFlag("prereq2") + assert.False(t, ok) }) }) - }