From af8c70a188890e4ddd90320d30444d5f3e32ad11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 5 Jun 2025 16:42:26 +0100 Subject: [PATCH 01/61] feat: implement MultiProvider class with placeholder methods for feature resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature/Extensions/MultiProvider.cs | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/OpenFeature/Extensions/MultiProvider.cs diff --git a/src/OpenFeature/Extensions/MultiProvider.cs b/src/OpenFeature/Extensions/MultiProvider.cs new file mode 100644 index 00000000..185f8339 --- /dev/null +++ b/src/OpenFeature/Extensions/MultiProvider.cs @@ -0,0 +1,34 @@ +using OpenFeature.Model; + +namespace OpenFeature.Extensions; + +public sealed class MultiProvider : FeatureProvider +{ + /// + public override Metadata? GetMetadata() => new("OpenFeature MultiProvider"); + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } +} From 6467b7599604a6f37660b9348056fc330a132284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:00:07 +0100 Subject: [PATCH 02/61] feat: add BaseEvaluationStrategy and MultiProvider classes for multi-provider feature flag evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/BaseEvaluationStrategy.cs | 27 +++++++++++++++++++ .../{ => MultiProvider}/MultiProvider.cs | 16 ++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs rename src/OpenFeature/Extensions/{ => MultiProvider}/MultiProvider.cs (67%) diff --git a/src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs new file mode 100644 index 00000000..b416415a --- /dev/null +++ b/src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs @@ -0,0 +1,27 @@ +using OpenFeature.Model; + +namespace OpenFeature.Extensions.MultiProvider; + +/// +/// Provides a base class for implementing evaluation strategies that determine how feature flags are evaluated across multiple feature providers. +/// +/// +/// This abstract class serves as the foundation for creating custom evaluation strategies that can handle feature flag resolution +/// across multiple providers. Implementations define the specific logic for how providers are selected, prioritized, or combined +/// when evaluating feature flags. +/// +/// Multi Provider specification +public abstract class BaseEvaluationStrategy +{ + /// + /// Evaluates a feature flag across multiple providers using the strategy's specific logic. + /// + /// The type of the feature flag value to be evaluated. + /// A dictionary of feature providers keyed by their identifier. + /// The feature flag key to evaluate. + /// The default value to return if evaluation fails or the flag is not found. + /// Optional context information for the evaluation. + /// A cancellation token to cancel the operation if needed. + /// A task that represents the asynchronous evaluation operation, containing the resolution details with the evaluated value and metadata. + public abstract Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default); +} diff --git a/src/OpenFeature/Extensions/MultiProvider.cs b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs similarity index 67% rename from src/OpenFeature/Extensions/MultiProvider.cs rename to src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs index 185f8339..e27b154e 100644 --- a/src/OpenFeature/Extensions/MultiProvider.cs +++ b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs @@ -1,32 +1,46 @@ using OpenFeature.Model; -namespace OpenFeature.Extensions; +namespace OpenFeature.Extensions.MultiProvider; +/// +/// A feature provider that enables the use of multiple underlying providers, allowing different providers +/// to be used for different flag keys or based on specific routing logic. +/// +/// +/// The MultiProvider acts as a composite provider that can delegate flag resolution to different +/// underlying providers based on configuration or routing rules. This enables scenarios where +/// different feature flags may be served by different sources or providers within the same application. +/// public sealed class MultiProvider : FeatureProvider { /// public override Metadata? GetMetadata() => new("OpenFeature MultiProvider"); + /// public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } + /// public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } + /// public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } + /// public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } + /// public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); From 0a69cee0b8a405e8213f0516b495114050ccbb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:27:12 +0100 Subject: [PATCH 03/61] feat: implement MultiProvider methods for feature flag resolution using evaluation strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Extensions/MultiProvider/MultiProvider.cs | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs index e27b154e..bfc15d90 100644 --- a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs @@ -13,36 +13,40 @@ namespace OpenFeature.Extensions.MultiProvider; /// public sealed class MultiProvider : FeatureProvider { + private readonly Dictionary _providers; + private readonly BaseEvaluationStrategy _evaluationStrategy; + + /// + /// Initializes a new instance of the class with the specified providers and evaluation strategy. + /// + /// A dictionary containing the feature providers keyed by their identifiers. + /// The base evaluation strategy to use for determining how to evaluate features across multiple providers. + public MultiProvider(Dictionary providers, BaseEvaluationStrategy evaluationStrategy) + { + this._providers = providers; + this._evaluationStrategy = evaluationStrategy; + } + /// public override Metadata? GetMetadata() => new("OpenFeature MultiProvider"); /// - public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); /// - public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); /// - public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); /// - public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); /// - public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); } From 0f00aa5bd0d56c942e1ff43218f086ad2e577f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 07:38:54 +0100 Subject: [PATCH 04/61] feat: add ComparisonStrategy, FirstMatchStrategy, and FirstSuccessfulStrategy classes for feature flag evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/BaseEvaluationStrategy.cs | 1 - .../MultiProvider/ComparisonStrategy.cs | 18 ++++++++++++++++++ .../MultiProvider/FirstMatchStrategy.cs | 18 ++++++++++++++++++ .../MultiProvider/FirstSuccessfulStrategy.cs | 17 +++++++++++++++++ .../Extensions/MultiProvider/MultiProvider.cs | 1 + 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs create mode 100644 src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs create mode 100644 src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs diff --git a/src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs index b416415a..819acec3 100644 --- a/src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs +++ b/src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs @@ -10,7 +10,6 @@ namespace OpenFeature.Extensions.MultiProvider; /// across multiple providers. Implementations define the specific logic for how providers are selected, prioritized, or combined /// when evaluating feature flags. /// -/// Multi Provider specification public abstract class BaseEvaluationStrategy { /// diff --git a/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs new file mode 100644 index 00000000..a0ff0699 --- /dev/null +++ b/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs @@ -0,0 +1,18 @@ +using OpenFeature.Model; + +namespace OpenFeature.Extensions.MultiProvider; + +/// +/// Require that all providers agree on a value. If every provider returns a non-error result, and the values do not agree, +/// the Multi-Provider should return the result from a configurable “fallback” provider. It will also call an optional “onMismatch” +/// callback that can be used to monitor cases where mismatches of evaluation occurred. Otherwise the value of the result will be +/// the result of the first provider in precedence order. +/// +public sealed class ComparisonStrategy : BaseEvaluationStrategy +{ + /// + public override Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } +} diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs new file mode 100644 index 00000000..d431c2cb --- /dev/null +++ b/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs @@ -0,0 +1,18 @@ +using OpenFeature.Model; + +namespace OpenFeature.Extensions.MultiProvider; + +/// +/// Return the first result returned by a provider. Skip providers that indicate they had no value due to FLAG_NOT_FOUND. +/// In all other cases, use the value returned by the provider. If any provider returns an error result other than FLAG_NOT_FOUND, +/// the whole evaluation should error and “bubble up” the individual provider’s error in the result. +/// As soon as a value is returned by a provider, the rest of the operation should short-circuit and not call the rest of the providers. +/// +public sealed class FirstMatchStrategy : BaseEvaluationStrategy +{ + /// + public override Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } +} diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs new file mode 100644 index 00000000..0c06b744 --- /dev/null +++ b/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs @@ -0,0 +1,17 @@ +using OpenFeature.Model; + +namespace OpenFeature.Extensions.MultiProvider; + +/// +/// Return the first result returned by a provider. Errors from evaluated providers do not halt execution. +/// Instead, it will return the first successful result from a provider. +/// If no provider successfully responds, it will throw an error result. +/// +public sealed class FirstSuccessfulStrategy : BaseEvaluationStrategy +{ + /// + public override Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } +} diff --git a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs index bfc15d90..c130b0e8 100644 --- a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs @@ -11,6 +11,7 @@ namespace OpenFeature.Extensions.MultiProvider; /// underlying providers based on configuration or routing rules. This enables scenarios where /// different feature flags may be served by different sources or providers within the same application. /// +/// Multi Provider specification public sealed class MultiProvider : FeatureProvider { private readonly Dictionary _providers; From d743d2dc09d8e04adedcada3fd60fc9e69650a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 14:28:31 +0100 Subject: [PATCH 05/61] feat: implement EvaluateAsync method in FirstMatchStrategy for type-specific feature resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/FirstMatchStrategy.cs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs index d431c2cb..1a44b159 100644 --- a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs +++ b/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs @@ -1,3 +1,4 @@ +using OpenFeature.Constant; using OpenFeature.Model; namespace OpenFeature.Extensions.MultiProvider; @@ -5,14 +6,32 @@ namespace OpenFeature.Extensions.MultiProvider; /// /// Return the first result returned by a provider. Skip providers that indicate they had no value due to FLAG_NOT_FOUND. /// In all other cases, use the value returned by the provider. If any provider returns an error result other than FLAG_NOT_FOUND, -/// the whole evaluation should error and “bubble up” the individual provider’s error in the result. +/// the whole evaluation should error and "bubble up" the individual provider's error in the result. /// As soon as a value is returned by a provider, the rest of the operation should short-circuit and not call the rest of the providers. /// public sealed class FirstMatchStrategy : BaseEvaluationStrategy { /// - public override Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) + public override async Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + foreach (var provider in providers.Values) + { + var result = typeof(T) switch + { + { } t when t == typeof(bool) => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, (bool)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(int) => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, (int)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(double) => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, (double)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + _ => new ResolutionDetails(key, defaultValue, ErrorType.TypeMismatch, Reason.Error, errorMessage: $"Unsupported type: {typeof(T).Name}") + }; + + if (result.ErrorType is ErrorType.None or not ErrorType.FlagNotFound) + { + return result; + } + } + + return new ResolutionDetails(key, defaultValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found in any provider"); } } From 9e7ed97b5d2601356aa2c06c95dcaf5ffc968ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 14:46:42 +0100 Subject: [PATCH 06/61] feat: enhance error handling in FirstMatchStrategy for feature flag resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Extensions/MultiProvider/FirstMatchStrategy.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs index 1a44b159..db169ec4 100644 --- a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs +++ b/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs @@ -26,10 +26,17 @@ public override async Task> EvaluateAsync(Dictionary new ResolutionDetails(key, defaultValue, ErrorType.TypeMismatch, Reason.Error, errorMessage: $"Unsupported type: {typeof(T).Name}") }; + // If the result is not FLAG_NOT_FOUND and is not an error, return it if (result.ErrorType is ErrorType.None or not ErrorType.FlagNotFound) { return result; } + + // If the result is an error other than FLAG_NOT_FOUND, bubble it up + if (result.ErrorType is not ErrorType.None and not ErrorType.FlagNotFound) + { + return result; + } } return new ResolutionDetails(key, defaultValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found in any provider"); From ddd714e7aa5b9ad59257012ec5b0953386a094a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 14:48:04 +0100 Subject: [PATCH 07/61] feat: implement EvaluateAsync method in FirstSuccessfulStrategy for multi-type feature resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/FirstSuccessfulStrategy.cs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs index 0c06b744..e3bc4406 100644 --- a/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs +++ b/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs @@ -1,3 +1,4 @@ +using OpenFeature.Constant; using OpenFeature.Model; namespace OpenFeature.Extensions.MultiProvider; @@ -10,8 +11,27 @@ namespace OpenFeature.Extensions.MultiProvider; public sealed class FirstSuccessfulStrategy : BaseEvaluationStrategy { /// - public override Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) + public override async Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) { - throw new NotImplementedException(); + foreach (var provider in providers.Values) + { + var result = typeof(T) switch + { + { } t when t == typeof(bool) => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, (bool)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(int) => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, (int)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(double) => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, (double)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + _ => new ResolutionDetails(key, defaultValue, ErrorType.TypeMismatch, Reason.Error, errorMessage: $"Unsupported type: {typeof(T).Name}") + }; + + // If the result is not FLAG_NOT_FOUND and is not an error, return it + if (result.ErrorType is ErrorType.None or not ErrorType.FlagNotFound) + { + return result; + } + } + + return new ResolutionDetails(key, defaultValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found in any provider"); } } From fdb2e6351b2a27229d887922b6237d7009275637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 14:52:59 +0100 Subject: [PATCH 08/61] feat: refactor feature resolution strategies to use EvaluateAsync method for improved multi-provider support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/FirstMatchStrategy.cs | 10 +-------- .../MultiProvider/FirstSuccessfulStrategy.cs | 10 +-------- .../MultiProvider/ProviderExtensions.cs | 22 +++++++++++++++++++ 3 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 src/OpenFeature/Extensions/MultiProvider/ProviderExtensions.cs diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs index db169ec4..15313eba 100644 --- a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs +++ b/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs @@ -16,15 +16,7 @@ public override async Task> EvaluateAsync(Dictionary (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, (bool)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(int) => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, (int)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(double) => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, (double)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - _ => new ResolutionDetails(key, defaultValue, ErrorType.TypeMismatch, Reason.Error, errorMessage: $"Unsupported type: {typeof(T).Name}") - }; + var result = await provider.EvaluateAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false); // If the result is not FLAG_NOT_FOUND and is not an error, return it if (result.ErrorType is ErrorType.None or not ErrorType.FlagNotFound) diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs index e3bc4406..6287268b 100644 --- a/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs +++ b/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs @@ -15,15 +15,7 @@ public override async Task> EvaluateAsync(Dictionary (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, (bool)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(int) => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, (int)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(double) => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, (double)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - _ => new ResolutionDetails(key, defaultValue, ErrorType.TypeMismatch, Reason.Error, errorMessage: $"Unsupported type: {typeof(T).Name}") - }; + var result = await provider.EvaluateAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false); // If the result is not FLAG_NOT_FOUND and is not an error, return it if (result.ErrorType is ErrorType.None or not ErrorType.FlagNotFound) diff --git a/src/OpenFeature/Extensions/MultiProvider/ProviderExtensions.cs b/src/OpenFeature/Extensions/MultiProvider/ProviderExtensions.cs new file mode 100644 index 00000000..8dfab356 --- /dev/null +++ b/src/OpenFeature/Extensions/MultiProvider/ProviderExtensions.cs @@ -0,0 +1,22 @@ +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature.Extensions.MultiProvider; + +internal static class ProviderExtensions +{ + internal static async Task> EvaluateAsync(this FeatureProvider provider, string key, T defaultValue, EvaluationContext? evaluationContext, + CancellationToken cancellationToken) + { + var result = typeof(T) switch + { + { } t when t == typeof(bool) => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, (bool)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(int) => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, (int)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(double) => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, (double)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + _ => new ResolutionDetails(key, defaultValue, ErrorType.TypeMismatch, Reason.Error, errorMessage: $"Unsupported type: {typeof(T).Name}") + }; + return result; + } +} From 776aa61e1a901f157280c58de05a88964fd31242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 15:06:49 +0100 Subject: [PATCH 09/61] Removed ComparisonStrategy.cs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/ComparisonStrategy.cs | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs diff --git a/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs deleted file mode 100644 index a0ff0699..00000000 --- a/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs +++ /dev/null @@ -1,18 +0,0 @@ -using OpenFeature.Model; - -namespace OpenFeature.Extensions.MultiProvider; - -/// -/// Require that all providers agree on a value. If every provider returns a non-error result, and the values do not agree, -/// the Multi-Provider should return the result from a configurable “fallback” provider. It will also call an optional “onMismatch” -/// callback that can be used to monitor cases where mismatches of evaluation occurred. Otherwise the value of the result will be -/// the result of the first provider in precedence order. -/// -public sealed class ComparisonStrategy : BaseEvaluationStrategy -{ - /// - public override Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } -} From 30f8c94a001d417c58dea8de677e33243d1e2a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 15:59:14 +0100 Subject: [PATCH 10/61] feat: add unit tests for FirstMatchStrategy and FirstSuccessfulStrategy to enhance multi-provider support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/FirstMatchStrategy.cs | 6 - .../MultiProvider/FirstMatchStrategyTests.cs | 302 ++++++++++++++++++ 2 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 test/OpenFeature.Tests/Extensions/MultiProvider/FirstMatchStrategyTests.cs diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs index 15313eba..8afb5f09 100644 --- a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs +++ b/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs @@ -23,12 +23,6 @@ public override async Task> EvaluateAsync(Dictionary(key, defaultValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found in any provider"); diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/FirstMatchStrategyTests.cs b/test/OpenFeature.Tests/Extensions/MultiProvider/FirstMatchStrategyTests.cs new file mode 100644 index 00000000..9c570de3 --- /dev/null +++ b/test/OpenFeature.Tests/Extensions/MultiProvider/FirstMatchStrategyTests.cs @@ -0,0 +1,302 @@ +using AutoFixture; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Extensions.MultiProvider; +using OpenFeature.Model; + +namespace OpenFeature.Tests.Extensions.MultiProvider; + +public class FirstMatchStrategyTests +{ + private readonly Fixture _fixture = new(); + + [Fact] + public async Task EvaluateAsync_Should_Return_First_Non_FLAG_NOT_FOUND_Result() + { + // Arrange + var strategy = new FirstMatchStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, { "provider2", provider2 } + }; + + var flagNotFoundResult = + new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); + var successResult = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagNotFoundResult); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(successResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(successResult, result); + } + + [Fact] + public async Task EvaluateAsync_Should_Return_FLAG_NOT_FOUND_When_All_Providers_Return_FLAG_NOT_FOUND() + { + // Arrange + var strategy = new FirstMatchStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, { "provider2", provider2 } + }; + + var flagNotFoundResult = + new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagNotFoundResult); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagNotFoundResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); + } + + [Fact] + public async Task EvaluateAsync_Should_Return_First_Result_When_No_FLAG_NOT_FOUND() + { + // Arrange + var strategy = new FirstMatchStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, { "provider2", provider2 } + }; + + var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); + var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result1); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result2); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(result1, result); + } + + [Fact] + public async Task EvaluateAsync_String_Should_Work_Correctly() + { + // Arrange + var strategy = new FirstMatchStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var providers = new Dictionary { { "provider", provider } }; + + var expectedResult = new ResolutionDetails(flagKey, "test-value", ErrorType.None, "STATIC", "variant"); + + provider.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Integer_Should_Work_Correctly() + { + // Arrange + var strategy = new FirstMatchStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var providers = new Dictionary { { "provider", provider } }; + + var expectedResult = new ResolutionDetails(flagKey, 42, ErrorType.None, "STATIC", "variant"); + + provider.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Double_Should_Work_Correctly() + { + // Arrange + var strategy = new FirstMatchStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var providers = new Dictionary { { "provider", provider } }; + + var expectedResult = new ResolutionDetails(flagKey, 3.14, ErrorType.None, "STATIC", "variant"); + + provider.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Structure_Should_Work_Correctly() + { + // Arrange + var strategy = new FirstMatchStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var providers = new Dictionary { { "provider", provider } }; + + var structureValue = new Value(new Structure(new Dictionary { { "key", new Value("value") } })); + var expectedResult = new ResolutionDetails(flagKey, structureValue, ErrorType.None, "STATIC", "variant"); + + provider.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Should_Bubble_Up_Non_FLAG_NOT_FOUND_Error() + { + // Arrange + var strategy = new FirstMatchStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var providers = new Dictionary { { "provider", provider } }; + + var flagErrorResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.General, "UNKNOWN"); + + provider.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagErrorResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(flagErrorResult, result); + } + + [Fact] + public async Task EvaluateAsync_Should_Continue_On_FLAG_NOT_FOUND_And_Bubble_Other_Errors() + { + // Arrange + var strategy = new FirstMatchStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, { "provider2", provider2 } + }; + + var flagNotFoundResult = + new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); + var flagErrorResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.General, "UNKNOWN"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagNotFoundResult); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagErrorResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(flagErrorResult, result); + } + + [Fact] + public async Task EvaluateAsync_Should_Respect_Provider_Order() + { + // Arrange + var strategy = new FirstMatchStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, { "provider2", provider2 } + }; + + var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); + var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result1); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result2); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(result1, result); + await provider1.Received(1).ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); + await provider2.DidNotReceive().ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); + } +} From b12a3cb936afb0698eaf8a3bf95b98c1627f4906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:11:02 +0100 Subject: [PATCH 11/61] feat: add unit tests for FirstSuccessfulStrategy to validate multi-provider evaluation logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/FirstSuccessfulStrategy.cs | 4 +- .../FirstSuccessfulStrategyTests.cs | 308 ++++++++++++++++++ 2 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 test/OpenFeature.Tests/Extensions/MultiProvider/FirstSuccessfulStrategyTests.cs diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs index 6287268b..635ed059 100644 --- a/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs +++ b/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs @@ -17,8 +17,8 @@ public override async Task> EvaluateAsync(Dictionary(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var flagNotFoundResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); + var successResult = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagNotFoundResult); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(successResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(successResult, result); + } + + [Fact] + public async Task EvaluateAsync_Should_Return_FLAG_NOT_FOUND_When_All_Providers_Return_FLAG_NOT_FOUND() + { + // Arrange + var strategy = new FirstSuccessfulStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var flagNotFoundResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagNotFoundResult); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagNotFoundResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); + } + + [Fact] + public async Task EvaluateAsync_Should_Return_First_Result_When_No_FLAG_NOT_FOUND() + { + // Arrange + var strategy = new FirstSuccessfulStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); + var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result1); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result2); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(result1, result); + } + + [Fact] + public async Task EvaluateAsync_String_Should_Work_Correctly() + { + // Arrange + var strategy = new FirstSuccessfulStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var providers = new Dictionary { { "provider", provider } }; + + var expectedResult = new ResolutionDetails(flagKey, "test-value", ErrorType.None, "STATIC", "variant"); + + provider.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Integer_Should_Work_Correctly() + { + // Arrange + var strategy = new FirstSuccessfulStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var providers = new Dictionary { { "provider", provider } }; + + var expectedResult = new ResolutionDetails(flagKey, 42, ErrorType.None, "STATIC", "variant"); + + provider.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Double_Should_Work_Correctly() + { + // Arrange + var strategy = new FirstSuccessfulStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var providers = new Dictionary { { "provider", provider } }; + + var expectedResult = new ResolutionDetails(flagKey, 3.14, ErrorType.None, "STATIC", "variant"); + + provider.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Structure_Should_Work_Correctly() + { + // Arrange + var strategy = new FirstSuccessfulStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var providers = new Dictionary { { "provider", provider } }; + + var structureValue = new Value(new Structure(new Dictionary { { "key", new Value("value") } })); + var expectedResult = new ResolutionDetails(flagKey, structureValue, ErrorType.None, "STATIC", "variant"); + + provider.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Should_Continue_FLAG_NOT_FOUND_Error() + { + // Arrange + var strategy = new FirstSuccessfulStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var providers = new Dictionary { { "provider", provider } }; + + var flagNotFoundResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); + + provider.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagNotFoundResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); + Assert.Equal(Reason.Error, result.Reason); + Assert.Equal("Flag not found in any provider", result.ErrorMessage); + } + + [Fact] + public async Task EvaluateAsync_Should_Continue_On_FLAG_NOT_FOUND_And_Continue_Other_Errors() + { + // Arrange + var strategy = new FirstSuccessfulStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var flagNotFoundResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); + var errorResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.TypeMismatch, "TYPE_MISMATCH"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(flagNotFoundResult); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(errorResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); + Assert.Equal(Reason.Error, result.Reason); + Assert.Equal("Flag not found in any provider", result.ErrorMessage); + } + + [Fact] + public async Task EvaluateAsync_Should_Respect_Provider_Order() + { + // Arrange + var strategy = new FirstSuccessfulStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); + var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result1); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result2); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(result1, result); + await provider1.Received(1).ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); + await provider2.DidNotReceive().ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); + } +} From a2584690fee39ade41f7a9aa725d297006b719f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:13:05 +0100 Subject: [PATCH 12/61] feat: add unit tests for MultiProvider and ProviderExtensions to validate multi-provider functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/ProviderExtensionsTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 test/OpenFeature.Tests/Extensions/MultiProvider/ProviderExtensionsTests.cs diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/ProviderExtensionsTests.cs b/test/OpenFeature.Tests/Extensions/MultiProvider/ProviderExtensionsTests.cs new file mode 100644 index 00000000..471d1d78 --- /dev/null +++ b/test/OpenFeature.Tests/Extensions/MultiProvider/ProviderExtensionsTests.cs @@ -0,0 +1,128 @@ +using AutoFixture; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Extensions.MultiProvider; +using OpenFeature.Model; + +namespace OpenFeature.Tests.Extensions.MultiProvider; + +public class ProviderExtensionsTests : ClearOpenFeatureInstanceFixture +{ + private readonly Fixture _fixture = new(); + + [Fact] + public async Task EvaluateAsync_Boolean_Should_Handle_Successful_Evaluation() + { + // Arrange + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var expectedResult = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant"); + + provider.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await provider.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await provider.Received(1).ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_String_Should_Handle_Successful_Evaluation() + { + // Arrange + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var expectedResult = new ResolutionDetails(flagKey, "success", ErrorType.None, "STATIC", "variant"); + + provider.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await provider.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await provider.Received(1).ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_Integer_Should_Handle_Successful_Evaluation() + { + // Arrange + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var expectedResult = new ResolutionDetails(flagKey, 42, ErrorType.None, "STATIC", "variant"); + + provider.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await provider.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await provider.Received(1).ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_Double_Should_Handle_Successful_Evaluation() + { + // Arrange + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var expectedResult = new ResolutionDetails(flagKey, 3.14, ErrorType.None, "STATIC", "variant"); + + provider.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await provider.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await provider.Received(1).ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_Structure_Should_Handle_Successful_Evaluation() + { + // Arrange + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider = Substitute.For(); + var successValue = new Value(Structure.Builder().Set("key", "value").Build()); + var expectedResult = new ResolutionDetails(flagKey, successValue, ErrorType.None, "STATIC", "variant"); + + provider.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await provider.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await provider.Received(1).ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken); + } +} From 8f752706166552b3b89366f5b41bb621c2cc4fea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:19:00 +0100 Subject: [PATCH 13/61] feat: add unit tests for MultiProvider to validate functionality and strategy delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/MultiProviderTests.cs | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs new file mode 100644 index 00000000..251c4005 --- /dev/null +++ b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs @@ -0,0 +1,201 @@ +using AutoFixture; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; + +using MultiProviderNamespace = OpenFeature.Extensions.MultiProvider; + +namespace OpenFeature.Tests.Extensions.MultiProvider; + +public class MultiProviderTests : ClearOpenFeatureInstanceFixture +{ + private readonly Fixture _fixture = new(); + + [Fact] + public void MultiProvider_Should_Have_Metadata() + { + // Arrange + var providers = new Dictionary { { "test", new TestProvider() } }; + var strategy = new MultiProviderNamespace.FirstMatchStrategy(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act + var metadata = multiProvider.GetMetadata(); + + // Assert + Assert.NotNull(metadata); + Assert.Equal("OpenFeature MultiProvider", metadata.Name); + } + + [Fact] + public void Constructor_Should_Set_Properties_Correctly() + { + // Arrange + var providers = new Dictionary { { "test", new TestProvider() } }; + var strategy = new MultiProviderNamespace.FirstMatchStrategy(); + + // Act + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Assert + Assert.NotNull(multiProvider); + Assert.NotNull(multiProvider.GetMetadata()); + Assert.Equal("OpenFeature MultiProvider", multiProvider.GetMetadata()?.Name); + } + + [Fact] + public async Task ResolveBooleanValueAsync_Should_Delegate_To_Strategy() + { + // Arrange + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var expectedResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.None, "STATIC", "variant"); + + var provider = Substitute.For(); + var providers = new Dictionary { { "test", provider } }; + var strategy = Substitute.For(); + + strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await strategy.Received(1).EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + } + + [Fact] + public async Task ResolveStringValueAsync_Should_Delegate_To_Strategy() + { + // Arrange + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var expectedResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.None, "STATIC", "variant"); + + var provider = Substitute.For(); + var providers = new Dictionary { { "test", provider } }; + var strategy = Substitute.For(); + + strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act + var result = await multiProvider.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await strategy.Received(1).EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + } + + [Fact] + public async Task ResolveIntegerValueAsync_Should_Delegate_To_Strategy() + { + // Arrange + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var expectedResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.None, "STATIC", "variant"); + + var provider = Substitute.For(); + var providers = new Dictionary { { "test", provider } }; + var strategy = Substitute.For(); + + strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act + var result = await multiProvider.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await strategy.Received(1).EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + } + + [Fact] + public async Task ResolveDoubleValueAsync_Should_Delegate_To_Strategy() + { + // Arrange + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var expectedResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.None, "STATIC", "variant"); + + var provider = Substitute.For(); + var providers = new Dictionary { { "test", provider } }; + var strategy = Substitute.For(); + + strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act + var result = await multiProvider.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await strategy.Received(1).EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + } + + [Fact] + public async Task ResolveStructureValueAsync_Should_Delegate_To_Strategy() + { + // Arrange + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var expectedResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.None, "STATIC", "variant"); + + var provider = Substitute.For(); + var providers = new Dictionary { { "test", provider } }; + var strategy = Substitute.For(); + + strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act + var result = await multiProvider.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + await strategy.Received(1).EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + } + + [Fact] + public void GetProviderHooks_Should_Return_Empty_List() + { + // Arrange + var providers = new Dictionary { { "test", new TestProvider() } }; + var strategy = new MultiProviderNamespace.FirstMatchStrategy(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act + var hooks = multiProvider.GetProviderHooks(); + + // Assert + Assert.NotNull(hooks); + Assert.Empty(hooks); + } +} From aca836ff75132157e9c1fde729e1acf8d3b4454a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:25:33 +0100 Subject: [PATCH 14/61] fix: update GetMetadata method to return non-nullable Metadata type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs | 2 +- .../Extensions/MultiProvider/MultiProviderTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs index c130b0e8..275e05a5 100644 --- a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs @@ -29,7 +29,7 @@ public MultiProvider(Dictionary providers, BaseEvaluati } /// - public override Metadata? GetMetadata() => new("OpenFeature MultiProvider"); + public override Metadata GetMetadata() => new("OpenFeature MultiProvider"); /// public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs index 251c4005..fd321134 100644 --- a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs @@ -40,7 +40,7 @@ public void Constructor_Should_Set_Properties_Correctly() // Assert Assert.NotNull(multiProvider); Assert.NotNull(multiProvider.GetMetadata()); - Assert.Equal("OpenFeature MultiProvider", multiProvider.GetMetadata()?.Name); + Assert.Equal("OpenFeature MultiProvider", multiProvider.GetMetadata().Name); } [Fact] From df11573081c13bb739c1b13da21444c621a36361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:32:08 +0100 Subject: [PATCH 15/61] feat: implement ShutdownAsync method to gracefully shut down all providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Extensions/MultiProvider/MultiProvider.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs index 275e05a5..486f4f4b 100644 --- a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs @@ -50,4 +50,13 @@ public override Task> ResolveStringValueAsync(string f /// public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); + + /// + public override async Task ShutdownAsync(CancellationToken cancellationToken = default) + { + foreach (var provider in this._providers.Values) + { + await provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); + } + } } From 60541028fcd42f63c074aa2ef5b2cc24304402e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:42:06 +0100 Subject: [PATCH 16/61] feat: implement InitializeAsync method to initialize all providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Extensions/MultiProvider/MultiProvider.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs index 486f4f4b..2cb3d0c1 100644 --- a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs @@ -51,6 +51,15 @@ public override Task> ResolveStringValueAsync(string f public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); + /// + public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + foreach (var provider in this._providers.Values) + { + await provider.InitializeAsync(context, cancellationToken).ConfigureAwait(false); + } + } + /// public override async Task ShutdownAsync(CancellationToken cancellationToken = default) { From 9ffd149205199783f0b4cfbb6cbbfcbf7c502960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 28 Jun 2025 14:01:49 +0100 Subject: [PATCH 17/61] Move to Extensions folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../EnumExtensions.cs | 26 +++++++++---------- .../ResolutionDetailsExtensions.cs | 24 ++++++++--------- src/OpenFeature/OpenFeatureClient.cs | 2 +- .../Steps/EvaluationStepDefinitions.cs | 2 +- .../FeatureProviderExceptionTests.cs | 2 +- .../OpenFeatureClientTests.cs | 2 +- 6 files changed, 29 insertions(+), 29 deletions(-) rename src/OpenFeature/{Extension => Extensions}/EnumExtensions.cs (89%) rename src/OpenFeature/{Extension => Extensions}/ResolutionDetailsExtensions.cs (89%) diff --git a/src/OpenFeature/Extension/EnumExtensions.cs b/src/OpenFeature/Extensions/EnumExtensions.cs similarity index 89% rename from src/OpenFeature/Extension/EnumExtensions.cs rename to src/OpenFeature/Extensions/EnumExtensions.cs index 73c39125..9bded22b 100644 --- a/src/OpenFeature/Extension/EnumExtensions.cs +++ b/src/OpenFeature/Extensions/EnumExtensions.cs @@ -1,13 +1,13 @@ -using System.ComponentModel; - -namespace OpenFeature.Extension; - -internal static class EnumExtensions -{ - public static string GetDescription(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); - var attribute = field?.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; - return attribute?.Description ?? value.ToString(); - } -} +using System.ComponentModel; + +namespace OpenFeature.Extensions; + +internal static class EnumExtensions +{ + public static string GetDescription(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + var attribute = field?.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; + return attribute?.Description ?? value.ToString(); + } +} diff --git a/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs b/src/OpenFeature/Extensions/ResolutionDetailsExtensions.cs similarity index 89% rename from src/OpenFeature/Extension/ResolutionDetailsExtensions.cs rename to src/OpenFeature/Extensions/ResolutionDetailsExtensions.cs index cf0d4f4a..1e005db4 100644 --- a/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs +++ b/src/OpenFeature/Extensions/ResolutionDetailsExtensions.cs @@ -1,12 +1,12 @@ -using OpenFeature.Model; - -namespace OpenFeature.Extension; - -internal static class ResolutionDetailsExtensions -{ - public static FlagEvaluationDetails ToFlagEvaluationDetails(this ResolutionDetails details) - { - return new FlagEvaluationDetails(details.FlagKey, details.Value, details.ErrorType, details.Reason, - details.Variant, details.ErrorMessage, details.FlagMetadata); - } -} +using OpenFeature.Model; + +namespace OpenFeature.Extensions; + +internal static class ResolutionDetailsExtensions +{ + public static FlagEvaluationDetails ToFlagEvaluationDetails(this ResolutionDetails details) + { + return new FlagEvaluationDetails(details.FlagKey, details.Value, details.ErrorType, details.Reason, + details.Variant, details.ErrorMessage, details.FlagMetadata); + } +} diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index 02acde07..5c2d24b8 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using OpenFeature.Constant; using OpenFeature.Error; -using OpenFeature.Extension; +using OpenFeature.Extensions; using OpenFeature.Model; namespace OpenFeature; diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs index 27e00359..16878e44 100644 --- a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs @@ -1,6 +1,6 @@ using OpenFeature.Constant; using OpenFeature.E2ETests.Utils; -using OpenFeature.Extension; +using OpenFeature.Extensions; using OpenFeature.Model; namespace OpenFeature.E2ETests.Steps; diff --git a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs index e1645269..61a9acdb 100644 --- a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs +++ b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs @@ -1,6 +1,6 @@ using OpenFeature.Constant; using OpenFeature.Error; -using OpenFeature.Extension; +using OpenFeature.Extensions; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index cbecddc2..713d09f0 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -5,7 +5,7 @@ using NSubstitute.ExceptionExtensions; using OpenFeature.Constant; using OpenFeature.Error; -using OpenFeature.Extension; +using OpenFeature.Extensions; using OpenFeature.Model; using OpenFeature.Tests.Internal; From 8f8127d71d7c611c17196a7f30213d26a4bdac2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 28 Jun 2025 14:13:33 +0100 Subject: [PATCH 18/61] test: add initialization and shutdown tests for MultiProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/MultiProviderTests.cs | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs index fd321134..a15f4f1d 100644 --- a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs @@ -198,4 +198,159 @@ public void GetProviderHooks_Should_Return_Empty_List() Assert.NotNull(hooks); Assert.Empty(hooks); } + + [Fact] + public async Task InitializeAsync_Should_Initialize_All_Providers() + { + // Arrange + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var provider3 = Substitute.For(); + + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 }, + { "provider3", provider3 } + }; + + var strategy = Substitute.For(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act + await multiProvider.InitializeAsync(context, cancellationToken); + + // Assert + await provider1.Received(1).InitializeAsync(context, cancellationToken); + await provider2.Received(1).InitializeAsync(context, cancellationToken); + await provider3.Received(1).InitializeAsync(context, cancellationToken); + } + + [Fact] + public async Task InitializeAsync_Should_Complete_When_No_Providers() + { + // Arrange + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var providers = new Dictionary(); + var strategy = Substitute.For(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act & Assert - Should not throw + await multiProvider.InitializeAsync(context, cancellationToken); + } + + [Fact] + public async Task InitializeAsync_Should_Propagate_Exception_From_Provider() + { + // Arrange + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + var expectedException = new InvalidOperationException("Provider initialization failed"); + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + + provider1.InitializeAsync(context, cancellationToken) + .Returns(Task.CompletedTask); + provider2.When(x => x.InitializeAsync(context, cancellationToken)) + .Do(x => throw expectedException); + + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var strategy = Substitute.For(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act & Assert + var thrownException = await Assert.ThrowsAsync( + () => multiProvider.InitializeAsync(context, cancellationToken)); + + Assert.Equal(expectedException.Message, thrownException.Message); + await provider1.Received(1).InitializeAsync(context, cancellationToken); + await provider2.Received(1).InitializeAsync(context, cancellationToken); + } + + [Fact] + public async Task ShutdownAsync_Should_Shutdown_All_Providers() + { + // Arrange + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var provider3 = Substitute.For(); + + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 }, + { "provider3", provider3 } + }; + + var strategy = Substitute.For(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act + await multiProvider.ShutdownAsync(cancellationToken); + + // Assert + await provider1.Received(1).ShutdownAsync(cancellationToken); + await provider2.Received(1).ShutdownAsync(cancellationToken); + await provider3.Received(1).ShutdownAsync(cancellationToken); + } + + [Fact] + public async Task ShutdownAsync_Should_Complete_When_No_Providers() + { + // Arrange + var cancellationToken = CancellationToken.None; + + var providers = new Dictionary(); + var strategy = Substitute.For(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act & Assert - Should not throw + await multiProvider.ShutdownAsync(cancellationToken); + } + + [Fact] + public async Task ShutdownAsync_Should_Propagate_Exception_From_Provider() + { + // Arrange + var cancellationToken = CancellationToken.None; + var expectedException = new InvalidOperationException("Provider shutdown failed"); + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + + provider1.ShutdownAsync(cancellationToken) + .Returns(Task.CompletedTask); + provider2.When(x => x.ShutdownAsync(cancellationToken)) + .Do(x => throw expectedException); + + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var strategy = Substitute.For(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act & Assert + var thrownException = await Assert.ThrowsAsync( + () => multiProvider.ShutdownAsync(cancellationToken)); + + Assert.Equal(expectedException.Message, thrownException.Message); + await provider1.Received(1).ShutdownAsync(cancellationToken); + await provider2.Received(1).ShutdownAsync(cancellationToken); + } } From dccb2c2e7795258d0de51ee8fe445e6c8cc92367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 28 Jun 2025 14:24:42 +0100 Subject: [PATCH 19/61] fix: enhance ShutdownAsync to handle exceptions from multiple providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Extensions/MultiProvider/MultiProvider.cs | 16 ++- .../MultiProvider/MultiProviderTests.cs | 111 +++++++++++++++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs index 2cb3d0c1..3b13255b 100644 --- a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs @@ -63,9 +63,23 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati /// public override async Task ShutdownAsync(CancellationToken cancellationToken = default) { + var exceptions = new List(); + foreach (var provider in this._providers.Values) { - await provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); + try + { + await provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + if (exceptions.Count > 0) + { + throw new AggregateException("One or more providers failed to shutdown", exceptions); } } } diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs index a15f4f1d..24f997dc 100644 --- a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs @@ -346,10 +346,117 @@ public async Task ShutdownAsync_Should_Propagate_Exception_From_Provider() var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); // Act & Assert - var thrownException = await Assert.ThrowsAsync( + var thrownException = await Assert.ThrowsAsync( () => multiProvider.ShutdownAsync(cancellationToken)); - Assert.Equal(expectedException.Message, thrownException.Message); + Assert.StartsWith("One or more providers failed to shutdown", thrownException.Message); + Assert.Single(thrownException.InnerExceptions); + Assert.Equal(expectedException.Message, thrownException.InnerExceptions.First().Message); + await provider1.Received(1).ShutdownAsync(cancellationToken); + await provider2.Received(1).ShutdownAsync(cancellationToken); + } + + [Fact] + public async Task ShutdownAsync_Should_Continue_All_Providers_Even_If_Some_Fail() + { + // Arrange + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var provider3 = Substitute.For(); + + // Make the second provider throw an exception + provider2.When(x => x.ShutdownAsync(cancellationToken)) + .Do(x => throw new InvalidOperationException("Provider 2 failed")); + + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 }, + { "provider3", provider3 } + }; + + var strategy = Substitute.For(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act & Assert + var thrownException = await Assert.ThrowsAsync( + () => multiProvider.ShutdownAsync(cancellationToken)); + + Assert.StartsWith("One or more providers failed to shutdown", thrownException.Message); + Assert.Single(thrownException.InnerExceptions); + Assert.Equal("Provider 2 failed", thrownException.InnerExceptions.First().Message); + + // All providers should be called even if one fails + await provider1.Received(1).ShutdownAsync(cancellationToken); + await provider2.Received(1).ShutdownAsync(cancellationToken); + await provider3.Received(1).ShutdownAsync(cancellationToken); + } + + [Fact] + public async Task ShutdownAsync_Should_Collect_Multiple_Exceptions() + { + // Arrange + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var provider3 = Substitute.For(); + + // Make multiple providers throw exceptions + provider1.When(x => x.ShutdownAsync(cancellationToken)) + .Do(x => throw new InvalidOperationException("Provider 1 failed")); + provider3.When(x => x.ShutdownAsync(cancellationToken)) + .Do(x => throw new ArgumentException("Provider 3 failed")); + + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 }, + { "provider3", provider3 } + }; + + var strategy = Substitute.For(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act & Assert + var thrownException = await Assert.ThrowsAsync( + () => multiProvider.ShutdownAsync(cancellationToken)); + + Assert.StartsWith("One or more providers failed to shutdown", thrownException.Message); + Assert.Equal(2, thrownException.InnerExceptions.Count); + Assert.Contains(thrownException.InnerExceptions, ex => ex.Message == "Provider 1 failed"); + Assert.Contains(thrownException.InnerExceptions, ex => ex.Message == "Provider 3 failed"); + + // All providers should be called + await provider1.Received(1).ShutdownAsync(cancellationToken); + await provider2.Received(1).ShutdownAsync(cancellationToken); + await provider3.Received(1).ShutdownAsync(cancellationToken); + } + + [Fact] + public async Task ShutdownAsync_Should_Succeed_When_All_Providers_Succeed_After_Exception_Handling_Changes() + { + // Arrange + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var strategy = Substitute.For(); + var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + + // Act - Should not throw + await multiProvider.ShutdownAsync(cancellationToken); + + // Assert await provider1.Received(1).ShutdownAsync(cancellationToken); await provider2.Received(1).ShutdownAsync(cancellationToken); } From 8ce39b23ec1818101329b8787e1d1ed52f3be4a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 28 Jun 2025 15:05:57 +0100 Subject: [PATCH 20/61] feat: implement ComparisonStrategy for evaluating provider values with fallback and mismatch handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/ComparisonStrategy.cs | 91 +++++ .../MultiProvider/ComparisonStrategyTests.cs | 342 ++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs create mode 100644 test/OpenFeature.Tests/Extensions/MultiProvider/ComparisonStrategyTests.cs diff --git a/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs new file mode 100644 index 00000000..e884a85e --- /dev/null +++ b/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs @@ -0,0 +1,91 @@ +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature.Extensions.MultiProvider; + +/// +/// Require that all providers agree on a value. If every provider returns a non-error result, and the values do not agree, +/// the Multi-Provider should return the result from a configurable "fallback" provider. It will also call an optional +/// "onMismatch" callback that can be used to monitor cases where mismatches of evaluation occurred. +/// Otherwise the value of the result will be the result of the first provider in precedence order. +/// +public sealed class ComparisonStrategy : BaseEvaluationStrategy +{ + private readonly string? _fallbackProviderName; + private readonly Action>? _onMismatch; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the provider to use as fallback when values don't match. If null, uses the first provider's result. + /// Optional callback that is called when providers return different values. + public ComparisonStrategy(string? fallbackProviderName = null, Action>? onMismatch = null) + { + _fallbackProviderName = fallbackProviderName; + _onMismatch = onMismatch; + } + + /// + public override async Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) + { + var results = new List<(string providerName, ResolutionDetails result)>(); + + // Evaluate all providers + foreach (var (providerName, provider) in providers) + { + var result = await provider.EvaluateAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false); + results.Add((providerName, result)); + + // If any provider returns an error, return that error immediately + if (result.ErrorType != ErrorType.None) + { + return result; + } + } + + // If no results, return flag not found + if (results.Count == 0) + { + return new ResolutionDetails(key, defaultValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "No providers available"); + } + + // Check if all successful results agree + var successfulResults = results.Where(r => r.result.ErrorType == ErrorType.None).ToList(); + if (successfulResults.Count == 0) + { + return new ResolutionDetails(key, defaultValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "No successful results from any provider"); + } + + var firstResult = successfulResults.First().result; + var allAgree = successfulResults.All(r => EqualityComparer.Default.Equals(r.result.Value, firstResult.Value)); + + if (allAgree) + { + // All providers agree, return the first result + return firstResult; + } + + // Values don't agree, trigger mismatch callback if provided + if (_onMismatch != null) + { + var mismatchValues = successfulResults.ToDictionary( + r => r.providerName, + r => (object?)r.result.Value + ); + _onMismatch(key, firstResult.Value, mismatchValues); + } + + // Return fallback provider result if specified and available + if (!string.IsNullOrEmpty(_fallbackProviderName)) + { + var fallbackResult = successfulResults.FirstOrDefault(r => r.providerName == _fallbackProviderName); + if (fallbackResult.result != null) + { + return fallbackResult.result; + } + } + + // Default to first provider's result + return firstResult; + } +} diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/ComparisonStrategyTests.cs b/test/OpenFeature.Tests/Extensions/MultiProvider/ComparisonStrategyTests.cs new file mode 100644 index 00000000..04b43ff8 --- /dev/null +++ b/test/OpenFeature.Tests/Extensions/MultiProvider/ComparisonStrategyTests.cs @@ -0,0 +1,342 @@ +using AutoFixture; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Extensions.MultiProvider; +using OpenFeature.Model; + +namespace OpenFeature.Tests.Extensions.MultiProvider; + +public class ComparisonStrategyTests : ClearOpenFeatureInstanceFixture +{ + private readonly Fixture _fixture = new(); + + [Fact] + public async Task EvaluateAsync_Should_Return_First_Result_When_All_Agree() + { + // Arrange + var strategy = new ComparisonStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var expectedResult = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Should_Return_First_Result_When_Values_Disagree_And_No_Fallback() + { + // Arrange + var strategy = new ComparisonStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); + var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result1); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result2); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(result1, result); + } + + [Fact] + public async Task EvaluateAsync_Should_Use_Fallback_Provider_When_Values_Disagree() + { + // Arrange + var strategy = new ComparisonStrategy("provider2"); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); + var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result1); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result2); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(result2, result); + } + + [Fact] + public async Task EvaluateAsync_Should_Call_OnMismatch_When_Values_Disagree() + { + // Arrange + string? mismatchFlagKey = null; + object? mismatchFirstValue = null; + Dictionary? mismatchValues = null; + + var strategy = new ComparisonStrategy( + fallbackProviderName: null, + onMismatch: (flagKey, firstValue, values) => + { + mismatchFlagKey = flagKey; + mismatchFirstValue = firstValue; + mismatchValues = values; + }); + + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); + var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result1); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(result2); + + // Act + await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(flagKey, mismatchFlagKey); + Assert.Equal(true, mismatchFirstValue); + Assert.NotNull(mismatchValues); + Assert.Equal(2, mismatchValues.Count); + Assert.Equal(true, mismatchValues["provider1"]); + Assert.Equal(false, mismatchValues["provider2"]); + } + + [Fact] + public async Task EvaluateAsync_Should_Return_Error_When_Provider_Has_Error() + { + // Arrange + var strategy = new ComparisonStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var errorResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.General, "ERROR", errorMessage: "Something went wrong"); + var successResult = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant"); + + provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(errorResult); + provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(successResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(errorResult, result); + } + + [Fact] + public async Task EvaluateAsync_Should_Return_FLAG_NOT_FOUND_When_No_Providers() + { + // Arrange + var strategy = new ComparisonStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var providers = new Dictionary(); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); + Assert.Equal("No providers available", result.ErrorMessage); + } + + [Fact] + public async Task EvaluateAsync_String_Should_Work_Correctly() + { + // Arrange + var strategy = new ComparisonStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var expectedResult = new ResolutionDetails(flagKey, "test-value", ErrorType.None, "STATIC", "variant"); + + provider1.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + provider2.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Integer_Should_Work_Correctly() + { + // Arrange + var strategy = new ComparisonStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var expectedResult = new ResolutionDetails(flagKey, 42, ErrorType.None, "STATIC", "variant"); + + provider1.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + provider2.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Double_Should_Work_Correctly() + { + // Arrange + var strategy = new ComparisonStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var expectedResult = new ResolutionDetails(flagKey, 3.14, ErrorType.None, "STATIC", "variant"); + + provider1.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + provider2.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public async Task EvaluateAsync_Structure_Should_Work_Correctly() + { + // Arrange + var strategy = new ComparisonStrategy(); + var flagKey = this._fixture.Create(); + var defaultValue = this._fixture.Create(); + var context = EvaluationContext.Empty; + var cancellationToken = CancellationToken.None; + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var providers = new Dictionary + { + { "provider1", provider1 }, + { "provider2", provider2 } + }; + + var structureValue = new Value(new Structure(new Dictionary { { "key", new Value("value") } })); + var expectedResult = new ResolutionDetails(flagKey, structureValue, ErrorType.None, "STATIC", "variant"); + + provider1.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + provider2.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken) + .Returns(expectedResult); + + // Act + var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); + + // Assert + Assert.Equal(expectedResult, result); + } +} From 183067629412155df25397ad1015778f6089f137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 28 Jun 2025 15:12:07 +0100 Subject: [PATCH 21/61] feat: add constructor to MultiProvider for default evaluation strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Extensions/MultiProvider/MultiProvider.cs | 8 +++ .../MultiProvider/MultiProviderTests.cs | 71 +++++++++---------- 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs index 3b13255b..d3cf4de6 100644 --- a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs @@ -28,6 +28,14 @@ public MultiProvider(Dictionary providers, BaseEvaluati this._evaluationStrategy = evaluationStrategy; } + /// + /// Initializes a new instance of the class with the specified providers. The default evaluation strategy is . + /// + /// A dictionary containing the feature providers keyed by their identifiers. + public MultiProvider(Dictionary providers) : this(providers, new FirstMatchStrategy()) + { + } + /// public override Metadata GetMetadata() => new("OpenFeature MultiProvider"); diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs index 24f997dc..0306a6f4 100644 --- a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs @@ -1,10 +1,9 @@ using AutoFixture; using NSubstitute; using OpenFeature.Constant; +using OpenFeature.Extensions.MultiProvider; using OpenFeature.Model; -using MultiProviderNamespace = OpenFeature.Extensions.MultiProvider; - namespace OpenFeature.Tests.Extensions.MultiProvider; public class MultiProviderTests : ClearOpenFeatureInstanceFixture @@ -16,8 +15,8 @@ public void MultiProvider_Should_Have_Metadata() { // Arrange var providers = new Dictionary { { "test", new TestProvider() } }; - var strategy = new MultiProviderNamespace.FirstMatchStrategy(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = new FirstMatchStrategy(); + var multiProvider = new MultiProvider(providers, strategy); // Act var metadata = multiProvider.GetMetadata(); @@ -32,10 +31,10 @@ public void Constructor_Should_Set_Properties_Correctly() { // Arrange var providers = new Dictionary { { "test", new TestProvider() } }; - var strategy = new MultiProviderNamespace.FirstMatchStrategy(); + var strategy = new FirstMatchStrategy(); // Act - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var multiProvider = new MultiProvider(providers, strategy); // Assert Assert.NotNull(multiProvider); @@ -56,12 +55,12 @@ public async Task ResolveBooleanValueAsync_Should_Delegate_To_Strategy() var provider = Substitute.For(); var providers = new Dictionary { { "test", provider } }; - var strategy = Substitute.For(); + var strategy = Substitute.For(); strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) .Returns(expectedResult); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var multiProvider = new MultiProvider(providers, strategy); // Act var result = await multiProvider.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); @@ -84,12 +83,12 @@ public async Task ResolveStringValueAsync_Should_Delegate_To_Strategy() var provider = Substitute.For(); var providers = new Dictionary { { "test", provider } }; - var strategy = Substitute.For(); + var strategy = Substitute.For(); strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) .Returns(expectedResult); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var multiProvider = new MultiProvider(providers, strategy); // Act var result = await multiProvider.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken); @@ -112,12 +111,12 @@ public async Task ResolveIntegerValueAsync_Should_Delegate_To_Strategy() var provider = Substitute.For(); var providers = new Dictionary { { "test", provider } }; - var strategy = Substitute.For(); + var strategy = Substitute.For(); strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) .Returns(expectedResult); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var multiProvider = new MultiProvider(providers, strategy); // Act var result = await multiProvider.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken); @@ -140,12 +139,12 @@ public async Task ResolveDoubleValueAsync_Should_Delegate_To_Strategy() var provider = Substitute.For(); var providers = new Dictionary { { "test", provider } }; - var strategy = Substitute.For(); + var strategy = Substitute.For(); strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) .Returns(expectedResult); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var multiProvider = new MultiProvider(providers, strategy); // Act var result = await multiProvider.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken); @@ -168,12 +167,12 @@ public async Task ResolveStructureValueAsync_Should_Delegate_To_Strategy() var provider = Substitute.For(); var providers = new Dictionary { { "test", provider } }; - var strategy = Substitute.For(); + var strategy = Substitute.For(); strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) .Returns(expectedResult); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var multiProvider = new MultiProvider(providers, strategy); // Act var result = await multiProvider.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken); @@ -188,8 +187,8 @@ public void GetProviderHooks_Should_Return_Empty_List() { // Arrange var providers = new Dictionary { { "test", new TestProvider() } }; - var strategy = new MultiProviderNamespace.FirstMatchStrategy(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = new FirstMatchStrategy(); + var multiProvider = new MultiProvider(providers, strategy); // Act var hooks = multiProvider.GetProviderHooks(); @@ -217,8 +216,8 @@ public async Task InitializeAsync_Should_Initialize_All_Providers() { "provider3", provider3 } }; - var strategy = Substitute.For(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = Substitute.For(); + var multiProvider = new MultiProvider(providers, strategy); // Act await multiProvider.InitializeAsync(context, cancellationToken); @@ -237,8 +236,8 @@ public async Task InitializeAsync_Should_Complete_When_No_Providers() var cancellationToken = CancellationToken.None; var providers = new Dictionary(); - var strategy = Substitute.For(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = Substitute.For(); + var multiProvider = new MultiProvider(providers, strategy); // Act & Assert - Should not throw await multiProvider.InitializeAsync(context, cancellationToken); @@ -266,8 +265,8 @@ public async Task InitializeAsync_Should_Propagate_Exception_From_Provider() { "provider2", provider2 } }; - var strategy = Substitute.For(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = Substitute.For(); + var multiProvider = new MultiProvider(providers, strategy); // Act & Assert var thrownException = await Assert.ThrowsAsync( @@ -295,8 +294,8 @@ public async Task ShutdownAsync_Should_Shutdown_All_Providers() { "provider3", provider3 } }; - var strategy = Substitute.For(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = Substitute.For(); + var multiProvider = new MultiProvider(providers, strategy); // Act await multiProvider.ShutdownAsync(cancellationToken); @@ -314,8 +313,8 @@ public async Task ShutdownAsync_Should_Complete_When_No_Providers() var cancellationToken = CancellationToken.None; var providers = new Dictionary(); - var strategy = Substitute.For(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = Substitute.For(); + var multiProvider = new MultiProvider(providers, strategy); // Act & Assert - Should not throw await multiProvider.ShutdownAsync(cancellationToken); @@ -342,8 +341,8 @@ public async Task ShutdownAsync_Should_Propagate_Exception_From_Provider() { "provider2", provider2 } }; - var strategy = Substitute.For(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = Substitute.For(); + var multiProvider = new MultiProvider(providers, strategy); // Act & Assert var thrownException = await Assert.ThrowsAsync( @@ -377,8 +376,8 @@ public async Task ShutdownAsync_Should_Continue_All_Providers_Even_If_Some_Fail( { "provider3", provider3 } }; - var strategy = Substitute.For(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = Substitute.For(); + var multiProvider = new MultiProvider(providers, strategy); // Act & Assert var thrownException = await Assert.ThrowsAsync( @@ -417,8 +416,8 @@ public async Task ShutdownAsync_Should_Collect_Multiple_Exceptions() { "provider3", provider3 } }; - var strategy = Substitute.For(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = Substitute.For(); + var multiProvider = new MultiProvider(providers, strategy); // Act & Assert var thrownException = await Assert.ThrowsAsync( @@ -450,8 +449,8 @@ public async Task ShutdownAsync_Should_Succeed_When_All_Providers_Succeed_After_ { "provider2", provider2 } }; - var strategy = Substitute.For(); - var multiProvider = new MultiProviderNamespace.MultiProvider(providers, strategy); + var strategy = Substitute.For(); + var multiProvider = new MultiProvider(providers, strategy); // Act - Should not throw await multiProvider.ShutdownAsync(cancellationToken); From b9bd51e1e4e5e8386181f54c840dda0f16afea9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 28 Jun 2025 15:20:24 +0100 Subject: [PATCH 22/61] refactor: update ComparisonStrategy and MultiProviderTests for improved clarity and consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/ComparisonStrategy.cs | 19 +++++----- .../MultiProvider/MultiProviderTests.cs | 36 ++++++++++--------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs b/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs index e884a85e..cb790193 100644 --- a/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs +++ b/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs @@ -7,7 +7,7 @@ namespace OpenFeature.Extensions.MultiProvider; /// Require that all providers agree on a value. If every provider returns a non-error result, and the values do not agree, /// the Multi-Provider should return the result from a configurable "fallback" provider. It will also call an optional /// "onMismatch" callback that can be used to monitor cases where mismatches of evaluation occurred. -/// Otherwise the value of the result will be the result of the first provider in precedence order. +/// Otherwise, the value of the result will be the result of the first provider in precedence order. /// public sealed class ComparisonStrategy : BaseEvaluationStrategy { @@ -21,8 +21,8 @@ public sealed class ComparisonStrategy : BaseEvaluationStrategy /// Optional callback that is called when providers return different values. public ComparisonStrategy(string? fallbackProviderName = null, Action>? onMismatch = null) { - _fallbackProviderName = fallbackProviderName; - _onMismatch = onMismatch; + this._fallbackProviderName = fallbackProviderName; + this._onMismatch = onMismatch; } /// @@ -31,8 +31,11 @@ public override async Task> EvaluateAsync(Dictionary result)>(); // Evaluate all providers - foreach (var (providerName, provider) in providers) + foreach (var kvp in providers) { + var providerName = kvp.Key; + var provider = kvp.Value; + var result = await provider.EvaluateAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false); results.Add((providerName, result)); @@ -66,19 +69,19 @@ public override async Task> EvaluateAsync(Dictionary r.providerName, r => (object?)r.result.Value ); - _onMismatch(key, firstResult.Value, mismatchValues); + this._onMismatch(key, firstResult.Value, mismatchValues); } // Return fallback provider result if specified and available - if (!string.IsNullOrEmpty(_fallbackProviderName)) + if (!string.IsNullOrEmpty(this._fallbackProviderName)) { - var fallbackResult = successfulResults.FirstOrDefault(r => r.providerName == _fallbackProviderName); + var fallbackResult = successfulResults.FirstOrDefault(r => r.providerName == this._fallbackProviderName); if (fallbackResult.result != null) { return fallbackResult.result; diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs index 0306a6f4..cd5d99c0 100644 --- a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs @@ -4,6 +4,8 @@ using OpenFeature.Extensions.MultiProvider; using OpenFeature.Model; +using Provider = OpenFeature.Extensions.MultiProvider; + namespace OpenFeature.Tests.Extensions.MultiProvider; public class MultiProviderTests : ClearOpenFeatureInstanceFixture @@ -16,7 +18,7 @@ public void MultiProvider_Should_Have_Metadata() // Arrange var providers = new Dictionary { { "test", new TestProvider() } }; var strategy = new FirstMatchStrategy(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act var metadata = multiProvider.GetMetadata(); @@ -34,7 +36,7 @@ public void Constructor_Should_Set_Properties_Correctly() var strategy = new FirstMatchStrategy(); // Act - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Assert Assert.NotNull(multiProvider); @@ -60,7 +62,7 @@ public async Task ResolveBooleanValueAsync_Should_Delegate_To_Strategy() strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) .Returns(expectedResult); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act var result = await multiProvider.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); @@ -88,7 +90,7 @@ public async Task ResolveStringValueAsync_Should_Delegate_To_Strategy() strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) .Returns(expectedResult); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act var result = await multiProvider.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken); @@ -116,7 +118,7 @@ public async Task ResolveIntegerValueAsync_Should_Delegate_To_Strategy() strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) .Returns(expectedResult); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act var result = await multiProvider.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken); @@ -144,7 +146,7 @@ public async Task ResolveDoubleValueAsync_Should_Delegate_To_Strategy() strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) .Returns(expectedResult); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act var result = await multiProvider.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken); @@ -172,7 +174,7 @@ public async Task ResolveStructureValueAsync_Should_Delegate_To_Strategy() strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) .Returns(expectedResult); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act var result = await multiProvider.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken); @@ -188,7 +190,7 @@ public void GetProviderHooks_Should_Return_Empty_List() // Arrange var providers = new Dictionary { { "test", new TestProvider() } }; var strategy = new FirstMatchStrategy(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act var hooks = multiProvider.GetProviderHooks(); @@ -217,7 +219,7 @@ public async Task InitializeAsync_Should_Initialize_All_Providers() }; var strategy = Substitute.For(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act await multiProvider.InitializeAsync(context, cancellationToken); @@ -237,7 +239,7 @@ public async Task InitializeAsync_Should_Complete_When_No_Providers() var providers = new Dictionary(); var strategy = Substitute.For(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act & Assert - Should not throw await multiProvider.InitializeAsync(context, cancellationToken); @@ -266,7 +268,7 @@ public async Task InitializeAsync_Should_Propagate_Exception_From_Provider() }; var strategy = Substitute.For(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act & Assert var thrownException = await Assert.ThrowsAsync( @@ -295,7 +297,7 @@ public async Task ShutdownAsync_Should_Shutdown_All_Providers() }; var strategy = Substitute.For(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act await multiProvider.ShutdownAsync(cancellationToken); @@ -314,7 +316,7 @@ public async Task ShutdownAsync_Should_Complete_When_No_Providers() var providers = new Dictionary(); var strategy = Substitute.For(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act & Assert - Should not throw await multiProvider.ShutdownAsync(cancellationToken); @@ -342,7 +344,7 @@ public async Task ShutdownAsync_Should_Propagate_Exception_From_Provider() }; var strategy = Substitute.For(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act & Assert var thrownException = await Assert.ThrowsAsync( @@ -377,7 +379,7 @@ public async Task ShutdownAsync_Should_Continue_All_Providers_Even_If_Some_Fail( }; var strategy = Substitute.For(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act & Assert var thrownException = await Assert.ThrowsAsync( @@ -417,7 +419,7 @@ public async Task ShutdownAsync_Should_Collect_Multiple_Exceptions() }; var strategy = Substitute.For(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act & Assert var thrownException = await Assert.ThrowsAsync( @@ -450,7 +452,7 @@ public async Task ShutdownAsync_Should_Succeed_When_All_Providers_Succeed_After_ }; var strategy = Substitute.For(); - var multiProvider = new MultiProvider(providers, strategy); + var multiProvider = new Provider.MultiProvider(providers, strategy); // Act - Should not throw await multiProvider.ShutdownAsync(cancellationToken); From 1149ac7eb10ebe40eabd2b5df5e5c6a2b48cab2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 28 Jun 2025 15:23:19 +0100 Subject: [PATCH 23/61] refactor: rename namespaces from OpenFeature.Extensions.MultiProvider to OpenFeature.Providers.MultiProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/BaseEvaluationStrategy.cs | 2 +- .../MultiProvider/ComparisonStrategy.cs | 2 +- .../MultiProvider/FirstMatchStrategy.cs | 2 +- .../MultiProvider/FirstSuccessfulStrategy.cs | 2 +- .../MultiProvider/MultiProvider.cs | 2 +- .../MultiProvider/ProviderExtensions.cs | 2 +- .../MultiProvider/ComparisonStrategyTests.cs | 4 ++-- .../MultiProvider/FirstMatchStrategyTests.cs | 4 ++-- .../MultiProvider/FirstSuccessfulStrategyTests.cs | 4 ++-- .../MultiProvider/MultiProviderTests.cs | 7 +++---- .../MultiProvider/ProviderExtensionsTests.cs | 4 ++-- 11 files changed, 17 insertions(+), 18 deletions(-) rename src/OpenFeature/{Extensions => Providers}/MultiProvider/BaseEvaluationStrategy.cs (97%) rename src/OpenFeature/{Extensions => Providers}/MultiProvider/ComparisonStrategy.cs (98%) rename src/OpenFeature/{Extensions => Providers}/MultiProvider/FirstMatchStrategy.cs (96%) rename src/OpenFeature/{Extensions => Providers}/MultiProvider/FirstSuccessfulStrategy.cs (96%) rename src/OpenFeature/{Extensions => Providers}/MultiProvider/MultiProvider.cs (98%) rename src/OpenFeature/{Extensions => Providers}/MultiProvider/ProviderExtensions.cs (97%) rename test/OpenFeature.Tests/{Extensions => Providers}/MultiProvider/ComparisonStrategyTests.cs (99%) rename test/OpenFeature.Tests/{Extensions => Providers}/MultiProvider/FirstMatchStrategyTests.cs (99%) rename test/OpenFeature.Tests/{Extensions => Providers}/MultiProvider/FirstSuccessfulStrategyTests.cs (99%) rename test/OpenFeature.Tests/{Extensions => Providers}/MultiProvider/MultiProviderTests.cs (99%) rename test/OpenFeature.Tests/{Extensions => Providers}/MultiProvider/ProviderExtensionsTests.cs (98%) diff --git a/src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs b/src/OpenFeature/Providers/MultiProvider/BaseEvaluationStrategy.cs similarity index 97% rename from src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs rename to src/OpenFeature/Providers/MultiProvider/BaseEvaluationStrategy.cs index 819acec3..545261a1 100644 --- a/src/OpenFeature/Extensions/MultiProvider/BaseEvaluationStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/BaseEvaluationStrategy.cs @@ -1,6 +1,6 @@ using OpenFeature.Model; -namespace OpenFeature.Extensions.MultiProvider; +namespace OpenFeature.Providers.MultiProvider; /// /// Provides a base class for implementing evaluation strategies that determine how feature flags are evaluated across multiple feature providers. diff --git a/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs b/src/OpenFeature/Providers/MultiProvider/ComparisonStrategy.cs similarity index 98% rename from src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs rename to src/OpenFeature/Providers/MultiProvider/ComparisonStrategy.cs index cb790193..5ff23135 100644 --- a/src/OpenFeature/Extensions/MultiProvider/ComparisonStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/ComparisonStrategy.cs @@ -1,7 +1,7 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature.Extensions.MultiProvider; +namespace OpenFeature.Providers.MultiProvider; /// /// Require that all providers agree on a value. If every provider returns a non-error result, and the values do not agree, diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs b/src/OpenFeature/Providers/MultiProvider/FirstMatchStrategy.cs similarity index 96% rename from src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs rename to src/OpenFeature/Providers/MultiProvider/FirstMatchStrategy.cs index 8afb5f09..a55b2280 100644 --- a/src/OpenFeature/Extensions/MultiProvider/FirstMatchStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/FirstMatchStrategy.cs @@ -1,7 +1,7 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature.Extensions.MultiProvider; +namespace OpenFeature.Providers.MultiProvider; /// /// Return the first result returned by a provider. Skip providers that indicate they had no value due to FLAG_NOT_FOUND. diff --git a/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs b/src/OpenFeature/Providers/MultiProvider/FirstSuccessfulStrategy.cs similarity index 96% rename from src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs rename to src/OpenFeature/Providers/MultiProvider/FirstSuccessfulStrategy.cs index 635ed059..482c98f4 100644 --- a/src/OpenFeature/Extensions/MultiProvider/FirstSuccessfulStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/FirstSuccessfulStrategy.cs @@ -1,7 +1,7 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature.Extensions.MultiProvider; +namespace OpenFeature.Providers.MultiProvider; /// /// Return the first result returned by a provider. Errors from evaluated providers do not halt execution. diff --git a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs similarity index 98% rename from src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs rename to src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index d3cf4de6..9bcec19d 100644 --- a/src/OpenFeature/Extensions/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -1,6 +1,6 @@ using OpenFeature.Model; -namespace OpenFeature.Extensions.MultiProvider; +namespace OpenFeature.Providers.MultiProvider; /// /// A feature provider that enables the use of multiple underlying providers, allowing different providers diff --git a/src/OpenFeature/Extensions/MultiProvider/ProviderExtensions.cs b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs similarity index 97% rename from src/OpenFeature/Extensions/MultiProvider/ProviderExtensions.cs rename to src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs index 8dfab356..7c68a9be 100644 --- a/src/OpenFeature/Extensions/MultiProvider/ProviderExtensions.cs +++ b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs @@ -1,7 +1,7 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature.Extensions.MultiProvider; +namespace OpenFeature.Providers.MultiProvider; internal static class ProviderExtensions { diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/ComparisonStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/ComparisonStrategyTests.cs similarity index 99% rename from test/OpenFeature.Tests/Extensions/MultiProvider/ComparisonStrategyTests.cs rename to test/OpenFeature.Tests/Providers/MultiProvider/ComparisonStrategyTests.cs index 04b43ff8..fd542a00 100644 --- a/test/OpenFeature.Tests/Extensions/MultiProvider/ComparisonStrategyTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/ComparisonStrategyTests.cs @@ -1,10 +1,10 @@ using AutoFixture; using NSubstitute; using OpenFeature.Constant; -using OpenFeature.Extensions.MultiProvider; using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider; -namespace OpenFeature.Tests.Extensions.MultiProvider; +namespace OpenFeature.Tests.Providers.MultiProvider; public class ComparisonStrategyTests : ClearOpenFeatureInstanceFixture { diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/FirstMatchStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/FirstMatchStrategyTests.cs similarity index 99% rename from test/OpenFeature.Tests/Extensions/MultiProvider/FirstMatchStrategyTests.cs rename to test/OpenFeature.Tests/Providers/MultiProvider/FirstMatchStrategyTests.cs index 9c570de3..691a76d7 100644 --- a/test/OpenFeature.Tests/Extensions/MultiProvider/FirstMatchStrategyTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/FirstMatchStrategyTests.cs @@ -1,10 +1,10 @@ using AutoFixture; using NSubstitute; using OpenFeature.Constant; -using OpenFeature.Extensions.MultiProvider; using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider; -namespace OpenFeature.Tests.Extensions.MultiProvider; +namespace OpenFeature.Tests.Providers.MultiProvider; public class FirstMatchStrategyTests { diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/FirstSuccessfulStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/FirstSuccessfulStrategyTests.cs similarity index 99% rename from test/OpenFeature.Tests/Extensions/MultiProvider/FirstSuccessfulStrategyTests.cs rename to test/OpenFeature.Tests/Providers/MultiProvider/FirstSuccessfulStrategyTests.cs index b273c90e..d2bd1444 100644 --- a/test/OpenFeature.Tests/Extensions/MultiProvider/FirstSuccessfulStrategyTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/FirstSuccessfulStrategyTests.cs @@ -1,10 +1,10 @@ using AutoFixture; using NSubstitute; using OpenFeature.Constant; -using OpenFeature.Extensions.MultiProvider; using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider; -namespace OpenFeature.Tests.Extensions.MultiProvider; +namespace OpenFeature.Tests.Providers.MultiProvider; public class FirstSuccessfulStrategyTests { diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs similarity index 99% rename from test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs rename to test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs index cd5d99c0..ed4be8d5 100644 --- a/test/OpenFeature.Tests/Extensions/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs @@ -1,12 +1,11 @@ using AutoFixture; using NSubstitute; using OpenFeature.Constant; -using OpenFeature.Extensions.MultiProvider; using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider; +using Provider = OpenFeature.Providers.MultiProvider; -using Provider = OpenFeature.Extensions.MultiProvider; - -namespace OpenFeature.Tests.Extensions.MultiProvider; +namespace OpenFeature.Tests.Providers.MultiProvider; public class MultiProviderTests : ClearOpenFeatureInstanceFixture { diff --git a/test/OpenFeature.Tests/Extensions/MultiProvider/ProviderExtensionsTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs similarity index 98% rename from test/OpenFeature.Tests/Extensions/MultiProvider/ProviderExtensionsTests.cs rename to test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs index 471d1d78..a1caf62d 100644 --- a/test/OpenFeature.Tests/Extensions/MultiProvider/ProviderExtensionsTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs @@ -1,10 +1,10 @@ using AutoFixture; using NSubstitute; using OpenFeature.Constant; -using OpenFeature.Extensions.MultiProvider; using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider; -namespace OpenFeature.Tests.Extensions.MultiProvider; +namespace OpenFeature.Tests.Providers.MultiProvider; public class ProviderExtensionsTests : ClearOpenFeatureInstanceFixture { From 35b07b3b7aa8e71456946c405cd957da29dbcae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 28 Jun 2025 17:55:40 +0100 Subject: [PATCH 24/61] Removed old files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/BaseEvaluationStrategy.cs | 26 - .../MultiProvider/ComparisonStrategy.cs | 94 ---- .../MultiProvider/FirstMatchStrategy.cs | 30 -- .../MultiProvider/FirstSuccessfulStrategy.cs | 29 -- .../MultiProvider/ComparisonStrategyTests.cs | 342 ------------- .../MultiProvider/FirstMatchStrategyTests.cs | 302 ------------ .../FirstSuccessfulStrategyTests.cs | 308 ------------ .../MultiProvider/MultiProviderTests.cs | 463 ------------------ .../MultiProvider/ProviderExtensionsTests.cs | 128 ----- 9 files changed, 1722 deletions(-) delete mode 100644 src/OpenFeature/Providers/MultiProvider/BaseEvaluationStrategy.cs delete mode 100644 src/OpenFeature/Providers/MultiProvider/ComparisonStrategy.cs delete mode 100644 src/OpenFeature/Providers/MultiProvider/FirstMatchStrategy.cs delete mode 100644 src/OpenFeature/Providers/MultiProvider/FirstSuccessfulStrategy.cs delete mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/ComparisonStrategyTests.cs delete mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/FirstMatchStrategyTests.cs delete mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/FirstSuccessfulStrategyTests.cs delete mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs delete mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs diff --git a/src/OpenFeature/Providers/MultiProvider/BaseEvaluationStrategy.cs b/src/OpenFeature/Providers/MultiProvider/BaseEvaluationStrategy.cs deleted file mode 100644 index 545261a1..00000000 --- a/src/OpenFeature/Providers/MultiProvider/BaseEvaluationStrategy.cs +++ /dev/null @@ -1,26 +0,0 @@ -using OpenFeature.Model; - -namespace OpenFeature.Providers.MultiProvider; - -/// -/// Provides a base class for implementing evaluation strategies that determine how feature flags are evaluated across multiple feature providers. -/// -/// -/// This abstract class serves as the foundation for creating custom evaluation strategies that can handle feature flag resolution -/// across multiple providers. Implementations define the specific logic for how providers are selected, prioritized, or combined -/// when evaluating feature flags. -/// -public abstract class BaseEvaluationStrategy -{ - /// - /// Evaluates a feature flag across multiple providers using the strategy's specific logic. - /// - /// The type of the feature flag value to be evaluated. - /// A dictionary of feature providers keyed by their identifier. - /// The feature flag key to evaluate. - /// The default value to return if evaluation fails or the flag is not found. - /// Optional context information for the evaluation. - /// A cancellation token to cancel the operation if needed. - /// A task that represents the asynchronous evaluation operation, containing the resolution details with the evaluated value and metadata. - public abstract Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default); -} diff --git a/src/OpenFeature/Providers/MultiProvider/ComparisonStrategy.cs b/src/OpenFeature/Providers/MultiProvider/ComparisonStrategy.cs deleted file mode 100644 index 5ff23135..00000000 --- a/src/OpenFeature/Providers/MultiProvider/ComparisonStrategy.cs +++ /dev/null @@ -1,94 +0,0 @@ -using OpenFeature.Constant; -using OpenFeature.Model; - -namespace OpenFeature.Providers.MultiProvider; - -/// -/// Require that all providers agree on a value. If every provider returns a non-error result, and the values do not agree, -/// the Multi-Provider should return the result from a configurable "fallback" provider. It will also call an optional -/// "onMismatch" callback that can be used to monitor cases where mismatches of evaluation occurred. -/// Otherwise, the value of the result will be the result of the first provider in precedence order. -/// -public sealed class ComparisonStrategy : BaseEvaluationStrategy -{ - private readonly string? _fallbackProviderName; - private readonly Action>? _onMismatch; - - /// - /// Initializes a new instance of the class. - /// - /// The name of the provider to use as fallback when values don't match. If null, uses the first provider's result. - /// Optional callback that is called when providers return different values. - public ComparisonStrategy(string? fallbackProviderName = null, Action>? onMismatch = null) - { - this._fallbackProviderName = fallbackProviderName; - this._onMismatch = onMismatch; - } - - /// - public override async Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) - { - var results = new List<(string providerName, ResolutionDetails result)>(); - - // Evaluate all providers - foreach (var kvp in providers) - { - var providerName = kvp.Key; - var provider = kvp.Value; - - var result = await provider.EvaluateAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false); - results.Add((providerName, result)); - - // If any provider returns an error, return that error immediately - if (result.ErrorType != ErrorType.None) - { - return result; - } - } - - // If no results, return flag not found - if (results.Count == 0) - { - return new ResolutionDetails(key, defaultValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "No providers available"); - } - - // Check if all successful results agree - var successfulResults = results.Where(r => r.result.ErrorType == ErrorType.None).ToList(); - if (successfulResults.Count == 0) - { - return new ResolutionDetails(key, defaultValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "No successful results from any provider"); - } - - var firstResult = successfulResults.First().result; - var allAgree = successfulResults.All(r => EqualityComparer.Default.Equals(r.result.Value, firstResult.Value)); - - if (allAgree) - { - // All providers agree, return the first result - return firstResult; - } - - // Values don't agree, trigger mismatch callback if provided - if (this._onMismatch != null) - { - var mismatchValues = successfulResults.ToDictionary( - r => r.providerName, - r => (object?)r.result.Value - ); - this._onMismatch(key, firstResult.Value, mismatchValues); - } - - // Return fallback provider result if specified and available - if (!string.IsNullOrEmpty(this._fallbackProviderName)) - { - var fallbackResult = successfulResults.FirstOrDefault(r => r.providerName == this._fallbackProviderName); - if (fallbackResult.result != null) - { - return fallbackResult.result; - } - } - - // Default to first provider's result - return firstResult; - } -} diff --git a/src/OpenFeature/Providers/MultiProvider/FirstMatchStrategy.cs b/src/OpenFeature/Providers/MultiProvider/FirstMatchStrategy.cs deleted file mode 100644 index a55b2280..00000000 --- a/src/OpenFeature/Providers/MultiProvider/FirstMatchStrategy.cs +++ /dev/null @@ -1,30 +0,0 @@ -using OpenFeature.Constant; -using OpenFeature.Model; - -namespace OpenFeature.Providers.MultiProvider; - -/// -/// Return the first result returned by a provider. Skip providers that indicate they had no value due to FLAG_NOT_FOUND. -/// In all other cases, use the value returned by the provider. If any provider returns an error result other than FLAG_NOT_FOUND, -/// the whole evaluation should error and "bubble up" the individual provider's error in the result. -/// As soon as a value is returned by a provider, the rest of the operation should short-circuit and not call the rest of the providers. -/// -public sealed class FirstMatchStrategy : BaseEvaluationStrategy -{ - /// - public override async Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) - { - foreach (var provider in providers.Values) - { - var result = await provider.EvaluateAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false); - - // If the result is not FLAG_NOT_FOUND and is not an error, return it - if (result.ErrorType is ErrorType.None or not ErrorType.FlagNotFound) - { - return result; - } - } - - return new ResolutionDetails(key, defaultValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found in any provider"); - } -} diff --git a/src/OpenFeature/Providers/MultiProvider/FirstSuccessfulStrategy.cs b/src/OpenFeature/Providers/MultiProvider/FirstSuccessfulStrategy.cs deleted file mode 100644 index 482c98f4..00000000 --- a/src/OpenFeature/Providers/MultiProvider/FirstSuccessfulStrategy.cs +++ /dev/null @@ -1,29 +0,0 @@ -using OpenFeature.Constant; -using OpenFeature.Model; - -namespace OpenFeature.Providers.MultiProvider; - -/// -/// Return the first result returned by a provider. Errors from evaluated providers do not halt execution. -/// Instead, it will return the first successful result from a provider. -/// If no provider successfully responds, it will throw an error result. -/// -public sealed class FirstSuccessfulStrategy : BaseEvaluationStrategy -{ - /// - public override async Task> EvaluateAsync(Dictionary providers, string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) - { - foreach (var provider in providers.Values) - { - var result = await provider.EvaluateAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false); - - // If the result is not an error, return it - if (result.ErrorType is ErrorType.None) - { - return result; - } - } - - return new ResolutionDetails(key, defaultValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found in any provider"); - } -} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/ComparisonStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/ComparisonStrategyTests.cs deleted file mode 100644 index fd542a00..00000000 --- a/test/OpenFeature.Tests/Providers/MultiProvider/ComparisonStrategyTests.cs +++ /dev/null @@ -1,342 +0,0 @@ -using AutoFixture; -using NSubstitute; -using OpenFeature.Constant; -using OpenFeature.Model; -using OpenFeature.Providers.MultiProvider; - -namespace OpenFeature.Tests.Providers.MultiProvider; - -public class ComparisonStrategyTests : ClearOpenFeatureInstanceFixture -{ - private readonly Fixture _fixture = new(); - - [Fact] - public async Task EvaluateAsync_Should_Return_First_Result_When_All_Agree() - { - // Arrange - var strategy = new ComparisonStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var expectedResult = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Should_Return_First_Result_When_Values_Disagree_And_No_Fallback() - { - // Arrange - var strategy = new ComparisonStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); - var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result1); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result2); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(result1, result); - } - - [Fact] - public async Task EvaluateAsync_Should_Use_Fallback_Provider_When_Values_Disagree() - { - // Arrange - var strategy = new ComparisonStrategy("provider2"); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); - var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result1); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result2); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(result2, result); - } - - [Fact] - public async Task EvaluateAsync_Should_Call_OnMismatch_When_Values_Disagree() - { - // Arrange - string? mismatchFlagKey = null; - object? mismatchFirstValue = null; - Dictionary? mismatchValues = null; - - var strategy = new ComparisonStrategy( - fallbackProviderName: null, - onMismatch: (flagKey, firstValue, values) => - { - mismatchFlagKey = flagKey; - mismatchFirstValue = firstValue; - mismatchValues = values; - }); - - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); - var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result1); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result2); - - // Act - await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(flagKey, mismatchFlagKey); - Assert.Equal(true, mismatchFirstValue); - Assert.NotNull(mismatchValues); - Assert.Equal(2, mismatchValues.Count); - Assert.Equal(true, mismatchValues["provider1"]); - Assert.Equal(false, mismatchValues["provider2"]); - } - - [Fact] - public async Task EvaluateAsync_Should_Return_Error_When_Provider_Has_Error() - { - // Arrange - var strategy = new ComparisonStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var errorResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.General, "ERROR", errorMessage: "Something went wrong"); - var successResult = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(errorResult); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(successResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(errorResult, result); - } - - [Fact] - public async Task EvaluateAsync_Should_Return_FLAG_NOT_FOUND_When_No_Providers() - { - // Arrange - var strategy = new ComparisonStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var providers = new Dictionary(); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); - Assert.Equal("No providers available", result.ErrorMessage); - } - - [Fact] - public async Task EvaluateAsync_String_Should_Work_Correctly() - { - // Arrange - var strategy = new ComparisonStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var expectedResult = new ResolutionDetails(flagKey, "test-value", ErrorType.None, "STATIC", "variant"); - - provider1.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - provider2.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Integer_Should_Work_Correctly() - { - // Arrange - var strategy = new ComparisonStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var expectedResult = new ResolutionDetails(flagKey, 42, ErrorType.None, "STATIC", "variant"); - - provider1.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - provider2.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Double_Should_Work_Correctly() - { - // Arrange - var strategy = new ComparisonStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var expectedResult = new ResolutionDetails(flagKey, 3.14, ErrorType.None, "STATIC", "variant"); - - provider1.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - provider2.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Structure_Should_Work_Correctly() - { - // Arrange - var strategy = new ComparisonStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var structureValue = new Value(new Structure(new Dictionary { { "key", new Value("value") } })); - var expectedResult = new ResolutionDetails(flagKey, structureValue, ErrorType.None, "STATIC", "variant"); - - provider1.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - provider2.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } -} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/FirstMatchStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/FirstMatchStrategyTests.cs deleted file mode 100644 index 691a76d7..00000000 --- a/test/OpenFeature.Tests/Providers/MultiProvider/FirstMatchStrategyTests.cs +++ /dev/null @@ -1,302 +0,0 @@ -using AutoFixture; -using NSubstitute; -using OpenFeature.Constant; -using OpenFeature.Model; -using OpenFeature.Providers.MultiProvider; - -namespace OpenFeature.Tests.Providers.MultiProvider; - -public class FirstMatchStrategyTests -{ - private readonly Fixture _fixture = new(); - - [Fact] - public async Task EvaluateAsync_Should_Return_First_Non_FLAG_NOT_FOUND_Result() - { - // Arrange - var strategy = new FirstMatchStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, { "provider2", provider2 } - }; - - var flagNotFoundResult = - new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); - var successResult = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagNotFoundResult); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(successResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(successResult, result); - } - - [Fact] - public async Task EvaluateAsync_Should_Return_FLAG_NOT_FOUND_When_All_Providers_Return_FLAG_NOT_FOUND() - { - // Arrange - var strategy = new FirstMatchStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, { "provider2", provider2 } - }; - - var flagNotFoundResult = - new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagNotFoundResult); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagNotFoundResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); - } - - [Fact] - public async Task EvaluateAsync_Should_Return_First_Result_When_No_FLAG_NOT_FOUND() - { - // Arrange - var strategy = new FirstMatchStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, { "provider2", provider2 } - }; - - var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); - var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result1); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result2); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(result1, result); - } - - [Fact] - public async Task EvaluateAsync_String_Should_Work_Correctly() - { - // Arrange - var strategy = new FirstMatchStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var providers = new Dictionary { { "provider", provider } }; - - var expectedResult = new ResolutionDetails(flagKey, "test-value", ErrorType.None, "STATIC", "variant"); - - provider.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Integer_Should_Work_Correctly() - { - // Arrange - var strategy = new FirstMatchStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var providers = new Dictionary { { "provider", provider } }; - - var expectedResult = new ResolutionDetails(flagKey, 42, ErrorType.None, "STATIC", "variant"); - - provider.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Double_Should_Work_Correctly() - { - // Arrange - var strategy = new FirstMatchStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var providers = new Dictionary { { "provider", provider } }; - - var expectedResult = new ResolutionDetails(flagKey, 3.14, ErrorType.None, "STATIC", "variant"); - - provider.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Structure_Should_Work_Correctly() - { - // Arrange - var strategy = new FirstMatchStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var providers = new Dictionary { { "provider", provider } }; - - var structureValue = new Value(new Structure(new Dictionary { { "key", new Value("value") } })); - var expectedResult = new ResolutionDetails(flagKey, structureValue, ErrorType.None, "STATIC", "variant"); - - provider.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Should_Bubble_Up_Non_FLAG_NOT_FOUND_Error() - { - // Arrange - var strategy = new FirstMatchStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var providers = new Dictionary { { "provider", provider } }; - - var flagErrorResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.General, "UNKNOWN"); - - provider.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagErrorResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(flagErrorResult, result); - } - - [Fact] - public async Task EvaluateAsync_Should_Continue_On_FLAG_NOT_FOUND_And_Bubble_Other_Errors() - { - // Arrange - var strategy = new FirstMatchStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, { "provider2", provider2 } - }; - - var flagNotFoundResult = - new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); - var flagErrorResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.General, "UNKNOWN"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagNotFoundResult); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagErrorResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(flagErrorResult, result); - } - - [Fact] - public async Task EvaluateAsync_Should_Respect_Provider_Order() - { - // Arrange - var strategy = new FirstMatchStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, { "provider2", provider2 } - }; - - var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); - var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result1); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result2); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(result1, result); - await provider1.Received(1).ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); - await provider2.DidNotReceive().ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); - } -} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/FirstSuccessfulStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/FirstSuccessfulStrategyTests.cs deleted file mode 100644 index d2bd1444..00000000 --- a/test/OpenFeature.Tests/Providers/MultiProvider/FirstSuccessfulStrategyTests.cs +++ /dev/null @@ -1,308 +0,0 @@ -using AutoFixture; -using NSubstitute; -using OpenFeature.Constant; -using OpenFeature.Model; -using OpenFeature.Providers.MultiProvider; - -namespace OpenFeature.Tests.Providers.MultiProvider; - -public class FirstSuccessfulStrategyTests -{ - private readonly Fixture _fixture = new(); - - [Fact] - public async Task EvaluateAsync_Should_Return_First_Non_FLAG_NOT_FOUND_Result() - { - // Arrange - var strategy = new FirstSuccessfulStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var flagNotFoundResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); - var successResult = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagNotFoundResult); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(successResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(successResult, result); - } - - [Fact] - public async Task EvaluateAsync_Should_Return_FLAG_NOT_FOUND_When_All_Providers_Return_FLAG_NOT_FOUND() - { - // Arrange - var strategy = new FirstSuccessfulStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var flagNotFoundResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagNotFoundResult); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagNotFoundResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); - } - - [Fact] - public async Task EvaluateAsync_Should_Return_First_Result_When_No_FLAG_NOT_FOUND() - { - // Arrange - var strategy = new FirstSuccessfulStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); - var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result1); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result2); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(result1, result); - } - - [Fact] - public async Task EvaluateAsync_String_Should_Work_Correctly() - { - // Arrange - var strategy = new FirstSuccessfulStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var providers = new Dictionary { { "provider", provider } }; - - var expectedResult = new ResolutionDetails(flagKey, "test-value", ErrorType.None, "STATIC", "variant"); - - provider.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Integer_Should_Work_Correctly() - { - // Arrange - var strategy = new FirstSuccessfulStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var providers = new Dictionary { { "provider", provider } }; - - var expectedResult = new ResolutionDetails(flagKey, 42, ErrorType.None, "STATIC", "variant"); - - provider.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Double_Should_Work_Correctly() - { - // Arrange - var strategy = new FirstSuccessfulStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var providers = new Dictionary { { "provider", provider } }; - - var expectedResult = new ResolutionDetails(flagKey, 3.14, ErrorType.None, "STATIC", "variant"); - - provider.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Structure_Should_Work_Correctly() - { - // Arrange - var strategy = new FirstSuccessfulStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var providers = new Dictionary { { "provider", provider } }; - - var structureValue = new Value(new Structure(new Dictionary { { "key", new Value("value") } })); - var expectedResult = new ResolutionDetails(flagKey, structureValue, ErrorType.None, "STATIC", "variant"); - - provider.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - } - - [Fact] - public async Task EvaluateAsync_Should_Continue_FLAG_NOT_FOUND_Error() - { - // Arrange - var strategy = new FirstSuccessfulStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var providers = new Dictionary { { "provider", provider } }; - - var flagNotFoundResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); - - provider.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagNotFoundResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); - Assert.Equal(Reason.Error, result.Reason); - Assert.Equal("Flag not found in any provider", result.ErrorMessage); - } - - [Fact] - public async Task EvaluateAsync_Should_Continue_On_FLAG_NOT_FOUND_And_Continue_Other_Errors() - { - // Arrange - var strategy = new FirstSuccessfulStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var flagNotFoundResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, "FLAG_NOT_FOUND"); - var errorResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.TypeMismatch, "TYPE_MISMATCH"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(flagNotFoundResult); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(errorResult); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); - Assert.Equal(Reason.Error, result.Reason); - Assert.Equal("Flag not found in any provider", result.ErrorMessage); - } - - [Fact] - public async Task EvaluateAsync_Should_Respect_Provider_Order() - { - // Arrange - var strategy = new FirstSuccessfulStrategy(); - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var result1 = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant1"); - var result2 = new ResolutionDetails(flagKey, false, ErrorType.None, "STATIC", "variant2"); - - provider1.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result1); - provider2.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(result2); - - // Act - var result = await strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(result1, result); - await provider1.Received(1).ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); - await provider2.DidNotReceive().ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); - } -} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs deleted file mode 100644 index ed4be8d5..00000000 --- a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs +++ /dev/null @@ -1,463 +0,0 @@ -using AutoFixture; -using NSubstitute; -using OpenFeature.Constant; -using OpenFeature.Model; -using OpenFeature.Providers.MultiProvider; -using Provider = OpenFeature.Providers.MultiProvider; - -namespace OpenFeature.Tests.Providers.MultiProvider; - -public class MultiProviderTests : ClearOpenFeatureInstanceFixture -{ - private readonly Fixture _fixture = new(); - - [Fact] - public void MultiProvider_Should_Have_Metadata() - { - // Arrange - var providers = new Dictionary { { "test", new TestProvider() } }; - var strategy = new FirstMatchStrategy(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act - var metadata = multiProvider.GetMetadata(); - - // Assert - Assert.NotNull(metadata); - Assert.Equal("OpenFeature MultiProvider", metadata.Name); - } - - [Fact] - public void Constructor_Should_Set_Properties_Correctly() - { - // Arrange - var providers = new Dictionary { { "test", new TestProvider() } }; - var strategy = new FirstMatchStrategy(); - - // Act - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Assert - Assert.NotNull(multiProvider); - Assert.NotNull(multiProvider.GetMetadata()); - Assert.Equal("OpenFeature MultiProvider", multiProvider.GetMetadata().Name); - } - - [Fact] - public async Task ResolveBooleanValueAsync_Should_Delegate_To_Strategy() - { - // Arrange - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var expectedResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.None, "STATIC", "variant"); - - var provider = Substitute.For(); - var providers = new Dictionary { { "test", provider } }; - var strategy = Substitute.For(); - - strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act - var result = await multiProvider.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - await strategy.Received(1).EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - } - - [Fact] - public async Task ResolveStringValueAsync_Should_Delegate_To_Strategy() - { - // Arrange - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var expectedResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.None, "STATIC", "variant"); - - var provider = Substitute.For(); - var providers = new Dictionary { { "test", provider } }; - var strategy = Substitute.For(); - - strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act - var result = await multiProvider.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - await strategy.Received(1).EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - } - - [Fact] - public async Task ResolveIntegerValueAsync_Should_Delegate_To_Strategy() - { - // Arrange - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var expectedResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.None, "STATIC", "variant"); - - var provider = Substitute.For(); - var providers = new Dictionary { { "test", provider } }; - var strategy = Substitute.For(); - - strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act - var result = await multiProvider.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - await strategy.Received(1).EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - } - - [Fact] - public async Task ResolveDoubleValueAsync_Should_Delegate_To_Strategy() - { - // Arrange - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var expectedResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.None, "STATIC", "variant"); - - var provider = Substitute.For(); - var providers = new Dictionary { { "test", provider } }; - var strategy = Substitute.For(); - - strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act - var result = await multiProvider.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - await strategy.Received(1).EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - } - - [Fact] - public async Task ResolveStructureValueAsync_Should_Delegate_To_Strategy() - { - // Arrange - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var expectedResult = new ResolutionDetails(flagKey, defaultValue, ErrorType.None, "STATIC", "variant"); - - var provider = Substitute.For(); - var providers = new Dictionary { { "test", provider } }; - var strategy = Substitute.For(); - - strategy.EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act - var result = await multiProvider.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - await strategy.Received(1).EvaluateAsync(providers, flagKey, defaultValue, context, cancellationToken); - } - - [Fact] - public void GetProviderHooks_Should_Return_Empty_List() - { - // Arrange - var providers = new Dictionary { { "test", new TestProvider() } }; - var strategy = new FirstMatchStrategy(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act - var hooks = multiProvider.GetProviderHooks(); - - // Assert - Assert.NotNull(hooks); - Assert.Empty(hooks); - } - - [Fact] - public async Task InitializeAsync_Should_Initialize_All_Providers() - { - // Arrange - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var provider3 = Substitute.For(); - - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 }, - { "provider3", provider3 } - }; - - var strategy = Substitute.For(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act - await multiProvider.InitializeAsync(context, cancellationToken); - - // Assert - await provider1.Received(1).InitializeAsync(context, cancellationToken); - await provider2.Received(1).InitializeAsync(context, cancellationToken); - await provider3.Received(1).InitializeAsync(context, cancellationToken); - } - - [Fact] - public async Task InitializeAsync_Should_Complete_When_No_Providers() - { - // Arrange - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var providers = new Dictionary(); - var strategy = Substitute.For(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act & Assert - Should not throw - await multiProvider.InitializeAsync(context, cancellationToken); - } - - [Fact] - public async Task InitializeAsync_Should_Propagate_Exception_From_Provider() - { - // Arrange - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - var expectedException = new InvalidOperationException("Provider initialization failed"); - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - - provider1.InitializeAsync(context, cancellationToken) - .Returns(Task.CompletedTask); - provider2.When(x => x.InitializeAsync(context, cancellationToken)) - .Do(x => throw expectedException); - - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var strategy = Substitute.For(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act & Assert - var thrownException = await Assert.ThrowsAsync( - () => multiProvider.InitializeAsync(context, cancellationToken)); - - Assert.Equal(expectedException.Message, thrownException.Message); - await provider1.Received(1).InitializeAsync(context, cancellationToken); - await provider2.Received(1).InitializeAsync(context, cancellationToken); - } - - [Fact] - public async Task ShutdownAsync_Should_Shutdown_All_Providers() - { - // Arrange - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var provider3 = Substitute.For(); - - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 }, - { "provider3", provider3 } - }; - - var strategy = Substitute.For(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act - await multiProvider.ShutdownAsync(cancellationToken); - - // Assert - await provider1.Received(1).ShutdownAsync(cancellationToken); - await provider2.Received(1).ShutdownAsync(cancellationToken); - await provider3.Received(1).ShutdownAsync(cancellationToken); - } - - [Fact] - public async Task ShutdownAsync_Should_Complete_When_No_Providers() - { - // Arrange - var cancellationToken = CancellationToken.None; - - var providers = new Dictionary(); - var strategy = Substitute.For(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act & Assert - Should not throw - await multiProvider.ShutdownAsync(cancellationToken); - } - - [Fact] - public async Task ShutdownAsync_Should_Propagate_Exception_From_Provider() - { - // Arrange - var cancellationToken = CancellationToken.None; - var expectedException = new InvalidOperationException("Provider shutdown failed"); - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - - provider1.ShutdownAsync(cancellationToken) - .Returns(Task.CompletedTask); - provider2.When(x => x.ShutdownAsync(cancellationToken)) - .Do(x => throw expectedException); - - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var strategy = Substitute.For(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act & Assert - var thrownException = await Assert.ThrowsAsync( - () => multiProvider.ShutdownAsync(cancellationToken)); - - Assert.StartsWith("One or more providers failed to shutdown", thrownException.Message); - Assert.Single(thrownException.InnerExceptions); - Assert.Equal(expectedException.Message, thrownException.InnerExceptions.First().Message); - await provider1.Received(1).ShutdownAsync(cancellationToken); - await provider2.Received(1).ShutdownAsync(cancellationToken); - } - - [Fact] - public async Task ShutdownAsync_Should_Continue_All_Providers_Even_If_Some_Fail() - { - // Arrange - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var provider3 = Substitute.For(); - - // Make the second provider throw an exception - provider2.When(x => x.ShutdownAsync(cancellationToken)) - .Do(x => throw new InvalidOperationException("Provider 2 failed")); - - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 }, - { "provider3", provider3 } - }; - - var strategy = Substitute.For(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act & Assert - var thrownException = await Assert.ThrowsAsync( - () => multiProvider.ShutdownAsync(cancellationToken)); - - Assert.StartsWith("One or more providers failed to shutdown", thrownException.Message); - Assert.Single(thrownException.InnerExceptions); - Assert.Equal("Provider 2 failed", thrownException.InnerExceptions.First().Message); - - // All providers should be called even if one fails - await provider1.Received(1).ShutdownAsync(cancellationToken); - await provider2.Received(1).ShutdownAsync(cancellationToken); - await provider3.Received(1).ShutdownAsync(cancellationToken); - } - - [Fact] - public async Task ShutdownAsync_Should_Collect_Multiple_Exceptions() - { - // Arrange - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - var provider3 = Substitute.For(); - - // Make multiple providers throw exceptions - provider1.When(x => x.ShutdownAsync(cancellationToken)) - .Do(x => throw new InvalidOperationException("Provider 1 failed")); - provider3.When(x => x.ShutdownAsync(cancellationToken)) - .Do(x => throw new ArgumentException("Provider 3 failed")); - - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 }, - { "provider3", provider3 } - }; - - var strategy = Substitute.For(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act & Assert - var thrownException = await Assert.ThrowsAsync( - () => multiProvider.ShutdownAsync(cancellationToken)); - - Assert.StartsWith("One or more providers failed to shutdown", thrownException.Message); - Assert.Equal(2, thrownException.InnerExceptions.Count); - Assert.Contains(thrownException.InnerExceptions, ex => ex.Message == "Provider 1 failed"); - Assert.Contains(thrownException.InnerExceptions, ex => ex.Message == "Provider 3 failed"); - - // All providers should be called - await provider1.Received(1).ShutdownAsync(cancellationToken); - await provider2.Received(1).ShutdownAsync(cancellationToken); - await provider3.Received(1).ShutdownAsync(cancellationToken); - } - - [Fact] - public async Task ShutdownAsync_Should_Succeed_When_All_Providers_Succeed_After_Exception_Handling_Changes() - { - // Arrange - var cancellationToken = CancellationToken.None; - - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - - var providers = new Dictionary - { - { "provider1", provider1 }, - { "provider2", provider2 } - }; - - var strategy = Substitute.For(); - var multiProvider = new Provider.MultiProvider(providers, strategy); - - // Act - Should not throw - await multiProvider.ShutdownAsync(cancellationToken); - - // Assert - await provider1.Received(1).ShutdownAsync(cancellationToken); - await provider2.Received(1).ShutdownAsync(cancellationToken); - } -} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs deleted file mode 100644 index a1caf62d..00000000 --- a/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using AutoFixture; -using NSubstitute; -using OpenFeature.Constant; -using OpenFeature.Model; -using OpenFeature.Providers.MultiProvider; - -namespace OpenFeature.Tests.Providers.MultiProvider; - -public class ProviderExtensionsTests : ClearOpenFeatureInstanceFixture -{ - private readonly Fixture _fixture = new(); - - [Fact] - public async Task EvaluateAsync_Boolean_Should_Handle_Successful_Evaluation() - { - // Arrange - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var expectedResult = new ResolutionDetails(flagKey, true, ErrorType.None, "STATIC", "variant"); - - provider.ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await provider.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - await provider.Received(1).ResolveBooleanValueAsync(flagKey, defaultValue, context, cancellationToken); - } - - [Fact] - public async Task EvaluateAsync_String_Should_Handle_Successful_Evaluation() - { - // Arrange - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var expectedResult = new ResolutionDetails(flagKey, "success", ErrorType.None, "STATIC", "variant"); - - provider.ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await provider.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - await provider.Received(1).ResolveStringValueAsync(flagKey, defaultValue, context, cancellationToken); - } - - [Fact] - public async Task EvaluateAsync_Integer_Should_Handle_Successful_Evaluation() - { - // Arrange - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var expectedResult = new ResolutionDetails(flagKey, 42, ErrorType.None, "STATIC", "variant"); - - provider.ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await provider.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - await provider.Received(1).ResolveIntegerValueAsync(flagKey, defaultValue, context, cancellationToken); - } - - [Fact] - public async Task EvaluateAsync_Double_Should_Handle_Successful_Evaluation() - { - // Arrange - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var expectedResult = new ResolutionDetails(flagKey, 3.14, ErrorType.None, "STATIC", "variant"); - - provider.ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await provider.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - await provider.Received(1).ResolveDoubleValueAsync(flagKey, defaultValue, context, cancellationToken); - } - - [Fact] - public async Task EvaluateAsync_Structure_Should_Handle_Successful_Evaluation() - { - // Arrange - var flagKey = this._fixture.Create(); - var defaultValue = this._fixture.Create(); - var context = EvaluationContext.Empty; - var cancellationToken = CancellationToken.None; - - var provider = Substitute.For(); - var successValue = new Value(Structure.Builder().Set("key", "value").Build()); - var expectedResult = new ResolutionDetails(flagKey, successValue, ErrorType.None, "STATIC", "variant"); - - provider.ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken) - .Returns(expectedResult); - - // Act - var result = await provider.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); - - // Assert - Assert.Equal(expectedResult, result); - await provider.Received(1).ResolveStructureValueAsync(flagKey, defaultValue, context, cancellationToken); - } -} From bfb79c49f62eec6444b82b74fab3ea01610b0588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 28 Jun 2025 18:37:13 +0100 Subject: [PATCH 25/61] feat: add multi-provider support with evaluation strategies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/Models/ProviderEntry.cs | 29 +++ .../MultiProvider/Models/ProviderStatus.cs | 7 + .../Models/RegisteredProvider.cs | 28 +++ .../Providers/MultiProvider/MultiProvider.cs | 205 +++++++++++++++--- .../MultiProvider/ProviderExtensions.cs | 44 +++- .../Strategies/BaseEvaluationStrategy.cs | 101 +++++++++ .../Strategies/ComparisonStrategy.cs | 79 +++++++ .../Strategies/FirstMatchStrategy.cs | 34 +++ .../Strategies/FirstSuccessfulStrategy.cs | 46 ++++ .../Strategies/Models/FinalResult.cs | 45 ++++ .../Strategies/Models/ProviderError.cs | 29 +++ .../Models/ProviderResolutionResult.cs | 38 ++++ .../Strategies/Models/RunMode.cs | 17 ++ .../Models/StrategyEvaluationContext.cs | 28 +++ .../Models/StrategyPerProviderContext.cs | 40 ++++ 15 files changed, 732 insertions(+), 38 deletions(-) create mode 100644 src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs b/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs new file mode 100644 index 00000000..da720da6 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs @@ -0,0 +1,29 @@ +namespace OpenFeature.Providers.MultiProvider.Models; + +/// +/// Represents an entry for a provider in the multi-provider configuration. +/// +public class ProviderEntry +{ + /// + /// Initializes a new instance of the class. + /// + /// The feature provider instance. + /// Optional custom name for the provider. If not provided, the provider's metadata name will be used. + public ProviderEntry(FeatureProvider provider, string? name = null) + { + this.Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + this.Name = name; + } + + /// + /// Gets the feature provider instance. + /// + public FeatureProvider Provider { get; } + + /// + /// Gets the optional custom name for the provider. + /// If null, the provider's metadata name should be used. + /// + public string? Name { get; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs b/src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs new file mode 100644 index 00000000..b2b873b1 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs @@ -0,0 +1,7 @@ +namespace OpenFeature.Providers.MultiProvider.Models; + +internal class ProviderStatus +{ + public string ProviderName { get; set; } = string.Empty; + public Exception? Exception { get; set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs new file mode 100644 index 00000000..ed658b0f --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs @@ -0,0 +1,28 @@ +namespace OpenFeature.Providers.MultiProvider.Models; + +/// +/// Represents a registered provider with its unique assigned name. +/// +internal class RegisteredProvider +{ + /// + /// Initializes a new instance of the class. + /// + /// The feature provider instance. + /// The unique assigned name for the provider. + public RegisteredProvider(FeatureProvider provider, string name) + { + this.Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + this.Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// Gets the feature provider instance. + /// + public FeatureProvider Provider { get; } + + /// + /// Gets the unique assigned name for the provider. + /// + public string Name { get; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 9bcec19d..376cac31 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -1,4 +1,8 @@ +using System.Collections.ObjectModel; using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; namespace OpenFeature.Providers.MultiProvider; @@ -14,80 +18,225 @@ namespace OpenFeature.Providers.MultiProvider; /// Multi Provider specification public sealed class MultiProvider : FeatureProvider { - private readonly Dictionary _providers; private readonly BaseEvaluationStrategy _evaluationStrategy; + private readonly IReadOnlyList _registeredProviders; + private readonly Metadata _metadata; /// - /// Initializes a new instance of the class with the specified providers and evaluation strategy. + /// Initializes a new instance of the class with the specified provider entries and evaluation strategy. /// - /// A dictionary containing the feature providers keyed by their identifiers. + /// A collection of provider entries containing the feature providers and their optional names. /// The base evaluation strategy to use for determining how to evaluate features across multiple providers. - public MultiProvider(Dictionary providers, BaseEvaluationStrategy evaluationStrategy) + public MultiProvider(IEnumerable providerEntries, BaseEvaluationStrategy? evaluationStrategy = null) { - this._providers = providers; - this._evaluationStrategy = evaluationStrategy; - } + if (providerEntries == null) + { + throw new ArgumentNullException(nameof(providerEntries)); + } - /// - /// Initializes a new instance of the class with the specified providers. The default evaluation strategy is . - /// - /// A dictionary containing the feature providers keyed by their identifiers. - public MultiProvider(Dictionary providers) : this(providers, new FirstMatchStrategy()) - { + var entries = providerEntries.ToList(); + if (entries.Count == 0) + { + throw new ArgumentException("At least one provider entry must be provided.", nameof(providerEntries)); + } + + this._evaluationStrategy = evaluationStrategy ?? new FirstMatchStrategy(); + this._registeredProviders = RegisterProviders(entries); + + // Create aggregate metadata + this._metadata = new Metadata("MultiProvider"); } /// - public override Metadata GetMetadata() => new("OpenFeature MultiProvider"); + public override Metadata GetMetadata() => this._metadata; /// public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => - this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); /// public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => - this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); /// public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => - this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); /// public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => - this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); /// public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => - this._evaluationStrategy.EvaluateAsync(this._providers, flagKey, defaultValue, context, cancellationToken); + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + /// public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) { - foreach (var provider in this._providers.Values) + var initializationTasks = this._registeredProviders.Select(async rp => + { + try + { + await rp.Provider.InitializeAsync(context, cancellationToken).ConfigureAwait(false); + return new ProviderStatus { ProviderName = rp.Name }; + } + catch (Exception ex) + { + return new ProviderStatus { ProviderName = rp.Name, Exception = ex}; + } + }); + + var results = await Task.WhenAll(initializationTasks).ConfigureAwait(false); + var failures = results.Where(r => r.Exception != null).ToList(); + + if (failures.Count != 0) { - await provider.InitializeAsync(context, cancellationToken).ConfigureAwait(false); + var exceptions = failures.Select(f => f.Exception!).ToList(); + var failedProviders = failures.Select(f => f.ProviderName).ToList(); + throw new AggregateException( + $"Failed to initialize providers: {string.Join(", ", failedProviders)}", + exceptions); } } /// public override async Task ShutdownAsync(CancellationToken cancellationToken = default) { - var exceptions = new List(); - - foreach (var provider in this._providers.Values) + var shutdownTasks = this._registeredProviders.Select(async rp => { try { - await provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); + await rp.Provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); + return new ProviderStatus { ProviderName = rp.Name }; } catch (Exception ex) { - exceptions.Add(ex); + return new ProviderStatus { ProviderName = rp.Name, Exception = ex}; } + }); + + var results = await Task.WhenAll(shutdownTasks).ConfigureAwait(false); + var failures = results.Where(r => r.Exception != null).ToList(); + + if (failures.Count != 0) + { + var exceptions = failures.Select(f => f.Exception!).ToList(); + var failedProviders = failures.Select(f => f.ProviderName).ToList(); + throw new AggregateException( + $"Failed to shutdown providers: {string.Join(", ", failedProviders)}", + exceptions); } + } - if (exceptions.Count > 0) + private async Task> EvaluateAsync(string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) + { + var strategyContext = new StrategyEvaluationContext(key, typeof(T)); + var resolutions = this._evaluationStrategy.RunMode switch { - throw new AggregateException("One or more providers failed to shutdown", exceptions); + RunMode.Parallel => await this.ParallelEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + RunMode.Sequential => await this.SequentialEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + _ => throw new NotSupportedException($"Unsupported run mode: {this._evaluationStrategy.RunMode}") + }; + + var finalResult = this._evaluationStrategy.DetermineFinalResult(strategyContext, key, defaultValue, evaluationContext, resolutions); + return finalResult.Details; + } + + private async Task>> SequentialEvaluationAsync(string key, T defaultValue, EvaluationContext? evaluationContext, CancellationToken cancellationToken) + { + var resolutions = new List>(); + + foreach (var registeredProvider in this._registeredProviders) + { + var providerContext = new StrategyPerProviderContext( + registeredProvider.Provider, + registeredProvider.Name, + registeredProvider.Provider.Status, + key, + typeof(T)); + + if (!this._evaluationStrategy.ShouldEvaluateThisProvider(providerContext, evaluationContext)) + { + continue; + } + + var result = await registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, cancellationToken).ConfigureAwait(false); + resolutions.Add(result); + + if (!this._evaluationStrategy.ShouldEvaluateNextProvider(providerContext, evaluationContext, result)) + { + break; + } } + + return resolutions; + } + + private async Task>> ParallelEvaluationAsync(string key, T defaultValue, EvaluationContext? evaluationContext, CancellationToken cancellationToken) + { + var resolutions = new List>(); + var tasks = new List>>(); + + foreach (var registeredProvider in this._registeredProviders) + { + var providerContext = new StrategyPerProviderContext( + registeredProvider.Provider, + registeredProvider.Name, + registeredProvider.Provider.Status, + key, + typeof(T)); + + if (this._evaluationStrategy.ShouldEvaluateThisProvider(providerContext, evaluationContext)) + { + tasks.Add(registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, cancellationToken)); + } + } + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + resolutions.AddRange(results); + + return resolutions; + } + + private static ReadOnlyCollection RegisterProviders(IEnumerable providerEntries) + { + var entries = providerEntries.ToList(); + var registeredProviders = new List(); + var nameGroups = entries.GroupBy(e => e.Name ?? e.Provider.GetMetadata()?.Name ?? "UnknownProvider").ToList(); + + // Check for duplicate explicit names + var duplicateExplicitNames = nameGroups + .FirstOrDefault(g => g.Count(e => e.Name != null) > 1)?.Key; + + if (duplicateExplicitNames != null) + { + throw new ArgumentException($"Multiple providers cannot have the same explicit name: '{duplicateExplicitNames}'"); + } + + // Assign unique names + foreach (var group in nameGroups) + { + var baseName = group.Key; + var groupEntries = group.ToList(); + + if (groupEntries.Count == 1) + { + var entry = groupEntries[0]; + registeredProviders.Add(new RegisteredProvider(entry.Provider, entry.Name ?? baseName)); + } + else + { + // Multiple providers with same metadata name - add indices + var index = 1; + foreach (var entry in groupEntries) + { + var finalName = entry.Name ?? $"{baseName}-{index++}"; + registeredProviders.Add(new RegisteredProvider(entry.Provider, finalName)); + } + } + } + + return registeredProviders.AsReadOnly(); } } diff --git a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs index 7c68a9be..2c651a86 100644 --- a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs +++ b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs @@ -1,22 +1,46 @@ using OpenFeature.Constant; using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; namespace OpenFeature.Providers.MultiProvider; internal static class ProviderExtensions { - internal static async Task> EvaluateAsync(this FeatureProvider provider, string key, T defaultValue, EvaluationContext? evaluationContext, + internal static async Task> EvaluateAsync( + this FeatureProvider provider, + StrategyPerProviderContext providerContext, + EvaluationContext? evaluationContext, + T defaultValue, CancellationToken cancellationToken) { - var result = typeof(T) switch + var key = providerContext.FlagKey; + + try + { + // Perform the actual flag resolution + var result = typeof(T) switch + { + { } t when t == typeof(bool) => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, (bool)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(int) => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, (int)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(double) => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, (double)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + { } t when t == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + _ => throw new ArgumentException($"Unsupported flag type: {typeof(T)}") + }; + + return new ProviderResolutionResult(provider, providerContext.ProviderName, result); + } + catch (Exception ex) { - { } t when t == typeof(bool) => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, (bool)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(int) => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, (int)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(double) => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, (double)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - _ => new ResolutionDetails(key, defaultValue, ErrorType.TypeMismatch, Reason.Error, errorMessage: $"Unsupported type: {typeof(T).Name}") - }; - return result; + // Create an error result + var errorResult = new ResolutionDetails( + key, + defaultValue, + ErrorType.General, + Reason.Error, + errorMessage: ex.Message); + + return new ProviderResolutionResult(provider, providerContext.ProviderName, errorResult); + } } } diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs new file mode 100644 index 00000000..d29ad8ee --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs @@ -0,0 +1,101 @@ +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.MultiProvider.Strategies; + +/// +/// Provides a base class for implementing evaluation strategies that determine how feature flags are evaluated across multiple feature providers. +/// +/// +/// This abstract class serves as the foundation for creating custom evaluation strategies that can handle feature flag resolution +/// across multiple providers. Implementations define the specific logic for how providers are selected, prioritized, or combined +/// when evaluating feature flags. +/// +public abstract class BaseEvaluationStrategy +{ + /// + /// Determines whether providers should be evaluated in parallel or sequentially. + /// + public virtual RunMode RunMode => RunMode.Sequential; + + /// + /// Determines whether a specific provider should be evaluated. + /// + /// Context information about the provider and evaluation. + /// The evaluation context for the flag resolution. + /// True if the provider should be evaluated, false otherwise. + public virtual bool ShouldEvaluateThisProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext) + { + // Skip providers that are not ready or have fatal errors + return strategyContext.ProviderStatus is not (ProviderStatus.NotReady or ProviderStatus.Fatal); + } + + /// + /// Determines whether the next provider should be evaluated after the current one. + /// This method is only called in sequential mode. + /// + /// The type of the flag value. + /// Context information about the provider and evaluation. + /// The evaluation context for the flag resolution. + /// The result from the current provider evaluation. + /// True if the next provider should be evaluated, false otherwise. + public virtual bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) + { + return true; + } + + /// + /// Determines the final result from all provider evaluation results. + /// + /// The type of the flag value. + /// Context information about the evaluation. + /// The feature flag key to evaluate. + /// The default value to return if evaluation fails or the flag is not found. + /// The evaluation context for the flag resolution. + /// All resolution results from provider evaluations. + /// The final evaluation result. + public abstract FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions); + + /// + /// Checks if a resolution result represents an error. + /// + /// The type of the resolved value. + /// The resolution result to check. + /// True if the result represents an error, false otherwise. + protected static bool HasError(ProviderResolutionResult resolution) + { + return resolution.ResolutionDetails switch + { + { } success => success.ErrorType != ErrorType.None, + _ => false + }; + } + + /// + /// Checks if a resolution result has a specific error code. + /// + /// The type of the resolved value. + /// The resolution result to check. + /// The error type to check for. + /// True if the result has the specified error type, false otherwise. + protected static bool HasErrorWithCode(ProviderResolutionResult resolution, ErrorType errorType) + { + return resolution.ResolutionDetails switch + { + { } success => success.ErrorType == errorType, + _ => false + }; + } + + /// + /// Converts a resolution result to a final result. + /// + /// The type of the resolved value. + /// The resolution result to convert. + /// The converted final result. + protected static FinalResult ToFinalResult(ProviderResolutionResult resolution) + { + return new FinalResult(resolution.ResolutionDetails, resolution.Provider, resolution.ProviderName, null); + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs new file mode 100644 index 00000000..b9bbdbe3 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs @@ -0,0 +1,79 @@ +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.MultiProvider.Strategies; + +/// +/// Evaluate all providers in parallel and compare the results. +/// If the values agree, return the value. +/// If the values disagree, return the value from the configured "fallback provider" and execute the "onMismatch" +/// callback if defined. +/// +public sealed class ComparisonStrategy : BaseEvaluationStrategy +{ + private readonly FeatureProvider? _fallbackProvider; + private readonly Action>? _onMismatch; + + /// + public override RunMode RunMode => RunMode.Parallel; + + /// + /// Initializes a new instance of the class. + /// + /// The provider to use as fallback when values don't match. + /// Optional callback that is called when providers return different values. + public ComparisonStrategy(FeatureProvider? fallbackProvider = null, Action>? onMismatch = null) + { + this._fallbackProvider = fallbackProvider; + this._onMismatch = onMismatch; + } + + /// + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + { + var successfulResolutions = resolutions.Where(r => !HasError(r)).ToList(); + + if (successfulResolutions.Count == 0) + { + var errorDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); + var errors = resolutions.Select(r => new ProviderError(r.ProviderName, new InvalidOperationException($"Provider {r.ProviderName} failed"))).ToList(); + return new FinalResult(errorDetails, null!, "MultiProvider", errors); + } + + var firstResult = successfulResolutions.First(); + ProviderResolutionResult? fallbackResolution = null; + + // Find fallback provider if specified + if (this._fallbackProvider != null) + { + fallbackResolution = successfulResolutions.FirstOrDefault(r => ReferenceEquals(r.Provider, this._fallbackProvider)); + } + + // Check if all successful results agree on the value + var allAgree = successfulResolutions.All(r => EqualityComparer.Default.Equals(r.ResolutionDetails.Value, firstResult.ResolutionDetails.Value)); + + if (allAgree) + { + return ToFinalResult(firstResult); + } + + // Values don't agree, trigger mismatch callback if provided + if (this._onMismatch != null) + { + // Create a dictionary with provider names and their values for the callback + var mismatchDetails = successfulResolutions.ToDictionary( + r => r.ProviderName, + r => (object)r.ResolutionDetails.Value! + ); + this._onMismatch(mismatchDetails); + } + + // Return fallback provider result if available + return fallbackResolution != null + ? ToFinalResult(fallbackResolution) + : + // Default to first provider's result + ToFinalResult(firstResult); + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs new file mode 100644 index 00000000..1aefb707 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs @@ -0,0 +1,34 @@ +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.MultiProvider.Strategies; + +/// +/// Return the first result that did not indicate "flag not found". +/// If any provider in the course of evaluation returns or throws an error, throw that error +/// +public sealed class FirstMatchStrategy : BaseEvaluationStrategy +{ + /// + public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) + { + return HasErrorWithCode(result, ErrorType.FlagNotFound); + } + + /// + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + { + if (resolutions.Count != 0) + { + return ToFinalResult(resolutions.Last()); + } + + var errorDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); + var errors = new List + { + new("MultiProvider", new InvalidOperationException("No providers available or all providers failed")) + }; + return new FinalResult(errorDetails, null!, "MultiProvider", errors); + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs new file mode 100644 index 00000000..c0d8d779 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs @@ -0,0 +1,46 @@ +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.MultiProvider.Strategies; + +/// +/// Return the first result that did not result in an error. +/// If any provider in the course of evaluation returns or throws an error, ignore it as long as there is a successful result. +/// If there is no successful result, throw all errors. +/// +public sealed class FirstSuccessfulStrategy : BaseEvaluationStrategy +{ + /// + public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) + { + // evaluate next only if there was an error + return HasError(result); + } + + /// + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + { + if (resolutions.Count == 0) + { + var noProvidersDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); + var noProvidersErrors = new List + { + new("MultiProvider", new InvalidOperationException("No providers available or all providers failed")) + }; + return new FinalResult(noProvidersDetails, null!, "MultiProvider", noProvidersErrors); + } + + // Find the first successful result + var successfulResult = resolutions.FirstOrDefault(r => !HasError(r)); + if (successfulResult != null) + { + return ToFinalResult(successfulResult); + } + + // All results had errors - collect them and throw + var allFailedDetails = new ResolutionDetails(key, defaultValue, ErrorType.General, Reason.Error, errorMessage: "All providers failed"); + var allFailedErrors = resolutions.Select(r => new ProviderError(r.ProviderName, new InvalidOperationException($"Provider {r.ProviderName} failed"))).ToList(); + return new FinalResult(allFailedDetails, null!, "MultiProvider", allFailedErrors); + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs new file mode 100644 index 00000000..1031523f --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs @@ -0,0 +1,45 @@ +using OpenFeature.Model; + +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Represents the final result of a feature flag resolution operation from a multi-provider strategy. +/// Contains the resolved details, the provider that successfully resolved the flag, and any errors encountered during the resolution process. +/// +public class FinalResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The resolution details containing the resolved value and associated metadata. + /// The provider that successfully resolved the feature flag. + /// The name of the provider that successfully resolved the feature flag. + /// The list of errors encountered during the resolution process. + public FinalResult(ResolutionDetails details, FeatureProvider provider, string providerName, List? errors) + { + Details = details; + Provider = provider; + ProviderName = providerName; + Errors = errors ?? []; + } + + /// + /// Gets or sets the resolution details containing the resolved value and associated metadata. + /// + public ResolutionDetails Details { get; private set; } + + /// + /// Gets or sets the provider that successfully resolved the feature flag. + /// + public FeatureProvider Provider { get; private set; } + + /// + /// Gets or sets the name of the provider that successfully resolved the feature flag. + /// + public string ProviderName { get; private set; } + + /// + /// Gets or sets the list of errors encountered during the resolution process. + /// + public List Errors { get; private set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs new file mode 100644 index 00000000..e7436652 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs @@ -0,0 +1,29 @@ +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Represents an error encountered during the resolution process. +/// Contains the name of the provider that encountered the error and the error details. +/// +public class ProviderError +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the provider that encountered the error. + /// The error details. + public ProviderError(string providerName, object? error) + { + ProviderName = providerName; + Error = error; + } + + /// + /// Gets or sets the name of the provider that encountered the error. + /// + public string ProviderName { get; private set; } + + /// + /// Gets or sets the error details. + /// + public object? Error { get; private set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs new file mode 100644 index 00000000..f3ed0b14 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs @@ -0,0 +1,38 @@ +using OpenFeature.Model; + +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Base class for provider resolution results. +/// +public class ProviderResolutionResult +{ + /// + /// Initializes a new instance of the class + /// with the specified provider and resolution details. + /// + /// The feature provider that produced this result. + /// The name of the provider that produced this result. + /// The resolution details. + public ProviderResolutionResult(FeatureProvider provider, string providerName, ResolutionDetails resolutionDetails) + { + Provider = provider; + ProviderName = providerName; + ResolutionDetails = resolutionDetails; + } + + /// + /// The feature provider that produced this result. + /// + public FeatureProvider Provider { get; set; } + + /// + /// The resolution details. + /// + public ResolutionDetails ResolutionDetails { get; set; } + + /// + /// The name of the provider that produced this result. + /// + public string ProviderName { get; set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs new file mode 100644 index 00000000..754cb5a9 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs @@ -0,0 +1,17 @@ +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Specifies how providers should be evaluated. +/// +public enum RunMode +{ + /// + /// Providers are evaluated one after another in sequence. + /// + Sequential, + + /// + /// Providers are evaluated concurrently in parallel. + /// + Parallel +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs new file mode 100644 index 00000000..17079f34 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs @@ -0,0 +1,28 @@ +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Evaluation context specific to strategy evaluation containing flag-related information. +/// +public class StrategyEvaluationContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The feature flag key being evaluated. + /// The type of the flag value being evaluated. + public StrategyEvaluationContext(string flagKey, Type flagType) + { + FlagKey = flagKey; + FlagType = flagType; + } + + /// + /// The feature flag key being evaluated. + /// + public string FlagKey { get; private set; } + + /// + /// The type of the flag value being evaluated. + /// + public Type FlagType { get; private set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs new file mode 100644 index 00000000..ebafbd0b --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs @@ -0,0 +1,40 @@ +using OpenFeature.Constant; + +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Per-provider context containing provider-specific information for strategy evaluation. +/// +public class StrategyPerProviderContext : StrategyEvaluationContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The feature provider instance. + /// The name/identifier of the provider. + /// The current status of the provider. + /// The feature flag key being evaluated. + /// The type of the flag value being evaluated. + public StrategyPerProviderContext(FeatureProvider provider, string providerName, ProviderStatus providerStatus, string key, Type flagType) + : base(key, flagType) + { + Provider = provider; + ProviderName = providerName; + ProviderStatus = providerStatus; + } + + /// + /// The feature provider instance. + /// + public FeatureProvider Provider { get; private set; } + + /// + /// The name/identifier of the provider. + /// + public string ProviderName { get; private set; } + + /// + /// The current status of the provider. + /// + public ProviderStatus ProviderStatus { get; private set; } +} From 0e7f202057d1d4337d7240d9f2da095720350800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 28 Jun 2025 18:38:52 +0100 Subject: [PATCH 26/61] Revert "Move to Extensions folder" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9ffd149205199783f0b4cfbb6cbbfcbf7c502960. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../EnumExtensions.cs | 26 +++++++++---------- .../ResolutionDetailsExtensions.cs | 24 ++++++++--------- src/OpenFeature/OpenFeatureClient.cs | 2 +- .../Steps/EvaluationStepDefinitions.cs | 2 +- .../FeatureProviderExceptionTests.cs | 2 +- .../OpenFeatureClientTests.cs | 2 +- 6 files changed, 29 insertions(+), 29 deletions(-) rename src/OpenFeature/{Extensions => Extension}/EnumExtensions.cs (89%) rename src/OpenFeature/{Extensions => Extension}/ResolutionDetailsExtensions.cs (89%) diff --git a/src/OpenFeature/Extensions/EnumExtensions.cs b/src/OpenFeature/Extension/EnumExtensions.cs similarity index 89% rename from src/OpenFeature/Extensions/EnumExtensions.cs rename to src/OpenFeature/Extension/EnumExtensions.cs index 9bded22b..73c39125 100644 --- a/src/OpenFeature/Extensions/EnumExtensions.cs +++ b/src/OpenFeature/Extension/EnumExtensions.cs @@ -1,13 +1,13 @@ -using System.ComponentModel; - -namespace OpenFeature.Extensions; - -internal static class EnumExtensions -{ - public static string GetDescription(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); - var attribute = field?.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; - return attribute?.Description ?? value.ToString(); - } -} +using System.ComponentModel; + +namespace OpenFeature.Extension; + +internal static class EnumExtensions +{ + public static string GetDescription(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + var attribute = field?.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; + return attribute?.Description ?? value.ToString(); + } +} diff --git a/src/OpenFeature/Extensions/ResolutionDetailsExtensions.cs b/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs similarity index 89% rename from src/OpenFeature/Extensions/ResolutionDetailsExtensions.cs rename to src/OpenFeature/Extension/ResolutionDetailsExtensions.cs index 1e005db4..cf0d4f4a 100644 --- a/src/OpenFeature/Extensions/ResolutionDetailsExtensions.cs +++ b/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs @@ -1,12 +1,12 @@ -using OpenFeature.Model; - -namespace OpenFeature.Extensions; - -internal static class ResolutionDetailsExtensions -{ - public static FlagEvaluationDetails ToFlagEvaluationDetails(this ResolutionDetails details) - { - return new FlagEvaluationDetails(details.FlagKey, details.Value, details.ErrorType, details.Reason, - details.Variant, details.ErrorMessage, details.FlagMetadata); - } -} +using OpenFeature.Model; + +namespace OpenFeature.Extension; + +internal static class ResolutionDetailsExtensions +{ + public static FlagEvaluationDetails ToFlagEvaluationDetails(this ResolutionDetails details) + { + return new FlagEvaluationDetails(details.FlagKey, details.Value, details.ErrorType, details.Reason, + details.Variant, details.ErrorMessage, details.FlagMetadata); + } +} diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index 5c2d24b8..02acde07 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using OpenFeature.Constant; using OpenFeature.Error; -using OpenFeature.Extensions; +using OpenFeature.Extension; using OpenFeature.Model; namespace OpenFeature; diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs index 16878e44..27e00359 100644 --- a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs @@ -1,6 +1,6 @@ using OpenFeature.Constant; using OpenFeature.E2ETests.Utils; -using OpenFeature.Extensions; +using OpenFeature.Extension; using OpenFeature.Model; namespace OpenFeature.E2ETests.Steps; diff --git a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs index 61a9acdb..e1645269 100644 --- a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs +++ b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs @@ -1,6 +1,6 @@ using OpenFeature.Constant; using OpenFeature.Error; -using OpenFeature.Extensions; +using OpenFeature.Extension; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index 713d09f0..cbecddc2 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -5,7 +5,7 @@ using NSubstitute.ExceptionExtensions; using OpenFeature.Constant; using OpenFeature.Error; -using OpenFeature.Extensions; +using OpenFeature.Extension; using OpenFeature.Model; using OpenFeature.Tests.Internal; From ecab884042f8450412696b814ac5806eb3e42fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sun, 29 Jun 2025 08:16:55 +0100 Subject: [PATCH 27/61] refactor: use 'this' keyword for clarity in constructors across multiple models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/Strategies/Models/FinalResult.cs | 8 ++++---- .../MultiProvider/Strategies/Models/ProviderError.cs | 4 ++-- .../Strategies/Models/ProviderResolutionResult.cs | 12 ++++++------ .../Strategies/Models/StrategyEvaluationContext.cs | 4 ++-- .../Strategies/Models/StrategyPerProviderContext.cs | 6 +++--- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs index 1031523f..0bcc0bd7 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs @@ -17,10 +17,10 @@ public class FinalResult /// The list of errors encountered during the resolution process. public FinalResult(ResolutionDetails details, FeatureProvider provider, string providerName, List? errors) { - Details = details; - Provider = provider; - ProviderName = providerName; - Errors = errors ?? []; + this.Details = details; + this.Provider = provider; + this.ProviderName = providerName; + this.Errors = errors ?? []; } /// diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs index e7436652..f2058745 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs @@ -13,8 +13,8 @@ public class ProviderError /// The error details. public ProviderError(string providerName, object? error) { - ProviderName = providerName; - Error = error; + this.ProviderName = providerName; + this.Error = error; } /// diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs index f3ed0b14..aefdb777 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs @@ -16,23 +16,23 @@ public class ProviderResolutionResult /// The resolution details. public ProviderResolutionResult(FeatureProvider provider, string providerName, ResolutionDetails resolutionDetails) { - Provider = provider; - ProviderName = providerName; - ResolutionDetails = resolutionDetails; + this.Provider = provider; + this.ProviderName = providerName; + this.ResolutionDetails = resolutionDetails; } /// /// The feature provider that produced this result. /// - public FeatureProvider Provider { get; set; } + public FeatureProvider Provider { get; private set; } /// /// The resolution details. /// - public ResolutionDetails ResolutionDetails { get; set; } + public ResolutionDetails ResolutionDetails { get; private set; } /// /// The name of the provider that produced this result. /// - public string ProviderName { get; set; } + public string ProviderName { get; private set; } } diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs index 17079f34..fa68f904 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs @@ -12,8 +12,8 @@ public class StrategyEvaluationContext /// The type of the flag value being evaluated. public StrategyEvaluationContext(string flagKey, Type flagType) { - FlagKey = flagKey; - FlagType = flagType; + this.FlagKey = flagKey; + this.FlagType = flagType; } /// diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs index ebafbd0b..5623faa1 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs @@ -18,9 +18,9 @@ public class StrategyPerProviderContext : StrategyEvaluationContext public StrategyPerProviderContext(FeatureProvider provider, string providerName, ProviderStatus providerStatus, string key, Type flagType) : base(key, flagType) { - Provider = provider; - ProviderName = providerName; - ProviderStatus = providerStatus; + this.Provider = provider; + this.ProviderName = providerName; + this.ProviderStatus = providerStatus; } /// From 05f779c30cb46d4f5edbef9cf0317b908371a6fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sun, 29 Jun 2025 08:17:51 +0100 Subject: [PATCH 28/61] fix: add missing space in ProviderStatus exception handling for consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature/Providers/MultiProvider/MultiProvider.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 376cac31..ac79bb70 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -83,7 +83,7 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati } catch (Exception ex) { - return new ProviderStatus { ProviderName = rp.Name, Exception = ex}; + return new ProviderStatus { ProviderName = rp.Name, Exception = ex }; } }); @@ -112,7 +112,7 @@ public override async Task ShutdownAsync(CancellationToken cancellationToken = d } catch (Exception ex) { - return new ProviderStatus { ProviderName = rp.Name, Exception = ex}; + return new ProviderStatus { ProviderName = rp.Name, Exception = ex }; } }); From 822d983cc3e47663c79edf9193e559ca55b9a2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sun, 29 Jun 2025 08:33:21 +0100 Subject: [PATCH 29/61] feat: enhance ProviderResolutionResult to include exception details in resolution results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Providers/MultiProvider/ProviderExtensions.cs | 2 +- .../Strategies/Models/ProviderResolutionResult.cs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs index 2c651a86..3207aca0 100644 --- a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs +++ b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs @@ -40,7 +40,7 @@ internal static async Task> EvaluateAsync( Reason.Error, errorMessage: ex.Message); - return new ProviderResolutionResult(provider, providerContext.ProviderName, errorResult); + return new ProviderResolutionResult(provider, providerContext.ProviderName, errorResult, ex); } } } diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs index aefdb777..20eddbe4 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs @@ -14,11 +14,13 @@ public class ProviderResolutionResult /// The feature provider that produced this result. /// The name of the provider that produced this result. /// The resolution details. - public ProviderResolutionResult(FeatureProvider provider, string providerName, ResolutionDetails resolutionDetails) + /// The exception that occurred during resolution, if any. + public ProviderResolutionResult(FeatureProvider provider, string providerName, ResolutionDetails resolutionDetails, Exception? thrownError = null) { this.Provider = provider; this.ProviderName = providerName; this.ResolutionDetails = resolutionDetails; + this.ThrownError = thrownError; } /// @@ -35,4 +37,9 @@ public ProviderResolutionResult(FeatureProvider provider, string providerName, R /// The name of the provider that produced this result. /// public string ProviderName { get; private set; } + + /// + /// The exception that occurred during resolution, if any. + /// + public Exception? ThrownError { get; private set; } } From e03d85d9e7e1001876a3b13a7f794e5574ed760e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sun, 29 Jun 2025 08:40:55 +0100 Subject: [PATCH 30/61] feat: add error collection method and refine ProviderError to use Exception type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Strategies/BaseEvaluationStrategy.cs | 29 ++++++++++++++++++- .../Strategies/Models/ProviderError.cs | 4 +-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs index d29ad8ee..f9464f6c 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs @@ -65,13 +65,40 @@ public virtual bool ShouldEvaluateNextProvider(StrategyPerProviderContext str /// True if the result represents an error, false otherwise. protected static bool HasError(ProviderResolutionResult resolution) { - return resolution.ResolutionDetails switch + return resolution.ThrownError is not null || resolution.ResolutionDetails switch { { } success => success.ErrorType != ErrorType.None, _ => false }; } + /// + /// Collects errors from provider resolution results. + /// + /// The type of the flag value. + /// The provider resolution results to collect errors from. + /// A list of provider errors. + protected static List CollectProviderErrors(List> resolutions) + { + var errors = new List(); + + foreach (var resolution in resolutions) + { + if (resolution.ThrownError is not null) + { + errors.Add(new ProviderError(resolution.ProviderName, resolution.ThrownError)); + } + else if (resolution.ResolutionDetails?.ErrorType != ErrorType.None) + { + var errorMessage = resolution.ResolutionDetails?.ErrorMessage ?? "unknown error"; + var error = new Exception(errorMessage); // Adjust based on your ErrorWithCode implementation + errors.Add(new ProviderError(resolution.ProviderName, error)); + } + } + + return errors; + } + /// /// Checks if a resolution result has a specific error code. /// diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs index f2058745..52204ce5 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs @@ -11,7 +11,7 @@ public class ProviderError /// /// The name of the provider that encountered the error. /// The error details. - public ProviderError(string providerName, object? error) + public ProviderError(string providerName, Exception? error) { this.ProviderName = providerName; this.Error = error; @@ -25,5 +25,5 @@ public ProviderError(string providerName, object? error) /// /// Gets or sets the error details. /// - public object? Error { get; private set; } + public Exception? Error { get; private set; } } From 644d6202ec87bb93d309a2047e49ec2605086562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 30 Jun 2025 16:12:30 +0100 Subject: [PATCH 31/61] refactor: simplify error handling in FirstMatchStrategy and FirstSuccessfulStrategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Providers/MultiProvider/Strategies/FirstMatchStrategy.cs | 5 +++-- .../MultiProvider/Strategies/FirstSuccessfulStrategy.cs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs index 1aefb707..28b1f230 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs @@ -19,9 +19,10 @@ public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext st /// public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) { - if (resolutions.Count != 0) + var lastResult = resolutions.LastOrDefault(); + if (lastResult != null) { - return ToFinalResult(resolutions.Last()); + return ToFinalResult(lastResult); } var errorDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs index c0d8d779..6e535842 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs @@ -39,8 +39,8 @@ public override FinalResult DetermineFinalResult(StrategyEvaluationContext } // All results had errors - collect them and throw + var collectedErrors = CollectProviderErrors(resolutions); var allFailedDetails = new ResolutionDetails(key, defaultValue, ErrorType.General, Reason.Error, errorMessage: "All providers failed"); - var allFailedErrors = resolutions.Select(r => new ProviderError(r.ProviderName, new InvalidOperationException($"Provider {r.ProviderName} failed"))).ToList(); - return new FinalResult(allFailedDetails, null!, "MultiProvider", allFailedErrors); + return new FinalResult(allFailedDetails, null!, "MultiProvider", collectedErrors); } } From c96d4358ed22038f7bc76aa70ca56514bc9e37ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 30 Jun 2025 16:31:55 +0100 Subject: [PATCH 32/61] test: add unit tests for FirstSuccessfulStrategy behavior and result determination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../FirstSuccessfulStrategyTests.cs | 475 ++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs new file mode 100644 index 00000000..e12f00ff --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs @@ -0,0 +1,475 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; + +public class FirstSuccessfulStrategyTests +{ + private readonly FirstSuccessfulStrategy _strategy = new(); + private readonly FeatureProvider _mockProvider1 = Substitute.For(); + private readonly FeatureProvider _mockProvider2 = Substitute.For(); + private readonly FeatureProvider _mockProvider3 = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + private readonly StrategyEvaluationContext _strategyContext = new("test-flag", typeof(bool)); + + [Fact] + public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, "provider1", ProviderStatus.Ready, "test-flag", typeof(bool)); + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", true, ErrorType.None, Reason.Static, "variant1")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, successfulResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithErrorResult_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, "provider1", ProviderStatus.Ready, "test-flag", typeof(bool)); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", false, ErrorType.General, Reason.Error, errorMessage: "Test error")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, errorResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithThrownException_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, "provider1", ProviderStatus.Ready, "test-flag", typeof(bool)); + var exceptionResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", false), + new InvalidOperationException("Test exception")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, exceptionResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void DetermineFinalResult_WithNoProviders_ReturnsErrorResult() + { + // Arrange + var resolutions = new List>(); + const bool defaultValue = false; + const string key = "test-flag"; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, key, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(key, result.Details.FlagKey); + Assert.Equal(defaultValue, result.Details.Value); + Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("No providers available or all providers failed", result.Details.ErrorMessage); + Assert.Equal("MultiProvider", result.ProviderName); + Assert.Single(result.Errors); + Assert.Equal("MultiProvider", result.Errors[0].ProviderName); + Assert.IsType(result.Errors[0].Error); + } + + [Fact] + public void DetermineFinalResult_WithFirstSuccessfulResult_ReturnsFirstSuccessfulResult() + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", false, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); + + var successfulResult = new ProviderResolutionResult( + this._mockProvider2, + "provider2", + new ResolutionDetails("test-flag", true, ErrorType.None, Reason.Static, "variant1")); + + var anotherSuccessfulResult = new ProviderResolutionResult( + this._mockProvider3, + "provider3", + new ResolutionDetails("test-flag", false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { errorResult, successfulResult, anotherSuccessfulResult }; + const bool defaultValue = false; + const string key = "test-flag"; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, key, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(key, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal("variant1", result.Details.Variant); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal("provider2", result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithAllFailedResults_ReturnsAllErrorsCollected() + { + // Arrange + var errorResult1 = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", false, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); + + var errorResult2 = new ProviderResolutionResult( + this._mockProvider2, + "provider2", + new ResolutionDetails("test-flag", false, ErrorType.InvalidContext, Reason.Error, errorMessage: "Error from provider2")); + + var exceptionResult = new ProviderResolutionResult( + this._mockProvider3, + "provider3", + new ResolutionDetails("test-flag", false), + new InvalidOperationException("Exception from provider3")); + + var resolutions = new List> { errorResult1, errorResult2, exceptionResult }; + const bool defaultValue = false; + const string key = "test-flag"; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, key, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(key, result.Details.FlagKey); + Assert.Equal(defaultValue, result.Details.Value); + Assert.Equal(ErrorType.General, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("All providers failed", result.Details.ErrorMessage); + Assert.Equal("MultiProvider", result.ProviderName); + Assert.Equal(3, result.Errors.Count); + + // Verify error from provider1 + Assert.Equal("provider1", result.Errors[0].ProviderName); + Assert.Equal("Error from provider1", result.Errors[0].Error?.Message); + + // Verify error from provider2 + Assert.Equal("provider2", result.Errors[1].ProviderName); + Assert.Equal("Error from provider2", result.Errors[1].Error?.Message); + + // Verify exception from provider3 + Assert.Equal("provider3", result.Errors[2].ProviderName); + Assert.IsType(result.Errors[2].Error); + Assert.Equal("Exception from provider3", result.Errors[2].Error?.Message); + } + + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(int))] + [InlineData(typeof(double))] + public void DetermineFinalResult_WorksWithDifferentTypes(Type flagType) + { + // Arrange + var strategyContext = new StrategyEvaluationContext("test-flag", flagType); + var defaultValue = default(T)!; + T successValue = GetTestValue(); + + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", defaultValue, ErrorType.General, Reason.Error, errorMessage: "Error")); + + var successfulResult = new ProviderResolutionResult( + this._mockProvider2, + "provider2", + new ResolutionDetails("test-flag", successValue, ErrorType.None, Reason.Static, "variant1")); + + var resolutions = new List> { errorResult, successfulResult }; + const string key = "test-flag"; + + // Act + var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(key, result.Details.FlagKey); + Assert.Equal(successValue, result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal("variant1", result.Details.Variant); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal("provider2", result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithMixedErrorTypesAndSuccess_ReturnsFirstSuccess() + { + // Arrange + var flagNotFoundResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", "default", ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found")); + + var parseErrorResult = new ProviderResolutionResult( + this._mockProvider2, + "provider2", + new ResolutionDetails("test-flag", "default", ErrorType.ParseError, Reason.Error, errorMessage: "Parse error")); + + var successfulResult = new ProviderResolutionResult( + this._mockProvider3, + "provider3", + new ResolutionDetails("test-flag", "success-value", ErrorType.None, Reason.Static, "success-variant")); + + var resolutions = new List> { flagNotFoundResult, parseErrorResult, successfulResult }; + var strategyContext = new StrategyEvaluationContext("test-flag", typeof(string)); + const string defaultValue = "default"; + const string key = "test-flag"; + + // Act + var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(key, result.Details.FlagKey); + Assert.Equal("success-value", result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal("success-variant", result.Details.Variant); + Assert.Equal(this._mockProvider3, result.Provider); + Assert.Equal("provider3", result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithOnlyExceptionResults_CollectsAllExceptions() + { + // Arrange + var exception1 = new ArgumentException("Argument error"); + var exception2 = new InvalidOperationException("Invalid operation"); + var exception3 = new TimeoutException("Timeout error"); + + var exceptionResult1 = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", 0), + exception1); + + var exceptionResult2 = new ProviderResolutionResult( + this._mockProvider2, + "provider2", + new ResolutionDetails("test-flag", 0), + exception2); + + var exceptionResult3 = new ProviderResolutionResult( + this._mockProvider3, + "provider3", + new ResolutionDetails("test-flag", 0), + exception3); + + var resolutions = new List> { exceptionResult1, exceptionResult2, exceptionResult3 }; + var strategyContext = new StrategyEvaluationContext("test-flag", typeof(int)); + const int defaultValue = 0; + const string key = "test-flag"; + + // Act + var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(key, result.Details.FlagKey); + Assert.Equal(defaultValue, result.Details.Value); + Assert.Equal(ErrorType.General, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("All providers failed", result.Details.ErrorMessage); + Assert.Equal("MultiProvider", result.ProviderName); + Assert.Equal(3, result.Errors.Count); + + Assert.Equal("provider1", result.Errors[0].ProviderName); + Assert.Equal(exception1, result.Errors[0].Error); + + Assert.Equal("provider2", result.Errors[1].ProviderName); + Assert.Equal(exception2, result.Errors[1].Error); + + Assert.Equal("provider3", result.Errors[2].ProviderName); + Assert.Equal(exception3, result.Errors[2].Error); + } + + [Fact] + public void DetermineFinalResult_WithNullEvaluationContext_HandlesGracefully() + { + // Arrange + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", true, ErrorType.None, Reason.Static, "variant1")); + + var resolutions = new List> { successfulResult }; + const bool defaultValue = false; + const string key = "test-flag"; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, key, defaultValue, null, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(key, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + } + + [Fact] + public void DetermineFinalResult_WithEmptyFlagKey_HandlesCorrectly() + { + // Arrange + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("", "test-value", ErrorType.None, Reason.Static, "variant1")); + + var resolutions = new List> { successfulResult }; + var strategyContext = new StrategyEvaluationContext("", typeof(string)); + const string defaultValue = "default"; + const string key = ""; + + // Act + var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(key, result.Details.FlagKey); + Assert.Equal("test-value", result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + } + + [Fact] + public void DetermineFinalResult_WithMultipleSuccessfulResults_ReturnsFirst() + { + // Arrange + var firstSuccessResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", 100, ErrorType.None, Reason.Static, "variant1")); + + var secondSuccessResult = new ProviderResolutionResult( + this._mockProvider2, + "provider2", + new ResolutionDetails("test-flag", 200, ErrorType.None, Reason.Static, "variant2")); + + var thirdSuccessResult = new ProviderResolutionResult( + this._mockProvider3, + "provider3", + new ResolutionDetails("test-flag", 300, ErrorType.None, Reason.Static, "variant3")); + + var resolutions = new List> { firstSuccessResult, secondSuccessResult, thirdSuccessResult }; + var strategyContext = new StrategyEvaluationContext("test-flag", typeof(int)); + const int defaultValue = 0; + const string key = "test-flag"; + + // Act + var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(key, result.Details.FlagKey); + Assert.Equal(100, result.Details.Value); // Should return the first successful result + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal("variant1", result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal("provider1", result.ProviderName); + Assert.Empty(result.Errors); + } + + [Theory] + [InlineData(ErrorType.FlagNotFound)] + [InlineData(ErrorType.ParseError)] + [InlineData(ErrorType.TypeMismatch)] + [InlineData(ErrorType.InvalidContext)] + [InlineData(ErrorType.ProviderNotReady)] + [InlineData(ErrorType.General)] + public void ShouldEvaluateNextProvider_WithDifferentErrorTypes_ReturnsTrue(ErrorType errorType) + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, "provider1", ProviderStatus.Ready, "test-flag", typeof(bool)); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", false, errorType, Reason.Error, errorMessage: $"Error of type {errorType}")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, errorResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void DetermineFinalResult_WithStructureType_HandlesCorrectly() + { + // Arrange + var structureValue = new Value(new Structure(new Dictionary + { + ["key1"] = new Value("value1"), + ["key2"] = new Value(42) + })); + + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + "provider1", + new ResolutionDetails("test-flag", new Value(), ErrorType.General, Reason.Error, errorMessage: "Error")); + + var successfulResult = new ProviderResolutionResult( + this._mockProvider2, + "provider2", + new ResolutionDetails("test-flag", structureValue, ErrorType.None, Reason.Static, "structure-variant")); + + var resolutions = new List> { errorResult, successfulResult }; + var strategyContext = new StrategyEvaluationContext("test-flag", typeof(Value)); + var defaultValue = new Value(); + const string key = "test-flag"; + + // Act + var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(key, result.Details.FlagKey); + Assert.Equal(structureValue, result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal("structure-variant", result.Details.Variant); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal("provider2", result.ProviderName); + Assert.Empty(result.Errors); + } + + private static T GetTestValue() + { + return typeof(T) switch + { + var t when t == typeof(string) => (T)(object)"test-string", + var t when t == typeof(int) => (T)(object)42, + var t when t == typeof(double) => (T)(object)3.14, + var t when t == typeof(bool) => (T)(object)true, + _ => default! + }; + } +} From b29fe39171f4b8e90fefe1f4b61ea9e3e361094e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 30 Jun 2025 16:41:56 +0100 Subject: [PATCH 33/61] test: add unit tests for FirstMatchStrategy behavior and result determination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Strategies/FirstMatchStrategyTests.cs | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs new file mode 100644 index 00000000..8b892ec0 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs @@ -0,0 +1,313 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; + +public class FirstMatchStrategyTests +{ + private const string TestFlagKey = "test-flag"; + private const bool DefaultBoolValue = false; + private const string TestVariant = "variant1"; + private const string TestErrorMessage = "Test error message"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + private const string MultiProviderName = "MultiProvider"; + private const string NoProvidersErrorMessage = "No providers available or all providers failed"; + + private readonly FirstMatchStrategy _strategy = new(); + private readonly FeatureProvider _mockProvider1 = Substitute.For(); + private readonly FeatureProvider _mockProvider2 = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey, typeof(bool)); + + [Fact] + public void ShouldEvaluateNextProvider_WithFlagNotFoundError_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var flagNotFoundResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, flagNotFoundResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, successfulResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithGeneralError_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var generalErrorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, generalErrorResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithInvalidContextError_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var invalidContextResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.InvalidContext, Reason.Error, errorMessage: "Invalid context")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, invalidContextResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithThrownException_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var exceptionResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue), + new InvalidOperationException("Test exception")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, exceptionResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void DetermineFinalResult_WithNoProviders_ReturnsErrorResult() + { + // Arrange + var resolutions = new List>(); + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal(NoProvidersErrorMessage, result.Details.ErrorMessage); + Assert.Equal(MultiProviderName, result.ProviderName); + Assert.Single(result.Errors); + Assert.Equal(MultiProviderName, result.Errors[0].ProviderName); + Assert.IsType(result.Errors[0].Error); + Assert.Equal(NoProvidersErrorMessage, result.Errors[0].Error?.Message); + } + + [Fact] + public void DetermineFinalResult_WithSingleSuccessfulResult_ReturnsLastResult() + { + // Arrange + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var resolutions = new List> { successfulResult }; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithMultipleResults_ReturnsLastResult() + { + // Arrange + var flagNotFoundResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found")); + + var successfulResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var resolutions = new List> { flagNotFoundResult, successfulResult }; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal(Provider2Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithLastResultHavingError_ReturnsLastResultWithError() + { + // Arrange + var flagNotFoundResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found")); + + var generalErrorResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + var resolutions = new List> { flagNotFoundResult, generalErrorResult }; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(ErrorType.General, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal(TestErrorMessage, result.Details.ErrorMessage); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal(Provider2Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithLastResultHavingException_ReturnsLastResultWithException() + { + // Arrange + var flagNotFoundResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found")); + + var exceptionResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue), + new ArgumentException("Test argument exception")); + + var resolutions = new List> { flagNotFoundResult, exceptionResult }; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal(Provider2Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithStringType_ReturnsCorrectType() + { + // Arrange + const string defaultStringValue = "default"; + const string testStringValue = "test-value"; + const string stringVariant = "string-variant"; + + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, testStringValue, ErrorType.None, Reason.Static, stringVariant)); + + var resolutions = new List> { successfulResult }; + var stringStrategyContext = new StrategyEvaluationContext(TestFlagKey, typeof(string)); + + // Act + var result = this._strategy.DetermineFinalResult(stringStrategyContext, TestFlagKey, defaultStringValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(testStringValue, result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(stringVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithIntType_ReturnsCorrectType() + { + // Arrange + const int defaultIntValue = 0; + const int testIntValue = 42; + const string intVariant = "int-variant"; + + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, testIntValue, ErrorType.None, Reason.Static, intVariant)); + + var resolutions = new List> { successfulResult }; + var intStrategyContext = new StrategyEvaluationContext(TestFlagKey, typeof(int)); + + // Act + var result = this._strategy.DetermineFinalResult(intStrategyContext, TestFlagKey, defaultIntValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(testIntValue, result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(intVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } +} From 9c3a2139f5c1c475f600f5f80d258a0a6c143bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 30 Jun 2025 16:51:10 +0100 Subject: [PATCH 34/61] test: simplify tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../FirstSuccessfulStrategyTests.cs | 345 +++--------------- 1 file changed, 50 insertions(+), 295 deletions(-) diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs index e12f00ff..5925972e 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs @@ -8,22 +8,29 @@ namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; public class FirstSuccessfulStrategyTests { + private const string TestFlagKey = "test-flag"; + private const bool DefaultBoolValue = false; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + private const string Provider3Name = "provider3"; + private const string MultiProviderName = "MultiProvider"; + private readonly FirstSuccessfulStrategy _strategy = new(); private readonly FeatureProvider _mockProvider1 = Substitute.For(); private readonly FeatureProvider _mockProvider2 = Substitute.For(); private readonly FeatureProvider _mockProvider3 = Substitute.For(); private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); - private readonly StrategyEvaluationContext _strategyContext = new("test-flag", typeof(bool)); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey, typeof(bool)); [Fact] public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, "provider1", ProviderStatus.Ready, "test-flag", typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); var successfulResult = new ProviderResolutionResult( this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", true, ErrorType.None, Reason.Static, "variant1")); + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant1")); // Act var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, successfulResult); @@ -36,11 +43,11 @@ public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() public void ShouldEvaluateNextProvider_WithErrorResult_ReturnsTrue() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, "provider1", ProviderStatus.Ready, "test-flag", typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); var errorResult = new ProviderResolutionResult( this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", false, ErrorType.General, Reason.Error, errorMessage: "Test error")); + Provider1Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.General, Reason.Error, errorMessage: "Test error")); // Act var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, errorResult); @@ -53,11 +60,11 @@ public void ShouldEvaluateNextProvider_WithErrorResult_ReturnsTrue() public void ShouldEvaluateNextProvider_WithThrownException_ReturnsTrue() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, "provider1", ProviderStatus.Ready, "test-flag", typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); var exceptionResult = new ProviderResolutionResult( this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", false), + Provider1Name, + new ResolutionDetails(TestFlagKey, false), new InvalidOperationException("Test exception")); // Act @@ -72,22 +79,20 @@ public void DetermineFinalResult_WithNoProviders_ReturnsErrorResult() { // Arrange var resolutions = new List>(); - const bool defaultValue = false; - const string key = "test-flag"; // Act - var result = this._strategy.DetermineFinalResult(this._strategyContext, key, defaultValue, this._evaluationContext, resolutions); + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); // Assert Assert.NotNull(result.Details); - Assert.Equal(key, result.Details.FlagKey); - Assert.Equal(defaultValue, result.Details.Value); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); Assert.Equal(Reason.Error, result.Details.Reason); Assert.Equal("No providers available or all providers failed", result.Details.ErrorMessage); - Assert.Equal("MultiProvider", result.ProviderName); + Assert.Equal(MultiProviderName, result.ProviderName); Assert.Single(result.Errors); - Assert.Equal("MultiProvider", result.Errors[0].ProviderName); + Assert.Equal(MultiProviderName, result.Errors[0].ProviderName); Assert.IsType(result.Errors[0].Error); } @@ -97,35 +102,34 @@ public void DetermineFinalResult_WithFirstSuccessfulResult_ReturnsFirstSuccessfu // Arrange var errorResult = new ProviderResolutionResult( this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", false, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); + Provider1Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); var successfulResult = new ProviderResolutionResult( this._mockProvider2, - "provider2", - new ResolutionDetails("test-flag", true, ErrorType.None, Reason.Static, "variant1")); + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant1")); var anotherSuccessfulResult = new ProviderResolutionResult( this._mockProvider3, - "provider3", - new ResolutionDetails("test-flag", false, ErrorType.None, Reason.Static, "variant2")); + Provider3Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); var resolutions = new List> { errorResult, successfulResult, anotherSuccessfulResult }; const bool defaultValue = false; - const string key = "test-flag"; // Act - var result = this._strategy.DetermineFinalResult(this._strategyContext, key, defaultValue, this._evaluationContext, resolutions); + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, defaultValue, this._evaluationContext, resolutions); // Assert Assert.NotNull(result.Details); - Assert.Equal(key, result.Details.FlagKey); + Assert.Equal(TestFlagKey, result.Details.FlagKey); Assert.True(result.Details.Value); Assert.Equal(ErrorType.None, result.Details.ErrorType); Assert.Equal(Reason.Static, result.Details.Reason); Assert.Equal("variant1", result.Details.Variant); Assert.Equal(this._mockProvider2, result.Provider); - Assert.Equal("provider2", result.ProviderName); + Assert.Equal(Provider2Name, result.ProviderName); Assert.Empty(result.Errors); } @@ -135,269 +139,72 @@ public void DetermineFinalResult_WithAllFailedResults_ReturnsAllErrorsCollected( // Arrange var errorResult1 = new ProviderResolutionResult( this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", false, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); + Provider1Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); var errorResult2 = new ProviderResolutionResult( this._mockProvider2, - "provider2", - new ResolutionDetails("test-flag", false, ErrorType.InvalidContext, Reason.Error, errorMessage: "Error from provider2")); + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.InvalidContext, Reason.Error, errorMessage: "Error from provider2")); var exceptionResult = new ProviderResolutionResult( this._mockProvider3, - "provider3", - new ResolutionDetails("test-flag", false), + Provider3Name, + new ResolutionDetails(TestFlagKey, false), new InvalidOperationException("Exception from provider3")); var resolutions = new List> { errorResult1, errorResult2, exceptionResult }; const bool defaultValue = false; - const string key = "test-flag"; // Act - var result = this._strategy.DetermineFinalResult(this._strategyContext, key, defaultValue, this._evaluationContext, resolutions); + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, defaultValue, this._evaluationContext, resolutions); // Assert Assert.NotNull(result.Details); - Assert.Equal(key, result.Details.FlagKey); + Assert.Equal(TestFlagKey, result.Details.FlagKey); Assert.Equal(defaultValue, result.Details.Value); Assert.Equal(ErrorType.General, result.Details.ErrorType); Assert.Equal(Reason.Error, result.Details.Reason); Assert.Equal("All providers failed", result.Details.ErrorMessage); - Assert.Equal("MultiProvider", result.ProviderName); + Assert.Equal(MultiProviderName, result.ProviderName); Assert.Equal(3, result.Errors.Count); // Verify error from provider1 - Assert.Equal("provider1", result.Errors[0].ProviderName); + Assert.Equal(Provider1Name, result.Errors[0].ProviderName); Assert.Equal("Error from provider1", result.Errors[0].Error?.Message); // Verify error from provider2 - Assert.Equal("provider2", result.Errors[1].ProviderName); + Assert.Equal(Provider2Name, result.Errors[1].ProviderName); Assert.Equal("Error from provider2", result.Errors[1].Error?.Message); // Verify exception from provider3 - Assert.Equal("provider3", result.Errors[2].ProviderName); + Assert.Equal(Provider3Name, result.Errors[2].ProviderName); Assert.IsType(result.Errors[2].Error); Assert.Equal("Exception from provider3", result.Errors[2].Error?.Message); } - [Theory] - [InlineData(typeof(string))] - [InlineData(typeof(int))] - [InlineData(typeof(double))] - public void DetermineFinalResult_WorksWithDifferentTypes(Type flagType) - { - // Arrange - var strategyContext = new StrategyEvaluationContext("test-flag", flagType); - var defaultValue = default(T)!; - T successValue = GetTestValue(); - - var errorResult = new ProviderResolutionResult( - this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", defaultValue, ErrorType.General, Reason.Error, errorMessage: "Error")); - - var successfulResult = new ProviderResolutionResult( - this._mockProvider2, - "provider2", - new ResolutionDetails("test-flag", successValue, ErrorType.None, Reason.Static, "variant1")); - - var resolutions = new List> { errorResult, successfulResult }; - const string key = "test-flag"; - - // Act - var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); - - // Assert - Assert.NotNull(result.Details); - Assert.Equal(key, result.Details.FlagKey); - Assert.Equal(successValue, result.Details.Value); - Assert.Equal(ErrorType.None, result.Details.ErrorType); - Assert.Equal(Reason.Static, result.Details.Reason); - Assert.Equal("variant1", result.Details.Variant); - Assert.Equal(this._mockProvider2, result.Provider); - Assert.Equal("provider2", result.ProviderName); - Assert.Empty(result.Errors); - } - - [Fact] - public void DetermineFinalResult_WithMixedErrorTypesAndSuccess_ReturnsFirstSuccess() - { - // Arrange - var flagNotFoundResult = new ProviderResolutionResult( - this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", "default", ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found")); - - var parseErrorResult = new ProviderResolutionResult( - this._mockProvider2, - "provider2", - new ResolutionDetails("test-flag", "default", ErrorType.ParseError, Reason.Error, errorMessage: "Parse error")); - - var successfulResult = new ProviderResolutionResult( - this._mockProvider3, - "provider3", - new ResolutionDetails("test-flag", "success-value", ErrorType.None, Reason.Static, "success-variant")); - - var resolutions = new List> { flagNotFoundResult, parseErrorResult, successfulResult }; - var strategyContext = new StrategyEvaluationContext("test-flag", typeof(string)); - const string defaultValue = "default"; - const string key = "test-flag"; - - // Act - var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); - - // Assert - Assert.NotNull(result.Details); - Assert.Equal(key, result.Details.FlagKey); - Assert.Equal("success-value", result.Details.Value); - Assert.Equal(ErrorType.None, result.Details.ErrorType); - Assert.Equal(Reason.Static, result.Details.Reason); - Assert.Equal("success-variant", result.Details.Variant); - Assert.Equal(this._mockProvider3, result.Provider); - Assert.Equal("provider3", result.ProviderName); - Assert.Empty(result.Errors); - } - - [Fact] - public void DetermineFinalResult_WithOnlyExceptionResults_CollectsAllExceptions() - { - // Arrange - var exception1 = new ArgumentException("Argument error"); - var exception2 = new InvalidOperationException("Invalid operation"); - var exception3 = new TimeoutException("Timeout error"); - - var exceptionResult1 = new ProviderResolutionResult( - this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", 0), - exception1); - - var exceptionResult2 = new ProviderResolutionResult( - this._mockProvider2, - "provider2", - new ResolutionDetails("test-flag", 0), - exception2); - - var exceptionResult3 = new ProviderResolutionResult( - this._mockProvider3, - "provider3", - new ResolutionDetails("test-flag", 0), - exception3); - - var resolutions = new List> { exceptionResult1, exceptionResult2, exceptionResult3 }; - var strategyContext = new StrategyEvaluationContext("test-flag", typeof(int)); - const int defaultValue = 0; - const string key = "test-flag"; - - // Act - var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); - - // Assert - Assert.NotNull(result.Details); - Assert.Equal(key, result.Details.FlagKey); - Assert.Equal(defaultValue, result.Details.Value); - Assert.Equal(ErrorType.General, result.Details.ErrorType); - Assert.Equal(Reason.Error, result.Details.Reason); - Assert.Equal("All providers failed", result.Details.ErrorMessage); - Assert.Equal("MultiProvider", result.ProviderName); - Assert.Equal(3, result.Errors.Count); - - Assert.Equal("provider1", result.Errors[0].ProviderName); - Assert.Equal(exception1, result.Errors[0].Error); - - Assert.Equal("provider2", result.Errors[1].ProviderName); - Assert.Equal(exception2, result.Errors[1].Error); - - Assert.Equal("provider3", result.Errors[2].ProviderName); - Assert.Equal(exception3, result.Errors[2].Error); - } - [Fact] public void DetermineFinalResult_WithNullEvaluationContext_HandlesGracefully() { // Arrange var successfulResult = new ProviderResolutionResult( this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", true, ErrorType.None, Reason.Static, "variant1")); + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant1")); var resolutions = new List> { successfulResult }; const bool defaultValue = false; - const string key = "test-flag"; // Act - var result = this._strategy.DetermineFinalResult(this._strategyContext, key, defaultValue, null, resolutions); + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, defaultValue, null, resolutions); // Assert Assert.NotNull(result.Details); - Assert.Equal(key, result.Details.FlagKey); + Assert.Equal(TestFlagKey, result.Details.FlagKey); Assert.True(result.Details.Value); Assert.Equal(ErrorType.None, result.Details.ErrorType); } - [Fact] - public void DetermineFinalResult_WithEmptyFlagKey_HandlesCorrectly() - { - // Arrange - var successfulResult = new ProviderResolutionResult( - this._mockProvider1, - "provider1", - new ResolutionDetails("", "test-value", ErrorType.None, Reason.Static, "variant1")); - - var resolutions = new List> { successfulResult }; - var strategyContext = new StrategyEvaluationContext("", typeof(string)); - const string defaultValue = "default"; - const string key = ""; - - // Act - var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); - - // Assert - Assert.NotNull(result.Details); - Assert.Equal(key, result.Details.FlagKey); - Assert.Equal("test-value", result.Details.Value); - Assert.Equal(ErrorType.None, result.Details.ErrorType); - } - - [Fact] - public void DetermineFinalResult_WithMultipleSuccessfulResults_ReturnsFirst() - { - // Arrange - var firstSuccessResult = new ProviderResolutionResult( - this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", 100, ErrorType.None, Reason.Static, "variant1")); - - var secondSuccessResult = new ProviderResolutionResult( - this._mockProvider2, - "provider2", - new ResolutionDetails("test-flag", 200, ErrorType.None, Reason.Static, "variant2")); - - var thirdSuccessResult = new ProviderResolutionResult( - this._mockProvider3, - "provider3", - new ResolutionDetails("test-flag", 300, ErrorType.None, Reason.Static, "variant3")); - - var resolutions = new List> { firstSuccessResult, secondSuccessResult, thirdSuccessResult }; - var strategyContext = new StrategyEvaluationContext("test-flag", typeof(int)); - const int defaultValue = 0; - const string key = "test-flag"; - - // Act - var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); - - // Assert - Assert.NotNull(result.Details); - Assert.Equal(key, result.Details.FlagKey); - Assert.Equal(100, result.Details.Value); // Should return the first successful result - Assert.Equal(ErrorType.None, result.Details.ErrorType); - Assert.Equal(Reason.Static, result.Details.Reason); - Assert.Equal("variant1", result.Details.Variant); - Assert.Equal(this._mockProvider1, result.Provider); - Assert.Equal("provider1", result.ProviderName); - Assert.Empty(result.Errors); - } - [Theory] [InlineData(ErrorType.FlagNotFound)] [InlineData(ErrorType.ParseError)] @@ -408,11 +215,11 @@ public void DetermineFinalResult_WithMultipleSuccessfulResults_ReturnsFirst() public void ShouldEvaluateNextProvider_WithDifferentErrorTypes_ReturnsTrue(ErrorType errorType) { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, "provider1", ProviderStatus.Ready, "test-flag", typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); var errorResult = new ProviderResolutionResult( this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", false, errorType, Reason.Error, errorMessage: $"Error of type {errorType}")); + Provider1Name, + new ResolutionDetails(TestFlagKey, false, errorType, Reason.Error, errorMessage: $"Error of type {errorType}")); // Act var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, errorResult); @@ -420,56 +227,4 @@ public void ShouldEvaluateNextProvider_WithDifferentErrorTypes_ReturnsTrue(Error // Assert Assert.True(result); } - - [Fact] - public void DetermineFinalResult_WithStructureType_HandlesCorrectly() - { - // Arrange - var structureValue = new Value(new Structure(new Dictionary - { - ["key1"] = new Value("value1"), - ["key2"] = new Value(42) - })); - - var errorResult = new ProviderResolutionResult( - this._mockProvider1, - "provider1", - new ResolutionDetails("test-flag", new Value(), ErrorType.General, Reason.Error, errorMessage: "Error")); - - var successfulResult = new ProviderResolutionResult( - this._mockProvider2, - "provider2", - new ResolutionDetails("test-flag", structureValue, ErrorType.None, Reason.Static, "structure-variant")); - - var resolutions = new List> { errorResult, successfulResult }; - var strategyContext = new StrategyEvaluationContext("test-flag", typeof(Value)); - var defaultValue = new Value(); - const string key = "test-flag"; - - // Act - var result = this._strategy.DetermineFinalResult(strategyContext, key, defaultValue, this._evaluationContext, resolutions); - - // Assert - Assert.NotNull(result.Details); - Assert.Equal(key, result.Details.FlagKey); - Assert.Equal(structureValue, result.Details.Value); - Assert.Equal(ErrorType.None, result.Details.ErrorType); - Assert.Equal(Reason.Static, result.Details.Reason); - Assert.Equal("structure-variant", result.Details.Variant); - Assert.Equal(this._mockProvider2, result.Provider); - Assert.Equal("provider2", result.ProviderName); - Assert.Empty(result.Errors); - } - - private static T GetTestValue() - { - return typeof(T) switch - { - var t when t == typeof(string) => (T)(object)"test-string", - var t when t == typeof(int) => (T)(object)42, - var t when t == typeof(double) => (T)(object)3.14, - var t when t == typeof(bool) => (T)(object)true, - _ => default! - }; - } } From 2255016960311bbdb92a2da6d86fe1fb623546d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 30 Jun 2025 16:59:39 +0100 Subject: [PATCH 35/61] test: add unit tests for ComparisonStrategy RunMode behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Strategies/FirstMatchStrategyTests.cs | 10 ++++++++++ .../Strategies/FirstSuccessfulStrategyTests.cs | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs index 8b892ec0..973d36d4 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs @@ -23,6 +23,16 @@ public class FirstMatchStrategyTests private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey, typeof(bool)); + [Fact] + public void RunMode_ReturnsSequential() + { + // Act + var result = this._strategy.RunMode; + + // Assert + Assert.Equal(RunMode.Sequential, result); + } + [Fact] public void ShouldEvaluateNextProvider_WithFlagNotFoundError_ReturnsTrue() { diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs index 5925972e..2960691e 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs @@ -22,6 +22,16 @@ public class FirstSuccessfulStrategyTests private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey, typeof(bool)); + [Fact] + public void RunMode_ReturnsSequential() + { + // Act + var result = this._strategy.RunMode; + + // Assert + Assert.Equal(RunMode.Sequential, result); + } + [Fact] public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() { From d6c6e1efd3473135e511c2397cf6bbe7c8a6d6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:02:39 +0100 Subject: [PATCH 36/61] test: add unit tests for ComparisonStrategy functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Strategies/ComparisonStrategyTests.cs | 475 ++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs new file mode 100644 index 00000000..82630863 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs @@ -0,0 +1,475 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; + +public class ComparisonStrategyTests +{ + private const string TestFlagKey = "test-flag"; + private const bool DefaultBoolValue = false; + private const string TestVariant = "variant1"; + private const string TestErrorMessage = "Test error message"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + private const string Provider3Name = "provider3"; + private const string MultiProviderName = "MultiProvider"; + + private readonly FeatureProvider _mockProvider1 = Substitute.For(); + private readonly FeatureProvider _mockProvider2 = Substitute.For(); + private readonly FeatureProvider _mockProvider3 = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey, typeof(bool)); + + [Fact] + public void RunMode_ReturnsParallel() + { + // Arrange + var strategy = new ComparisonStrategy(); + + // Act + var result = strategy.RunMode; + + // Assert + Assert.Equal(RunMode.Parallel, result); + } + + [Fact] + public void Constructor_WithNoParameters_InitializesSuccessfully() + { + // Act + var strategy = new ComparisonStrategy(); + + // Assert + Assert.NotNull(strategy); + Assert.Equal(RunMode.Parallel, strategy.RunMode); + } + + [Fact] + public void Constructor_WithFallbackProvider_InitializesSuccessfully() + { + // Act + var strategy = new ComparisonStrategy(this._mockProvider1); + + // Assert + Assert.NotNull(strategy); + Assert.Equal(RunMode.Parallel, strategy.RunMode); + } + + [Fact] + public void Constructor_WithOnMismatchCallback_InitializesSuccessfully() + { + // Arrange + var onMismatch = Substitute.For>>(); + + // Act + var strategy = new ComparisonStrategy(onMismatch: onMismatch); + + // Assert + Assert.NotNull(strategy); + Assert.Equal(RunMode.Parallel, strategy.RunMode); + } + + [Fact] + public void Constructor_WithBothParameters_InitializesSuccessfully() + { + // Arrange + var onMismatch = Substitute.For>>(); + + // Act + var strategy = new ComparisonStrategy(this._mockProvider1, onMismatch); + + // Assert + Assert.NotNull(strategy); + Assert.Equal(RunMode.Parallel, strategy.RunMode); + } + + [Fact] + public void DetermineFinalResult_WithNoProviders_ReturnsErrorResult() + { + // Arrange + var strategy = new ComparisonStrategy(); + var resolutions = new List>(); + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("No providers available or all providers failed", result.Details.ErrorMessage); + Assert.Equal(MultiProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithAllFailedProviders_ReturnsErrorResult() + { + // Arrange + var strategy = new ComparisonStrategy(); + var errorResult1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); + + var errorResult2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.InvalidContext, Reason.Error, errorMessage: "Error from provider2")); + + var resolutions = new List> { errorResult1, errorResult2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("No providers available or all providers failed", result.Details.ErrorMessage); + Assert.Equal(MultiProviderName, result.ProviderName); + Assert.Equal(2, result.Errors.Count); + } + + [Fact] + public void DetermineFinalResult_WithSingleSuccessfulProvider_ReturnsResult() + { + // Arrange + var strategy = new ComparisonStrategy(); + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var resolutions = new List> { successfulResult }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithAgreingProviders_ReturnsFirstResult() + { + // Arrange + var strategy = new ComparisonStrategy(); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant2")); + + var result3 = new ProviderResolutionResult( + this._mockProvider3, + Provider3Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant3")); + + var resolutions = new List> { result1, result2, result3 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithDisagreeingProviders_ReturnsFirstResult() + { + // Arrange + var strategy = new ComparisonStrategy(); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, result2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithDisagreeingProvidersAndFallback_ReturnsFallbackResult() + { + // Arrange + var strategy = new ComparisonStrategy(this._mockProvider2); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, result2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.False(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal("variant2", result.Details.Variant); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal(Provider2Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithDisagreeingProvidersAndNonExistentFallback_ReturnsFirstResult() + { + // Arrange + var nonExistentProvider = Substitute.For(); + var strategy = new ComparisonStrategy(nonExistentProvider); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, result2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithDisagreeingProvidersAndOnMismatchCallback_CallsCallback() + { + // Arrange + var onMismatchCalled = false; + IDictionary? capturedMismatchDetails = null; + + var onMismatch = new Action>(details => + { + onMismatchCalled = true; + capturedMismatchDetails = details; + }); + + var strategy = new ComparisonStrategy(onMismatch: onMismatch); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, result2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.True(onMismatchCalled); + Assert.NotNull(capturedMismatchDetails); + Assert.Equal(2, capturedMismatchDetails.Count); + Assert.True((bool)capturedMismatchDetails[Provider1Name]); + Assert.False((bool)capturedMismatchDetails[Provider2Name]); + } + + [Fact] + public void DetermineFinalResult_WithAgreingProvidersAndOnMismatchCallback_DoesNotCallCallback() + { + // Arrange + var onMismatchCalled = false; + + var onMismatch = new Action>(_ => + { + onMismatchCalled = true; + }); + + var strategy = new ComparisonStrategy(onMismatch: onMismatch); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, result2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.False(onMismatchCalled); + } + + [Fact] + public void DetermineFinalResult_WithMixedSuccessAndErrorResults_IgnoresErrors() + { + // Arrange + var strategy = new ComparisonStrategy(); + var successfulResult1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var errorResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + var successfulResult2 = new ProviderResolutionResult( + this._mockProvider3, + Provider3Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant3")); + + var resolutions = new List> { successfulResult1, errorResult, successfulResult2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithFallbackProviderAndBothSuccessfulAndFallbackAgree_ReturnsFallbackResult() + { + // Arrange + var strategy = new ComparisonStrategy(this._mockProvider2); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var fallbackResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, fallbackResult }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); // Returns first result when all agree + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithFallbackProviderHavingError_UsesFallbackWhenAvailable() + { + // Arrange + var strategy = new ComparisonStrategy(this._mockProvider1); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var errorResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + var result3 = new ProviderResolutionResult( + this._mockProvider3, + Provider3Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant3")); + + var resolutions = new List> { result1, errorResult, result3 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } +} From dedadc869c5b1ab1b40438d8754f7dd8c3ccec0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:17:11 +0100 Subject: [PATCH 37/61] test: add unit tests for ProviderEntry, ProviderStatus, and RegisteredProvider classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Models/ProviderEntryTests.cs | 93 +++++++++++++ .../Models/ProviderStatusTests.cs | 123 +++++++++++++++++ .../Models/RegisteredProviderTests.cs | 127 ++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderEntryTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderEntryTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderEntryTests.cs new file mode 100644 index 00000000..aed5b5a9 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderEntryTests.cs @@ -0,0 +1,93 @@ +using NSubstitute; +using OpenFeature.Providers.MultiProvider.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Models; + +public class ProviderEntryTests +{ + private readonly FeatureProvider _mockProvider = Substitute.For(); + + [Fact] + public void Constructor_WithProvider_CreatesProviderEntry() + { + // Act + var providerEntry = new ProviderEntry(this._mockProvider); + + // Assert + Assert.Equal(this._mockProvider, providerEntry.Provider); + Assert.Null(providerEntry.Name); + } + + [Fact] + public void Constructor_WithProviderAndName_CreatesProviderEntry() + { + // Arrange + const string customName = "custom-provider-name"; + + // Act + var providerEntry = new ProviderEntry(this._mockProvider, customName); + + // Assert + Assert.Equal(this._mockProvider, providerEntry.Provider); + Assert.Equal(customName, providerEntry.Name); + } + + [Fact] + public void Constructor_WithNullProvider_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => new ProviderEntry(null!)); + Assert.Equal("provider", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullName_CreatesProviderEntryWithNullName() + { + // Act + var providerEntry = new ProviderEntry(this._mockProvider, null); + + // Assert + Assert.Equal(this._mockProvider, providerEntry.Provider); + Assert.Null(providerEntry.Name); + } + + [Fact] + public void Constructor_WithEmptyName_CreatesProviderEntryWithEmptyName() + { + // Act + var providerEntry = new ProviderEntry(this._mockProvider, string.Empty); + + // Assert + Assert.Equal(this._mockProvider, providerEntry.Provider); + Assert.Equal(string.Empty, providerEntry.Name); + } + + [Fact] + public void Provider_Property_IsReadOnly() + { + // Arrange + var providerEntry = new ProviderEntry(this._mockProvider); + + // Act & Assert + // Verify that Provider property is read-only by checking it has no setter + var providerProperty = typeof(ProviderEntry).GetProperty(nameof(ProviderEntry.Provider)); + Assert.NotNull(providerProperty); + Assert.True(providerProperty.CanRead); + Assert.False(providerProperty.CanWrite); + } + + [Fact] + public void Name_Property_IsReadOnly() + { + // Arrange + const string customName = "test-name"; + var providerEntry = new ProviderEntry(this._mockProvider, customName); + + // Act & Assert + // Verify that Name property is read-only by checking it has no setter + var nameProperty = typeof(ProviderEntry).GetProperty(nameof(ProviderEntry.Name)); + Assert.NotNull(nameProperty); + Assert.True(nameProperty.CanRead); + Assert.False(nameProperty.CanWrite); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs new file mode 100644 index 00000000..bfd33d11 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs @@ -0,0 +1,123 @@ +using OpenFeature.Providers.MultiProvider.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Models; + +public class ProviderStatusTests +{ + [Fact] + public void Constructor_CreatesProviderStatusWithDefaultValues() + { + // Act + var providerStatus = new ProviderStatus(); + + // Assert + Assert.Equal(string.Empty, providerStatus.ProviderName); + Assert.Null(providerStatus.Exception); + } + + [Fact] + public void ProviderName_CanBeSet() + { + // Arrange + const string providerName = "test-provider"; + var providerStatus = new ProviderStatus(); + + // Act + providerStatus.ProviderName = providerName; + + // Assert + Assert.Equal(providerName, providerStatus.ProviderName); + } + + [Fact] + public void ProviderName_CanBeSetToNull() + { + // Arrange + var providerStatus = new ProviderStatus { ProviderName = "initial-name" }; + + // Act + providerStatus.ProviderName = null!; + + // Assert + Assert.Null(providerStatus.ProviderName); + } + + [Fact] + public void ProviderName_CanBeSetToEmptyString() + { + // Arrange + var providerStatus = new ProviderStatus { ProviderName = "initial-name" }; + + // Act + providerStatus.ProviderName = string.Empty; + + // Assert + Assert.Equal(string.Empty, providerStatus.ProviderName); + } + + [Fact] + public void Exception_CanBeSet() + { + // Arrange + var exception = new InvalidOperationException("Test exception"); + var providerStatus = new ProviderStatus(); + + // Act + providerStatus.Exception = exception; + + // Assert + Assert.Equal(exception, providerStatus.Exception); + } + + [Fact] + public void Exception_CanBeSetToNull() + { + // Arrange + var providerStatus = new ProviderStatus { Exception = new Exception("initial exception") }; + + // Act + providerStatus.Exception = null; + + // Assert + Assert.Null(providerStatus.Exception); + } + + [Fact] + public void ProviderStatus_CanBeInitializedWithObjectInitializer() + { + // Arrange + const string providerName = "test-provider"; + var exception = new ArgumentException("Test exception"); + + // Act + var providerStatus = new ProviderStatus + { + ProviderName = providerName, + Exception = exception + }; + + // Assert + Assert.Equal(providerName, providerStatus.ProviderName); + Assert.Equal(exception, providerStatus.Exception); + } + + [Fact] + public void ProviderName_Property_HasGetterAndSetter() + { + // Act & Assert + var providerNameProperty = typeof(ProviderStatus).GetProperty(nameof(ProviderStatus.ProviderName)); + Assert.NotNull(providerNameProperty); + Assert.True(providerNameProperty.CanRead); + Assert.True(providerNameProperty.CanWrite); + } + + [Fact] + public void Exception_Property_HasGetterAndSetter() + { + // Act & Assert + var exceptionProperty = typeof(ProviderStatus).GetProperty(nameof(ProviderStatus.Exception)); + Assert.NotNull(exceptionProperty); + Assert.True(exceptionProperty.CanRead); + Assert.True(exceptionProperty.CanWrite); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs new file mode 100644 index 00000000..4a2efc76 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs @@ -0,0 +1,127 @@ +using NSubstitute; +using OpenFeature.Providers.MultiProvider.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Models; + +public class RegisteredProviderTests +{ + private readonly FeatureProvider _mockProvider = Substitute.For(); + private const string TestProviderName = "test-provider"; + + [Fact] + public void Constructor_WithValidParameters_CreatesRegisteredProvider() + { + // Act + var registeredProvider = new RegisteredProvider(this._mockProvider, TestProviderName); + + // Assert + Assert.Equal(this._mockProvider, registeredProvider.Provider); + Assert.Equal(TestProviderName, registeredProvider.Name); + } + + [Fact] + public void Constructor_WithNullProvider_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => new RegisteredProvider(null!, TestProviderName)); + Assert.Equal("provider", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullName_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => new RegisteredProvider(this._mockProvider, null!)); + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public void Constructor_WithEmptyName_CreatesRegisteredProviderWithEmptyName() + { + // Act + var registeredProvider = new RegisteredProvider(this._mockProvider, string.Empty); + + // Assert + Assert.Equal(this._mockProvider, registeredProvider.Provider); + Assert.Equal(string.Empty, registeredProvider.Name); + } + + [Fact] + public void Constructor_WithWhitespaceName_CreatesRegisteredProviderWithWhitespaceName() + { + // Arrange + const string whitespaceName = " "; + + // Act + var registeredProvider = new RegisteredProvider(this._mockProvider, whitespaceName); + + // Assert + Assert.Equal(this._mockProvider, registeredProvider.Provider); + Assert.Equal(whitespaceName, registeredProvider.Name); + } + + [Fact] + public void Provider_Property_IsReadOnly() + { + // Arrange + var registeredProvider = new RegisteredProvider(this._mockProvider, TestProviderName); + + // Act & Assert + // Verify that Provider property is read-only by checking it has no setter + var providerProperty = typeof(RegisteredProvider).GetProperty(nameof(RegisteredProvider.Provider)); + Assert.NotNull(providerProperty); + Assert.True(providerProperty.CanRead); + Assert.False(providerProperty.CanWrite); + } + + [Fact] + public void Name_Property_IsReadOnly() + { + // Arrange + var registeredProvider = new RegisteredProvider(this._mockProvider, TestProviderName); + + // Act & Assert + // Verify that Name property is read-only by checking it has no setter + var nameProperty = typeof(RegisteredProvider).GetProperty(nameof(RegisteredProvider.Name)); + Assert.NotNull(nameProperty); + Assert.True(nameProperty.CanRead); + Assert.False(nameProperty.CanWrite); + } + + [Fact] + public void Constructor_WithSameProviderAndDifferentNames_CreatesDistinctInstances() + { + // Arrange + const string name1 = "provider-1"; + const string name2 = "provider-2"; + + // Act + var registeredProvider1 = new RegisteredProvider(this._mockProvider, name1); + var registeredProvider2 = new RegisteredProvider(this._mockProvider, name2); + + // Assert + Assert.Equal(this._mockProvider, registeredProvider1.Provider); + Assert.Equal(this._mockProvider, registeredProvider2.Provider); + Assert.Equal(name1, registeredProvider1.Name); + Assert.Equal(name2, registeredProvider2.Name); + Assert.NotEqual(registeredProvider1.Name, registeredProvider2.Name); + } + + [Fact] + public void Constructor_WithDifferentProvidersAndSameName_CreatesDistinctInstances() + { + // Arrange + var mockProvider2 = Substitute.For(); + + // Act + var registeredProvider1 = new RegisteredProvider(this._mockProvider, TestProviderName); + var registeredProvider2 = new RegisteredProvider(mockProvider2, TestProviderName); + + // Assert + Assert.Equal(this._mockProvider, registeredProvider1.Provider); + Assert.Equal(mockProvider2, registeredProvider2.Provider); + Assert.Equal(TestProviderName, registeredProvider1.Name); + Assert.Equal(TestProviderName, registeredProvider2.Name); + Assert.NotEqual(registeredProvider1.Provider, registeredProvider2.Provider); + } +} From 1ced81ce42d3d33c4b75b9279806472d63543fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:32:45 +0100 Subject: [PATCH 38/61] test: add unit tests for FinalResult and ProviderError classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Strategies/Models/FinalResultTests.cs | 260 ++++++++++++++++++ .../Strategies/Models/ProviderErrorTests.cs | 146 ++++++++++ 2 files changed, 406 insertions(+) create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs new file mode 100644 index 00000000..008f61cf --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs @@ -0,0 +1,260 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies.Models; + +public class FinalResultTests +{ + private const string TestFlagKey = "test-flag"; + private const string TestProviderName = "test-provider"; + private const string TestVariant = "test-variant"; + private const bool TestValue = true; + + private readonly FeatureProvider _mockProvider = Substitute.For(); + private readonly ResolutionDetails _testDetails = new(TestFlagKey, TestValue, ErrorType.None, Reason.Static, TestVariant); + + [Fact] + public void Constructor_WithAllParameters_CreatesFinalResult() + { + // Arrange + var errors = new List + { + new("provider1", new InvalidOperationException("Test error")) + }; + + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, TestProviderName, errors); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(errors, result.Errors); + Assert.Single(result.Errors); + } + + [Fact] + public void Constructor_WithNullErrors_CreatesEmptyErrorsList() + { + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, TestProviderName, null); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.NotNull(result.Errors); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithEmptyErrors_CreatesEmptyErrorsList() + { + // Arrange + var emptyErrors = new List(); + + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, TestProviderName, emptyErrors); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(emptyErrors, result.Errors); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithMultipleErrors_StoresAllErrors() + { + // Arrange + var errors = new List + { + new("provider1", new InvalidOperationException("Error 1")), + new("provider2", new ArgumentException("Error 2")), + new("provider3", null) + }; + + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, TestProviderName, errors); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(errors, result.Errors); + Assert.Equal(3, result.Errors.Count); + } + + [Fact] + public void Constructor_WithDifferentGenericType_CreatesTypedResult() + { + // Arrange + const string stringValue = "test-string-value"; + var stringDetails = new ResolutionDetails(TestFlagKey, stringValue, ErrorType.None, Reason.Static, TestVariant); + + // Act + var result = new FinalResult(stringDetails, this._mockProvider, TestProviderName, null); + + // Assert + Assert.Equal(stringDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithIntegerType_CreatesTypedResult() + { + // Arrange + const int intValue = 42; + var intDetails = new ResolutionDetails(TestFlagKey, intValue, ErrorType.None, Reason.Static, TestVariant); + + // Act + var result = new FinalResult(intDetails, this._mockProvider, TestProviderName, null); + + // Assert + Assert.Equal(intDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithComplexType_CreatesTypedResult() + { + // Arrange + var complexValue = new { Name = "Test", Value = 123 }; + var complexDetails = new ResolutionDetails(TestFlagKey, complexValue, ErrorType.None, Reason.Static, TestVariant); + + // Act + var result = new FinalResult(complexDetails, this._mockProvider, TestProviderName, null); + + // Assert + Assert.Equal(complexDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithErrorDetails_PreservesErrorInformation() + { + // Arrange + var errorDetails = new ResolutionDetails(TestFlagKey, false, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "Provider not ready"); + var errors = new List + { + new(TestProviderName, new InvalidOperationException("Provider initialization failed")) + }; + + // Act + var result = new FinalResult(errorDetails, this._mockProvider, TestProviderName, errors); + + // Assert + Assert.Equal(errorDetails, result.Details); + Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("Provider not ready", result.Details.ErrorMessage); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Single(result.Errors); + } + + [Fact] + public void Details_Property_HasPrivateSetter() + { + // Act & Assert + var detailsProperty = typeof(FinalResult).GetProperty(nameof(FinalResult.Details)); + Assert.NotNull(detailsProperty); + Assert.True(detailsProperty.CanRead); + Assert.True(detailsProperty.CanWrite); + Assert.True(detailsProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void Provider_Property_HasPrivateSetter() + { + // Act & Assert + var providerProperty = typeof(FinalResult).GetProperty(nameof(FinalResult.Provider)); + Assert.NotNull(providerProperty); + Assert.True(providerProperty.CanRead); + Assert.True(providerProperty.CanWrite); + Assert.True(providerProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void ProviderName_Property_HasPrivateSetter() + { + // Act & Assert + var providerNameProperty = typeof(FinalResult).GetProperty(nameof(FinalResult.ProviderName)); + Assert.NotNull(providerNameProperty); + Assert.True(providerNameProperty.CanRead); + Assert.True(providerNameProperty.CanWrite); + Assert.True(providerNameProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void Errors_Property_HasPrivateSetter() + { + // Act & Assert + var errorsProperty = typeof(FinalResult).GetProperty(nameof(FinalResult.Errors)); + Assert.NotNull(errorsProperty); + Assert.True(errorsProperty.CanRead); + Assert.True(errorsProperty.CanWrite); + Assert.True(errorsProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void Constructor_WithNullProvider_StoresNullProvider() + { + // Act + var result = new FinalResult(this._testDetails, null!, TestProviderName, null); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Null(result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithNullProviderName_StoresNullProviderName() + { + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, null!, null); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Null(result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithEmptyProviderName_StoresEmptyProviderName() + { + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, string.Empty, null); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(string.Empty, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithNullDetails_StoresNullDetails() + { + // Act + var result = new FinalResult(null!, this._mockProvider, TestProviderName, null); + + // Assert + Assert.Null(result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs new file mode 100644 index 00000000..b305c2cc --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs @@ -0,0 +1,146 @@ +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies.Models; + +public class ProviderErrorTests +{ + private const string TestProviderName = "test-provider"; + + [Fact] + public void Constructor_WithProviderNameAndException_CreatesProviderError() + { + // Arrange + var exception = new InvalidOperationException("Test exception"); + + // Act + var providerError = new ProviderError(TestProviderName, exception); + + // Assert + Assert.Equal(TestProviderName, providerError.ProviderName); + Assert.Equal(exception, providerError.Error); + } + + [Fact] + public void Constructor_WithProviderNameAndNullException_CreatesProviderError() + { + // Act + var providerError = new ProviderError(TestProviderName, null); + + // Assert + Assert.Equal(TestProviderName, providerError.ProviderName); + Assert.Null(providerError.Error); + } + + [Fact] + public void Constructor_WithNullProviderName_CreatesProviderError() + { + // Arrange + var exception = new ArgumentException("Test exception"); + + // Act + var providerError = new ProviderError(null!, exception); + + // Assert + Assert.Null(providerError.ProviderName); + Assert.Equal(exception, providerError.Error); + } + + [Fact] + public void Constructor_WithEmptyProviderName_CreatesProviderError() + { + // Arrange + var exception = new Exception("Test exception"); + + // Act + var providerError = new ProviderError(string.Empty, exception); + + // Assert + Assert.Equal(string.Empty, providerError.ProviderName); + Assert.Equal(exception, providerError.Error); + } + + [Fact] + public void Constructor_WithWhitespaceProviderName_CreatesProviderError() + { + // Arrange + const string whitespaceName = " "; + var exception = new NotSupportedException("Test exception"); + + // Act + var providerError = new ProviderError(whitespaceName, exception); + + // Assert + Assert.Equal(whitespaceName, providerError.ProviderName); + Assert.Equal(exception, providerError.Error); + } + + [Fact] + public void Constructor_WithDifferentExceptionTypes_CreatesProviderError() + { + // Arrange + var argumentException = new ArgumentException("Argument exception"); + var invalidOperationException = new InvalidOperationException("Invalid operation exception"); + var notImplementedException = new NotImplementedException("Not implemented exception"); + + // Act + var providerError1 = new ProviderError("provider1", argumentException); + var providerError2 = new ProviderError("provider2", invalidOperationException); + var providerError3 = new ProviderError("provider3", notImplementedException); + + // Assert + Assert.Equal("provider1", providerError1.ProviderName); + Assert.Equal(argumentException, providerError1.Error); + Assert.Equal("provider2", providerError2.ProviderName); + Assert.Equal(invalidOperationException, providerError2.Error); + Assert.Equal("provider3", providerError3.ProviderName); + Assert.Equal(notImplementedException, providerError3.Error); + } + + [Fact] + public void ProviderName_Property_HasPrivateSetter() + { + // Act & Assert + var providerNameProperty = typeof(ProviderError).GetProperty(nameof(ProviderError.ProviderName)); + Assert.NotNull(providerNameProperty); + Assert.True(providerNameProperty.CanRead); + Assert.True(providerNameProperty.CanWrite); + Assert.True(providerNameProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void Error_Property_HasPrivateSetter() + { + // Act & Assert + var errorProperty = typeof(ProviderError).GetProperty(nameof(ProviderError.Error)); + Assert.NotNull(errorProperty); + Assert.True(errorProperty.CanRead); + Assert.True(errorProperty.CanWrite); + Assert.True(errorProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void Constructor_WithNullProviderNameAndNullException_CreatesProviderError() + { + // Act + var providerError = new ProviderError(null!, null); + + // Assert + Assert.Null(providerError.ProviderName); + Assert.Null(providerError.Error); + } + + [Fact] + public void Constructor_WithLongProviderName_CreatesProviderError() + { + // Arrange + var longProviderName = new string('a', 1000); + var exception = new TimeoutException("Test exception"); + + // Act + var providerError = new ProviderError(longProviderName, exception); + + // Assert + Assert.Equal(longProviderName, providerError.ProviderName); + Assert.Equal(exception, providerError.Error); + } +} From af70acee585aaea7812db02e27f0ab7c47e15f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:37:29 +0100 Subject: [PATCH 39/61] test: add unit tests for ProviderExtensions functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/ProviderExtensionsTests.cs | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs new file mode 100644 index 00000000..1db2fefd --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs @@ -0,0 +1,334 @@ +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider; + +public class ProviderExtensionsTests +{ + private const string TestFlagKey = "test-flag"; + private const string TestProviderName = "test-provider"; + private const string TestVariant = "test-variant"; + + private readonly FeatureProvider _mockProvider = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + private readonly CancellationToken _cancellationToken = CancellationToken.None; + + [Fact] + public async Task EvaluateAsync_WithBooleanType_CallsResolveBooleanValueAsync() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithStringType_CallsResolveStringValueAsync() + { + // Arrange + const string defaultValue = "default"; + const string resolvedValue = "resolved"; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(string)); + + this._mockProvider.ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithIntegerType_CallsResolveIntegerValueAsync() + { + // Arrange + const int defaultValue = 0; + const int resolvedValue = 42; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(int)); + + this._mockProvider.ResolveIntegerValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveIntegerValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithDoubleType_CallsResolveDoubleValueAsync() + { + // Arrange + const double defaultValue = 0.0; + const double resolvedValue = 3.14; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(double)); + + this._mockProvider.ResolveDoubleValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveDoubleValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithValueType_CallsResolveStructureValueAsync() + { + // Arrange + var defaultValue = new Value(); + var resolvedValue = new Value("resolved"); + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(Value)); + + this._mockProvider.ResolveStructureValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveStructureValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithUnsupportedType_ThrowsArgumentException() + { + // Arrange + var defaultValue = new DateTime(2023, 1, 1); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(DateTime)); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(TestFlagKey, result.ResolutionDetails.FlagKey); + Assert.Equal(defaultValue, result.ResolutionDetails.Value); + Assert.Equal(ErrorType.General, result.ResolutionDetails.ErrorType); + Assert.Equal(Reason.Error, result.ResolutionDetails.Reason); + Assert.Contains("Unsupported flag type", result.ResolutionDetails.ErrorMessage); + Assert.NotNull(result.ThrownError); + Assert.IsType(result.ThrownError); + } + + [Fact] + public async Task EvaluateAsync_WhenProviderThrowsException_ReturnsErrorResult() + { + // Arrange + const bool defaultValue = false; + var expectedException = new InvalidOperationException("Provider error"); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .ThrowsAsync(expectedException); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(TestFlagKey, result.ResolutionDetails.FlagKey); + Assert.Equal(defaultValue, result.ResolutionDetails.Value); + Assert.Equal(ErrorType.General, result.ResolutionDetails.ErrorType); + Assert.Equal(Reason.Error, result.ResolutionDetails.Reason); + Assert.Equal("Provider error", result.ResolutionDetails.ErrorMessage); + Assert.Equal(expectedException, result.ThrownError); + } + + [Fact] + public async Task EvaluateAsync_WithNullEvaluationContext_CallsProviderWithNullContext() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, null, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, null, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, null, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithCancellationToken_PassesToProvider() + { + // Arrange + const string defaultValue = "default"; + const string resolvedValue = "resolved"; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(string)); + var customCancellationToken = new CancellationTokenSource().Token; + + this._mockProvider.ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext, customCancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, customCancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedDetails, result.ResolutionDetails); + await this._mockProvider.Received(1).ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext, customCancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithNullDefaultValue_PassesNullToProvider() + { + // Arrange + string? defaultValue = null; + const string resolvedValue = "resolved"; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(string)); + + this._mockProvider.ResolveStringValueAsync(TestFlagKey, defaultValue!, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue!, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedDetails, result.ResolutionDetails); + await this._mockProvider.Received(1).ResolveStringValueAsync(TestFlagKey, defaultValue!, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithDifferentFlagKeys_UsesCorrectKey() + { + // Arrange + const string customFlagKey = "custom-flag-key"; + const int defaultValue = 0; + const int resolvedValue = 123; + var expectedDetails = new ResolutionDetails(customFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, customFlagKey, typeof(int)); + + this._mockProvider.ResolveIntegerValueAsync(customFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Equal(customFlagKey, result.ResolutionDetails.FlagKey); + await this._mockProvider.Received(1).ResolveIntegerValueAsync(customFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WhenOperationCancelled_ReturnsErrorResult() + { + // Arrange + const bool defaultValue = false; + var cancellationTokenSource = new CancellationTokenSource(); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, cancellationTokenSource.Token) + .Returns(async callInfo => + { + cancellationTokenSource.Cancel(); + await Task.Delay(100, cancellationTokenSource.Token); + return new ResolutionDetails(TestFlagKey, true); + }); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, cancellationTokenSource.Token); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(TestFlagKey, result.ResolutionDetails.FlagKey); + Assert.Equal(defaultValue, result.ResolutionDetails.Value); + Assert.Equal(ErrorType.General, result.ResolutionDetails.ErrorType); + Assert.Equal(Reason.Error, result.ResolutionDetails.Reason); + Assert.NotNull(result.ThrownError); + Assert.True(result.ThrownError is OperationCanceledException); + } + + [Fact] + public async Task EvaluateAsync_WithComplexEvaluationContext_PassesContextToProvider() + { + // Arrange + const double defaultValue = 1.0; + const double resolvedValue = 2.5; + var complexContext = new EvaluationContextBuilder() + .Set("user", "test-user") + .Set("environment", "test") + .Build(); + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(double)); + + this._mockProvider.ResolveDoubleValueAsync(TestFlagKey, defaultValue, complexContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, complexContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedDetails, result.ResolutionDetails); + await this._mockProvider.Received(1).ResolveDoubleValueAsync(TestFlagKey, defaultValue, complexContext, this._cancellationToken); + } +} From be942fb1dcae4dd8bb30f453c28c4fd04802ecfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 4 Jul 2025 17:28:48 +0100 Subject: [PATCH 40/61] test: add unit tests for MultiProvider functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/MultiProviderTests.cs | 623 ++++++++++++++++++ 1 file changed, 623 insertions(+) create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs new file mode 100644 index 00000000..2ad9086d --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs @@ -0,0 +1,623 @@ +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; +using MultiProviderImplementation = OpenFeature.Providers.MultiProvider; + +namespace OpenFeature.Tests.Providers.MultiProvider; + +public class MultiProviderClassTests +{ + private const string TestFlagKey = "test-flag"; + private const string TestVariant = "test-variant"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + private const string Provider3Name = "provider3"; + + private readonly FeatureProvider _mockProvider1 = Substitute.For(); + private readonly FeatureProvider _mockProvider2 = Substitute.For(); + private readonly FeatureProvider _mockProvider3 = Substitute.For(); + private readonly BaseEvaluationStrategy _mockStrategy = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + + public MultiProviderClassTests() + { + // Setup default metadata for providers + this._mockProvider1.GetMetadata().Returns(new Metadata(Provider1Name)); + this._mockProvider2.GetMetadata().Returns(new Metadata(Provider2Name)); + this._mockProvider3.GetMetadata().Returns(new Metadata(Provider3Name)); + + // Setup default strategy behavior + this._mockStrategy.RunMode.Returns(RunMode.Sequential); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any(), Arg.Any()).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any(), Arg.Any(), Arg.Any>()).Returns(false); + } + + [Fact] + public void Constructor_WithValidProviderEntries_CreatesMultiProvider() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + + // Act + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Assert + Assert.NotNull(multiProvider); + var metadata = multiProvider.GetMetadata(); + Assert.Equal("MultiProvider", metadata.Name); + } + + [Fact] + public void Constructor_WithNullProviderEntries_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(null!, this._mockStrategy)); + Assert.Equal("providerEntries", exception.ParamName); + } + + [Fact] + public void Constructor_WithEmptyProviderEntries_ThrowsArgumentException() + { + // Arrange + var emptyProviderEntries = new List(); + + // Act & Assert + var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(emptyProviderEntries, this._mockStrategy)); + Assert.Contains("At least one provider entry must be provided", exception.Message); + Assert.Equal("providerEntries", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullStrategy_UsesDefaultFirstMatchStrategy() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + + // Act + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, null); + + // Assert + Assert.NotNull(multiProvider); + var metadata = multiProvider.GetMetadata(); + Assert.Equal("MultiProvider", metadata.Name); + } + + [Fact] + public void Constructor_WithDuplicateExplicitNames_ThrowsArgumentException() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, "duplicate-name"), + new(this._mockProvider2, "duplicate-name") + }; + + // Act & Assert + var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy)); + Assert.Contains("Multiple providers cannot have the same explicit name: 'duplicate-name'", exception.Message); + } + + [Fact] + public async Task ResolveBooleanValueAsync_CallsEvaluateAsync() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + this._mockStrategy.Received(1).DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()); + } + + [Fact] + public async Task ResolveStringValueAsync_CallsEvaluateAsync() + { + // Arrange + const string defaultValue = "default"; + const string resolvedValue = "resolved"; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + // Act + var result = await multiProvider.ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + } + + [Fact] + public async Task InitializeAsync_WithAllSuccessfulProviders_InitializesAllProviders() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + + // Act + await multiProvider.InitializeAsync(this._evaluationContext); + + // Assert + await this._mockProvider1.Received(1).InitializeAsync(this._evaluationContext, Arg.Any()); + await this._mockProvider2.Received(1).InitializeAsync(this._evaluationContext, Arg.Any()); + } + + [Fact] + public async Task InitializeAsync_WithSomeFailingProviders_ThrowsAggregateException() + { + // Arrange + var expectedException = new InvalidOperationException("Initialization failed"); + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).ThrowsAsync(expectedException); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => multiProvider.InitializeAsync(this._evaluationContext)); + Assert.Contains("Failed to initialize providers", exception.Message); + Assert.Contains(Provider2Name, exception.Message); + Assert.Contains(expectedException, exception.InnerExceptions); + } + + [Fact] + public async Task ShutdownAsync_WithAllSuccessfulProviders_ShutsDownAllProviders() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + + // Act + await multiProvider.ShutdownAsync(); + + // Assert + await this._mockProvider1.Received(1).ShutdownAsync(Arg.Any()); + await this._mockProvider2.Received(1).ShutdownAsync(Arg.Any()); + } + + [Fact] + public async Task ShutdownAsync_WithSomeFailingProviders_ThrowsAggregateException() + { + // Arrange + var expectedException = new InvalidOperationException("Shutdown failed"); + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.ShutdownAsync(Arg.Any()).ThrowsAsync(expectedException); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => multiProvider.ShutdownAsync()); + Assert.Contains("Failed to shutdown providers", exception.Message); + Assert.Contains(Provider2Name, exception.Message); + Assert.Contains(expectedException, exception.InnerExceptions); + } + + [Fact] + public void GetMetadata_ReturnsMultiProviderMetadata() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + var metadata = multiProvider.GetMetadata(); + + // Assert + Assert.NotNull(metadata); + Assert.Equal("MultiProvider", metadata.Name); + } + + [Fact] + public async Task ResolveDoubleValueAsync_CallsEvaluateAsync() + { + // Arrange + const double defaultValue = 1.0; + const double resolvedValue = 2.5; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + // Act + var result = await multiProvider.ResolveDoubleValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + this._mockStrategy.Received(1).DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()); + } + + [Fact] + public async Task ResolveIntegerValueAsync_CallsEvaluateAsync() + { + // Arrange + const int defaultValue = 10; + const int resolvedValue = 42; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + // Act + var result = await multiProvider.ResolveIntegerValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + } + + [Fact] + public async Task ResolveStructureValueAsync_CallsEvaluateAsync() + { + // Arrange + var defaultValue = new Value("default"); + var resolvedValue = new Value("resolved"); + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + // Act + var result = await multiProvider.ResolveStructureValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + } + + [Fact] + public async Task EvaluateAsync_WithSequentialMode_EvaluatesProvidersSequentially() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.RunMode.Returns(RunMode.Sequential); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any(), this._evaluationContext).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any(), this._evaluationContext, Arg.Any>()).Returns(false); + this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) + .Returns(expectedDetails); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + await this._mockProvider1.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()); + await this._mockProvider2.DidNotReceive().ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task EvaluateAsync_WithParallelMode_EvaluatesProvidersInParallel() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.RunMode.Returns(RunMode.Parallel); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any(), this._evaluationContext).Returns(true); + this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) + .Returns(expectedDetails); + this._mockProvider2.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) + .Returns(expectedDetails); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + await this._mockProvider1.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()); + await this._mockProvider2.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()); + } + + [Fact] + public async Task EvaluateAsync_WithUnsupportedRunMode_ThrowsNotSupportedException() + { + // Arrange + const bool defaultValue = false; + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.RunMode.Returns((RunMode)999); // Invalid enum value + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext)); + Assert.Contains("Unsupported run mode", exception.Message); + } + + [Fact] + public async Task EvaluateAsync_WithStrategySkippingProvider_DoesNotCallSkippedProvider() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.RunMode.Returns(RunMode.Sequential); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any(), this._evaluationContext) + .Returns(callInfo => + { + var context = callInfo.Arg(); + return context.ProviderName == Provider1Name; // Only evaluate provider1 + }); + this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) + .Returns(expectedDetails); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + await this._mockProvider1.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()); + await this._mockProvider2.DidNotReceive().ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task EvaluateAsync_WithCancellationToken_PassesCancellationTokenToProviders() + { + // Arrange + const bool defaultValue = false; + var expectedDetails = new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.RunMode.Returns(RunMode.Sequential); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any(), this._evaluationContext).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any(), this._evaluationContext, Arg.Any>()).Returns(false); + this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) + .Returns(expectedDetails); + + using var cts = new CancellationTokenSource(); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, cts.Token); + + // Assert + Assert.Equal(expectedDetails, result); + await this._mockProvider1.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, cts.Token); + } + + [Fact] + public void Constructor_WithProvidersHavingSameMetadataName_AssignsUniqueNames() + { + // Arrange + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + provider1.GetMetadata().Returns(new Metadata("SameName")); + provider2.GetMetadata().Returns(new Metadata("SameName")); + + var providerEntries = new List + { + new(provider1), // No explicit name, will use metadata name + new(provider2) // No explicit name, will use metadata name + }; + + // Act + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Assert + Assert.NotNull(multiProvider); + // The internal logic should assign unique names like "SameName-1", "SameName-2" + var metadata = multiProvider.GetMetadata(); + Assert.Equal("MultiProvider", metadata.Name); + } + + [Fact] + public void Constructor_WithProviderHavingNullMetadata_AssignsDefaultName() + { + // Arrange + var provider = Substitute.For(); + provider.GetMetadata().Returns((Metadata?)null); + + var providerEntries = new List { new(provider) }; + + // Act + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Assert + Assert.NotNull(multiProvider); + var metadata = multiProvider.GetMetadata(); + Assert.Equal("MultiProvider", metadata.Name); + } + + [Fact] + public void Constructor_WithProviderHavingNullMetadataName_AssignsDefaultName() + { + // Arrange + var provider = Substitute.For(); + var metadata = new Metadata(null); + provider.GetMetadata().Returns(metadata); + + var providerEntries = new List { new(provider) }; + + // Act + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Assert + Assert.NotNull(multiProvider); + var multiProviderMetadata = multiProvider.GetMetadata(); + Assert.Equal("MultiProvider", multiProviderMetadata.Name); + } + + [Fact] + public async Task InitializeAsync_WithCancellationToken_PassesCancellationTokenToProviders() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + + using var cts = new CancellationTokenSource(); + + // Act + await multiProvider.InitializeAsync(this._evaluationContext, cts.Token); + + // Assert + await this._mockProvider1.Received(1).InitializeAsync(this._evaluationContext, cts.Token); + } + + [Fact] + public async Task ShutdownAsync_WithCancellationToken_PassesCancellationTokenToProviders() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + + using var cts = new CancellationTokenSource(); + + // Act + await multiProvider.ShutdownAsync(cts.Token); + + // Assert + await this._mockProvider1.Received(1).ShutdownAsync(cts.Token); + } + + [Fact] + public async Task InitializeAsync_WithAllSuccessfulProviders_CompletesWithoutException() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name), + new(this._mockProvider3, Provider3Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider3.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + + // Act & Assert + await multiProvider.InitializeAsync(this._evaluationContext); + + // Verify all providers were called + await this._mockProvider1.Received(1).InitializeAsync(this._evaluationContext, Arg.Any()); + await this._mockProvider2.Received(1).InitializeAsync(this._evaluationContext, Arg.Any()); + await this._mockProvider3.Received(1).InitializeAsync(this._evaluationContext, Arg.Any()); + } + + [Fact] + public async Task ShutdownAsync_WithAllSuccessfulProviders_CompletesWithoutException() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name), + new(this._mockProvider3, Provider3Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider3.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + + // Act & Assert + await multiProvider.ShutdownAsync(); + + // Verify all providers were called + await this._mockProvider1.Received(1).ShutdownAsync(Arg.Any()); + await this._mockProvider2.Received(1).ShutdownAsync(Arg.Any()); + await this._mockProvider3.Received(1).ShutdownAsync(Arg.Any()); + } +} From 3a325ed6bc6c5038ac4560decc8e9e40a46efaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 4 Jul 2025 17:47:40 +0100 Subject: [PATCH 41/61] test: add unit tests for BaseEvaluationStrategy functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Strategies/BaseEvaluationStrategyTests.cs | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs new file mode 100644 index 00000000..d3adbb41 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs @@ -0,0 +1,500 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; + +public class BaseEvaluationStrategyTests +{ + private const string TestFlagKey = "test-flag"; + private const bool DefaultBoolValue = false; + private const string TestVariant = "variant1"; + private const string TestErrorMessage = "Test error message"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + + private readonly TestableBaseEvaluationStrategy _strategy = new(); + private readonly FeatureProvider _mockProvider1 = Substitute.For(); + private readonly FeatureProvider _mockProvider2 = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey, typeof(bool)); + + [Fact] + public void RunMode_DefaultValue_ReturnsSequential() + { + // Act + var result = this._strategy.RunMode; + + // Assert + Assert.Equal(RunMode.Sequential, result); + } + + [Fact] + public void ShouldEvaluateThisProvider_WithReadyProvider_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateThisProvider_WithNotReadyProvider_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.NotReady, TestFlagKey, typeof(bool)); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateThisProvider_WithFatalProvider_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Fatal, TestFlagKey, typeof(bool)); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateThisProvider_WithStaleProvider_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Stale, TestFlagKey, typeof(bool)); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateThisProvider_WithNullEvaluationContext_ReturnsExpectedResult() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, null); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_DefaultImplementation_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var successResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, successResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithErrorResult_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, errorResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithNullEvaluationContext_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var successResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, null, successResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasError_WithThrownException_ReturnsTrue() + { + // Arrange + var exception = new InvalidOperationException(TestErrorMessage); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.None, Reason.Static), + exception); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasError(errorResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasError_WithErrorType_ReturnsTrue() + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasError(errorResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasError_WithNoError_ReturnsFalse() + { + // Arrange + var successResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasError(successResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void CollectProviderErrors_WithThrownExceptions_ReturnsAllErrors() + { + // Arrange + var exception1 = new InvalidOperationException("Error 1"); + var exception2 = new ArgumentException("Error 2"); + + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.None, Reason.Static), exception1), + new(this._mockProvider2, Provider2Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.None, Reason.Static), exception2) + }; + + // Act + var errors = TestableBaseEvaluationStrategy.TestCollectProviderErrors(resolutions); + + // Assert + Assert.Equal(2, errors.Count); + Assert.Equal(Provider1Name, errors[0].ProviderName); + Assert.Equal(exception1, errors[0].Error); + Assert.Equal(Provider2Name, errors[1].ProviderName); + Assert.Equal(exception2, errors[1].Error); + } + + [Fact] + public void CollectProviderErrors_WithErrorTypes_ReturnsAllErrors() + { + // Arrange + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: "Error 1")), + new(this._mockProvider2, Provider2Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Error 2")) + }; + + // Act + var errors = TestableBaseEvaluationStrategy.TestCollectProviderErrors(resolutions); + + // Assert + Assert.Equal(2, errors.Count); + Assert.Equal(Provider1Name, errors[0].ProviderName); + Assert.Equal("Error 1", errors[0].Error?.Message); + Assert.Equal(Provider2Name, errors[1].ProviderName); + Assert.Equal("Error 2", errors[1].Error?.Message); + } + + [Fact] + public void CollectProviderErrors_WithMixedErrors_ReturnsAllErrors() + { + // Arrange + var thrownException = new InvalidOperationException("Thrown error"); + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.None, Reason.Static), thrownException), + new(this._mockProvider2, Provider2Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: "Resolution error")), + new(this._mockProvider1, "provider3", new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static)) + }; + + // Act + var errors = TestableBaseEvaluationStrategy.TestCollectProviderErrors(resolutions); + + // Assert + Assert.Equal(2, errors.Count); + Assert.Equal(Provider1Name, errors[0].ProviderName); + Assert.Equal(thrownException, errors[0].Error); + Assert.Equal(Provider2Name, errors[1].ProviderName); + Assert.Equal("Resolution error", errors[1].Error?.Message); + } + + [Fact] + public void CollectProviderErrors_WithNoErrors_ReturnsEmptyList() + { + // Arrange + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static)), + new(this._mockProvider2, Provider2Name, new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static)) + }; + + // Act + var errors = TestableBaseEvaluationStrategy.TestCollectProviderErrors(resolutions); + + // Assert + Assert.Empty(errors); + } + + [Fact] + public void CollectProviderErrors_WithNullErrorMessage_UsesDefaultMessage() + { + // Arrange + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: null)) + }; + + // Act + var errors = TestableBaseEvaluationStrategy.TestCollectProviderErrors(resolutions); + + // Assert + Assert.Single(errors); + Assert.Equal("unknown error", errors[0].Error?.Message); + } + + [Fact] + public void HasErrorWithCode_WithMatchingErrorType_ReturnsTrue() + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasErrorWithCode(errorResult, ErrorType.FlagNotFound); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasErrorWithCode_WithDifferentErrorType_ReturnsFalse() + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasErrorWithCode(errorResult, ErrorType.FlagNotFound); + + // Assert + Assert.False(result); + } + + [Fact] + public void HasErrorWithCode_WithNoError_ReturnsFalse() + { + // Arrange + var successResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasErrorWithCode(successResult, ErrorType.FlagNotFound); + + // Assert + Assert.False(result); + } + + [Fact] + public void HasErrorWithCode_WithThrownException_ReturnsFalse() + { + // Arrange + var exception = new InvalidOperationException(TestErrorMessage); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.None, Reason.Static), + exception); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasErrorWithCode(errorResult, ErrorType.General); + + // Assert + Assert.False(result); + } + + [Fact] + public void ToFinalResult_WithSuccessResult_ReturnsCorrectFinalResult() + { + // Arrange + var resolutionDetails = new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant); + var providerResult = new ProviderResolutionResult(this._mockProvider1, Provider1Name, resolutionDetails); + + // Act + var finalResult = TestableBaseEvaluationStrategy.TestToFinalResult(providerResult); + + // Assert + Assert.Equal(resolutionDetails, finalResult.Details); + Assert.Equal(this._mockProvider1, finalResult.Provider); + Assert.Equal(Provider1Name, finalResult.ProviderName); + Assert.Empty(finalResult.Errors); + } + + [Fact] + public void ToFinalResult_WithErrorResult_ReturnsCorrectFinalResult() + { + // Arrange + var resolutionDetails = new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage); + var providerResult = new ProviderResolutionResult(this._mockProvider1, Provider1Name, resolutionDetails); + + // Act + var finalResult = TestableBaseEvaluationStrategy.TestToFinalResult(providerResult); + + // Assert + Assert.Equal(resolutionDetails, finalResult.Details); + Assert.Equal(this._mockProvider1, finalResult.Provider); + Assert.Equal(Provider1Name, finalResult.ProviderName); + Assert.Empty(finalResult.Errors); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + public void ShouldEvaluateThisProvider_WithAllowedStatuses_ReturnsTrue(ProviderStatus status) + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, status, TestFlagKey, typeof(bool)); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData(ProviderStatus.NotReady)] + [InlineData(ProviderStatus.Fatal)] + public void ShouldEvaluateThisProvider_WithDisallowedStatuses_ReturnsFalse(ProviderStatus status) + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, status, TestFlagKey, typeof(bool)); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(ErrorType.None)] + [InlineData(ErrorType.FlagNotFound)] + [InlineData(ErrorType.General)] + [InlineData(ErrorType.ParseError)] + [InlineData(ErrorType.TypeMismatch)] + [InlineData(ErrorType.TargetingKeyMissing)] + [InlineData(ErrorType.InvalidContext)] + [InlineData(ErrorType.ProviderNotReady)] + public void HasErrorWithCode_WithAllErrorTypes_ReturnsCorrectResult(ErrorType errorType) + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, errorType, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasErrorWithCode(errorResult, errorType); + + // Assert + Assert.True(result); + } + + [Fact] + public void DetermineFinalResult_IsAbstractMethod_RequiresImplementation() + { + // This test verifies that DetermineFinalResult is abstract and must be implemented + // by testing our concrete implementation + + // Arrange + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)) + }; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result); + Assert.Equal("TestImplementation", result.ProviderName); // From our test implementation + } + + /// + /// Concrete implementation of BaseEvaluationStrategy for testing purposes. + /// + private class TestableBaseEvaluationStrategy : BaseEvaluationStrategy + { + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + { + // Simple test implementation that returns the first result or a default + if (resolutions.Count > 0) + { + return new FinalResult(resolutions[0].ResolutionDetails, resolutions[0].Provider, "TestImplementation", null); + } + + var defaultDetails = new ResolutionDetails(key, defaultValue, ErrorType.None, Reason.Default); + return new FinalResult(defaultDetails, null!, "TestImplementation", null); + } + + // Expose protected methods for testing + public static bool TestHasError(ProviderResolutionResult resolution) => HasError(resolution); + public static List TestCollectProviderErrors(List> resolutions) => CollectProviderErrors(resolutions); + public static bool TestHasErrorWithCode(ProviderResolutionResult resolution, ErrorType errorType) => HasErrorWithCode(resolution, errorType); + public static FinalResult TestToFinalResult(ProviderResolutionResult resolution) => ToFinalResult(resolution); + } +} From c8570fd4a2569e4ed12343c877dc470f4b250278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:24:44 +0100 Subject: [PATCH 42/61] test: add multi-provider endpoint and evaluation logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- samples/AspNetCore/Program.cs | 69 +++++++++++++++++++ .../Providers/MultiProvider/MultiProvider.cs | 4 +- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 5f4f0146..f1695f26 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -3,6 +3,9 @@ using OpenFeature.DependencyInjection.Providers.Memory; using OpenFeature.Hooks; using OpenFeature.Providers.Memory; +using OpenFeature.Providers.MultiProvider; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; @@ -56,4 +59,70 @@ return TypedResults.Ok("Hello world!"); }); +app.MapGet("/multi-provider", async context => +{ + // Create first in-memory provider with some flags + var provider1Flags = new Dictionary + { + { "providername", new Flag(new Dictionary { { "enabled", "enabled-provider1" }, { "disabled", "disabled-provider1" } }, "enabled") }, + { "max-items", new Flag(new Dictionary { { "low", 10 }, { "high", 100 } }, "high") }, + }; + var provider1 = new InMemoryProvider(provider1Flags); + + // Create second in-memory provider with different flags + var provider2Flags = new Dictionary + { + { "providername", new Flag(new Dictionary { { "enabled", "enabled-provider2" }, { "disabled", "disabled-provider2" } }, "enabled") }, + }; + var provider2 = new InMemoryProvider(provider2Flags); + + // Create provider entries + var providerEntries = new List + { + new(provider1, "Provider1"), + new(provider2, "Provider2") + }; + + // Create multi-provider with FirstMatchStrategy (default) + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + + // Set the multi-provider as the default provider using OpenFeature API + await Api.Instance.SetProviderAsync(multiProvider); + + // Create a client directly using the API + var client = Api.Instance.GetClient(); + + try + { + // Test flag evaluation from different providers + var maxItemsFlag = await client.GetIntegerDetailsAsync("max-items", 0); + var providerNameFlag = await client.GetStringDetailsAsync("providername", "default"); + + // Test a flag that doesn't exist in any provider + var unknownFlag = await client.GetBooleanDetailsAsync("unknown-flag", false); + + var result = new + { + message = "Multi-provider evaluation results", + results = new + { + maxItemsFlag = new { value = maxItemsFlag }, + providerNameFlag = new { value = providerNameFlag }, + } + }; + + await context.Response.WriteAsJsonAsync(result); + } + catch (Exception ex) + { + context.Response.StatusCode = 500; + await context.Response.WriteAsJsonAsync(new + { + title = "Multi-provider evaluation failed", + detail = ex.Message, + status = 500 + }); + } +}); + app.Run(); diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index ac79bb70..a1b5e8b9 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -78,7 +78,9 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati { try { - await rp.Provider.InitializeAsync(context, cancellationToken).ConfigureAwait(false); + // TODO: I don't like this part here, at the moment it is the only way we have so we can set the provider name + // but we should find a better way to do this + await Api.Instance.SetProviderAsync(rp.Name, rp.Provider).ConfigureAwait(false); return new ProviderStatus { ProviderName = rp.Name }; } catch (Exception ex) From ac5d07600fba1dba0790e18ea7ae10d336f0bd47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:37:53 +0100 Subject: [PATCH 43/61] refactor: update RegisteredProvider to use internal access modifiers and enhance status management; add unit test for SetStatus method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Models/RegisteredProvider.cs | 27 +++++------ .../Providers/MultiProvider/MultiProvider.cs | 12 ++--- .../Models/RegisteredProviderTests.cs | 45 +++++++------------ 3 files changed, 34 insertions(+), 50 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs index ed658b0f..b3b23e2e 100644 --- a/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs @@ -1,28 +1,21 @@ namespace OpenFeature.Providers.MultiProvider.Models; -/// -/// Represents a registered provider with its unique assigned name. -/// internal class RegisteredProvider { - /// - /// Initializes a new instance of the class. - /// - /// The feature provider instance. - /// The unique assigned name for the provider. - public RegisteredProvider(FeatureProvider provider, string name) + internal RegisteredProvider(FeatureProvider provider, string name) { this.Provider = provider ?? throw new ArgumentNullException(nameof(provider)); this.Name = name ?? throw new ArgumentNullException(nameof(name)); } - /// - /// Gets the feature provider instance. - /// - public FeatureProvider Provider { get; } + internal FeatureProvider Provider { get; } - /// - /// Gets the unique assigned name for the provider. - /// - public string Name { get; } + internal string Name { get; } + + internal Constant.ProviderStatus Status { get; private set; } = Constant.ProviderStatus.NotReady; + + internal void SetStatus(Constant.ProviderStatus status) + { + this.Status = status; + } } diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index a1b5e8b9..2ea12051 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -78,13 +78,13 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati { try { - // TODO: I don't like this part here, at the moment it is the only way we have so we can set the provider name - // but we should find a better way to do this - await Api.Instance.SetProviderAsync(rp.Name, rp.Provider).ConfigureAwait(false); + await rp.Provider.InitializeAsync(context, cancellationToken).ConfigureAwait(false); + rp.SetStatus(Constant.ProviderStatus.Ready); return new ProviderStatus { ProviderName = rp.Name }; } catch (Exception ex) { + rp.SetStatus(Constant.ProviderStatus.Fatal); return new ProviderStatus { ProviderName = rp.Name, Exception = ex }; } }); @@ -110,10 +110,12 @@ public override async Task ShutdownAsync(CancellationToken cancellationToken = d try { await rp.Provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); + rp.SetStatus(Constant.ProviderStatus.NotReady); return new ProviderStatus { ProviderName = rp.Name }; } catch (Exception ex) { + rp.SetStatus(Constant.ProviderStatus.Fatal); return new ProviderStatus { ProviderName = rp.Name, Exception = ex }; } }); @@ -154,7 +156,7 @@ private async Task>> SequentialEvaluationAsync< var providerContext = new StrategyPerProviderContext( registeredProvider.Provider, registeredProvider.Name, - registeredProvider.Provider.Status, + registeredProvider.Status, key, typeof(T)); @@ -185,7 +187,7 @@ private async Task>> ParallelEvaluationAsync var providerContext = new StrategyPerProviderContext( registeredProvider.Provider, registeredProvider.Name, - registeredProvider.Provider.Status, + registeredProvider.Status, key, typeof(T)); diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs index 4a2efc76..8734775a 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs @@ -60,34 +60,6 @@ public void Constructor_WithWhitespaceName_CreatesRegisteredProviderWithWhitespa Assert.Equal(whitespaceName, registeredProvider.Name); } - [Fact] - public void Provider_Property_IsReadOnly() - { - // Arrange - var registeredProvider = new RegisteredProvider(this._mockProvider, TestProviderName); - - // Act & Assert - // Verify that Provider property is read-only by checking it has no setter - var providerProperty = typeof(RegisteredProvider).GetProperty(nameof(RegisteredProvider.Provider)); - Assert.NotNull(providerProperty); - Assert.True(providerProperty.CanRead); - Assert.False(providerProperty.CanWrite); - } - - [Fact] - public void Name_Property_IsReadOnly() - { - // Arrange - var registeredProvider = new RegisteredProvider(this._mockProvider, TestProviderName); - - // Act & Assert - // Verify that Name property is read-only by checking it has no setter - var nameProperty = typeof(RegisteredProvider).GetProperty(nameof(RegisteredProvider.Name)); - Assert.NotNull(nameProperty); - Assert.True(nameProperty.CanRead); - Assert.False(nameProperty.CanWrite); - } - [Fact] public void Constructor_WithSameProviderAndDifferentNames_CreatesDistinctInstances() { @@ -124,4 +96,21 @@ public void Constructor_WithDifferentProvidersAndSameName_CreatesDistinctInstanc Assert.Equal(TestProviderName, registeredProvider2.Name); Assert.NotEqual(registeredProvider1.Provider, registeredProvider2.Provider); } + + [Theory] + [InlineData(Constant.ProviderStatus.Ready)] + [InlineData(Constant.ProviderStatus.Error)] + [InlineData(Constant.ProviderStatus.Fatal)] + [InlineData(Constant.ProviderStatus.NotReady)] + public void SetStatus_WithDifferentStatuses_UpdatesCorrectly(Constant.ProviderStatus status) + { + // Arrange + var registeredProvider = new RegisteredProvider(new TestProvider(), "test"); + + // Act + registeredProvider.SetStatus(status); + + // Assert + Assert.Equal(status, registeredProvider.Status); + } } From 099b75d5b7460371155333e48073c24e11e6e4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:52:30 +0100 Subject: [PATCH 44/61] docs: add Multi-Provider section to README with usage examples and evaluation strategies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- README.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/README.md b/README.md index e87f2584..64396161 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide! | ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | | ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread). | | ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | +| 🔬 | [Multi-Provider](#multi-provider) | Use multiple feature flag providers simultaneously with configurable evaluation strategies. | | 🔬 | [DependencyInjection](#DependencyInjection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. | > Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ | Experimental: 🔬 @@ -433,6 +434,96 @@ Hooks support passing per-evaluation data between that stages using `hook data`. Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs! +### Multi-Provider + +> [!NOTE] +> The Multi-Provider feature is currently experimental. Hooks and events are not supported at the moment. + +The Multi-Provider enables the use of multiple underlying feature flag providers simultaneously, allowing different providers to be used for different flag keys or based on specific evaluation strategies. + +#### Basic Usage + +```csharp +using OpenFeature.Providers.MultiProvider; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; + +// Create provider entries +var providerEntries = new List +{ + new(new InMemoryProvider(provider1Flags), "Provider1"), + new(new InMemoryProvider(provider2Flags), "Provider2") +}; + +// Create multi-provider with FirstMatchStrategy (default) +var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + +// Set as the default provider +await Api.Instance.SetProviderAsync(multiProvider); + +// Use normally - the multi-provider will handle delegation +var client = Api.Instance.GetClient(); +var flagValue = await client.GetBooleanValueAsync("my-flag", false); +``` + +#### Evaluation Strategies + +The Multi-Provider supports different evaluation strategies that determine how multiple providers are used: + +##### FirstMatchStrategy (Default) + +Evaluates providers sequentially and returns the first result that is not "flag not found". If any provider returns an error, that error is returned immediately. + +```csharp +var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); +``` + +##### FirstSuccessfulStrategy + +Evaluates providers sequentially and returns the first successful result, ignoring errors. Only if all providers fail will errors be returned. + +```csharp +var multiProvider = new MultiProvider(providerEntries, new FirstSuccessfulStrategy()); +``` + +##### ComparisonStrategy + +Evaluates all providers in parallel and compares results. If values agree, returns the agreed value. If they disagree, returns the fallback provider's value (or first provider if no fallback is specified) and optionally calls a mismatch callback. + +```csharp +// Basic comparison +var multiProvider = new MultiProvider(providerEntries, new ComparisonStrategy()); + +// With fallback provider +var multiProvider = new MultiProvider(providerEntries, + new ComparisonStrategy(fallbackProvider: provider1)); + +// With mismatch callback +var multiProvider = new MultiProvider(providerEntries, + new ComparisonStrategy(onMismatch: (mismatchDetails) => { + // Log or handle mismatches between providers + foreach (var kvp in mismatchDetails) + { + Console.WriteLine($"Provider {kvp.Key}: {kvp.Value}"); + } + })); +``` + +#### Evaluation Modes + +The Multi-Provider supports two evaluation modes: + +- **Sequential**: Providers are evaluated one after another (used by `FirstMatchStrategy` and `FirstSuccessfulStrategy`) +- **Parallel**: All providers are evaluated simultaneously (used by `ComparisonStrategy`) + +#### Limitations + +- **Hooks are not supported**: Multi-Provider does not currently support hook registration or execution +- **Events are not supported**: Provider events are not propagated from underlying providers +- **Experimental status**: The API may change in future releases + +For a complete example, see the [AspNetCore sample](./samples/AspNetCore/README.md) which demonstrates Multi-Provider usage. + ### DependencyInjection > [!NOTE] From bbdf85d191f8d6d4749588f6ba76f1c55cc76181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 12 Jul 2025 08:18:58 +0100 Subject: [PATCH 45/61] refactor: change properties in StrategyPerProviderContext to use read-only accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Strategies/Models/StrategyPerProviderContext.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs index 5623faa1..8cc40a28 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs @@ -26,15 +26,15 @@ public StrategyPerProviderContext(FeatureProvider provider, string providerName, /// /// The feature provider instance. /// - public FeatureProvider Provider { get; private set; } + public FeatureProvider Provider { get; } /// /// The name/identifier of the provider. /// - public string ProviderName { get; private set; } + public string ProviderName { get; } /// /// The current status of the provider. /// - public ProviderStatus ProviderStatus { get; private set; } + public ProviderStatus ProviderStatus { get; } } From 19fd5892707d5b359c4d25963cc9f18f1aaafc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 12 Jul 2025 08:20:13 +0100 Subject: [PATCH 46/61] docs: update summary comments in FirstMatchStrategy and FirstSuccessfulStrategy to clarify provider evaluation order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Providers/MultiProvider/Strategies/FirstMatchStrategy.cs | 1 + .../MultiProvider/Strategies/FirstSuccessfulStrategy.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs index 28b1f230..71805215 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs @@ -6,6 +6,7 @@ namespace OpenFeature.Providers.MultiProvider.Strategies; /// /// Return the first result that did not indicate "flag not found". +/// Providers are evaluated sequentially in the order they were configured. /// If any provider in the course of evaluation returns or throws an error, throw that error /// public sealed class FirstMatchStrategy : BaseEvaluationStrategy diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs index 6e535842..bb948fc9 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs @@ -6,6 +6,7 @@ namespace OpenFeature.Providers.MultiProvider.Strategies; /// /// Return the first result that did not result in an error. +/// Providers are evaluated sequentially in the order they were configured. /// If any provider in the course of evaluation returns or throws an error, ignore it as long as there is a successful result. /// If there is no successful result, throw all errors. /// From 1e293cddf312ded39f262c7e849ce457a4e42784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 12 Jul 2025 08:40:12 +0100 Subject: [PATCH 47/61] Refactor StrategyEvaluationContext to use generic types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Providers/MultiProvider/MultiProvider.cs | 12 +++--- .../MultiProvider/ProviderExtensions.cs | 2 +- .../Strategies/BaseEvaluationStrategy.cs | 7 ++-- .../Strategies/ComparisonStrategy.cs | 2 +- .../Strategies/FirstMatchStrategy.cs | 4 +- .../Strategies/FirstSuccessfulStrategy.cs | 4 +- .../Models/StrategyEvaluationContext.cs | 14 ++----- .../Models/StrategyPerProviderContext.cs | 10 ++--- .../MultiProvider/MultiProviderTests.cs | 40 +++++++++---------- .../MultiProvider/ProviderExtensionsTests.cs | 26 ++++++------ .../Strategies/BaseEvaluationStrategyTests.cs | 24 +++++------ .../Strategies/ComparisonStrategyTests.cs | 2 +- .../Strategies/FirstMatchStrategyTests.cs | 16 ++++---- .../FirstSuccessfulStrategyTests.cs | 10 ++--- 14 files changed, 83 insertions(+), 90 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 2ea12051..8b2b4485 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -135,7 +135,7 @@ public override async Task ShutdownAsync(CancellationToken cancellationToken = d private async Task> EvaluateAsync(string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) { - var strategyContext = new StrategyEvaluationContext(key, typeof(T)); + var strategyContext = new StrategyEvaluationContext(key); var resolutions = this._evaluationStrategy.RunMode switch { RunMode.Parallel => await this.ParallelEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), @@ -153,12 +153,11 @@ private async Task>> SequentialEvaluationAsync< foreach (var registeredProvider in this._registeredProviders) { - var providerContext = new StrategyPerProviderContext( + var providerContext = new StrategyPerProviderContext( registeredProvider.Provider, registeredProvider.Name, registeredProvider.Status, - key, - typeof(T)); + key); if (!this._evaluationStrategy.ShouldEvaluateThisProvider(providerContext, evaluationContext)) { @@ -184,12 +183,11 @@ private async Task>> ParallelEvaluationAsync foreach (var registeredProvider in this._registeredProviders) { - var providerContext = new StrategyPerProviderContext( + var providerContext = new StrategyPerProviderContext( registeredProvider.Provider, registeredProvider.Name, registeredProvider.Status, - key, - typeof(T)); + key); if (this._evaluationStrategy.ShouldEvaluateThisProvider(providerContext, evaluationContext)) { diff --git a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs index 3207aca0..4b555cc8 100644 --- a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs +++ b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs @@ -8,7 +8,7 @@ internal static class ProviderExtensions { internal static async Task> EvaluateAsync( this FeatureProvider provider, - StrategyPerProviderContext providerContext, + StrategyPerProviderContext providerContext, EvaluationContext? evaluationContext, T defaultValue, CancellationToken cancellationToken) diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs index f9464f6c..f31b2c4a 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs @@ -22,10 +22,11 @@ public abstract class BaseEvaluationStrategy /// /// Determines whether a specific provider should be evaluated. /// + /// The type of the flag value. /// Context information about the provider and evaluation. /// The evaluation context for the flag resolution. /// True if the provider should be evaluated, false otherwise. - public virtual bool ShouldEvaluateThisProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext) + public virtual bool ShouldEvaluateThisProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext) { // Skip providers that are not ready or have fatal errors return strategyContext.ProviderStatus is not (ProviderStatus.NotReady or ProviderStatus.Fatal); @@ -40,7 +41,7 @@ public virtual bool ShouldEvaluateThisProvider(StrategyPerProviderContext strate /// The evaluation context for the flag resolution. /// The result from the current provider evaluation. /// True if the next provider should be evaluated, false otherwise. - public virtual bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) + public virtual bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) { return true; } @@ -55,7 +56,7 @@ public virtual bool ShouldEvaluateNextProvider(StrategyPerProviderContext str /// The evaluation context for the flag resolution. /// All resolution results from provider evaluations. /// The final evaluation result. - public abstract FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions); + public abstract FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions); /// /// Checks if a resolution result represents an error. diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs index b9bbdbe3..7fa0b7bb 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs @@ -30,7 +30,7 @@ public ComparisonStrategy(FeatureProvider? fallbackProvider = null, Action - public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) { var successfulResolutions = resolutions.Where(r => !HasError(r)).ToList(); diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs index 71805215..4ec15bac 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs @@ -12,13 +12,13 @@ namespace OpenFeature.Providers.MultiProvider.Strategies; public sealed class FirstMatchStrategy : BaseEvaluationStrategy { /// - public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) + public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) { return HasErrorWithCode(result, ErrorType.FlagNotFound); } /// - public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) { var lastResult = resolutions.LastOrDefault(); if (lastResult != null) diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs index bb948fc9..daf42985 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs @@ -13,14 +13,14 @@ namespace OpenFeature.Providers.MultiProvider.Strategies; public sealed class FirstSuccessfulStrategy : BaseEvaluationStrategy { /// - public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) + public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) { // evaluate next only if there was an error return HasError(result); } /// - public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) { if (resolutions.Count == 0) { diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs index fa68f904..215c85e4 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs @@ -3,26 +3,20 @@ namespace OpenFeature.Providers.MultiProvider.Strategies.Models; /// /// Evaluation context specific to strategy evaluation containing flag-related information. /// -public class StrategyEvaluationContext +/// The type of the flag value being evaluated. +public class StrategyEvaluationContext { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The feature flag key being evaluated. - /// The type of the flag value being evaluated. - public StrategyEvaluationContext(string flagKey, Type flagType) + public StrategyEvaluationContext(string flagKey) { this.FlagKey = flagKey; - this.FlagType = flagType; } /// /// The feature flag key being evaluated. /// public string FlagKey { get; private set; } - - /// - /// The type of the flag value being evaluated. - /// - public Type FlagType { get; private set; } } diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs index 8cc40a28..4abc434a 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs @@ -5,18 +5,18 @@ namespace OpenFeature.Providers.MultiProvider.Strategies.Models; /// /// Per-provider context containing provider-specific information for strategy evaluation. /// -public class StrategyPerProviderContext : StrategyEvaluationContext +/// The type of the flag value being evaluated. +public class StrategyPerProviderContext : StrategyEvaluationContext { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The feature provider instance. /// The name/identifier of the provider. /// The current status of the provider. /// The feature flag key being evaluated. - /// The type of the flag value being evaluated. - public StrategyPerProviderContext(FeatureProvider provider, string providerName, ProviderStatus providerStatus, string key, Type flagType) - : base(key, flagType) + public StrategyPerProviderContext(FeatureProvider provider, string providerName, ProviderStatus providerStatus, string key) + : base(key) { this.Provider = provider; this.ProviderName = providerName; diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs index 2ad9086d..37fdb281 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs @@ -32,8 +32,8 @@ public MultiProviderClassTests() // Setup default strategy behavior this._mockStrategy.RunMode.Returns(RunMode.Sequential); - this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any(), Arg.Any()).Returns(true); - this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any(), Arg.Any(), Arg.Any>()).Returns(false); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), Arg.Any()).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), Arg.Any(), Arg.Any>()).Returns(false); } [Fact] @@ -117,7 +117,7 @@ public async Task ResolveBooleanValueAsync_CallsEvaluateAsync() var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); - this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); // Act @@ -125,7 +125,7 @@ public async Task ResolveBooleanValueAsync_CallsEvaluateAsync() // Assert Assert.Equal(expectedDetails, result); - this._mockStrategy.Received(1).DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()); + this._mockStrategy.Received(1).DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()); } [Fact] @@ -140,7 +140,7 @@ public async Task ResolveStringValueAsync_CallsEvaluateAsync() var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); - this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); // Act @@ -265,7 +265,7 @@ public async Task ResolveDoubleValueAsync_CallsEvaluateAsync() var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); - this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); // Act @@ -273,7 +273,7 @@ public async Task ResolveDoubleValueAsync_CallsEvaluateAsync() // Assert Assert.Equal(expectedDetails, result); - this._mockStrategy.Received(1).DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()); + this._mockStrategy.Received(1).DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()); } [Fact] @@ -288,7 +288,7 @@ public async Task ResolveIntegerValueAsync_CallsEvaluateAsync() var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); - this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); // Act @@ -310,7 +310,7 @@ public async Task ResolveStructureValueAsync_CallsEvaluateAsync() var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); - this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); // Act @@ -337,9 +337,9 @@ public async Task EvaluateAsync_WithSequentialMode_EvaluatesProvidersSequentiall var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.RunMode.Returns(RunMode.Sequential); - this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any(), this._evaluationContext).Returns(true); - this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any(), this._evaluationContext, Arg.Any>()).Returns(false); - this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), this._evaluationContext, Arg.Any>()).Returns(false); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) @@ -371,8 +371,8 @@ public async Task EvaluateAsync_WithParallelMode_EvaluatesProvidersInParallel() var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.RunMode.Returns(RunMode.Parallel); - this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any(), this._evaluationContext).Returns(true); - this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) @@ -422,13 +422,13 @@ public async Task EvaluateAsync_WithStrategySkippingProvider_DoesNotCallSkippedP var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.RunMode.Returns(RunMode.Sequential); - this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any(), this._evaluationContext) + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext) .Returns(callInfo => { - var context = callInfo.Arg(); + var context = callInfo.Arg>(); return context.ProviderName == Provider1Name; // Only evaluate provider1 }); - this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) @@ -455,9 +455,9 @@ public async Task EvaluateAsync_WithCancellationToken_PassesCancellationTokenToP var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.RunMode.Returns(RunMode.Sequential); - this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any(), this._evaluationContext).Returns(true); - this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any(), this._evaluationContext, Arg.Any>()).Returns(false); - this._mockStrategy.DetermineFinalResult(Arg.Any(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), this._evaluationContext, Arg.Any>()).Returns(false); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs index 1db2fefd..702fc397 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs @@ -24,7 +24,7 @@ public async Task EvaluateAsync_WithBooleanType_CallsResolveBooleanValueAsync() const bool defaultValue = false; const bool resolvedValue = true; var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) .Returns(expectedDetails); @@ -48,7 +48,7 @@ public async Task EvaluateAsync_WithStringType_CallsResolveStringValueAsync() const string defaultValue = "default"; const string resolvedValue = "resolved"; var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(string)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) .Returns(expectedDetails); @@ -72,7 +72,7 @@ public async Task EvaluateAsync_WithIntegerType_CallsResolveIntegerValueAsync() const int defaultValue = 0; const int resolvedValue = 42; var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(int)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveIntegerValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) .Returns(expectedDetails); @@ -96,7 +96,7 @@ public async Task EvaluateAsync_WithDoubleType_CallsResolveDoubleValueAsync() const double defaultValue = 0.0; const double resolvedValue = 3.14; var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(double)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveDoubleValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) .Returns(expectedDetails); @@ -120,7 +120,7 @@ public async Task EvaluateAsync_WithValueType_CallsResolveStructureValueAsync() var defaultValue = new Value(); var resolvedValue = new Value("resolved"); var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(Value)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveStructureValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) .Returns(expectedDetails); @@ -142,7 +142,7 @@ public async Task EvaluateAsync_WithUnsupportedType_ThrowsArgumentException() { // Arrange var defaultValue = new DateTime(2023, 1, 1); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(DateTime)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); // Act var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); @@ -166,7 +166,7 @@ public async Task EvaluateAsync_WhenProviderThrowsException_ReturnsErrorResult() // Arrange const bool defaultValue = false; var expectedException = new InvalidOperationException("Provider error"); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) .ThrowsAsync(expectedException); @@ -193,7 +193,7 @@ public async Task EvaluateAsync_WithNullEvaluationContext_CallsProviderWithNullC const bool defaultValue = false; const bool resolvedValue = true; var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, null, this._cancellationToken) .Returns(expectedDetails); @@ -217,7 +217,7 @@ public async Task EvaluateAsync_WithCancellationToken_PassesToProvider() const string defaultValue = "default"; const string resolvedValue = "resolved"; var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(string)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); var customCancellationToken = new CancellationTokenSource().Token; this._mockProvider.ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext, customCancellationToken) @@ -239,7 +239,7 @@ public async Task EvaluateAsync_WithNullDefaultValue_PassesNullToProvider() string? defaultValue = null; const string resolvedValue = "resolved"; var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(string)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveStringValueAsync(TestFlagKey, defaultValue!, this._evaluationContext, this._cancellationToken) .Returns(expectedDetails); @@ -261,7 +261,7 @@ public async Task EvaluateAsync_WithDifferentFlagKeys_UsesCorrectKey() const int defaultValue = 0; const int resolvedValue = 123; var expectedDetails = new ResolutionDetails(customFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, customFlagKey, typeof(int)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, customFlagKey); this._mockProvider.ResolveIntegerValueAsync(customFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) .Returns(expectedDetails); @@ -282,7 +282,7 @@ public async Task EvaluateAsync_WhenOperationCancelled_ReturnsErrorResult() // Arrange const bool defaultValue = false; var cancellationTokenSource = new CancellationTokenSource(); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, cancellationTokenSource.Token) .Returns(async callInfo => @@ -318,7 +318,7 @@ public async Task EvaluateAsync_WithComplexEvaluationContext_PassesContextToProv .Set("environment", "test") .Build(); var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); - var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey, typeof(double)); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveDoubleValueAsync(TestFlagKey, defaultValue, complexContext, this._cancellationToken) .Returns(expectedDetails); diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs index d3adbb41..f2960be0 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs @@ -19,7 +19,7 @@ public class BaseEvaluationStrategyTests private readonly FeatureProvider _mockProvider1 = Substitute.For(); private readonly FeatureProvider _mockProvider2 = Substitute.For(); private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); - private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey, typeof(bool)); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey); [Fact] public void RunMode_DefaultValue_ReturnsSequential() @@ -35,7 +35,7 @@ public void RunMode_DefaultValue_ReturnsSequential() public void ShouldEvaluateThisProvider_WithReadyProvider_ReturnsTrue() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); // Act var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); @@ -48,7 +48,7 @@ public void ShouldEvaluateThisProvider_WithReadyProvider_ReturnsTrue() public void ShouldEvaluateThisProvider_WithNotReadyProvider_ReturnsFalse() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.NotReady, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.NotReady, TestFlagKey); // Act var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); @@ -61,7 +61,7 @@ public void ShouldEvaluateThisProvider_WithNotReadyProvider_ReturnsFalse() public void ShouldEvaluateThisProvider_WithFatalProvider_ReturnsFalse() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Fatal, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Fatal, TestFlagKey); // Act var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); @@ -74,7 +74,7 @@ public void ShouldEvaluateThisProvider_WithFatalProvider_ReturnsFalse() public void ShouldEvaluateThisProvider_WithStaleProvider_ReturnsTrue() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Stale, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Stale, TestFlagKey); // Act var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); @@ -87,7 +87,7 @@ public void ShouldEvaluateThisProvider_WithStaleProvider_ReturnsTrue() public void ShouldEvaluateThisProvider_WithNullEvaluationContext_ReturnsExpectedResult() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); // Act var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, null); @@ -100,7 +100,7 @@ public void ShouldEvaluateThisProvider_WithNullEvaluationContext_ReturnsExpected public void ShouldEvaluateNextProvider_DefaultImplementation_ReturnsTrue() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var successResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -117,7 +117,7 @@ public void ShouldEvaluateNextProvider_DefaultImplementation_ReturnsTrue() public void ShouldEvaluateNextProvider_WithErrorResult_ReturnsTrue() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var errorResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -134,7 +134,7 @@ public void ShouldEvaluateNextProvider_WithErrorResult_ReturnsTrue() public void ShouldEvaluateNextProvider_WithNullEvaluationContext_ReturnsTrue() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var successResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -406,7 +406,7 @@ public void ToFinalResult_WithErrorResult_ReturnsCorrectFinalResult() public void ShouldEvaluateThisProvider_WithAllowedStatuses_ReturnsTrue(ProviderStatus status) { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, status, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, status, TestFlagKey); // Act var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); @@ -421,7 +421,7 @@ public void ShouldEvaluateThisProvider_WithAllowedStatuses_ReturnsTrue(ProviderS public void ShouldEvaluateThisProvider_WithDisallowedStatuses_ReturnsFalse(ProviderStatus status) { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, status, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, status, TestFlagKey); // Act var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); @@ -479,7 +479,7 @@ public void DetermineFinalResult_IsAbstractMethod_RequiresImplementation() /// private class TestableBaseEvaluationStrategy : BaseEvaluationStrategy { - public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) { // Simple test implementation that returns the first result or a default if (resolutions.Count > 0) diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs index 82630863..480ef6b9 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs @@ -21,7 +21,7 @@ public class ComparisonStrategyTests private readonly FeatureProvider _mockProvider2 = Substitute.For(); private readonly FeatureProvider _mockProvider3 = Substitute.For(); private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); - private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey, typeof(bool)); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey); [Fact] public void RunMode_ReturnsParallel() diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs index 973d36d4..8c95ef00 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs @@ -21,7 +21,7 @@ public class FirstMatchStrategyTests private readonly FeatureProvider _mockProvider1 = Substitute.For(); private readonly FeatureProvider _mockProvider2 = Substitute.For(); private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); - private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey, typeof(bool)); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey); [Fact] public void RunMode_ReturnsSequential() @@ -37,7 +37,7 @@ public void RunMode_ReturnsSequential() public void ShouldEvaluateNextProvider_WithFlagNotFoundError_ReturnsTrue() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var flagNotFoundResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -54,7 +54,7 @@ public void ShouldEvaluateNextProvider_WithFlagNotFoundError_ReturnsTrue() public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var successfulResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -71,7 +71,7 @@ public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() public void ShouldEvaluateNextProvider_WithGeneralError_ReturnsFalse() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var generalErrorResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -88,7 +88,7 @@ public void ShouldEvaluateNextProvider_WithGeneralError_ReturnsFalse() public void ShouldEvaluateNextProvider_WithInvalidContextError_ReturnsFalse() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var invalidContextResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -105,7 +105,7 @@ public void ShouldEvaluateNextProvider_WithInvalidContextError_ReturnsFalse() public void ShouldEvaluateNextProvider_WithThrownException_ReturnsFalse() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var exceptionResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -273,7 +273,7 @@ public void DetermineFinalResult_WithStringType_ReturnsCorrectType() new ResolutionDetails(TestFlagKey, testStringValue, ErrorType.None, Reason.Static, stringVariant)); var resolutions = new List> { successfulResult }; - var stringStrategyContext = new StrategyEvaluationContext(TestFlagKey, typeof(string)); + var stringStrategyContext = new StrategyEvaluationContext(TestFlagKey); // Act var result = this._strategy.DetermineFinalResult(stringStrategyContext, TestFlagKey, defaultStringValue, this._evaluationContext, resolutions); @@ -304,7 +304,7 @@ public void DetermineFinalResult_WithIntType_ReturnsCorrectType() new ResolutionDetails(TestFlagKey, testIntValue, ErrorType.None, Reason.Static, intVariant)); var resolutions = new List> { successfulResult }; - var intStrategyContext = new StrategyEvaluationContext(TestFlagKey, typeof(int)); + var intStrategyContext = new StrategyEvaluationContext(TestFlagKey); // Act var result = this._strategy.DetermineFinalResult(intStrategyContext, TestFlagKey, defaultIntValue, this._evaluationContext, resolutions); diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs index 2960691e..da0d8740 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs @@ -20,7 +20,7 @@ public class FirstSuccessfulStrategyTests private readonly FeatureProvider _mockProvider2 = Substitute.For(); private readonly FeatureProvider _mockProvider3 = Substitute.For(); private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); - private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey, typeof(bool)); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey); [Fact] public void RunMode_ReturnsSequential() @@ -36,7 +36,7 @@ public void RunMode_ReturnsSequential() public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var successfulResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -53,7 +53,7 @@ public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() public void ShouldEvaluateNextProvider_WithErrorResult_ReturnsTrue() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var errorResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -70,7 +70,7 @@ public void ShouldEvaluateNextProvider_WithErrorResult_ReturnsTrue() public void ShouldEvaluateNextProvider_WithThrownException_ReturnsTrue() { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var exceptionResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, @@ -225,7 +225,7 @@ public void DetermineFinalResult_WithNullEvaluationContext_HandlesGracefully() public void ShouldEvaluateNextProvider_WithDifferentErrorTypes_ReturnsTrue(ErrorType errorType) { // Arrange - var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey, typeof(bool)); + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); var errorResult = new ProviderResolutionResult( this._mockProvider1, Provider1Name, From e48b6bd40d546f252e758e3a012c7d57b31279a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 12 Jul 2025 08:49:51 +0100 Subject: [PATCH 48/61] refactor: simplify flag resolution logic in EvaluateAsync method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/ProviderExtensions.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs index 4b555cc8..d8f70dfb 100644 --- a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs +++ b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs @@ -17,17 +17,17 @@ internal static async Task> EvaluateAsync( try { - // Perform the actual flag resolution - var result = typeof(T) switch + var result = defaultValue switch { - { } t when t == typeof(bool) => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, (bool)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(int) => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, (int)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(double) => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, (double)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - { } t when t == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + bool boolDefaultValue => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, boolDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + string stringDefaultValue => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, stringDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + int intDefaultValue => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, intDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + double doubleDefaultValue => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, doubleDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + Value valueDefaultValue => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, valueDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + null when typeof(T) == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + null when typeof(T) == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), _ => throw new ArgumentException($"Unsupported flag type: {typeof(T)}") }; - return new ProviderResolutionResult(provider, providerContext.ProviderName, result); } catch (Exception ex) From 828d56413f0e1844d059a4c1c353f951af88af65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 12 Jul 2025 09:03:19 +0100 Subject: [PATCH 49/61] refactor: replace hardcoded provider name with constant in MultiProvider strategies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Providers/MultiProvider/MultiProvider.cs | 2 +- .../MultiProvider/MultiProviderConstants.cs | 12 ++++++++++++ .../MultiProvider/Strategies/ComparisonStrategy.cs | 2 +- .../MultiProvider/Strategies/FirstMatchStrategy.cs | 4 ++-- .../Strategies/FirstSuccessfulStrategy.cs | 4 ++-- 5 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 8b2b4485..2eaeeda8 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -44,7 +44,7 @@ public MultiProvider(IEnumerable providerEntries, BaseEvaluationS this._registeredProviders = RegisterProviders(entries); // Create aggregate metadata - this._metadata = new Metadata("MultiProvider"); + this._metadata = new Metadata(MultiProviderConstants.ProviderName); } /// diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs b/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs new file mode 100644 index 00000000..76df2444 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs @@ -0,0 +1,12 @@ +namespace OpenFeature.Providers.MultiProvider; + +/// +/// Constants used by the MultiProvider. +/// +internal static class MultiProviderConstants +{ + /// + /// The provider name for MultiProvider. + /// + public const string ProviderName = "MultiProvider"; +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs index 7fa0b7bb..8438ac3c 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs @@ -38,7 +38,7 @@ public override FinalResult DetermineFinalResult(StrategyEvaluationContext { var errorDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); var errors = resolutions.Select(r => new ProviderError(r.ProviderName, new InvalidOperationException($"Provider {r.ProviderName} failed"))).ToList(); - return new FinalResult(errorDetails, null!, "MultiProvider", errors); + return new FinalResult(errorDetails, null!, MultiProviderConstants.ProviderName, errors); } var firstResult = successfulResolutions.First(); diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs index 4ec15bac..88eba550 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs @@ -29,8 +29,8 @@ public override FinalResult DetermineFinalResult(StrategyEvaluationContext var errorDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); var errors = new List { - new("MultiProvider", new InvalidOperationException("No providers available or all providers failed")) + new(MultiProviderConstants.ProviderName, new InvalidOperationException("No providers available or all providers failed")) }; - return new FinalResult(errorDetails, null!, "MultiProvider", errors); + return new FinalResult(errorDetails, null!, MultiProviderConstants.ProviderName, errors); } } diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs index daf42985..7caef6a5 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs @@ -27,9 +27,9 @@ public override FinalResult DetermineFinalResult(StrategyEvaluationContext var noProvidersDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); var noProvidersErrors = new List { - new("MultiProvider", new InvalidOperationException("No providers available or all providers failed")) + new(MultiProviderConstants.ProviderName, new InvalidOperationException("No providers available or all providers failed")) }; - return new FinalResult(noProvidersDetails, null!, "MultiProvider", noProvidersErrors); + return new FinalResult(noProvidersDetails, null!, MultiProviderConstants.ProviderName, noProvidersErrors); } // Find the first successful result From dac15c6f34930eae3374ddd619721965765c260b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 28 Jul 2025 07:20:37 +0100 Subject: [PATCH 50/61] refactor: Rename Exception property to Error in ProviderStatus class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider/Models/ProviderStatus.cs | 2 +- .../Providers/MultiProvider/MultiProvider.cs | 12 ++++++------ .../Models/ProviderStatusTests.cs | 18 +++++++++--------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs b/src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs index b2b873b1..52e012a6 100644 --- a/src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs +++ b/src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs @@ -3,5 +3,5 @@ namespace OpenFeature.Providers.MultiProvider.Models; internal class ProviderStatus { public string ProviderName { get; set; } = string.Empty; - public Exception? Exception { get; set; } + public Exception? Error { get; set; } } diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 2eaeeda8..82619c3e 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -85,16 +85,16 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati catch (Exception ex) { rp.SetStatus(Constant.ProviderStatus.Fatal); - return new ProviderStatus { ProviderName = rp.Name, Exception = ex }; + return new ProviderStatus { ProviderName = rp.Name, Error = ex }; } }); var results = await Task.WhenAll(initializationTasks).ConfigureAwait(false); - var failures = results.Where(r => r.Exception != null).ToList(); + var failures = results.Where(r => r.Error != null).ToList(); if (failures.Count != 0) { - var exceptions = failures.Select(f => f.Exception!).ToList(); + var exceptions = failures.Select(f => f.Error!).ToList(); var failedProviders = failures.Select(f => f.ProviderName).ToList(); throw new AggregateException( $"Failed to initialize providers: {string.Join(", ", failedProviders)}", @@ -116,16 +116,16 @@ public override async Task ShutdownAsync(CancellationToken cancellationToken = d catch (Exception ex) { rp.SetStatus(Constant.ProviderStatus.Fatal); - return new ProviderStatus { ProviderName = rp.Name, Exception = ex }; + return new ProviderStatus { ProviderName = rp.Name, Error = ex }; } }); var results = await Task.WhenAll(shutdownTasks).ConfigureAwait(false); - var failures = results.Where(r => r.Exception != null).ToList(); + var failures = results.Where(r => r.Error != null).ToList(); if (failures.Count != 0) { - var exceptions = failures.Select(f => f.Exception!).ToList(); + var exceptions = failures.Select(f => f.Error!).ToList(); var failedProviders = failures.Select(f => f.ProviderName).ToList(); throw new AggregateException( $"Failed to shutdown providers: {string.Join(", ", failedProviders)}", diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs index bfd33d11..38e944a6 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs @@ -12,7 +12,7 @@ public void Constructor_CreatesProviderStatusWithDefaultValues() // Assert Assert.Equal(string.Empty, providerStatus.ProviderName); - Assert.Null(providerStatus.Exception); + Assert.Null(providerStatus.Error); } [Fact] @@ -63,23 +63,23 @@ public void Exception_CanBeSet() var providerStatus = new ProviderStatus(); // Act - providerStatus.Exception = exception; + providerStatus.Error = exception; // Assert - Assert.Equal(exception, providerStatus.Exception); + Assert.Equal(exception, providerStatus.Error); } [Fact] public void Exception_CanBeSetToNull() { // Arrange - var providerStatus = new ProviderStatus { Exception = new Exception("initial exception") }; + var providerStatus = new ProviderStatus { Error = new Exception("initial exception") }; // Act - providerStatus.Exception = null; + providerStatus.Error = null; // Assert - Assert.Null(providerStatus.Exception); + Assert.Null(providerStatus.Error); } [Fact] @@ -93,12 +93,12 @@ public void ProviderStatus_CanBeInitializedWithObjectInitializer() var providerStatus = new ProviderStatus { ProviderName = providerName, - Exception = exception + Error = exception }; // Assert Assert.Equal(providerName, providerStatus.ProviderName); - Assert.Equal(exception, providerStatus.Exception); + Assert.Equal(exception, providerStatus.Error); } [Fact] @@ -115,7 +115,7 @@ public void ProviderName_Property_HasGetterAndSetter() public void Exception_Property_HasGetterAndSetter() { // Act & Assert - var exceptionProperty = typeof(ProviderStatus).GetProperty(nameof(ProviderStatus.Exception)); + var exceptionProperty = typeof(ProviderStatus).GetProperty(nameof(ProviderStatus.Error)); Assert.NotNull(exceptionProperty); Assert.True(exceptionProperty.CanRead); Assert.True(exceptionProperty.CanWrite); From 339b196db4965b9be9aad59c433537a29433a17b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:45:21 +0100 Subject: [PATCH 51/61] feat: Improved the thread safety for Multiprovider. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- ...oviderStatus.cs => ChildProviderStatus.cs} | 2 +- .../Models/RegisteredProvider.cs | 24 ++- .../Providers/MultiProvider/MultiProvider.cs | 153 +++++++++++---- ...tryTests.cs => ChildProviderEntryTests.cs} | 2 +- .../Models/ProviderStatusTests.cs | 18 +- .../MultiProvider/MultiProviderTests.cs | 174 ++++++++++++++++++ 6 files changed, 323 insertions(+), 50 deletions(-) rename src/OpenFeature/Providers/MultiProvider/Models/{ProviderStatus.cs => ChildProviderStatus.cs} (82%) rename test/OpenFeature.Tests/Providers/MultiProvider/Models/{ProviderEntryTests.cs => ChildProviderEntryTests.cs} (98%) diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs b/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs similarity index 82% rename from src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs rename to src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs index 52e012a6..f66f8fae 100644 --- a/src/OpenFeature/Providers/MultiProvider/Models/ProviderStatus.cs +++ b/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs @@ -1,6 +1,6 @@ namespace OpenFeature.Providers.MultiProvider.Models; -internal class ProviderStatus +internal class ChildProviderStatus { public string ProviderName { get; set; } = string.Empty; public Exception? Error { get; set; } diff --git a/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs index b3b23e2e..b5f3aff3 100644 --- a/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs @@ -2,6 +2,14 @@ namespace OpenFeature.Providers.MultiProvider.Models; internal class RegisteredProvider { +#if NET9_0_OR_GREATER + private readonly Lock _statusLock = new(); +#else + private readonly object _statusLock = new object(); +#endif + + private volatile Constant.ProviderStatus _status = Constant.ProviderStatus.NotReady; + internal RegisteredProvider(FeatureProvider provider, string name) { this.Provider = provider ?? throw new ArgumentNullException(nameof(provider)); @@ -12,10 +20,22 @@ internal RegisteredProvider(FeatureProvider provider, string name) internal string Name { get; } - internal Constant.ProviderStatus Status { get; private set; } = Constant.ProviderStatus.NotReady; + internal Constant.ProviderStatus Status + { + get + { + lock (this._statusLock) + { + return this._status; + } + } + } internal void SetStatus(Constant.ProviderStatus status) { - this.Status = status; + lock (this._statusLock) + { + this._status = status; + } } } diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 82619c3e..609d464d 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using OpenFeature.Constant; using OpenFeature.Model; using OpenFeature.Providers.MultiProvider.Models; using OpenFeature.Providers.MultiProvider.Strategies; @@ -16,12 +17,17 @@ namespace OpenFeature.Providers.MultiProvider; /// different feature flags may be served by different sources or providers within the same application. /// /// Multi Provider specification -public sealed class MultiProvider : FeatureProvider +public sealed class MultiProvider : FeatureProvider, IDisposable { private readonly BaseEvaluationStrategy _evaluationStrategy; private readonly IReadOnlyList _registeredProviders; private readonly Metadata _metadata; + private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); + private readonly SemaphoreSlim _shutdownSemaphore = new(1, 1); + private volatile ProviderStatus _providerStatus = ProviderStatus.NotReady; + private volatile bool _disposed; + /// /// Initializes a new instance of the class with the specified provider entries and evaluation strategy. /// @@ -74,62 +80,113 @@ public override Task> ResolveStructureValueAsync(string /// public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) { - var initializationTasks = this._registeredProviders.Select(async rp => +#if NET8_0_OR_GREATER + ObjectDisposedException.ThrowIf(this._disposed, this); +#else + if (this._disposed) + { + throw new ObjectDisposedException(nameof(MultiProvider)); + } +#endif + + await this._initializationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - try + if (this._providerStatus != ProviderStatus.NotReady || this._disposed) { - await rp.Provider.InitializeAsync(context, cancellationToken).ConfigureAwait(false); - rp.SetStatus(Constant.ProviderStatus.Ready); - return new ProviderStatus { ProviderName = rp.Name }; + return; } - catch (Exception ex) + + var initializationTasks = this._registeredProviders.Select(async rp => { - rp.SetStatus(Constant.ProviderStatus.Fatal); - return new ProviderStatus { ProviderName = rp.Name, Error = ex }; - } - }); + try + { + await rp.Provider.InitializeAsync(context, cancellationToken).ConfigureAwait(false); + rp.SetStatus(ProviderStatus.Ready); + return new ChildProviderStatus { ProviderName = rp.Name }; + } + catch (Exception ex) + { + rp.SetStatus(ProviderStatus.Fatal); + return new ChildProviderStatus { ProviderName = rp.Name, Error = ex }; + } + }); - var results = await Task.WhenAll(initializationTasks).ConfigureAwait(false); - var failures = results.Where(r => r.Error != null).ToList(); + var results = await Task.WhenAll(initializationTasks).ConfigureAwait(false); + var failures = results.Where(r => r.Error != null).ToList(); - if (failures.Count != 0) + if (failures.Count != 0) + { + var exceptions = failures.Select(f => f.Error!).ToList(); + var failedProviders = failures.Select(f => f.ProviderName).ToList(); + this._providerStatus = ProviderStatus.Fatal; + throw new AggregateException( + $"Failed to initialize providers: {string.Join(", ", failedProviders)}", + exceptions); + } + else + { + this._providerStatus = ProviderStatus.Ready; + } + } + finally { - var exceptions = failures.Select(f => f.Error!).ToList(); - var failedProviders = failures.Select(f => f.ProviderName).ToList(); - throw new AggregateException( - $"Failed to initialize providers: {string.Join(", ", failedProviders)}", - exceptions); + this._initializationSemaphore.Release(); } } /// public override async Task ShutdownAsync(CancellationToken cancellationToken = default) { - var shutdownTasks = this._registeredProviders.Select(async rp => +#if NET8_0_OR_GREATER + ObjectDisposedException.ThrowIf(this._disposed, this); +#else + if (this._disposed) { - try + throw new ObjectDisposedException(nameof(MultiProvider)); + } +#endif + + await this._shutdownSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._providerStatus != ProviderStatus.Ready || this._disposed) { - await rp.Provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); - rp.SetStatus(Constant.ProviderStatus.NotReady); - return new ProviderStatus { ProviderName = rp.Name }; + return; } - catch (Exception ex) + + var shutdownTasks = this._registeredProviders.Select(async rp => { - rp.SetStatus(Constant.ProviderStatus.Fatal); - return new ProviderStatus { ProviderName = rp.Name, Error = ex }; - } - }); + try + { + await rp.Provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); + rp.SetStatus(ProviderStatus.NotReady); + return new ChildProviderStatus { ProviderName = rp.Name }; + } + catch (Exception ex) + { + rp.SetStatus(ProviderStatus.Fatal); + return new ChildProviderStatus { ProviderName = rp.Name, Error = ex }; + } + }); + + var results = await Task.WhenAll(shutdownTasks).ConfigureAwait(false); + var failures = results.Where(r => r.Error != null).ToList(); - var results = await Task.WhenAll(shutdownTasks).ConfigureAwait(false); - var failures = results.Where(r => r.Error != null).ToList(); + if (failures.Count != 0) + { + var exceptions = failures.Select(f => f.Error!).ToList(); + var failedProviders = failures.Select(f => f.ProviderName).ToList(); + throw new AggregateException( + $"Failed to shutdown providers: {string.Join(", ", failedProviders)}", + exceptions); + } - if (failures.Count != 0) + this._providerStatus = ProviderStatus.NotReady; + } + finally { - var exceptions = failures.Select(f => f.Error!).ToList(); - var failedProviders = failures.Select(f => f.ProviderName).ToList(); - throw new AggregateException( - $"Failed to shutdown providers: {string.Join(", ", failedProviders)}", - exceptions); + this._shutdownSemaphore.Release(); } } @@ -241,4 +298,26 @@ private static ReadOnlyCollection RegisterProviders(IEnumera return registeredProviders.AsReadOnly(); } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + if (!this._disposed) + { + this._initializationSemaphore.Dispose(); + this._shutdownSemaphore.Dispose(); + this._disposed = true; + } + } + + /// + /// This should only be used for testing purposes. + /// + /// The status to set. + internal void SetStatus(ProviderStatus providerStatus) + { + this._providerStatus = providerStatus; + } } diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderEntryTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs similarity index 98% rename from test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderEntryTests.cs rename to test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs index aed5b5a9..69bb6232 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderEntryTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs @@ -3,7 +3,7 @@ namespace OpenFeature.Tests.Providers.MultiProvider.Models; -public class ProviderEntryTests +public class ChildProviderEntryTests { private readonly FeatureProvider _mockProvider = Substitute.For(); diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs index 38e944a6..ad3990aa 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs @@ -8,7 +8,7 @@ public class ProviderStatusTests public void Constructor_CreatesProviderStatusWithDefaultValues() { // Act - var providerStatus = new ProviderStatus(); + var providerStatus = new ChildProviderStatus(); // Assert Assert.Equal(string.Empty, providerStatus.ProviderName); @@ -20,7 +20,7 @@ public void ProviderName_CanBeSet() { // Arrange const string providerName = "test-provider"; - var providerStatus = new ProviderStatus(); + var providerStatus = new ChildProviderStatus(); // Act providerStatus.ProviderName = providerName; @@ -33,7 +33,7 @@ public void ProviderName_CanBeSet() public void ProviderName_CanBeSetToNull() { // Arrange - var providerStatus = new ProviderStatus { ProviderName = "initial-name" }; + var providerStatus = new ChildProviderStatus { ProviderName = "initial-name" }; // Act providerStatus.ProviderName = null!; @@ -46,7 +46,7 @@ public void ProviderName_CanBeSetToNull() public void ProviderName_CanBeSetToEmptyString() { // Arrange - var providerStatus = new ProviderStatus { ProviderName = "initial-name" }; + var providerStatus = new ChildProviderStatus { ProviderName = "initial-name" }; // Act providerStatus.ProviderName = string.Empty; @@ -60,7 +60,7 @@ public void Exception_CanBeSet() { // Arrange var exception = new InvalidOperationException("Test exception"); - var providerStatus = new ProviderStatus(); + var providerStatus = new ChildProviderStatus(); // Act providerStatus.Error = exception; @@ -73,7 +73,7 @@ public void Exception_CanBeSet() public void Exception_CanBeSetToNull() { // Arrange - var providerStatus = new ProviderStatus { Error = new Exception("initial exception") }; + var providerStatus = new ChildProviderStatus { Error = new Exception("initial exception") }; // Act providerStatus.Error = null; @@ -90,7 +90,7 @@ public void ProviderStatus_CanBeInitializedWithObjectInitializer() var exception = new ArgumentException("Test exception"); // Act - var providerStatus = new ProviderStatus + var providerStatus = new ChildProviderStatus { ProviderName = providerName, Error = exception @@ -105,7 +105,7 @@ public void ProviderStatus_CanBeInitializedWithObjectInitializer() public void ProviderName_Property_HasGetterAndSetter() { // Act & Assert - var providerNameProperty = typeof(ProviderStatus).GetProperty(nameof(ProviderStatus.ProviderName)); + var providerNameProperty = typeof(ChildProviderStatus).GetProperty(nameof(ChildProviderStatus.ProviderName)); Assert.NotNull(providerNameProperty); Assert.True(providerNameProperty.CanRead); Assert.True(providerNameProperty.CanWrite); @@ -115,7 +115,7 @@ public void ProviderName_Property_HasGetterAndSetter() public void Exception_Property_HasGetterAndSetter() { // Act & Assert - var exceptionProperty = typeof(ProviderStatus).GetProperty(nameof(ProviderStatus.Error)); + var exceptionProperty = typeof(ChildProviderStatus).GetProperty(nameof(ChildProviderStatus.Error)); Assert.NotNull(exceptionProperty); Assert.True(exceptionProperty.CanRead); Assert.True(exceptionProperty.CanWrite); diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs index 37fdb281..a07e5aa7 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using NSubstitute; using NSubstitute.ExceptionExtensions; using OpenFeature.Constant; @@ -204,6 +205,7 @@ public async Task ShutdownAsync_WithAllSuccessfulProviders_ShutsDownAllProviders new(this._mockProvider2, Provider2Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + multiProvider.SetStatus(ProviderStatus.Ready); this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); this._mockProvider2.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); @@ -227,6 +229,7 @@ public async Task ShutdownAsync_WithSomeFailingProviders_ThrowsAggregateExceptio new(this._mockProvider2, Provider2Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + multiProvider.SetStatus(ProviderStatus.Ready); this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); this._mockProvider2.ShutdownAsync(Arg.Any()).ThrowsAsync(expectedException); @@ -559,6 +562,7 @@ public async Task ShutdownAsync_WithCancellationToken_PassesCancellationTokenToP // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + multiProvider.SetStatus(ProviderStatus.Ready); this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); @@ -607,6 +611,7 @@ public async Task ShutdownAsync_WithAllSuccessfulProviders_CompletesWithoutExcep new(this._mockProvider3, Provider3Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + multiProvider.SetStatus(ProviderStatus.Ready); this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); this._mockProvider2.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); @@ -620,4 +625,173 @@ public async Task ShutdownAsync_WithAllSuccessfulProviders_CompletesWithoutExcep await this._mockProvider2.Received(1).ShutdownAsync(Arg.Any()); await this._mockProvider3.Received(1).ShutdownAsync(Arg.Any()); } + + [Fact] + public async Task MultiProvider_ConcurrentInitializationAndShutdown_ShouldMaintainConsistentProviderStatus() + { + // Arrange + const int providerCount = 20; + var random = new Random(); + var providerEntries = new List(); + + for (int i = 0; i < providerCount; i++) + { + var provider = Substitute.For(); + + provider.InitializeAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + provider.ShutdownAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + provider.GetMetadata() + .Returns(new Metadata(name: $"provider-{i}")); + + providerEntries.Add(new ProviderEntry(provider)); + } + + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries); + + // Act: simulate concurrent initialization and shutdown with one task each + var initTasks = Enumerable.Range(0, 1).Select(_ => + Task.Run(() => multiProvider.InitializeAsync(Arg.Any(), CancellationToken.None))); + + var shutdownTasks = Enumerable.Range(0, 1).Select(_ => + Task.Run(() => multiProvider.ShutdownAsync(CancellationToken.None))); + + await Task.WhenAll(initTasks.Concat(shutdownTasks)); + + // Assert: ensure that each provider ends in a valid lifecycle state + var statuses = GetRegisteredStatuses().ToList(); + + Assert.All(statuses, status => + { + Assert.True( + status is ProviderStatus.Ready or ProviderStatus.NotReady, + $"Unexpected provider status: {status}"); + }); + + // Local helper: uses reflection to access the private '_registeredProviders' field + // and retrieve the current status of each registered provider. + // Consider replacing this with an internal or public method if testing becomes more frequent. + IEnumerable GetRegisteredStatuses() + { + var field = typeof(MultiProviderImplementation.MultiProvider).GetField("_registeredProviders", BindingFlags.NonPublic | BindingFlags.Instance); + if (field?.GetValue(multiProvider) is not IEnumerable list) + throw new InvalidOperationException("Could not retrieve registered providers via reflection."); + + foreach (var p in list) + { + var statusProperty = p.GetType().GetProperty("Status", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (statusProperty == null) + throw new InvalidOperationException($"'Status' property not found on type {p.GetType().Name}."); + + if (statusProperty.GetValue(p) is not ProviderStatus status) + throw new InvalidOperationException("Unable to read status property value."); + + yield return status; + } + } + } + + [Fact] + public void Dispose_ShouldDisposeInternalResources() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + multiProvider.Dispose(); + + // Assert - Should not throw any exception + // The internal semaphores should be disposed + Assert.True(true); // If we get here without exception, disposal worked + } + + [Fact] + public void Dispose_CalledMultipleTimes_ShouldNotThrow() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act & Assert - Multiple calls to Dispose should not throw + multiProvider.Dispose(); + multiProvider.Dispose(); + multiProvider.Dispose(); + + // If we get here without exception, multiple disposal calls worked correctly + Assert.True(true); + } + + [Fact] + public async Task InitializeAsync_AfterDispose_ShouldThrowObjectDisposedException() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + multiProvider.Dispose(); + + // Assert + var exception = await Assert.ThrowsAsync(() => + multiProvider.InitializeAsync(this._evaluationContext)); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), exception.ObjectName); + } + + [Fact] + public async Task ShutdownAsync_AfterDispose_ShouldThrowObjectDisposedException() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + multiProvider.Dispose(); + + // Assert + var exception = await Assert.ThrowsAsync(() => + multiProvider.ShutdownAsync()); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), exception.ObjectName); + } + + [Fact] + public async Task InitializeAsync_WhenAlreadyDisposed_DuringExecution_ShouldExitEarly() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Dispose before calling InitializeAsync + multiProvider.Dispose(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + multiProvider.InitializeAsync(this._evaluationContext)); + + // Verify that the underlying provider was never called since the object was disposed + await this._mockProvider1.DidNotReceive().InitializeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ShutdownAsync_WhenAlreadyDisposed_DuringExecution_ShouldExitEarly() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Dispose before calling ShutdownAsync + multiProvider.Dispose(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + multiProvider.ShutdownAsync()); + + // Verify that the underlying provider was never called since the object was disposed + await this._mockProvider1.DidNotReceive().ShutdownAsync(Arg.Any()); + } + + } From 7637aeefacdd7fdb756c9d3667195a2e8a52ea70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:51:00 +0100 Subject: [PATCH 52/61] fix: Update exception object name in MultiProvider tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Providers/MultiProvider/MultiProviderTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs index a07e5aa7..3000d77a 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs @@ -738,7 +738,7 @@ public async Task InitializeAsync_AfterDispose_ShouldThrowObjectDisposedExceptio // Assert var exception = await Assert.ThrowsAsync(() => multiProvider.InitializeAsync(this._evaluationContext)); - Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), exception.ObjectName); + Assert.Equal("OpenFeature.Providers.MultiProvider.MultiProvider", exception.ObjectName); } [Fact] @@ -754,7 +754,7 @@ public async Task ShutdownAsync_AfterDispose_ShouldThrowObjectDisposedException( // Assert var exception = await Assert.ThrowsAsync(() => multiProvider.ShutdownAsync()); - Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), exception.ObjectName); + Assert.Equal("OpenFeature.Providers.MultiProvider.MultiProvider", exception.ObjectName); } [Fact] From 78acd865f0c8506e229476b9198ee68e39f5667b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:54:39 +0100 Subject: [PATCH 53/61] fix: Update ObjectDisposedException object name in MultiProvider tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature/Providers/MultiProvider/MultiProvider.cs | 8 -------- .../Providers/MultiProvider/MultiProviderTests.cs | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 609d464d..d3124d9c 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -80,14 +80,10 @@ public override Task> ResolveStructureValueAsync(string /// public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) { -#if NET8_0_OR_GREATER - ObjectDisposedException.ThrowIf(this._disposed, this); -#else if (this._disposed) { throw new ObjectDisposedException(nameof(MultiProvider)); } -#endif await this._initializationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -138,14 +134,10 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati /// public override async Task ShutdownAsync(CancellationToken cancellationToken = default) { -#if NET8_0_OR_GREATER - ObjectDisposedException.ThrowIf(this._disposed, this); -#else if (this._disposed) { throw new ObjectDisposedException(nameof(MultiProvider)); } -#endif await this._shutdownSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs index 3000d77a..a07e5aa7 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs @@ -738,7 +738,7 @@ public async Task InitializeAsync_AfterDispose_ShouldThrowObjectDisposedExceptio // Assert var exception = await Assert.ThrowsAsync(() => multiProvider.InitializeAsync(this._evaluationContext)); - Assert.Equal("OpenFeature.Providers.MultiProvider.MultiProvider", exception.ObjectName); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), exception.ObjectName); } [Fact] @@ -754,7 +754,7 @@ public async Task ShutdownAsync_AfterDispose_ShouldThrowObjectDisposedException( // Assert var exception = await Assert.ThrowsAsync(() => multiProvider.ShutdownAsync()); - Assert.Equal("OpenFeature.Providers.MultiProvider.MultiProvider", exception.ObjectName); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), exception.ObjectName); } [Fact] From 6685f018fb1d4a5f1e9036e03b64f28b3cc39aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Tue, 29 Jul 2025 05:51:36 +0100 Subject: [PATCH 54/61] fix: Remove volatile modifier from status fields in RegisteredProvider and MultiProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Providers/MultiProvider/Models/RegisteredProvider.cs | 2 +- src/OpenFeature/Providers/MultiProvider/MultiProvider.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs index b5f3aff3..ee62fd00 100644 --- a/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs @@ -8,7 +8,7 @@ internal class RegisteredProvider private readonly object _statusLock = new object(); #endif - private volatile Constant.ProviderStatus _status = Constant.ProviderStatus.NotReady; + private Constant.ProviderStatus _status = Constant.ProviderStatus.NotReady; internal RegisteredProvider(FeatureProvider provider, string name) { diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index d3124d9c..9bb704ec 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -25,8 +25,8 @@ public sealed class MultiProvider : FeatureProvider, IDisposable private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); private readonly SemaphoreSlim _shutdownSemaphore = new(1, 1); - private volatile ProviderStatus _providerStatus = ProviderStatus.NotReady; - private volatile bool _disposed; + private ProviderStatus _providerStatus = ProviderStatus.NotReady; + private bool _disposed; /// /// Initializes a new instance of the class with the specified provider entries and evaluation strategy. From 036d23d04b5288c3c2aaaf2096bd9813599afdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:41:10 +0100 Subject: [PATCH 55/61] docs: Clarify evaluation strategy parameter description in MultiProvider constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature/Providers/MultiProvider/MultiProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 9bb704ec..26f83081 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -32,7 +32,7 @@ public sealed class MultiProvider : FeatureProvider, IDisposable /// Initializes a new instance of the class with the specified provider entries and evaluation strategy. /// /// A collection of provider entries containing the feature providers and their optional names. - /// The base evaluation strategy to use for determining how to evaluate features across multiple providers. + /// The base evaluation strategy to use for determining how to evaluate features across multiple providers. If not specified, the first matching strategy will be used. public MultiProvider(IEnumerable providerEntries, BaseEvaluationStrategy? evaluationStrategy = null) { if (providerEntries == null) From ed784d4da1e6b7ac11aa83eac958adf9d6abbf66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:14:12 +0100 Subject: [PATCH 56/61] fix: Update shutdown logic to allow shutdown in Ready or Fatal status and add corresponding tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Providers/MultiProvider/MultiProvider.cs | 3 ++- .../MultiProvider/MultiProviderTests.cs | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 26f83081..06c1507e 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -142,7 +142,8 @@ public override async Task ShutdownAsync(CancellationToken cancellationToken = d await this._shutdownSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (this._providerStatus != ProviderStatus.Ready || this._disposed) + // We should be able to shutdown the provider when it is in Ready or Fatal status. + if ((this._providerStatus != ProviderStatus.Ready && this._providerStatus != ProviderStatus.Fatal) || this._disposed) { return; } diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs index a07e5aa7..22f8bb1c 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs @@ -218,6 +218,29 @@ public async Task ShutdownAsync_WithAllSuccessfulProviders_ShutsDownAllProviders await this._mockProvider2.Received(1).ShutdownAsync(Arg.Any()); } + [Fact] + public async Task ShutdownAsync_WithFatalProvider_ShutsDownAllProviders() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + multiProvider.SetStatus(ProviderStatus.Fatal); + + this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + + // Act + await multiProvider.ShutdownAsync(); + + // Assert + await this._mockProvider1.Received(1).ShutdownAsync(Arg.Any()); + await this._mockProvider2.Received(1).ShutdownAsync(Arg.Any()); + } + [Fact] public async Task ShutdownAsync_WithSomeFailingProviders_ThrowsAggregateException() { From 8d1bb37d3d5dd9ef176f71e460b630fed3f0fec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 31 Jul 2025 21:33:19 +0100 Subject: [PATCH 57/61] refactor: Move fallback provider resolution logic to a more appropriate location in ComparisonStrategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Strategies/ComparisonStrategy.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs index 8438ac3c..b004b6d3 100644 --- a/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs @@ -42,13 +42,6 @@ public override FinalResult DetermineFinalResult(StrategyEvaluationContext } var firstResult = successfulResolutions.First(); - ProviderResolutionResult? fallbackResolution = null; - - // Find fallback provider if specified - if (this._fallbackProvider != null) - { - fallbackResolution = successfulResolutions.FirstOrDefault(r => ReferenceEquals(r.Provider, this._fallbackProvider)); - } // Check if all successful results agree on the value var allAgree = successfulResolutions.All(r => EqualityComparer.Default.Equals(r.ResolutionDetails.Value, firstResult.ResolutionDetails.Value)); @@ -58,6 +51,14 @@ public override FinalResult DetermineFinalResult(StrategyEvaluationContext return ToFinalResult(firstResult); } + ProviderResolutionResult? fallbackResolution = null; + + // Find fallback provider if specified + if (this._fallbackProvider != null) + { + fallbackResolution = successfulResolutions.FirstOrDefault(r => ReferenceEquals(r.Provider, this._fallbackProvider)); + } + // Values don't agree, trigger mismatch callback if provided if (this._onMismatch != null) { From 340df6e91b66218893cdc747f8ae178413821384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 31 Jul 2025 22:04:54 +0100 Subject: [PATCH 58/61] fix: Simplify multi-provider endpoint response and improve error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- samples/AspNetCore/Program.cs | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 4c4dd9b8..90d1888c 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -89,7 +89,7 @@ return Results.Ok(config); }); -app.MapGet("/multi-provider", async context => +app.MapGet("/multi-provider", async () => { // Create first in-memory provider with some flags var provider1Flags = new Dictionary @@ -131,27 +131,11 @@ // Test a flag that doesn't exist in any provider var unknownFlag = await client.GetBooleanDetailsAsync("unknown-flag", false); - var result = new - { - message = "Multi-provider evaluation results", - results = new - { - maxItemsFlag = new { value = maxItemsFlag }, - providerNameFlag = new { value = providerNameFlag }, - } - }; - - await context.Response.WriteAsJsonAsync(result); + return Results.Ok(); } - catch (Exception ex) + catch (Exception) { - context.Response.StatusCode = 500; - await context.Response.WriteAsJsonAsync(new - { - title = "Multi-provider evaluation failed", - detail = ex.Message, - status = 500 - }); + return Results.InternalServerError(); } }); From eb98a1b62f912190d090b24094b9f7796b9bf47d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 1 Aug 2025 08:23:24 +0100 Subject: [PATCH 59/61] fix: Improve dispose pattern handling in MultiProvider to ensure correct async initialization and shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Providers/MultiProvider/MultiProvider.cs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 06c1507e..5821180c 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -26,7 +26,9 @@ public sealed class MultiProvider : FeatureProvider, IDisposable private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); private readonly SemaphoreSlim _shutdownSemaphore = new(1, 1); private ProviderStatus _providerStatus = ProviderStatus.NotReady; - private bool _disposed; + // 0 = Not disposed, 1 = Disposed + // This is to handle the dispose pattern correctly with the async initialization and shutdown methods + private int _disposed = 0; /// /// Initializes a new instance of the class with the specified provider entries and evaluation strategy. @@ -80,7 +82,7 @@ public override Task> ResolveStructureValueAsync(string /// public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) { - if (this._disposed) + if (this._disposed == 1) { throw new ObjectDisposedException(nameof(MultiProvider)); } @@ -88,7 +90,7 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati await this._initializationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (this._providerStatus != ProviderStatus.NotReady || this._disposed) + if (this._providerStatus != ProviderStatus.NotReady || this._disposed == 1) { return; } @@ -134,7 +136,7 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati /// public override async Task ShutdownAsync(CancellationToken cancellationToken = default) { - if (this._disposed) + if (this._disposed == 1) { throw new ObjectDisposedException(nameof(MultiProvider)); } @@ -143,7 +145,7 @@ public override async Task ShutdownAsync(CancellationToken cancellationToken = d try { // We should be able to shutdown the provider when it is in Ready or Fatal status. - if ((this._providerStatus != ProviderStatus.Ready && this._providerStatus != ProviderStatus.Fatal) || this._disposed) + if ((this._providerStatus != ProviderStatus.Ready && this._providerStatus != ProviderStatus.Fatal) || this._disposed == 1) { return; } @@ -297,12 +299,14 @@ private static ReadOnlyCollection RegisterProviders(IEnumera /// public void Dispose() { - if (!this._disposed) + if (Interlocked.Exchange(ref this._disposed, 1) == 1) { - this._initializationSemaphore.Dispose(); - this._shutdownSemaphore.Dispose(); - this._disposed = true; + // Already disposed + return; } + + this._initializationSemaphore.Dispose(); + this._shutdownSemaphore.Dispose(); } /// From 003e3433f6a850ef9e61e4ddebb8356399c6415d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:24:58 +0100 Subject: [PATCH 60/61] fix: Mark _disposed as volatile to ensure thread-safe access in async methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature/Providers/MultiProvider/MultiProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 5821180c..162f5827 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -28,7 +28,7 @@ public sealed class MultiProvider : FeatureProvider, IDisposable private ProviderStatus _providerStatus = ProviderStatus.NotReady; // 0 = Not disposed, 1 = Disposed // This is to handle the dispose pattern correctly with the async initialization and shutdown methods - private int _disposed = 0; + private volatile int _disposed = 0; /// /// Initializes a new instance of the class with the specified provider entries and evaluation strategy. From bfeab2859d123e1e4d080015fd0bfe5bcc7926d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Wed, 6 Aug 2025 16:27:17 +0100 Subject: [PATCH 61/61] fix: Update MultiProvider to implement IAsyncDisposable and improve dispose pattern handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Providers/MultiProvider/MultiProvider.cs | 112 +++++++++++------- .../MultiProvider/MultiProviderTests.cs | 51 ++++++-- 2 files changed, 107 insertions(+), 56 deletions(-) diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs index 162f5827..73ce72eb 100644 --- a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -17,7 +17,7 @@ namespace OpenFeature.Providers.MultiProvider; /// different feature flags may be served by different sources or providers within the same application. /// /// Multi Provider specification -public sealed class MultiProvider : FeatureProvider, IDisposable +public sealed class MultiProvider : FeatureProvider, IAsyncDisposable { private readonly BaseEvaluationStrategy _evaluationStrategy; private readonly IReadOnlyList _registeredProviders; @@ -141,52 +141,19 @@ public override async Task ShutdownAsync(CancellationToken cancellationToken = d throw new ObjectDisposedException(nameof(MultiProvider)); } - await this._shutdownSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - // We should be able to shutdown the provider when it is in Ready or Fatal status. - if ((this._providerStatus != ProviderStatus.Ready && this._providerStatus != ProviderStatus.Fatal) || this._disposed == 1) - { - return; - } - - var shutdownTasks = this._registeredProviders.Select(async rp => - { - try - { - await rp.Provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); - rp.SetStatus(ProviderStatus.NotReady); - return new ChildProviderStatus { ProviderName = rp.Name }; - } - catch (Exception ex) - { - rp.SetStatus(ProviderStatus.Fatal); - return new ChildProviderStatus { ProviderName = rp.Name, Error = ex }; - } - }); - - var results = await Task.WhenAll(shutdownTasks).ConfigureAwait(false); - var failures = results.Where(r => r.Error != null).ToList(); - - if (failures.Count != 0) - { - var exceptions = failures.Select(f => f.Error!).ToList(); - var failedProviders = failures.Select(f => f.ProviderName).ToList(); - throw new AggregateException( - $"Failed to shutdown providers: {string.Join(", ", failedProviders)}", - exceptions); - } - - this._providerStatus = ProviderStatus.NotReady; - } - finally - { - this._shutdownSemaphore.Release(); - } + await this.InternalShutdownAsync(cancellationToken).ConfigureAwait(false); } private async Task> EvaluateAsync(string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) { + // Check if the provider has been disposed + // This is to handle the dispose pattern correctly with the async initialization and shutdown methods + // It is checked here to avoid the check in every public EvaluateAsync method + if (this._disposed == 1) + { + throw new ObjectDisposedException(nameof(MultiProvider)); + } + var strategyContext = new StrategyEvaluationContext(key); var resolutions = this._evaluationStrategy.RunMode switch { @@ -297,7 +264,7 @@ private static ReadOnlyCollection RegisterProviders(IEnumera /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// - public void Dispose() + public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref this._disposed, 1) == 1) { @@ -305,8 +272,61 @@ public void Dispose() return; } - this._initializationSemaphore.Dispose(); - this._shutdownSemaphore.Dispose(); + try + { + await this.InternalShutdownAsync(CancellationToken.None).ConfigureAwait(false); + } + finally + { + this._initializationSemaphore.Dispose(); + this._shutdownSemaphore.Dispose(); + } + } + + private async Task InternalShutdownAsync(CancellationToken cancellationToken) + { + await this._shutdownSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // We should be able to shutdown the provider when it is in Ready or Fatal status. + if ((this._providerStatus != ProviderStatus.Ready && this._providerStatus != ProviderStatus.Fatal) || this._disposed == 1) + { + return; + } + + var shutdownTasks = this._registeredProviders.Select(async rp => + { + try + { + await rp.Provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); + rp.SetStatus(ProviderStatus.NotReady); + return new ChildProviderStatus { ProviderName = rp.Name }; + } + catch (Exception ex) + { + rp.SetStatus(ProviderStatus.Fatal); + return new ChildProviderStatus { ProviderName = rp.Name, Error = ex }; + } + }); + + var results = await Task.WhenAll(shutdownTasks).ConfigureAwait(false); + var failures = results.Where(r => r.Error != null).ToList(); + + if (failures.Count != 0) + { + var exceptions = failures.Select(f => f.Error!).ToList(); + var failedProviders = failures.Select(f => f.ProviderName).ToList(); + throw new AggregateException( + $"Failed to shutdown providers: {string.Join(", ", failedProviders)}", + exceptions); + } + + this._providerStatus = ProviderStatus.NotReady; + } + finally + { + this._shutdownSemaphore.Release(); + } } /// diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs index 22f8bb1c..bf1dfb4e 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs @@ -718,14 +718,14 @@ IEnumerable GetRegisteredStatuses() } [Fact] - public void Dispose_ShouldDisposeInternalResources() + public async Task DisposeAsync_ShouldDisposeInternalResources() { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); // Act - multiProvider.Dispose(); + await multiProvider.DisposeAsync(); // Assert - Should not throw any exception // The internal semaphores should be disposed @@ -733,16 +733,16 @@ public void Dispose_ShouldDisposeInternalResources() } [Fact] - public void Dispose_CalledMultipleTimes_ShouldNotThrow() + public async Task DisposeAsync_CalledMultipleTimes_ShouldNotThrow() { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); // Act & Assert - Multiple calls to Dispose should not throw - multiProvider.Dispose(); - multiProvider.Dispose(); - multiProvider.Dispose(); + await multiProvider.DisposeAsync(); + await multiProvider.DisposeAsync(); + await multiProvider.DisposeAsync(); // If we get here without exception, multiple disposal calls worked correctly Assert.True(true); @@ -756,7 +756,7 @@ public async Task InitializeAsync_AfterDispose_ShouldThrowObjectDisposedExceptio var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); // Act - multiProvider.Dispose(); + await multiProvider.DisposeAsync(); // Assert var exception = await Assert.ThrowsAsync(() => @@ -772,7 +772,7 @@ public async Task ShutdownAsync_AfterDispose_ShouldThrowObjectDisposedException( var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); // Act - multiProvider.Dispose(); + await multiProvider.DisposeAsync(); // Assert var exception = await Assert.ThrowsAsync(() => @@ -788,7 +788,7 @@ public async Task InitializeAsync_WhenAlreadyDisposed_DuringExecution_ShouldExit var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); // Dispose before calling InitializeAsync - multiProvider.Dispose(); + await multiProvider.DisposeAsync(); // Act & Assert var exception = await Assert.ThrowsAsync(() => @@ -806,7 +806,7 @@ public async Task ShutdownAsync_WhenAlreadyDisposed_DuringExecution_ShouldExitEa var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); // Dispose before calling ShutdownAsync - multiProvider.Dispose(); + await multiProvider.DisposeAsync(); // Act & Assert var exception = await Assert.ThrowsAsync(() => @@ -816,5 +816,36 @@ public async Task ShutdownAsync_WhenAlreadyDisposed_DuringExecution_ShouldExitEa await this._mockProvider1.DidNotReceive().ShutdownAsync(Arg.Any()); } + [Fact] + public async Task EvaluateAsync_AfterDispose_ShouldThrowObjectDisposedException() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + await multiProvider.DisposeAsync(); + + // Assert - All evaluate methods should throw ObjectDisposedException + var boolException = await Assert.ThrowsAsync(() => + multiProvider.ResolveBooleanValueAsync(TestFlagKey, false)); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), boolException.ObjectName); + + var stringException = await Assert.ThrowsAsync(() => + multiProvider.ResolveStringValueAsync(TestFlagKey, "default")); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), stringException.ObjectName); + + var intException = await Assert.ThrowsAsync(() => + multiProvider.ResolveIntegerValueAsync(TestFlagKey, 0)); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), intException.ObjectName); + + var doubleException = await Assert.ThrowsAsync(() => + multiProvider.ResolveDoubleValueAsync(TestFlagKey, 0.0)); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), doubleException.ObjectName); + + var structureException = await Assert.ThrowsAsync(() => + multiProvider.ResolveStructureValueAsync(TestFlagKey, new Value())); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), structureException.ObjectName); + } }