diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs b/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs index 5ae0d2c9105e..3be747c018ac 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs @@ -74,6 +74,8 @@ public void Configure(MvcOptions options) options.ModelBinderProviders.Add(new ArrayModelBinderProvider()); options.ModelBinderProviders.Add(new CollectionModelBinderProvider()); options.ModelBinderProviders.Add(new ComplexObjectModelBinderProvider()); + options.ModelBinderProviders.Add(new DateOnlyModelBinderProvider()); + options.ModelBinderProviders.Add(new TimeOnlyModelBinderProvider()); // Set up filters options.Filters.Add(new UnsupportedContentTypeFilter()); diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinder.cs new file mode 100644 index 000000000000..20dfc90ccbd1 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinder.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +/// +/// An for and nullable models. +/// +public class DateOnlyModelBinder : IModelBinder +{ + private readonly DateTimeStyles _supportedStyles; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of . + /// + /// The . + /// The . + public DateOnlyModelBinder(DateTimeStyles supportedStyles, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(loggerFactory, nameof(loggerFactory)); + + _supportedStyles = supportedStyles; + _logger = loggerFactory.CreateLogger(); + } + + /// + public Task BindModelAsync(ModelBindingContext bindingContext) + { + ArgumentNullException.ThrowIfNull(bindingContext, nameof(bindingContext)); + + _logger.AttemptingToBindModel(bindingContext); + + var modelName = bindingContext.ModelName; + var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + if (valueProviderResult == ValueProviderResult.None) + { + _logger.FoundNoValueInRequest(bindingContext); + + // no entry + _logger.DoneAttemptingToBindModel(bindingContext); + return Task.CompletedTask; + } + + var modelState = bindingContext.ModelState; + modelState.SetModelValue(modelName, valueProviderResult); + + var metadata = bindingContext.ModelMetadata; + var type = metadata.UnderlyingOrModelType; + try + { + var value = valueProviderResult.FirstValue; + + object? model; + if (string.IsNullOrWhiteSpace(value)) + { + // Parse() method trims the value (with common DateTimeSyles) then throws if the result is empty. + model = null; + } + else if (type == typeof(DateOnly)) + { + model = DateOnly.Parse(value, valueProviderResult.Culture, _supportedStyles); + } + else + { + throw new NotSupportedException(); + } + + // When converting value, a null model may indicate a failed conversion for an otherwise required + // model (can't set a ValueType to null). This detects if a null model value is acceptable given the + // current bindingContext. If not, an error is logged. + if (model == null && !metadata.IsReferenceOrNullableType) + { + modelState.TryAddModelError( + modelName, + metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor( + valueProviderResult.ToString())); + } + else + { + bindingContext.Result = ModelBindingResult.Success(model); + } + } + catch (Exception exception) + { + // Conversion failed. + modelState.TryAddModelError(modelName, exception, metadata); + } + + _logger.DoneAttemptingToBindModel(bindingContext); + return Task.CompletedTask; + } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinderProvider.cs new file mode 100644 index 000000000000..2614abdc76a2 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinderProvider.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +/// +/// An for binding and nullable models. +/// +public class DateOnlyModelBinderProvider : IModelBinderProvider +{ + internal const DateTimeStyles SupportedStyles = DateTimeStyles.AllowWhiteSpaces; + + /// + public IModelBinder? GetBinder(ModelBinderProviderContext context) + { + ArgumentNullException.ThrowIfNull(context, nameof(context)); + + var modelType = context.Metadata.UnderlyingOrModelType; + if (modelType == typeof(DateOnly)) + { + var loggerFactory = context.Services.GetRequiredService(); + return new DateOnlyModelBinder(SupportedStyles, loggerFactory); + } + + return null; + } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinder.cs new file mode 100644 index 000000000000..e3abfa29b123 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinder.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +/// +/// An for and nullable models. +/// +public class TimeOnlyModelBinder : IModelBinder +{ + private readonly DateTimeStyles _supportedStyles; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of . + /// + /// The . + /// The . + public TimeOnlyModelBinder(DateTimeStyles supportedStyles, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(loggerFactory, nameof(loggerFactory)); + + _supportedStyles = supportedStyles; + _logger = loggerFactory.CreateLogger(); + } + + /// + public Task BindModelAsync(ModelBindingContext bindingContext) + { + ArgumentNullException.ThrowIfNull(bindingContext, nameof(bindingContext)); + + _logger.AttemptingToBindModel(bindingContext); + + var modelName = bindingContext.ModelName; + var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + if (valueProviderResult == ValueProviderResult.None) + { + _logger.FoundNoValueInRequest(bindingContext); + + // no entry + _logger.DoneAttemptingToBindModel(bindingContext); + return Task.CompletedTask; + } + + var modelState = bindingContext.ModelState; + modelState.SetModelValue(modelName, valueProviderResult); + + var metadata = bindingContext.ModelMetadata; + var type = metadata.UnderlyingOrModelType; + try + { + var value = valueProviderResult.FirstValue; + + object? model; + if (string.IsNullOrWhiteSpace(value)) + { + // Parse() method trims the value (with common DateTimeSyles) then throws if the result is empty. + model = null; + } + else if (type == typeof(TimeOnly)) + { + model = TimeOnly.Parse(value, valueProviderResult.Culture, _supportedStyles); + } + else + { + throw new NotSupportedException(); + } + + // When converting value, a null model may indicate a failed conversion for an otherwise required + // model (can't set a ValueType to null). This detects if a null model value is acceptable given the + // current bindingContext. If not, an error is logged. + if (model == null && !metadata.IsReferenceOrNullableType) + { + modelState.TryAddModelError( + modelName, + metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor( + valueProviderResult.ToString())); + } + else + { + bindingContext.Result = ModelBindingResult.Success(model); + } + } + catch (Exception exception) + { + // Conversion failed. + modelState.TryAddModelError(modelName, exception, metadata); + } + + _logger.DoneAttemptingToBindModel(bindingContext); + return Task.CompletedTask; + } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinderProvider.cs new file mode 100644 index 000000000000..8a994c8d56ac --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinderProvider.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +/// +/// An for binding and nullable models. +/// +public class TimeOnlyModelBinderProvider : IModelBinderProvider +{ + internal const DateTimeStyles SupportedStyles = DateTimeStyles.AllowWhiteSpaces; + + /// + public IModelBinder? GetBinder(ModelBinderProviderContext context) + { + ArgumentNullException.ThrowIfNull(context, nameof(context)); + + var modelType = context.Metadata.UnderlyingOrModelType; + if (modelType == typeof(TimeOnly)) + { + var loggerFactory = context.Services.GetRequiredService(); + return new TimeOnlyModelBinder(SupportedStyles, loggerFactory); + } + + return null; + } +} diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index c2c8fdf41a5a..17e0567d0d1e 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -36,3 +36,15 @@ virtual Microsoft.AspNetCore.Mvc.Infrastructure.ConfigureCompatibilityOptions Microsoft.AspNetCore.Mvc.EmptyResult! *REMOVED*virtual Microsoft.AspNetCore.Mvc.ModelBinding.DefaultPropertyFilterProvider.PropertyIncludeExpressions.get -> System.Collections.Generic.IEnumerable!>!>? virtual Microsoft.AspNetCore.Mvc.ModelBinding.DefaultPropertyFilterProvider.PropertyIncludeExpressions.get -> System.Collections.Generic.IEnumerable!>!>? +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinder +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinder.BindModelAsync(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext! bindingContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinder.DateOnlyModelBinder(System.Globalization.DateTimeStyles supportedStyles, Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinderProvider +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinderProvider.DateOnlyModelBinderProvider() -> void +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinderProvider.GetBinder(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext! context) -> Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder? +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinder +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinder.BindModelAsync(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext! bindingContext) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinder.TimeOnlyModelBinder(System.Globalization.DateTimeStyles supportedStyles, Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinderProvider +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinderProvider.TimeOnlyModelBinderProvider() -> void +Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinderProvider.GetBinder(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext! context) -> Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder? diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderProviderTest.cs new file mode 100644 index 000000000000..98e1dfa5d293 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderProviderTest.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +public class DateOnlyModelBinderProviderTest +{ + private readonly DateOnlyModelBinderProvider _provider = new DateOnlyModelBinderProvider(); + + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(DateTimeOffset))] + [InlineData(typeof(DateTimeOffset?))] + [InlineData(typeof(DateTime))] + [InlineData(typeof(DateTime?))] + [InlineData(typeof(TimeSpan))] + public void Create_ForNonDateOnly_ReturnsNull(Type modelType) + { + // Arrange + var context = new TestModelBinderProviderContext(modelType); + + // Act + var result = _provider.GetBinder(context); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Create_ForDateOnly_ReturnsBinder() + { + // Arrange + var context = new TestModelBinderProviderContext(typeof(DateOnly)); + + // Act + var result = _provider.GetBinder(context); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Create_ForNullableDateOnly_ReturnsBinder() + { + // Arrange + var context = new TestModelBinderProviderContext(typeof(DateOnly?)); + + // Act + var result = _provider.GetBinder(context); + + // Assert + Assert.IsType(result); + } +} diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderTest.cs new file mode 100644 index 000000000000..c6db62a58be5 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderTest.cs @@ -0,0 +1,216 @@ +// 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.Extensions.Logging.Abstractions; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +public class DateOnlyModelBinderTest +{ + [Fact] + public async Task BindModel_ReturnsFailure_IfAttemptedValueCannotBeParsed() + { + // Arrange + var bindingContext = GetBindingContext(); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", "some-value" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.False(bindingContext.Result.IsModelSet); + } + + [Fact] + public async Task BindModel_CreatesError_IfAttemptedValueCannotBeParsed() + { + // Arrange + var message = "The value 'not a date' is not valid."; + var bindingContext = GetBindingContext(); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", "not a date" }, + }; + var binder = GetBinder(); + + // 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); + } + + [Fact] + public async Task BindModel_CreatesError_IfAttemptedValueCannotBeCompletelyParsed() + { + // Arrange + var bindingContext = GetBindingContext(); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB")) + { + { "theModelName", "2020-08-not-a-date" } + }; + var binder = GetBinder(); + + // 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 '2020-08-not-a-date' is not valid.", error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + } + + [Fact] + public async Task BindModel_ReturnsFailed_IfValueProviderEmpty() + { + // Arrange + var bindingContext = GetBindingContext(typeof(DateOnly)); + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result); + Assert.Empty(bindingContext.ModelState); + } + + [Fact] + public async Task BindModel_NullableDateOnly_ReturnsFailed_IfValueProviderEmpty() + { + // Arrange + var bindingContext = GetBindingContext(typeof(DateOnly?)); + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result); + Assert.Empty(bindingContext.ModelState); + } + + [Theory] + [InlineData("")] + [InlineData(" \t \r\n ")] + public async Task BindModel_CreatesError_IfTrimmedAttemptedValueIsEmpty_NonNullableDestination(string value) + { + // Arrange + var message = $"The value '{value}' is invalid."; + var bindingContext = GetBindingContext(); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", value }, + }; + var binder = GetBinder(); + + // 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(message, error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + } + + [Theory] + [InlineData("")] + [InlineData(" \t \r\n ")] + public async Task BindModel_ReturnsNull_IfTrimmedAttemptedValueIsEmpty_NullableDestination(string value) + { + // Arrange + var bindingContext = GetBindingContext(typeof(DateOnly?)); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", value } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Null(bindingContext.Result.Model); + var entry = Assert.Single(bindingContext.ModelState); + Assert.Equal("theModelName", entry.Key); + } + + [Theory] + [InlineData(typeof(DateOnly))] + [InlineData(typeof(DateOnly?))] + public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid(Type type) + { + // Arrange + var expected = new DateOnly(2019, 06, 14); + var bindingContext = GetBindingContext(type); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR")) + { + { "theModelName", "2019-06-14" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + var model = Assert.IsType(bindingContext.Result.Model); + Assert.Equal(expected, model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + [Fact] + public async Task UsesSpecifiedStyleToParseModel() + { + // Arrange + var bindingContext = GetBindingContext(); + var expected = DateOnly.Parse("2019-06-14", CultureInfo.InvariantCulture); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR")) + { + { "theModelName", " 2019-06-14" } + }; + var binder = GetBinder(DateTimeStyles.AllowLeadingWhite); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + var model = Assert.IsType(bindingContext.Result.Model); + Assert.Equal(expected, model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + private IModelBinder GetBinder(DateTimeStyles? dateTimeStyles = null) + { + return new DateOnlyModelBinder(dateTimeStyles ?? DateOnlyModelBinderProvider.SupportedStyles, NullLoggerFactory.Instance); + } + + private static DefaultModelBindingContext GetBindingContext(Type modelType = null) + { + modelType ??= typeof(DateOnly); + return new DefaultModelBindingContext + { + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(modelType), + ModelName = "theModelName", + ModelState = new ModelStateDictionary(), + ValueProvider = new SimpleValueProvider() // empty + }; + } +} diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderProviderTest.cs new file mode 100644 index 000000000000..6eaf5bd39215 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderProviderTest.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +public class TimeOnlyModelBinderProviderTest +{ + private readonly TimeOnlyModelBinderProvider _provider = new TimeOnlyModelBinderProvider(); + + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(DateTimeOffset))] + [InlineData(typeof(DateTimeOffset?))] + [InlineData(typeof(DateTime))] + [InlineData(typeof(DateTime?))] + [InlineData(typeof(TimeSpan))] + public void Create_ForNonDateTime_ReturnsNull(Type modelType) + { + // Arrange + var context = new TestModelBinderProviderContext(modelType); + + // Act + var result = _provider.GetBinder(context); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Create_ForTimeOnly_ReturnsBinder() + { + // Arrange + var context = new TestModelBinderProviderContext(typeof(TimeOnly)); + + // Act + var result = _provider.GetBinder(context); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Create_ForNullableTimeOnly_ReturnsBinder() + { + // Arrange + var context = new TestModelBinderProviderContext(typeof(TimeOnly?)); + + // Act + var result = _provider.GetBinder(context); + + // Assert + Assert.IsType(result); + } +} diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderTest.cs new file mode 100644 index 000000000000..eb2fab9cf7ed --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderTest.cs @@ -0,0 +1,216 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging.Abstractions; +using System.Globalization; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +public class TimeOnlyModelBinderTest +{ + [Fact] + public async Task BindModel_ReturnsFailure_IfAttemptedValueCannotBeParsed() + { + // Arrange + var bindingContext = GetBindingContext(); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", "some-value" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.False(bindingContext.Result.IsModelSet); + } + + [Fact] + public async Task BindModel_CreatesError_IfAttemptedValueCannotBeParsed() + { + // Arrange + var message = "The value 'not a time' is not valid."; + var bindingContext = GetBindingContext(); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", "not a time" }, + }; + var binder = GetBinder(); + + // 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); + } + + [Fact] + public async Task BindModel_CreatesError_IfAttemptedValueCannotBeCompletelyParsed() + { + // Arrange + var bindingContext = GetBindingContext(); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB")) + { + { "theModelName", "11:05:08-not-a-time" } + }; + var binder = GetBinder(); + + // 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 '11:05:08-not-a-time' is not valid.", error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + } + + [Fact] + public async Task BindModel_ReturnsFailed_IfValueProviderEmpty() + { + // Arrange + var bindingContext = GetBindingContext(typeof(TimeOnly)); + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result); + Assert.Empty(bindingContext.ModelState); + } + + [Fact] + public async Task BindModel_NullableTimeOnly_ReturnsFailed_IfValueProviderEmpty() + { + // Arrange + var bindingContext = GetBindingContext(typeof(TimeOnly?)); + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result); + Assert.Empty(bindingContext.ModelState); + } + + [Theory] + [InlineData("")] + [InlineData(" \t \r\n ")] + public async Task BindModel_CreatesError_IfTrimmedAttemptedValueIsEmpty_NonNullableDestination(string value) + { + // Arrange + var message = $"The value '{value}' is invalid."; + var bindingContext = GetBindingContext(); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", value }, + }; + var binder = GetBinder(); + + // 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(message, error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + } + + [Theory] + [InlineData("")] + [InlineData(" \t \r\n ")] + public async Task BindModel_ReturnsNull_IfTrimmedAttemptedValueIsEmpty_NullableDestination(string value) + { + // Arrange + var bindingContext = GetBindingContext(typeof(TimeOnly?)); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", value } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Null(bindingContext.Result.Model); + var entry = Assert.Single(bindingContext.ModelState); + Assert.Equal("theModelName", entry.Key); + } + + [Theory] + [InlineData(typeof(TimeOnly))] + [InlineData(typeof(TimeOnly?))] + public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid(Type type) + { + // Arrange + var expected = new TimeOnly(2, 30, 4, 0); + var bindingContext = GetBindingContext(type); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR")) + { + { "theModelName", "02:30:04.0000000" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + var model = Assert.IsType(bindingContext.Result.Model); + Assert.Equal(expected, model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + [Fact] + public async Task UsesSpecifiedStyleToParseModel() + { + // Arrange + var bindingContext = GetBindingContext(); + var expected = TimeOnly.Parse("02:30:04.0000000", CultureInfo.InvariantCulture); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR")) + { + { "theModelName", " 02:30:04.0000000" } + }; + var binder = GetBinder(DateTimeStyles.AllowLeadingWhite); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + var model = Assert.IsType(bindingContext.Result.Model); + Assert.Equal(expected, model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + private IModelBinder GetBinder(DateTimeStyles? dateTimeStyles = null) + { + return new TimeOnlyModelBinder(dateTimeStyles ?? TimeOnlyModelBinderProvider.SupportedStyles, NullLoggerFactory.Instance); + } + + private static DefaultModelBindingContext GetBindingContext(Type modelType = null) + { + modelType ??= typeof(TimeOnly); + return new DefaultModelBindingContext + { + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(modelType), + ModelName = "theModelName", + ModelState = new ModelStateDictionary(), + ValueProvider = new SimpleValueProvider() // empty + }; + } +} diff --git a/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs b/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs index 79c83b8ec421..cdb3a37f6072 100644 --- a/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs +++ b/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs @@ -62,7 +62,9 @@ public void Setup_SetsUpModelBinderProviders() binder => Assert.IsType(binder), binder => Assert.IsType(binder), binder => Assert.IsType(binder), - binder => Assert.IsType(binder)); + binder => Assert.IsType(binder), + binder => Assert.IsType(binder), + binder => Assert.IsType(binder)); } [Fact]