diff --git a/README.md b/README.md index c263023f9..1b0a96047 100644 --- a/README.md +++ b/README.md @@ -443,10 +443,12 @@ Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/ ### Multi-Provider > [!NOTE] -> The Multi-Provider feature is currently experimental. Hooks and events are not supported at the moment. +> The Multi-Provider feature is currently experimental. 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. +The Multi-Provider supports provider hooks and executes them in accordance with the OpenFeature specification. Each provider's hooks are executed with context isolation, ensuring that context modifications by one provider's hooks do not affect other providers. + #### Basic Usage ```csharp @@ -524,9 +526,7 @@ The Multi-Provider supports two evaluation modes: #### 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 +- **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. diff --git a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs index 574b2e1e4..9737198f0 100644 --- a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs +++ b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs @@ -270,7 +270,7 @@ private async Task>> SequentialEvaluationAsync< continue; } - var result = await registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, cancellationToken).ConfigureAwait(false); + var result = await registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, this._logger, cancellationToken).ConfigureAwait(false); resolutions.Add(result); if (!this._evaluationStrategy.ShouldEvaluateNextProvider(providerContext, evaluationContext, result)) @@ -297,7 +297,7 @@ private async Task>> ParallelEvaluationAsync if (this._evaluationStrategy.ShouldEvaluateThisProvider(providerContext, evaluationContext)) { - tasks.Add(registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, cancellationToken)); + tasks.Add(registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, this._logger, cancellationToken)); } } diff --git a/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs b/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs index d8f70dfbf..160fc9e00 100644 --- a/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs +++ b/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs @@ -1,4 +1,8 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.Logging; using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Extension; using OpenFeature.Model; using OpenFeature.Providers.MultiProvider.Strategies.Models; @@ -11,23 +15,56 @@ internal static async Task> EvaluateAsync( StrategyPerProviderContext providerContext, EvaluationContext? evaluationContext, T defaultValue, - CancellationToken cancellationToken) + ILogger logger, + CancellationToken cancellationToken = default) { var key = providerContext.FlagKey; try { + // Execute provider hooks for this specific provider + var providerHooks = provider.GetProviderHooks(); + EvaluationContext? contextForThisProvider = evaluationContext; + + if (providerHooks.Count > 0) + { + // Execute hooks for this provider with context isolation + var (modifiedContext, hookResult) = await ExecuteBeforeEvaluationHooksAsync( + provider, + providerHooks, + key, + defaultValue, + evaluationContext, + logger, + cancellationToken).ConfigureAwait(false); + + if (hookResult != null) + { + return hookResult; + } + + contextForThisProvider = modifiedContext ?? evaluationContext; + } + + // Evaluate the flag with the (possibly modified) context 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), + bool boolDefaultValue => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, boolDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false), + string stringDefaultValue => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, stringDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false), + int intDefaultValue => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, intDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false), + double doubleDefaultValue => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, doubleDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false), + Value valueDefaultValue => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, valueDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false), + null when typeof(T) == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, contextForThisProvider, cancellationToken).ConfigureAwait(false), + null when typeof(T) == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, contextForThisProvider, cancellationToken).ConfigureAwait(false), _ => throw new ArgumentException($"Unsupported flag type: {typeof(T)}") }; + + // Execute after/finally hooks for this provider if we have them + if (providerHooks.Count > 0) + { + await ExecuteAfterEvaluationHooksAsync(provider, providerHooks, key, defaultValue, contextForThisProvider, result, logger, cancellationToken).ConfigureAwait(false); + } + return new ProviderResolutionResult(provider, providerContext.ProviderName, result); } catch (Exception ex) @@ -43,4 +80,101 @@ null when typeof(T) == typeof(Value) => (ResolutionDetails)(object)await prov return new ProviderResolutionResult(provider, providerContext.ProviderName, errorResult, ex); } } + + private static async Task<(EvaluationContext?, ProviderResolutionResult?)> ExecuteBeforeEvaluationHooksAsync( + FeatureProvider provider, + IImmutableList hooks, + string key, + T defaultValue, + EvaluationContext? evaluationContext, + ILogger logger, + CancellationToken cancellationToken) + { + try + { + var sharedHookContext = new SharedHookContext( + key, + defaultValue, + GetFlagValueType(), + new ClientMetadata(MultiProviderConstants.ProviderName, null), + provider.GetMetadata() + ); + + var initialContext = evaluationContext ?? EvaluationContext.Empty; + var hookRunner = new HookRunner([.. hooks], initialContext, sharedHookContext, logger); + + // Execute before hooks for this provider + var modifiedContext = await hookRunner.TriggerBeforeHooksAsync(null, cancellationToken).ConfigureAwait(false); + return (modifiedContext, null); + } + catch (Exception hookEx) + { + // If before hooks fail, return error result + var errorResult = new ResolutionDetails( + key, + defaultValue, + ErrorType.General, + Reason.Error, + errorMessage: $"Provider hook execution failed: {hookEx.Message}"); + + var result = new ProviderResolutionResult(provider, provider.GetMetadata()?.Name ?? "unknown", errorResult, hookEx); + return (null, result); + } + } + + private static async Task ExecuteAfterEvaluationHooksAsync( + FeatureProvider provider, + IImmutableList hooks, + string key, + T defaultValue, + EvaluationContext? evaluationContext, + ResolutionDetails result, + ILogger logger, + CancellationToken cancellationToken) + { + try + { + var sharedHookContext = new SharedHookContext( + key, + defaultValue, + GetFlagValueType(), + new ClientMetadata(MultiProviderConstants.ProviderName, null), + provider.GetMetadata() + ); + + var hookRunner = new HookRunner([.. hooks], evaluationContext ?? EvaluationContext.Empty, sharedHookContext, logger); + + var evaluationDetails = result.ToFlagEvaluationDetails(); + + if (result.ErrorType == ErrorType.None) + { + await hookRunner.TriggerAfterHooksAsync(evaluationDetails, null, cancellationToken).ConfigureAwait(false); + } + else + { + var exception = new FeatureProviderException(result.ErrorType, result.ErrorMessage); + await hookRunner.TriggerErrorHooksAsync(exception, null, cancellationToken).ConfigureAwait(false); + } + + await hookRunner.TriggerFinallyHooksAsync(evaluationDetails, null, cancellationToken).ConfigureAwait(false); + } + catch (Exception hookEx) + { + // Log hook execution errors but don't fail the evaluation + logger.LogWarning(hookEx, "Provider after/finally hook execution failed for provider {ProviderName}", provider.GetMetadata()?.Name ?? "unknown"); + } + } + + internal static FlagValueType GetFlagValueType() + { + return typeof(T) switch + { + _ when typeof(T) == typeof(bool) => FlagValueType.Boolean, + _ when typeof(T) == typeof(string) => FlagValueType.String, + _ when typeof(T) == typeof(int) => FlagValueType.Number, + _ when typeof(T) == typeof(double) => FlagValueType.Number, + _ when typeof(T) == typeof(Value) => FlagValueType.Object, + _ => FlagValueType.Object // Default fallback + }; + } } diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs index c90a8a68d..615b67c7e 100644 --- a/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Reflection; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -844,4 +845,251 @@ public async Task EvaluateAsync_AfterDispose_ShouldThrowObjectDisposedException( Assert.Equal(nameof(MultiProvider), structureException.ObjectName); } + #region Hook Tests + + [Fact] + public void GetProviderHooks_WithNoProviders_ReturnsEmptyList() + { + // Arrange - Create provider without hooks + var provider = Substitute.For(); + provider.GetProviderHooks().Returns(ImmutableList.Empty); + var providerEntries = new List { new(provider, Provider1Name) }; + + // Act + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + var hooks = multiProvider.GetProviderHooks(); + + // Assert + Assert.Empty(hooks); + } + + [Fact] + public void GetProviderHooks_WithSingleProviderWithHooks_ReturnsEmptyList() + { + // Arrange + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var providerHooks = ImmutableList.Create(hook1, hook2); + + var provider = Substitute.For(); + provider.GetProviderHooks().Returns(providerHooks); + var providerEntries = new List { new(provider, Provider1Name) }; + + // Act + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + var hooks = multiProvider.GetProviderHooks(); + + // Assert + Assert.Empty(hooks); + } + + [Fact] + public void GetProviderHooks_WithMultipleProvidersWithHooks_ReturnsEmptyList() + { + // Arrange + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var hook3 = Substitute.For(); + + this._mockProvider1.GetProviderHooks().Returns(ImmutableList.Create(hook1, hook2)); + this._mockProvider2.GetProviderHooks().Returns(ImmutableList.Create(hook3)); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + + // Act + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + var hooks = multiProvider.GetProviderHooks(); + + // Assert + Assert.Empty(hooks); + } + + [Fact] + public async Task EvaluateAsync_WithProviderHooks_ExecutesHooksForEachProvider() + { + // Arrange + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + + // Setup hooks to return modified context + var modifiedContext = new EvaluationContextBuilder() + .Set("modified", "value") + .Build(); + + hook1.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(modifiedContext); + hook2.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(EvaluationContext.Empty); + + this._mockProvider1.GetProviderHooks().Returns(ImmutableList.Create(hook1)); + this._mockProvider2.GetProviderHooks().Returns(ImmutableList.Create(hook2)); + + // Setup providers to return successful results + const bool expectedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, expectedValue, ErrorType.None, Reason.Static, TestVariant); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, false, Arg.Any(), Arg.Any()) + .Returns(expectedDetails); + this._mockProvider2.ResolveBooleanValueAsync(TestFlagKey, false, Arg.Any(), Arg.Any()) + .Returns(expectedDetails); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + + // Setup strategy to evaluate both providers + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), Arg.Any()).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), Arg.Any(), Arg.Any>()).Returns(true); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, false, Arg.Any(), Arg.Any>>()) + .Returns(new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null)); + + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, false, this._evaluationContext); + + // Assert + Assert.Equal(expectedValue, result.Value); + + // Verify hooks were called + await hook1.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()); + await hook2.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()); + + // Verify after hooks were called + await hook1.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + await hook2.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + + // Verify finally hooks were called + await hook1.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + await hook2.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task EvaluateAsync_WithHookContextModification_IsolatesContextBetweenProviders() + { + // Arrange + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + + // Setup hook1 to modify context + var modifiedContext1 = new EvaluationContextBuilder() + .Set("provider1", "modified") + .Build(); + + // Setup hook2 to modify context differently + var modifiedContext2 = new EvaluationContextBuilder() + .Set("provider2", "modified") + .Build(); + + hook1.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(modifiedContext1); + hook2.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(modifiedContext2); + + this._mockProvider1.GetProviderHooks().Returns(ImmutableList.Create(hook1)); + this._mockProvider2.GetProviderHooks().Returns(ImmutableList.Create(hook2)); + + // Setup providers to return results and capture context + EvaluationContext? capturedContext1 = null; + EvaluationContext? capturedContext2 = null; + + const bool expectedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, expectedValue, ErrorType.None, Reason.Static, TestVariant); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, false, Arg.Do(ctx => capturedContext1 = ctx), Arg.Any()) + .Returns(expectedDetails); + this._mockProvider2.ResolveBooleanValueAsync(TestFlagKey, false, Arg.Do(ctx => capturedContext2 = ctx), Arg.Any()) + .Returns(expectedDetails); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + + // Setup strategy to evaluate both providers + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), Arg.Any()).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), Arg.Any(), Arg.Any>()).Returns(true); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, false, Arg.Any(), Arg.Any>>()) + .Returns(new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null)); + + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + + // Act + await multiProvider.ResolveBooleanValueAsync(TestFlagKey, false, this._evaluationContext); + + // Assert - Verify context isolation + Assert.NotNull(capturedContext1); + Assert.NotNull(capturedContext2); + + // Provider1 should have received the context modified by hook1 + Assert.True(capturedContext1!.ContainsKey("provider1")); + Assert.Equal("modified", capturedContext1.GetValue("provider1").AsString); + Assert.False(capturedContext1.ContainsKey("provider2")); + + // Provider2 should have received the context modified by hook2 + Assert.True(capturedContext2!.ContainsKey("provider2")); + Assert.Equal("modified", capturedContext2.GetValue("provider2").AsString); + Assert.False(capturedContext2.ContainsKey("provider1")); + } + + [Fact] + public async Task EvaluateAsync_WithHookError_HandlesErrorAndContinuesEvaluation() + { + // Arrange + var throwingHook = Substitute.For(); + var normalHook = Substitute.For(); + + // Setup throwing hook to throw exception in before hook + throwingHook.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Throws(new InvalidOperationException("Hook error")); + + // Setup normal hook + normalHook.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(EvaluationContext.Empty); + + this._mockProvider1.GetProviderHooks().Returns(ImmutableList.Create(throwingHook)); + this._mockProvider2.GetProviderHooks().Returns(ImmutableList.Create(normalHook)); + + // Setup provider2 to return successful result + const bool expectedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, expectedValue, ErrorType.None, Reason.Static, TestVariant); + + this._mockProvider2.ResolveBooleanValueAsync(TestFlagKey, false, Arg.Any(), Arg.Any()) + .Returns(expectedDetails); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + + // Setup strategy to continue evaluation after first provider error + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), Arg.Any()).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), Arg.Any(), Arg.Any>()).Returns(true); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, false, Arg.Any(), Arg.Any>>()) + .Returns(new FinalResult(expectedDetails, this._mockProvider2, Provider2Name, null)); + + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, false, this._evaluationContext); + + // Assert + Assert.Equal(expectedValue, result.Value); + + // Verify that the first provider returned an error due to hook failure + // and the second provider succeeded + await this._mockProvider1.DidNotReceive().ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await this._mockProvider2.Received(1).ResolveBooleanValueAsync(TestFlagKey, false, Arg.Any(), Arg.Any()); + } + + #endregion + } diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs index f37e0ddf3..afb14fe65 100644 --- a/test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; using OpenFeature.Constant; @@ -15,6 +17,7 @@ public class ProviderExtensionsTests private readonly FeatureProvider _mockProvider = Substitute.For(); private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); private readonly CancellationToken _cancellationToken = CancellationToken.None; + private readonly ILogger _mockLogger = Substitute.For(); [Fact] public async Task EvaluateAsync_WithBooleanType_CallsResolveBooleanValueAsync() @@ -29,7 +32,7 @@ public async Task EvaluateAsync_WithBooleanType_CallsResolveBooleanValueAsync() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -53,7 +56,7 @@ public async Task EvaluateAsync_WithStringType_CallsResolveStringValueAsync() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -77,7 +80,7 @@ public async Task EvaluateAsync_WithIntegerType_CallsResolveIntegerValueAsync() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -101,7 +104,7 @@ public async Task EvaluateAsync_WithDoubleType_CallsResolveDoubleValueAsync() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -125,7 +128,7 @@ public async Task EvaluateAsync_WithValueType_CallsResolveStructureValueAsync() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -144,7 +147,7 @@ public async Task EvaluateAsync_WithUnsupportedType_ThrowsArgumentException() var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -171,7 +174,7 @@ public async Task EvaluateAsync_WhenProviderThrowsException_ReturnsErrorResult() .ThrowsAsync(expectedException); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -198,7 +201,7 @@ public async Task EvaluateAsync_WithNullEvaluationContext_CallsProviderWithNullC .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, null, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, null, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -223,7 +226,7 @@ public async Task EvaluateAsync_WithCancellationToken_PassesToProvider() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, customCancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, customCancellationToken); // Assert Assert.NotNull(result); @@ -244,7 +247,7 @@ public async Task EvaluateAsync_WithNullDefaultValue_PassesNullToProvider() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue!, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue!, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -266,7 +269,7 @@ public async Task EvaluateAsync_WithDifferentFlagKeys_UsesCorrectKey() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -294,7 +297,7 @@ public async Task EvaluateAsync_WhenOperationCancelled_ReturnsErrorResult() }); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, cancellationTokenSource.Token); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, cancellationTokenSource.Token); // Assert Assert.NotNull(result); @@ -325,11 +328,137 @@ public async Task EvaluateAsync_WithComplexEvaluationContext_PassesContextToProv .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, complexContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, complexContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); Assert.Equal(expectedDetails, result.ResolutionDetails); await this._mockProvider.Received(1).ResolveDoubleValueAsync(TestFlagKey, defaultValue, complexContext, this._cancellationToken); } + + [Fact] + public async Task EvaluateAsync_WithProviderHooksAndErrorResult_TriggersErrorHooks() + { + // Arrange + var mockHook = Substitute.For(); + + // Setup hook to return evaluation context successfully + mockHook.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(EvaluationContext.Empty); + + // Setup provider metadata + var providerMetadata = new Metadata(TestProviderName); + this._mockProvider.GetMetadata().Returns(providerMetadata); + this._mockProvider.GetProviderHooks().Returns(ImmutableList.Create(mockHook)); + + const bool defaultValue = false; + var errorDetails = new ResolutionDetails( + TestFlagKey, + defaultValue, + ErrorType.FlagNotFound, + Reason.Error, + TestVariant, + errorMessage: "Flag not found"); + + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, Arg.Any(), this._cancellationToken) + .Returns(errorDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(ErrorType.FlagNotFound, result.ResolutionDetails.ErrorType); + Assert.Equal(Reason.Error, result.ResolutionDetails.Reason); + + // Verify before hook was called + await mockHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()); + + // Verify error hook was called (not after hook) + await mockHook.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>(), Arg.Any()); + await mockHook.DidNotReceive().AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + + // Verify finally hook was called + await mockHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + } + + [Theory] + [InlineData(typeof(bool), FlagValueType.Boolean)] + [InlineData(typeof(string), FlagValueType.String)] + [InlineData(typeof(int), FlagValueType.Number)] + [InlineData(typeof(double), FlagValueType.Number)] + [InlineData(typeof(Value), FlagValueType.Object)] + [InlineData(typeof(ProviderExtensionsTests), FlagValueType.Object)] // fallback path + public void GetFlagValueType_ReturnsExpectedFlagValueType(Type inputType, FlagValueType expected) + { + FlagValueType result = inputType == typeof(bool) ? ProviderExtensions.GetFlagValueType() + : inputType == typeof(string) ? ProviderExtensions.GetFlagValueType() + : inputType == typeof(int) ? ProviderExtensions.GetFlagValueType() + : inputType == typeof(double) ? ProviderExtensions.GetFlagValueType() + : inputType == typeof(Value) ? ProviderExtensions.GetFlagValueType() + : ProviderExtensions.GetFlagValueType(); + + Assert.Equal(expected, result); + } + + [Fact] + public async Task EvaluateAsync_WhenAfterHookThrowsException_LogsWarningButSucceeds() + { + // Arrange + var hookException = new InvalidOperationException("After hook failed"); + var throwingHook = new ThrowingAfterHook(hookException); + + // Setup provider metadata and hooks + var providerMetadata = new Metadata(TestProviderName); + this._mockProvider.GetMetadata().Returns(providerMetadata); + this._mockProvider.GetProviderHooks().Returns(ImmutableList.Create(throwingHook)); + + const bool defaultValue = false; + const bool resolvedValue = true; + var successDetails = new ResolutionDetails( + TestFlagKey, + resolvedValue, + ErrorType.None, + Reason.Static, + TestVariant); + + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, Arg.Any(), this._cancellationToken) + .Returns(successDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(resolvedValue, result.ResolutionDetails.Value); + Assert.Equal(ErrorType.None, result.ResolutionDetails.ErrorType); + Assert.Null(result.ThrownError); // Hook errors don't propagate + + // Verify warning was logged + this._mockLogger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains("Provider after/finally hook execution failed")), + Arg.Is(ex => ex == hookException), + Arg.Any>()); + } +} + +internal class ThrowingAfterHook : Hook +{ + private InvalidOperationException hookException; + + public ThrowingAfterHook(InvalidOperationException hookException) + { + this.hookException = hookException; + } + + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + throw this.hookException; + } }