File: Validation\ValidatableParameterInfoTests.cs
Web Access
Project: src\src\Http\Http.Abstractions\test\Microsoft.AspNetCore.Http.Abstractions.Tests.csproj (Microsoft.AspNetCore.Http.Abstractions.Tests)
// 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.Diagnostics.CodeAnalysis;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
 
namespace Microsoft.AspNetCore.Http.Validation.Tests;
 
public class ValidatableParameterInfoTests
{
    [Fact]
    public async Task Validate_RequiredParameter_AddsErrorWhenNull()
    {
        // Arrange
        var paramInfo = CreateTestParameterInfo(
            parameterType: typeof(string),
            name: "testParam",
            displayName: "Test Parameter",
            validationAttributes: [new RequiredAttribute()]);
 
        var context = CreateValidatableContext();
 
        // Act
        await paramInfo.ValidateAsync(null, context, default);
 
        // Assert
        var errors = context.ValidationErrors;
        Assert.NotNull(errors);
        var error = Assert.Single(errors);
        Assert.Equal("testParam", error.Key);
        Assert.Equal("The Test Parameter field is required.", error.Value.Single());
    }
 
    [Fact]
    public async Task Validate_RequiredParameter_ShortCircuitsOtherValidations()
    {
        // Arrange
        var paramInfo = CreateTestParameterInfo(
            parameterType: typeof(string),
            name: "testParam",
            displayName: "Test Parameter",
            // Most ValidationAttributes skip validation if the value is null
            // so we use a custom one that always fails to assert on the behavior here
            validationAttributes: [new RequiredAttribute(), new CustomTestValidationAttribute()]);
 
        var context = CreateValidatableContext();
 
        // Act
        await paramInfo.ValidateAsync(null, context, default);
 
        // Assert
        var errors = context.ValidationErrors;
        Assert.NotNull(errors);
        var error = Assert.Single(errors);
        Assert.Equal("testParam", error.Key);
        Assert.Equal("The Test Parameter field is required.", error.Value.Single());
    }
 
    [Fact]
    public async Task Validate_SkipsValidation_WhenNullAndNotRequired()
    {
        // Arrange
        var paramInfo = CreateTestParameterInfo(
            parameterType: typeof(string),
            name: "testParam",
            displayName: "Test Parameter",
            validationAttributes: [new StringLengthAttribute(10)]);
 
        var context = CreateValidatableContext();
 
        // Act
        await paramInfo.ValidateAsync(null, context, default);
 
        // Assert
        var errors = context.ValidationErrors;
        Assert.Null(errors); // No errors added
    }
 
    [Fact]
    public async Task Validate_WithRangeAttribute_ValidatesCorrectly()
    {
        // Arrange
        var paramInfo = CreateTestParameterInfo(
            parameterType: typeof(int),
            name: "testParam",
            displayName: "Test Parameter",
            validationAttributes: [new RangeAttribute(10, 100)]);
 
        var context = CreateValidatableContext();
 
        // Act
        await paramInfo.ValidateAsync(5, context, default);
 
        // Assert
        var errors = context.ValidationErrors;
        Assert.NotNull(errors);
        var error = Assert.Single(errors);
        Assert.Equal("testParam", error.Key);
        Assert.Equal("The field Test Parameter must be between 10 and 100.", error.Value.First());
    }
 
    [Fact]
    public async Task Validate_WithDisplayNameAttribute_UsesDisplayNameInErrorMessage()
    {
        // Arrange
        var paramInfo = CreateTestParameterInfo(
            parameterType: typeof(string),
            name: "testParam",
            displayName: "Custom Display Name",
            validationAttributes: [new RequiredAttribute()]);
 
        var context = CreateValidatableContext();
 
        // Act
        await paramInfo.ValidateAsync(null, context, default);
 
        // Assert
        var errors = context.ValidationErrors;
        Assert.NotNull(errors);
        var error = Assert.Single(errors);
        Assert.Equal("testParam", error.Key);
        // The error message should use the display name
        Assert.Equal("The Custom Display Name field is required.", error.Value.First());
    }
 
    [Fact]
    public async Task Validate_WhenValidatableTypeHasErrors_AddsNestedErrors()
    {
        // Arrange
        var personTypeInfo = new TestValidatableTypeInfo(
            typeof(Person),
            [
                new TestValidatablePropertyInfo(
                    typeof(Person),
                    typeof(string),
                    "Name",
                    "Name",
                    [new RequiredAttribute()])
            ]);
 
        var paramInfo = CreateTestParameterInfo(
            parameterType: typeof(Person),
            name: "person",
            displayName: "Person",
            validationAttributes: []);
 
        var typeMapping = new Dictionary<Type, ValidatableTypeInfo>
        {
            { typeof(Person), personTypeInfo }
        };
 
        var context = CreateValidatableContext(typeMapping);
        var person = new Person(); // Name is null, so should fail validation
 
        // Act
        await paramInfo.ValidateAsync(person, context, default);
 
        // Assert
        var errors = context.ValidationErrors;
        Assert.NotNull(errors);
        var error = Assert.Single(errors);
        Assert.Equal("Name", error.Key);
        Assert.Equal("The Name field is required.", error.Value[0]);
    }
 
    [Fact]
    public async Task Validate_WithEnumerableOfValidatableType_ValidatesEachItem()
    {
        // Arrange
        var personTypeInfo = new TestValidatableTypeInfo(
            typeof(Person),
            [
                new TestValidatablePropertyInfo(
                    typeof(Person),
                    typeof(string),
                    "Name",
                    "Name",
                    [new RequiredAttribute()])
            ]);
 
        var paramInfo = CreateTestParameterInfo(
            parameterType: typeof(IEnumerable<Person>),
            name: "people",
            displayName: "People",
            validationAttributes: []);
 
        var typeMapping = new Dictionary<Type, ValidatableTypeInfo>
        {
            { typeof(Person), personTypeInfo }
        };
 
        var context = CreateValidatableContext(typeMapping);
        var people = new List<Person>
        {
            new() { Name = "Valid" },
            new() // Name is null, should fail
        };
 
        // Act
        await paramInfo.ValidateAsync(people, context, default);
 
        // Assert
        var errors = context.ValidationErrors;
        Assert.NotNull(errors);
        var error = Assert.Single(errors);
        Assert.Equal("people[1].Name", error.Key);
        Assert.Equal("The Name field is required.", error.Value[0]);
    }
 
    [Fact]
    public async Task Validate_MultipleErrorsOnSameParameter_CollectsAllErrors()
    {
        // Arrange
        var paramInfo = CreateTestParameterInfo(
            parameterType: typeof(int),
            name: "testParam",
            displayName: "Test Parameter",
            validationAttributes:
            [
                new RangeAttribute(10, 100) { ErrorMessage = "Range error" },
                new CustomTestValidationAttribute { ErrorMessage = "Custom error" }
            ]);
 
        var context = CreateValidatableContext();
 
        // Act
        await paramInfo.ValidateAsync(5, context, default);
 
        // Assert
        var errors = context.ValidationErrors;
        Assert.NotNull(errors);
        var error = Assert.Single(errors);
        Assert.Equal("testParam", error.Key);
        Assert.Collection(error.Value,
            e => Assert.Equal("Range error", e),
            e => Assert.Equal("Custom error", e));
    }
 
    [Fact]
    public async Task Validate_WithContextPrefix_AddsErrorsWithCorrectPrefix()
    {
        // Arrange
        var paramInfo = CreateTestParameterInfo(
            parameterType: typeof(int),
            name: "testParam",
            displayName: "Test Parameter",
            validationAttributes: [new RangeAttribute(10, 100)]);
 
        var context = CreateValidatableContext();
        context.CurrentValidationPath = "parent";
 
        // Act
        await paramInfo.ValidateAsync(5, context, default);
 
        // Assert
        var errors = context.ValidationErrors;
        Assert.NotNull(errors);
        var error = Assert.Single(errors);
        Assert.Equal("parent.testParam", error.Key);
        Assert.Equal("The field Test Parameter must be between 10 and 100.", error.Value.First());
    }
 
    [Fact]
    public async Task Validate_ExceptionDuringValidation_CapturesExceptionAsError()
    {
        // Arrange
        var paramInfo = CreateTestParameterInfo(
            parameterType: typeof(string),
            name: "testParam",
            displayName: "Test Parameter",
            validationAttributes: [new ThrowingValidationAttribute()]);
 
        var context = CreateValidatableContext();
 
        // Act
        await paramInfo.ValidateAsync("test", context, default);
 
        // Assert
        var errors = context.ValidationErrors;
        Assert.NotNull(errors);
        var error = Assert.Single(errors);
        Assert.Equal("testParam", error.Key);
        Assert.Equal("Test exception", error.Value.First());
    }
 
    private TestValidatableParameterInfo CreateTestParameterInfo(
        Type parameterType,
        string name,
        string displayName,
        ValidationAttribute[] validationAttributes)
    {
        return new TestValidatableParameterInfo(
            parameterType,
            name,
            displayName,
            validationAttributes);
    }
 
    private ValidateContext CreateValidatableContext(
        Dictionary<Type, ValidatableTypeInfo>? typeMapping = null)
    {
        var serviceProvider = new ServiceCollection().BuildServiceProvider();
        var validationContext = new ValidationContext(new object(), serviceProvider, null);
 
        return new ValidateContext
        {
            ValidationContext = validationContext,
            ValidationOptions = new TestValidationOptions(typeMapping ?? new Dictionary<Type, ValidatableTypeInfo>())
        };
    }
 
    private class TestValidatableParameterInfo : ValidatableParameterInfo
    {
        private readonly ValidationAttribute[] _validationAttributes;
 
        public TestValidatableParameterInfo(
            Type parameterType,
            string name,
            string displayName,
            ValidationAttribute[] validationAttributes)
            : base(parameterType, name, displayName)
        {
            _validationAttributes = validationAttributes;
        }
 
        protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
    }
 
    private class TestValidatablePropertyInfo : ValidatablePropertyInfo
    {
        private readonly ValidationAttribute[] _validationAttributes;
 
        public TestValidatablePropertyInfo(
            Type containingType,
            Type propertyType,
            string name,
            string displayName,
            ValidationAttribute[] validationAttributes)
            : base(containingType, propertyType, name, displayName)
        {
            _validationAttributes = validationAttributes;
        }
 
        protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
    }
 
    private class TestValidatableTypeInfo(
        Type type,
        ValidatablePropertyInfo[] members) : ValidatableTypeInfo(type, members)
    {
    }
 
    private class TestValidationOptions : ValidationOptions
    {
        public TestValidationOptions(Dictionary<Type, ValidatableTypeInfo> typeInfoMappings)
        {
            // Create a custom resolver that uses the dictionary
            var resolver = new DictionaryBasedResolver(typeInfoMappings);
 
            // Add it to the resolvers collection
            Resolvers.Add(resolver);
        }
 
        // Private resolver implementation that uses a dictionary lookup
        private class DictionaryBasedResolver : IValidatableInfoResolver
        {
            private readonly Dictionary<Type, ValidatableTypeInfo> _typeInfoMappings;
 
            public DictionaryBasedResolver(Dictionary<Type, ValidatableTypeInfo> typeInfoMappings)
            {
                _typeInfoMappings = typeInfoMappings;
            }
 
            public ValidatableTypeInfo? TryGetValidatableTypeInfo(Type type)
            {
                _typeInfoMappings.TryGetValue(type, out var info);
                return info;
            }
 
            public ValidatableParameterInfo? GetValidatableParameterInfo(ParameterInfo parameterInfo)
            {
                // Not implemented in the test
                return null;
            }
 
            public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
            {
                if (_typeInfoMappings.TryGetValue(type, out var validatableTypeInfo))
                {
                    validatableInfo = validatableTypeInfo;
                    return true;
                }
                validatableInfo = null;
                return false;
            }
 
            public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
            {
                validatableInfo = null;
                return false;
            }
        }
    }
 
    // Test data classes and validation attributes
 
    private class Person
    {
        public string? Name { get; set; }
    }
 
    private class CustomTestValidationAttribute : ValidationAttribute
    {
        public override bool IsValid(object? value)
        {
            // Always fail for testing
            return false;
        }
    }
 
    private class ThrowingValidationAttribute : ValidationAttribute
    {
        public override bool IsValid(object? value)
        {
            throw new InvalidOperationException("Test exception");
        }
    }
}