From b92e939a74a178ab8f2e4d82b3376b74100255fc Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:56:14 +0100 Subject: [PATCH 01/15] Initial update Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- spec | 2 +- test/OpenFeature.E2ETests/Features/README.md | 148 ++++++++ .../Features/test-flags.json | 221 ++++++++++++ .../OpenFeature.E2ETests.csproj | 11 +- .../Steps/BaseStepDefinitions.cs | 224 ++++++++++-- .../Steps/EvaluationStepDefinitions.cs | 14 + .../Steps/Evaluationv2StepDefinitions.cs | 319 ++++++++++++++++++ .../Steps/MetadataStepDefinitions.cs | 2 +- .../Steps/ProviderStepDefinition.cs | 41 +++ test/OpenFeature.E2ETests/Utils/State.cs | 1 + 10 files changed, 960 insertions(+), 23 deletions(-) create mode 100644 test/OpenFeature.E2ETests/Features/README.md create mode 100644 test/OpenFeature.E2ETests/Features/test-flags.json create mode 100644 test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs create mode 100644 test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs diff --git a/spec b/spec index 969e11c4d..4443c345b 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 969e11c4d5df4ab16b400965ef1b3e313dcb923e +Subproject commit 4443c345b2a0e766226af00094d593dc5362f7ce diff --git a/test/OpenFeature.E2ETests/Features/README.md b/test/OpenFeature.E2ETests/Features/README.md new file mode 100644 index 000000000..deb2c7312 --- /dev/null +++ b/test/OpenFeature.E2ETests/Features/README.md @@ -0,0 +1,148 @@ +# Test Assets + +This directory contains test assets for the OpenFeature specification, including Gherkin test scenarios and structured JSON test data for comprehensive implementation validation. + +## Overview + +The test assets improve the existing Gherkin test suite by providing structured JSON test data that eliminates the need for manual test data creation. The data includes various flag types with different targeting scenarios to test edge cases and standard behavior. + +## Test Data Structure (`test-flags.json`) + +The [JSON test data](./test-flags.json) contains flags organized into several categories based on their behavior and purpose: + +### Standard Flags +Basic feature flags with straightforward evaluation: +- `boolean-flag`: Boolean flag with variants `on` (true) and `off` (false), defaults to `on` +- `string-flag`: String flag with variants `greeting` ("hi") and `parting` ("bye"), defaults to `greeting` +- `integer-flag`: Integer flag with variants `one` (1) and `ten` (10), defaults to `ten` +- `float-flag`: Float flag with variants `tenth` (0.1) and `half` (0.5), defaults to `half` +- `object-flag`: Object flag with `empty` ({}) and `template` variants, defaults to `template` + +### Zero Value Flags +Flags specifically designed to test zero/empty value handling: +- `boolean-zero-flag`: Boolean flag that defaults to `zero` variant (false) +- `string-zero-flag`: String flag that defaults to `zero` variant (empty string "") +- `integer-zero-flag`: Integer flag that defaults to `zero` variant (0) +- `float-zero-flag`: Float flag that defaults to `zero` variant (0.0) +- `object-zero-flag`: Object flag that defaults to `zero` variant (empty object {}) + +### Targeted Zero Flags +Flags with CEL expressions that can evaluate to zero values based on context: +- `boolean-targeted-zero-flag`: Uses CEL targeting, defaults to `zero` (false) +- `string-targeted-zero-flag`: Uses CEL targeting, defaults to `zero` (empty string) +- `integer-targeted-zero-flag`: Uses CEL targeting, defaults to `zero` (0) +- `float-targeted-zero-flag`: Uses CEL targeting, defaults to `zero` (0.0) +- `object-targeted-zero-flag`: Uses CEL targeting, defaults to `zero` (empty object) + +### Disabled Flags +Flags that are statically disabled: +- `boolean-disabled-flag`: Disabled Flag +- `string-disabled-flag`: Disabled Flag +- `integer-disabled-flag`: Disabled Flag +- `float-disabled-flag`: Disabled Flag +- `object-disabled-flag`: Disabled Flag + +### Special Testing Flags +Flags for testing edge cases and metadata: +- `metadata-flag`: Boolean flag with rich metadata including string, integer, boolean, and float values +- `complex-targeted`: String flag with complex CEL expression for internal/external user distinction +- `null-default-flag`: Flag with explicitly null default variant +- `undefined-default-flag`: Flag with no default variant defined +- `wrong-flag`: Flag for testing error scenarios + +## CEL Expression Variables + +The test data uses Common Expression Language (CEL) expressions in the `contextEvaluator` field. Based on the expressions in the test data, the following context variables are expected: + +### Available Context Variables +- `email`: User's email address (string) +- `customer`: Boolean flag indicating customer status +- `age`: User's age (integer) + +### CEL Expressions Used + +#### Simple Email Targeting +```cel +email == 'ballmer@macrosoft.com' ? 'zero' : '' +``` +Used in: `boolean-targeted-zero-flag`, `string-targeted-zero-flag`, `integer-targeted-zero-flag`, `float-targeted-zero-flag`, `object-targeted-zero-flag` + +#### Complex Multi-Condition Targeting +```cel +!customer && email == 'ballmer@macrosoft.com' && age > 10 ? 'internal' : '' +``` +Used in: `complex-targeted` + +## Flag Structure Schema + +Each flag in the test data follows this structure: + +```json +{ + "flag-name": { + "variants": { + "variant-key": "variant-value" + }, + "defaultVariant": "variant-key-or-null", + "contextEvaluator": "CEL-expression", // Optional + "flagMetadata": {} // Optional + } +} +``` + +### Key Components +- **variants**: Object containing all possible flag values mapped to variant keys +- **defaultVariant**: The variant key to use when no targeting rules match (can be null or omitted) +- **contextEvaluator**: Optional CEL expression for dynamic targeting +- **flagMetadata**: Optional metadata object containing additional flag information + +## Usage + +1. **Test Implementation**: Parse the JSON file with your preferred JSON library +2. **Context Setup**: Ensure your test contexts include the required variables (`email`, `customer`, `age`) +3. **CEL Evaluation**: Implement CEL expression evaluation for flags with `contextEvaluator` +4. **Edge Case Testing**: Use the zero flags and special flags to test boundary conditions + +## Test Context Examples + +For comprehensive testing, use these context combinations: + +```json +// Triggers targeted zero variants +{ + "targetingKey": "user1", + "email": "ballmer@macrosoft.com", + "customer": false, + "age": 25 +} + +// Triggers complex targeting +{ + "targetingKey": "user2", + "email": "ballmer@macrosoft.com", + "customer": false, + "age": 15 +} + +// Triggers alternative targeting +{ + "targetingKey": "user3", + "email": "jobs@orange.com" +} + +// Default behavior (no targeting matches) +{ + "targetingKey": "user4", + "email": "test@example.com", + "customer": true, + "age": 30 +} +``` + +## Contributing + +When modifying test data: +1. Maintain the category structure (standard, zero, targeted-zero, disabled) +2. Validate CEL expressions for syntax correctness +3. Ensure all required context variables are documented +4. Test both matching and non-matching targeting scenarios diff --git a/test/OpenFeature.E2ETests/Features/test-flags.json b/test/OpenFeature.E2ETests/Features/test-flags.json new file mode 100644 index 000000000..0233bdae6 --- /dev/null +++ b/test/OpenFeature.E2ETests/Features/test-flags.json @@ -0,0 +1,221 @@ +{ + "boolean-flag": { + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on", + "flagMetadata": null + }, + "boolean-disabled-flag": { + "disabled": true, + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on", + "flagMetadata": null + }, + "boolean-targeted-zero-flag": { + "variants": { + "zero": false, + "non-zero": true + }, + "defaultVariant": "zero", + "contextEvaluator": "email == 'ballmer@macrosoft.com' ? 'zero' : ''" + }, + "boolean-zero-flag": { + "variants": { + "zero": false, + "non-zero": true + }, + "defaultVariant": "zero" + }, + "complex-targeted": { + "variants": { + "internal": "INTERNAL", + "external": "EXTERNAL" + }, + "defaultVariant": "external", + "flagMetadata": null, + "contextEvaluator": "!customer && email == 'ballmer@macrosoft.com' && age > 10 ? 'internal' : ''" + }, + "float-flag": { + "variants": { + "tenth": 0.1, + "half": 0.5 + }, + "defaultVariant": "half", + "flagMetadata": null + }, + "float-disabled-flag": { + "disabled": true, + "variants": { + "tenth": 0.1, + "half": 0.5 + }, + "defaultVariant": "half", + "flagMetadata": null + }, + "float-targeted-zero-flag": { + "variants": { + "zero": 0.0, + "non-zero": 1.0 + }, + "defaultVariant": "zero", + "contextEvaluator": "email == 'ballmer@macrosoft.com' ? 'zero' : ''" + }, + "float-zero-flag": { + "variants": { + "zero": 0.0, + "non-zero": 1.0 + }, + "defaultVariant": "zero" + }, + "integer-flag": { + "variants": { + "one": 1, + "ten": 10 + }, + "defaultVariant": "ten", + "flagMetadata": null + }, + "integer-disabled-flag": { + "disabled": true, + "variants": { + "one": 1, + "ten": 10 + }, + "defaultVariant": "ten", + "flagMetadata": null + }, + "integer-targeted-zero-flag": { + "variants": { + "zero": 0, + "non-zero": 1 + }, + "defaultVariant": "zero", + "contextEvaluator": "email == 'ballmer@macrosoft.com' ? 'zero' : ''" + }, + "integer-zero-flag": { + "variants": { + "zero": 0, + "non-zero": 1 + }, + "defaultVariant": "zero" + }, + "metadata-flag": { + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on", + "flagMetadata": { + "string": "1.0.2", + "integer": 2, + "boolean": true, + "float": 0.1 + } + }, + "null-default-flag": { + "variants": { + "on": true, + "off": false + }, + "defaultVariant": null + }, + "object-flag": { + "variants": { + "empty": {}, + "template": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "template", + "flagMetadata": null + }, + "object-disabled-flag": { + "disabled": true, + "variants": { + "empty": {}, + "template": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "template", + "flagMetadata": null + }, + "object-targeted-zero-flag": { + "variants": { + "zero": {}, + "non-zero": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "zero", + "contextEvaluator": "email == 'ballmer@macrosoft.com' ? 'zero' : ''" + }, + "object-zero-flag": { + "variants": { + "zero": {}, + "non-zero": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "zero" + }, + "string-flag": { + "variants": { + "greeting": "hi", + "parting": "bye" + }, + "defaultVariant": "greeting", + "flagMetadata": null + }, + "string-disabled-flag": { + "disabled": true, + "variants": { + "greeting": "hi", + "parting": "bye" + }, + "defaultVariant": "greeting", + "flagMetadata": null + }, + "string-targeted-zero-flag": { + "variants": { + "zero": "", + "non-zero": "str" + }, + "defaultVariant": "zero", + "contextEvaluator": "email == 'ballmer@macrosoft.com' ? 'zero' : ''" + }, + "string-zero-flag": { + "variants": { + "zero": "", + "non-zero": "str" + }, + "defaultVariant": "zero" + }, + "undefined-default-flag": { + "variants": { + "small": 10, + "big": 1000 + } + }, + "wrong-flag": { + "variants": { + "one": "uno", + "two": "dos" + }, + "defaultVariant": "one", + "flagMetadata": null + } +} diff --git a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj index 0d5ed8cec..645cfe534 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 @@ -33,7 +34,13 @@ - + + + + PreserveNewest + + + diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index 7a9c3c0a9..efb315221 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -23,7 +23,9 @@ public async Task GivenAStableProvider() } [Given(@"a Boolean-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a Boolean-flag with key ""(.*)"" and a fallback value ""(.*)""")] [Given(@"a boolean-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a boolean-flag with key ""(.*)"" and a fallback value ""(.*)""")] public void GivenABoolean_FlagWithKeyAndADefaultValue(string key, string defaultType) { var flagState = new FlagState(key, defaultType, FlagType.Boolean); @@ -31,7 +33,9 @@ public void GivenABoolean_FlagWithKeyAndADefaultValue(string key, string default } [Given(@"a Float-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a Float-flag with key ""(.*)"" and a fallback value ""(.*)""")] [Given(@"a float-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a float-flag with key ""(.*)"" and a fallback value ""(.*)""")] public void GivenAFloat_FlagWithKeyAndADefaultValue(string key, string defaultType) { var flagState = new FlagState(key, defaultType, FlagType.Float); @@ -39,7 +43,9 @@ public void GivenAFloat_FlagWithKeyAndADefaultValue(string key, string defaultTy } [Given(@"a Integer-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a Integer-flag with key ""(.*)"" and a fallback value ""(.*)""")] [Given(@"a integer-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a integer-flag with key ""(.*)"" and a fallback value ""(.*)""")] public void GivenAnInteger_FlagWithKeyAndADefaultValue(string key, string defaultType) { var flagState = new FlagState(key, defaultType, FlagType.Integer); @@ -47,13 +53,22 @@ public void GivenAnInteger_FlagWithKeyAndADefaultValue(string key, string defaul } [Given(@"a String-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a String-flag with key ""(.*)"" and a fallback value ""(.*)""")] [Given(@"a string-flag with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a string-flag with key ""(.*)"" and a fallback value ""(.*)""")] public void GivenAString_FlagWithKeyAndADefaultValue(string key, string defaultType) { var flagState = new FlagState(key, defaultType, FlagType.String); this.State.Flag = flagState; } + [Given(@"a Object-flag with key ""(.*)"" and a fallback value ""(.*)""")] + [Given(@"a object-flag with key ""(.*)"" and a fallback value ""(.*)""")] + public void GivenAObject_FlagWithKeyAndADefaultValue(string key, string defaultType) + { + Skip.If(true, "Object e2e test not supported"); + } + [Given("a stable provider with retrievable context is registered")] public async Task GivenAStableProviderWithRetrievableContextIsRegistered() { @@ -125,6 +140,9 @@ public async Task WhenTheFlagWasEvaluatedWithDetails() this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flag.Key, flag.DefaultValue) .ConfigureAwait(false); break; + case FlagType.Object: + Skip.If(true, "Object e2e test not supported"); + break; } } private void InitializeContext(string level, EvaluationContext context) @@ -179,31 +197,47 @@ private void InitializeContext(string level, EvaluationContext context) private static readonly IDictionary E2EFlagConfig = new Dictionary { { - "metadata-flag", new Flag( + "boolean-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 } - }) + defaultVariant: "on" ) }, { - "boolean-flag", new Flag( + "boolean-disabled-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" + "boolean-targeted-zero-flag", new Flag( + variants: new Dictionary { { "zero", false }, { "non-zero", true } }, + defaultVariant: "zero", + contextEvaluator: (context) => + { + return context.GetValue("email").AsString == "ballmer@macrosoft.com" ? "zero" : ""; + } ) }, { - "integer-flag", new Flag( - variants: new Dictionary() { { "one", 1 }, { "ten", 10 } }, - defaultVariant: "ten" + "boolean-zero-flag", new Flag( + variants: new Dictionary { { "zero", false }, { "non-zero", true } }, + defaultVariant: "zero" + ) + }, + { + "complex-targeted", new Flag( + variants: new Dictionary() { { "internal", "INTERNAL" }, { "external", "EXTERNAL" } }, + defaultVariant: "external", + (context) => + { + if (context.GetValue("email").AsString == "ballmer@macrosoft.com" + && context.GetValue("age").AsInteger > 10 + && context.GetValue("customer").AsBoolean == false) + { + return "internal"; + } + return "external"; + } ) }, { @@ -212,6 +246,72 @@ private void InitializeContext(string level, EvaluationContext context) defaultVariant: "half" ) }, + { + "float-disabled-flag", new Flag( + variants: new Dictionary() { { "tenth", 0.1 }, { "half", 0.5 } }, + defaultVariant: "half" + ) + }, + { + "float-targeted-zero-flag", new Flag( + variants: new Dictionary() { { "zero", 0.0 }, { "non-zero", 1.0 } }, + defaultVariant: "zero", + contextEvaluator: (context) => + { + return context.GetValue("email").AsString == "ballmer@macrosoft.com" ? "zero" : ""; + } + ) + }, + { + "float-zero-flag", new Flag( + variants: new Dictionary() { { "zero", 0.0 }, { "non-zero", 1.0 } }, + defaultVariant: "zero" + ) + }, + { + "integer-flag", new Flag( + variants: new Dictionary() { { "one", 1 }, { "ten", 10 } }, + defaultVariant: "ten" + ) + }, + { + "integer-disabled-flag", new Flag( + variants: new Dictionary() { { "one", 1 }, { "ten", 10 } }, + defaultVariant: "ten" + ) + }, + { + "integer-targeted-zero-flag", new Flag( + variants: new Dictionary() { { "zero", 0 }, { "non-zero", 1 } }, + defaultVariant: "zero", + contextEvaluator: (context) => + { + return context.GetValue("email").AsString == "ballmer@macrosoft.com" ? "zero" : ""; + } + ) + }, + { + "integer-zero-flag", new Flag( + variants: new Dictionary() { { "zero", 0 }, { "non-zero", 1 } }, + defaultVariant: "zero" + ) + }, + { + "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 } + }) + ) + }, + { + "null-default-flag", new Flag( + variants: new Dictionary { { "on", true }, { "off", false } }, + defaultVariant: null! + ) + }, { "object-flag", new Flag( variants: new Dictionary() @@ -228,6 +328,98 @@ private void InitializeContext(string level, EvaluationContext context) defaultVariant: "template" ) }, + { + "object-disabled-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" + ) + }, + { + "object-targeted-zero-flag", new Flag( + variants: new Dictionary() + { + { "zero", new Value() }, + { + "non-zero", new Value(Structure.Builder() + .Set("showImages", true) + .Set("title", "Check out these pics!") + .Set("imagesPerPage", 100).Build() + ) + } + }, + defaultVariant: "zero", + contextEvaluator: (context) => + { + return context.GetValue("email").AsString == "ballmer@macrosoft.com" ? "zero" : ""; + } + ) + }, + { + "object-zero-flag", new Flag( + variants: new Dictionary() + { + { "zero", new Value() }, + { + "non-zero", new Value(Structure.Builder() + .Set("showImages", true) + .Set("title", "Check out these pics!") + .Set("imagesPerPage", 100).Build() + ) + } + }, + defaultVariant: "zero" + ) + }, + { + "string-flag", new Flag( + variants: new Dictionary() { { "greeting", "hi" }, { "parting", "bye" } }, + defaultVariant: "greeting" + ) + }, + { + "string-disabled-flag", new Flag( + variants: new Dictionary() { { "greeting", "hi" }, { "parting", "bye" } }, + defaultVariant: "greeting" + ) + }, + { + "string-targeted-zero-flag", new Flag( + variants: new Dictionary() { { "zero", "" }, { "non-zero", "str" } }, + defaultVariant: "zero", + contextEvaluator: (context) => + { + return context.GetValue("email").AsString == "ballmer@macrosoft.com" ? "zero" : ""; + } + ) + }, + { + "string-zero-flag", new Flag( + variants: new Dictionary() { { "zero", "" }, { "non-zero", "str" } }, + defaultVariant: "zero" + ) + }, + { + "undefined-default-flag", new Flag( + variants: new Dictionary() { { "small", 10 }, { "big", 1000 } }, + defaultVariant: null! + ) + }, + { + "wrong-flag", new Flag( + variants: new Dictionary() { { "one", "uno" }, { "two", "dos" } }, + defaultVariant: "one" + ) + }, { "context-aware", new Flag( variants: new Dictionary() { { "internal", "INTERNAL" }, { "external", "EXTERNAL" } }, @@ -244,12 +436,6 @@ private void InitializeContext(string level, EvaluationContext context) else return "external"; } ) - }, - { - "wrong-flag", new Flag( - variants: new Dictionary() { { "one", "uno" }, { "two", "dos" } }, - defaultVariant: "one" - ) } }; diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs index 27e00359b..4b30d200d 100644 --- a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs @@ -255,4 +255,18 @@ public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateatyp Assert.Equal(Reason.Error, result?.Reason); Assert.Equal(errorCode, result?.ErrorType.GetDescription()); } + + [When(@"a non-existent string flag with key ""(.*)"" is evaluated with details and a fallback value ""(.*)""")] + public async Task WhenANon_ExistentStringFlagWithKeyIsEvaluatedWithDetailsAndAFallbackValue(string flagKey, string defaultValue) + { + this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.String); + this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + } + + [When(@"a string flag with key ""(.*)"" is evaluated as an integer, with details and a fallback value (.*)")] + public async Task WhenAStringFlagWithKeyIsEvaluatedAsAnIntegerWithDetailsAndAFallbackValue(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); + } } diff --git a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs new file mode 100644 index 000000000..682c6fcf2 --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs @@ -0,0 +1,319 @@ +using System.ComponentModel; +using System.Reflection; +using OpenFeature.Constant; +using OpenFeature.E2ETests.Utils; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +[Scope(Feature = "Flag Evaluations - Complete OpenFeature Specification Coverage")] +public class Evaluationv2StepDefinitions : BaseStepDefinitions +{ + public Evaluationv2StepDefinitions(State state) : base(state) + { + } + + [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); + var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(intResult); + Assert.Equal(intValue, intResult.Value); + break; + case FlagType.Float: + var floatValue = double.Parse(value); + var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(floatResult); + Assert.Equal(floatValue, floatResult.Value); + break; + case FlagType.String: + var stringValue = value; + var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(stringResult); + Assert.Equal(stringValue, stringResult.Value); + break; + case FlagType.Boolean: + var booleanValue = bool.Parse(value); + var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(booleanResult); + Assert.Equal(booleanValue, booleanResult.Value); + break; + case FlagType.Object: + Skip.If(true, "Object e2e test not supported"); + 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: + var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(intResult); + Assert.Equal(reason, intResult.Reason); + break; + case FlagType.Float: + var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(floatResult); + Assert.Equal(reason, floatResult.Reason); + break; + case FlagType.String: + var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(stringResult); + Assert.Equal(reason, stringResult.Reason); + break; + case FlagType.Boolean: + var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(booleanResult); + Assert.Equal(reason, booleanResult.Reason); + break; + case FlagType.Object: + Skip.If(true, "Object e2e test not supported"); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Given(@"a context containing a key ""(.*)"", with type ""(.*)"" and with value ""(.*)""")] + public void GivenAContextContainingAKeyWithTypeAndWithValue(string key, string type, string value) + { + Type expectedType = typeof(string); + switch (type) + { + case "Integer": + expectedType = typeof(int); + break; + case "Float": + expectedType = typeof(double); + break; + case "String": + break; + case "Boolean": + expectedType = typeof(bool); + break; + case "Object": + Skip.If(true, "Object e2e test not supported"); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + + var structureBuilder = new StructureBuilder() + .Set(key, new Value(Convert.ChangeType(value, expectedType))); + + foreach (var item in this.State.EvaluationContext ?? EvaluationContext.Empty) + { + structureBuilder.Set(item.Key, item.Value); + } + + this.State.EvaluationContext = new EvaluationContext(structureBuilder.Build()); + } + + [Then(@"the error-code should be ""(.*)""")] + public void ThenTheError_CodeShouldBe(string error) + { + var errorType = ParseFromDescription(error); + switch (this.State.Flag!.Type) + { + case FlagType.Integer: + var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(intResult); + Assert.Equal(errorType, intResult.ErrorType); + break; + case FlagType.Float: + var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(floatResult); + Assert.Equal(errorType, floatResult.ErrorType); + break; + case FlagType.String: + var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(stringResult); + Assert.Equal(errorType, stringResult.ErrorType); + break; + case FlagType.Boolean: + var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(booleanResult); + Assert.Equal(errorType, booleanResult.ErrorType); + break; + case FlagType.Object: + Skip.If(true, "Object e2e test not supported"); + 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: + var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(intResult); + Assert.Equal(key, intResult.FlagKey); + break; + case FlagType.Float: + var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(floatResult); + Assert.Equal(key, floatResult.FlagKey); + break; + case FlagType.String: + var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(stringResult); + Assert.Equal(key, stringResult.FlagKey); + break; + case FlagType.Boolean: + var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(booleanResult); + Assert.Equal(key, booleanResult.FlagKey); + break; + case FlagType.Object: + Skip.If(true, "Object e2e test not supported"); + 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: + var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(intResult); + Assert.Equal(variant, intResult.Variant); + break; + case FlagType.Float: + var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(floatResult); + Assert.Equal(variant, floatResult.Variant); + break; + case FlagType.String: + var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(stringResult); + Assert.Equal(variant, stringResult.Variant); + break; + case FlagType.Boolean: + var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(booleanResult); + Assert.Equal(variant, booleanResult.Variant); + break; + case FlagType.Object: + Skip.If(true, "Object e2e test not supported"); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Then("the resolved metadata should contain")] + public void ThenTheResolvedMetadataShouldContain(DataTable dataTable) + { + throw new PendingStepException(); + } + + [Given(@"a context containing a key ""(.*)"" with null value")] + public void GivenAContextContainingAKeyWithNullValue(string key) + { + throw new PendingStepException(); + } + + [Then(@"the resolved details value should be ""(.*)""showImages\\\\""(.*)""title\\\\""(.*)""Check out these pics!\\\\""(.*)""imagesPerPage\\\\""(.*)""")] + public void ThenTheResolvedDetailsValueShouldBeShowImagesTitleCheckOutThesePicsImagesPerPage(string p0, string p1, string p2, string p3, string p4) + { + throw new PendingStepException(); + } + + [Then(@"the provider status should be ""(.*)""")] + public void ThenTheProviderStatusShouldBe(string status) + { + var expectedStatus = ParseFromDescription(status); + Assert.Equal(expectedStatus, this.State.Provider!.Status); + } + + [Given("evaluation options containing specific hooks")] + public void GivenEvaluationOptionsContainingSpecificHooks() + { + throw new PendingStepException(); + } + + [When("the flag was evaluated with details using the evaluation options")] + public void WhenTheFlagWasEvaluatedWithDetailsUsingTheEvaluationOptions() + { + throw new PendingStepException(); + } + + [Then("the specified hooks should execute during evaluation")] + public void ThenTheSpecifiedHooksShouldExecuteDuringEvaluation() + { + throw new PendingStepException(); + } + + [Then("the hook order should be maintained")] + public void ThenTheHookOrderShouldBeMaintained() + { + throw new PendingStepException(); + } + + [Given("an evaluation context with modifiable data")] + public void GivenAnEvaluationContextWithModifiableData() + { + throw new PendingStepException(); + } + + [Then("the original evaluation context should remain unmodified")] + public void ThenTheOriginalEvaluationContextShouldRemainUnmodified() + { + throw new PendingStepException(); + } + + [Then("the evaluation details should be immutable")] + public void ThenTheEvaluationDetailsShouldBeImmutable() + { + throw new PendingStepException(); + } + + [When("the flag was evaluated with details asynchronously")] + public void WhenTheFlagWasEvaluatedWithDetailsAsynchronously() + { + throw new PendingStepException(); + } + + [Then("the evaluation should complete without blocking")] + public void ThenTheEvaluationShouldCompleteWithoutBlocking() + { + throw new PendingStepException(); + } + + 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/Steps/MetadataStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs index 033e9bd6c..153a6b560 100644 --- a/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs @@ -7,7 +7,7 @@ namespace OpenFeature.E2ETests.Steps; [Scope(Feature = "Metadata")] public class MetadataStepDefinitions : BaseStepDefinitions { - MetadataStepDefinitions(State state) : base(state) + public MetadataStepDefinitions(State state) : base(state) { } diff --git a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs new file mode 100644 index 000000000..945a8f409 --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs @@ -0,0 +1,41 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.E2ETests.Utils; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +public class ProviderStepDefinition +{ + private State State { get; } + + public ProviderStepDefinition(State state) + { + this.State = state; + } + + [Given("a not ready provider")] + public async Task GivenANotReadyProvider() + { + var provider = Substitute.For(); + provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); + provider.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + this.State.Provider = provider; + } + + [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"); + this.State.Provider = provider; + } +} diff --git a/test/OpenFeature.E2ETests/Utils/State.cs b/test/OpenFeature.E2ETests/Utils/State.cs index 13a4e5a39..be68fe002 100644 --- a/test/OpenFeature.E2ETests/Utils/State.cs +++ b/test/OpenFeature.E2ETests/Utils/State.cs @@ -4,6 +4,7 @@ namespace OpenFeature.E2ETests.Utils; public class State { + public FeatureProvider? Provider; public FeatureClient? Client; public FlagState? Flag; public object? FlagEvaluationDetailsResult; From 3727780ddd9e856ae5439eb0b20277849f58f453 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 15 Oct 2025 22:31:32 +0100 Subject: [PATCH 02/15] Fix ProviderNotReady tests Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs index 945a8f409..f29a496f1 100644 --- a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs +++ b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs @@ -20,7 +20,7 @@ public async Task GivenANotReadyProvider() { var provider = Substitute.For(); provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); - provider.Status.Returns(ProviderStatus.NotReady); + provider.Status.Returns(ProviderStatus.Ready, ProviderStatus.NotReady); await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); From 77aa26d774a3f6e588503f8e7459c3388789b59f Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 15 Oct 2025 22:46:33 +0100 Subject: [PATCH 03/15] Fix test on provider status Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/Evaluationv2StepDefinitions.cs | 7 --- .../Steps/ProviderStepDefinition.cs | 58 ++++++++++++++++++- test/OpenFeature.E2ETests/Utils/State.cs | 1 - 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs index 682c6fcf2..b6ea955af 100644 --- a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs @@ -243,13 +243,6 @@ public void ThenTheResolvedDetailsValueShouldBeShowImagesTitleCheckOutThesePicsI throw new PendingStepException(); } - [Then(@"the provider status should be ""(.*)""")] - public void ThenTheProviderStatusShouldBe(string status) - { - var expectedStatus = ParseFromDescription(status); - Assert.Equal(expectedStatus, this.State.Provider!.Status); - } - [Given("evaluation options containing specific hooks")] public void GivenEvaluationOptionsContainingSpecificHooks() { diff --git a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs index f29a496f1..dad853a91 100644 --- a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs +++ b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; +using System.Reflection; using NSubstitute; using OpenFeature.Constant; using OpenFeature.E2ETests.Utils; @@ -15,6 +17,39 @@ public ProviderStepDefinition(State state) this.State = state; } + [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 stable provider")] + public async Task GivenAStableProvider() + { + var provider = Substitute.For(); + provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); + provider.Status.Returns(ProviderStatus.Ready); + + 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() { @@ -24,7 +59,6 @@ public async Task GivenANotReadyProvider() await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); - this.State.Provider = provider; } [Given("a fatal provider")] @@ -36,6 +70,26 @@ public async Task GivenAFatalProvider() await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); - this.State.Provider = provider; + } + + [Then(@"the provider status should be ""(.*)""")] + public void ThenTheProviderStatusShouldBe(string status) + { + var expectedStatus = ParseFromDescription(status); + var provider = Api.Instance.GetProvider(); + Assert.Equal(expectedStatus, provider.Status); + } + + 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/State.cs b/test/OpenFeature.E2ETests/Utils/State.cs index be68fe002..13a4e5a39 100644 --- a/test/OpenFeature.E2ETests/Utils/State.cs +++ b/test/OpenFeature.E2ETests/Utils/State.cs @@ -4,7 +4,6 @@ namespace OpenFeature.E2ETests.Utils; public class State { - public FeatureProvider? Provider; public FeatureClient? Client; public FlagState? Flag; public object? FlagEvaluationDetailsResult; From 0869166016f221e89fd74fc7ec4fa25d10e138d6 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Sun, 19 Oct 2025 20:08:54 +0100 Subject: [PATCH 04/15] Add remaining E2E tests where possible Some of the test cases have been skipped due to the SDK not having support for it yet. Some tests will fail due to a bug in the InMemoryProvider. Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- spec | 2 +- .../Steps/BaseStepDefinitions.cs | 36 ++++++---- .../Steps/Evaluationv2StepDefinitions.cs | 69 ++++++++++++++++++- .../Steps/ExcludedTagsStep.cs | 17 +++++ 4 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs diff --git a/spec b/spec index 4443c345b..4542c3cdf 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 4443c345b2a0e766226af00094d593dc5362f7ce +Subproject commit 4542c3cdfe8ae9947c7963cb91bfef1d21b643d4 diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index efb315221..5507a2e43 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -126,18 +126,22 @@ public async Task WhenTheFlagWasEvaluatedWithDetails() { case FlagType.Boolean: this.State.FlagEvaluationDetailsResult = await this.State.Client! - .GetBooleanDetailsAsync(flag.Key, bool.Parse(flag.DefaultValue)).ConfigureAwait(false); + .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)).ConfigureAwait(false); + .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)).ConfigureAwait(false); + .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.FlagEvaluationDetailsResult = await this.State.Client! + .GetStringDetailsAsync(flag.Key, flag.DefaultValue, this.State.EvaluationContext) .ConfigureAwait(false); break; case FlagType.Object: @@ -214,7 +218,8 @@ private void InitializeContext(string level, EvaluationContext context) defaultVariant: "zero", contextEvaluator: (context) => { - return context.GetValue("email").AsString == "ballmer@macrosoft.com" ? "zero" : ""; + return context.TryGetValue("email", out var email) + && email?.AsString == "ballmer@macrosoft.com" ? "zero" : ""; } ) }, @@ -230,9 +235,12 @@ private void InitializeContext(string level, EvaluationContext context) defaultVariant: "external", (context) => { - if (context.GetValue("email").AsString == "ballmer@macrosoft.com" - && context.GetValue("age").AsInteger > 10 - && context.GetValue("customer").AsBoolean == false) + if (context.TryGetValue("email", out var email) + && email?.AsString == "ballmer@macrosoft.com" + && context.TryGetValue("age", out var age) + && age?.AsInteger > 10 + && context.TryGetValue("customer", out var customer) + && customer?.AsBoolean == false) { return "internal"; } @@ -258,7 +266,8 @@ private void InitializeContext(string level, EvaluationContext context) defaultVariant: "zero", contextEvaluator: (context) => { - return context.GetValue("email").AsString == "ballmer@macrosoft.com" ? "zero" : ""; + return context.TryGetValue("email", out var email) + && email?.AsString == "ballmer@macrosoft.com" ? "zero" : ""; } ) }, @@ -286,7 +295,8 @@ private void InitializeContext(string level, EvaluationContext context) defaultVariant: "zero", contextEvaluator: (context) => { - return context.GetValue("email").AsString == "ballmer@macrosoft.com" ? "zero" : ""; + return context.TryGetValue("email", out var email) + && email?.AsString == "ballmer@macrosoft.com" ? "zero" : ""; } ) }, @@ -360,7 +370,8 @@ private void InitializeContext(string level, EvaluationContext context) defaultVariant: "zero", contextEvaluator: (context) => { - return context.GetValue("email").AsString == "ballmer@macrosoft.com" ? "zero" : ""; + return context.TryGetValue("email", out var email) + && email?.AsString == "ballmer@macrosoft.com" ? "zero" : ""; } ) }, @@ -398,7 +409,8 @@ private void InitializeContext(string level, EvaluationContext context) defaultVariant: "zero", contextEvaluator: (context) => { - return context.GetValue("email").AsString == "ballmer@macrosoft.com" ? "zero" : ""; + return context.TryGetValue("email", out var email) + && email?.AsString == "ballmer@macrosoft.com" ? "zero" : ""; } ) }, diff --git a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs index b6ea955af..08cb5922f 100644 --- a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs @@ -228,13 +228,48 @@ public void ThenTheVariantShouldBe(string variant) [Then("the resolved metadata should contain")] public void ThenTheResolvedMetadataShouldContain(DataTable dataTable) { - throw new PendingStepException(); + switch (this.State.Flag!.Type) + { + case FlagType.Integer: + var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(intResult); + Assert.NotNull(intResult.FlagMetadata); + AssertMetadataContains(dataTable, intResult); + break; + case FlagType.Float: + var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(floatResult); + Assert.NotNull(floatResult.FlagMetadata); + AssertMetadataContains(dataTable, floatResult); + break; + case FlagType.String: + var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(stringResult); + Assert.NotNull(stringResult.FlagMetadata); + AssertMetadataContains(dataTable, stringResult); + break; + case FlagType.Boolean: + var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + Assert.NotNull(booleanResult); + Assert.NotNull(booleanResult.FlagMetadata); + AssertMetadataContains(dataTable, booleanResult); + break; + case FlagType.Object: + Skip.If(true, "Object e2e test not supported"); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } } [Given(@"a context containing a key ""(.*)"" with null value")] public void GivenAContextContainingAKeyWithNullValue(string key) { - throw new PendingStepException(); + this.State.EvaluationContext = EvaluationContext.Builder() + .Merge(this.State.EvaluationContext ?? EvaluationContext.Empty) + .Set(key, (string?)null!) + .Build(); } [Then(@"the resolved details value should be ""(.*)""showImages\\\\""(.*)""title\\\\""(.*)""Check out these pics!\\\\""(.*)""imagesPerPage\\\\""(.*)""")] @@ -309,4 +344,34 @@ public static TEnum ParseFromDescription(string description) where TEnum } throw new ArgumentException($"No {typeof(TEnum).Name} with description '{description}' found."); } + + 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/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"); + } +} From c0ee5957c69270825c97de0d470106e4f543f950 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:10:30 +0000 Subject: [PATCH 05/15] Reduce duplicate code for fetching Enum from descriptions Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/Evaluationv2StepDefinitions.cs | 17 +--------------- .../Steps/ProviderStepDefinition.cs | 17 +--------------- .../OpenFeature.E2ETests/Utils/EnumHelpers.cs | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 32 deletions(-) create mode 100644 test/OpenFeature.E2ETests/Utils/EnumHelpers.cs diff --git a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs index 08cb5922f..2df70de4a 100644 --- a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs @@ -1,5 +1,3 @@ -using System.ComponentModel; -using System.Reflection; using OpenFeature.Constant; using OpenFeature.E2ETests.Utils; using OpenFeature.Model; @@ -125,7 +123,7 @@ public void GivenAContextContainingAKeyWithTypeAndWithValue(string key, string t [Then(@"the error-code should be ""(.*)""")] public void ThenTheError_CodeShouldBe(string error) { - var errorType = ParseFromDescription(error); + var errorType = EnumHelpers.ParseFromDescription(error); switch (this.State.Flag!.Type) { case FlagType.Integer: @@ -332,19 +330,6 @@ public void ThenTheEvaluationShouldCompleteWithoutBlocking() throw new PendingStepException(); } - 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."); - } - private static void AssertMetadataContains(DataTable dataTable, FlagEvaluationDetails details) { foreach (var row in dataTable.Rows) diff --git a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs index dad853a91..4c409147e 100644 --- a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs +++ b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs @@ -1,5 +1,3 @@ -using System.ComponentModel; -using System.Reflection; using NSubstitute; using OpenFeature.Constant; using OpenFeature.E2ETests.Utils; @@ -75,21 +73,8 @@ public async Task GivenAFatalProvider() [Then(@"the provider status should be ""(.*)""")] public void ThenTheProviderStatusShouldBe(string status) { - var expectedStatus = ParseFromDescription(status); + var expectedStatus = EnumHelpers.ParseFromDescription(status); var provider = Api.Instance.GetProvider(); Assert.Equal(expectedStatus, provider.Status); } - - 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/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."); + } +} From f5c2c767045b80e401ea3b06875b894bec019a67 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:24:37 +0000 Subject: [PATCH 06/15] Reduce code duplication Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/Evaluationv2StepDefinitions.cs | 108 ++++++------------ 1 file changed, 32 insertions(+), 76 deletions(-) diff --git a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs index 2df70de4a..33d9b0afe 100644 --- a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs @@ -19,27 +19,19 @@ public void ThenTheResolvedDetailsValueShouldBe(string value) { case FlagType.Integer: var intValue = int.Parse(value); - var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(intResult); - Assert.Equal(intValue, intResult.Value); + AssertOnDetails(r => Assert.Equal(intValue, r.Value)); break; case FlagType.Float: var floatValue = double.Parse(value); - var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(floatResult); - Assert.Equal(floatValue, floatResult.Value); + AssertOnDetails(r => Assert.Equal(floatValue, r.Value)); break; case FlagType.String: var stringValue = value; - var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(stringResult); - Assert.Equal(stringValue, stringResult.Value); + AssertOnDetails(r => Assert.Equal(stringValue, r.Value)); break; case FlagType.Boolean: var booleanValue = bool.Parse(value); - var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(booleanResult); - Assert.Equal(booleanValue, booleanResult.Value); + AssertOnDetails(r => Assert.Equal(booleanValue, r.Value)); break; case FlagType.Object: Skip.If(true, "Object e2e test not supported"); @@ -56,24 +48,16 @@ public void ThenTheReasonShouldBe(string reason) switch (this.State.Flag!.Type) { case FlagType.Integer: - var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(intResult); - Assert.Equal(reason, intResult.Reason); + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); break; case FlagType.Float: - var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(floatResult); - Assert.Equal(reason, floatResult.Reason); + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); break; case FlagType.String: - var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(stringResult); - Assert.Equal(reason, stringResult.Reason); + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); break; case FlagType.Boolean: - var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(booleanResult); - Assert.Equal(reason, booleanResult.Reason); + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); break; case FlagType.Object: Skip.If(true, "Object e2e test not supported"); @@ -127,24 +111,16 @@ public void ThenTheError_CodeShouldBe(string error) switch (this.State.Flag!.Type) { case FlagType.Integer: - var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(intResult); - Assert.Equal(errorType, intResult.ErrorType); + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); break; case FlagType.Float: - var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(floatResult); - Assert.Equal(errorType, floatResult.ErrorType); + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); break; case FlagType.String: - var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(stringResult); - Assert.Equal(errorType, stringResult.ErrorType); + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); break; case FlagType.Boolean: - var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(booleanResult); - Assert.Equal(errorType, booleanResult.ErrorType); + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); break; case FlagType.Object: Skip.If(true, "Object e2e test not supported"); @@ -161,24 +137,16 @@ public void ThenTheFlagKeyShouldBe(string key) switch (this.State.Flag!.Type) { case FlagType.Integer: - var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(intResult); - Assert.Equal(key, intResult.FlagKey); + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); break; case FlagType.Float: - var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(floatResult); - Assert.Equal(key, floatResult.FlagKey); + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); break; case FlagType.String: - var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(stringResult); - Assert.Equal(key, stringResult.FlagKey); + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); break; case FlagType.Boolean: - var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(booleanResult); - Assert.Equal(key, booleanResult.FlagKey); + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); break; case FlagType.Object: Skip.If(true, "Object e2e test not supported"); @@ -195,24 +163,16 @@ public void ThenTheVariantShouldBe(string variant) switch (this.State.Flag!.Type) { case FlagType.Integer: - var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(intResult); - Assert.Equal(variant, intResult.Variant); + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); break; case FlagType.Float: - var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(floatResult); - Assert.Equal(variant, floatResult.Variant); + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); break; case FlagType.String: - var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(stringResult); - Assert.Equal(variant, stringResult.Variant); + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); break; case FlagType.Boolean: - var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(booleanResult); - Assert.Equal(variant, booleanResult.Variant); + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); break; case FlagType.Object: Skip.If(true, "Object e2e test not supported"); @@ -229,28 +189,16 @@ public void ThenTheResolvedMetadataShouldContain(DataTable dataTable) switch (this.State.Flag!.Type) { case FlagType.Integer: - var intResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(intResult); - Assert.NotNull(intResult.FlagMetadata); - AssertMetadataContains(dataTable, intResult); + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); break; case FlagType.Float: - var floatResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(floatResult); - Assert.NotNull(floatResult.FlagMetadata); - AssertMetadataContains(dataTable, floatResult); + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); break; case FlagType.String: - var stringResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(stringResult); - Assert.NotNull(stringResult.FlagMetadata); - AssertMetadataContains(dataTable, stringResult); + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); break; case FlagType.Boolean: - var booleanResult = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(booleanResult); - Assert.NotNull(booleanResult.FlagMetadata); - AssertMetadataContains(dataTable, booleanResult); + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); break; case FlagType.Object: Skip.If(true, "Object e2e test not supported"); @@ -330,6 +278,14 @@ public void ThenTheEvaluationShouldCompleteWithoutBlocking() throw new PendingStepException(); } + 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) From c8e06484556638c474fa5a1a75e6d96c87fe17eb Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:20:39 +0000 Subject: [PATCH 07/15] Add FlagDictionaryJsonConverter instead of mapping manually Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/BaseStepDefinitions.cs | 266 +----------------- .../Utils/ContextEvaluatorUtility.cs | 138 +++++++++ .../Utils/FlagDictionaryJsonConverter.cs | 186 ++++++++++++ 3 files changed, 336 insertions(+), 254 deletions(-) create mode 100644 test/OpenFeature.E2ETests/Utils/ContextEvaluatorUtility.cs create mode 100644 test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index 5507a2e43..9241b6087 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using OpenFeature.E2ETests.Utils; using OpenFeature.Model; using OpenFeature.Providers.Memory; @@ -17,7 +18,17 @@ public BaseStepDefinitions(State state) [Given(@"a stable provider")] public async Task GivenAStableProvider() { - var memProvider = new InMemoryProvider(E2EFlagConfig); + 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"); } @@ -198,259 +209,6 @@ private void InitializeContext(string level, EvaluationContext context) } } - private static readonly IDictionary E2EFlagConfig = new Dictionary - { - { - "boolean-flag", new Flag( - variants: new Dictionary { { "on", true }, { "off", false } }, - defaultVariant: "on" - ) - }, - { - "boolean-disabled-flag", new Flag( - variants: new Dictionary { { "on", true }, { "off", false } }, - defaultVariant: "on" - ) - }, - { - "boolean-targeted-zero-flag", new Flag( - variants: new Dictionary { { "zero", false }, { "non-zero", true } }, - defaultVariant: "zero", - contextEvaluator: (context) => - { - return context.TryGetValue("email", out var email) - && email?.AsString == "ballmer@macrosoft.com" ? "zero" : ""; - } - ) - }, - { - "boolean-zero-flag", new Flag( - variants: new Dictionary { { "zero", false }, { "non-zero", true } }, - defaultVariant: "zero" - ) - }, - { - "complex-targeted", new Flag( - variants: new Dictionary() { { "internal", "INTERNAL" }, { "external", "EXTERNAL" } }, - defaultVariant: "external", - (context) => - { - if (context.TryGetValue("email", out var email) - && email?.AsString == "ballmer@macrosoft.com" - && context.TryGetValue("age", out var age) - && age?.AsInteger > 10 - && context.TryGetValue("customer", out var customer) - && customer?.AsBoolean == false) - { - return "internal"; - } - return "external"; - } - ) - }, - { - "float-flag", new Flag( - variants: new Dictionary() { { "tenth", 0.1 }, { "half", 0.5 } }, - defaultVariant: "half" - ) - }, - { - "float-disabled-flag", new Flag( - variants: new Dictionary() { { "tenth", 0.1 }, { "half", 0.5 } }, - defaultVariant: "half" - ) - }, - { - "float-targeted-zero-flag", new Flag( - variants: new Dictionary() { { "zero", 0.0 }, { "non-zero", 1.0 } }, - defaultVariant: "zero", - contextEvaluator: (context) => - { - return context.TryGetValue("email", out var email) - && email?.AsString == "ballmer@macrosoft.com" ? "zero" : ""; - } - ) - }, - { - "float-zero-flag", new Flag( - variants: new Dictionary() { { "zero", 0.0 }, { "non-zero", 1.0 } }, - defaultVariant: "zero" - ) - }, - { - "integer-flag", new Flag( - variants: new Dictionary() { { "one", 1 }, { "ten", 10 } }, - defaultVariant: "ten" - ) - }, - { - "integer-disabled-flag", new Flag( - variants: new Dictionary() { { "one", 1 }, { "ten", 10 } }, - defaultVariant: "ten" - ) - }, - { - "integer-targeted-zero-flag", new Flag( - variants: new Dictionary() { { "zero", 0 }, { "non-zero", 1 } }, - defaultVariant: "zero", - contextEvaluator: (context) => - { - return context.TryGetValue("email", out var email) - && email?.AsString == "ballmer@macrosoft.com" ? "zero" : ""; - } - ) - }, - { - "integer-zero-flag", new Flag( - variants: new Dictionary() { { "zero", 0 }, { "non-zero", 1 } }, - defaultVariant: "zero" - ) - }, - { - "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 } - }) - ) - }, - { - "null-default-flag", new Flag( - variants: new Dictionary { { "on", true }, { "off", false } }, - defaultVariant: null! - ) - }, - { - "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" - ) - }, - { - "object-disabled-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" - ) - }, - { - "object-targeted-zero-flag", new Flag( - variants: new Dictionary() - { - { "zero", new Value() }, - { - "non-zero", new Value(Structure.Builder() - .Set("showImages", true) - .Set("title", "Check out these pics!") - .Set("imagesPerPage", 100).Build() - ) - } - }, - defaultVariant: "zero", - contextEvaluator: (context) => - { - return context.TryGetValue("email", out var email) - && email?.AsString == "ballmer@macrosoft.com" ? "zero" : ""; - } - ) - }, - { - "object-zero-flag", new Flag( - variants: new Dictionary() - { - { "zero", new Value() }, - { - "non-zero", new Value(Structure.Builder() - .Set("showImages", true) - .Set("title", "Check out these pics!") - .Set("imagesPerPage", 100).Build() - ) - } - }, - defaultVariant: "zero" - ) - }, - { - "string-flag", new Flag( - variants: new Dictionary() { { "greeting", "hi" }, { "parting", "bye" } }, - defaultVariant: "greeting" - ) - }, - { - "string-disabled-flag", new Flag( - variants: new Dictionary() { { "greeting", "hi" }, { "parting", "bye" } }, - defaultVariant: "greeting" - ) - }, - { - "string-targeted-zero-flag", new Flag( - variants: new Dictionary() { { "zero", "" }, { "non-zero", "str" } }, - defaultVariant: "zero", - contextEvaluator: (context) => - { - return context.TryGetValue("email", out var email) - && email?.AsString == "ballmer@macrosoft.com" ? "zero" : ""; - } - ) - }, - { - "string-zero-flag", new Flag( - variants: new Dictionary() { { "zero", "" }, { "non-zero", "str" } }, - defaultVariant: "zero" - ) - }, - { - "undefined-default-flag", new Flag( - variants: new Dictionary() { { "small", 10 }, { "big", 1000 } }, - defaultVariant: null! - ) - }, - { - "wrong-flag", new Flag( - variants: new Dictionary() { { "one", "uno" }, { "two", "dos" } }, - defaultVariant: "one" - ) - }, - { - "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"; - } - ) - } - }; - public class BeforeHook : Hook { private readonly EvaluationContext 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/FlagDictionaryJsonConverter.cs b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs new file mode 100644 index 000000000..e311bee68 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs @@ -0,0 +1,186 @@ +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 Dictionary ExtractObjectVariant(JsonElement obj) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var p in obj.EnumerateObject()) + { + switch (p.Value.ValueKind) + { + case JsonValueKind.String: result[p.Name] = p.Value.GetString()!; break; + case JsonValueKind.Number: + if (p.Value.TryGetInt64(out var l) && l >= int.MinValue && l <= int.MaxValue) + result[p.Name] = (int)l; + else + result[p.Name] = p.Value.GetDouble(); + break; + case JsonValueKind.True: + case JsonValueKind.False: + result[p.Name] = p.Value.GetBoolean(); + break; + case JsonValueKind.Object: + case JsonValueKind.Array: + // Nested complex structures not required by current test data; could be added if needed. + result[p.Name] = p.Value.Clone(); + break; + case JsonValueKind.Null: + result[p.Name] = null!; + break; + } + } + return result; + } + + 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}"); + } +} From 034f15adf2b0fc59d76ad2f1ba353707d2003394 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:59:04 +0000 Subject: [PATCH 08/15] Remove README and test-flags.json Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .gitignore | 2 + test/OpenFeature.E2ETests/Features/README.md | 148 ------------ .../Features/test-flags.json | 221 ------------------ 3 files changed, 2 insertions(+), 369 deletions(-) delete mode 100644 test/OpenFeature.E2ETests/Features/README.md delete mode 100644 test/OpenFeature.E2ETests/Features/test-flags.json 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/test/OpenFeature.E2ETests/Features/README.md b/test/OpenFeature.E2ETests/Features/README.md deleted file mode 100644 index deb2c7312..000000000 --- a/test/OpenFeature.E2ETests/Features/README.md +++ /dev/null @@ -1,148 +0,0 @@ -# Test Assets - -This directory contains test assets for the OpenFeature specification, including Gherkin test scenarios and structured JSON test data for comprehensive implementation validation. - -## Overview - -The test assets improve the existing Gherkin test suite by providing structured JSON test data that eliminates the need for manual test data creation. The data includes various flag types with different targeting scenarios to test edge cases and standard behavior. - -## Test Data Structure (`test-flags.json`) - -The [JSON test data](./test-flags.json) contains flags organized into several categories based on their behavior and purpose: - -### Standard Flags -Basic feature flags with straightforward evaluation: -- `boolean-flag`: Boolean flag with variants `on` (true) and `off` (false), defaults to `on` -- `string-flag`: String flag with variants `greeting` ("hi") and `parting` ("bye"), defaults to `greeting` -- `integer-flag`: Integer flag with variants `one` (1) and `ten` (10), defaults to `ten` -- `float-flag`: Float flag with variants `tenth` (0.1) and `half` (0.5), defaults to `half` -- `object-flag`: Object flag with `empty` ({}) and `template` variants, defaults to `template` - -### Zero Value Flags -Flags specifically designed to test zero/empty value handling: -- `boolean-zero-flag`: Boolean flag that defaults to `zero` variant (false) -- `string-zero-flag`: String flag that defaults to `zero` variant (empty string "") -- `integer-zero-flag`: Integer flag that defaults to `zero` variant (0) -- `float-zero-flag`: Float flag that defaults to `zero` variant (0.0) -- `object-zero-flag`: Object flag that defaults to `zero` variant (empty object {}) - -### Targeted Zero Flags -Flags with CEL expressions that can evaluate to zero values based on context: -- `boolean-targeted-zero-flag`: Uses CEL targeting, defaults to `zero` (false) -- `string-targeted-zero-flag`: Uses CEL targeting, defaults to `zero` (empty string) -- `integer-targeted-zero-flag`: Uses CEL targeting, defaults to `zero` (0) -- `float-targeted-zero-flag`: Uses CEL targeting, defaults to `zero` (0.0) -- `object-targeted-zero-flag`: Uses CEL targeting, defaults to `zero` (empty object) - -### Disabled Flags -Flags that are statically disabled: -- `boolean-disabled-flag`: Disabled Flag -- `string-disabled-flag`: Disabled Flag -- `integer-disabled-flag`: Disabled Flag -- `float-disabled-flag`: Disabled Flag -- `object-disabled-flag`: Disabled Flag - -### Special Testing Flags -Flags for testing edge cases and metadata: -- `metadata-flag`: Boolean flag with rich metadata including string, integer, boolean, and float values -- `complex-targeted`: String flag with complex CEL expression for internal/external user distinction -- `null-default-flag`: Flag with explicitly null default variant -- `undefined-default-flag`: Flag with no default variant defined -- `wrong-flag`: Flag for testing error scenarios - -## CEL Expression Variables - -The test data uses Common Expression Language (CEL) expressions in the `contextEvaluator` field. Based on the expressions in the test data, the following context variables are expected: - -### Available Context Variables -- `email`: User's email address (string) -- `customer`: Boolean flag indicating customer status -- `age`: User's age (integer) - -### CEL Expressions Used - -#### Simple Email Targeting -```cel -email == 'ballmer@macrosoft.com' ? 'zero' : '' -``` -Used in: `boolean-targeted-zero-flag`, `string-targeted-zero-flag`, `integer-targeted-zero-flag`, `float-targeted-zero-flag`, `object-targeted-zero-flag` - -#### Complex Multi-Condition Targeting -```cel -!customer && email == 'ballmer@macrosoft.com' && age > 10 ? 'internal' : '' -``` -Used in: `complex-targeted` - -## Flag Structure Schema - -Each flag in the test data follows this structure: - -```json -{ - "flag-name": { - "variants": { - "variant-key": "variant-value" - }, - "defaultVariant": "variant-key-or-null", - "contextEvaluator": "CEL-expression", // Optional - "flagMetadata": {} // Optional - } -} -``` - -### Key Components -- **variants**: Object containing all possible flag values mapped to variant keys -- **defaultVariant**: The variant key to use when no targeting rules match (can be null or omitted) -- **contextEvaluator**: Optional CEL expression for dynamic targeting -- **flagMetadata**: Optional metadata object containing additional flag information - -## Usage - -1. **Test Implementation**: Parse the JSON file with your preferred JSON library -2. **Context Setup**: Ensure your test contexts include the required variables (`email`, `customer`, `age`) -3. **CEL Evaluation**: Implement CEL expression evaluation for flags with `contextEvaluator` -4. **Edge Case Testing**: Use the zero flags and special flags to test boundary conditions - -## Test Context Examples - -For comprehensive testing, use these context combinations: - -```json -// Triggers targeted zero variants -{ - "targetingKey": "user1", - "email": "ballmer@macrosoft.com", - "customer": false, - "age": 25 -} - -// Triggers complex targeting -{ - "targetingKey": "user2", - "email": "ballmer@macrosoft.com", - "customer": false, - "age": 15 -} - -// Triggers alternative targeting -{ - "targetingKey": "user3", - "email": "jobs@orange.com" -} - -// Default behavior (no targeting matches) -{ - "targetingKey": "user4", - "email": "test@example.com", - "customer": true, - "age": 30 -} -``` - -## Contributing - -When modifying test data: -1. Maintain the category structure (standard, zero, targeted-zero, disabled) -2. Validate CEL expressions for syntax correctness -3. Ensure all required context variables are documented -4. Test both matching and non-matching targeting scenarios diff --git a/test/OpenFeature.E2ETests/Features/test-flags.json b/test/OpenFeature.E2ETests/Features/test-flags.json deleted file mode 100644 index 0233bdae6..000000000 --- a/test/OpenFeature.E2ETests/Features/test-flags.json +++ /dev/null @@ -1,221 +0,0 @@ -{ - "boolean-flag": { - "variants": { - "on": true, - "off": false - }, - "defaultVariant": "on", - "flagMetadata": null - }, - "boolean-disabled-flag": { - "disabled": true, - "variants": { - "on": true, - "off": false - }, - "defaultVariant": "on", - "flagMetadata": null - }, - "boolean-targeted-zero-flag": { - "variants": { - "zero": false, - "non-zero": true - }, - "defaultVariant": "zero", - "contextEvaluator": "email == 'ballmer@macrosoft.com' ? 'zero' : ''" - }, - "boolean-zero-flag": { - "variants": { - "zero": false, - "non-zero": true - }, - "defaultVariant": "zero" - }, - "complex-targeted": { - "variants": { - "internal": "INTERNAL", - "external": "EXTERNAL" - }, - "defaultVariant": "external", - "flagMetadata": null, - "contextEvaluator": "!customer && email == 'ballmer@macrosoft.com' && age > 10 ? 'internal' : ''" - }, - "float-flag": { - "variants": { - "tenth": 0.1, - "half": 0.5 - }, - "defaultVariant": "half", - "flagMetadata": null - }, - "float-disabled-flag": { - "disabled": true, - "variants": { - "tenth": 0.1, - "half": 0.5 - }, - "defaultVariant": "half", - "flagMetadata": null - }, - "float-targeted-zero-flag": { - "variants": { - "zero": 0.0, - "non-zero": 1.0 - }, - "defaultVariant": "zero", - "contextEvaluator": "email == 'ballmer@macrosoft.com' ? 'zero' : ''" - }, - "float-zero-flag": { - "variants": { - "zero": 0.0, - "non-zero": 1.0 - }, - "defaultVariant": "zero" - }, - "integer-flag": { - "variants": { - "one": 1, - "ten": 10 - }, - "defaultVariant": "ten", - "flagMetadata": null - }, - "integer-disabled-flag": { - "disabled": true, - "variants": { - "one": 1, - "ten": 10 - }, - "defaultVariant": "ten", - "flagMetadata": null - }, - "integer-targeted-zero-flag": { - "variants": { - "zero": 0, - "non-zero": 1 - }, - "defaultVariant": "zero", - "contextEvaluator": "email == 'ballmer@macrosoft.com' ? 'zero' : ''" - }, - "integer-zero-flag": { - "variants": { - "zero": 0, - "non-zero": 1 - }, - "defaultVariant": "zero" - }, - "metadata-flag": { - "variants": { - "on": true, - "off": false - }, - "defaultVariant": "on", - "flagMetadata": { - "string": "1.0.2", - "integer": 2, - "boolean": true, - "float": 0.1 - } - }, - "null-default-flag": { - "variants": { - "on": true, - "off": false - }, - "defaultVariant": null - }, - "object-flag": { - "variants": { - "empty": {}, - "template": { - "showImages": true, - "title": "Check out these pics!", - "imagesPerPage": 100 - } - }, - "defaultVariant": "template", - "flagMetadata": null - }, - "object-disabled-flag": { - "disabled": true, - "variants": { - "empty": {}, - "template": { - "showImages": true, - "title": "Check out these pics!", - "imagesPerPage": 100 - } - }, - "defaultVariant": "template", - "flagMetadata": null - }, - "object-targeted-zero-flag": { - "variants": { - "zero": {}, - "non-zero": { - "showImages": true, - "title": "Check out these pics!", - "imagesPerPage": 100 - } - }, - "defaultVariant": "zero", - "contextEvaluator": "email == 'ballmer@macrosoft.com' ? 'zero' : ''" - }, - "object-zero-flag": { - "variants": { - "zero": {}, - "non-zero": { - "showImages": true, - "title": "Check out these pics!", - "imagesPerPage": 100 - } - }, - "defaultVariant": "zero" - }, - "string-flag": { - "variants": { - "greeting": "hi", - "parting": "bye" - }, - "defaultVariant": "greeting", - "flagMetadata": null - }, - "string-disabled-flag": { - "disabled": true, - "variants": { - "greeting": "hi", - "parting": "bye" - }, - "defaultVariant": "greeting", - "flagMetadata": null - }, - "string-targeted-zero-flag": { - "variants": { - "zero": "", - "non-zero": "str" - }, - "defaultVariant": "zero", - "contextEvaluator": "email == 'ballmer@macrosoft.com' ? 'zero' : ''" - }, - "string-zero-flag": { - "variants": { - "zero": "", - "non-zero": "str" - }, - "defaultVariant": "zero" - }, - "undefined-default-flag": { - "variants": { - "small": 10, - "big": 1000 - } - }, - "wrong-flag": { - "variants": { - "one": "uno", - "two": "dos" - }, - "defaultVariant": "one", - "flagMetadata": null - } -} From 072f812a4eca13d2c8123281860196cbf23eaa20 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:38:33 +0000 Subject: [PATCH 09/15] Add tests to handle Object flag types Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/BaseStepDefinitions.cs | 8 ++- .../Steps/Evaluationv2StepDefinitions.cs | 36 +++++----- .../Utils/FlagDictionaryJsonConverter.cs | 51 ++++++------- .../Utils/JsonStructureLoader.cs | 72 +++++++++++++++++++ 4 files changed, 120 insertions(+), 47 deletions(-) create mode 100644 test/OpenFeature.E2ETests/Utils/JsonStructureLoader.cs diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index 9241b6087..2e1e2c3aa 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -77,7 +77,8 @@ public void GivenAString_FlagWithKeyAndADefaultValue(string key, string defaultT [Given(@"a object-flag with key ""(.*)"" and a fallback value ""(.*)""")] public void GivenAObject_FlagWithKeyAndADefaultValue(string key, string defaultType) { - Skip.If(true, "Object e2e test not supported"); + var flagState = new FlagState(key, defaultType, FlagType.Object); + this.State.Flag = flagState; } [Given("a stable provider with retrievable context is registered")] @@ -156,7 +157,10 @@ public async Task WhenTheFlagWasEvaluatedWithDetails() .ConfigureAwait(false); break; case FlagType.Object: - Skip.If(true, "Object e2e test not supported"); + 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; } } diff --git a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs index 33d9b0afe..38c1cde1c 100644 --- a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs @@ -34,7 +34,8 @@ public void ThenTheResolvedDetailsValueShouldBe(string value) AssertOnDetails(r => Assert.Equal(booleanValue, r.Value)); break; case FlagType.Object: - Skip.If(true, "Object e2e test not supported"); + var objectValue = JsonStructureLoader.ParseJsonValue(value); + AssertOnDetails(r => Assert.Equal(new Value(objectValue), r.Value)); break; default: Assert.Fail("FlagType not yet supported."); @@ -60,7 +61,7 @@ public void ThenTheReasonShouldBe(string reason) AssertOnDetails(r => Assert.Equal(reason, r.Reason)); break; case FlagType.Object: - Skip.If(true, "Object e2e test not supported"); + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); break; default: Assert.Fail("FlagType not yet supported."); @@ -71,37 +72,32 @@ public void ThenTheReasonShouldBe(string reason) [Given(@"a context containing a key ""(.*)"", with type ""(.*)"" and with value ""(.*)""")] public void GivenAContextContainingAKeyWithTypeAndWithValue(string key, string type, string value) { - Type expectedType = typeof(string); + var context = EvaluationContext.Builder() + .Merge(this.State.EvaluationContext ?? EvaluationContext.Empty); + switch (type) { case "Integer": - expectedType = typeof(int); + context = context.Set(key, int.Parse(value)); break; case "Float": - expectedType = typeof(double); + context = context.Set(key, double.Parse(value)); break; case "String": + context = context.Set(key, value); break; case "Boolean": - expectedType = typeof(bool); + context = context.Set(key, bool.Parse(value)); break; case "Object": - Skip.If(true, "Object e2e test not supported"); + context = context.Set(key, new Value(value)); break; default: Assert.Fail("FlagType not yet supported."); break; } - var structureBuilder = new StructureBuilder() - .Set(key, new Value(Convert.ChangeType(value, expectedType))); - - foreach (var item in this.State.EvaluationContext ?? EvaluationContext.Empty) - { - structureBuilder.Set(item.Key, item.Value); - } - - this.State.EvaluationContext = new EvaluationContext(structureBuilder.Build()); + this.State.EvaluationContext = context.Build(); } [Then(@"the error-code should be ""(.*)""")] @@ -123,7 +119,7 @@ public void ThenTheError_CodeShouldBe(string error) AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); break; case FlagType.Object: - Skip.If(true, "Object e2e test not supported"); + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); break; default: Assert.Fail("FlagType not yet supported."); @@ -149,7 +145,7 @@ public void ThenTheFlagKeyShouldBe(string key) AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); break; case FlagType.Object: - Skip.If(true, "Object e2e test not supported"); + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); break; default: Assert.Fail("FlagType not yet supported."); @@ -175,7 +171,7 @@ public void ThenTheVariantShouldBe(string variant) AssertOnDetails(r => Assert.Equal(variant, r.Variant)); break; case FlagType.Object: - Skip.If(true, "Object e2e test not supported"); + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); break; default: Assert.Fail("FlagType not yet supported."); @@ -201,7 +197,7 @@ public void ThenTheResolvedMetadataShouldContain(DataTable dataTable) AssertOnDetails(r => AssertMetadataContains(dataTable, r)); break; case FlagType.Object: - Skip.If(true, "Object e2e test not supported"); + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); break; default: Assert.Fail("FlagType not yet supported."); diff --git a/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs index e311bee68..153de67da 100644 --- a/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs +++ b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Serialization; using OpenFeature.Model; @@ -127,37 +128,37 @@ private static Flag BuildFlag( return dict.Count == 0 ? null : new ImmutableMetadata(dict); } - private static Dictionary ExtractObjectVariant(JsonElement obj) + private static Value ExtractObjectVariant(JsonElement obj) { - var result = new Dictionary(StringComparer.Ordinal); + if (obj.ValueKind != JsonValueKind.Object) + throw new JsonException("Expected object for variant"); + + var dict = new Dictionary(StringComparer.Ordinal); foreach (var p in obj.EnumerateObject()) { - switch (p.Value.ValueKind) - { - case JsonValueKind.String: result[p.Name] = p.Value.GetString()!; break; - case JsonValueKind.Number: - if (p.Value.TryGetInt64(out var l) && l >= int.MinValue && l <= int.MaxValue) - result[p.Name] = (int)l; - else - result[p.Name] = p.Value.GetDouble(); - break; - case JsonValueKind.True: - case JsonValueKind.False: - result[p.Name] = p.Value.GetBoolean(); - break; - case JsonValueKind.Object: - case JsonValueKind.Array: - // Nested complex structures not required by current test data; could be added if needed. - result[p.Name] = p.Value.Clone(); - break; - case JsonValueKind.Null: - result[p.Name] = null!; - break; - } + dict[p.Name] = ConvertElement(p.Value); } - return result; + + 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) => 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()); + } +} From b8469be9f658be9fd67980f96d080ff36ab65c78 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:52:04 +0000 Subject: [PATCH 10/15] Fix build by ensuring test-flags.json is linked from the submodule Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj index 645cfe534..cf6cdd428 100644 --- a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj +++ b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj @@ -38,7 +38,7 @@ - + PreserveNewest From fa6072f79e5f2dd152c0952b3d70a17dbb51f99a Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 30 Oct 2025 21:06:07 +0000 Subject: [PATCH 11/15] Fix issue when running e2e tests in non e2e ci Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj index cf6cdd428..78d5c4bbd 100644 --- a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj +++ b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj @@ -37,7 +37,9 @@ - + + + PreserveNewest From 56bf21912f235a718a63fd78bac23cd7f7dd6c64 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 1 Nov 2025 13:53:48 +0000 Subject: [PATCH 12/15] Reduce code duplication by adopting StepArgumentTransformation Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/BaseStepDefinitions.cs | 58 +++++-------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index 2e1e2c3aa..1edf5e84d 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -33,53 +33,25 @@ public async Task GivenAStableProvider() 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 fallback value ""(.*)""")] - [Given(@"a boolean-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a boolean-flag with key ""(.*)"" and a fallback value ""(.*)""")] - public void GivenABoolean_FlagWithKeyAndADefaultValue(string key, string defaultType) + [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.Boolean); + var flagState = new FlagState(key, defaultType, flagType); this.State.Flag = flagState; } - [Given(@"a Float-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a Float-flag with key ""(.*)"" and a fallback value ""(.*)""")] - [Given(@"a float-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a float-flag with key ""(.*)"" and a fallback 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 fallback value ""(.*)""")] - [Given(@"a integer-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a integer-flag with key ""(.*)"" and a fallback 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 fallback value ""(.*)""")] - [Given(@"a string-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a string-flag with key ""(.*)"" and a fallback value ""(.*)""")] - public void GivenAString_FlagWithKeyAndADefaultValue(string key, string defaultType) - { - var flagState = new FlagState(key, defaultType, FlagType.String); - this.State.Flag = flagState; - } - - [Given(@"a Object-flag with key ""(.*)"" and a fallback value ""(.*)""")] - [Given(@"a object-flag with key ""(.*)"" and a fallback value ""(.*)""")] - public void GivenAObject_FlagWithKeyAndADefaultValue(string key, string defaultType) - { - var flagState = new FlagState(key, defaultType, FlagType.Object); - 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}'") + }; [Given("a stable provider with retrievable context is registered")] public async Task GivenAStableProviderWithRetrievableContextIsRegistered() From d0e3580bab6dd1ebb8071d55fef71b251d97c1b8 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 1 Nov 2025 13:56:43 +0000 Subject: [PATCH 13/15] Remove step definitions for deprecated gherkin file Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/EvaluationStepDefinitions.cs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs index 4b30d200d..27e00359b 100644 --- a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs @@ -255,18 +255,4 @@ public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateatyp Assert.Equal(Reason.Error, result?.Reason); Assert.Equal(errorCode, result?.ErrorType.GetDescription()); } - - [When(@"a non-existent string flag with key ""(.*)"" is evaluated with details and a fallback value ""(.*)""")] - public async Task WhenANon_ExistentStringFlagWithKeyIsEvaluatedWithDetailsAndAFallbackValue(string flagKey, string defaultValue) - { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.String); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [When(@"a string flag with key ""(.*)"" is evaluated as an integer, with details and a fallback value (.*)")] - public async Task WhenAStringFlagWithKeyIsEvaluatedAsAnIntegerWithDetailsAndAFallbackValue(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); - } } From f5470eb64d3aade957a3296551aa2110652ee93b Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 1 Nov 2025 14:13:53 +0000 Subject: [PATCH 14/15] Align step definition files like it is in other languages and sdks Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/BaseStepDefinitions.cs | 202 --------------- ...ContextMergingPrecedenceStepDefinitions.cs | 32 --- .../Steps/EvaluationContextStepDefinitions.cs | 144 +++++++++++ .../Steps/EvaluationStepDefinitions.cs | 103 ++++---- ...pDefinitions.cs => FlagStepDefinitions.cs} | 234 ++++++------------ .../Steps/HooksStepDefinitions.cs | 19 +- .../Steps/MetadataStepDefinitions.cs | 104 +++++--- .../Steps/ProviderStepDefinition.cs | 39 ++- test/OpenFeature.E2ETests/Utils/BeforeHook.cs | 18 ++ 9 files changed, 400 insertions(+), 495 deletions(-) delete mode 100644 test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs delete mode 100644 test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs create mode 100644 test/OpenFeature.E2ETests/Steps/EvaluationContextStepDefinitions.cs rename test/OpenFeature.E2ETests/Steps/{Evaluationv2StepDefinitions.cs => FlagStepDefinitions.cs} (51%) create mode 100644 test/OpenFeature.E2ETests/Utils/BeforeHook.cs diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs deleted file mode 100644 index 1edf5e84d..000000000 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System.Text.Json; -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 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 (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}'") - }; - - [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), 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; - } - } - 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"); - } - } - - 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/Evaluationv2StepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/FlagStepDefinitions.cs similarity index 51% rename from test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs rename to test/OpenFeature.E2ETests/Steps/FlagStepDefinitions.cs index 38c1cde1c..599ae62e2 100644 --- a/test/OpenFeature.E2ETests/Steps/Evaluationv2StepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/FlagStepDefinitions.cs @@ -5,17 +5,82 @@ namespace OpenFeature.E2ETests.Steps; [Binding] -[Scope(Feature = "Flag Evaluations - Complete OpenFeature Specification Coverage")] -public class Evaluationv2StepDefinitions : BaseStepDefinitions +public class FlagStepDefinitions { - public Evaluationv2StepDefinitions(State state) : base(state) + 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) + switch (this._state.Flag!.Type) { case FlagType.Integer: var intValue = int.Parse(value); @@ -46,7 +111,7 @@ public void ThenTheResolvedDetailsValueShouldBe(string value) [Then(@"the reason should be ""(.*)""")] public void ThenTheReasonShouldBe(string reason) { - switch (this.State.Flag!.Type) + switch (this._state.Flag!.Type) { case FlagType.Integer: AssertOnDetails(r => Assert.Equal(reason, r.Reason)); @@ -69,42 +134,11 @@ public void ThenTheReasonShouldBe(string reason) } } - [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(); - } - [Then(@"the error-code should be ""(.*)""")] public void ThenTheError_CodeShouldBe(string error) { var errorType = EnumHelpers.ParseFromDescription(error); - switch (this.State.Flag!.Type) + switch (this._state.Flag!.Type) { case FlagType.Integer: AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); @@ -130,7 +164,7 @@ public void ThenTheError_CodeShouldBe(string error) [Then(@"the flag key should be ""(.*)""")] public void ThenTheFlagKeyShouldBe(string key) { - switch (this.State.Flag!.Type) + switch (this._state.Flag!.Type) { case FlagType.Integer: AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); @@ -156,7 +190,7 @@ public void ThenTheFlagKeyShouldBe(string key) [Then(@"the variant should be ""(.*)""")] public void ThenTheVariantShouldBe(string variant) { - switch (this.State.Flag!.Type) + switch (this._state.Flag!.Type) { case FlagType.Integer: AssertOnDetails(r => Assert.Equal(variant, r.Variant)); @@ -179,136 +213,20 @@ public void ThenTheVariantShouldBe(string variant) } } - [Then("the resolved metadata should contain")] - public void ThenTheResolvedMetadataShouldContain(DataTable dataTable) - { - switch (this.State.Flag!.Type) - { - 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; - } - } - [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) + this._state.EvaluationContext = EvaluationContext.Builder() + .Merge(this._state.EvaluationContext ?? EvaluationContext.Empty) .Set(key, (string?)null!) .Build(); } - [Then(@"the resolved details value should be ""(.*)""showImages\\\\""(.*)""title\\\\""(.*)""Check out these pics!\\\\""(.*)""imagesPerPage\\\\""(.*)""")] - public void ThenTheResolvedDetailsValueShouldBeShowImagesTitleCheckOutThesePicsImagesPerPage(string p0, string p1, string p2, string p3, string p4) - { - throw new PendingStepException(); - } - - [Given("evaluation options containing specific hooks")] - public void GivenEvaluationOptionsContainingSpecificHooks() - { - throw new PendingStepException(); - } - - [When("the flag was evaluated with details using the evaluation options")] - public void WhenTheFlagWasEvaluatedWithDetailsUsingTheEvaluationOptions() - { - throw new PendingStepException(); - } - - [Then("the specified hooks should execute during evaluation")] - public void ThenTheSpecifiedHooksShouldExecuteDuringEvaluation() - { - throw new PendingStepException(); - } - - [Then("the hook order should be maintained")] - public void ThenTheHookOrderShouldBeMaintained() - { - throw new PendingStepException(); - } - - [Given("an evaluation context with modifiable data")] - public void GivenAnEvaluationContextWithModifiableData() - { - throw new PendingStepException(); - } - - [Then("the original evaluation context should remain unmodified")] - public void ThenTheOriginalEvaluationContextShouldRemainUnmodified() - { - throw new PendingStepException(); - } - - [Then("the evaluation details should be immutable")] - public void ThenTheEvaluationDetailsShouldBeImmutable() - { - throw new PendingStepException(); - } - - [When("the flag was evaluated with details asynchronously")] - public void WhenTheFlagWasEvaluatedWithDetailsAsynchronously() - { - throw new PendingStepException(); - } - - [Then("the evaluation should complete without blocking")] - public void ThenTheEvaluationShouldCompleteWithoutBlocking() - { - throw new PendingStepException(); - } - private void AssertOnDetails(Action> assertion) { - var details = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + 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/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 153a6b560..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 { - public 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/ProviderStepDefinition.cs b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs index 4c409147e..2d8b7a1a9 100644 --- a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs +++ b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs @@ -1,7 +1,9 @@ +using System.Text.Json; using NSubstitute; using OpenFeature.Constant; using OpenFeature.E2ETests.Utils; using OpenFeature.Model; +using OpenFeature.Providers.Memory; namespace OpenFeature.E2ETests.Steps; @@ -15,23 +17,42 @@ public ProviderStepDefinition(State state) this.State = state; } - [Given("a error provider")] - public async Task GivenAErrorProvider() + [Given(@"a stable provider")] + public async Task GivenAStableProvider() { - var provider = Substitute.For(); - provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); - provider.Status.Returns(ProviderStatus.Error); + var options = new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = true + }; + options.Converters.Add(new FlagDictionaryJsonConverter()); - await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + 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")] - public async Task GivenAStableProvider() + [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.Ready); + provider.Status.Returns(ProviderStatus.Error); await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); 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); + } +} From 3924c2883cc21712c1b489dbd22ec7749e429a54 Mon Sep 17 00:00:00 2001 From: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 1 Nov 2025 14:14:52 +0000 Subject: [PATCH 15/15] Rename provider step definition file Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../{ProviderStepDefinition.cs => ProviderStepDefinitions.cs} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename test/OpenFeature.E2ETests/Steps/{ProviderStepDefinition.cs => ProviderStepDefinitions.cs} (97%) diff --git a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinitions.cs similarity index 97% rename from test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs rename to test/OpenFeature.E2ETests/Steps/ProviderStepDefinitions.cs index 2d8b7a1a9..d3f482556 100644 --- a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinition.cs +++ b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinitions.cs @@ -8,11 +8,11 @@ namespace OpenFeature.E2ETests.Steps; [Binding] -public class ProviderStepDefinition +public class ProviderStepDefinitions { private State State { get; } - public ProviderStepDefinition(State state) + public ProviderStepDefinitions(State state) { this.State = state; }