From 2976c3db99d0b593754ccfa388a44d869917344b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viviana=20Due=C3=B1as=20Chavez?= Date: Tue, 9 Jun 2026 22:23:30 -0700 Subject: [PATCH 1/2] Add async DataAnnotation options validation bridge and tests --- ...xtensions.Options.DataAnnotations.Async.cs | 13 ++ ....Extensions.Options.DataAnnotations.csproj | 4 + .../DataAnnotationValidateOptions.Async.cs | 156 ++++++++++++++++++ .../src/DataAnnotationValidateOptions.cs | 11 +- ...OptionsBuilderDataAnnotationsExtensions.cs | 13 +- .../OptionsBuilderTest.cs | 130 +++++++++++++++ 6 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.Async.cs create mode 100644 src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.Async.cs diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.Async.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.Async.cs new file mode 100644 index 00000000000000..6a02e2f3a23b1d --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.Async.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ------------------------------------------------------------------------------ +// Changes to this file must follow the https://aka.ms/api-review process. +// ------------------------------------------------------------------------------ + +namespace Microsoft.Extensions.Options +{ + public partial class DataAnnotationValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions + { + public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } + } +} diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.csproj b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.csproj index c5420188cbc6fe..ca2147b5729a0c 100644 --- a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.csproj +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.csproj @@ -7,6 +7,10 @@ + + + + diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.Async.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.Async.cs new file mode 100644 index 00000000000000..743e2234e37ef2 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.Async.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET11_0_OR_GREATER + +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Options +{ + /// + /// Async validation implementation for . + /// + public partial class DataAnnotationValidateOptions + { + /// + /// Asynchronously validates a specific named options instance (or all when is null). + /// + /// The name of the options instance being validated. + /// The options instance. + /// The token to monitor for cancellation requests. + /// The result. + /// + /// The is propagated from + /// Host.StartAsync(CancellationToken). By default, no startup timeout + /// is applied. Configure or pass + /// a with a timeout to Host.StartAsync + /// to bound I/O-bound async validators: + /// + /// builder.Services.Configure<HostOptions>(opts => + /// opts.StartupTimeout = TimeSpan.FromSeconds(30)); + /// + /// + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", + Justification = "Suppressing the warnings on this method since the constructor of the type is annotated as RequiresUnreferencedCode.")] + public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) + { + // Null name is used to configure all named options. + if (Name is not null && Name != name) + { + // Ignored if not validating this instance. + return ValidateOptionsResult.Skip; + } + + // Ensure options are provided to validate against + ArgumentNullException.ThrowIfNull(options); + + var validationResults = new List(); + HashSet? visited = null; + List? errors = null; + + (bool success, errors) = await TryValidateOptionsAsync(options, options.GetType().Name, validationResults, errors, visited, cancellationToken).ConfigureAwait(false); + + if (success) + { + return ValidateOptionsResult.Success; + } + + Debug.Assert(errors is not null && errors.Count > 0); + + return ValidateOptionsResult.Fail(errors); + } + + /// + /// Async counterpart of . Uses a tuple return + /// (bool success, List<string>? errors) instead of ref parameters + /// because async methods cannot have ref/out parameters. + /// + /// visited does not need to be returned — it is created before each + /// recursive call, so the callee always receives a non-null, shared instance. + /// errors must be returned because it may be lazily created + /// (errors ??= new List<string>()) inside the callee. + /// + [RequiresUnreferencedCode("This method on this type will walk through all properties of the passed in options object, and its type cannot be " + + "statically analyzed so its members may be trimmed.")] + private static async Task<(bool success, List? errors)> TryValidateOptionsAsync( + object options, + string qualifiedName, + List results, + List? errors, + HashSet? visited, + CancellationToken cancellationToken) + { + Debug.Assert(options is not null); + + if (visited is not null && visited.Contains(options)) + { + return (true, errors); + } + + results.Clear(); + + bool res = await Validator.TryValidateObjectAsync(options, new ValidationContext(options), results, validateAllProperties: true, cancellationToken).ConfigureAwait(false); + if (!res) + { + errors ??= new List(); + + foreach (ValidationResult result in results) + { + errors.Add($"DataAnnotation validation failed for '{qualifiedName}' members: '{string.Join(",", result.MemberNames)}' with the error: '{result.ErrorMessage}'."); + } + } + + foreach (PropertyInfo propertyInfo in options.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + // Indexers are properties which take parameters. Ignore them. + if (propertyInfo.GetMethod is null || propertyInfo.GetMethod.GetParameters().Length > 0) + { + continue; + } + + object? value = propertyInfo.GetValue(options); + + if (value is null) + { + continue; + } + + if (propertyInfo.GetCustomAttribute() is not null) + { + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + visited.Add(options); + + bool innerRes; + (innerRes, errors) = await TryValidateOptionsAsync(value, $"{qualifiedName}.{propertyInfo.Name}", results, errors, visited, cancellationToken).ConfigureAwait(false); + res = innerRes && res; + } + else if (value is IEnumerable enumerable && + propertyInfo.GetCustomAttribute() is not null) + { + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + visited.Add(options); + + int index = 0; + foreach (object item in enumerable) + { + bool innerRes; + (innerRes, errors) = await TryValidateOptionsAsync(item, $"{qualifiedName}.{propertyInfo.Name}[{index++}]", results, errors, visited, cancellationToken).ConfigureAwait(false); + res = innerRes && res; + } + } + } + + return (res, errors); + } + } +} + +#endif diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.cs index 2b85690478eff2..c6cc204e3e9a60 100644 --- a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.cs +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.cs @@ -16,8 +16,15 @@ namespace Microsoft.Extensions.Options /// Implementation of that uses DataAnnotation's for validation. /// /// The instance being validated. - public class DataAnnotationValidateOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions> - : IValidateOptions where TOptions : class +#if NET11_0_OR_GREATER + public partial class DataAnnotationValidateOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions> + : IValidateOptions, IAsyncValidateOptions + where TOptions : class +#else + public partial class DataAnnotationValidateOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions> + : IValidateOptions + where TOptions : class +#endif { /// /// Initializes a new instance of . diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs index 55f8b33a70311d..fe5c59be5e93c2 100644 --- a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs @@ -12,8 +12,13 @@ namespace Microsoft.Extensions.DependencyInjection public static class OptionsBuilderDataAnnotationsExtensions { /// - /// Register this options instance for validation of its DataAnnotations. + /// Registers this options instance for validation of its DataAnnotations. /// + /// + /// Synchronous validation runs on every options access. When targeting .NET 11 or later, + /// asynchronous validation (including AsyncValidationAttribute-derived attributes) + /// runs once at startup when ValidateOnStart() is also called. + /// /// The options type to be configured. /// The options builder to add the services to. /// The so that additional calls can be chained. @@ -21,7 +26,11 @@ public static class OptionsBuilderDataAnnotationsExtensions " members may be trimmed.")] public static OptionsBuilder ValidateDataAnnotations<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>(this OptionsBuilder optionsBuilder) where TOptions : class { - optionsBuilder.Services.AddSingleton>(new DataAnnotationValidateOptions(optionsBuilder.Name)); + var instance = new DataAnnotationValidateOptions(optionsBuilder.Name); + optionsBuilder.Services.AddSingleton>(instance); +#if NET11_0_OR_GREATER + optionsBuilder.Services.AddSingleton>(instance); +#endif return optionsBuilder; } } diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs index 15be6497c82182..1d740ddf9ed7d4 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs @@ -5,6 +5,8 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -901,5 +903,133 @@ public void ValidateWithValidatorType_AreScopedToNamedOptions() var unvalidated = monitor.Get("unvalidated"); Assert.NotNull(unvalidated); } + +#if NET11_0_OR_GREATER + [Fact] + public async Task DataAnnotationValidateOptions_ValidateAsync_ReportsAnnotationFailures() + { + var options = new AnnotatedOptions + { + StringLength = "111111", + IntRange = 10, + Custom = "nowhere", + Dep1 = "Not dep2" + }; + + var validator = new DataAnnotationValidateOptions(Options.DefaultName); + + ValidateOptionsResult syncResult = validator.Validate(Options.DefaultName, options); + Assert.True(syncResult.Failed); + + ValidateOptionsResult asyncResult = await validator.ValidateAsync(Options.DefaultName, options); + Assert.True(asyncResult.Failed); + Assert.Equal(syncResult.Failures, asyncResult.Failures); + Assert.Equal(5, asyncResult.Failures.Count()); + Assert.Contains("DataAnnotation validation failed", asyncResult.Failures.First()); + } + + [Fact] + public async Task DataAnnotationValidateOptions_ValidateAsync_SucceedsWhenValid() + { + var options = new AnnotatedOptions + { + Required = "value", + StringLength = "1234", + IntRange = 0, + Custom = "USA", + Dep1 = "dep", + Dep2 = "dep" + }; + + var validator = new DataAnnotationValidateOptions(Options.DefaultName); + ValidateOptionsResult result = await validator.ValidateAsync(Options.DefaultName, options); + Assert.True(result.Succeeded); + } + + [Fact] + public async Task DataAnnotationValidateOptions_ValidateAsync_SkipsWhenNameMismatch() + { + var validator = new DataAnnotationValidateOptions("expected"); + ValidateOptionsResult result = await validator.ValidateAsync("other", new AnnotatedOptions()); + Assert.True(result.Skipped); + } + + [Theory] + [InlineData("named1")] + [InlineData(null)] + public async Task DataAnnotationValidateOptions_ValidateAsync_NameMatching(string? registeredName) + { + var options = new AnnotatedOptions + { + StringLength = "111111", + IntRange = 10, + Custom = "nowhere", + Dep1 = "Not dep2" + }; + + var validator = new DataAnnotationValidateOptions(registeredName); + + ValidateOptionsResult defaultResult = await validator.ValidateAsync(Options.DefaultName, options); + + if (registeredName is null) + { + Assert.True(defaultResult.Failed); + } + else + { + Assert.True(defaultResult.Skipped); + } + } + + [Fact] + public async Task DataAnnotationValidateOptions_ValidateAsync_ThrowsOnNullOptions() + { + var validator = new DataAnnotationValidateOptions(Options.DefaultName); + await Assert.ThrowsAsync( + () => validator.ValidateAsync(Options.DefaultName, null!, CancellationToken.None)); + } + + [Fact] + public async Task ValidateDataAnnotations_ValidateOnStart_AsyncStartupValidator_Success() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => + { + o.Required = "required"; + o.StringLength = "1111"; + o.IntRange = 0; + o.Custom = "USA"; + o.Dep1 = "dep"; + o.Dep2 = "dep"; + }) + .ValidateDataAnnotations() + .ValidateOnStart(); + + using ServiceProvider sp = services.BuildServiceProvider(); + var asyncValidator = sp.GetRequiredService(); + await asyncValidator.ValidateAsync(CancellationToken.None); + } + + [Fact] + public async Task DataAnnotationValidateOptions_ValidateAsync_AcceptsCancellationToken() + { + var options = new AnnotatedOptions + { + Required = "value", + StringLength = "1234", + IntRange = 0, + Custom = "USA", + Dep1 = "dep", + Dep2 = "dep" + }; + + var validator = new DataAnnotationValidateOptions(Options.DefaultName); + using var cts = new CancellationTokenSource(); + + ValidateOptionsResult result = await validator.ValidateAsync(Options.DefaultName, options, cts.Token); + Assert.True(result.Succeeded); + } +#endif // NET11_0_OR_GREATER } } From 5aaa12024075ae296c16ceddd36e9efd2703fad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viviana=20Due=C3=B1as=20Chavez?= Date: Thu, 11 Jun 2026 16:36:49 -0700 Subject: [PATCH 2/2] Address PR feedback: expand async validation docs, add async-only and recursion tests --- .../DataAnnotationValidateOptions.Async.cs | 2 +- ...OptionsBuilderDataAnnotationsExtensions.cs | 11 +- .../OptionsBuilderTest.cs | 109 +++++++++++++++++- 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.Async.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.Async.cs index 743e2234e37ef2..08d02537616203 100644 --- a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.Async.cs +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.Async.cs @@ -30,7 +30,7 @@ public partial class DataAnnotationValidateOptions /// /// The is propagated from /// Host.StartAsync(CancellationToken). By default, no startup timeout - /// is applied. Configure or pass + /// is applied. Configure HostOptions.StartupTimeout or pass /// a with a timeout to Host.StartAsync /// to bound I/O-bound async validators: /// diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs index fe5c59be5e93c2..748af2943b1f80 100644 --- a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs @@ -15,9 +15,16 @@ public static class OptionsBuilderDataAnnotationsExtensions /// Registers this options instance for validation of its DataAnnotations. /// /// - /// Synchronous validation runs on every options access. When targeting .NET 11 or later, + /// Synchronous validation runs when the options instance is created or accessed. When targeting .NET 11 or later, /// asynchronous validation (including AsyncValidationAttribute-derived attributes) - /// runs once at startup when ValidateOnStart() is also called. + /// runs once at startup, and only when ValidateOnStart() is also called. + /// If ValidateOnStart() is not called, attributes deriving from + /// AsyncValidationAttribute are never evaluated asynchronously: runtime options access triggers only + /// synchronous validation, which invokes the attribute's synchronous fallback instead. + /// When using AsyncValidationAttribute-derived attributes, ensure the synchronous + /// IsValid fallback does not throw: synchronous validation still runs on every + /// options access, so a throwing fallback surfaces as an exception on each access (for example + /// when resolving IOptions{TOptions}.Value), even if startup validation succeeded. /// /// The options type to be configured. /// The options builder to add the services to. diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs index 1d740ddf9ed7d4..3629ad9de262ad 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs @@ -904,6 +904,45 @@ public void ValidateWithValidatorType_AreScopedToNamedOptions() Assert.NotNull(unvalidated); } +#if NET11_0_OR_GREATER + private sealed class AsyncOnlyFailAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) => ValidationResult.Success; + + protected override async Task IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken) + { + await Task.Yield(); + if (value is null) + { + return ValidationResult.Success; + } + + return new ValidationResult("Async-only failure", new[] { validationContext.MemberName! }); + } + } + + private class AsyncAnnotatedOptions + { + [AsyncOnlyFail] + public string? Value { get; set; } + } + + private class ParentWithNestedAsync + { + [Required] + public string? Name { get; set; } + + [ValidateObjectMembers] + public AsyncAnnotatedOptions? Child { get; set; } + } + + private class ParentWithEnumeratedAsync + { + [ValidateEnumeratedItems] + public List? Items { get; set; } + } +#endif + #if NET11_0_OR_GREATER [Fact] public async Task DataAnnotationValidateOptions_ValidateAsync_ReportsAnnotationFailures() @@ -923,9 +962,9 @@ public async Task DataAnnotationValidateOptions_ValidateAsync_ReportsAnnotationF ValidateOptionsResult asyncResult = await validator.ValidateAsync(Options.DefaultName, options); Assert.True(asyncResult.Failed); - Assert.Equal(syncResult.Failures, asyncResult.Failures); + Assert.Equal(syncResult.Failures.OrderBy(f => f), asyncResult.Failures.OrderBy(f => f)); Assert.Equal(5, asyncResult.Failures.Count()); - Assert.Contains("DataAnnotation validation failed", asyncResult.Failures.First()); + Assert.All(asyncResult.Failures, f => Assert.Contains("DataAnnotation validation failed", f)); } [Fact] @@ -1030,6 +1069,72 @@ public async Task DataAnnotationValidateOptions_ValidateAsync_AcceptsCancellatio ValidateOptionsResult result = await validator.ValidateAsync(Options.DefaultName, options, cts.Token); Assert.True(result.Succeeded); } + + [Fact] + public async Task DataAnnotationValidateOptions_ValidateAsync_DetectsAsyncOnlyFailure() + { + var options = new AsyncAnnotatedOptions { Value = "test" }; + var validator = new DataAnnotationValidateOptions(Options.DefaultName); + + ValidateOptionsResult syncResult = validator.Validate(Options.DefaultName, options); + Assert.True(syncResult.Succeeded); + + ValidateOptionsResult asyncResult = await validator.ValidateAsync(Options.DefaultName, options); + Assert.True(asyncResult.Failed); + Assert.Contains("Async-only failure", asyncResult.FailureMessage); + } + + [Fact] + public async Task DataAnnotationValidateOptions_ValidateAsync_AsyncOnlySuccess() + { + var options = new AsyncAnnotatedOptions { Value = null }; + var validator = new DataAnnotationValidateOptions(Options.DefaultName); + + ValidateOptionsResult result = await validator.ValidateAsync(Options.DefaultName, options); + Assert.True(result.Succeeded); + } + + [Fact] + public async Task DataAnnotationValidateOptions_ValidateAsync_NestedObjectRecursion() + { + var options = new ParentWithNestedAsync + { + Name = "parent", + Child = new AsyncAnnotatedOptions { Value = "test" } + }; + var validator = new DataAnnotationValidateOptions(Options.DefaultName); + + ValidateOptionsResult syncResult = validator.Validate(Options.DefaultName, options); + Assert.True(syncResult.Succeeded); + + ValidateOptionsResult asyncResult = await validator.ValidateAsync(Options.DefaultName, options); + Assert.True(asyncResult.Failed); + Assert.Contains("ParentWithNestedAsync.Child", asyncResult.FailureMessage); + Assert.Contains("Async-only failure", asyncResult.FailureMessage); + } + + [Fact] + public async Task DataAnnotationValidateOptions_ValidateAsync_EnumeratedItemsRecursion() + { + var options = new ParentWithEnumeratedAsync + { + Items = new List + { + new() { Value = "a" }, + new() { Value = "b" } + } + }; + var validator = new DataAnnotationValidateOptions(Options.DefaultName); + + ValidateOptionsResult syncResult = validator.Validate(Options.DefaultName, options); + Assert.True(syncResult.Succeeded); + + ValidateOptionsResult asyncResult = await validator.ValidateAsync(Options.DefaultName, options); + Assert.True(asyncResult.Failed); + Assert.Contains("[0]", asyncResult.FailureMessage); + Assert.Contains("[1]", asyncResult.FailureMessage); + Assert.Contains("Async-only failure", asyncResult.FailureMessage); + } #endif // NET11_0_OR_GREATER } }