diff --git a/.gitignore b/.gitignore index c77e4f530..5648ca1dc 100644 --- a/.gitignore +++ b/.gitignore @@ -351,5 +351,7 @@ ASALocalRun/ # integration tests test/OpenFeature.E2ETests/Features/*.feature test/OpenFeature.E2ETests/Features/*.feature.cs +test/OpenFeature.E2ETests/Features/README.md +test/OpenFeature.E2ETests/Features/test-flags.json cs-report.json specification.json diff --git a/spec b/spec index 969e11c4d..4542c3cdf 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 969e11c4d5df4ab16b400965ef1b3e313dcb923e +Subproject commit 4542c3cdfe8ae9947c7963cb91bfef1d21b643d4 diff --git a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj index c27e693c5..a6fabda89 100644 --- a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj +++ b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj @@ -1,4 +1,4 @@ - + net8.0;net9.0 @@ -18,6 +18,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -35,7 +36,15 @@ - + + + + + + PreserveNewest + + + diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs deleted file mode 100644 index 7a9c3c0a9..000000000 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ /dev/null @@ -1,270 +0,0 @@ -using OpenFeature.E2ETests.Utils; -using OpenFeature.Model; -using OpenFeature.Providers.Memory; - -namespace OpenFeature.E2ETests.Steps; - -[Binding] -public class BaseStepDefinitions -{ - protected readonly State State; - - public BaseStepDefinitions(State state) - { - this.State = state; - } - - [Given(@"a stable provider")] - public async Task GivenAStableProvider() - { - var memProvider = new InMemoryProvider(E2EFlagConfig); - await Api.Instance.SetProviderAsync(memProvider).ConfigureAwait(false); - this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); - } - - [Given(@"a Boolean-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a boolean-flag with key ""(.*)"" and a default value ""(.*)""")] - public void GivenABoolean_FlagWithKeyAndADefaultValue(string key, string defaultType) - { - var flagState = new FlagState(key, defaultType, FlagType.Boolean); - this.State.Flag = flagState; - } - - [Given(@"a Float-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a float-flag with key ""(.*)"" and a default value ""(.*)""")] - public void GivenAFloat_FlagWithKeyAndADefaultValue(string key, string defaultType) - { - var flagState = new FlagState(key, defaultType, FlagType.Float); - this.State.Flag = flagState; - } - - [Given(@"a Integer-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a integer-flag with key ""(.*)"" and a default value ""(.*)""")] - public void GivenAnInteger_FlagWithKeyAndADefaultValue(string key, string defaultType) - { - var flagState = new FlagState(key, defaultType, FlagType.Integer); - this.State.Flag = flagState; - } - - [Given(@"a String-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a string-flag with key ""(.*)"" and a default value ""(.*)""")] - public void GivenAString_FlagWithKeyAndADefaultValue(string key, string defaultType) - { - var flagState = new FlagState(key, defaultType, FlagType.String); - this.State.Flag = flagState; - } - - [Given("a stable provider with retrievable context is registered")] - public async Task GivenAStableProviderWithRetrievableContextIsRegistered() - { - this.State.ContextStoringProvider = new ContextStoringProvider(); - - await Api.Instance.SetProviderAsync(this.State.ContextStoringProvider).ConfigureAwait(false); - - 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.InitializeContext(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.InitializeContext(level, context); - } - } - - [When(@"the flag was evaluated with details")] - public async Task WhenTheFlagWasEvaluatedWithDetails() - { - var flag = this.State.Flag!; - - switch (flag.Type) - { - case FlagType.Boolean: - this.State.FlagEvaluationDetailsResult = await this.State.Client! - .GetBooleanDetailsAsync(flag.Key, bool.Parse(flag.DefaultValue)).ConfigureAwait(false); - break; - case FlagType.Float: - this.State.FlagEvaluationDetailsResult = await this.State.Client! - .GetDoubleDetailsAsync(flag.Key, double.Parse(flag.DefaultValue)).ConfigureAwait(false); - break; - case FlagType.Integer: - this.State.FlagEvaluationDetailsResult = await this.State.Client! - .GetIntegerDetailsAsync(flag.Key, int.Parse(flag.DefaultValue)).ConfigureAwait(false); - break; - case FlagType.String: - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flag.Key, flag.DefaultValue) - .ConfigureAwait(false); - break; - } - } - private void InitializeContext(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.Client != null) - { - this.State.Client.AddHooks(new BeforeHook(context)); - } - else - { - throw new PendingStepException("You must initialise a FeatureClient before adding some EvaluationContext"); - } - - break; - } - default: - throw new PendingStepException("Context level not defined"); - } - } - - private static readonly IDictionary E2EFlagConfig = new Dictionary - { - { - "metadata-flag", new Flag( - variants: new Dictionary { { "on", true }, { "off", false } }, - defaultVariant: "on", - flagMetadata: new ImmutableMetadata(new Dictionary - { - { "string", "1.0.2" }, { "integer", 2 }, { "float", 0.1 }, { "boolean", true } - }) - ) - }, - { - "boolean-flag", new Flag( - variants: new Dictionary { { "on", true }, { "off", false } }, - defaultVariant: "on" - ) - }, - { - "string-flag", new Flag( - variants: new Dictionary() { { "greeting", "hi" }, { "parting", "bye" } }, - defaultVariant: "greeting" - ) - }, - { - "integer-flag", new Flag( - variants: new Dictionary() { { "one", 1 }, { "ten", 10 } }, - defaultVariant: "ten" - ) - }, - { - "float-flag", new Flag( - variants: new Dictionary() { { "tenth", 0.1 }, { "half", 0.5 } }, - defaultVariant: "half" - ) - }, - { - "object-flag", new Flag( - variants: new Dictionary() - { - { "empty", new Value() }, - { - "template", new Value(Structure.Builder() - .Set("showImages", true) - .Set("title", "Check out these pics!") - .Set("imagesPerPage", 100).Build() - ) - } - }, - defaultVariant: "template" - ) - }, - { - "context-aware", new Flag( - variants: new Dictionary() { { "internal", "INTERNAL" }, { "external", "EXTERNAL" } }, - defaultVariant: "external", - (context) => - { - if (context.GetValue("fn").AsString == "Sulisław" - && context.GetValue("ln").AsString == "Świętopełk" - && context.GetValue("age").AsInteger == 29 - && context.GetValue("customer").AsBoolean == false) - { - return "internal"; - } - else return "external"; - } - ) - }, - { - "wrong-flag", new Flag( - variants: new Dictionary() { { "one", "uno" }, { "two", "dos" } }, - defaultVariant: "one" - ) - } - }; - - public class BeforeHook : Hook - { - private readonly EvaluationContext context; - - public BeforeHook(EvaluationContext context) - { - this.context = context; - } - - public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) - { - return new ValueTask(this.context); - } - } -} diff --git a/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs deleted file mode 100644 index c95b20495..000000000 --- a/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using OpenFeature.E2ETests.Utils; - -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", 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 provider = this.State.ContextStoringProvider; - - var mergedContext = provider!.EvaluationContext!; - - Assert.NotNull(mergedContext); - - var actualValue = mergedContext.GetValue(key); - Assert.Contains(value, actualValue.AsString); - } -} diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationContextStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationContextStepDefinitions.cs new file mode 100644 index 000000000..198d47cdb --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/EvaluationContextStepDefinitions.cs @@ -0,0 +1,144 @@ +using OpenFeature.E2ETests.Utils; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +public class EvaluationContextStepDefinitions +{ + private readonly State _state; + + public EvaluationContextStepDefinitions(State state) + { + this._state = state; + } + + [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.InitializeContext(level, context); + } + + [Given(@"a context containing a key ""(.*)"", with type ""(.*)"" and with value ""(.*)""")] + public void GivenAContextContainingAKeyWithTypeAndWithValue(string key, string type, string value) + { + var context = EvaluationContext.Builder() + .Merge(this._state.EvaluationContext ?? EvaluationContext.Empty); + + switch (type) + { + case "Integer": + context = context.Set(key, int.Parse(value)); + break; + case "Float": + context = context.Set(key, double.Parse(value)); + break; + case "String": + context = context.Set(key, value); + break; + case "Boolean": + context = context.Set(key, bool.Parse(value)); + break; + case "Object": + context = context.Set(key, new Value(value)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + + this._state.EvaluationContext = context.Build(); + } + + [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.InitializeContext(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(); + } + + [Then(@"The merged context contains an entry with key ""(.*)"" and value ""(.*)""")] + public void ThenTheMergedContextContainsAnEntryWithKeyAndValue(string key, string value) + { + var provider = this._state.ContextStoringProvider; + + var mergedContext = provider!.EvaluationContext!; + + Assert.NotNull(mergedContext); + + var actualValue = mergedContext.GetValue(key); + Assert.Contains(value, actualValue.AsString); + } + + private void InitializeContext(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.Client != null) + { + this._state.Client.AddHooks(new BeforeHook(context)); + } + else + { + throw new PendingStepException("You must initialise a FeatureClient before adding some EvaluationContext"); + } + + break; + } + default: + throw new PendingStepException("Context level not defined"); + } + } +} diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs index 27e00359b..7c843b453 100644 --- a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs @@ -7,79 +7,82 @@ namespace OpenFeature.E2ETests.Steps; [Binding] [Scope(Feature = "Flag evaluation")] -public class EvaluationStepDefinitions : BaseStepDefinitions +public class EvaluationStepDefinitions { - public EvaluationStepDefinitions(State state) : base(state) + private readonly State _state; + + public EvaluationStepDefinitions(State state) { + this._state = state; } [When(@"a boolean flag with key ""(.*)"" is evaluated with default value ""(.*)""")] public async Task Whenabooleanflagwithkeyisevaluatedwithdefaultvalue(string flagKey, bool defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); - this.State.FlagResult = await this.State.Client!.GetBooleanValueAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); + this._state.FlagResult = await this._state.Client!.GetBooleanValueAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved boolean value should be ""(.*)""")] public void Thentheresolvedbooleanvalueshouldbe(bool expectedValue) { - var result = this.State.FlagResult as bool?; + var result = this._state.FlagResult as bool?; Assert.Equal(expectedValue, result); } [When(@"a string flag with key ""(.*)"" is evaluated with default value ""(.*)""")] public async Task Whenastringflagwithkeyisevaluatedwithdefaultvalue(string flagKey, string defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this.State.FlagResult = await this.State.Client!.GetStringValueAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this._state.FlagResult = await this._state.Client!.GetStringValueAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved string value should be ""(.*)""")] public void Thentheresolvedstringvalueshouldbe(string expected) { - var result = this.State.FlagResult as string; + var result = this._state.FlagResult as string; Assert.Equal(expected, result); } [When(@"an integer flag with key ""(.*)"" is evaluated with default value (.*)")] public async Task Whenanintegerflagwithkeyisevaluatedwithdefaultvalue(string flagKey, int defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); - this.State.FlagResult = await this.State.Client!.GetIntegerValueAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); + this._state.FlagResult = await this._state.Client!.GetIntegerValueAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved integer value should be (.*)")] public void Thentheresolvedintegervalueshouldbe(int expected) { - var result = this.State.FlagResult as int?; + var result = this._state.FlagResult as int?; Assert.Equal(expected, result); } [When(@"a float flag with key ""(.*)"" is evaluated with default value (.*)")] public async Task Whenafloatflagwithkeyisevaluatedwithdefaultvalue(string flagKey, double defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); - this.State.FlagResult = await this.State.Client!.GetDoubleValueAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); + this._state.FlagResult = await this._state.Client!.GetDoubleValueAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved float value should be (.*)")] public void Thentheresolvedfloatvalueshouldbe(double expected) { - var result = this.State.FlagResult as double?; + var result = this._state.FlagResult as double?; Assert.Equal(expected, result); } [When(@"an object flag with key ""(.*)"" is evaluated with a null default value")] public async Task Whenanobjectflagwithkeyisevaluatedwithanulldefaultvalue(string flagKey) { - this.State.Flag = new FlagState(flagKey, null!, FlagType.Object); - this.State.FlagResult = await this.State.Client!.GetObjectValueAsync(flagKey, new Value()).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, null!, FlagType.Object); + this._state.FlagResult = await this._state.Client!.GetObjectValueAsync(flagKey, new Value()).ConfigureAwait(false); } [Then(@"the resolved object value should be contain fields ""(.*)"", ""(.*)"", and ""(.*)"", with values ""(.*)"", ""(.*)"" and (.*), respectively")] public void Thentheresolvedobjectvalueshouldbecontainfieldsandwithvaluesandrespectively(string boolField, string stringField, string numberField, bool boolValue, string stringValue, int numberValue) { - Value? value = this.State.FlagResult as Value; + Value? value = this._state.FlagResult as Value; Assert.Equal(boolValue, value?.AsStructure?[boolField].AsBoolean); Assert.Equal(stringValue, value?.AsStructure?[stringField].AsString); Assert.Equal(numberValue, value?.AsStructure?[numberField].AsInteger); @@ -88,14 +91,14 @@ public void Thentheresolvedobjectvalueshouldbecontainfieldsandwithvaluesandrespe [When(@"a boolean flag with key ""(.*)"" is evaluated with details and default value ""(.*)""")] public async Task Whenabooleanflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, bool defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetBooleanDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetBooleanDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved boolean details value should be ""(.*)"", the variant should be ""(.*)"", and the reason should be ""(.*)""")] public void Thentheresolvedbooleandetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(bool expectedValue, string expectedVariant, string expectedReason) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(expectedValue, result?.Value); Assert.Equal(expectedVariant, result?.Variant); Assert.Equal(expectedReason, result?.Reason); @@ -104,14 +107,14 @@ public void Thentheresolvedbooleandetailsvalueshouldbethevariantshouldbeandthere [When(@"a string flag with key ""(.*)"" is evaluated with details and default value ""(.*)""")] public async Task Whenastringflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, string defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved string details value should be ""(.*)"", the variant should be ""(.*)"", and the reason should be ""(.*)""")] public void Thentheresolvedstringdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(string expectedValue, string expectedVariant, string expectedReason) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(expectedValue, result?.Value); Assert.Equal(expectedVariant, result?.Variant); Assert.Equal(expectedReason, result?.Reason); @@ -120,14 +123,14 @@ public void Thentheresolvedstringdetailsvalueshouldbethevariantshouldbeandtherea [When(@"an integer flag with key ""(.*)"" is evaluated with details and default value (.*)")] public async Task Whenanintegerflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, int defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved integer details value should be (.*), the variant should be ""(.*)"", and the reason should be ""(.*)""")] public void Thentheresolvedintegerdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(int expectedValue, string expectedVariant, string expectedReason) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(expectedValue, result?.Value); Assert.Equal(expectedVariant, result?.Variant); Assert.Equal(expectedReason, result?.Reason); @@ -136,14 +139,14 @@ public void Thentheresolvedintegerdetailsvalueshouldbethevariantshouldbeandthere [When(@"a float flag with key ""(.*)"" is evaluated with details and default value (.*)")] public async Task Whenafloatflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, double defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetDoubleDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetDoubleDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved float details value should be (.*), the variant should be ""(.*)"", and the reason should be ""(.*)""")] public void Thentheresolvedfloatdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(double expectedValue, string expectedVariant, string expectedReason) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(expectedValue, result?.Value); Assert.Equal(expectedVariant, result?.Variant); Assert.Equal(expectedReason, result?.Reason); @@ -152,14 +155,14 @@ public void Thentheresolvedfloatdetailsvalueshouldbethevariantshouldbeandthereas [When(@"an object flag with key ""(.*)"" is evaluated with details and a null default value")] public async Task Whenanobjectflagwithkeyisevaluatedwithdetailsandanulldefaultvalue(string flagKey) { - this.State.Flag = new FlagState(flagKey, null!, FlagType.Object); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetObjectDetailsAsync(flagKey, new Value()).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, null!, FlagType.Object); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetObjectDetailsAsync(flagKey, new Value()).ConfigureAwait(false); } [Then(@"the resolved object details value should be contain fields ""(.*)"", ""(.*)"", and ""(.*)"", with values ""(.*)"", ""(.*)"" and (.*), respectively")] public void Thentheresolvedobjectdetailsvalueshouldbecontainfieldsandwithvaluesandrespectively(string boolField, string stringField, string numberField, bool boolValue, string stringValue, int numberValue) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; var value = result?.Value; Assert.NotNull(value); Assert.Equal(boolValue, value?.AsStructure?[boolField].AsBoolean); @@ -170,7 +173,7 @@ public void Thentheresolvedobjectdetailsvalueshouldbecontainfieldsandwithvaluesa [Then(@"the variant should be ""(.*)"", and the reason should be ""(.*)""")] public void Giventhevariantshouldbeandthereasonshouldbe(string expectedVariant, string expectedReason) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.NotNull(result); Assert.Equal(expectedVariant, result?.Variant); Assert.Equal(expectedReason, result?.Reason); @@ -179,7 +182,7 @@ public void Giventhevariantshouldbeandthereasonshouldbe(string expectedVariant, [When(@"context contains keys ""(.*)"", ""(.*)"", ""(.*)"", ""(.*)"" with values ""(.*)"", ""(.*)"", (.*), ""(.*)""")] public void Whencontextcontainskeyswithvalues(string field1, string field2, string field3, string field4, string value1, string value2, int value3, string value4) { - this.State.EvaluationContext = new EvaluationContextBuilder() + this._state.EvaluationContext = new EvaluationContextBuilder() .Set(field1, value1) .Set(field2, value2) .Set(field3, value3) @@ -189,46 +192,46 @@ public void Whencontextcontainskeyswithvalues(string field1, string field2, stri [When(@"a flag with key ""(.*)"" is evaluated with default value ""(.*)""")] public async Task Givenaflagwithkeyisevaluatedwithdefaultvalue(string flagKey, string defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this.State.FlagResult = await this.State.Client!.GetStringValueAsync(flagKey, defaultValue, this.State.EvaluationContext).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this._state.FlagResult = await this._state.Client!.GetStringValueAsync(flagKey, defaultValue, this._state.EvaluationContext).ConfigureAwait(false); } [Then(@"the resolved string response should be ""(.*)""")] public void Thentheresolvedstringresponseshouldbe(string expected) { - var result = this.State.FlagResult as string; + var result = this._state.FlagResult as string; Assert.Equal(expected, result); } [Then(@"the resolved flag value is ""(.*)"" when the context is empty")] public async Task Giventheresolvedflagvalueiswhenthecontextisempty(string expected) { - var key = this.State.Flag!.Key; - var defaultValue = this.State.Flag.DefaultValue; + var key = this._state.Flag!.Key; + var defaultValue = this._state.Flag.DefaultValue; - string? emptyContextValue = await this.State.Client!.GetStringValueAsync(key, defaultValue, EvaluationContext.Empty).ConfigureAwait(false); + string? emptyContextValue = await this._state.Client!.GetStringValueAsync(key, defaultValue, EvaluationContext.Empty).ConfigureAwait(false); Assert.Equal(expected, emptyContextValue); } [When(@"a non-existent string flag with key ""(.*)"" is evaluated with details and a default value ""(.*)""")] public async Task Whenanonexistentstringflagwithkeyisevaluatedwithdetailsandadefaultvalue(string flagKey, string defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the default string value should be returned")] public void Thenthedefaultstringvalueshouldbereturned() { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - var defaultValue = this.State.Flag!.DefaultValue; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var defaultValue = this._state.Flag!.DefaultValue; Assert.Equal(defaultValue, result?.Value); } [Then(@"the reason should indicate an error and the error code should indicate a missing flag with ""(.*)""")] public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateamissingflagwith(string errorCode) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(Reason.Error, result?.Reason); Assert.Equal(errorCode, result?.ErrorType.GetDescription()); } @@ -236,22 +239,22 @@ public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateamis [When(@"a string flag with key ""(.*)"" is evaluated as an integer, with details and a default value (.*)")] public async Task Whenastringflagwithkeyisevaluatedasanintegerwithdetailsandadefaultvalue(string flagKey, int defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.String); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.String); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the default integer value should be returned")] public void Thenthedefaultintegervalueshouldbereturned() { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - var defaultValue = int.Parse(this.State.Flag!.DefaultValue); + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var defaultValue = int.Parse(this._state.Flag!.DefaultValue); Assert.Equal(defaultValue, result?.Value); } [Then(@"the reason should indicate an error and the error code should indicate a type mismatch with ""(.*)""")] public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateatypemismatchwith(string errorCode) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(Reason.Error, result?.Reason); Assert.Equal(errorCode, result?.ErrorType.GetDescription()); } diff --git a/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs b/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs new file mode 100644 index 000000000..19b474db8 --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs @@ -0,0 +1,17 @@ +namespace OpenFeature.E2ETests.Steps; + +[Binding] +[Scope(Tag = "evaluation-options")] +[Scope(Tag = "immutability")] +[Scope(Tag = "async")] +[Scope(Tag = "reason-codes-cached")] +[Scope(Tag = "reason-codes-disabled")] +[Scope(Tag = "deprecated")] +public class ExcludedTagsStep +{ + [BeforeScenario] + public static void BeforeScenario() + { + Skip.If(true, "Tag is not supported"); + } +} diff --git a/test/OpenFeature.E2ETests/Steps/FlagStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/FlagStepDefinitions.cs new file mode 100644 index 000000000..599ae62e2 --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/FlagStepDefinitions.cs @@ -0,0 +1,232 @@ +using OpenFeature.Constant; +using OpenFeature.E2ETests.Utils; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +public class FlagStepDefinitions +{ + private readonly State _state; + + public FlagStepDefinitions(State state) + { + this._state = state; + } + + [Given(@"a (Boolean|boolean|Float|Integer|String|string|Object)(?:-flag)? with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a (Boolean|boolean|Float|Integer|String|string|Object)(?:-flag)? with key ""(.*)"" and a fallback value ""(.*)""")] + public void GivenAFlagType_FlagWithKeyAndADefaultValue(FlagType flagType, string key, string defaultType) + { + var flagState = new FlagState(key, defaultType, flagType); + this._state.Flag = flagState; + } + + [StepArgumentTransformation(@"^(Boolean|boolean|Float|Integer|String|string|Object)(?:-flag)?$")] + public static FlagType TransformFlagType(string raw) + => raw.Replace("-flag", "").ToLowerInvariant() switch + { + "boolean" => FlagType.Boolean, + "float" => FlagType.Float, + "integer" => FlagType.Integer, + "string" => FlagType.String, + "object" => FlagType.Object, + _ => throw new Exception($"Unsupported flag type '{raw}'") + }; + + [When("Some flag was evaluated")] + public async Task WhenSomeFlagWasEvaluated() + { + 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); + } + + [When(@"the flag was evaluated with details")] + public async Task WhenTheFlagWasEvaluatedWithDetails() + { + var flag = this._state.Flag!; + + switch (flag.Type) + { + case FlagType.Boolean: + this._state.FlagEvaluationDetailsResult = await this._state.Client! + .GetBooleanDetailsAsync(flag.Key, bool.Parse(flag.DefaultValue), this._state.EvaluationContext) + .ConfigureAwait(false); + break; + case FlagType.Float: + this._state.FlagEvaluationDetailsResult = await this._state.Client! + .GetDoubleDetailsAsync(flag.Key, double.Parse(flag.DefaultValue), this._state.EvaluationContext) + .ConfigureAwait(false); + break; + case FlagType.Integer: + this._state.FlagEvaluationDetailsResult = await this._state.Client! + .GetIntegerDetailsAsync(flag.Key, int.Parse(flag.DefaultValue), this._state.EvaluationContext) + .ConfigureAwait(false); + break; + case FlagType.String: + this._state.FlagEvaluationDetailsResult = await this._state.Client! + .GetStringDetailsAsync(flag.Key, flag.DefaultValue, this._state.EvaluationContext) + .ConfigureAwait(false); + break; + case FlagType.Object: + var defaultStructure = JsonStructureLoader.ParseJsonValue(flag.DefaultValue); + this._state.FlagEvaluationDetailsResult = await this._state.Client! + .GetObjectDetailsAsync(flag.Key, new Value(defaultStructure), this._state.EvaluationContext) + .ConfigureAwait(false); + break; + } + } + + [Then(@"the resolved details value should be ""(.*)""")] + public void ThenTheResolvedDetailsValueShouldBe(string value) + { + switch (this._state.Flag!.Type) + { + case FlagType.Integer: + var intValue = int.Parse(value); + AssertOnDetails(r => Assert.Equal(intValue, r.Value)); + break; + case FlagType.Float: + var floatValue = double.Parse(value); + AssertOnDetails(r => Assert.Equal(floatValue, r.Value)); + break; + case FlagType.String: + var stringValue = value; + AssertOnDetails(r => Assert.Equal(stringValue, r.Value)); + break; + case FlagType.Boolean: + var booleanValue = bool.Parse(value); + AssertOnDetails(r => Assert.Equal(booleanValue, r.Value)); + break; + case FlagType.Object: + var objectValue = JsonStructureLoader.ParseJsonValue(value); + AssertOnDetails(r => Assert.Equal(new Value(objectValue), r.Value)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Then(@"the reason should be ""(.*)""")] + public void ThenTheReasonShouldBe(string reason) + { + switch (this._state.Flag!.Type) + { + case FlagType.Integer: + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); + break; + case FlagType.Float: + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); + break; + case FlagType.String: + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); + break; + case FlagType.Boolean: + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); + break; + case FlagType.Object: + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Then(@"the error-code should be ""(.*)""")] + public void ThenTheError_CodeShouldBe(string error) + { + var errorType = EnumHelpers.ParseFromDescription(error); + switch (this._state.Flag!.Type) + { + case FlagType.Integer: + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); + break; + case FlagType.Float: + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); + break; + case FlagType.String: + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); + break; + case FlagType.Boolean: + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); + break; + case FlagType.Object: + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Then(@"the flag key should be ""(.*)""")] + public void ThenTheFlagKeyShouldBe(string key) + { + switch (this._state.Flag!.Type) + { + case FlagType.Integer: + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); + break; + case FlagType.Float: + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); + break; + case FlagType.String: + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); + break; + case FlagType.Boolean: + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); + break; + case FlagType.Object: + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Then(@"the variant should be ""(.*)""")] + public void ThenTheVariantShouldBe(string variant) + { + switch (this._state.Flag!.Type) + { + case FlagType.Integer: + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); + break; + case FlagType.Float: + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); + break; + case FlagType.String: + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); + break; + case FlagType.Boolean: + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); + break; + case FlagType.Object: + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Given(@"a context containing a key ""(.*)"" with null value")] + public void GivenAContextContainingAKeyWithNullValue(string key) + { + this._state.EvaluationContext = EvaluationContext.Builder() + .Merge(this._state.EvaluationContext ?? EvaluationContext.Empty) + .Set(key, (string?)null!) + .Build(); + } + + private void AssertOnDetails(Action> assertion) + { + var details = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; + + Assert.NotNull(details); + assertion(details); + } +} diff --git a/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs index a79a616aa..0d5e6264b 100644 --- a/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs @@ -4,17 +4,20 @@ namespace OpenFeature.E2ETests.Steps; [Binding] [Scope(Feature = "Evaluation details through hooks")] -public class HooksStepDefinitions : BaseStepDefinitions +public class HooksStepDefinitions { - public HooksStepDefinitions(State state) : base(state) + private readonly State _state; + + public HooksStepDefinitions(State state) { + this._state = state; } [Given(@"a client with added hook")] public void GivenAClientWithAddedHook() { - this.State.TestHook = new TestHook(); - this.State.Client!.AddHooks(this.State.TestHook); + this._state.TestHook = new TestHook(); + this._state.Client!.AddHooks(this._state.TestHook); } [Then(@"the ""(.*)"" hook should have been executed")] @@ -116,16 +119,16 @@ private void CheckHookExecution(string hook) switch (hook) { case "before": - Assert.Equal(1, this.State.TestHook!.BeforeCount); + Assert.Equal(1, this._state.TestHook!.BeforeCount); break; case "after": - Assert.Equal(1, this.State.TestHook!.AfterCount); + Assert.Equal(1, this._state.TestHook!.AfterCount); break; case "error": - Assert.Equal(1, this.State.TestHook!.ErrorCount); + Assert.Equal(1, this._state.TestHook!.ErrorCount); break; case "finally": - Assert.Equal(1, this.State.TestHook!.FinallyCount); + Assert.Equal(1, this._state.TestHook!.FinallyCount); break; } } diff --git a/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs index 033e9bd6c..83b83f901 100644 --- a/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs @@ -4,67 +4,99 @@ namespace OpenFeature.E2ETests.Steps; [Binding] -[Scope(Feature = "Metadata")] -public class MetadataStepDefinitions : BaseStepDefinitions +public class MetadataStepDefinitions { - MetadataStepDefinitions(State state) : base(state) + private readonly State _state; + + public MetadataStepDefinitions(State _state) { + this._state = _state; } [Then("the resolved metadata should contain")] - [Scope(Scenario = "Returns metadata")] - public void ThenTheResolvedMetadataShouldContain(DataTable itemsTable) + public void ThenTheResolvedMetadataShouldContain(DataTable dataTable) { - var items = itemsTable.Rows.Select(row => new DataTableRows(row["key"], row["value"], row["metadata_type"])).ToList(); - var metadata = (this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata; - - foreach (var item in items) + switch (this._state.Flag!.Type) { - var key = item.Key; - var value = item.Value; - var metadataType = item.MetadataType; - - string? actual = null!; - switch (metadataType) - { - case FlagType.Boolean: - actual = metadata!.GetBool(key).ToString(); - break; - case FlagType.Integer: - actual = metadata!.GetInt(key).ToString(); - break; - case FlagType.Float: - actual = metadata!.GetDouble(key).ToString(); - break; - case FlagType.String: - actual = metadata!.GetString(key); - break; - } - - Assert.Equal(value.ToLowerInvariant(), actual?.ToLowerInvariant()); + case FlagType.Integer: + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); + break; + case FlagType.Float: + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); + break; + case FlagType.String: + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); + break; + case FlagType.Boolean: + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); + break; + case FlagType.Object: + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; } } [Then("the resolved metadata is empty")] public void ThenTheResolvedMetadataIsEmpty() { - var flag = this.State.Flag!; + var flag = this._state.Flag!; switch (flag.Type) { case FlagType.Boolean: - Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + AssertOnDetails(d => Assert.Null(d.FlagMetadata?.Count)); break; case FlagType.Float: - Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + AssertOnDetails(d => Assert.Null(d.FlagMetadata?.Count)); break; case FlagType.Integer: - Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + AssertOnDetails(d => Assert.Null(d.FlagMetadata?.Count)); break; case FlagType.String: - Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + AssertOnDetails(d => Assert.Null(d.FlagMetadata?.Count)); break; default: throw new ArgumentOutOfRangeException(); } } + + private void AssertOnDetails(Action> assertion) + { + var details = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; + + Assert.NotNull(details); + assertion(details); + } + + private static void AssertMetadataContains(DataTable dataTable, FlagEvaluationDetails details) + { + foreach (var row in dataTable.Rows) + { + var key = row[0]; + var metadataType = row[1]; + var expected = row[2]; + + object expectedValue = metadataType switch + { + "String" => expected, + "Integer" => int.Parse(expected), + "Float" => double.Parse(expected), + "Boolean" => bool.Parse(expected), + _ => throw new ArgumentException("Unsupported metadata type"), + }; + object? actualValue = metadataType switch + { + "String" => details.FlagMetadata!.GetString(key), + "Integer" => details.FlagMetadata!.GetInt(key), + "Float" => details.FlagMetadata!.GetDouble(key), + "Boolean" => details.FlagMetadata!.GetBool(key), + _ => throw new ArgumentException("Unsupported metadata type") + }; + + Assert.NotNull(actualValue); + Assert.Equal(expectedValue, actualValue); + } + } } diff --git a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinitions.cs new file mode 100644 index 000000000..d3f482556 --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinitions.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.E2ETests.Utils; +using OpenFeature.Model; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +public class ProviderStepDefinitions +{ + private State State { get; } + + public ProviderStepDefinitions(State state) + { + this.State = state; + } + + [Given(@"a stable provider")] + public async Task GivenAStableProvider() + { + var options = new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = true + }; + options.Converters.Add(new FlagDictionaryJsonConverter()); + + var json = File.ReadAllText(Path.Combine("Features", "test-flags.json")); + var flags = JsonSerializer.Deserialize>(json, options) + ?? new Dictionary(); + + var memProvider = new InMemoryProvider(flags); + await Api.Instance.SetProviderAsync(memProvider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given("a stable provider with retrievable context is registered")] + public async Task GivenAStableProviderWithRetrievableContextIsRegistered() + { + this.State.ContextStoringProvider = new ContextStoringProvider(); + + await Api.Instance.SetProviderAsync(this.State.ContextStoringProvider).ConfigureAwait(false); + + Api.Instance.SetTransactionContextPropagator(new AsyncLocalTransactionContextPropagator()); + + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given("a error provider")] + public async Task GivenAErrorProvider() + { + var provider = Substitute.For(); + provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); + provider.Status.Returns(ProviderStatus.Error); + + await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given("a stale provider")] + public async Task GivenAStaleProvider() + { + var provider = Substitute.For(); + provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); + provider.Status.Returns(ProviderStatus.Stale); + + await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given("a not ready provider")] + public async Task GivenANotReadyProvider() + { + var provider = Substitute.For(); + provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); + provider.Status.Returns(ProviderStatus.Ready, ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given("a fatal provider")] + public async Task GivenAFatalProvider() + { + var provider = Substitute.For(); + provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); + provider.Status.Returns(ProviderStatus.Fatal); + + await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Then(@"the provider status should be ""(.*)""")] + public void ThenTheProviderStatusShouldBe(string status) + { + var expectedStatus = EnumHelpers.ParseFromDescription(status); + var provider = Api.Instance.GetProvider(); + Assert.Equal(expectedStatus, provider.Status); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/BeforeHook.cs b/test/OpenFeature.E2ETests/Utils/BeforeHook.cs new file mode 100644 index 000000000..e6da0bb9c --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/BeforeHook.cs @@ -0,0 +1,18 @@ +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +public class BeforeHook : Hook +{ + private readonly EvaluationContext context; + + public BeforeHook(EvaluationContext context) + { + this.context = context; + } + + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return new ValueTask(this.context); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/ContextEvaluatorUtility.cs b/test/OpenFeature.E2ETests/Utils/ContextEvaluatorUtility.cs new file mode 100644 index 000000000..fea0026b6 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/ContextEvaluatorUtility.cs @@ -0,0 +1,138 @@ +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +public class ContextEvaluatorUtility +{ + // Very small expression translator for patterns like: + // "email == 'ballmer@macrosoft.com' ? 'zero' : ''" + // "!customer && email == 'x' && age > 10 ? 'internal' : ''" + public static Func? BuildContextEvaluator(string expression) + { + // Split "condition ? 'trueVariant' : 'falseVariant'" + var qIndex = expression.IndexOf('?'); + var colonIndex = expression.LastIndexOf(':'); + if (qIndex < 0 || colonIndex < 0 || colonIndex < qIndex) + return null; // unsupported format, ignore + + var conditionPart = expression.Substring(0, qIndex).Trim(); + var truePart = ExtractQuoted(expression.Substring(qIndex + 1, colonIndex - qIndex - 1)); + var falsePart = ExtractQuoted(expression.Substring(colonIndex + 1)); + + var conditions = conditionPart.Split(new[] { "&&" }, StringSplitOptions.RemoveEmptyEntries); + + return ctx => + { + foreach (var raw in conditions) + { + if (!EvaluateSingle(raw.Trim(), ctx)) + return falsePart; + } + return truePart; + }; + } + + private static string ExtractQuoted(string segment) + { + segment = segment.Trim(); + if (segment.StartsWith("'") && segment.EndsWith("'") && segment.Length >= 2) + return segment.Substring(1, segment.Length - 2); + return segment; + } + + private static bool EvaluateSingle(string expr, EvaluationContext ctx) + { + // Supported fragments: + // !key + // key + // key == 'string' + // key != 'string' + // key > number, key < number, key >= number, key <= number + expr = expr.Trim(); + + bool negate = false; + if (expr.StartsWith("!")) + { + negate = true; + expr = expr.Substring(1).Trim(); + } + + bool result; + if (TryParseComparison(expr, ctx, out result)) + { + return negate ? !result : result; + } + + // Treat raw key presence / truthiness + if (!ctx.TryGetValue(expr, out var value) || value == null || value.IsNull) + result = false; + else if (value.IsBoolean) + result = value.AsBoolean == true; + else if (value.IsString) + result = !string.IsNullOrEmpty(value.AsString); + else if (value.IsNumber) + result = value.AsDouble.GetValueOrDefault() != 0.0; + else + result = true; + + return negate ? !result : result; + } + + // Supported operations + static readonly string[] _operations = ["==", "!=", ">=", "<=", ">", "<"]; + + private static bool TryParseComparison(string expr, EvaluationContext ctx, out bool result) + { + result = false; + + foreach (var op in _operations) + { + var idx = expr.IndexOf(op, StringComparison.Ordinal); + if (idx <= 0) continue; + + var left = expr.Substring(0, idx).Trim(); + var right = expr.Substring(idx + op.Length).Trim(); + + if (!ctx.TryGetValue(left, out var val) || val == null) + return true; // treat missing as false; caller will interpret + + if (right.StartsWith("'") && right.EndsWith("'")) + { + var literal = right.Substring(1, right.Length - 2); + var strVal = val.AsString; + result = op switch + { + "==" => strVal == literal, + "!=" => strVal != literal, + _ => false + }; + + return true; + } + + if (double.TryParse(right, out var numRight)) + { + var numLeft = val.AsDouble ?? val.AsInteger; + if (numLeft == null) + return true; + + result = op switch + { + ">" => numLeft > numRight, + "<" => numLeft < numRight, + ">=" => numLeft >= numRight, + "<=" => numLeft <= numRight, + "==" => Math.Abs(numLeft.Value - numRight) < double.Epsilon, + "!=" => Math.Abs(numLeft.Value - numRight) >= double.Epsilon, + _ => false + }; + + return true; + } + + return true; + } + + return false; + } +} diff --git a/test/OpenFeature.E2ETests/Utils/EnumHelpers.cs b/test/OpenFeature.E2ETests/Utils/EnumHelpers.cs new file mode 100644 index 000000000..a4e535682 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/EnumHelpers.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using System.Reflection; + +namespace OpenFeature.E2ETests.Utils; + +public static class EnumHelpers +{ + public static TEnum ParseFromDescription(string description) where TEnum : struct, Enum + { + foreach (var field in typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var attr = field.GetCustomAttribute(); + if (attr != null && attr.Description == description) + { + return (TEnum)field.GetValue(null)!; + } + } + throw new ArgumentException($"No {typeof(TEnum).Name} with description '{description}' found."); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs new file mode 100644 index 000000000..153de67da --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs @@ -0,0 +1,187 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenFeature.Model; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.E2ETests.Utils; + +public sealed class FlagDictionaryJsonConverter : JsonConverter> +{ + public override Dictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("Root of flags JSON must be an object"); + + var result = new Dictionary(StringComparer.Ordinal); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException("Expected property name (flag key)"); + + var flagKey = reader.GetString()!; + reader.Read(); + + var flagDoc = JsonDocument.ParseValue(ref reader); + var flagElement = flagDoc.RootElement; + result[flagKey] = ReadFlag(flagKey, flagElement); + } + + return result; + } + + private static Flag ReadFlag(string flagKey, JsonElement flagElement) + { + if (!flagElement.TryGetProperty("variants", out var variantsElement) || variantsElement.ValueKind != JsonValueKind.Object) + throw new JsonException($"Flag '{flagKey}' is missing 'variants' object"); + + // Infer variant type + VariantKind? inferredKind = null; + foreach (var v in variantsElement.EnumerateObject()) + { + var kind = ClassifyVariantValue(v.Value); + inferredKind = inferredKind == null ? kind : Promote(inferredKind.Value, kind); + } + + if (inferredKind == null) + throw new JsonException($"Flag '{flagKey}' has no variants"); + + var defaultVariant = InferDefaultVariant(flagElement, variantsElement); + + var contextEvaluator = flagElement.TryGetProperty("contextEvaluator", out var ctxElem) && ctxElem.ValueKind == JsonValueKind.String + ? ContextEvaluatorUtility.BuildContextEvaluator(ctxElem.GetString()!) + : null; + + var metadata = flagElement.TryGetProperty("flagMetadata", out var metaElem) && metaElem.ValueKind == JsonValueKind.Object + ? BuildMetadata(metaElem) + : null; + + // NOTE: The current Flag type does not model 'disabled' + + return inferredKind switch + { + VariantKind.Boolean => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetBoolean()), + VariantKind.Integer => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetInt32()), + VariantKind.Double => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetDouble()), + VariantKind.String => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetString()!), + VariantKind.Object => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, ExtractObjectVariant), + _ => throw new JsonException($"Unsupported variant kind for flag '{flagKey}'") + }; + } + + public override void Write(Utf8JsonWriter writer, Dictionary value, JsonSerializerOptions options) + => throw new NotSupportedException("Serialization is not implemented."); + + private static Flag BuildFlag( + JsonElement variantsElement, + string? defaultVariant, + Func? contextEvaluator, + ImmutableMetadata? metadata, + Func projector) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var v in variantsElement.EnumerateObject()) + { + dict[v.Name] = projector(v.Value); + } + return new Flag(dict, defaultVariant!, contextEvaluator, metadata); + } + + private static string? InferDefaultVariant(JsonElement flagElement, JsonElement variantsElement) + { + if (flagElement.TryGetProperty("defaultVariant", out var dv)) + { + if (dv.ValueKind == JsonValueKind.String) + return dv.GetString()!; + } + + return null; + } + + private static ImmutableMetadata? BuildMetadata(JsonElement metaElem) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var p in metaElem.EnumerateObject()) + { + switch (p.Value.ValueKind) + { + case JsonValueKind.String: dict[p.Name] = p.Value.GetString()!; break; + case JsonValueKind.Number: + if (p.Value.TryGetInt64(out var l) && l >= int.MinValue && l <= int.MaxValue) + dict[p.Name] = (int)l; + else + dict[p.Name] = p.Value.GetDouble(); + break; + case JsonValueKind.True: + case JsonValueKind.False: + dict[p.Name] = p.Value.GetBoolean(); + break; + default: + // Ignore null or complex types + break; + } + } + return dict.Count == 0 ? null : new ImmutableMetadata(dict); + } + + private static Value ExtractObjectVariant(JsonElement obj) + { + if (obj.ValueKind != JsonValueKind.Object) + throw new JsonException("Expected object for variant"); + + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var p in obj.EnumerateObject()) + { + dict[p.Name] = ConvertElement(p.Value); + } + + var structure = dict.Count == 0 ? Structure.Empty : new Structure(dict); + return new Value(structure); + } + + private static Value ConvertElement(JsonElement el) => + el.ValueKind switch + { + JsonValueKind.Object => ExtractObjectVariant(el), // delegates to structure builder + JsonValueKind.Array => new Value(el.EnumerateArray().Select(ConvertElement).ToImmutableList()), + JsonValueKind.String => new Value(el.GetString()!), + JsonValueKind.Number => el.TryGetInt64(out var l) && l is >= int.MinValue and <= int.MaxValue + ? new Value((int)l) + : new Value(el.GetDouble()), + JsonValueKind.True => new Value(true), + JsonValueKind.False => new Value(false), + JsonValueKind.Null => new Value(), + JsonValueKind.Undefined => new Value(), + _ => throw new JsonException($"Unsupported JSON token: {el.ValueKind}") + }; + + private enum VariantKind { Boolean, Integer, Double, String, Object } + + private static VariantKind ClassifyVariantValue(JsonElement e) => + e.ValueKind switch + { + JsonValueKind.True or JsonValueKind.False => VariantKind.Boolean, + JsonValueKind.String => VariantKind.String, + JsonValueKind.Object => VariantKind.Object, + JsonValueKind.Number => e.TryGetInt64(out _) ? VariantKind.Integer : VariantKind.Double, + _ => throw new JsonException($"Unsupported variant value kind '{e.ValueKind}'") + }; + + // Promote mixed numeric (int + double) to double + private static VariantKind Promote(VariantKind existing, VariantKind incoming) + { + static bool IsNumeric(VariantKind k) => k == VariantKind.Integer || k == VariantKind.Double; + + if (existing == incoming) + return existing; + + if (IsNumeric(existing) && IsNumeric(incoming)) + return VariantKind.Double; + + throw new JsonException($"Mixed incompatible variant kinds: {existing} and {incoming}"); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/JsonStructureLoader.cs b/test/OpenFeature.E2ETests/Utils/JsonStructureLoader.cs new file mode 100644 index 000000000..e03149bae --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/JsonStructureLoader.cs @@ -0,0 +1,72 @@ +using System.Collections.Immutable; +using System.Text.Json; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +public static class JsonStructureLoader +{ + public static Value ParseJsonValue(string raw) + { + var json = UnescapeGherkinJson(raw); + if (json == "{}") + return new Value(Structure.Empty); + + using var doc = JsonDocument.Parse(json); + return ConvertElement(doc.RootElement); + } + + private static string UnescapeGherkinJson(string s) + { + if (string.IsNullOrWhiteSpace(s)) + return s; + + // Replace escaped quotes, if still present. + if (s.Contains("\\\"")) + s = s.Replace("\\\"", "\""); + + // Trim wrapping quotes "\"{...}\"" if present. + if (s.Length > 2 && s[0] == '"' && s[s.Length - 1] == '"' && s[1] == '{' && s[s.Length - 2] == '}') + { + var inner = s.Substring(1, s.Length - 2); + if (inner.StartsWith("{") && inner.EndsWith("}")) + s = inner; + } + + return s.Trim(); + } + + private static Structure ConvertObject(JsonElement element) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var prop in element.EnumerateObject()) + { + dict[prop.Name] = ConvertElement(prop.Value); + } + return new Structure(dict); + } + + private static Value ConvertElement(JsonElement el) => + el.ValueKind switch + { + JsonValueKind.Object => new Value(ConvertObject(el)), + JsonValueKind.Array => new Value(el.EnumerateArray().Select(ConvertElement).ToImmutableList()), + JsonValueKind.String => new Value(el.GetString()!), + JsonValueKind.Number => ConvertNumber(el), + JsonValueKind.True => new Value(true), + JsonValueKind.False => new Value(false), + JsonValueKind.Null => new Value(), // null inner value + JsonValueKind.Undefined => new Value(), + _ => throw new ArgumentOutOfRangeException(nameof(el), $"Unsupported JSON token: {el.ValueKind}") + }; + + private static Value ConvertNumber(JsonElement el) + { + // Prefer int when representable; Value(int) internally stores as double. + if (el.TryGetInt64(out var l) && l is >= int.MinValue and <= int.MaxValue) + { + return new Value((int)l); + } + return new Value(el.GetDouble()); + } +}