File: ModelBinding\Binders\TryParseTypeModelBinderTest.cs
Web Access
Project: src\src\Mvc\Mvc.Core\test\Microsoft.AspNetCore.Mvc.Core.Test.csproj (Microsoft.AspNetCore.Mvc.Core.Test)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
 
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
 
public class TryParseTypeModelBinderTest
{
    public static TheoryData<Type> ConvertibleTypeData
    {
        get
        {
            var data = new TheoryData<Type>
                {
                    typeof(byte),
                    typeof(short),
                    typeof(int),
                    typeof(long),
                    typeof(Guid),
                    typeof(double),
                    typeof(DayOfWeek),
                };
 
            // DateTimeOffset doesn't have a TypeConverter in Mono.
            if (!TestPlatformHelper.IsMono)
            {
                data.Add(typeof(DateTimeOffset));
            }
 
            return data;
        }
    }
 
    [Theory]
    [MemberData(nameof(ConvertibleTypeData))]
    public async Task BindModel_ReturnsFailure_IfTypeCanBeConverted_AndConversionFails(Type destinationType)
    {
        // Arrange
        var bindingContext = GetBindingContext(destinationType);
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", "some-value" }
            };
 
        var binder = CreateBinder(destinationType);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
    }
 
    [Theory]
    [MemberData(nameof(ConvertibleTypeData))]
    public async Task BindModel_CreatesError_WhenTypeConversionIsNull(Type destinationType)
    {
        // Arrange
        var bindingContext = GetBindingContext(destinationType);
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", string.Empty }
            };
        var binder = CreateBinder(destinationType);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
        Assert.Null(bindingContext.Result.Model);
 
        var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
        Assert.Equal("The value '' is invalid.", error.ErrorMessage, StringComparer.Ordinal);
        Assert.Null(error.Exception);
    }
 
    [Fact]
    public async Task BindModel_Error_FormatExceptionsTurnedIntoStringsInModelState()
    {
        // Arrange
        var message = "The value 'not an integer' is not valid.";
        var bindingContext = GetBindingContext(typeof(int));
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", "not an integer" }
            };
 
        var binder = CreateBinder(typeof(int));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
        Assert.Null(bindingContext.Result.Model);
        Assert.False(bindingContext.ModelState.IsValid);
        var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
        Assert.Equal(message, error.ErrorMessage);
    }
 
    public static TheoryData<ModelMetadata> IntegerModelMetadataDataSet
    {
        get
        {
            var metadataProvider = new EmptyModelMetadataProvider();
            var method = typeof(MetadataClass).GetMethod(nameof(MetadataClass.IsLovely));
            var parameter = method.GetParameters()[0]; // IsLovely(int parameter)
 
            return new TheoryData<ModelMetadata>
                {
                    metadataProvider.GetMetadataForParameter(parameter),
                    metadataProvider.GetMetadataForProperty(typeof(MetadataClass), nameof(MetadataClass.Property)),
                    metadataProvider.GetMetadataForType(typeof(int)),
                };
        }
    }
 
    [Theory]
    [MemberData(nameof(IntegerModelMetadataDataSet))]
    public async Task BindModel_EmptyValueProviderResult_ReturnsFailedAndLogsSuccessfully(ModelMetadata metadata)
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(int));
        bindingContext.ModelMetadata = metadata;
 
        var sink = new TestSink();
        var loggerFactory = new TestLoggerFactory(sink, enabled: true);
        var binder = CreateBinder(typeof(int), loggerFactory);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result);
        Assert.Empty(bindingContext.ModelState);
        Assert.Equal(3, sink.Writes.Count());
    }
 
    [Fact]
    public async Task BindModel_NullableIntegerValueProviderResult_ReturnsModel()
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(int?));
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", "12" }
            };
 
        var binder = CreateBinder(typeof(int?));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        Assert.Equal(12, bindingContext.Result.Model);
        Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
    }
 
    [Fact]
    public async Task BindModel_NullableDoubleValueProviderResult_ReturnsModel()
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(double?));
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", "12.5" }
            };
 
        var binder = CreateBinder(typeof(double?));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        Assert.Equal(12.5, bindingContext.Result.Model);
        Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
    }
 
    [Theory]
    [MemberData(nameof(IntegerModelMetadataDataSet))]
    public async Task BindModel_ValidValueProviderResult_ReturnsModelAndLogsSuccessfully(ModelMetadata metadata)
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(int));
        bindingContext.ModelMetadata = metadata;
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", "42" }
            };
 
        var sink = new TestSink();
        var loggerFactory = new TestLoggerFactory(sink, enabled: true);
        var binder = CreateBinder(typeof(int), loggerFactory);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        Assert.Equal(42, bindingContext.Result.Model);
        Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
        Assert.Equal(2, sink.Writes.Count());
    }
 
    public static TheoryData<Type> BiggerNumericTypes
    {
        get
        {
            // Data set does not include bool, byte, sbyte, or char because they do not need thousands separators.
            // Also does not include float point types  (eg.:  decimal, double, float) because for those we will use
            // NumberStyles.Number that allow thousands operator
            return new TheoryData<Type>
                {
                    typeof(int),
                    typeof(long),
                    typeof(short),
                    typeof(uint),
                    typeof(ulong),
                    typeof(ushort),
                };
        }
    }
 
    [Theory]
    [MemberData(nameof(BiggerNumericTypes))]
    public async Task BindModel_ThousandsSeparators_LeadToErrors(Type type)
    {
        // Arrange
        var bindingContext = GetBindingContext(type);
        bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
            {
                { "theModelName", "32,000" }
            };
 
        var binder = CreateBinder(type);
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
 
        var entry = Assert.Single(bindingContext.ModelState);
        Assert.Equal("theModelName", entry.Key);
        Assert.Equal("32,000", entry.Value.AttemptedValue);
        Assert.Equal(ModelValidationState.Invalid, entry.Value.ValidationState);
 
        var error = Assert.Single(entry.Value.Errors);
        Assert.Equal("The value '32,000' is not valid.", error.ErrorMessage);
        Assert.Null(error.Exception);
    }
 
    [Fact]
    public async Task BindModel_ValidValueProviderResultWithProvidedCulture_ReturnsModel()
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(decimal));
        bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR"))
            {
                { "theModelName", "12,5" }
            };
 
        var binder = CreateBinder(typeof(decimal));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        Assert.Equal(12.5M, bindingContext.Result.Model);
        Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
    }
 
    [Fact]
    public async Task BindModel_CreatesErrorForFormatException_ValueProviderResultWithInvalidCulture()
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(decimal));
        bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
            {
                { "theModelName", "12-5" }
            };
 
        var binder = CreateBinder(typeof(decimal));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
        Assert.Null(bindingContext.Result.Model);
 
        var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
        Assert.Equal("The value '12-5' is not valid.", error.ErrorMessage, StringComparer.Ordinal);
        Assert.Null(error.Exception);
    }
 
    [Fact]
    public async Task BindModel_BindsEnumModels_IfArrayElementIsStringKey()
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(IntEnum));
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", new object[] { "Value1" } }
            };
 
        var binder = CreateBinder(typeof(IntEnum));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        var boundModel = Assert.IsType<IntEnum>(bindingContext.Result.Model);
        Assert.Equal(IntEnum.Value1, boundModel);
    }
 
    [Fact]
    public async Task BindModel_BindsEnumModels_IfArrayElementIsStringValue()
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(IntEnum));
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", new object[] { "1" } }
            };
 
        var binder = CreateBinder(typeof(IntEnum));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        var boundModel = Assert.IsType<IntEnum>(bindingContext.Result.Model);
        Assert.Equal(IntEnum.Value1, boundModel);
    }
 
    public static TheoryData<string, int> EnumValues
    {
        get
        {
            return new TheoryData<string, int>
                {
                    { "0", 0 },
                    { "1", 1 },
                    { "13", 13 },
                    { "Value1", 1 },
                    { "Value1, Value2", 3 },
                };
        }
    }
 
    [Theory]
    [MemberData(nameof(EnumValues))]
    public async Task BindModel_BindsIntEnumModels(string flagsEnumValue, int expected)
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(IntEnum));
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", flagsEnumValue }
            };
 
        var binder = CreateBinder(typeof(IntEnum));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        var boundModel = Assert.IsType<IntEnum>(bindingContext.Result.Model);
        Assert.Equal((IntEnum)expected, boundModel);
    }
 
    [Theory]
    [InlineData("32,015")]
    [InlineData("32,128")]
    public async Task BindModel_CreatesErrorForFormatException_BindsIntEnumModels(string flagsEnumValue)
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(FlagsEnum));
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", flagsEnumValue }
            };
 
        var binder = CreateBinder(typeof(FlagsEnum));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Different than the Converter when calling the TryParse the values will
        // NOT be treat as two separate enum values that are or'd together
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
        var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
        Assert.Equal($"The value '{flagsEnumValue}' is not valid.", error.ErrorMessage, StringComparer.Ordinal);
        Assert.Null(error.Exception);
    }
 
    [Theory]
    [InlineData("~10~", 10)]
    [InlineData("~5", 5)]
    [InlineData("aaaa~1~aaaa", 1)]
    public async Task BindModel_BindClassWithTryParseMethod(string value, int expected)
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(TestTryParseClass));
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", value }
            };
 
        var binder = CreateBinder(typeof(TestTryParseClass));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        var boundModel = Assert.IsType<TestTryParseClass>(bindingContext.Result.Model);
        Assert.Equal(expected, boundModel.Id);
    }
 
    [Theory]
    [InlineData("10")]
    [InlineData("~~0")]
    public async Task BindModel_CreatesErrorForFormatException_BindClassWithTryParseMethod(string value)
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(TestTryParseClass));
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", value }
            };
 
        var binder = CreateBinder(typeof(TestTryParseClass));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Different than the Converter when calling the TryParse the values will
        // NOT be treat as two separate enum values that are or'd together
        // Assert
        Assert.False(bindingContext.Result.IsModelSet);
        var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors);
        Assert.Equal($"The value '{value}' is not valid.", error.ErrorMessage, StringComparer.Ordinal);
        Assert.Null(error.Exception);
    }
 
    [Theory]
    [MemberData(nameof(EnumValues))]
    [InlineData("Value8, Value4", 12)]
    public async Task BindModel_BindsFlagsEnumModels(string flagsEnumValue, int expected)
    {
        // Arrange
        var bindingContext = GetBindingContext(typeof(FlagsEnum));
        bindingContext.ValueProvider = new SimpleValueProvider
            {
                { "theModelName", flagsEnumValue }
            };
 
        var binder = CreateBinder(typeof(FlagsEnum));
 
        // Act
        await binder.BindModelAsync(bindingContext);
 
        // Assert
        Assert.True(bindingContext.Result.IsModelSet);
        var boundModel = Assert.IsType<FlagsEnum>(bindingContext.Result.Model);
        Assert.Equal((FlagsEnum)expected, boundModel);
    }
 
    [Fact]
    public void BindModel_ThrowsInvalidOperationException_WhenTryParseNotFound()
    {
        // Act & assert
        Assert.Throws<InvalidOperationException>(() => new TryParseModelBinder(typeof(TestClass), NullLoggerFactory.Instance));
    }
 
    private static DefaultModelBindingContext GetBindingContext(Type modelType)
    {
        return new DefaultModelBindingContext
        {
            ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(modelType),
            ModelName = "theModelName",
            ModelState = new ModelStateDictionary(),
            ValueProvider = new SimpleValueProvider() // empty
        };
    }
 
    private static IModelBinder CreateBinder(Type modelType, ILoggerFactory loggerFactory = null) =>
        new TryParseModelBinder(modelType, loggerFactory ?? NullLoggerFactory.Instance);
 
    private sealed class TestClass
    {
    }
 
    private sealed class TestTryParseClass
    {
        public int? Id { get; set; }
 
        public static bool TryParse(string s, out TestTryParseClass result)
        {
            result = new TestTryParseClass();
 
            if (!string.IsNullOrWhiteSpace(s))
            {
                var tokens = s.Split('~');
 
                if (tokens.Length >= 2)
                {
                    result.Id = int.Parse(tokens[1], CultureInfo.CurrentCulture);
                    return true;
                }
            }
 
            return false;
 
        }
    }
 
    [Flags]
    private enum FlagsEnum
    {
        Value1 = 1,
        Value2 = 2,
        Value4 = 4,
        Value8 = 8,
    }
 
    private enum IntEnum
    {
        Value0 = 0,
        Value1 = 1,
        Value2 = 2,
        MaxValue = int.MaxValue
    }
 
    private class MetadataClass
    {
        public int Property { get; set; }
 
        public bool IsLovely(int parameter)
        {
            return true;
        }
    }
}