From 9274056200d2da5939d35468b2fc8c0961dbdd51 Mon Sep 17 00:00:00 2001 From: Benjamin Evenson <2031163+benjiro@users.noreply.github.com> Date: Sat, 9 Jul 2022 15:22:39 +1000 Subject: [PATCH] Add spec compliant test suite Signed-off-by: Benjamin Evenson <2031163+benjiro@users.noreply.github.com> --- build/Common.props | 1 + src/OpenFeature/Constant/Error.cs | 12 - src/OpenFeature/Constant/ErrorType.cs | 19 + src/OpenFeature/Constant/NoOpProvider.cs | 1 + .../Error/FeatureProviderException.cs | 19 + src/OpenFeature/Extension/EnumExtensions.cs | 16 + .../Extension/ResolutionDetailsExtensions.cs | 2 +- src/OpenFeature/Hook.cs | 2 + src/OpenFeature/Model/ClientMetadata.cs | 2 +- src/OpenFeature/Model/EvaluationContext.cs | 27 +- .../Model/FlagEvaluationOptions.cs | 10 +- .../Model/FlagEvalusationDetails.cs | 16 +- src/OpenFeature/Model/HookContext.cs | 13 +- src/OpenFeature/Model/Metadata.cs | 2 +- src/OpenFeature/Model/ResolutionDetails.cs | 16 +- src/OpenFeature/NoOpProvider.cs | 5 +- src/OpenFeature/OpenFeature.cs | 30 +- src/OpenFeature/OpenFeatureClient.cs | 114 +++--- .../FeatureProviderExceptionTests.cs | 24 ++ .../OpenFeature.Tests/FeatureProviderTests.cs | 88 ++++ .../Internal/SpecificationAttribute.cs | 17 + .../NoOpFeatureProviderTest.cs | 83 ---- .../OpenFeatureClientTests.cs | 232 +++++++++-- .../OpenFeatureEvaluationContextTests.cs | 21 +- .../OpenFeature.Tests/OpenFeatureHookTests.cs | 384 +++++++++++++++--- test/OpenFeature.Tests/OpenFeatureTests.cs | 78 +++- test/OpenFeature.Tests/TestImplementations.cs | 4 +- 27 files changed, 905 insertions(+), 333 deletions(-) delete mode 100644 src/OpenFeature/Constant/Error.cs create mode 100644 src/OpenFeature/Constant/ErrorType.cs create mode 100644 src/OpenFeature/Error/FeatureProviderException.cs create mode 100644 src/OpenFeature/Extension/EnumExtensions.cs create mode 100644 test/OpenFeature.Tests/FeatureProviderExceptionTests.cs create mode 100644 test/OpenFeature.Tests/FeatureProviderTests.cs create mode 100644 test/OpenFeature.Tests/Internal/SpecificationAttribute.cs delete mode 100644 test/OpenFeature.Tests/NoOpFeatureProviderTest.cs diff --git a/build/Common.props b/build/Common.props index dcf6c6e0..1e1f8be4 100644 --- a/build/Common.props +++ b/build/Common.props @@ -1,5 +1,6 @@ + 7.3 true diff --git a/src/OpenFeature/Constant/Error.cs b/src/OpenFeature/Constant/Error.cs deleted file mode 100644 index d0eb3c50..00000000 --- a/src/OpenFeature/Constant/Error.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace OpenFeature.Constant -{ - // TODO Probably should make this a enum - public static class Error - { - public static string ProviderNotReady = "PROVIDER_NOT_READY"; - public static string FlagNotFound = "FLAG_NOT_FOUND"; - public static string ParseError = "PARSE_ERROR"; - public static string TypeMismatch = "TYPE_MISMATCH"; - public static string GeneralError = "GENERAL"; - } -} diff --git a/src/OpenFeature/Constant/ErrorType.cs b/src/OpenFeature/Constant/ErrorType.cs new file mode 100644 index 00000000..452ef951 --- /dev/null +++ b/src/OpenFeature/Constant/ErrorType.cs @@ -0,0 +1,19 @@ +using System.ComponentModel; + +namespace OpenFeature.Constant +{ + public enum ErrorType + { + None, + [Description("PROVIDER_NOT_READY")] + ProviderNotReady, + [Description("FLAG_NOT_FOUND")] + FlagNotFound, + [Description("PARSE_ERROR")] + ParseError, + [Description("TYPE_MISMATCH")] + TypeMismatch, + [Description("GENERAL")] + General + } +} diff --git a/src/OpenFeature/Constant/NoOpProvider.cs b/src/OpenFeature/Constant/NoOpProvider.cs index 16ada1bf..24f1769a 100644 --- a/src/OpenFeature/Constant/NoOpProvider.cs +++ b/src/OpenFeature/Constant/NoOpProvider.cs @@ -4,5 +4,6 @@ public static class NoOpProvider { public const string NoOpProviderName = "No-op Provider"; public const string ReasonNoOp = "No-op"; + public const string Variant = "No-op"; } } diff --git a/src/OpenFeature/Error/FeatureProviderException.cs b/src/OpenFeature/Error/FeatureProviderException.cs new file mode 100644 index 00000000..39847fd4 --- /dev/null +++ b/src/OpenFeature/Error/FeatureProviderException.cs @@ -0,0 +1,19 @@ +using System; +using OpenFeature.Constant; +using OpenFeature.Extention; + +namespace OpenFeature.Error +{ + public class FeatureProviderException : Exception + { + public ErrorType ErrorType { get; } + public string ErrorTypeDescription { get; } + + public FeatureProviderException(ErrorType errorType, Exception innerException) + : base(null, innerException) + { + this.ErrorType = errorType; + this.ErrorTypeDescription = errorType.GetDescription(); + } + } +} diff --git a/src/OpenFeature/Extension/EnumExtensions.cs b/src/OpenFeature/Extension/EnumExtensions.cs new file mode 100644 index 00000000..d6597fa7 --- /dev/null +++ b/src/OpenFeature/Extension/EnumExtensions.cs @@ -0,0 +1,16 @@ +using System; +using System.ComponentModel; +using System.Linq; + +namespace OpenFeature.Extention +{ + public static class EnumExtensions + { + public static string GetDescription(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + var attribute = field.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; + return attribute?.Description ?? value.ToString(); + } + } +} diff --git a/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs b/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs index d46570fb..957bc8e9 100644 --- a/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs +++ b/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs @@ -6,7 +6,7 @@ public static class ResolutionDetailsExtensions { public static FlagEvaluationDetails ToFlagEvaluationDetails(this ResolutionDetails details) { - return new FlagEvaluationDetails(details.FlagKey, details.Value, details.ErrorCode, details.Reason, details.Variant); + return new FlagEvaluationDetails(details.FlagKey, details.Value, details.ErrorType, details.Reason, details.Variant); } } } diff --git a/src/OpenFeature/Hook.cs b/src/OpenFeature/Hook.cs index d3b38123..647f2954 100644 --- a/src/OpenFeature/Hook.cs +++ b/src/OpenFeature/Hook.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; namespace OpenFeature.Model { + internal interface IHook { Task Before(HookContext context, IReadOnlyDictionary hints = null); diff --git a/src/OpenFeature/Model/ClientMetadata.cs b/src/OpenFeature/Model/ClientMetadata.cs index ca2fff99..cc4e86a2 100644 --- a/src/OpenFeature/Model/ClientMetadata.cs +++ b/src/OpenFeature/Model/ClientMetadata.cs @@ -6,7 +6,7 @@ public class ClientMetadata : Metadata public ClientMetadata(string name, string version) : base(name) { - Version = version; + this.Version = version; } } } diff --git a/src/OpenFeature/Model/EvaluationContext.cs b/src/OpenFeature/Model/EvaluationContext.cs index a080e3e3..70bac08f 100644 --- a/src/OpenFeature/Model/EvaluationContext.cs +++ b/src/OpenFeature/Model/EvaluationContext.cs @@ -3,56 +3,55 @@ namespace OpenFeature.Model { - public class EvaluationContext : IEnumerable + public class EvaluationContext : IEnumerable> { private readonly Dictionary _internalContext = new Dictionary(); public void Add(string key, T value) { - _internalContext.Add(key, value); + this._internalContext.Add(key, value); } public void Remove(string key) { - _internalContext.Remove(key); + this._internalContext.Remove(key); } public T Get(string key) { - return (T)_internalContext[key]; + return (T)this._internalContext[key]; } public object this[string key] { - get => _internalContext[key]; - set => _internalContext[key] = value; + get => this._internalContext[key]; + set => this._internalContext[key] = value; } public void Merge(EvaluationContext other) { foreach (var key in other._internalContext.Keys) { - if (_internalContext.ContainsKey(key)) + if (this._internalContext.ContainsKey(key)) { - _internalContext[key] = other._internalContext[key]; + this._internalContext[key] = other._internalContext[key]; } else { - _internalContext.Add(key, other._internalContext[key]); + this._internalContext.Add(key, other._internalContext[key]); } } } - public int Count => _internalContext.Count; - - public IEnumerator GetEnumerator() + public int Count => this._internalContext.Count; + public IEnumerator> GetEnumerator() { - return _internalContext.Values.GetEnumerator(); + return this._internalContext.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() { - return GetEnumerator(); + return this.GetEnumerator(); } } } diff --git a/src/OpenFeature/Model/FlagEvaluationOptions.cs b/src/OpenFeature/Model/FlagEvaluationOptions.cs index af28f071..fbbad6f3 100644 --- a/src/OpenFeature/Model/FlagEvaluationOptions.cs +++ b/src/OpenFeature/Model/FlagEvaluationOptions.cs @@ -9,8 +9,14 @@ public class FlagEvaluationOptions public FlagEvaluationOptions(IReadOnlyList hooks, IReadOnlyDictionary hookHints) { - Hooks = hooks; - HookHints = hookHints; + this.Hooks = hooks; + this.HookHints = hookHints; + } + + public FlagEvaluationOptions(Hook hook, IReadOnlyDictionary hookHints) + { + this.Hooks = new[] { hook }; + this.HookHints = hookHints; } } } diff --git a/src/OpenFeature/Model/FlagEvalusationDetails.cs b/src/OpenFeature/Model/FlagEvalusationDetails.cs index 7652dd7a..957ce232 100644 --- a/src/OpenFeature/Model/FlagEvalusationDetails.cs +++ b/src/OpenFeature/Model/FlagEvalusationDetails.cs @@ -1,20 +1,22 @@ +using OpenFeature.Constant; + namespace OpenFeature.Model { public class FlagEvaluationDetails { public T Value { get; } public string FlagKey { get; } - public string ErrorCode { get; } + public ErrorType ErrorType { get; } public string Reason { get; } public string Variant { get; } - public FlagEvaluationDetails(string flagKey, T value, string errorCode, string reason, string variant) + public FlagEvaluationDetails(string flagKey, T value, ErrorType errorType, string reason, string variant) { - Value = value; - FlagKey = flagKey; - ErrorCode = errorCode; - Reason = reason; - Variant = variant; + this.Value = value; + this.FlagKey = flagKey; + this.ErrorType = errorType; + this.Reason = reason; + this.Variant = variant; } } } diff --git a/src/OpenFeature/Model/HookContext.cs b/src/OpenFeature/Model/HookContext.cs index 568b6bef..19ec7cdc 100644 --- a/src/OpenFeature/Model/HookContext.cs +++ b/src/OpenFeature/Model/HookContext.cs @@ -1,3 +1,4 @@ +using System; using OpenFeature.Constant; namespace OpenFeature.Model @@ -18,12 +19,12 @@ public HookContext(string flagKey, Metadata providerMetadata, EvaluationContext evaluationContext) { - FlagKey = flagKey; - DefaultValue = defaultValue; - FlagValueType = flagValueType; - ClientMetadata = clientMetadata; - ProviderMetadata = providerMetadata; - EvaluationContext = evaluationContext; + this.FlagKey = flagKey ?? throw new ArgumentNullException(nameof(flagKey)); + this.DefaultValue = defaultValue; + this.FlagValueType = flagValueType; + this.ClientMetadata = clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata)); + this.ProviderMetadata = providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata)); + this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); } } } diff --git a/src/OpenFeature/Model/Metadata.cs b/src/OpenFeature/Model/Metadata.cs index 4708e3fd..c8905e2d 100644 --- a/src/OpenFeature/Model/Metadata.cs +++ b/src/OpenFeature/Model/Metadata.cs @@ -6,7 +6,7 @@ public class Metadata public Metadata(string name) { - Name = name; + this.Name = name; } } } diff --git a/src/OpenFeature/Model/ResolutionDetails.cs b/src/OpenFeature/Model/ResolutionDetails.cs index 36cde8c6..0f967457 100644 --- a/src/OpenFeature/Model/ResolutionDetails.cs +++ b/src/OpenFeature/Model/ResolutionDetails.cs @@ -1,20 +1,22 @@ +using OpenFeature.Constant; + namespace OpenFeature.Model { public class ResolutionDetails { public T Value { get; } public string FlagKey { get; } - public string ErrorCode { get; } + public ErrorType ErrorType { get; } public string Reason { get; } public string Variant { get; } - public ResolutionDetails(string flagKey, T value, string errorCode = null, string reason = null, string variant = null) + public ResolutionDetails(string flagKey, T value, ErrorType errorType = ErrorType.None, string reason = null, string variant = null) { - Value = value; - FlagKey = flagKey; - ErrorCode = errorCode; - Reason = reason; - Variant = variant; + this.Value = value; + this.FlagKey = flagKey; + this.ErrorType = errorType; + this.Reason = reason; + this.Variant = variant; } } } diff --git a/src/OpenFeature/NoOpProvider.cs b/src/OpenFeature/NoOpProvider.cs index df3b5a73..fca25529 100644 --- a/src/OpenFeature/NoOpProvider.cs +++ b/src/OpenFeature/NoOpProvider.cs @@ -14,7 +14,7 @@ public class NoOpFeatureProvider : IFeatureProvider public Metadata GetMetadata() { - return _metadata; + return this._metadata; } public Task> ResolveBooleanValue(string flagKey, bool defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) @@ -42,7 +42,8 @@ private static ResolutionDetails NoOpResponse(string flagKey, T defaultVal return new ResolutionDetails( flagKey, defaultValue, - reason: NoOpProvider.ReasonNoOp + reason: NoOpProvider.ReasonNoOp, + variant: NoOpProvider.Variant ); } } diff --git a/src/OpenFeature/OpenFeature.cs b/src/OpenFeature/OpenFeature.cs index 1a2ddaf8..326e12fd 100644 --- a/src/OpenFeature/OpenFeature.cs +++ b/src/OpenFeature/OpenFeature.cs @@ -1,7 +1,5 @@ -using System; using System.Collections.Generic; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using OpenFeature.Model; namespace OpenFeature @@ -11,24 +9,22 @@ public sealed class OpenFeature private EvaluationContext _evaluationContext = new EvaluationContext(); private IFeatureProvider _featureProvider = new NoOpFeatureProvider(); private readonly List _hooks = new List(); - public static ILogger Logger { get; private set; } = new Logger(new NullLoggerFactory()); - // Thread-safe singleton instance - private static readonly Lazy lazy = new Lazy(); - private static OpenFeature Instance => lazy.Value; + public static OpenFeature Instance { get; } = new OpenFeature(); + static OpenFeature() { } + private OpenFeature() { } - public static void SetProvider(IFeatureProvider featureProvider) => Instance._featureProvider = featureProvider; - public static IFeatureProvider GetProvider() => Instance._featureProvider; - public static Metadata GetProviderMetadata() => Instance._featureProvider.GetMetadata(); - public static FeatureClient GetClient(string name = null, string version = null) => new FeatureClient(Instance._featureProvider, name, version); + public void SetProvider(IFeatureProvider featureProvider) => this._featureProvider = featureProvider; + public IFeatureProvider GetProvider() => this._featureProvider; + public Metadata GetProviderMetadata() => this._featureProvider.GetMetadata(); + public FeatureClient GetClient(string name = null, string version = null, ILogger logger = null) => new FeatureClient(this._featureProvider, name, version, logger); - public static void AddHooks(IEnumerable hooks) => Instance._hooks.AddRange(hooks); - public static IEnumerable GetHooks() => Instance._hooks.AsReadOnly(); - public static void ClearHooks() => Instance._hooks.Clear(); + public void AddHooks(IEnumerable hooks) => this._hooks.AddRange(hooks); + public void AddHooks(Hook hook) => this._hooks.Add(hook); + public IReadOnlyList GetHooks() => this._hooks.AsReadOnly(); + public void ClearHooks() => this._hooks.Clear(); - public static void SetContext(EvaluationContext context) => Instance._evaluationContext = context; - public static EvaluationContext GetContext() => Instance._evaluationContext; - - public static void SetLogger(ILogger logger) => Logger = logger; + public void SetContext(EvaluationContext context) => this._evaluationContext = context; + public EvaluationContext GetContext() => this._evaluationContext; } } diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index 0db9629e..f7936500 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -3,7 +3,9 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using OpenFeature.Constant; +using OpenFeature.Error; using OpenFeature.Extention; using OpenFeature.Model; @@ -14,43 +16,45 @@ public class FeatureClient : IFeatureClient private readonly ClientMetadata _metadata; private readonly IFeatureProvider _featureProvider; private readonly List _hooks = new List(); + private readonly ILogger _logger; - public FeatureClient(IFeatureProvider featureProvider, string name, string version) + public FeatureClient(IFeatureProvider featureProvider, string name, string version, ILogger logger = null) { - _featureProvider = featureProvider ?? throw new ArgumentNullException(nameof(featureProvider)); - _metadata = new ClientMetadata(name, version); + this._featureProvider = featureProvider ?? throw new ArgumentNullException(nameof(featureProvider)); + this._metadata = new ClientMetadata(name, version); + this._logger = logger ?? new Logger(new NullLoggerFactory()); } - public ClientMetadata GetMetadata() => _metadata; + public ClientMetadata GetMetadata() => this._metadata; - public void AddHooks(Hook hook) => _hooks.Add(hook); - public void AddHooks(IEnumerable hooks) => _hooks.AddRange(hooks); - public IReadOnlyList GetHooks() => _hooks.ToList(); - public void ClearHooks() => _hooks.Clear(); + public void AddHooks(Hook hook) => this._hooks.Add(hook); + public void AddHooks(IEnumerable hooks) => this._hooks.AddRange(hooks); + public IReadOnlyList GetHooks() => this._hooks.ToList(); + public void ClearHooks() => this._hooks.Clear(); public async Task GetBooleanValue(string flagKey, bool defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - (await GetBooleanDetails(flagKey, defaultValue, context, config)).Value; + (await this.GetBooleanDetails(flagKey, defaultValue, context, config)).Value; public async Task> GetBooleanDetails(string flagKey, bool defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - await EvaluateFlag(_featureProvider.ResolveBooleanValue, FlagValueType.Boolean, flagKey, defaultValue, context, config); + await this.EvaluateFlag(this._featureProvider.ResolveBooleanValue, FlagValueType.Boolean, flagKey, defaultValue, context, config); public async Task GetStringValue(string flagKey, string defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - (await GetStringDetails(flagKey, defaultValue, context, config)).Value; + (await this.GetStringDetails(flagKey, defaultValue, context, config)).Value; public async Task> GetStringDetails(string flagKey, string defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - await EvaluateFlag(_featureProvider.ResolveStringValue, FlagValueType.String, flagKey, defaultValue, context, config); + await this.EvaluateFlag(this._featureProvider.ResolveStringValue, FlagValueType.String, flagKey, defaultValue, context, config); public async Task GetNumberValue(string flagKey, int defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - (await GetNumberDetails(flagKey, defaultValue, context, config)).Value; + (await this.GetNumberDetails(flagKey, defaultValue, context, config)).Value; public async Task> GetNumberDetails(string flagKey, int defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - await EvaluateFlag(_featureProvider.ResolveNumberValue, FlagValueType.Number, flagKey, defaultValue, context, config); + await this.EvaluateFlag(this._featureProvider.ResolveNumberValue, FlagValueType.Number, flagKey, defaultValue, context, config); public async Task GetObjectValue(string flagKey, T defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - (await GetObjectDetails(flagKey, defaultValue, context, config)).Value; + (await this.GetObjectDetails(flagKey, defaultValue, context, config)).Value; public async Task> GetObjectDetails(string flagKey, T defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - await EvaluateFlag(_featureProvider.ResolveStructureValue, FlagValueType.Object, flagKey, defaultValue, context, config); + await this.EvaluateFlag(this._featureProvider.ResolveStructureValue, FlagValueType.Object, flagKey, defaultValue, context, config); private async Task> EvaluateFlag( Func>> resolveValueDelegate, @@ -62,12 +66,13 @@ private async Task> EvaluateFlag( context = new EvaluationContext(); } - var evaluationContext = OpenFeature.GetContext(); + var evaluationContext = OpenFeature.Instance.GetContext(); evaluationContext.Merge(context); - var allHooks = new List() - .Concat(OpenFeature.GetHooks()) - .Concat(_hooks) + var allHooks = new List() + .Concat(OpenFeature.Instance.GetHooks()) + .Concat(this._hooks) + .Concat(options?.Hooks ?? Enumerable.Empty()) .ToList() .AsReadOnly(); @@ -80,76 +85,69 @@ private async Task> EvaluateFlag( var hookContext = new HookContext( flagKey, defaultValue, - flagValueType, - _metadata, - OpenFeature.GetProviderMetadata(), + flagValueType, this._metadata, + OpenFeature.Instance.GetProviderMetadata(), evaluationContext ); FlagEvaluationDetails evaluation; try { - await TriggerBeforeHooks(allHooks, hookContext, options); + await this.TriggerBeforeHooks(allHooks, hookContext, options); - evaluation = (await resolveValueDelegate.Invoke(flagKey, defaultValue, hookContext.EvaluationContext, options)) + evaluation = + (await resolveValueDelegate.Invoke(flagKey, defaultValue, hookContext.EvaluationContext, options)) .ToFlagEvaluationDetails(); - await TriggerAfterHooks(allHooksReversed, hookContext, evaluation, options); + await this.TriggerAfterHooks(allHooksReversed, hookContext, evaluation, options); } - catch (Exception e) + catch (FeatureProviderException ex) { - OpenFeature.Logger.LogError(e, "Error while evaluating flag {FlagKey}", flagKey); - // TODO needs to handle error codes being thrown from provider that maps to Errors enums - evaluation = new FlagEvaluationDetails(flagKey, defaultValue, e.Message, Reason.Error, string.Empty); - await TriggerErrorHooks(allHooksReversed, hookContext, e, options); + this._logger.LogError(ex, "Error while evaluating flag {FlagKey}. Error {ErrorType}", flagKey, ex.ErrorTypeDescription); + evaluation = new FlagEvaluationDetails(flagKey, defaultValue, ex.ErrorType, Reason.Error, string.Empty); + await this.TriggerErrorHooks(allHooksReversed, hookContext, ex, options); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error while evaluating flag {FlagKey}", flagKey); + var errorCode = ex is InvalidCastException ? ErrorType.TypeMismatch : ErrorType.General; + evaluation = new FlagEvaluationDetails(flagKey, defaultValue, errorCode, Reason.Error, string.Empty); + await this.TriggerErrorHooks(allHooksReversed, hookContext, ex, options); } finally { - await TriggerFinallyHooks(allHooksReversed, hookContext, options); + await this.TriggerFinallyHooks(allHooksReversed, hookContext, options); } return evaluation; } - private static async Task TriggerBeforeHooks(IReadOnlyList hooks, HookContext context, FlagEvaluationOptions options) + private async Task TriggerBeforeHooks(IReadOnlyList hooks, HookContext context, FlagEvaluationOptions options) { foreach (var hook in hooks) { - try + var resp = await hook.Before(context, options?.HookHints); + if (resp != null) { - var resp = await hook.Before(context, options?.HookHints); - if (resp != null) - { - context.EvaluationContext.Merge(resp); - } - else - { - OpenFeature.Logger.LogDebug("Hook {HookName} returned null, nothing to merge back into context", hook.GetType().Name); - } + context.EvaluationContext.Merge(resp); } - catch (Exception e) + else { - OpenFeature.Logger.LogError(e, "Error while executing Before hook {0}", hook.GetType().Name); + this._logger.LogDebug("Hook {HookName} returned null, nothing to merge back into context", + hook.GetType().Name); } } } - private static async Task TriggerAfterHooks(IReadOnlyList hooks, HookContext context, FlagEvaluationDetails evaluationDetails, FlagEvaluationOptions options) + private async Task TriggerAfterHooks(IReadOnlyList hooks, HookContext context, FlagEvaluationDetails evaluationDetails, FlagEvaluationOptions options) { foreach (var hook in hooks) { - try - { - await hook.After(context, evaluationDetails, options?.HookHints); - } - catch (Exception e) - { - OpenFeature.Logger.LogError(e, "Error while executing After hook {0}", hook.GetType().Name); - } + await hook.After(context, evaluationDetails, options?.HookHints); } } - private static async Task TriggerErrorHooks(IReadOnlyList hooks, HookContext context, Exception exception, FlagEvaluationOptions options) + private async Task TriggerErrorHooks(IReadOnlyList hooks, HookContext context, Exception exception, FlagEvaluationOptions options) { foreach (var hook in hooks) { @@ -159,12 +157,12 @@ private static async Task TriggerErrorHooks(IReadOnlyList hooks, HookC } catch (Exception e) { - OpenFeature.Logger.LogError(e, "Error while executing Error hook {0}", hook.GetType().Name); + this._logger.LogError(e, "Error while executing Error hook {0}", hook.GetType().Name); } } } - private static async Task TriggerFinallyHooks(IReadOnlyList hooks, HookContext context, FlagEvaluationOptions options) + private async Task TriggerFinallyHooks(IReadOnlyList hooks, HookContext context, FlagEvaluationOptions options) { foreach (var hook in hooks) { @@ -174,7 +172,7 @@ private static async Task TriggerFinallyHooks(IReadOnlyList hooks, Hoo } catch (Exception e) { - OpenFeature.Logger.LogError(e, "Error while executing Finally hook {0}", hook.GetType().Name); + this._logger.LogError(e, "Error while executing Finally hook {0}", hook.GetType().Name); } } } diff --git a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs new file mode 100644 index 00000000..3611e455 --- /dev/null +++ b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs @@ -0,0 +1,24 @@ +using System; +using FluentAssertions; +using OpenFeature.Constant; +using OpenFeature.Error; +using Xunit; + +namespace OpenFeature.Tests +{ + public class FeatureProviderExceptionTests + { + [Theory] + [InlineData(ErrorType.General, "GENERAL")] + [InlineData(ErrorType.ParseError, "PARSE_ERROR")] + [InlineData(ErrorType.TypeMismatch, "TYPE_MISMATCH")] + [InlineData(ErrorType.FlagNotFound, "FLAG_NOT_FOUND")] + [InlineData(ErrorType.ProviderNotReady, "PROVIDER_NOT_READY")] + public void FeatureProviderException_Should_Resolve_Description(ErrorType errorType, string errorDescription) + { + var ex = new FeatureProviderException(errorType, new Exception()); + ex.ErrorType.Should().Be(errorType); + ex.ErrorTypeDescription.Should().Be(errorDescription); + } + } +} diff --git a/test/OpenFeature.Tests/FeatureProviderTests.cs b/test/OpenFeature.Tests/FeatureProviderTests.cs new file mode 100644 index 00000000..96fc60dd --- /dev/null +++ b/test/OpenFeature.Tests/FeatureProviderTests.cs @@ -0,0 +1,88 @@ +using System.Threading.Tasks; +using AutoFixture; +using FluentAssertions; +using Moq; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; +using Xunit; + +namespace OpenFeature.Tests +{ + public class FeatureProviderTests + { + [Fact] + [Specification("2.1", "The provider interface MUST define a `metadata` member or accessor, containing a `name` field or accessor of type string, which identifies the provider implementation.")] + public void Provider_Must_Have_Metadata() + { + var provider = new TestProvider(); + + provider.GetMetadata().Name.Should().Be(TestProvider.Name); + } + + [Fact] + [Specification("2.2", "The `feature provider` interface MUST define methods to resolve flag values, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns a `flag resolution` structure.")] + [Specification("2.3.1", "The `feature provider` interface MUST define methods for typed flag resolution, including boolean, numeric, string, and structure.")] + [Specification("2.4", "In cases of normal execution, the `provider` MUST populate the `flag resolution` structure's `value` field with the resolved flag value.")] + [Specification("2.5", "In cases of normal execution, the `provider` SHOULD populate the `flag resolution` structure's `variant` field with a string identifier corresponding to the returned flag value.")] + [Specification("2.6", "The `provider` SHOULD populate the `flag resolution` structure's `reason` field with a string indicating the semantic reason for the returned flag value.")] + [Specification("2.7", "In cases of normal execution, the `provider` MUST NOT populate the `flag resolution` structure's `error code` field, or otherwise must populate it with a null or falsy value.")] + [Specification("2.9", "In cases of normal execution, the `provider` MUST NOT populate the `flag resolution` structure's `error code` field, or otherwise must populate it with a null or falsy value.")] + public async Task Provider_Must_Resolve_Flag_Values() + { + var fixture = new Fixture(); + var flagName = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultNumberValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var provider = new NoOpFeatureProvider(); + + var boolResolutionDetails = new ResolutionDetails(flagName, defaultBoolValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + (await provider.ResolveBooleanValue(flagName, defaultBoolValue)).Should().BeEquivalentTo(boolResolutionDetails); + var numberResolutionDetails = new ResolutionDetails(flagName, defaultNumberValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + (await provider.ResolveNumberValue(flagName, defaultNumberValue)).Should().BeEquivalentTo(numberResolutionDetails); + var stringResolutionDetails = new ResolutionDetails(flagName, defaultStringValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + (await provider.ResolveStringValue(flagName, defaultStringValue)).Should().BeEquivalentTo(stringResolutionDetails); + var structureResolutionDetails = new ResolutionDetails(flagName, defaultStructureValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + (await provider.ResolveStructureValue(flagName, defaultStructureValue)).Should().BeEquivalentTo(structureResolutionDetails); + } + + [Fact] + [Specification("2.8", "In cases of abnormal execution, the `provider` MUST indicate an error using the idioms of the implementation language, with an associated error code having possible values `PROVIDER_NOT_READY`, `FLAG_NOT_FOUND`, `PARSE_ERROR`, `TYPE_MISMATCH`, or `GENERAL`.")] + public async Task Provider_Must_ErrorType() + { + var fixture = new Fixture(); + var flagName = fixture.Create(); + var flagName2 = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultNumberValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var providerMock = new Mock(); + + providerMock.Setup(x => x.ResolveBooleanValue(flagName, defaultBoolValue, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ResolutionDetails(flagName, defaultBoolValue, ErrorType.General, NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); + + providerMock.Setup(x => x.ResolveNumberValue(flagName, defaultNumberValue, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ResolutionDetails(flagName, defaultNumberValue, ErrorType.ParseError, NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); + + providerMock.Setup(x => x.ResolveStringValue(flagName, defaultStringValue, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ResolutionDetails(flagName, defaultStringValue, ErrorType.TypeMismatch, NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); + + providerMock.Setup(x => x.ResolveStructureValue(flagName, defaultStructureValue, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ResolutionDetails(flagName, defaultStructureValue, ErrorType.FlagNotFound, NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); + + providerMock.Setup(x => x.ResolveStructureValue(flagName2, defaultStructureValue, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ResolutionDetails(flagName, defaultStructureValue, ErrorType.ProviderNotReady, NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); + + var provider = providerMock.Object; + + (await provider.ResolveBooleanValue(flagName, defaultBoolValue)).ErrorType.Should().Be(ErrorType.General); + (await provider.ResolveNumberValue(flagName, defaultNumberValue)).ErrorType.Should().Be(ErrorType.ParseError); + (await provider.ResolveStringValue(flagName, defaultStringValue)).ErrorType.Should().Be(ErrorType.TypeMismatch); + (await provider.ResolveStructureValue(flagName, defaultStructureValue)).ErrorType.Should().Be(ErrorType.FlagNotFound); + (await provider.ResolveStructureValue(flagName2, defaultStructureValue)).ErrorType.Should().Be(ErrorType.ProviderNotReady); + } + } +} diff --git a/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs b/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs new file mode 100644 index 00000000..7c0aac2a --- /dev/null +++ b/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace OpenFeature.Tests.Internal +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] + public class SpecificationAttribute : Attribute + { + public string Code { get; } + public string Description { get; } + + public SpecificationAttribute(string code, string description) + { + this.Code = code; + this.Description = description; + } + } +} diff --git a/test/OpenFeature.Tests/NoOpFeatureProviderTest.cs b/test/OpenFeature.Tests/NoOpFeatureProviderTest.cs deleted file mode 100644 index fc1cc0be..00000000 --- a/test/OpenFeature.Tests/NoOpFeatureProviderTest.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Threading.Tasks; -using AutoFixture; -using FluentAssertions; -using OpenFeature.Constant; -using Xunit; - -namespace OpenFeature.Tests -{ - public class NoOpFeatureProviderTest - { - [Fact] - public async Task ShouldResolveBoolFlag() - { - var fixture = new Fixture(); - var flagKey = fixture.Create(); - var defaultValue = fixture.Create(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var client = new FeatureClient(new NoOpFeatureProvider(), clientName, clientVersion); - - var result = await client.GetBooleanValue(flagKey, defaultValue); - var resultDetails = await client.GetBooleanDetails(flagKey, defaultValue); - - result.Should().Be(defaultValue); - resultDetails.Reason.Should().Be(NoOpProvider.ReasonNoOp); - resultDetails.Value.Should().Be(defaultValue); - } - - [Fact] - public async Task ShouldResolveNumberFlag() - { - var fixture = new Fixture(); - var flagKey = fixture.Create(); - var defaultValue = fixture.Create(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var client = new FeatureClient(new NoOpFeatureProvider(), clientName, clientVersion); - - var result = await client.GetNumberValue(flagKey, defaultValue); - var resultDetails = await client.GetNumberDetails(flagKey, defaultValue); - - result.Should().Be(defaultValue); - resultDetails.Reason.Should().Be(NoOpProvider.ReasonNoOp); - resultDetails.Value.Should().Be(defaultValue); - } - - [Fact] - public async Task ShouldResolveStringFlag() - { - var fixture = new Fixture(); - var flagKey = fixture.Create(); - var defaultValue = fixture.Create(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var client = new FeatureClient(new NoOpFeatureProvider(), clientName, clientVersion); - - var result = await client.GetStringValue(flagKey, defaultValue); - var resultDetails = await client.GetStringDetails(flagKey, defaultValue); - - result.Should().Be(defaultValue); - resultDetails.Reason.Should().Be(NoOpProvider.ReasonNoOp); - resultDetails.Value.Should().Be(defaultValue); - } - - [Fact] - public async Task ShouldResolveStructureFlag() - { - var fixture = new Fixture(); - var flagKey = fixture.Create(); - var testStructure = fixture.Create(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var client = new FeatureClient(new NoOpFeatureProvider(), clientName, clientVersion); - - var result = await client.GetObjectValue(flagKey, testStructure); - var resultDetails = await client.GetObjectDetails(flagKey, testStructure); - - result.Should().Be(testStructure); - resultDetails.Reason.Should().Be(NoOpProvider.ReasonNoOp); - resultDetails.Value.Should().Be(testStructure); - } - } -} diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index 23a7b8f7..990400c7 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -3,8 +3,13 @@ using System.Threading.Tasks; using AutoFixture; using FluentAssertions; +using Microsoft.Extensions.Logging; using Moq; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Extention; using OpenFeature.Model; +using OpenFeature.Tests.Internal; using Xunit; namespace OpenFeature.Tests @@ -12,7 +17,168 @@ namespace OpenFeature.Tests public class OpenFeatureClientTests { [Fact] - public async Task ShouldResolveBooleanValue() + [Specification("1.2.1", "The client MUST provide a method to add `hooks` which accepts one or more API-conformant `hooks`, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.")] + public void OpenFeatureClient_Should_Allow_Hooks() + { + var fixture = new Fixture(); + var clientName = fixture.Create(); + var hook1 = new Mock().Object; + var hook2 = new Mock().Object; + var hook3 = new Mock().Object; + + var client = OpenFeature.Instance.GetClient(clientName); + + client.AddHooks(new[] { hook1, hook2 }); + + client.GetHooks().Should().ContainInOrder(hook1, hook2); + client.GetHooks().Count.Should().Be(2); + + client.AddHooks(hook3); + client.GetHooks().Should().ContainInOrder(hook1, hook2, hook3); + client.GetHooks().Count.Should().Be(3); + + client.ClearHooks(); + client.GetHooks().Count.Should().Be(0); + } + + [Fact] + [Specification("1.2.2", "The client interface MUST define a `metadata` member or accessor, containing an immutable `name` field or accessor of type string, which corresponds to the `name` value supplied during client creation.")] + public void OpenFeatureClient_Metadata_Should_Have_Name() + { + var fixture = new Fixture(); + var clientName = fixture.Create(); + var clientVersion = fixture.Create(); + var client = OpenFeature.Instance.GetClient(clientName, clientVersion); + + client.GetMetadata().Name.Should().Be(clientName); + client.GetMetadata().Version.Should().Be(clientVersion); + } + + [Fact] + [Specification("1.3.1", "The `client` MUST provide methods for flag evaluation, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns the flag value.")] + [Specification("1.3.2.1", "The `client` MUST provide methods for typed flag evaluation, including boolean, numeric, string, and structure.")] + public async Task OpenFeatureClient_Should_Allow_Flag_Evaluation() + { + var fixture = new Fixture(); + var clientName = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultNumberValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var emptyFlagOptions = new FlagEvaluationOptions(new List(), new Dictionary()); + + OpenFeature.Instance.SetProvider(new NoOpFeatureProvider()); + var client = OpenFeature.Instance.GetClient(clientName, clientVersion); + + (await client.GetBooleanValue(flagName, defaultBoolValue)).Should().Be(defaultBoolValue); + (await client.GetBooleanValue(flagName, defaultBoolValue, new EvaluationContext())).Should().Be(defaultBoolValue); + (await client.GetBooleanValue(flagName, defaultBoolValue, new EvaluationContext(), emptyFlagOptions)).Should().Be(defaultBoolValue); + + (await client.GetNumberValue(flagName, defaultNumberValue)).Should().Be(defaultNumberValue); + (await client.GetNumberValue(flagName, defaultNumberValue, new EvaluationContext())).Should().Be(defaultNumberValue); + (await client.GetNumberValue(flagName, defaultNumberValue, new EvaluationContext(), emptyFlagOptions)).Should().Be(defaultNumberValue); + + (await client.GetStringValue(flagName, defaultStringValue)).Should().Be(defaultStringValue); + (await client.GetStringValue(flagName, defaultStringValue, new EvaluationContext())).Should().Be(defaultStringValue); + (await client.GetStringValue(flagName, defaultStringValue, new EvaluationContext(), emptyFlagOptions)).Should().Be(defaultStringValue); + + (await client.GetObjectValue(flagName, defaultStructureValue)).Should().BeEquivalentTo(defaultStructureValue); + (await client.GetObjectValue(flagName, defaultStructureValue, new EvaluationContext())).Should().BeEquivalentTo(defaultStructureValue); + (await client.GetObjectValue(flagName, defaultStructureValue, new EvaluationContext(), emptyFlagOptions)).Should().BeEquivalentTo(defaultStructureValue); + } + + [Fact] + [Specification("1.4.1", "The `client` MUST provide methods for detailed flag value evaluation with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns an `evaluation details` structure.")] + [Specification("1.4.2", "The `evaluation details` structure's `value` field MUST contain the evaluated flag value.")] + [Specification("1.4.3.1", "The `evaluation details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] + [Specification("1.4.4", "The `evaluation details` structure's `flag key` field MUST contain the `flag key` argument passed to the detailed flag evaluation method.")] + [Specification("1.4.5", "In cases of normal execution, the `evaluation details` structure's `variant` field MUST contain the value of the `variant` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.")] + [Specification("1.4.6", "In cases of normal execution, the `evaluation details` structure's `reason` field MUST contain the value of the `reason` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.")] + [Specification("1.4.11", "The `client` SHOULD provide asynchronous or non-blocking mechanisms for flag evaluation.")] + [Specification("2.9", "The `flag resolution` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] + public async Task OpenFeatureClient_Should_Allow_Details_Flag_Evaluation() + { + var fixture = new Fixture(); + var clientName = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultNumberValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var emptyFlagOptions = new FlagEvaluationOptions(new List(), new Dictionary()); + + OpenFeature.Instance.SetProvider(new NoOpFeatureProvider()); + var client = OpenFeature.Instance.GetClient(clientName, clientVersion); + + var boolFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultBoolValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + (await client.GetBooleanDetails(flagName, defaultBoolValue)).Should().BeEquivalentTo(boolFlagEvaluationDetails); + (await client.GetBooleanDetails(flagName, defaultBoolValue, new EvaluationContext())).Should().BeEquivalentTo(boolFlagEvaluationDetails); + (await client.GetBooleanDetails(flagName, defaultBoolValue, new EvaluationContext(), emptyFlagOptions)).Should().BeEquivalentTo(boolFlagEvaluationDetails); + + var numberFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultNumberValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + (await client.GetNumberDetails(flagName, defaultNumberValue)).Should().BeEquivalentTo(numberFlagEvaluationDetails); + (await client.GetNumberDetails(flagName, defaultNumberValue, new EvaluationContext())).Should().BeEquivalentTo(numberFlagEvaluationDetails); + (await client.GetNumberDetails(flagName, defaultNumberValue, new EvaluationContext(), emptyFlagOptions)).Should().BeEquivalentTo(numberFlagEvaluationDetails); + + var stringFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultStringValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + (await client.GetStringDetails(flagName, defaultStringValue)).Should().BeEquivalentTo(stringFlagEvaluationDetails); + (await client.GetStringDetails(flagName, defaultStringValue, new EvaluationContext())).Should().BeEquivalentTo(stringFlagEvaluationDetails); + (await client.GetStringDetails(flagName, defaultStringValue, new EvaluationContext(), emptyFlagOptions)).Should().BeEquivalentTo(stringFlagEvaluationDetails); + + var structureFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultStructureValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + (await client.GetObjectDetails(flagName, defaultStructureValue)).Should().BeEquivalentTo(structureFlagEvaluationDetails); + (await client.GetObjectDetails(flagName, defaultStructureValue, new EvaluationContext())).Should().BeEquivalentTo(structureFlagEvaluationDetails); + (await client.GetObjectDetails(flagName, defaultStructureValue, new EvaluationContext(), emptyFlagOptions)).Should().BeEquivalentTo(structureFlagEvaluationDetails); + } + + [Fact] + [Specification("1.1.2", "The API MUST provide a function to set the global provider singleton, which accepts an API-conformant provider implementation.")] + [Specification("1.3.3", "The `client` SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied `default value` should be returned.")] + [Specification("1.4.7", "In cases of abnormal execution, the `evaluation details` structure's `error code` field MUST contain a string identifying an error occurred during flag evaluation and the nature of the error.")] + [Specification("1.4.8", "In cases of abnormal execution (network failure, unhandled error, etc) the `reason` field in the `evaluation details` SHOULD indicate an error.")] + [Specification("1.4.9", "Methods, functions, or operations on the client MUST NOT throw exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the `default value` in the event of abnormal execution. Exceptions include functions or methods for the purposes for configuration or setup.")] + [Specification("1.4.10", "In the case of abnormal execution, the client SHOULD log an informative error message.")] + public async Task OpenFeatureClient_Should_Return_DefaultValue_When_Type_Mismatch() + { + var fixture = new Fixture(); + var clientName = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + var mockedFeatureProvider = new Mock(); + var mockedLogger = new Mock>(); + + // This will fail to case a String to TestStructure + mockedFeatureProvider + .Setup(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null)) + .ReturnsAsync(new ResolutionDetails(flagName, "Mismatch")); + mockedFeatureProvider.Setup(x => x.GetMetadata()) + .Returns(new Metadata(fixture.Create())); + + OpenFeature.Instance.SetProvider(mockedFeatureProvider.Object); + var client = OpenFeature.Instance.GetClient(clientName, clientVersion, mockedLogger.Object); + + var evaluationDetails = await client.GetObjectDetails(flagName, defaultValue); + evaluationDetails.ErrorType.Should().Be(ErrorType.TypeMismatch); + + mockedFeatureProvider + .Verify(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null), Times.Once); + + mockedLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((o, t) => string.Equals($"Error while evaluating flag {flagName}", o.ToString(), StringComparison.InvariantCultureIgnoreCase)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Should_Resolve_BooleanValue() { var fixture = new Fixture(); var clientName = fixture.Create(); @@ -24,9 +190,11 @@ public async Task ShouldResolveBooleanValue() featureProviderMock .Setup(x => x.ResolveBooleanValue(flagName, defaultValue, It.IsAny(), null)) .ReturnsAsync(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.Setup(x => x.GetMetadata()) + .Returns(new Metadata(fixture.Create())); - OpenFeature.SetProvider(featureProviderMock.Object); - var client = OpenFeature.GetClient(clientName, clientVersion); + OpenFeature.Instance.SetProvider(featureProviderMock.Object); + var client = OpenFeature.Instance.GetClient(clientName, clientVersion); (await client.GetBooleanValue(flagName, defaultValue)).Should().Be(defaultValue); @@ -34,7 +202,7 @@ public async Task ShouldResolveBooleanValue() } [Fact] - public async Task ShouldResolveStringValue() + public async Task Should_Resolve_StringValue() { var fixture = new Fixture(); var clientName = fixture.Create(); @@ -46,9 +214,11 @@ public async Task ShouldResolveStringValue() featureProviderMock .Setup(x => x.ResolveStringValue(flagName, defaultValue, It.IsAny(), null)) .ReturnsAsync(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.Setup(x => x.GetMetadata()) + .Returns(new Metadata(fixture.Create())); - OpenFeature.SetProvider(featureProviderMock.Object); - var client = OpenFeature.GetClient(clientName, clientVersion); + OpenFeature.Instance.SetProvider(featureProviderMock.Object); + var client = OpenFeature.Instance.GetClient(clientName, clientVersion); (await client.GetStringValue(flagName, defaultValue)).Should().Be(defaultValue); @@ -56,7 +226,7 @@ public async Task ShouldResolveStringValue() } [Fact] - public async Task ShouldResolveNumberValue() + public async Task Should_Resolve_NumberValue() { var fixture = new Fixture(); var clientName = fixture.Create(); @@ -68,9 +238,11 @@ public async Task ShouldResolveNumberValue() featureProviderMock .Setup(x => x.ResolveNumberValue(flagName, defaultValue, It.IsAny(), null)) .ReturnsAsync(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.Setup(x => x.GetMetadata()) + .Returns(new Metadata(fixture.Create())); - OpenFeature.SetProvider(featureProviderMock.Object); - var client = OpenFeature.GetClient(clientName, clientVersion); + OpenFeature.Instance.SetProvider(featureProviderMock.Object); + var client = OpenFeature.Instance.GetClient(clientName, clientVersion); (await client.GetNumberValue(flagName, defaultValue)).Should().Be(defaultValue); @@ -78,7 +250,7 @@ public async Task ShouldResolveNumberValue() } [Fact] - public async Task ShouldResolveStructureValue() + public async Task Should_Resolve_StructureValue() { var fixture = new Fixture(); var clientName = fixture.Create(); @@ -90,9 +262,11 @@ public async Task ShouldResolveStructureValue() featureProviderMock .Setup(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null)) .ReturnsAsync(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.Setup(x => x.GetMetadata()) + .Returns(new Metadata(fixture.Create())); - OpenFeature.SetProvider(featureProviderMock.Object); - var client = OpenFeature.GetClient(clientName, clientVersion); + OpenFeature.Instance.SetProvider(featureProviderMock.Object); + var client = OpenFeature.Instance.GetClient(clientName, clientVersion); (await client.GetObjectValue(flagName, defaultValue)).Should().Be(defaultValue); @@ -100,46 +274,28 @@ public async Task ShouldResolveStructureValue() } [Fact] - public async Task WhenExceptionOccursDuringEvaluationShouldReturnError() + public async Task When_Exception_Occurs_During_Evaluation_Should_Return_Error() { var fixture = new Fixture(); var clientName = fixture.Create(); var clientVersion = fixture.Create(); var flagName = fixture.Create(); var defaultValue = fixture.Create(); - var errorMessage = fixture.Create(); var featureProviderMock = new Mock(); featureProviderMock .Setup(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null)) - .Throws(new Exception(errorMessage)); + .Throws(new FeatureProviderException(ErrorType.ParseError, new Exception("test"))); + featureProviderMock.Setup(x => x.GetMetadata()) + .Returns(new Metadata(fixture.Create())); - OpenFeature.SetProvider(featureProviderMock.Object); - var client = OpenFeature.GetClient(clientName, clientVersion); + OpenFeature.Instance.SetProvider(featureProviderMock.Object); + var client = OpenFeature.Instance.GetClient(clientName, clientVersion); var response = await client.GetObjectDetails(flagName, defaultValue); - response.ErrorCode.Should().Be(errorMessage); - response.Reason.Should().Be(Constant.Reason.Error); + response.ErrorType.Should().Be(ErrorType.ParseError); + response.Reason.Should().Be(Reason.Error); featureProviderMock.Verify(x => x.ResolveStructureValue(flagName, defaultValue, It.IsAny(), null), Times.Once); } - - [Fact] - public void ShouldAddGivenHooks() - { - var fixture = new Fixture(); - var hooks = fixture.Create>(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - - var client = OpenFeature.GetClient(clientName, clientVersion); - - client.AddHooks(hooks); - - client.GetHooks().Should().Contain(hooks); - - client.ClearHooks(); - - client.GetHooks().Should().BeEmpty(); - } } } diff --git a/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs b/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs index 665af097..a8f02c76 100644 --- a/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using AutoFixture; using FluentAssertions; using OpenFeature.Model; +using OpenFeature.Tests.Internal; using Xunit; namespace OpenFeature.Tests @@ -9,7 +11,7 @@ namespace OpenFeature.Tests public class OpenFeatureEvaluationContextTests { [Fact] - public void ShouldMergeTwoContexts() + public void Should_Merge_Two_Contexts() { var context1 = new EvaluationContext(); var context2 = new EvaluationContext(); @@ -25,7 +27,7 @@ public void ShouldMergeTwoContexts() } [Fact] - public void ShouldMergeTwoContextsAndOverrideDuplicatesWithRightHandContext() + public void Should_Merge_TwoContexts_And_Override_Duplicates_With_RightHand_Context() { var context1 = new EvaluationContext(); var context2 = new EvaluationContext(); @@ -39,10 +41,23 @@ public void ShouldMergeTwoContextsAndOverrideDuplicatesWithRightHandContext() Assert.Equal(2, context1.Count); Assert.Equal("overriden_value", context1["key1"]); Assert.Equal("value2", context1["key2"]); + + context1.Remove("key1"); + Assert.Throws(() => context1["key1"]); + } + + [Fact] + public void Should_Be_Able_To_Set_Value_Via_Indexer() + { + var context = new EvaluationContext(); + context["key"] = "value"; + context["key"].Should().Be("value"); } [Fact] - public void ShouldReturnValueTypeViaGet() + [Specification("3.1", "The `evaluation context` structure MUST define an optional `targeting key` field of type string, identifying the subject of the flag evaluation.")] + [Specification("3.2", "The evaluation context MUST support the inclusion of custom fields, having keys of type `string`, and values of type `boolean | string | number | datetime | structure`.")] + public void EvaluationContext_Should_All_Types() { var fixture = new Fixture(); var now = fixture.Create(); diff --git a/test/OpenFeature.Tests/OpenFeatureHookTests.cs b/test/OpenFeature.Tests/OpenFeatureHookTests.cs index 3efa86cd..16c235f0 100644 --- a/test/OpenFeature.Tests/OpenFeatureHookTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureHookTests.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using AutoFixture; using FluentAssertions; using Moq; +using OpenFeature.Constant; using OpenFeature.Model; +using OpenFeature.Tests.Internal; using Xunit; namespace OpenFeature.Tests @@ -11,101 +14,358 @@ namespace OpenFeature.Tests public class OpenFeatureHookTests { [Fact] - public async Task ShouldRunAllHooksSuccessfully() + [Specification("1.5.1", "The `evaluation options` structure's `hooks` field denotes an ordered collection of hooks that the client MUST execute for the respective flag evaluation, in addition to those already configured.")] + [Specification("4.4.2", "Hooks MUST be evaluated in the following order: - before: API, Client, Invocation - after: Invocation, Client, API - error (if applicable): Invocation, Client, API - finally: Invocation, Client, API")] + public async Task Hooks_Should_Be_Called_In_Order() { var fixture = new Fixture(); var clientName = fixture.Create(); var clientVersion = fixture.Create(); var flagName = fixture.Create(); var defaultValue = fixture.Create(); + var clientHook = new Mock(); + var invocationHook = new Mock(); - var featureProviderMock = new Mock(); - featureProviderMock - .Setup(x => x.ResolveBooleanValue(flagName, defaultValue, It.IsAny(), null)) - .ReturnsAsync(new ResolutionDetails(flagName, defaultValue)); + var sequence = new MockSequence(); - var client = new FeatureClient(featureProviderMock.Object, clientName, clientVersion); - client.AddHooks(new TestHook()); + invocationHook.InSequence(sequence).Setup(x => x.Before(It.IsAny>(), + It.IsAny>())) + .ReturnsAsync(new EvaluationContext()); - (await client.GetBooleanValue(flagName, defaultValue)).Should().Be(defaultValue); + clientHook.InSequence(sequence).Setup(x => x.Before(It.IsAny>(), + It.IsAny>())) + .ReturnsAsync(new EvaluationContext()); - featureProviderMock.Verify(x => x.ResolveBooleanValue(flagName, defaultValue, It.IsAny(), null), Times.Once); + invocationHook.InSequence(sequence).Setup(x => x.After(It.IsAny>(), + It.IsAny>(), + It.IsAny>())); + + clientHook.InSequence(sequence).Setup(x => x.After(It.IsAny>(), + It.IsAny>(), + It.IsAny>())); + + invocationHook.InSequence(sequence).Setup(x => x.Finally(It.IsAny>(), + It.IsAny>())); + + clientHook.InSequence(sequence).Setup(x => x.Finally(It.IsAny>(), + It.IsAny>())); + + OpenFeature.Instance.SetProvider(new NoOpFeatureProvider()); + var client = OpenFeature.Instance.GetClient(clientName, clientVersion); + client.AddHooks(clientHook.Object); + + await client.GetBooleanValue(flagName, defaultValue, new EvaluationContext(), + new FlagEvaluationOptions(invocationHook.Object, new Dictionary())); + + invocationHook.Verify(x => x.Before( + It.IsAny>(), It.IsAny>()), Times.Once); + + clientHook.Verify(x => x.Before( + It.IsAny>(), It.IsAny>()), Times.Once); + + invocationHook.Verify(x => x.After( + It.IsAny>(), It.IsAny>(), It.IsAny>()), Times.Once); + + clientHook.Verify(x => x.After( + It.IsAny>(), It.IsAny>(), It.IsAny>()), Times.Once); + + invocationHook.Verify(x => x.Finally( + It.IsAny>(), It.IsAny>()), Times.Once); + + clientHook.Verify(x => x.Finally( + It.IsAny>(), It.IsAny>()), Times.Once); } [Fact] - public async Task ShouldCatchExceptionFromNoImplementedHookMethod() + [Specification("4.1.1", "Hook context MUST provide: the `flag key`, `flag value type`, `evaluation context`, and the `default value`.")] + public void Hook_Context_Should_Not_Allow_Nulls() { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + Assert.Throws(() => + new HookContext(null, new TestStructure(), FlagValueType.Object, new ClientMetadata(null, null), + new Metadata(null), new EvaluationContext())); + + Assert.Throws(() => + new HookContext("test", new TestStructure(), FlagValueType.Object, null, + new Metadata(null), new EvaluationContext())); + + Assert.Throws(() => + new HookContext("test", new TestStructure(), FlagValueType.Object, new ClientMetadata(null, null), + null, new EvaluationContext())); + + Assert.Throws(() => + new HookContext("test", new TestStructure(), FlagValueType.Object, new ClientMetadata(null, null), + new Metadata(null), null)); + } + + [Fact] + [Specification("4.1.2", "The `hook context` SHOULD provide: access to the `client metadata` and the `provider metadata` fields.")] + [Specification("4.1.3", "The `flag key`, `flag type`, and `default value` properties MUST be immutable. If the language does not support immutability, the hook MUST NOT modify these properties.")] + public void Hook_Context_Should_Have_Properties_And_Be_Immutable() + { + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var testStructure = new TestStructure(); + var context = new HookContext("test", testStructure, FlagValueType.Object, clientMetadata, + providerMetadata, new EvaluationContext()); - var featureProviderMock = new Mock(); - featureProviderMock - .Setup(x => x.ResolveBooleanValue(flagName, defaultValue, It.IsAny(), null)) - .ReturnsAsync(new ResolutionDetails(flagName, defaultValue)); + context.ClientMetadata.Should().BeSameAs(clientMetadata); + context.ProviderMetadata.Should().BeSameAs(providerMetadata); + context.FlagKey.Should().Be("test"); + context.DefaultValue.Should().BeSameAs(testStructure); + context.FlagValueType.Should().Be(FlagValueType.Object); + } - var hookMock = new Mock(); - hookMock - .Setup(x => x.Before(It.IsAny>(), null)) - .ThrowsAsync(new NotImplementedException()); + [Fact] + [Specification("4.1.4", "The evaluation context MUST be mutable only within the `before` hook.")] + [Specification("4.3.3", "Any `evaluation context` returned from a `before` hook MUST be passed to subsequent `before` hooks (via `HookContext`).")] + [Specification("4.3.4", "When `before` hooks have finished executing, any resulting `evaluation context` MUST be merged with the invocation `evaluation context` with the invocation `evaluation context` taking precedence in the case of any conflicts.")] + public async Task Evaluation_Context_Must_Be_Mutable_Before_Hook() + { + var evaluationContext = new EvaluationContext { ["test"] = "test" }; + var hook1 = new Mock(); + var hook2 = new Mock(); + var hookContext = new HookContext("test", false, + FlagValueType.Boolean, new ClientMetadata("test", "1.0.0"), new Metadata(NoOpProvider.NoOpProviderName), + evaluationContext); - hookMock - .Setup(x => x.After(It.IsAny>(), It.IsAny>(), null)) - .ThrowsAsync(new NotImplementedException()); + hook1.Setup(x => x.Before(It.IsAny>(), It.IsAny>())) + .ReturnsAsync(evaluationContext); - hookMock - .Setup(x => x.Finally(It.IsAny>(), null)) - .ThrowsAsync(new NotImplementedException()); + hook2.Setup(x => + x.Before(hookContext, It.IsAny>())) + .ReturnsAsync(evaluationContext); - var client = new FeatureClient(featureProviderMock.Object, clientName, clientVersion); - client.AddHooks(hookMock.Object); + var client = OpenFeature.Instance.GetClient("test", "1.0.0"); + await client.GetBooleanValue("test", false, new EvaluationContext(), + new FlagEvaluationOptions(new[] { hook1.Object, hook2.Object }, new Dictionary())); - (await client.GetBooleanValue(flagName, defaultValue)).Should().Be(defaultValue); + hook1.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); + hook2.Verify(x => x.Before(It.Is>(a => a.EvaluationContext.Get("test") == "test"), It.IsAny>()), Times.Once); + } - featureProviderMock.Verify(x => x.ResolveBooleanValue(flagName, defaultValue, It.IsAny(), null), Times.Once); - hookMock.Verify(x => x.Before(It.IsAny>(), null), Times.Once); - hookMock.Verify(x => x.After(It.IsAny>(), It.IsAny>(), null), Times.Once); - hookMock.Verify(x => x.Finally(It.IsAny>(), null), Times.Once); + [Fact] + [Specification("4.2.1", "`hook hints` MUST be a structure supports definition of arbitrary properties, with keys of type `string`, and values of type `boolean | string | number | datetime | structure`..")] + [Specification("4.2.2.1", "Condition: `Hook hints` MUST be immutable.")] + [Specification("4.2.2.2", "Condition: The client `metadata` field in the `hook context` MUST be immutable.")] + [Specification("4.2.2.3", "Condition: The provider `metadata` field in the `hook context` MUST be immutable")] + [Specification("4.3.1", "Hooks MUST specify at least one stage.")] + public async Task Hook_Should_Return_No_Errors() + { + var hook = new TestHookNoOverride(); + var hookHints = new Dictionary + { + ["string"] = "test", + ["number"] = 1, + ["boolean"] = true, + ["datetime"] = DateTime.Now, + ["structure"] = new TestStructure() + }; + var hookContext = new HookContext("test", false, FlagValueType.Boolean, + new ClientMetadata(null, null), new Metadata(null), new EvaluationContext()); + + await hook.Before(hookContext, hookHints); + await hook.After(hookContext, new FlagEvaluationDetails("test", false, ErrorType.None, "testing", "testing"), hookHints); + await hook.Finally(hookContext, hookHints); + await hook.Error(hookContext, new Exception(), hookHints); + + hookContext.ClientMetadata.Name.Should().BeNull(); + hookContext.ClientMetadata.Version.Should().BeNull(); + hookContext.ProviderMetadata.Name.Should().BeNull(); } [Fact] - public async Task ShouldCatchExceptionFromNoImplementedHookMethodWhenProviderThrowException() + [Specification("4.3.5", "The `after` stage MUST run after flag resolution occurs. It accepts a `hook context` (required), `flag evaluation details` (required) and `hook hints` (optional). It has no return value.")] + [Specification("4.3.6", "The `error` hook MUST run when errors are encountered in the `before` stage, the `after` stage or during flag resolution. It accepts `hook context` (required), `exception` representing what went wrong (required), and `hook hints` (optional). It has no return value.")] + [Specification("4.3.7", "The `finally` hook MUST run after the `before`, `after`, and `error` stages. It accepts a `hook context` (required) and `hook hints` (optional). There is no return value.")] + [Specification("4.5.1", "`Flag evaluation options` MAY contain `hook hints`, a map of data to be provided to hook invocations.")] + [Specification("4.5.2", "`hook hints` MUST be passed to each hook.")] + [Specification("4.5.3", "The hook MUST NOT alter the `hook hints` structure.")] + public async Task Hook_Should_Execute_In_Correct_Order() { - var fixture = new Fixture(); - var clientName = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + var featureProvider = new Mock(); + var hook = new Mock(); + + var sequence = new MockSequence(); + + featureProvider.Setup(x => x.GetMetadata()) + .Returns(new Metadata(null)); + + hook.InSequence(sequence).Setup(x => + x.Before(It.IsAny>(), It.IsAny>())) + .ReturnsAsync(new EvaluationContext()); + + featureProvider.InSequence(sequence) + .Setup(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny(), null)) + .ReturnsAsync(new ResolutionDetails("test", false)); + + hook.InSequence(sequence).Setup(x => x.After(It.IsAny>(), + It.IsAny>(), It.IsAny>())); + + hook.InSequence(sequence).Setup(x => + x.Finally(It.IsAny>(), It.IsAny>())); + + OpenFeature.Instance.SetProvider(featureProvider.Object); + var client = OpenFeature.Instance.GetClient(); + client.AddHooks(hook.Object); + + await client.GetBooleanValue("test", false); - var featureProviderMock = new Mock(); - featureProviderMock - .Setup(x => x.ResolveBooleanValue(flagName, defaultValue, It.IsAny(), null)) + hook.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); + hook.Verify(x => x.After(It.IsAny>(), It.IsAny>(), It.IsAny>()), Times.Once); + hook.Verify(x => x.Finally(It.IsAny>(), It.IsAny>()), Times.Once); + featureProvider.Verify(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Once); + + } + + [Fact] + [Specification("4.4.1", "The API, Client and invocation MUST have a method for registering hooks which accepts `flag evaluation options`")] + public async Task Register_Hooks_Should_Be_Available_At_All_Levels() + { + var hook1 = new Mock(); + var hook2 = new Mock(); + var hook3 = new Mock(); + + OpenFeature.Instance.AddHooks(hook1.Object); + var client = OpenFeature.Instance.GetClient(); + client.AddHooks(hook2.Object); + await client.GetBooleanValue("test", false, null, + new FlagEvaluationOptions(hook3.Object, new Dictionary())); + + client.ClearHooks(); + } + + [Fact] + [Specification("4.4.3", "If a `finally` hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining `finally` hooks.")] + public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() + { + var featureProvider = new Mock(); + var hook1 = new Mock(); + var hook2 = new Mock(); + + var sequence = new MockSequence(); + + featureProvider.Setup(x => x.GetMetadata()) + .Returns(new Metadata(null)); + + hook1.InSequence(sequence).Setup(x => + x.Before(It.IsAny>(), It.IsAny>())) + .ReturnsAsync(new EvaluationContext()); + + hook2.InSequence(sequence).Setup(x => + x.Before(It.IsAny>(), It.IsAny>())) + .ReturnsAsync(new EvaluationContext()); + + featureProvider.InSequence(sequence) + .Setup(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny(), + null)) + .ReturnsAsync(new ResolutionDetails("test", false)); + + hook1.InSequence(sequence).Setup(x => x.After(It.IsAny>(), + It.IsAny>(), It.IsAny>())); + + hook2.InSequence(sequence).Setup(x => x.After(It.IsAny>(), + It.IsAny>(), It.IsAny>())); + + hook1.Setup(x => + x.Finally(It.IsAny>(), It.IsAny>())) + .Throws(new Exception()); + + hook2.InSequence(sequence).Setup(x => + x.Finally(It.IsAny>(), It.IsAny>())); + + OpenFeature.Instance.SetProvider(featureProvider.Object); + var client = OpenFeature.Instance.GetClient(); + client.AddHooks(new[] { hook1.Object, hook2.Object }); + + await client.GetBooleanValue("test", false); + + hook1.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); + hook1.Verify(x => x.After(It.IsAny>(), It.IsAny>(), It.IsAny>()), Times.Once); + hook1.Verify(x => x.Finally(It.IsAny>(), It.IsAny>()), Times.Once); + hook2.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); + hook2.Verify(x => x.After(It.IsAny>(), It.IsAny>(), It.IsAny>()), Times.Once); + hook2.Verify(x => x.Finally(It.IsAny>(), It.IsAny>()), Times.Once); + featureProvider.Verify(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Once); + } + + [Fact] + [Specification("4.4.4", "If a `finally` hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining `finally` hooks.")] + public async Task Error_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() + { + var featureProvider = new Mock(); + var hook1 = new Mock(); + var hook2 = new Mock(); + + var sequence = new MockSequence(); + + featureProvider.Setup(x => x.GetMetadata()) + .Returns(new Metadata(null)); + + hook1.InSequence(sequence).Setup(x => + x.Before(It.IsAny>(), It.IsAny>())) + .ReturnsAsync(new EvaluationContext()); + + hook2.InSequence(sequence).Setup(x => + x.Before(It.IsAny>(), It.IsAny>())) + .ReturnsAsync(new EvaluationContext()); + + featureProvider.InSequence(sequence) + .Setup(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny(), + null)) + .Throws(new Exception()); + + hook1.InSequence(sequence).Setup(x => + x.Error(It.IsAny>(), It.IsAny(), null)) .ThrowsAsync(new Exception()); - var hookMock = new Mock(); - hookMock - .Setup(x => x.Before(It.IsAny>(), null)) - .ThrowsAsync(new NotImplementedException()); + hook2.InSequence(sequence).Setup(x => + x.Error(It.IsAny>(), It.IsAny(), null)); + + OpenFeature.Instance.SetProvider(featureProvider.Object); + var client = OpenFeature.Instance.GetClient(); + client.AddHooks(new[] { hook1.Object, hook2.Object }); + + await client.GetBooleanValue("test", false); + + hook1.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); + hook1.Verify(x => x.Error(It.IsAny>(), It.IsAny(), null), Times.Once); + hook2.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); + hook2.Verify(x => x.Error(It.IsAny>(), It.IsAny(), null), Times.Once); + } + + [Fact] + [Specification("4.4.6", "If an error occurs during the evaluation of `before` or `after` hooks, any remaining hooks in the `before` or `after` stages MUST NOT be invoked.")] + public async Task Error_Occurs_During_Before_After_Evaluation_Should_Not_Invoke_Any_Remaining_Hooks() + { + var featureProvider = new Mock(); + var hook1 = new Mock(); + var hook2 = new Mock(); + + var sequence = new MockSequence(); + + featureProvider.Setup(x => x.GetMetadata()) + .Returns(new Metadata(null)); + + hook1.InSequence(sequence).Setup(x => + x.Before(It.IsAny>(), It.IsAny>())) + .ThrowsAsync(new Exception()); - hookMock - .Setup(x => x.Error(It.IsAny>(), It.IsAny(), null)) - .ThrowsAsync(new NotImplementedException()); + hook1.InSequence(sequence).Setup(x => + x.Error(It.IsAny>(), It.IsAny(), null)); - hookMock - .Setup(x => x.Finally(It.IsAny>(), null)) - .ThrowsAsync(new NotImplementedException()); + hook2.InSequence(sequence).Setup(x => + x.Error(It.IsAny>(), It.IsAny(), null)); - var client = new FeatureClient(featureProviderMock.Object, clientName, clientVersion); - client.AddHooks(hookMock.Object); + OpenFeature.Instance.SetProvider(featureProvider.Object); + var client = OpenFeature.Instance.GetClient(); + client.AddHooks(new[] { hook1.Object, hook2.Object }); - (await client.GetBooleanValue(flagName, defaultValue)).Should().Be(defaultValue); + await client.GetBooleanValue("test", false); - featureProviderMock.Verify(x => x.ResolveBooleanValue(flagName, defaultValue, It.IsAny(), null), Times.Once); - hookMock.Verify(x => x.Before(It.IsAny>(), null), Times.Once); - hookMock.Verify(x => x.Error(It.IsAny>(), It.IsAny(), null), Times.Once); - hookMock.Verify(x => x.Finally(It.IsAny>(), null), Times.Once); + hook1.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); + hook2.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Never); + hook1.Verify(x => x.Error(It.IsAny>(), It.IsAny(), null), Times.Once); + hook2.Verify(x => x.Error(It.IsAny>(), It.IsAny(), null), Times.Once); } } } diff --git a/test/OpenFeature.Tests/OpenFeatureTests.cs b/test/OpenFeature.Tests/OpenFeatureTests.cs index f5a8611e..38cc14c6 100644 --- a/test/OpenFeature.Tests/OpenFeatureTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureTests.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; +using System.Linq; using AutoFixture; using FluentAssertions; using Microsoft.Extensions.Logging; using Moq; using OpenFeature.Constant; using OpenFeature.Model; +using OpenFeature.Tests.Internal; using Xunit; namespace OpenFeature.Tests @@ -13,40 +15,80 @@ namespace OpenFeature.Tests public class OpenFeatureTests { [Fact] - public void ShouldSetGivenProvider() + [Specification("1.1.1", "The API, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the API are present at runtime.")] + public void OpenFeature_Should_Be_Singleton() { - var provider = new TestProvider(); + var openFeature = OpenFeature.Instance; + var openFeature2 = OpenFeature.Instance; - OpenFeature.SetProvider(provider); - - OpenFeature.GetProvider().GetMetadata().Name.Should().Be(provider.Name); - OpenFeature.GetProviderMetadata().Name.Should().Be(provider.Name); + openFeature.Should().BeSameAs(openFeature2); } [Fact] - public void ShouldSetGivenContext() + [Specification("1.1.3", "The API MUST provide a function to add hooks which accepts one or more API-conformant hooks, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.")] + public void OpenFeature_Should_Add_Hooks() { - var fixture = new Fixture(); - var context = fixture.Create(); + var openFeature = OpenFeature.Instance; + var hook1 = new Mock().Object; + var hook2 = new Mock().Object; + var hook3 = new Mock().Object; + var hook4 = new Mock().Object; + + openFeature.ClearHooks(); + + openFeature.AddHooks(hook1); - OpenFeature.SetContext(context); + openFeature.GetHooks().Should().Contain(hook1); + openFeature.GetHooks().Count.Should().Be(1); - OpenFeature.GetContext().Should().Equal(context); + openFeature.AddHooks(hook2); + openFeature.GetHooks().Should().ContainInOrder(hook1, hook2); + openFeature.GetHooks().Count.Should().Be(2); + + openFeature.AddHooks(new[] { hook3, hook4 }); + openFeature.GetHooks().Should().ContainInOrder(hook1, hook2, hook3, hook4); + openFeature.GetHooks().Count.Should().Be(4); + + openFeature.ClearHooks(); + openFeature.GetHooks().Count.Should().Be(0); } [Fact] - public void ShouldAddGivenHooks() + [Specification("1.1.4", "The API MUST provide a function for retrieving the metadata field of the configured `provider`.")] + public void OpenFeature_Should_Get_Metadata() { - var fixture = new Fixture(); - var hooks = fixture.Create>(); + OpenFeature.Instance.SetProvider(new NoOpFeatureProvider()); + var openFeature = OpenFeature.Instance; + var metadata = openFeature.GetProviderMetadata(); - OpenFeature.AddHooks(hooks); + metadata.Should().NotBeNull(); + metadata.Name.Should().Be(NoOpProvider.NoOpProviderName); + } - OpenFeature.GetHooks().Should().Contain(hooks); + [Theory] + [InlineData("client1", "version1")] + [InlineData("client2", null)] + [InlineData(null, null)] + [Specification("1.1.5", "The `API` MUST provide a function for creating a `client` which accepts the following options: - name (optional): A logical string identifier for the client.")] + public void OpenFeature_Should_Create_Client(string name = null, string version = null) + { + var openFeature = OpenFeature.Instance; + var client = openFeature.GetClient(name, version); + + client.Should().NotBeNull(); + client.GetMetadata().Name.Should().Be(name); + client.GetMetadata().Version.Should().Be(version); + } + + [Fact] + public void Should_Set_Given_Context() + { + var fixture = new Fixture(); + var context = fixture.Create(); - OpenFeature.ClearHooks(); + OpenFeature.Instance.SetContext(context); - OpenFeature.GetHooks().Should().BeEmpty(); + OpenFeature.Instance.GetContext().Should().BeSameAs(context); } } } diff --git a/test/OpenFeature.Tests/TestImplementations.cs b/test/OpenFeature.Tests/TestImplementations.cs index d0dc78f9..1a9a5d5a 100644 --- a/test/OpenFeature.Tests/TestImplementations.cs +++ b/test/OpenFeature.Tests/TestImplementations.cs @@ -11,6 +11,8 @@ public class TestStructure public string Value { get; set; } } + public class TestHookNoOverride : Hook { } + public class TestHook : Hook { public override Task Before(HookContext context, IReadOnlyDictionary hints = null) @@ -37,7 +39,7 @@ public override Task Finally(HookContext context, IReadOnlyDictionary "test-provider"; + public static string Name => "test-provider"; public Metadata GetMetadata() {