// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq.Expressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; using Microsoft.AspNetCore.Mvc.ModelBinding.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Testing; using Moq; using Xunit; namespace Microsoft.AspNetCore.Mvc.ModelBinding { public class ModelBindingHelperTest { [Fact] public async Task TryUpdateModel_ReturnsFalse_IfBinderIsUnsuccessful() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); var binder = new StubModelBinder(ModelBindingResult.Failed()); var model = new MyModel(); // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, string.Empty, new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider, GetModelBinderFactory(binder), Mock.Of(), new Mock(MockBehavior.Strict).Object); // Assert Assert.False(result); Assert.Null(model.MyProperty); } [Fact] public async Task TryUpdateModel_ReturnsFalse_IfModelValidationFails() { // Arrange var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), new ComplexTypeModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( new ValidationAttributeAdapterProvider(), new TestOptionsManager(), stringLocalizerFactory: null); var model = new MyModel(); var values = new Dictionary { { "", null } }; var valueProvider = new TestValueProvider(values); var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var actionContext = new ActionContext() { HttpContext = new DefaultHttpContext() }; var modelState = actionContext.ModelState; // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, "", actionContext, metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, new DefaultObjectValidator(metadataProvider, new[] { validator })); // Assert Assert.False(result); var error = Assert.Single(modelState["MyProperty"].Errors); Assert.Equal(ValidationAttributeUtil.GetRequiredErrorMessage("MyProperty"), error.ErrorMessage); } [Fact] public async Task TryUpdateModel_ReturnsTrue_IfModelBindsAndValidatesSuccessfully() { // Arrange var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), new ComplexTypeModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( new ValidationAttributeAdapterProvider(), new TestOptionsManager(), stringLocalizerFactory: null); var model = new MyModel { MyProperty = "Old-Value" }; var values = new Dictionary { { "", null }, { "MyProperty", "MyPropertyValue" } }; var valueProvider = new TestValueProvider(values); var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, "", new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, new DefaultObjectValidator(metadataProvider, new[] { validator })); // Assert Assert.True(result); Assert.Equal("MyPropertyValue", model.MyProperty); } [Fact] public async Task TryUpdateModel_UsingPropertyFilterOverload_ReturnsFalse_IfBinderIsUnsuccessful() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); var binder = new StubModelBinder(ModelBindingResult.Failed()); var model = new MyModel(); Func propertyFilter = (m) => true; // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, string.Empty, new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider, GetModelBinderFactory(binder), Mock.Of(), new Mock(MockBehavior.Strict).Object, propertyFilter); // Assert Assert.False(result); Assert.Null(model.MyProperty); Assert.Null(model.IncludedProperty); Assert.Null(model.ExcludedProperty); } [Fact] public async Task TryUpdateModel_UsingPropertyFilterOverload_ReturnsTrue_ModelBindsAndValidatesSuccessfully() { // Arrange var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), new ComplexTypeModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( new ValidationAttributeAdapterProvider(), new TestOptionsManager(), stringLocalizerFactory: null); var model = new MyModel { MyProperty = "Old-Value", IncludedProperty = "Old-IncludedPropertyValue", ExcludedProperty = "Old-ExcludedPropertyValue" }; var values = new Dictionary { { "", null }, { "MyProperty", "MyPropertyValue" }, { "IncludedProperty", "IncludedPropertyValue" }, { "ExcludedProperty", "ExcludedPropertyValue" } }; Func propertyFilter = (m) => string.Equals(m.PropertyName, "IncludedProperty", StringComparison.OrdinalIgnoreCase) || string.Equals(m.PropertyName, "MyProperty", StringComparison.OrdinalIgnoreCase); var valueProvider = new TestValueProvider(values); var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, "", new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, new DefaultObjectValidator(metadataProvider, new[] { validator }), propertyFilter); // Assert Assert.True(result); Assert.Equal("MyPropertyValue", model.MyProperty); Assert.Equal("IncludedPropertyValue", model.IncludedProperty); Assert.Equal("Old-ExcludedPropertyValue", model.ExcludedProperty); } [Fact] public async Task TryUpdateModel_UsingIncludeExpressionOverload_ReturnsFalse_IfBinderIsUnsuccessful() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); var binder = new StubModelBinder(ModelBindingResult.Failed()); var model = new MyModel(); // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, string.Empty, new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider, GetModelBinderFactory(binder), Mock.Of(), new Mock(MockBehavior.Strict).Object, m => m.IncludedProperty); // Assert Assert.False(result); Assert.Null(model.MyProperty); Assert.Null(model.IncludedProperty); Assert.Null(model.ExcludedProperty); } [Fact] public async Task TryUpdateModel_UsingIncludeExpressionOverload_ReturnsTrue_ModelBindsAndValidatesSuccessfully() { // Arrange var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), new ComplexTypeModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( new ValidationAttributeAdapterProvider(), new TestOptionsManager(), stringLocalizerFactory: null); var model = new MyModel { MyProperty = "Old-Value", IncludedProperty = "Old-IncludedPropertyValue", ExcludedProperty = "Old-ExcludedPropertyValue" }; var values = new Dictionary { { "", null }, { "MyProperty", "MyPropertyValue" }, { "IncludedProperty", "IncludedPropertyValue" }, { "ExcludedProperty", "ExcludedPropertyValue" } }; var valueProvider = new TestValueProvider(values); var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, "", new ActionContext() { HttpContext = new DefaultHttpContext() }, TestModelMetadataProvider.CreateDefaultProvider(), GetModelBinderFactory(binderProviders), valueProvider, new DefaultObjectValidator(metadataProvider, new[] { validator }), m => m.IncludedProperty, m => m.MyProperty); // Assert Assert.True(result); Assert.Equal("MyPropertyValue", model.MyProperty); Assert.Equal("IncludedPropertyValue", model.IncludedProperty); Assert.Equal("Old-ExcludedPropertyValue", model.ExcludedProperty); } [Fact] public async Task TryUpdateModel_UsingDefaultIncludeOverload_IncludesAllProperties() { // Arrange var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), new ComplexTypeModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( new ValidationAttributeAdapterProvider(), new TestOptionsManager(), stringLocalizerFactory: null); var model = new MyModel { MyProperty = "Old-Value", IncludedProperty = "Old-IncludedPropertyValue", ExcludedProperty = "Old-ExcludedPropertyValue" }; var values = new Dictionary { { "", null }, { "MyProperty", "MyPropertyValue" }, { "IncludedProperty", "IncludedPropertyValue" }, { "ExcludedProperty", "ExcludedPropertyValue" } }; var valueProvider = new TestValueProvider(values); var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, "", new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, new DefaultObjectValidator(metadataProvider, new[] { validator })); // Assert // Includes everything. Assert.True(result); Assert.Equal("MyPropertyValue", model.MyProperty); Assert.Equal("IncludedPropertyValue", model.IncludedProperty); Assert.Equal("ExcludedPropertyValue", model.ExcludedProperty); } [Fact] public void GetPropertyName_PropertyMemberAccessReturnsPropertyName() { // Arrange Expression> expression = m => m.Address; // Act var propertyName = ModelBindingHelper.GetPropertyName(expression.Body); // Assert Assert.Equal(nameof(User.Address), propertyName); } [Fact] public void GetPropertyName_ChainedExpression_Throws() { // Arrange Expression> expression = m => m.Address.Street; // Act & Assert var ex = Assert.Throws(() => ModelBindingHelper.GetPropertyName(expression.Body)); Assert.Equal(string.Format("The passed expression of expression node type '{0}' is invalid." + " Only simple member access expressions for model properties are supported.", expression.Body.NodeType), ex.Message); } public static IEnumerable InvalidExpressionDataSet { get { Expression> expression = m => new Func(() => m); yield return new object[] { expression }; // lambda expression. expression = m => m.Save(); yield return new object[] { expression }; // method call expression. expression = m => m.Friends[0]; // ArrayIndex expression. yield return new object[] { expression }; expression = m => m.Colleagues[0]; // Indexer expression. yield return new object[] { expression }; expression = m => m; // Parameter expression. yield return new object[] { expression }; object someVariable = "something"; expression = m => someVariable; // Variable accessor. yield return new object[] { expression }; } } [Theory] [MemberData(nameof(InvalidExpressionDataSet))] public void GetPropertyName_ExpressionsOtherThanMemberAccess_Throws(Expression> expression) { // Arrange Act & Assert var ex = Assert.Throws(() => ModelBindingHelper.GetPropertyName(expression.Body)); Assert.Equal( $"The passed expression of expression node type '{expression.Body.NodeType}' is invalid." + " Only simple member access expressions for model properties are supported.", ex.Message); } [Fact] public void GetPropertyName_NonParameterBasedExpression_Throws() { // Arrange var someUser = new User(); // PropertyAccessor with a property name invalid as it originates from a variable accessor. Expression> expression = m => someUser.Address; // Act & Assert var ex = Assert.Throws(() => ModelBindingHelper.GetPropertyName(expression.Body)); Assert.Equal( $"The passed expression of expression node type '{expression.Body.NodeType}' is invalid." + " Only simple member access expressions for model properties are supported.", ex.Message); } [Fact] public void GetPropertyName_TopLevelCollectionIndexer_Throws() { // Arrange Expression, object>> expression = m => m[0]; // Act & Assert var ex = Assert.Throws(() => ModelBindingHelper.GetPropertyName(expression.Body)); Assert.Equal( $"The passed expression of expression node type '{expression.Body.NodeType}' is invalid." + " Only simple member access expressions for model properties are supported.", ex.Message); } [Fact] public void GetPropertyName_FieldExpression_Throws() { // Arrange Expression> expression = m => m._userId; // Act & Assert var ex = Assert.Throws(() => ModelBindingHelper.GetPropertyName(expression.Body)); Assert.Equal( $"The passed expression of expression node type '{expression.Body.NodeType}' is invalid." + " Only simple member access expressions for model properties are supported.", ex.Message); } [Fact] public async Task TryUpdateModelNonGeneric_PropertyFilterOverload_ReturnsFalse_IfBinderIsUnsuccessful() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); var binder = new StubModelBinder(ModelBindingResult.Failed()); var model = new MyModel(); Func propertyFilter = (m) => true; // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, model.GetType(), prefix: "", actionContext: new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider: metadataProvider, modelBinderFactory: GetModelBinderFactory(binder), valueProvider: Mock.Of(), objectModelValidator: new Mock(MockBehavior.Strict).Object, propertyFilter: propertyFilter); // Assert Assert.False(result); Assert.Null(model.MyProperty); Assert.Null(model.IncludedProperty); Assert.Null(model.ExcludedProperty); } [Fact] public async Task TryUpdateModelNonGeneric_PropertyFilterOverload_ReturnsTrue_ModelBindsAndValidatesSuccessfully() { // Arrange var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), new ComplexTypeModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( new ValidationAttributeAdapterProvider(), new TestOptionsManager(), stringLocalizerFactory: null); var model = new MyModel { MyProperty = "Old-Value", IncludedProperty = "Old-IncludedPropertyValue", ExcludedProperty = "Old-ExcludedPropertyValue" }; var values = new Dictionary { { "", null }, { "MyProperty", "MyPropertyValue" }, { "IncludedProperty", "IncludedPropertyValue" }, { "ExcludedProperty", "ExcludedPropertyValue" } }; Func propertyFilter = (m) => string.Equals(m.PropertyName, "IncludedProperty", StringComparison.OrdinalIgnoreCase) || string.Equals(m.PropertyName, "MyProperty", StringComparison.OrdinalIgnoreCase); var valueProvider = new TestValueProvider(values); var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, model.GetType(), "", new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, new DefaultObjectValidator(metadataProvider, new[] { validator }), propertyFilter); // Assert Assert.True(result); Assert.Equal("MyPropertyValue", model.MyProperty); Assert.Equal("IncludedPropertyValue", model.IncludedProperty); Assert.Equal("Old-ExcludedPropertyValue", model.ExcludedProperty); } [Fact] public async Task TryUpdateModelNonGeneric_ModelTypeOverload_ReturnsFalse_IfBinderIsUnsuccessful() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); var binder = new StubModelBinder(ModelBindingResult.Failed()); var model = new MyModel(); // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, modelType: model.GetType(), prefix: "", actionContext: new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider: metadataProvider, modelBinderFactory: GetModelBinderFactory(binder.Object), valueProvider: Mock.Of(), objectModelValidator: new Mock(MockBehavior.Strict).Object); // Assert Assert.False(result); Assert.Null(model.MyProperty); } [Fact] public async Task TryUpdateModelNonGeneric_ModelTypeOverload_ReturnsTrue_IfModelBindsAndValidatesSuccessfully() { // Arrange var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), new ComplexTypeModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( new ValidationAttributeAdapterProvider(), new TestOptionsManager(), stringLocalizerFactory: null); var model = new MyModel { MyProperty = "Old-Value" }; var values = new Dictionary { { "", null }, { "MyProperty", "MyPropertyValue" } }; var valueProvider = new TestValueProvider(values); var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); // Act var result = await ModelBindingHelper.TryUpdateModelAsync( model, model.GetType(), "", new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider, GetModelBinderFactory(binderProviders), valueProvider, new DefaultObjectValidator(metadataProvider, new[] { validator })); // Assert Assert.True(result); Assert.Equal("MyPropertyValue", model.MyProperty); } [Fact] public async Task TryUpdataModel_ModelTypeDifferentFromModel_Throws() { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); var binder = new StubModelBinder(); var model = new MyModel(); Func propertyFilter = (m) => true; var modelName = model.GetType().FullName; var userName = typeof(User).FullName; var expectedMessage = $"The model's runtime type '{modelName}' is not assignable to the type '{userName}'."; // Act & Assert var exception = await ExceptionAssert.ThrowsArgumentAsync( () => ModelBindingHelper.TryUpdateModelAsync( model, typeof(User), "", new ActionContext() { HttpContext = new DefaultHttpContext() }, metadataProvider, GetModelBinderFactory(binder.Object), Mock.Of(), new Mock(MockBehavior.Strict).Object, propertyFilter), "modelType", expectedMessage); } [Theory] [InlineData("")] [InlineData(null)] public void ClearValidationState_ForComplexTypeModel_EmptyModelKey(string modelKey) { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); var modelMetadata = metadataProvider.GetMetadataForType(typeof(Product)); var dictionary = new ModelStateDictionary(); dictionary.AddModelError("Name", "MyProperty invalid."); dictionary.AddModelError("Id", "Id invalid."); dictionary.AddModelError("Id", "Id is required."); dictionary.MarkFieldValid("Category"); dictionary.AddModelError("Unrelated", "Unrelated is required."); // Act ModelBindingHelper.ClearValidationStateForModel(modelMetadata, dictionary, modelKey); // Assert Assert.Equal(0, dictionary["Name"].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary["Name"].ValidationState); Assert.Equal(0, dictionary["Id"].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary["Id"].ValidationState); Assert.Equal(0, dictionary["Category"].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary["Category"].ValidationState); Assert.Equal(1, dictionary["Unrelated"].Errors.Count); Assert.Equal(ModelValidationState.Invalid, dictionary["Unrelated"].ValidationState); } // Not a wholly realistic scenario, but testing it regardless. [Theory] [InlineData("")] [InlineData(null)] public void ClearValidationState_ForSimpleTypeModel_EmptyModelKey(string modelKey) { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); var modelMetadata = metadataProvider.GetMetadataForType(typeof(string)); var dictionary = new ModelStateDictionary(); dictionary.AddModelError(string.Empty, "MyProperty invalid."); dictionary.AddModelError("Unrelated", "Unrelated is required."); // Act ModelBindingHelper.ClearValidationStateForModel(modelMetadata, dictionary, modelKey); // Assert Assert.Equal(0, dictionary[string.Empty].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary[string.Empty].ValidationState); Assert.Equal(1, dictionary["Unrelated"].Errors.Count); Assert.Equal(ModelValidationState.Invalid, dictionary["Unrelated"].ValidationState); } [Theory] [InlineData("")] [InlineData(null)] public void ClearValidationState_ForCollectionsModel_EmptyModelKey(string modelKey) { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); var modelMetadata = metadataProvider.GetMetadataForType(typeof(List)); var dictionary = new ModelStateDictionary(); dictionary.AddModelError("[0].Name", "Name invalid."); dictionary.AddModelError("[0].Id", "Id invalid."); dictionary.AddModelError("[0].Id", "Id required."); dictionary.MarkFieldValid("[0].Category"); dictionary.MarkFieldValid("[1].Name"); dictionary.MarkFieldValid("[1].Id"); dictionary.AddModelError("[1].Category", "Category invalid."); dictionary.AddModelError("Unrelated", "Unrelated is required."); // Act ModelBindingHelper.ClearValidationStateForModel(modelMetadata, dictionary, modelKey); // Assert Assert.Equal(0, dictionary["[0].Name"].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary["[0].Name"].ValidationState); Assert.Equal(0, dictionary["[0].Id"].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary["[0].Id"].ValidationState); Assert.Equal(0, dictionary["[0].Category"].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary["[0].Category"].ValidationState); Assert.Equal(0, dictionary["[1].Name"].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary["[1].Name"].ValidationState); Assert.Equal(0, dictionary["[1].Id"].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary["[1].Id"].ValidationState); Assert.Equal(0, dictionary["[1].Category"].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary["[1].Category"].ValidationState); Assert.Equal(1, dictionary["Unrelated"].Errors.Count); Assert.Equal(ModelValidationState.Invalid, dictionary["Unrelated"].ValidationState); } [Theory] [InlineData("product")] [InlineData("product.Name")] [InlineData("product.Order[0].Name")] [InlineData("product.Order[0].Address.Street")] [InlineData("product.Category.Name")] [InlineData("product.Order")] public void ClearValidationState_ForComplexModel_NonEmptyModelKey(string prefix) { // Arrange var metadataProvider = new EmptyModelMetadataProvider(); var modelMetadata = metadataProvider.GetMetadataForType(typeof(Product)); var dictionary = new ModelStateDictionary(); dictionary.AddModelError("product.Name", "Name invalid."); dictionary.AddModelError("product.Id", "Id invalid."); dictionary.AddModelError("product.Id", "Id required."); dictionary.MarkFieldValid("product.Category"); dictionary.MarkFieldValid("product.Category.Name"); dictionary.AddModelError("product.Order[0].Name", "Order name invalid."); dictionary.AddModelError("product.Order[0].Address.Street", "Street invalid."); dictionary.MarkFieldValid("product.Order[1].Name"); dictionary.AddModelError("product.Order[0]", "Order invalid."); // Act ModelBindingHelper.ClearValidationStateForModel(modelMetadata, dictionary, prefix); // Assert foreach (var entry in dictionary.Keys) { if (entry.StartsWith(prefix)) { Assert.Equal(0, dictionary[entry].Errors.Count); Assert.Equal(ModelValidationState.Unvalidated, dictionary[entry].ValidationState); } } } public static ModelBinderFactory GetModelBinderFactory(IModelBinder binder) { var binderProvider = new Mock(); binderProvider .Setup(p => p.GetBinder(It.IsAny())) .Returns(binder); return TestModelBinderFactory.Create(binderProvider.Object); } private static ModelBinderFactory GetModelBinderFactory(params IModelBinderProvider[] providers) { return TestModelBinderFactory.CreateDefault(providers); } public class User { public string _userId; public Address Address { get; set; } public User[] Friends { get; set; } public List Colleagues { get; set; } public bool IsReadOnly { get { throw new NotImplementedException(); } } public User Save() { return this; } } public class Address { public string Street { get; set; } } private class MyModel { [Required] public string MyProperty { get; set; } public string IncludedProperty { get; set; } public string ExcludedProperty { get; set; } } private class Product { public string Name { get; set; } public int Id { get; set; } public Category Category { get; set; } public List Orders { get; set; } } public class Category { public string Name { get; set; } } public class Order { public string Name { get; set; } public Address Address { get; set; } } [Fact] public void ConvertTo_ReturnsNullForReferenceTypes_WhenValueIsNull() { var convertedValue = ModelBindingHelper.ConvertTo(value: null, type: typeof(string), culture: null); Assert.Null(convertedValue); } [Fact] public void ConvertTo_ReturnsDefaultForValueTypes_WhenValueIsNull() { var convertedValue = ModelBindingHelper.ConvertTo(value: null, type: typeof(int), culture: null); Assert.Equal(0, convertedValue); } [Fact] public void ConvertToCanConvertArraysToSingleElements() { // Arrange var value = new int[] { 1, 20, 42 }; // Act var converted = ModelBindingHelper.ConvertTo(value, typeof(string), culture: null); // Assert Assert.Equal("1", converted); } [Fact] public void ConvertToCanConvertSingleElementsToArrays() { // Arrange var value = 42; // Act var converted = ModelBindingHelper.ConvertTo(value, culture: null); // Assert Assert.NotNull(converted); var result = Assert.Single(converted); Assert.Equal("42", result); } [Fact] public void ConvertToCanConvertSingleElementsToSingleElements() { // Arrange // Act var converted = ModelBindingHelper.ConvertTo(42, culture: null); // Assert Assert.NotNull(converted); Assert.Equal("42", converted); } [Fact] public void ConvertingNullStringToNullableIntReturnsNull() { // Arrange // Act var returned = ModelBindingHelper.ConvertTo(value: null, culture: null); // Assert Assert.Null(returned); } [Fact] public void ConvertingWhiteSpaceStringToNullableIntReturnsNull() { // Arrange var original = " "; // Act var returned = ModelBindingHelper.ConvertTo(original, culture: null); // Assert Assert.Null(returned); } [Fact] public void ConvertToReturnsNullIfArrayElementValueIsNull() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(new string[] { null }, typeof(int), culture: null); // Assert Assert.Null(outValue); } [Fact] public void ConvertToReturnsNullIfTryingToConvertEmptyArrayToSingleElement() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(new int[0], typeof(int), culture: null); // Assert Assert.Null(outValue); } [Theory] [InlineData("")] [InlineData(" \t \r\n ")] public void ConvertToReturnsNullIfTrimmedValueIsEmptyString(object value) { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(value, typeof(int), culture: null); // Assert Assert.Null(outValue); } [Fact] public void ConvertToReturnsNull_IfConvertingNullToArrayType() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(value: null, type: typeof(int[]), culture: null); // Assert Assert.Null(outValue); } [Fact] public void ConvertToReturnsValueIfArrayElementIsIntegerAndDestinationTypeIsEnum() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(new object[] { 1 }, typeof(IntEnum), culture: null); // Assert Assert.Equal(IntEnum.Value1, outValue); } [Theory] [InlineData(1, typeof(IntEnum), IntEnum.Value1)] [InlineData(1L, typeof(LongEnum), LongEnum.Value1)] [InlineData(long.MaxValue, typeof(LongEnum), LongEnum.MaxValue)] [InlineData(1U, typeof(UnsignedIntEnum), UnsignedIntEnum.Value1)] [InlineData(1UL, typeof(IntEnum), IntEnum.Value1)] [InlineData((byte)1, typeof(ByteEnum), ByteEnum.Value1)] [InlineData(byte.MaxValue, typeof(ByteEnum), ByteEnum.MaxValue)] [InlineData((sbyte)1, typeof(ByteEnum), ByteEnum.Value1)] [InlineData((short)1, typeof(IntEnum), IntEnum.Value1)] [InlineData((ushort)1, typeof(IntEnum), IntEnum.Value1)] [InlineData(int.MaxValue, typeof(IntEnum?), IntEnum.MaxValue)] [InlineData(null, typeof(IntEnum?), null)] [InlineData(1L, typeof(LongEnum?), LongEnum.Value1)] [InlineData(null, typeof(LongEnum?), null)] [InlineData(uint.MaxValue, typeof(UnsignedIntEnum?), UnsignedIntEnum.MaxValue)] [InlineData((byte)1, typeof(ByteEnum?), ByteEnum.Value1)] [InlineData(null, typeof(ByteEnum?), null)] [InlineData((ushort)1, typeof(LongEnum?), LongEnum.Value1)] public void ConvertToReturnsValueIfArrayElementIsAnyIntegerTypeAndDestinationTypeIsEnum( object input, Type enumType, object expected) { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(new object[] { input }, enumType, culture: null); // Assert Assert.Equal(expected, outValue); } [Fact] public void ConvertToReturnsValueIfArrayElementIsStringValueAndDestinationTypeIsEnum() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(new object[] { "1" }, typeof(IntEnum), culture: null); // Assert Assert.Equal(IntEnum.Value1, outValue); } [Fact] public void ConvertToReturnsValueIfArrayElementIsStringKeyAndDestinationTypeIsEnum() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(new object[] { "Value1" }, typeof(IntEnum), culture: null); // Assert Assert.Equal(IntEnum.Value1, outValue); } [Fact] public void ConvertToReturnsValueIfElementIsStringAndDestinationIsNullableInteger() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo("12", typeof(int?), culture: null); // Assert Assert.Equal(12, outValue); } [Fact] public void ConvertToReturnsValueIfElementIsStringAndDestinationIsNullableDouble() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo("12.5", typeof(double?), culture: null); // Assert Assert.Equal(12.5, outValue); } [Fact] public void ConvertToReturnsValueIfElementIsDecimalAndDestinationIsNullableInteger() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(12M, typeof(int?), culture: null); // Assert Assert.Equal(12, outValue); } [Fact] public void ConvertToReturnsValueIfElementIsDecimalAndDestinationIsNullableDouble() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(12.5M, typeof(double?), culture: null); // Assert Assert.Equal(12.5, outValue); } [Fact] public void ConvertToReturnsValueIfElementIsDecimalDoubleAndDestinationIsNullableInteger() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(12M, typeof(int?), culture: null); // Assert Assert.Equal(12, outValue); } [Fact] public void ConvertToReturnsValueIfElementIsDecimalDoubleAndDestinationIsNullableLong() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(12M, typeof(long?), culture: null); // Assert Assert.Equal(12L, outValue); } [Fact] public void ConvertToReturnsValueIfArrayElementInstanceOfDestinationType() { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(new object[] { "some string" }, typeof(string), culture: null); // Assert Assert.Equal("some string", outValue); } [Theory] [InlineData(new object[] { new object[] { 1, 0 } })] [InlineData(new object[] { new[] { "Value1", "Value0" } })] [InlineData(new object[] { new[] { "Value1", "value0" } })] public void ConvertTo_ConvertsEnumArrays(object value) { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(value, typeof(IntEnum[]), culture: null); // Assert var result = Assert.IsType(outValue); Assert.Equal(2, result.Length); Assert.Equal(IntEnum.Value1, result[0]); Assert.Equal(IntEnum.Value0, result[1]); } [Theory] [InlineData(new object[] { new object[] { 1, 2 }, new[] { FlagsEnum.Value1, FlagsEnum.Value2 } })] [InlineData(new object[] { new[] { "Value1", "Value2" }, new[] { FlagsEnum.Value1, FlagsEnum.Value2 } })] [InlineData(new object[] { new object[] { 5, 2 }, new[] { FlagsEnum.Value1 | FlagsEnum.Value4, FlagsEnum.Value2 } })] public void ConvertTo_ConvertsFlagsEnumArrays(object value, FlagsEnum[] expected) { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(value, typeof(FlagsEnum[]), culture: null); // Assert var result = Assert.IsType(outValue); Assert.Equal(2, result.Length); Assert.Equal(expected[0], result[0]); Assert.Equal(expected[1], result[1]); } [Fact] public void ConvertToReturnsValueIfInstanceOfDestinationType() { // Arrange var original = new[] { "some string" }; // Act var outValue = ModelBindingHelper.ConvertTo(original, typeof(string[]), culture: null); // Assert Assert.Same(original, outValue); } [Theory] [InlineData(typeof(int))] [InlineData(typeof(double?))] [InlineData(typeof(IntEnum?))] public void ConvertToThrowsIfConverterThrows(Type destinationType) { // Arrange // Act & Assert var ex = Assert.Throws( () => ModelBindingHelper.ConvertTo("this-is-not-a-valid-value", destinationType, culture: null)); } [Fact] public void ConvertToUsesProvidedCulture() { // Arrange // Act var cultureResult = ModelBindingHelper.ConvertTo("12,5", typeof(decimal), new CultureInfo("fr-FR")); // Assert Assert.Equal(12.5M, cultureResult); Assert.Throws( () => ModelBindingHelper.ConvertTo("12,5", typeof(decimal), new CultureInfo("en-GB"))); } [Theory] [MemberData(nameof(IntrinsicConversionData))] public void ConvertToCanConvertIntrinsics(object initialValue, T expectedValue) { // Arrange // Act & Assert Assert.Equal(expectedValue, ModelBindingHelper.ConvertTo(initialValue, typeof(T), culture: null)); } public static IEnumerable IntrinsicConversionData { get { yield return new object[] { 42, 42L }; yield return new object[] { 42, (short)42 }; yield return new object[] { 42, (float)42.0 }; yield return new object[] { 42, (double)42.0 }; yield return new object[] { 42M, 42 }; yield return new object[] { 42L, 42 }; yield return new object[] { 42, (byte)42 }; yield return new object[] { (short)42, 42 }; yield return new object[] { (float)42.0, 42 }; yield return new object[] { (double)42.0, 42 }; yield return new object[] { (byte)42, 42 }; yield return new object[] { "2008-01-01", new DateTime(2008, 01, 01) }; yield return new object[] { "00:00:20", TimeSpan.FromSeconds(20) }; yield return new object[] { "c6687d3a-51f9-4159-8771-a66d2b7d7038", Guid.Parse("c6687d3a-51f9-4159-8771-a66d2b7d7038") }; } } // None of the types here have converters from MyClassWithoutConverter. [Theory] [InlineData(typeof(TimeSpan))] [InlineData(typeof(DateTime))] [InlineData(typeof(DateTimeOffset))] [InlineData(typeof(Guid))] [InlineData(typeof(IntEnum))] public void ConvertTo_Throws_IfValueIsNotConvertible(Type destinationType) { // Arrange var expectedMessage = $"The parameter conversion from type '{typeof(MyClassWithoutConverter)}' to type " + $"'{destinationType}' failed because no type converter can convert between these types."; // Act & Assert var ex = Assert.Throws( () => ModelBindingHelper.ConvertTo(new MyClassWithoutConverter(), destinationType, culture: null)); Assert.Equal(expectedMessage, ex.Message); } // String does not have a converter to MyClassWithoutConverter. [Fact] public void ConvertTo_Throws_IfDestinationTypeIsNotConvertible() { // Arrange var value = "Hello world"; var destinationType = typeof(MyClassWithoutConverter); var expectedMessage = $"The parameter conversion from type '{value.GetType()}' to type " + $"'{typeof(MyClassWithoutConverter)}' failed because no type converter can convert between these types."; // Act & Assert var ex = Assert.Throws( () => ModelBindingHelper.ConvertTo(value, destinationType, culture: null)); Assert.Equal(expectedMessage, ex.Message); } // Happens very rarely in practice since conversion is almost-always from strings or string arrays. [Theory] [InlineData(typeof(MyClassWithoutConverter))] [InlineData(typeof(MySubClassWithoutConverter))] public void ConvertTo_ReturnsValue_IfCompatible(Type destinationType) { // Arrange var value = new MySubClassWithoutConverter(); // Act var result = ModelBindingHelper.ConvertTo(value, destinationType, culture: null); // Assert Assert.Same(value, result); } [Theory] [InlineData(typeof(MyClassWithoutConverter[]))] [InlineData(typeof(MySubClassWithoutConverter[]))] public void ConvertTo_ReusesArrayElements_IfCompatible(Type destinationType) { // Arrange var value = new MyClassWithoutConverter[] { new MySubClassWithoutConverter(), new MySubClassWithoutConverter(), new MySubClassWithoutConverter(), }; // Act var result = ModelBindingHelper.ConvertTo(value, destinationType, culture: null); // Assert Assert.IsType(destinationType, result); Assert.Collection( result as IEnumerable, element => { Assert.Same(value[0], element); }, element => { Assert.Same(value[1], element); }, element => { Assert.Same(value[2], element); }); } [Theory] [InlineData(new object[] { 2, FlagsEnum.Value2 })] [InlineData(new object[] { 5, FlagsEnum.Value1 | FlagsEnum.Value4 })] [InlineData(new object[] { 15, FlagsEnum.Value1 | FlagsEnum.Value2 | FlagsEnum.Value4 | FlagsEnum.Value8 })] [InlineData(new object[] { 16, (FlagsEnum)16 })] [InlineData(new object[] { 0, (FlagsEnum)0 })] [InlineData(new object[] { null, (FlagsEnum)0 })] [InlineData(new object[] { "Value1,Value2", (FlagsEnum)3 })] [InlineData(new object[] { "Value1,Value2,value4, value8", (FlagsEnum)15 })] public void ConvertTo_ConvertsEnumFlags(object value, object expected) { // Arrange // Act var outValue = ModelBindingHelper.ConvertTo(value, culture: null); // Assert Assert.Equal(expected, outValue); } [Theory] [InlineData(typeof(int))] [InlineData(typeof(int[]))] [InlineData(typeof(IEnumerable))] [InlineData(typeof(IReadOnlyCollection))] [InlineData(typeof(IReadOnlyList))] [InlineData(typeof(ICollection))] [InlineData(typeof(IList))] [InlineData(typeof(List))] [InlineData(typeof(Collection))] [InlineData(typeof(IntList))] [InlineData(typeof(LinkedList))] public void CanGetCompatibleCollection_ReturnsTrue(Type destinationType) { // Arrange var bindingContext = GetBindingContext(destinationType); // Act var result = ModelBindingHelper.CanGetCompatibleCollection(bindingContext); // Assert Assert.True(result); } [Theory] [InlineData(typeof(int))] [InlineData(typeof(int[]))] [InlineData(typeof(IEnumerable))] [InlineData(typeof(IReadOnlyCollection))] [InlineData(typeof(IReadOnlyList))] [InlineData(typeof(ICollection))] [InlineData(typeof(IList))] [InlineData(typeof(List))] public void GetCompatibleCollection_ReturnsList(Type destinationType) { // Arrange var bindingContext = GetBindingContext(destinationType); // Act var result = ModelBindingHelper.GetCompatibleCollection(bindingContext); // Assert Assert.IsType>(result); } [Theory] [InlineData(typeof(Collection))] [InlineData(typeof(IntList))] [InlineData(typeof(LinkedList))] public void GetCompatibleCollection_ActivatesCollection(Type destinationType) { // Arrange var bindingContext = GetBindingContext(destinationType); // Act var result = ModelBindingHelper.GetCompatibleCollection(bindingContext); // Assert Assert.IsType(destinationType, result); } [Fact] public void GetCompatibleCollection_SetsCapacity() { // Arrange var bindingContext = GetBindingContext(typeof(IList)); // Act var result = ModelBindingHelper.GetCompatibleCollection(bindingContext, capacity: 23); // Assert var list = Assert.IsType>(result); Assert.Equal(23, list.Capacity); } [Theory] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.ArrayProperty))] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.ArrayPropertyWithValue))] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.EnumerableProperty))] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.EnumerablePropertyWithArrayValue))] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.ListProperty))] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.ScalarProperty))] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.ScalarPropertyWithValue))] public void CanGetCompatibleCollection_ReturnsTrue_IfReadOnly(string propertyName) { // Arrange var bindingContext = GetBindingContextForProperty(propertyName); // Act var result = ModelBindingHelper.CanGetCompatibleCollection(bindingContext); // Assert Assert.True(result); } [Theory] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.EnumerablePropertyWithArrayValueAndSetter))] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.EnumerablePropertyWithListValue))] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.ListPropertyWithValue))] public void CanGetCompatibleCollection_ReturnsTrue_IfCollection(string propertyName) { // Arrange var bindingContext = GetBindingContextForProperty(propertyName); // Act var result = ModelBindingHelper.CanGetCompatibleCollection(bindingContext); // Assert Assert.True(result); } [Theory] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.EnumerablePropertyWithListValue))] [InlineData(nameof(ModelWithReadOnlyAndSpecialCaseProperties.ListPropertyWithValue))] public void GetCompatibleCollection_ReturnsExistingCollection(string propertyName) { // Arrange var bindingContext = GetBindingContextForProperty(propertyName); // Act var result = ModelBindingHelper.GetCompatibleCollection(bindingContext); // Assert Assert.Same(bindingContext.Model, result); var list = Assert.IsType>(result); Assert.Empty(list); } [Fact] public void CanGetCompatibleCollection_ReturnsNewCollection() { // Arrange var bindingContext = GetBindingContextForProperty( nameof(ModelWithReadOnlyAndSpecialCaseProperties.EnumerablePropertyWithArrayValueAndSetter)); // Act var result = ModelBindingHelper.GetCompatibleCollection(bindingContext); // Assert Assert.NotSame(bindingContext.Model, result); var list = Assert.IsType>(result); Assert.Empty(list); } [Theory] [InlineData(typeof(Collection))] [InlineData(typeof(List))] [InlineData(typeof(MyModel))] [InlineData(typeof(AbstractIntList))] [InlineData(typeof(ISet))] public void CanGetCompatibleCollection_ReturnsFalse(Type destinationType) { // Arrange var bindingContext = GetBindingContext(destinationType); // Act var result = ModelBindingHelper.CanGetCompatibleCollection(bindingContext); // Assert Assert.False(result); } private static DefaultModelBindingContext GetBindingContextForProperty(string propertyName) { var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); var modelMetadata = metadataProvider.GetMetadataForProperty( typeof(ModelWithReadOnlyAndSpecialCaseProperties), propertyName); var bindingContext = GetBindingContext(modelMetadata); var container = new ModelWithReadOnlyAndSpecialCaseProperties(); bindingContext.Model = modelMetadata.PropertyGetter(container); return bindingContext; } private static DefaultModelBindingContext GetBindingContext(Type modelType) { var metadataProvider = new EmptyModelMetadataProvider(); var metadata = metadataProvider.GetMetadataForType(modelType); return GetBindingContext(metadata); } private static DefaultModelBindingContext GetBindingContext(ModelMetadata metadata) { var bindingContext = new DefaultModelBindingContext { ModelMetadata = metadata, }; return bindingContext; } private class ModelWithReadOnlyAndSpecialCaseProperties { public int[] ArrayProperty { get; } public int[] ArrayPropertyWithValue { get; } = new int[4]; public IEnumerable EnumerableProperty { get; } public IEnumerable EnumerablePropertyWithArrayValue { get; } = new int[4]; // Special case: Value cannot be used but property can be set. public IEnumerable EnumerablePropertyWithArrayValueAndSetter { get; set; } = new int[4]; public IEnumerable EnumerablePropertyWithListValue { get; } = new List { 23 }; public List ListProperty { get; } public List ListPropertyWithValue { get; } = new List { 23 }; public int ScalarProperty { get; } public int ScalarPropertyWithValue { get; } = 23; } private class MyClassWithoutConverter { } private class MySubClassWithoutConverter : MyClassWithoutConverter { } private abstract class AbstractIntList : List { } private class IntList : List { } private enum IntEnum { Value0 = 0, Value1 = 1, MaxValue = int.MaxValue } private enum LongEnum : long { Value0 = 0L, Value1 = 1L, MaxValue = long.MaxValue } private enum UnsignedIntEnum : uint { Value0 = 0U, Value1 = 1U, MaxValue = uint.MaxValue } private enum ByteEnum : byte { Value0 = 0, Value1 = 1, MaxValue = byte.MaxValue } [Flags] public enum FlagsEnum { Value1 = 1, Value2 = 2, Value4 = 4, Value8 = 8 } } }