From 9310b37fccdda1bc0eb274f372de633add4c4820 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:06:09 +0000 Subject: [PATCH 1/5] chore(deps): update spec digest to 27e4461 --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 0cd553d8..27e4461b 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 0cd553d85f03b0b7ab983a988ce1720a3190bc88 +Subproject commit 27e4461b452429ec64bcb89af0301f7664cb702a From 1b832007ee1543c15af1e04e3946625b1fdf4506 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 9 Apr 2025 22:57:32 +0100 Subject: [PATCH 2/5] Add steps for ContextMerging feature tests Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/BaseStepDefinitions.cs | 122 ++++++++++++++++++ ...ContextMergingPrecedenceStepDefinitions.cs | 33 +++++ test/OpenFeature.E2ETests/Utils/State.cs | 2 + 3 files changed, 157 insertions(+) create mode 100644 test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index 1e8311ae..4f4375d4 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -1,4 +1,7 @@ +using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using OpenFeature.E2ETests.Utils; using OpenFeature.Model; @@ -57,6 +60,56 @@ public void GivenAString_FlagWithKeyAndADefaultValue(string key, string defaultT this.State.Flag = flagState; } + [Given("a stable provider with retrievable context is registered")] + public void GivenAStableProviderWithRetrievableContextIsRegistered() + { + var memProvider = new InMemoryProvider(E2EFlagConfig); + Api.Instance.SetProviderAsync(memProvider).Wait(); + + var hook = new MockHook((ctx) => this.State.EvaluationContext = ctx); + Api.Instance.AddHooks(hook); + + Api.Instance.SetTransactionContextPropagator(new AsyncLocalTransactionContextPropagator()); + + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given(@"A context entry with key ""(.*)"" and value ""(.*)"" is added to the ""(.*)"" level")] + public void GivenAContextEntryWithKeyAndValueIsAddedToTheLevel(string key, string value, string level) + { + var context = EvaluationContext.Builder() + .Set(key, value) + .Build(); + + this.InitialiseContext(level, context); + } + + [Given("A table with levels of increasing precedence")] + public void GivenATableWithLevelsOfIncreasingPrecedence(DataTable dataTable) + { + var items = dataTable.Rows.ToList(); + + var levels = items.Select(r => r.Values.First()); + + this.State.ContextPrecedenceLevels = levels.ToArray(); + } + + [Given(@"Context entries for each level from API level down to the ""(.*)"" level, with key ""(.*)"" and value ""(.*)""")] + public void GivenContextEntriesForEachLevelFromAPILevelDownToTheLevelWithKeyAndValue(string currentLevel, string key, string value) + { + if (this.State.ContextPrecedenceLevels == null) + this.State.ContextPrecedenceLevels = new string[0]; + + foreach (var level in this.State.ContextPrecedenceLevels ) + { + var context = EvaluationContext.Builder() + .Set(key, value) + .Build(); + + this.InitialiseContext(level, context); + } + } + [When(@"the flag was evaluated with details")] public async Task WhenTheFlagWasEvaluatedWithDetails() { @@ -82,6 +135,57 @@ public async Task WhenTheFlagWasEvaluatedWithDetails() break; } } + private void InitialiseContext(string level, EvaluationContext context) + { + switch (level) + { + case "API": + { + Api.Instance.SetContext(context); + break; + } + case "Transaction": + { + Api.Instance.SetTransactionContext(context); + break; + } + case "Client": + { + if (this.State.Client != null) + { + this.State.Client.SetContext(context); + } + else + { + throw new PendingStepException("You must initialise a FeatureClient before adding some EvaluationContext"); + } + break; + } + case "Invocation": + { + this.State.InvocationEvaluationContext = context; + break; + } + case "Before Hooks": // Assumed before hooks is the same as Invocation + { + if (this.State.InvocationEvaluationContext != null) + { + this.State.InvocationEvaluationContext = EvaluationContext.Builder() + .Merge(context) + .Merge(this.State.InvocationEvaluationContext!) + .Build(); + } + else + { + this.State.InvocationEvaluationContext = context; + } + + break; + } + default: + break; + } + } private static readonly IDictionary E2EFlagConfig = new Dictionary { @@ -159,4 +263,22 @@ public async Task WhenTheFlagWasEvaluatedWithDetails() ) } }; + + public class MockHook : Hook + { + private readonly Func mergedFinallyContext; + + public MockHook(Func mergedFinallyContext) + { + this.mergedFinallyContext = mergedFinallyContext; + } + + public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails evaluationDetails, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + this.mergedFinallyContext(context.EvaluationContext); + + return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); + } + } + } diff --git a/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs new file mode 100644 index 00000000..5b3fea8f --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using OpenFeature.E2ETests.Utils; +using Reqnroll; +using Xunit; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +[Scope(Feature = "Context merging precedence")] +public class ContextMergingPrecedenceStepDefinitions : BaseStepDefinitions +{ + public ContextMergingPrecedenceStepDefinitions(State state) : base(state) + { + } + + [When("Some flag was evaluated")] + public async Task WhenSomeFlagWasEvaluated() + { + this.State.Flag = new FlagState("boolean-flag", "true".ToString(), FlagType.Boolean); + this.State.FlagResult = await this.State.Client!.GetBooleanValueAsync("boolean-flag", true, this.State.InvocationEvaluationContext).ConfigureAwait(false); + } + + [Then(@"The merged context contains an entry with key ""(.*)"" and value ""(.*)""")] + public void ThenTheMergedContextContainsAnEntryWithKeyAndValue(string key, string value) + { + var mergedContext = this.State.EvaluationContext!; + + Assert.NotNull(mergedContext); + + var actualValue = mergedContext.GetValue(key); + Assert.Contains(value, actualValue.AsString); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/State.cs b/test/OpenFeature.E2ETests/Utils/State.cs index b3380132..76fc86ef 100644 --- a/test/OpenFeature.E2ETests/Utils/State.cs +++ b/test/OpenFeature.E2ETests/Utils/State.cs @@ -10,4 +10,6 @@ public class State public TestHook? TestHook; public object? FlagResult; public EvaluationContext? EvaluationContext; + public EvaluationContext? InvocationEvaluationContext; + public string[]? ContextPrecedenceLevels; } From 3c7c70b4c8ab8748a9ffe9ca7c116ad20b86c7ad Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 9 Apr 2025 23:05:37 +0100 Subject: [PATCH 3/5] Remove extra space in foreach statement Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index 4f4375d4..26876d24 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -100,7 +100,7 @@ public void GivenContextEntriesForEachLevelFromAPILevelDownToTheLevelWithKeyAndV if (this.State.ContextPrecedenceLevels == null) this.State.ContextPrecedenceLevels = new string[0]; - foreach (var level in this.State.ContextPrecedenceLevels ) + foreach (var level in this.State.ContextPrecedenceLevels) { var context = EvaluationContext.Builder() .Set(key, value) From b1dbe04711be4ff707056c786a78e1aefad590ee Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Mon, 14 Apr 2025 18:58:11 +0100 Subject: [PATCH 4/5] Replace .Wait() with await Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index 26876d24..50295bb1 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -21,10 +21,10 @@ public BaseStepDefinitions(State state) } [Given(@"a stable provider")] - public void GivenAStableProvider() + public async Task GivenAStableProvider() { var memProvider = new InMemoryProvider(E2EFlagConfig); - Api.Instance.SetProviderAsync(memProvider).Wait(); + await Api.Instance.SetProviderAsync(memProvider).ConfigureAwait(false); this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); } @@ -61,10 +61,10 @@ public void GivenAString_FlagWithKeyAndADefaultValue(string key, string defaultT } [Given("a stable provider with retrievable context is registered")] - public void GivenAStableProviderWithRetrievableContextIsRegistered() + public async Task GivenAStableProviderWithRetrievableContextIsRegistered() { var memProvider = new InMemoryProvider(E2EFlagConfig); - Api.Instance.SetProviderAsync(memProvider).Wait(); + await Api.Instance.SetProviderAsync(memProvider).ConfigureAwait(false); var hook = new MockHook((ctx) => this.State.EvaluationContext = ctx); Api.Instance.AddHooks(hook); From cede356ecbf22c58686bbcbdfa41038908b48542 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Tue, 15 Apr 2025 20:38:50 +0100 Subject: [PATCH 5/5] Address PR comments * Follow Java SDK by creating ContextStoringProvider for accessing the merged EvaluationContext * Correctly implement the BeforeHook tests * Correct naming of InitializeContext * Remove unnecessary ToString on true string Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/BaseStepDefinitions.cs | 39 ++++++---------- ...ContextMergingPrecedenceStepDefinitions.cs | 6 ++- .../Utils/ContextStoringProvider.cs | 46 +++++++++++++++++++ test/OpenFeature.E2ETests/Utils/State.cs | 1 + 4 files changed, 66 insertions(+), 26 deletions(-) create mode 100644 test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index 50295bb1..6b2bfebf 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -63,11 +62,9 @@ public void GivenAString_FlagWithKeyAndADefaultValue(string key, string defaultT [Given("a stable provider with retrievable context is registered")] public async Task GivenAStableProviderWithRetrievableContextIsRegistered() { - var memProvider = new InMemoryProvider(E2EFlagConfig); - await Api.Instance.SetProviderAsync(memProvider).ConfigureAwait(false); + this.State.ContextStoringProvider = new ContextStoringProvider(); - var hook = new MockHook((ctx) => this.State.EvaluationContext = ctx); - Api.Instance.AddHooks(hook); + await Api.Instance.SetProviderAsync(this.State.ContextStoringProvider).ConfigureAwait(false); Api.Instance.SetTransactionContextPropagator(new AsyncLocalTransactionContextPropagator()); @@ -81,7 +78,7 @@ public void GivenAContextEntryWithKeyAndValueIsAddedToTheLevel(string key, strin .Set(key, value) .Build(); - this.InitialiseContext(level, context); + this.InitializeContext(level, context); } [Given("A table with levels of increasing precedence")] @@ -106,7 +103,7 @@ public void GivenContextEntriesForEachLevelFromAPILevelDownToTheLevelWithKeyAndV .Set(key, value) .Build(); - this.InitialiseContext(level, context); + this.InitializeContext(level, context); } } @@ -135,7 +132,7 @@ public async Task WhenTheFlagWasEvaluatedWithDetails() break; } } - private void InitialiseContext(string level, EvaluationContext context) + private void InitializeContext(string level, EvaluationContext context) { switch (level) { @@ -168,22 +165,19 @@ private void InitialiseContext(string level, EvaluationContext context) } case "Before Hooks": // Assumed before hooks is the same as Invocation { - if (this.State.InvocationEvaluationContext != null) + if (this.State.Client != null) { - this.State.InvocationEvaluationContext = EvaluationContext.Builder() - .Merge(context) - .Merge(this.State.InvocationEvaluationContext!) - .Build(); + this.State.Client.AddHooks(new BeforeHook(context)); } else { - this.State.InvocationEvaluationContext = context; + throw new PendingStepException("You must initialise a FeatureClient before adding some EvaluationContext"); } break; } default: - break; + throw new PendingStepException("Context level not defined"); } } @@ -264,21 +258,18 @@ private void InitialiseContext(string level, EvaluationContext context) } }; - public class MockHook : Hook + public class BeforeHook : Hook { - private readonly Func mergedFinallyContext; + private readonly EvaluationContext context; - public MockHook(Func mergedFinallyContext) + public BeforeHook(EvaluationContext context) { - this.mergedFinallyContext = mergedFinallyContext; + this.context = context; } - public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails evaluationDetails, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) { - this.mergedFinallyContext(context.EvaluationContext); - - return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); + return new ValueTask(this.context); } } - } diff --git a/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs index 5b3fea8f..c9f454ac 100644 --- a/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs @@ -16,14 +16,16 @@ public ContextMergingPrecedenceStepDefinitions(State state) : base(state) [When("Some flag was evaluated")] public async Task WhenSomeFlagWasEvaluated() { - this.State.Flag = new FlagState("boolean-flag", "true".ToString(), FlagType.Boolean); + this.State.Flag = new FlagState("boolean-flag", "true", FlagType.Boolean); this.State.FlagResult = await this.State.Client!.GetBooleanValueAsync("boolean-flag", true, this.State.InvocationEvaluationContext).ConfigureAwait(false); } [Then(@"The merged context contains an entry with key ""(.*)"" and value ""(.*)""")] public void ThenTheMergedContextContainsAnEntryWithKeyAndValue(string key, string value) { - var mergedContext = this.State.EvaluationContext!; + var provider = this.State.ContextStoringProvider; + + var mergedContext = provider!.EvaluationContext!; Assert.NotNull(mergedContext); diff --git a/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs b/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs new file mode 100644 index 00000000..40141e79 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs @@ -0,0 +1,46 @@ +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +public class ContextStoringProvider : FeatureProvider +{ + private EvaluationContext? evaluationContext; + public EvaluationContext? EvaluationContext { get => this.evaluationContext; } + + public override Metadata? GetMetadata() + { + return new Metadata("ContextStoringProvider"); + } + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/State.cs b/test/OpenFeature.E2ETests/Utils/State.cs index 76fc86ef..13a4e5a3 100644 --- a/test/OpenFeature.E2ETests/Utils/State.cs +++ b/test/OpenFeature.E2ETests/Utils/State.cs @@ -10,6 +10,7 @@ public class State public TestHook? TestHook; public object? FlagResult; public EvaluationContext? EvaluationContext; + public ContextStoringProvider? ContextStoringProvider; public EvaluationContext? InvocationEvaluationContext; public string[]? ContextPrecedenceLevels; }