diff --git a/src/Validation/src/Microsoft.Extensions.Validation.csproj b/src/Validation/src/Microsoft.Extensions.Validation.csproj
index 72d50e224f42..320c925bb0c2 100644
--- a/src/Validation/src/Microsoft.Extensions.Validation.csproj
+++ b/src/Validation/src/Microsoft.Extensions.Validation.csproj
@@ -12,6 +12,7 @@
+
diff --git a/src/Validation/src/PublicAPI.Unshipped.txt b/src/Validation/src/PublicAPI.Unshipped.txt
index d7f657e38875..bbc3ce87ca34 100644
--- a/src/Validation/src/PublicAPI.Unshipped.txt
+++ b/src/Validation/src/PublicAPI.Unshipped.txt
@@ -45,7 +45,11 @@ Microsoft.Extensions.Validation.ValidationOptions.TryGetValidatableTypeInfo(Syst
Microsoft.Extensions.Validation.ValidationOptions.ValidationOptions() -> void
abstract Microsoft.Extensions.Validation.ValidatableParameterInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
abstract Microsoft.Extensions.Validation.ValidatablePropertyInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
+Microsoft.Extensions.Validation.RuntimeValidatableTypeInfoResolver
+Microsoft.Extensions.Validation.RuntimeValidatableTypeInfoResolver.RuntimeValidatableTypeInfoResolver() -> void
+Microsoft.Extensions.Validation.RuntimeValidatableTypeInfoResolver.TryGetValidatableParameterInfo(System.Reflection.ParameterInfo! parameterInfo, out Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) -> bool
+Microsoft.Extensions.Validation.RuntimeValidatableTypeInfoResolver.TryGetValidatableTypeInfo(System.Type! type, out Microsoft.Extensions.Validation.IValidatableInfo? info) -> bool
static Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action? configureOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
virtual Microsoft.Extensions.Validation.ValidatableParameterInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
virtual Microsoft.Extensions.Validation.ValidatablePropertyInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
-virtual Microsoft.Extensions.Validation.ValidatableTypeInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
\ No newline at end of file
+virtual Microsoft.Extensions.Validation.ValidatableTypeInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
diff --git a/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs b/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs
index d8f0c3699dbf..9c569c12b453 100644
--- a/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs
+++ b/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs
@@ -9,6 +9,8 @@
using System.Linq;
using System.Reflection;
using System.Security.Claims;
+using Microsoft.AspNetCore.Http.Metadata;
+using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Extensions.Validation;
@@ -28,6 +30,13 @@ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNull
throw new InvalidOperationException($"Encountered a parameter of type '{parameterInfo.ParameterType}' without a name. Parameters must have a name.");
}
+ // Skip parameters marked with [FromService] or [FromKeyedService] attributes
+ if (HasFromServiceAttributes(parameterInfo.GetCustomAttributes()))
+ {
+ validatableInfo = null;
+ return false;
+ }
+
var validationAttributes = parameterInfo
.GetCustomAttributes()
.ToArray();
@@ -60,18 +69,6 @@ private static string GetDisplayName(ParameterInfo parameterInfo)
return parameterInfo.Name!;
}
- internal sealed class RuntimeValidatableParameterInfo(
- Type parameterType,
- string name,
- string displayName,
- ValidationAttribute[] validationAttributes) :
- ValidatableParameterInfo(parameterType, name, displayName)
- {
- protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
-
- private readonly ValidationAttribute[] _validationAttributes = validationAttributes;
- }
-
private static bool IsClass(Type type)
{
// Skip primitives, enums, common built-in types, and types that are specially
@@ -102,4 +99,23 @@ private static bool IsClass(Type type)
return type.IsClass;
}
-}
+
+ private static bool HasFromServiceAttributes(IEnumerable attributes)
+ {
+ return attributes.Any(attr =>
+ attr is IFromServiceMetadata ||
+ attr is FromKeyedServicesAttribute);
+ }
+
+ internal sealed class RuntimeValidatableParameterInfo(
+ Type parameterType,
+ string name,
+ string displayName,
+ ValidationAttribute[] validationAttributes) :
+ ValidatableParameterInfo(parameterType, name, displayName)
+ {
+ protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
+
+ private readonly ValidationAttribute[] _validationAttributes = validationAttributes;
+ }
+}
\ No newline at end of file
diff --git a/src/Validation/src/RuntimeValidatableTypeInfoResolver.cs b/src/Validation/src/RuntimeValidatableTypeInfoResolver.cs
new file mode 100644
index 000000000000..443a37954964
--- /dev/null
+++ b/src/Validation/src/RuntimeValidatableTypeInfoResolver.cs
@@ -0,0 +1,430 @@
+#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Concurrent;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Reflection;
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Http.Metadata;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Extensions.Validation;
+
+///
+/// Experimental runtime implementation of for type validation.
+///
+///
+/// This is an experimental API and may change in future versions.
+///
+[RequiresUnreferencedCode("RuntimeValidatableTypeInfoResolver uses reflection to inspect types, properties, and attributes at runtime, including JsonDerivedTypeAttribute and record constructors. Trimming or AOT compilation may remove members required for validation.")]
+[Experimental("ASP0029")]
+public sealed class RuntimeValidatableTypeInfoResolver : IValidatableInfoResolver
+{
+ private static readonly ConcurrentDictionary _cache = new();
+
+ ///
+ /// Attempts to get validatable type information for the specified type using runtime reflection.
+ ///
+ /// The type to get validation information for.
+ /// When this method returns, contains the validatable type information if found; otherwise, .
+ /// if validatable type information was found; otherwise, .
+ public bool TryGetValidatableTypeInfo(
+ Type type,
+ [NotNullWhen(true)] out IValidatableInfo? info)
+ {
+ if (_cache.TryGetValue(type, out info))
+ {
+ return info is not null;
+ }
+
+ info = CreateValidatableTypeInfo(type, new HashSet());
+ _cache.TryAdd(type, info);
+ return info is not null;
+ }
+
+ ///
+ /// Attempts to get validatable parameter information for the specified parameter.
+ ///
+ /// The parameter to get validation information for.
+ /// When this method returns, contains the validatable parameter information if found; otherwise, .
+ /// if validatable parameter information was found; otherwise, .
+ ///
+ /// This implementation always returns as parameter resolution is handled by .
+ ///
+ public bool TryGetValidatableParameterInfo(
+ ParameterInfo parameterInfo,
+ [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
+ {
+ validatableInfo = null;
+ return false;
+ }
+
+ private static RuntimeValidatableTypeInfo? CreateValidatableTypeInfo(Type type, HashSet visitedTypes)
+ {
+ // Prevent infinite recursion by tracking visited types
+ if (!visitedTypes.Add(type))
+ {
+ return null;
+ }
+
+ try
+ {
+ // Skip types that don't need validation (same logic as parameter resolver)
+ if (!IsClassForType(type))
+ {
+ return null;
+ }
+
+ // Get validation attributes applied to the type
+ var typeValidationAttributes = type
+ .GetCustomAttributes()
+ .ToArray();
+
+ // Skip early if the type has no validation attributes and no validatable properties
+ var hasTypeValidationAttributes = typeValidationAttributes.Length > 0;
+
+ // Get all public instance properties
+ var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
+ var validatableProperties = new List();
+
+ foreach (var property in properties)
+ {
+ // Skip properties without setters (read-only properties) for normal classes
+ if (!property.CanWrite && !IsRecordType(type) && !IsRecordStruct(type))
+ {
+ continue;
+ }
+
+ // Skip properties marked with [FromService] or [FromKeyedService] attributes
+ if (HasFromServiceAttributes(property.GetCustomAttributes()))
+ {
+ continue;
+ }
+
+ // Get validation attributes for this property
+ var propertyValidationAttributes = property
+ .GetCustomAttributes()
+ .ToArray();
+
+ // For record types, also check constructor parameters for validation attributes
+ if (IsRecordType(type) || IsRecordStruct(type))
+ {
+ var constructorValidationAttributes = GetValidationAttributesFromConstructorParameter(type, property.Name);
+ if (constructorValidationAttributes.Length > 0)
+ {
+ // Merge property and constructor validation attributes
+ var allAttributes = new List(propertyValidationAttributes);
+ allAttributes.AddRange(constructorValidationAttributes);
+ propertyValidationAttributes = [.. allAttributes];
+ }
+ }
+
+ // Get display name
+ var displayName = GetDisplayNameForProperty(property);
+
+ // Determine if this property has a validatable type
+ // Use the simpler check that doesn't cause recursion
+ var hasValidatableType = IsClassForType(property.PropertyType);
+
+ // Create the property info if it has validation attributes or a validatable type
+ if (propertyValidationAttributes.Length > 0 || hasValidatableType)
+ {
+ var propertyInfo = new RuntimeValidatablePropertyInfo(
+ declaringType: type,
+ propertyType: property.PropertyType,
+ name: property.Name,
+ displayName: displayName,
+ validationAttributes: propertyValidationAttributes);
+
+ validatableProperties.Add(propertyInfo);
+ }
+ }
+
+ // Check for polymorphic derived types (JsonDerivedType attributes)
+ var derivedTypes = GetDerivedTypes(type);
+ foreach (var derivedType in derivedTypes)
+ {
+ // Ensure derived types are also available for validation
+ // We don't need to use the return value as it's automatically cached
+ if (!_cache.ContainsKey(derivedType))
+ {
+ CreateValidatableTypeInfo(derivedType, visitedTypes);
+ }
+ }
+
+ // Only create type info if there are validation attributes on the type or validatable properties
+ if (hasTypeValidationAttributes || validatableProperties.Count > 0)
+ {
+ return new RuntimeValidatableTypeInfo(type, validatableProperties);
+ }
+
+ return null;
+ }
+ finally
+ {
+ visitedTypes.Remove(type);
+ }
+ }
+
+ private static List GetDerivedTypes(Type baseType)
+ {
+ var derivedTypes = new List();
+
+ // Look for JsonDerivedType attributes on the base type
+ var jsonDerivedTypeAttributes = baseType.GetCustomAttributes();
+
+ foreach (var attr in jsonDerivedTypeAttributes)
+ {
+ if (attr.DerivedType != null && attr.DerivedType.IsSubclassOf(baseType))
+ {
+ derivedTypes.Add(attr.DerivedType);
+ }
+ }
+
+ return derivedTypes;
+ }
+
+ private static string GetDisplayNameForProperty(PropertyInfo property)
+ {
+ var displayAttribute = property.GetCustomAttribute();
+ if (displayAttribute?.Name is not null)
+ {
+ return displayAttribute.Name;
+ }
+
+ // For record types, also check constructor parameter for Display attribute
+ if (IsRecordType(property.DeclaringType!) || IsRecordStruct(property.DeclaringType!))
+ {
+ var constructorDisplayName = GetDisplayNameFromConstructorParameter(property.DeclaringType!, property.Name);
+ if (!string.IsNullOrEmpty(constructorDisplayName))
+ {
+ return constructorDisplayName;
+ }
+ }
+
+ return property.Name;
+ }
+
+ private static string? GetDisplayNameFromConstructorParameter(Type type, string propertyName)
+ {
+ var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
+
+ foreach (var constructor in constructors)
+ {
+ var parameters = constructor.GetParameters();
+
+ // Find parameter that matches the property name (case-insensitive for records)
+ var matchingParameter = parameters.FirstOrDefault(p =>
+ string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase));
+
+ if (matchingParameter != null)
+ {
+ var displayAttribute = matchingParameter.GetCustomAttribute();
+ if (displayAttribute?.Name is not null)
+ {
+ return displayAttribute.Name;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsRecordType(Type type)
+ {
+ // Check if the type is a record by looking for specific record-related compiler-generated methods
+ // Records have a special $ method and EqualityContract property
+ return type.IsClass &&
+ type.GetMethods(BindingFlags.Public | BindingFlags.Instance)
+ .Any(m => m.Name == "$" || m.Name == "get_EqualityContract");
+ }
+
+ private static bool IsRecordStruct(Type type)
+ {
+ // Check if the type is a record struct by looking for record-specific characteristics
+ // Record structs are value types with specific compiler-generated methods
+ if (!type.IsValueType || type.IsEnum || type.IsPrimitive)
+ {
+ return false;
+ }
+
+ // Record structs have an EqualityContract property like classes but as static readonly
+ var equalityContract = type.GetProperty("EqualityContract", BindingFlags.Public | BindingFlags.Static);
+ if (equalityContract?.GetMethod?.IsStatic == true)
+ {
+ return true;
+ }
+
+ // Alternative check: Record structs have a primary constructor pattern
+ // They typically have a ToString() override and specific constructor patterns
+ var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
+ var hasParameterizedConstructor = constructors.Any(c => c.GetParameters().Length > 0);
+
+ if (hasParameterizedConstructor)
+ {
+ // Check for ToString override that's compiler generated for records
+ var toStringMethod = type.GetMethod("ToString", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null);
+ return toStringMethod?.DeclaringType == type;
+ }
+
+ return false;
+ }
+
+ private static ValidationAttribute[] GetValidationAttributesFromConstructorParameter(Type type, string propertyName)
+ {
+ // Look for primary constructor parameters that match the property name
+ var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
+
+ // For records, prefer the primary constructor (typically the one with the most parameters)
+ var primaryConstructor = constructors
+ .OrderByDescending(c => c.GetParameters().Length)
+ .FirstOrDefault();
+
+ if (primaryConstructor != null)
+ {
+ var parameters = primaryConstructor.GetParameters();
+
+ // Find parameter that matches the property name (case-insensitive for records)
+ var matchingParameter = parameters.FirstOrDefault(p =>
+ string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase));
+
+ if (matchingParameter != null)
+ {
+ var attributes = matchingParameter.GetCustomAttributes().ToArray();
+ if (attributes.Length > 0)
+ {
+ return attributes;
+ }
+ }
+ }
+
+ return [];
+ }
+
+ private static Type UnwrapType(Type type)
+ {
+ // Handle Nullable
+ if (Nullable.GetUnderlyingType(type) is { } nullableType)
+ {
+ type = nullableType;
+ }
+
+ // Handle collection types - extract element type
+ if (type.IsGenericType)
+ {
+ var genericDefinition = type.GetGenericTypeDefinition();
+ if (genericDefinition == typeof(IEnumerable<>) ||
+ genericDefinition == typeof(ICollection<>) ||
+ genericDefinition == typeof(IList<>) ||
+ genericDefinition == typeof(List<>) ||
+ genericDefinition == typeof(IReadOnlyCollection<>) ||
+ genericDefinition == typeof(IReadOnlyList<>))
+ {
+ type = type.GetGenericArguments()[0];
+ return UnwrapType(type); // Recursively unwrap nested collections
+ }
+ }
+
+ // Handle arrays
+ if (type.IsArray)
+ {
+ type = type.GetElementType()!;
+ return UnwrapType(type); // Recursively unwrap nested arrays
+ }
+
+ return type;
+ }
+
+ private static bool IsParsableType(Type type)
+ {
+ var unwrappedType = UnwrapType(type);
+
+ // Check for built-in parsable types
+ if (unwrappedType.IsPrimitive ||
+ unwrappedType.IsEnum ||
+ unwrappedType == typeof(string) ||
+ unwrappedType == typeof(decimal) ||
+ unwrappedType == typeof(DateTime) ||
+ unwrappedType == typeof(DateTimeOffset) ||
+ unwrappedType == typeof(TimeOnly) ||
+ unwrappedType == typeof(DateOnly) ||
+ unwrappedType == typeof(TimeSpan) ||
+ unwrappedType == typeof(Guid) ||
+ unwrappedType == typeof(Uri))
+ {
+ return true;
+ }
+
+ try
+ {
+ // Check for IParsable interface
+ // Check if unwrappedType implements IParsable for itself
+ foreach (var iface in unwrappedType.GetInterfaces())
+ {
+ // Look for IParsable in its generic-definition form
+ if (iface.IsGenericType &&
+ iface.GetGenericTypeDefinition() == typeof(IParsable<>))
+ {
+ return true;
+ }
+ }
+ }
+ catch
+ {
+ // If we can't construct the generic type, it's not parsable
+ }
+
+ try
+ {
+ // Check for TryParse methods
+ var tryParseMethod = unwrappedType.GetMethod("TryParse",
+ BindingFlags.Public | BindingFlags.Static,
+ null,
+ [typeof(string), unwrappedType.MakeByRefType()],
+ null);
+
+ if (tryParseMethod != null && tryParseMethod.ReturnType == typeof(bool))
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // If we can't find the method, it's not parsable
+ }
+
+ return false;
+ }
+
+ private static bool IsClassForType(Type type)
+ => !IsParsableType(type) && (type.IsClass || IsRecordStruct(type));
+
+ private static bool HasFromServiceAttributes(IEnumerable attributes)
+ {
+ return attributes.Any(attr =>
+ attr is IFromServiceMetadata ||
+ attr is FromKeyedServicesAttribute);
+ }
+
+ internal sealed class RuntimeValidatablePropertyInfo([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType,
+ Type propertyType,
+ string name,
+ string displayName,
+ ValidationAttribute[] validationAttributes) :
+ ValidatablePropertyInfo(declaringType, propertyType, name, displayName)
+ {
+ private readonly ValidationAttribute[] _validationAttributes = validationAttributes;
+
+ protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
+ }
+
+ internal sealed class RuntimeValidatableTypeInfo(
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type,
+ IReadOnlyList members) :
+ ValidatableTypeInfo(type, [.. members])
+ { }
+}
\ No newline at end of file
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs
index eebe4032d9af..929658cdf58e 100644
--- a/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs
+++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs
@@ -8,13 +8,14 @@
namespace Microsoft.Extensions.Validation.Tests;
-public class RuntimeValidatableParameterInfoResolverTests
+public class RuntimeValidatableInfoResolverTests
{
private readonly RuntimeValidatableParameterInfoResolver _resolver = new();
[Fact]
- public void TryGetValidatableTypeInfo_AlwaysReturnsFalse()
+ public void TryGetValidatableTypeInfo_WithStringType_ReturnsFalse()
{
+ // String types should not be validatable at the type level
var result = _resolver.TryGetValidatableTypeInfo(typeof(string), out var validatableInfo);
Assert.False(result);
diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableTypeInfoResolverTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableTypeInfoResolverTests.cs
new file mode 100644
index 000000000000..419ca9139b6e
--- /dev/null
+++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableTypeInfoResolverTests.cs
@@ -0,0 +1,1181 @@
+#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using System.Reflection;
+
+namespace Microsoft.Extensions.Validation.Tests;
+
+public class RuntimeValidatableTypeInfoResolverTests
+{
+ private readonly RuntimeValidatableTypeInfoResolver _resolver = new();
+
+ [Fact]
+ public void TryGetValidatableParameterInfo_WithStringParameterNoAttributes_ReturnsFalse()
+ {
+ var methodInfo = typeof(RuntimeValidatableTypeInfoResolverTests).GetMethod(nameof(SampleMethod), BindingFlags.NonPublic | BindingFlags.Instance);
+ var parameterInfo = methodInfo!.GetParameters()[0];
+
+ var result = _resolver.TryGetValidatableParameterInfo(parameterInfo, out var validatableInfo);
+
+ Assert.False(result);
+ Assert.Null(validatableInfo);
+ }
+
+ [Fact]
+ public void TryGetValidatableTypeInfo_WithPrimitiveType_ReturnsFalse()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(int), out var validatableInfo);
+
+ Assert.False(result);
+ Assert.Null(validatableInfo);
+ }
+
+ [Fact]
+ public void TryGetValidatableTypeInfo_WithString_ReturnsFalse()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(string), out var validatableInfo);
+
+ Assert.False(result);
+ Assert.Null(validatableInfo);
+ }
+
+ [Fact]
+ public void TryGetValidatableTypeInfo_WithEnum_ReturnsFalse()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(SampleEnum), out var validatableInfo);
+
+ Assert.False(result);
+ Assert.Null(validatableInfo);
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithSimplePocoWithValidationAttributes_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(SimplePocoWithValidation), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+ Assert.IsType(validatableInfo);
+
+ // Test validation with invalid data
+ var invalidPoco = new SimplePocoWithValidation
+ {
+ Name = "", // Required but empty
+ Age = 150 // Out of range (0-100)
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidPoco)
+ };
+
+ await validatableInfo.ValidateAsync(invalidPoco, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Age", kvp.Key);
+ Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First());
+ });
+
+ // Test validation with valid data
+ var validPoco = new SimplePocoWithValidation
+ {
+ Name = "John Doe",
+ Age = 25
+ };
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validPoco)
+ };
+
+ await validatableInfo.ValidateAsync(validPoco, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors); // No validation errors for valid data
+ }
+
+ [Fact]
+ public void TryGetValidatableTypeInfo_WithSimplePocoWithoutValidation_ReturnsFalse()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(SimplePocoWithoutValidation), out var validatableInfo);
+
+ Assert.False(result);
+ Assert.Null(validatableInfo);
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithNestedComplexType_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithNestedType), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ // Test validation with invalid nested data
+ var invalidPoco = new PocoWithNestedType
+ {
+ Name = "", // Required but empty
+ NestedPoco = new SimplePocoWithValidation
+ {
+ Name = "", // Required but empty in nested object
+ Age = -5 // Out of range (0-100) in nested object
+ }
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidPoco)
+ };
+
+ await validatableInfo.ValidateAsync(invalidPoco, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("NestedPoco.Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("NestedPoco.Age", kvp.Key);
+ Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First());
+ });
+
+ // Test validation with valid nested data
+ var validPoco = new PocoWithNestedType
+ {
+ Name = "John Doe",
+ NestedPoco = new SimplePocoWithValidation
+ {
+ Name = "Jane Smith",
+ Age = 30
+ }
+ };
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validPoco)
+ };
+
+ await validatableInfo.ValidateAsync(validPoco, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors); // No validation errors for valid data
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithCyclicReference_DoesNotCauseStackOverflow_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(CyclicTypeA), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ // Test validation with invalid data in cyclic structure
+ var cyclicA = new CyclicTypeA
+ {
+ Name = "", // Required but empty
+ TypeB = new CyclicTypeB
+ {
+ Value = "", // Required but empty
+ TypeA = new CyclicTypeA
+ {
+ Name = "Valid Name", // This one is valid
+ TypeB = null // No further nesting
+ }
+ }
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(cyclicA)
+ };
+
+ await validatableInfo.ValidateAsync(cyclicA, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("TypeB.Value", kvp.Key);
+ Assert.Equal("The Value field is required.", kvp.Value.First());
+ });
+
+ // Test validation with valid cyclic data
+ var validCyclicA = new CyclicTypeA
+ {
+ Name = "Valid A",
+ TypeB = new CyclicTypeB
+ {
+ Value = "Valid B",
+ TypeA = new CyclicTypeA
+ {
+ Name = "Valid Nested A",
+ TypeB = null // Stop the cycle
+ }
+ }
+ };
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validCyclicA)
+ };
+
+ await validatableInfo.ValidateAsync(validCyclicA, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors); // No validation errors for valid data
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithCollectionOfComplexTypes_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithCollection), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ // Test validation with invalid data in collection
+ var invalidPoco = new PocoWithCollection
+ {
+ Name = "", // Required but empty
+ Items = new List
+ {
+ new SimplePocoWithValidation { Name = "Valid Item", Age = 25 }, // Valid item
+ new SimplePocoWithValidation { Name = "", Age = 150 }, // Invalid: empty name and out of range age
+ new SimplePocoWithValidation { Name = "Another Valid", Age = 30 } // Valid item
+ }
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidPoco)
+ };
+
+ await validatableInfo.ValidateAsync(invalidPoco, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Items[1].Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Items[1].Age", kvp.Key);
+ Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First());
+ });
+
+ // Test validation with valid collection data
+ var validPoco = new PocoWithCollection
+ {
+ Name = "Collection Owner",
+ Items = new List
+ {
+ new SimplePocoWithValidation { Name = "Item 1", Age = 25 },
+ new SimplePocoWithValidation { Name = "Item 2", Age = 30 }
+ }
+ };
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validPoco)
+ };
+
+ await validatableInfo.ValidateAsync(validPoco, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors); // No validation errors for valid data
+ }
+
+ [Fact]
+ public void TryGetValidatableTypeInfo_UsesCaching()
+ {
+ // First call
+ var result1 = _resolver.TryGetValidatableTypeInfo(typeof(SimplePocoWithValidation), out var validatableInfo1);
+
+ // Second call
+ var result2 = _resolver.TryGetValidatableTypeInfo(typeof(SimplePocoWithValidation), out var validatableInfo2);
+
+ Assert.True(result1);
+ Assert.True(result2);
+ Assert.Same(validatableInfo1, validatableInfo2); // Should be the same cached instance
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithReadOnlyProperty_IgnoresReadOnlyProperty_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithReadOnlyProperty), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ var typeInfo = Assert.IsType(validatableInfo);
+
+ // Test validation with invalid writable property (read-only property should be ignored)
+ var invalidPoco = new PocoWithReadOnlyProperty
+ {
+ Name = "" // Required but empty (ReadOnlyValue should be ignored)
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidPoco)
+ };
+
+ await validatableInfo.ValidateAsync(invalidPoco, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ var error = Assert.Single(context.ValidationErrors);
+ Assert.Equal("Name", error.Key);
+ Assert.Equal("The Name field is required.", error.Value.First());
+ // ReadOnlyValue should not generate validation errors even though it has [Required]
+
+ // Test validation with valid writable property
+ var validPoco = new PocoWithReadOnlyProperty
+ {
+ Name = "Valid Name" // ReadOnlyValue is always "ReadOnly" and should be ignored
+ };
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validPoco)
+ };
+
+ await validatableInfo.ValidateAsync(validPoco, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors); // No validation errors for valid data
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithRecord_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(SimpleRecordWithValidation), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ // Test validation with invalid record data
+ var invalidRecord = new SimpleRecordWithValidation("", 150); // Empty name, out of range age
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidRecord)
+ };
+
+ await validatableInfo.ValidateAsync(invalidRecord, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Age", kvp.Key);
+ Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First());
+ });
+
+ // Test validation with valid record data
+ var validRecord = new SimpleRecordWithValidation("John Doe", 25);
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validRecord)
+ };
+
+ await validatableInfo.ValidateAsync(validRecord, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors);
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithRecordContainingComplexProperty_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(RecordWithComplexProperty), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ // Test validation with invalid nested data in record
+ var invalidRecord = new RecordWithComplexProperty(
+ "",
+ new SimplePocoWithValidation { Name = "", Age = 150 });
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidRecord)
+ };
+
+ await validatableInfo.ValidateAsync(invalidRecord, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("ComplexProperty.Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("ComplexProperty.Age", kvp.Key);
+ Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First());
+ });
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithIValidatableObject_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(ValidatableObject), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ // Test attribute validation fails first, then IValidatableObject.Validate is called
+ var invalidObject = new ValidatableObject
+ {
+ Name = "", // Required but empty - attribute validation
+ Value = 150 // Out of range - attribute validation
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidObject)
+ };
+
+ await validatableInfo.ValidateAsync(invalidObject, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Value", kvp.Key);
+ Assert.Equal("The field Value must be between 0 and 100.", kvp.Value.First());
+ });
+
+ // Test IValidatableObject.Validate custom logic
+ var customInvalidObject = new ValidatableObject
+ {
+ Name = "Invalid", // Triggers custom validation
+ Value = 25
+ };
+
+ var customContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(customInvalidObject)
+ };
+
+ await validatableInfo.ValidateAsync(customInvalidObject, customContext, default);
+
+ Assert.NotNull(customContext.ValidationErrors);
+ var error = Assert.Single(customContext.ValidationErrors);
+ Assert.Equal("Name", error.Key);
+ Assert.Equal("Name cannot be 'Invalid'", error.Value.First());
+
+ // Test complex IValidatableObject logic with multiple properties
+ var multiPropertyInvalidObject = new ValidatableObject
+ {
+ Name = "Joe", // Valid but short (< 5 chars)
+ Value = 75 // Valid range but > 50, triggers multi-property validation
+ };
+
+ var multiContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(multiPropertyInvalidObject)
+ };
+
+ await validatableInfo.ValidateAsync(multiPropertyInvalidObject, multiContext, default);
+
+ Assert.NotNull(multiContext.ValidationErrors);
+ Assert.Equal(2, multiContext.ValidationErrors.Count);
+ Assert.True(multiContext.ValidationErrors.ContainsKey("Name"));
+ Assert.True(multiContext.ValidationErrors.ContainsKey("Value"));
+ Assert.Equal("When Value > 50, Name must be at least 5 characters", multiContext.ValidationErrors["Name"].First());
+ Assert.Equal("When Value > 50, Name must be at least 5 characters", multiContext.ValidationErrors["Value"].First());
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithNestedIValidatableObject_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithValidatableObject), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ var invalidObject = new PocoWithValidatableObject
+ {
+ Title = "", // Required but empty
+ ValidatableProperty = new ValidatableObject
+ {
+ Name = "Invalid", // Triggers custom validation
+ Value = 25
+ }
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidObject)
+ };
+
+ await validatableInfo.ValidateAsync(invalidObject, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Title", kvp.Key);
+ Assert.Equal("The Title field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("ValidatableProperty.Name", kvp.Key);
+ Assert.Equal("Name cannot be 'Invalid'", kvp.Value.First());
+ });
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithCustomValidationAttribute_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithCustomValidation), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ var invalidObject = new PocoWithCustomValidation
+ {
+ Name = "", // Required but empty
+ EvenValue = 3, // Odd number - custom validation fails
+ MultipleAttributesValue = -1 // Odd number and out of range (-1 is not in range 1-100)
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidObject)
+ };
+
+ await validatableInfo.ValidateAsync(invalidObject, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("EvenValue", kvp.Key);
+ Assert.Equal("The field EvenValue must be an even number.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("MultipleAttributesValue", kvp.Key);
+ Assert.Equal(2, kvp.Value.Count());
+ Assert.Contains(kvp.Value, error => error == "The field MultipleAttributesValue must be between 1 and 100.");
+ Assert.Contains(kvp.Value, error => error == "The field MultipleAttributesValue must be an even number.");
+ });
+
+ // Test valid custom validation
+ var validObject = new PocoWithCustomValidation
+ {
+ Name = "Valid Name",
+ EvenValue = 4,
+ MultipleAttributesValue = 50
+ };
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validObject)
+ };
+
+ await validatableInfo.ValidateAsync(validObject, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors);
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithStringValidationAttributes_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithStringValidation), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ var invalidObject = new PocoWithStringValidation
+ {
+ Name = "AB", // Too short (min 3, max 10)
+ Email = "invalid-email",
+ Website = "not-a-url",
+ Phone = "123-456" // Invalid format
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidObject)
+ };
+
+ await validatableInfo.ValidateAsync(invalidObject, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Contains("length", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Email", kvp.Key);
+ Assert.Contains("valid e-mail", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Website", kvp.Key);
+ Assert.Contains("valid", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Phone", kvp.Key);
+ Assert.Equal("Phone must be in format XXX-XXX-XXXX", kvp.Value.First());
+ });
+
+ // Test valid string validation
+ var validObject = new PocoWithStringValidation
+ {
+ Name = "Valid Name",
+ Email = "test@example.com",
+ Website = "https://example.com",
+ Phone = "123-456-7890"
+ };
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validObject)
+ };
+
+ await validatableInfo.ValidateAsync(validObject, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors);
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithRangeValidationDifferentTypes_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithRangeValidation), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ var invalidObject = new PocoWithRangeValidation
+ {
+ DecimalValue = 150.75m, // Out of range (0.1 - 100.5)
+ DateValue = new DateTime(2024, 6, 1), // Out of range (2023 only)
+ DateOnlyValue = new DateOnly(2024, 6, 1) // Out of range (2023 only)
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidObject)
+ };
+
+ await validatableInfo.ValidateAsync(invalidObject, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Equal(3, context.ValidationErrors.Count);
+ Assert.All(context.ValidationErrors, kvp => Assert.Contains("must be between", kvp.Value.First()));
+
+ // Test valid range validation
+ var validObject = new PocoWithRangeValidation
+ {
+ DecimalValue = 50.25m,
+ DateValue = new DateTime(2023, 6, 1),
+ DateOnlyValue = new DateOnly(2023, 6, 1)
+ };
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validObject)
+ };
+
+ await validatableInfo.ValidateAsync(validObject, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors);
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithDisplayAttributes_ReturnsTrue_AndUsesDisplayNamesInErrors()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithDisplayAttributes), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ var invalidObject = new PocoWithDisplayAttributes
+ {
+ Name = "", // Required but empty
+ Age = 150 // Out of range
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidObject)
+ };
+
+ await validatableInfo.ValidateAsync(invalidObject, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Full Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Age", kvp.Key);
+ Assert.Equal("The field User Age must be between 0 and 100.", kvp.Value.First());
+ });
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithCustomValidationMethod_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithCustomValidationMethod), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ var invalidObject = new PocoWithCustomValidationMethod
+ {
+ FirstName = "John",
+ LastName = "Doe",
+ FullName = "Jane Smith" // Doesn't match FirstName + LastName
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidObject)
+ };
+
+ await validatableInfo.ValidateAsync(invalidObject, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ var error = Assert.Single(context.ValidationErrors);
+ Assert.Equal("FullName", error.Key);
+ Assert.Equal("FullName must be 'John Doe'", error.Value.First());
+
+ // Test valid custom validation method
+ var validObject = new PocoWithCustomValidationMethod
+ {
+ FirstName = "John",
+ LastName = "Doe",
+ FullName = "John Doe"
+ };
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validObject)
+ };
+
+ await validatableInfo.ValidateAsync(validObject, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors);
+ }
+
+ [Fact]
+ public async Task TryGetValidatableTypeInfo_WithArrayValidation_ReturnsTrue_AndValidatesCorrectly()
+ {
+ var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithArrayValidation), out var validatableInfo);
+
+ Assert.True(result);
+ Assert.NotNull(validatableInfo);
+
+ var invalidObject = new PocoWithArrayValidation
+ {
+ Name = "", // Required but empty
+ Items = new[]
+ {
+ new SimplePocoWithValidation { Name = "Valid", Age = 25 },
+ new SimplePocoWithValidation { Name = "", Age = 150 }, // Invalid item
+ new SimplePocoWithValidation { Name = "Another Valid", Age = 30 }
+ }
+ };
+
+ var validationOptions = new ValidationOptions();
+ validationOptions.Resolvers.Add(_resolver);
+
+ var context = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(invalidObject)
+ };
+
+ await validatableInfo.ValidateAsync(invalidObject, context, default);
+
+ Assert.NotNull(context.ValidationErrors);
+ Assert.Collection(context.ValidationErrors,
+ kvp =>
+ {
+ Assert.Equal("Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Items[1].Name", kvp.Key);
+ Assert.Equal("The Name field is required.", kvp.Value.First());
+ },
+ kvp =>
+ {
+ Assert.Equal("Items[1].Age", kvp.Key);
+ Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First());
+ });
+
+ // Test valid array validation
+ var validObject = new PocoWithArrayValidation
+ {
+ Name = "Valid Name",
+ Items = new[]
+ {
+ new SimplePocoWithValidation { Name = "Item 1", Age = 25 },
+ new SimplePocoWithValidation { Name = "Item 2", Age = 30 }
+ }
+ };
+
+ var validContext = new ValidateContext
+ {
+ ValidationOptions = validationOptions,
+ ValidationContext = new ValidationContext(validObject)
+ };
+
+ await validatableInfo.ValidateAsync(validObject, validContext, default);
+
+ Assert.Null(validContext.ValidationErrors);
+ }
+
+ // Helper method for parameter test
+ private void SampleMethod(string parameter) { }
+
+ // Test classes
+ public enum SampleEnum
+ {
+ Value1,
+ Value2
+ }
+
+ public class SimplePocoWithValidation
+ {
+ [Required]
+ public string Name { get; set; } = string.Empty;
+
+ [Range(0, 100)]
+ public int Age { get; set; }
+ }
+
+ public class SimplePocoWithoutValidation
+ {
+ public string Name { get; set; } = string.Empty;
+ public int Age { get; set; }
+ }
+
+ public class PocoWithNestedType
+ {
+ [Required]
+ public string Name { get; set; } = string.Empty;
+
+ public SimplePocoWithValidation NestedPoco { get; set; } = new();
+ }
+
+ public class CyclicTypeA
+ {
+ [Required]
+ public string Name { get; set; } = string.Empty;
+
+ public CyclicTypeB? TypeB { get; set; }
+ }
+
+ public class CyclicTypeB
+ {
+ [Required]
+ public string Value { get; set; } = string.Empty;
+
+ public CyclicTypeA? TypeA { get; set; }
+ }
+
+ public class PocoWithCollection
+ {
+ [Required]
+ public string Name { get; set; } = string.Empty;
+
+ public List Items { get; set; } = new();
+ }
+
+ public class PocoWithReadOnlyProperty
+ {
+ [Required]
+ public string Name { get; set; } = string.Empty;
+
+ [Required]
+ public string ReadOnlyValue { get; } = "ReadOnly";
+ }
+
+ // Test record types
+ public record SimpleRecordWithValidation(
+ [Required] string Name,
+ [Range(0, 100)] int Age);
+
+ public record RecordWithComplexProperty(
+ [Required] string Name,
+ SimplePocoWithValidation ComplexProperty);
+
+ // Test IValidatableObject implementations
+ public class ValidatableObject : IValidatableObject
+ {
+ [Required]
+ public string Name { get; set; } = string.Empty;
+
+ [Range(0, 100)]
+ public int Value { get; set; }
+
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ if (Name == "Invalid")
+ {
+ yield return new ValidationResult("Name cannot be 'Invalid'", new[] { nameof(Name) });
+ }
+
+ if (Value > 50 && Name?.Length < 5)
+ {
+ yield return new ValidationResult("When Value > 50, Name must be at least 5 characters", new[] { nameof(Name), nameof(Value) });
+ }
+ }
+ }
+
+ public class PocoWithValidatableObject
+ {
+ [Required]
+ public string Title { get; set; } = string.Empty;
+
+ public ValidatableObject ValidatableProperty { get; set; } = new();
+ }
+
+ // Test custom validation attributes
+ public class EvenNumberAttribute : ValidationAttribute
+ {
+ public override bool IsValid(object? value)
+ {
+ if (value is int number)
+ {
+ return number % 2 == 0;
+ }
+ return true;
+ }
+
+ public override string FormatErrorMessage(string name)
+ {
+ return $"The field {name} must be an even number.";
+ }
+ }
+
+ public class PocoWithCustomValidation
+ {
+ [Required]
+ public string Name { get; set; } = string.Empty;
+
+ [EvenNumber]
+ public int EvenValue { get; set; }
+
+ [Range(1, 100), EvenNumber]
+ public int MultipleAttributesValue { get; set; }
+ }
+
+ // Test string-specific validation attributes
+ public class PocoWithStringValidation
+ {
+ [Required]
+ [StringLength(10, MinimumLength = 3)]
+ public string Name { get; set; } = string.Empty;
+
+ [EmailAddress]
+ public string? Email { get; set; }
+
+ [Url]
+ public string? Website { get; set; }
+
+ [RegularExpression(@"^\d{3}-\d{3}-\d{4}$", ErrorMessage = "Phone must be in format XXX-XXX-XXXX")]
+ public string? Phone { get; set; }
+ }
+
+ // Test range validation with different data types
+ public class PocoWithRangeValidation
+ {
+ [Range(0.1, 100.5)]
+ public decimal DecimalValue { get; set; }
+
+ [Range(typeof(DateTime), "2023-01-01", "2023-12-31")]
+ public DateTime DateValue { get; set; }
+
+ [Range(typeof(DateOnly), "2023-01-01", "2023-12-31")]
+ public DateOnly DateOnlyValue { get; set; }
+ }
+
+ // Test Display attribute handling
+ public class PocoWithDisplayAttributes
+ {
+ [Required]
+ [Display(Name = "Full Name")]
+ public string Name { get; set; } = string.Empty;
+
+ [Range(0, 100)]
+ [Display(Name = "User Age", Description = "Age in years")]
+ public int Age { get; set; }
+ }
+
+ // Test CustomValidation attribute
+ public class PocoWithCustomValidationMethod
+ {
+ [Required]
+ public string FirstName { get; set; } = string.Empty;
+
+ [Required]
+ public string LastName { get; set; } = string.Empty;
+
+ [CustomValidation(typeof(PocoWithCustomValidationMethod), nameof(ValidateFullName))]
+ public string FullName { get; set; } = string.Empty;
+
+ public static ValidationResult? ValidateFullName(string fullName, ValidationContext context)
+ {
+ if (context.ObjectInstance is PocoWithCustomValidationMethod instance)
+ {
+ var expectedFullName = $"{instance.FirstName} {instance.LastName}";
+ if (fullName != expectedFullName)
+ {
+ return new ValidationResult($"FullName must be '{expectedFullName}'", new[] { context.MemberName! });
+ }
+ }
+ return ValidationResult.Success;
+ }
+ }
+
+ // Test array validation
+ public class PocoWithArrayValidation
+ {
+ [Required]
+ public string Name { get; set; } = string.Empty;
+
+ public SimplePocoWithValidation[]? Items { get; set; }
+ }
+
+ // Test service-like type that should be excluded from validation
+ public class TestService
+ {
+ [Range(10, 100)]
+ public int Value { get; set; } = 4;
+
+ [Required]
+ public string Name { get; set; } = string.Empty;
+ }
+}