diff --git a/README.md b/README.md index 8349bc19a..2da256cd8 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,8 @@ 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. | -| 🔬 | [DependencyInjection](#dependency-injection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. | +| 🔬 | [Multi-Provider](#multi-provider) | Use multiple feature flag providers simultaneously with configurable evaluation strategies. | +| 🔬 | [DependencyInjection](#dependency-injection) | 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. + ### Dependency Injection > [!NOTE] diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index e8faf5a5e..90d1888c6 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -6,6 +6,9 @@ using OpenFeature.Hooks; using OpenFeature.Model; using OpenFeature.Providers.Memory; +using OpenFeature.Providers.MultiProvider; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -75,6 +78,7 @@ return TypedResults.Ok("Hello world!"); }); + app.MapGet("/test-config", async ([FromServices] IFeatureClient featureClient) => { var testConfigValue = await featureClient.GetObjectValueAsync("test-config", @@ -85,6 +89,56 @@ return Results.Ok(config); }); +app.MapGet("/multi-provider", async () => +{ + // 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); + + return Results.Ok(); + } + catch (Exception) + { + return Results.InternalServerError(); + } +}); + app.Run(); diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs b/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs new file mode 100644 index 000000000..f66f8fae7 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs @@ -0,0 +1,7 @@ +namespace OpenFeature.Providers.MultiProvider.Models; + +internal class ChildProviderStatus +{ + public string ProviderName { get; set; } = string.Empty; + public Exception? Error { get; set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs b/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs new file mode 100644 index 000000000..da720da6c --- /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/RegisteredProvider.cs b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs new file mode 100644 index 000000000..ee62fd006 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs @@ -0,0 +1,41 @@ +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 Constant.ProviderStatus _status = Constant.ProviderStatus.NotReady; + + internal RegisteredProvider(FeatureProvider provider, string name) + { + this.Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + this.Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + internal FeatureProvider Provider { get; } + + internal string Name { get; } + + internal Constant.ProviderStatus Status + { + get + { + lock (this._statusLock) + { + return this._status; + } + } + } + + internal void SetStatus(Constant.ProviderStatus status) + { + lock (this._statusLock) + { + this._status = status; + } + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs new file mode 100644 index 000000000..73ce72eba --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -0,0 +1,340 @@ +using System.Collections.ObjectModel; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.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. +/// +/// Multi Provider specification +public sealed class MultiProvider : FeatureProvider, IAsyncDisposable +{ + 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 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 volatile int _disposed = 0; + + /// + /// 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. If not specified, the first matching strategy will be used. + public MultiProvider(IEnumerable providerEntries, BaseEvaluationStrategy? evaluationStrategy = null) + { + if (providerEntries == null) + { + throw new ArgumentNullException(nameof(providerEntries)); + } + + 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(MultiProviderConstants.ProviderName); + } + + /// + public override Metadata GetMetadata() => this._metadata; + + /// + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + /// + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + /// + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + /// + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + /// + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + + /// + public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + if (this._disposed == 1) + { + throw new ObjectDisposedException(nameof(MultiProvider)); + } + + await this._initializationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._providerStatus != ProviderStatus.NotReady || this._disposed == 1) + { + return; + } + + var initializationTasks = this._registeredProviders.Select(async rp => + { + 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(); + + 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 + { + this._initializationSemaphore.Release(); + } + } + + /// + public override async Task ShutdownAsync(CancellationToken cancellationToken = default) + { + if (this._disposed == 1) + { + throw new ObjectDisposedException(nameof(MultiProvider)); + } + + 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 + { + 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.Status, + key); + + 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.Status, + key); + + 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(); + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref this._disposed, 1) == 1) + { + // Already disposed + return; + } + + 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(); + } + } + + /// + /// This should only be used for testing purposes. + /// + /// The status to set. + internal void SetStatus(ProviderStatus providerStatus) + { + this._providerStatus = providerStatus; + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs b/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs new file mode 100644 index 000000000..76df24448 --- /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/ProviderExtensions.cs b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs new file mode 100644 index 000000000..d8f70dfbf --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs @@ -0,0 +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, + StrategyPerProviderContext providerContext, + EvaluationContext? evaluationContext, + T defaultValue, + CancellationToken cancellationToken) + { + var key = providerContext.FlagKey; + + try + { + var result = defaultValue switch + { + 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) + { + // Create an error result + var errorResult = new ResolutionDetails( + key, + defaultValue, + ErrorType.General, + Reason.Error, + errorMessage: ex.Message); + + return new ProviderResolutionResult(provider, providerContext.ProviderName, errorResult, ex); + } + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs new file mode 100644 index 000000000..f31b2c4ab --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs @@ -0,0 +1,129 @@ +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. + /// + /// 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) + { + // 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.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. + /// + /// 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 000000000..b004b6d32 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs @@ -0,0 +1,80 @@ +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!, MultiProviderConstants.ProviderName, errors); + } + + var firstResult = successfulResolutions.First(); + + // 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); + } + + 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) + { + // 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 000000000..88eba5509 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs @@ -0,0 +1,36 @@ +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". +/// 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 +{ + /// + 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) + { + var lastResult = resolutions.LastOrDefault(); + if (lastResult != null) + { + return ToFinalResult(lastResult); + } + + var errorDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); + var errors = new List + { + new(MultiProviderConstants.ProviderName, new InvalidOperationException("No providers available or all providers failed")) + }; + 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 new file mode 100644 index 000000000..7caef6a51 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs @@ -0,0 +1,47 @@ +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. +/// 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. +/// +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(MultiProviderConstants.ProviderName, new InvalidOperationException("No providers available or all providers failed")) + }; + return new FinalResult(noProvidersDetails, null!, MultiProviderConstants.ProviderName, 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 collectedErrors = CollectProviderErrors(resolutions); + var allFailedDetails = new ResolutionDetails(key, defaultValue, ErrorType.General, Reason.Error, errorMessage: "All providers failed"); + return new FinalResult(allFailedDetails, null!, "MultiProvider", collectedErrors); + } +} 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 000000000..0bcc0bd7d --- /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) + { + this.Details = details; + this.Provider = provider; + this.ProviderName = providerName; + this.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 000000000..52204ce5a --- /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, Exception? error) + { + this.ProviderName = providerName; + this.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 Exception? 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 000000000..20eddbe44 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs @@ -0,0 +1,45 @@ +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. + /// 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; + } + + /// + /// The feature provider that produced this result. + /// + public FeatureProvider Provider { get; private set; } + + /// + /// The resolution details. + /// + public ResolutionDetails ResolutionDetails { get; private set; } + + /// + /// 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; } +} 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 000000000..754cb5a9e --- /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 000000000..215c85e4d --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs @@ -0,0 +1,22 @@ +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Evaluation context specific to strategy evaluation containing flag-related information. +/// +/// The type of the flag value being evaluated. +public class StrategyEvaluationContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The feature flag key being evaluated. + public StrategyEvaluationContext(string flagKey) + { + this.FlagKey = flagKey; + } + + /// + /// The feature flag key being evaluated. + /// + public string FlagKey { 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 000000000..4abc434a3 --- /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. +/// +/// The type of the flag value being evaluated. +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. + public StrategyPerProviderContext(FeatureProvider provider, string providerName, ProviderStatus providerStatus, string key) + : base(key) + { + this.Provider = provider; + this.ProviderName = providerName; + this.ProviderStatus = providerStatus; + } + + /// + /// The feature provider instance. + /// + public FeatureProvider Provider { get; } + + /// + /// The name/identifier of the provider. + /// + public string ProviderName { get; } + + /// + /// The current status of the provider. + /// + public ProviderStatus ProviderStatus { get; } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs new file mode 100644 index 000000000..69bb62322 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs @@ -0,0 +1,93 @@ +using NSubstitute; +using OpenFeature.Providers.MultiProvider.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Models; + +public class ChildProviderEntryTests +{ + 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 000000000..ad3990aaa --- /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 ChildProviderStatus(); + + // Assert + Assert.Equal(string.Empty, providerStatus.ProviderName); + Assert.Null(providerStatus.Error); + } + + [Fact] + public void ProviderName_CanBeSet() + { + // Arrange + const string providerName = "test-provider"; + var providerStatus = new ChildProviderStatus(); + + // Act + providerStatus.ProviderName = providerName; + + // Assert + Assert.Equal(providerName, providerStatus.ProviderName); + } + + [Fact] + public void ProviderName_CanBeSetToNull() + { + // Arrange + var providerStatus = new ChildProviderStatus { ProviderName = "initial-name" }; + + // Act + providerStatus.ProviderName = null!; + + // Assert + Assert.Null(providerStatus.ProviderName); + } + + [Fact] + public void ProviderName_CanBeSetToEmptyString() + { + // Arrange + var providerStatus = new ChildProviderStatus { 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 ChildProviderStatus(); + + // Act + providerStatus.Error = exception; + + // Assert + Assert.Equal(exception, providerStatus.Error); + } + + [Fact] + public void Exception_CanBeSetToNull() + { + // Arrange + var providerStatus = new ChildProviderStatus { Error = new Exception("initial exception") }; + + // Act + providerStatus.Error = null; + + // Assert + Assert.Null(providerStatus.Error); + } + + [Fact] + public void ProviderStatus_CanBeInitializedWithObjectInitializer() + { + // Arrange + const string providerName = "test-provider"; + var exception = new ArgumentException("Test exception"); + + // Act + var providerStatus = new ChildProviderStatus + { + ProviderName = providerName, + Error = exception + }; + + // Assert + Assert.Equal(providerName, providerStatus.ProviderName); + Assert.Equal(exception, providerStatus.Error); + } + + [Fact] + public void ProviderName_Property_HasGetterAndSetter() + { + // Act & Assert + var providerNameProperty = typeof(ChildProviderStatus).GetProperty(nameof(ChildProviderStatus.ProviderName)); + Assert.NotNull(providerNameProperty); + Assert.True(providerNameProperty.CanRead); + Assert.True(providerNameProperty.CanWrite); + } + + [Fact] + public void Exception_Property_HasGetterAndSetter() + { + // Act & Assert + 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/Models/RegisteredProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs new file mode 100644 index 000000000..8734775a7 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs @@ -0,0 +1,116 @@ +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 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); + } + + [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); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs new file mode 100644 index 000000000..bf1dfb4e6 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs @@ -0,0 +1,851 @@ +using System.Reflection; +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); + multiProvider.SetStatus(ProviderStatus.Ready); + + 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_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() + { + // 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); + multiProvider.SetStatus(ProviderStatus.Ready); + + 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); + multiProvider.SetStatus(ProviderStatus.Ready); + + 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); + multiProvider.SetStatus(ProviderStatus.Ready); + + 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()); + } + + [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 async Task DisposeAsync_ShouldDisposeInternalResources() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + await multiProvider.DisposeAsync(); + + // 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 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 + await multiProvider.DisposeAsync(); + await multiProvider.DisposeAsync(); + await multiProvider.DisposeAsync(); + + // 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 + await multiProvider.DisposeAsync(); + + // 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 + await multiProvider.DisposeAsync(); + + // 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 + await multiProvider.DisposeAsync(); + + // 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 + await multiProvider.DisposeAsync(); + + // 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()); + } + + [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); + } + +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs new file mode 100644 index 000000000..702fc3973 --- /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); + + 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); + + 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); + + 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); + + 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); + + 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); + + // 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); + + 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); + + 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); + 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); + + 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); + + 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); + + 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); + + 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); + } +} 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 000000000..f2960be07 --- /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); + + [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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + 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); + 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); + 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); + + // 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); + + // 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); + } +} 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 000000000..480ef6b90 --- /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); + + [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); + } +} 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 000000000..8c95ef00d --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs @@ -0,0 +1,323 @@ +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); + + [Fact] + public void RunMode_ReturnsSequential() + { + // Act + var result = this._strategy.RunMode; + + // Assert + Assert.Equal(RunMode.Sequential, result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithFlagNotFoundError_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + 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); + 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); + 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); + 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); + 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); + + // 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); + + // 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); + } +} 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 000000000..da0d87409 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs @@ -0,0 +1,240 @@ +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 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(TestFlagKey); + + [Fact] + public void RunMode_ReturnsSequential() + { + // Act + var result = this._strategy.RunMode; + + // Assert + Assert.Equal(RunMode.Sequential, result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, 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, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, 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, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var exceptionResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, 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>(); + + // 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("No providers available or all providers failed", 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); + } + + [Fact] + public void DetermineFinalResult_WithFirstSuccessfulResult_ReturnsFirstSuccessfulResult() + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); + + var successfulResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant1")); + + var anotherSuccessfulResult = new ProviderResolutionResult( + this._mockProvider3, + Provider3Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { errorResult, successfulResult, anotherSuccessfulResult }; + const bool defaultValue = false; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, defaultValue, 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("variant1", result.Details.Variant); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal(Provider2Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithAllFailedResults_ReturnsAllErrorsCollected() + { + // Arrange + var errorResult1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); + + var errorResult2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.InvalidContext, Reason.Error, errorMessage: "Error from provider2")); + + var exceptionResult = new ProviderResolutionResult( + this._mockProvider3, + Provider3Name, + new ResolutionDetails(TestFlagKey, false), + new InvalidOperationException("Exception from provider3")); + + var resolutions = new List> { errorResult1, errorResult2, exceptionResult }; + const bool defaultValue = false; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + 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(MultiProviderName, result.ProviderName); + Assert.Equal(3, result.Errors.Count); + + // Verify error from provider1 + Assert.Equal(Provider1Name, result.Errors[0].ProviderName); + Assert.Equal("Error from provider1", result.Errors[0].Error?.Message); + + // Verify error from provider2 + Assert.Equal(Provider2Name, result.Errors[1].ProviderName); + Assert.Equal("Error from provider2", result.Errors[1].Error?.Message); + + // Verify exception from provider3 + Assert.Equal(Provider3Name, result.Errors[2].ProviderName); + Assert.IsType(result.Errors[2].Error); + Assert.Equal("Exception from provider3", result.Errors[2].Error?.Message); + } + + [Fact] + public void DetermineFinalResult_WithNullEvaluationContext_HandlesGracefully() + { + // Arrange + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant1")); + + var resolutions = new List> { successfulResult }; + const bool defaultValue = false; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, defaultValue, null, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + } + + [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, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, false, errorType, Reason.Error, errorMessage: $"Error of type {errorType}")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, errorResult); + + // Assert + Assert.True(result); + } +} 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 000000000..008f61cf2 --- /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 000000000..b305c2cc7 --- /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); + } +}