diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs index d79ed4c635..c76aa09b82 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs @@ -8,10 +8,26 @@ namespace JsonApiDotNetCore.Resources.Internal; [PublicAPI] public static class RuntimeTypeConverter { + private const string ParseQueryStringsUsingCurrentCultureSwitchName = "JsonApiDotNetCore.ParseQueryStringsUsingCurrentCulture"; + public static object? ConvertType(object? value, Type type) { ArgumentGuard.NotNull(type); + // Earlier versions of JsonApiDotNetCore failed to pass CultureInfo.InvariantCulture in the parsing below, which resulted in the 'current' + // culture being used. Unlike parsing JSON request/response bodies, this effectively meant that query strings were parsed based on the + // OS-level regional settings of the web server. + // Because this was fixed in a non-major release, the switch below enables to revert to the old behavior. + + // With the switch activated, API developers can still choose between: + // - Requiring localized date/number formats: parsing occurs using the OS-level regional settings (the default). + // - Requiring culture-invariant date/number formats: requires setting CultureInfo.DefaultThreadCurrentCulture to CultureInfo.InvariantCulture at startup. + // - Allowing clients to choose by sending an Accept-Language HTTP header: requires app.UseRequestLocalization() at startup. + + CultureInfo? cultureInfo = AppContext.TryGetSwitch(ParseQueryStringsUsingCurrentCultureSwitchName, out bool useCurrentCulture) && useCurrentCulture + ? null + : CultureInfo.InvariantCulture; + if (value == null) { if (!CanContainNull(type)) @@ -50,22 +66,34 @@ public static class RuntimeTypeConverter if (nonNullableType == typeof(DateTime)) { - DateTime convertedValue = DateTime.Parse(stringValue, null, DateTimeStyles.RoundtripKind); + DateTime convertedValue = DateTime.Parse(stringValue, cultureInfo, DateTimeStyles.RoundtripKind); return isNullableTypeRequested ? (DateTime?)convertedValue : convertedValue; } if (nonNullableType == typeof(DateTimeOffset)) { - DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, null, DateTimeStyles.RoundtripKind); + DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, cultureInfo, DateTimeStyles.RoundtripKind); return isNullableTypeRequested ? (DateTimeOffset?)convertedValue : convertedValue; } if (nonNullableType == typeof(TimeSpan)) { - TimeSpan convertedValue = TimeSpan.Parse(stringValue); + TimeSpan convertedValue = TimeSpan.Parse(stringValue, cultureInfo); return isNullableTypeRequested ? (TimeSpan?)convertedValue : convertedValue; } + if (nonNullableType == typeof(DateOnly)) + { + DateOnly convertedValue = DateOnly.Parse(stringValue, cultureInfo); + return isNullableTypeRequested ? (DateOnly?)convertedValue : convertedValue; + } + + if (nonNullableType == typeof(TimeOnly)) + { + TimeOnly convertedValue = TimeOnly.Parse(stringValue, cultureInfo); + return isNullableTypeRequested ? (TimeOnly?)convertedValue : convertedValue; + } + if (nonNullableType.IsEnum) { object convertedValue = Enum.Parse(nonNullableType, stringValue); @@ -75,7 +103,7 @@ public static class RuntimeTypeConverter } // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html - return Convert.ChangeType(stringValue, nonNullableType); + return Convert.ChangeType(stringValue, nonNullableType, cultureInfo); } catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs index 7d7d7d1acf..a16e9a97b7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Bogus; using TestBuildingBlocks; @@ -8,6 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; internal sealed class ModelStateFakers : FakerContainer { + private static readonly DateOnly MinCreatedOn = DateOnly.Parse("2000-01-01", CultureInfo.InvariantCulture); + private static readonly DateOnly MaxCreatedOn = DateOnly.Parse("2050-01-01", CultureInfo.InvariantCulture); + + private static readonly TimeOnly MinCreatedAt = TimeOnly.Parse("09:00:00", CultureInfo.InvariantCulture); + private static readonly TimeOnly MaxCreatedAt = TimeOnly.Parse("17:30:00", CultureInfo.InvariantCulture); + private readonly Lazy> _lazySystemVolumeFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) @@ -18,7 +25,9 @@ internal sealed class ModelStateFakers : FakerContainer .UseSeed(GetFakerSeed()) .RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName()) .RuleFor(systemFile => systemFile.Attributes, faker => faker.Random.Enum(FileAttributes.Normal, FileAttributes.Hidden, FileAttributes.ReadOnly)) - .RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); + .RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000)) + .RuleFor(systemFile => systemFile.CreatedOn, faker => faker.Date.BetweenDateOnly(MinCreatedOn, MaxCreatedOn)) + .RuleFor(systemFile => systemFile.CreatedAt, faker => faker.Date.BetweenTimeOnly(MinCreatedAt, MaxCreatedAt))); private readonly Lazy> _lazySystemDirectoryFaker = new(() => new Faker() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index b114962bd3..5a0738b0a9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -1,6 +1,7 @@ using System.Net; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -17,6 +18,12 @@ public ModelStateValidationTests(IntegrationTestContext(); testContext.UseController(); + + testContext.ConfigureServicesBeforeStartup(services => + { + // Polyfill for missing DateOnly/TimeOnly support in .NET 6 ModelState validation. + services.AddDateOnlyTimeOnlyStringConverters(); + }); } [Fact] @@ -123,6 +130,53 @@ public async Task Cannot_create_resource_with_invalid_attribute_value() error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } + [Fact] + public async Task Cannot_create_resource_with_invalid_DateOnly_TimeOnly_attribute_value() + { + // Arrange + SystemFile newFile = _fakers.SystemFile.Generate(); + + var requestBody = new + { + data = new + { + type = "systemFiles", + attributes = new + { + fileName = newFile.FileName, + attributes = newFile.Attributes, + sizeInBytes = newFile.SizeInBytes, + createdOn = DateOnly.MinValue, + createdAt = TimeOnly.MinValue + } + } + }; + + const string route = "/systemFiles"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(2); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().StartWith("The field CreatedAt must be between "); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/attributes/createdAt"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().StartWith("The field CreatedOn must be between "); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/createdOn"); + } + [Fact] public async Task Can_create_resource_with_valid_attribute_value() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs index a5515922e4..2fa659b6ef 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs @@ -20,4 +20,12 @@ public sealed class SystemFile : Identifiable [Attr] [Range(typeof(long), "1", "9223372036854775807")] public long SizeInBytes { get; set; } + + [Attr] + [Range(typeof(DateOnly), "2000-01-01", "2050-01-01")] + public DateOnly CreatedOn { get; set; } + + [Attr] + [Range(typeof(TimeOnly), "09:00:00", "17:30:00")] + public TimeOnly CreatedAt { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs index 402e6f86af..b464a15083 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Reflection; using System.Text.Json.Serialization; @@ -60,7 +61,9 @@ public async Task Can_filter_equality_on_type(string propertyName, object proper }); string attributeName = propertyName.Camelize(); - string route = $"/filterableResources?filter=equals({attributeName},'{propertyValue}')"; + string? attributeValue = Convert.ToString(propertyValue, CultureInfo.InvariantCulture); + + string route = $"/filterableResources?filter=equals({attributeName},'{attributeValue}')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -88,7 +91,7 @@ public async Task Can_filter_equality_on_type_Decimal() await dbContext.SaveChangesAsync(); }); - string route = $"/filterableResources?filter=equals(someDecimal,'{resource.SomeDecimal}')"; + string route = $"/filterableResources?filter=equals(someDecimal,'{resource.SomeDecimal.ToString(CultureInfo.InvariantCulture)}')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -232,7 +235,7 @@ public async Task Can_filter_equality_on_type_TimeSpan() await dbContext.SaveChangesAsync(); }); - string route = $"/filterableResources?filter=equals(someTimeSpan,'{resource.SomeTimeSpan}')"; + string route = $"/filterableResources?filter=equals(someTimeSpan,'{resource.SomeTimeSpan:c}')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -244,6 +247,62 @@ public async Task Can_filter_equality_on_type_TimeSpan() responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan)); } + [Fact] + public async Task Can_filter_equality_on_type_DateOnly() + { + // Arrange + var resource = new FilterableResource + { + SomeDateOnly = DateOnly.FromDateTime(27.January(2003)) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter=equals(someDateOnly,'{resource.SomeDateOnly:O}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateOnly").With(value => value.Should().Be(resource.SomeDateOnly)); + } + + [Fact] + public async Task Can_filter_equality_on_type_TimeOnly() + { + // Arrange + var resource = new FilterableResource + { + SomeTimeOnly = new TimeOnly(23, 59, 59, 999) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter=equals(someTimeOnly,'{resource.SomeTimeOnly:O}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeOnly").With(value => value.Should().Be(resource.SomeTimeOnly)); + } + [Fact] public async Task Cannot_filter_equality_on_incompatible_value() { @@ -288,6 +347,8 @@ public async Task Cannot_filter_equality_on_incompatible_value() [InlineData(nameof(FilterableResource.SomeNullableDateTime))] [InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))] [InlineData(nameof(FilterableResource.SomeNullableTimeSpan))] + [InlineData(nameof(FilterableResource.SomeNullableDateOnly))] + [InlineData(nameof(FilterableResource.SomeNullableTimeOnly))] [InlineData(nameof(FilterableResource.SomeNullableEnum))] public async Task Can_filter_is_null_on_type(string propertyName) { @@ -308,6 +369,8 @@ public async Task Can_filter_is_null_on_type(string propertyName) SomeNullableDateTime = 1.January(2001).AsUtc(), SomeNullableDateTimeOffset = 1.January(2001).AsUtc(), SomeNullableTimeSpan = TimeSpan.FromHours(1), + SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)), + SomeNullableTimeOnly = new TimeOnly(1, 0), SomeNullableEnum = DayOfWeek.Friday }; @@ -342,6 +405,8 @@ public async Task Can_filter_is_null_on_type(string propertyName) [InlineData(nameof(FilterableResource.SomeNullableDateTime))] [InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))] [InlineData(nameof(FilterableResource.SomeNullableTimeSpan))] + [InlineData(nameof(FilterableResource.SomeNullableDateOnly))] + [InlineData(nameof(FilterableResource.SomeNullableTimeOnly))] [InlineData(nameof(FilterableResource.SomeNullableEnum))] public async Task Can_filter_is_not_null_on_type(string propertyName) { @@ -358,6 +423,8 @@ public async Task Can_filter_is_not_null_on_type(string propertyName) SomeNullableDateTime = 1.January(2001).AsUtc(), SomeNullableDateTimeOffset = 1.January(2001).AsUtc(), SomeNullableTimeSpan = TimeSpan.FromHours(1), + SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)), + SomeNullableTimeOnly = new TimeOnly(1, 0), SomeNullableEnum = DayOfWeek.Friday }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs index 6d000f6433..b9fd8e2b2a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs @@ -15,6 +15,38 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Filtering; public sealed class FilterOperatorTests : IClassFixture, FilterDbContext>> { + private const string IntLowerBound = "19"; + private const string IntInTheRange = "20"; + private const string IntUpperBound = "21"; + + private const string DoubleLowerBound = "1.9"; + private const string DoubleInTheRange = "2.0"; + private const string DoubleUpperBound = "2.1"; + + private const string IsoDateTimeLowerBound = "2000-11-22T09:48:17"; + private const string IsoDateTimeInTheRange = "2000-11-22T12:34:56"; + private const string IsoDateTimeUpperBound = "2000-11-22T18:47:32"; + + private const string InvariantDateTimeLowerBound = "11/22/2000 9:48:17"; + private const string InvariantDateTimeInTheRange = "11/22/2000 12:34:56"; + private const string InvariantDateTimeUpperBound = "11/22/2000 18:47:32"; + + private const string TimeSpanLowerBound = "2:15:28:54.997"; + private const string TimeSpanInTheRange = "2:15:51:42.397"; + private const string TimeSpanUpperBound = "2:16:22:41.736"; + + private const string IsoDateOnlyLowerBound = "2000-10-22"; + private const string IsoDateOnlyInTheRange = "2000-11-22"; + private const string IsoDateOnlyUpperBound = "2000-12-22"; + + private const string InvariantDateOnlyLowerBound = "10/22/2000"; + private const string InvariantDateOnlyInTheRange = "11/22/2000"; + private const string InvariantDateOnlyUpperBound = "12/22/2000"; + + private const string TimeOnlyLowerBound = "15:28:54.997"; + private const string TimeOnlyInTheRange = "15:51:42.397"; + private const string TimeOnlyUpperBound = "16:22:41.736"; + private readonly IntegrationTestContext, FilterDbContext> _testContext; public FilterOperatorTests(IntegrationTestContext, FilterDbContext> testContext) @@ -257,25 +289,26 @@ public async Task Cannot_filter_equality_on_two_attributes_of_incompatible_types } [Theory] - [InlineData(19, 21, ComparisonOperator.LessThan, 20)] - [InlineData(19, 21, ComparisonOperator.LessThan, 21)] - [InlineData(19, 21, ComparisonOperator.LessOrEqual, 20)] - [InlineData(19, 21, ComparisonOperator.LessOrEqual, 19)] - [InlineData(21, 19, ComparisonOperator.GreaterThan, 20)] - [InlineData(21, 19, ComparisonOperator.GreaterThan, 19)] - [InlineData(21, 19, ComparisonOperator.GreaterOrEqual, 20)] - [InlineData(21, 19, ComparisonOperator.GreaterOrEqual, 21)] - public async Task Can_filter_comparison_on_whole_number(int matchingValue, int nonMatchingValue, ComparisonOperator filterOperator, double filterValue) + [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessThan, IntInTheRange)] + [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessThan, IntUpperBound)] + [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessOrEqual, IntInTheRange)] + [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessOrEqual, IntLowerBound)] + [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterThan, IntInTheRange)] + [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterThan, IntLowerBound)] + [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterOrEqual, IntInTheRange)] + [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterOrEqual, IntUpperBound)] + public async Task Can_filter_comparison_on_whole_number(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, + string filterValue) { // Arrange var resource = new FilterableResource { - SomeInt32 = matchingValue + SomeInt32 = int.Parse(matchingValue, CultureInfo.InvariantCulture) }; var otherResource = new FilterableResource { - SomeInt32 = nonMatchingValue + SomeInt32 = int.Parse(nonMatchingValue, CultureInfo.InvariantCulture) }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -298,26 +331,26 @@ public async Task Can_filter_comparison_on_whole_number(int matchingValue, int n } [Theory] - [InlineData(1.9, 2.1, ComparisonOperator.LessThan, 2.0)] - [InlineData(1.9, 2.1, ComparisonOperator.LessThan, 2.1)] - [InlineData(1.9, 2.1, ComparisonOperator.LessOrEqual, 2.0)] - [InlineData(1.9, 2.1, ComparisonOperator.LessOrEqual, 1.9)] - [InlineData(2.1, 1.9, ComparisonOperator.GreaterThan, 2.0)] - [InlineData(2.1, 1.9, ComparisonOperator.GreaterThan, 1.9)] - [InlineData(2.1, 1.9, ComparisonOperator.GreaterOrEqual, 2.0)] - [InlineData(2.1, 1.9, ComparisonOperator.GreaterOrEqual, 2.1)] - public async Task Can_filter_comparison_on_fractional_number(double matchingValue, double nonMatchingValue, ComparisonOperator filterOperator, - double filterValue) + [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessThan, DoubleInTheRange)] + [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessThan, DoubleUpperBound)] + [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessOrEqual, DoubleInTheRange)] + [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessOrEqual, DoubleLowerBound)] + [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterThan, DoubleInTheRange)] + [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterThan, DoubleLowerBound)] + [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterOrEqual, DoubleInTheRange)] + [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterOrEqual, DoubleUpperBound)] + public async Task Can_filter_comparison_on_fractional_number(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, + string filterValue) { // Arrange var resource = new FilterableResource { - SomeDouble = matchingValue + SomeDouble = double.Parse(matchingValue, CultureInfo.InvariantCulture) }; var otherResource = new FilterableResource { - SomeDouble = nonMatchingValue + SomeDouble = double.Parse(nonMatchingValue, CultureInfo.InvariantCulture) }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -340,26 +373,34 @@ public async Task Can_filter_comparison_on_whole_number(int matchingValue, int n } [Theory] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-05")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-09")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-05")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-01")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-05")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-01")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-05")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-09")] - public async Task Can_filter_comparison_on_DateTime_in_local_time_zone(string matchingDateTime, string nonMatchingDateTime, - ComparisonOperator filterOperator, string filterDateTime) + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeUpperBound)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeUpperBound)] + public async Task Can_filter_comparison_on_DateTime_in_local_time_zone(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, + string filterValue) { // Arrange var resource = new FilterableResource { - SomeDateTimeInLocalZone = DateTime.Parse(matchingDateTime, CultureInfo.InvariantCulture).AsLocal() + SomeDateTimeInLocalZone = DateTime.Parse(matchingValue, CultureInfo.InvariantCulture).AsLocal() }; var otherResource = new FilterableResource { - SomeDateTimeInLocalZone = DateTime.Parse(nonMatchingDateTime, CultureInfo.InvariantCulture).AsLocal() + SomeDateTimeInLocalZone = DateTime.Parse(nonMatchingValue, CultureInfo.InvariantCulture).AsLocal() }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -369,7 +410,7 @@ public async Task Can_filter_comparison_on_whole_number(int matchingValue, int n await dbContext.SaveChangesAsync(); }); - string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInLocalZone,'{filterDateTime}')"; + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInLocalZone,'{filterValue}')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -384,26 +425,34 @@ public async Task Can_filter_comparison_on_whole_number(int matchingValue, int n } [Theory] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-05Z")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-09Z")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-05Z")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-01Z")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-05Z")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-01Z")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-05Z")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-09Z")] - public async Task Can_filter_comparison_on_DateTime_in_UTC_time_zone(string matchingDateTime, string nonMatchingDateTime, ComparisonOperator filterOperator, - string filterDateTime) + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeUpperBound)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeUpperBound)] + public async Task Can_filter_comparison_on_DateTime_in_UTC_time_zone(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, + string filterValue) { // Arrange var resource = new FilterableResource { - SomeDateTimeInUtcZone = DateTime.Parse(matchingDateTime, CultureInfo.InvariantCulture).AsUtc() + SomeDateTimeInUtcZone = DateTime.Parse(matchingValue, CultureInfo.InvariantCulture).AsUtc() }; var otherResource = new FilterableResource { - SomeDateTimeInUtcZone = DateTime.Parse(nonMatchingDateTime, CultureInfo.InvariantCulture).AsUtc() + SomeDateTimeInUtcZone = DateTime.Parse(nonMatchingValue, CultureInfo.InvariantCulture).AsUtc() }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -413,7 +462,7 @@ public async Task Can_filter_comparison_on_whole_number(int matchingValue, int n await dbContext.SaveChangesAsync(); }); - string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInUtcZone,'{filterDateTime}')"; + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInUtcZone,'{filterValue}Z')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -427,6 +476,188 @@ public async Task Can_filter_comparison_on_whole_number(int matchingValue, int n .With(value => value.Should().Be(resource.SomeDateTimeInUtcZone)); } + [Theory] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeUpperBound)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeUpperBound)] + public async Task Can_filter_comparison_on_DateTimeOffset(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, + string filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeDateTimeOffset = DateTime.Parse(matchingValue, CultureInfo.InvariantCulture).AsUtc() + }; + + var otherResource = new FilterableResource + { + SomeDateTimeOffset = DateTime.Parse(nonMatchingValue, CultureInfo.InvariantCulture).AsUtc() + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, otherResource); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeOffset,'{filterValue}Z')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateTimeOffset").With(value => value.Should().Be(resource.SomeDateTimeOffset)); + } + + [Theory] + [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessThan, TimeSpanInTheRange)] + [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessThan, TimeSpanUpperBound)] + [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessOrEqual, TimeSpanInTheRange)] + [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessOrEqual, TimeSpanLowerBound)] + [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterThan, TimeSpanInTheRange)] + [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterThan, TimeSpanLowerBound)] + [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterOrEqual, TimeSpanInTheRange)] + [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterOrEqual, TimeSpanUpperBound)] + public async Task Can_filter_comparison_on_TimeSpan(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeTimeSpan = TimeSpan.Parse(matchingValue, CultureInfo.InvariantCulture) + }; + + var otherResource = new FilterableResource + { + SomeTimeSpan = TimeSpan.Parse(nonMatchingValue, CultureInfo.InvariantCulture) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, otherResource); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someTimeSpan,'{filterValue}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan)); + } + + [Theory] + [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessThan, IsoDateOnlyInTheRange)] + [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessThan, IsoDateOnlyUpperBound)] + [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessOrEqual, IsoDateOnlyInTheRange)] + [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessOrEqual, IsoDateOnlyLowerBound)] + [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterThan, IsoDateOnlyInTheRange)] + [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterThan, IsoDateOnlyLowerBound)] + [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateOnlyInTheRange)] + [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateOnlyUpperBound)] + [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessThan, InvariantDateOnlyInTheRange)] + [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessThan, InvariantDateOnlyUpperBound)] + [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessOrEqual, InvariantDateOnlyInTheRange)] + [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessOrEqual, InvariantDateOnlyLowerBound)] + [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterThan, InvariantDateOnlyInTheRange)] + [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterThan, InvariantDateOnlyLowerBound)] + [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateOnlyInTheRange)] + [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateOnlyUpperBound)] + public async Task Can_filter_comparison_on_DateOnly(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeDateOnly = DateOnly.Parse(matchingValue, CultureInfo.InvariantCulture) + }; + + var otherResource = new FilterableResource + { + SomeDateOnly = DateOnly.Parse(nonMatchingValue, CultureInfo.InvariantCulture) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, otherResource); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateOnly,'{filterValue}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateOnly").With(value => value.Should().Be(resource.SomeDateOnly)); + } + + [Theory] + [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessThan, TimeOnlyInTheRange)] + [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessThan, TimeOnlyUpperBound)] + [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessOrEqual, TimeOnlyInTheRange)] + [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessOrEqual, TimeOnlyLowerBound)] + [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterThan, TimeOnlyInTheRange)] + [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterThan, TimeOnlyLowerBound)] + [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterOrEqual, TimeOnlyInTheRange)] + [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterOrEqual, TimeOnlyUpperBound)] + public async Task Can_filter_comparison_on_TimeOnly(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeTimeOnly = TimeOnly.Parse(matchingValue, CultureInfo.InvariantCulture) + }; + + var otherResource = new FilterableResource + { + SomeTimeOnly = TimeOnly.Parse(nonMatchingValue, CultureInfo.InvariantCulture) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, otherResource); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someTimeOnly,'{filterValue}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeOnly").With(value => value.Should().Be(resource.SomeTimeOnly)); + } + [Theory] [InlineData("The fox jumped over the lazy dog", "Other", TextMatchKind.Contains, "jumped")] [InlineData("The fox jumped over the lazy dog", "the fox...", TextMatchKind.Contains, "The")] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs index dbc0323e9c..52b0ddbdd9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs @@ -77,6 +77,18 @@ public sealed class FilterableResource : Identifiable [Attr] public TimeSpan? SomeNullableTimeSpan { get; set; } + [Attr] + public DateOnly SomeDateOnly { get; set; } + + [Attr] + public DateOnly? SomeNullableDateOnly { get; set; } + + [Attr] + public TimeOnly SomeTimeOnly { get; set; } + + [Attr] + public TimeOnly? SomeNullableTimeOnly { get; set; } + [Attr] public DayOfWeek SomeEnum { get; set; } diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index 22d50630ca..3bd3632461 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -1,4 +1,4 @@ - + $(TargetFrameworkName) @@ -11,9 +11,11 @@ + + diff --git a/test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs new file mode 100644 index 0000000000..e60dcefe64 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/TypeConversion/RuntimeTypeConverterTests.cs @@ -0,0 +1,164 @@ +using FluentAssertions; +using JsonApiDotNetCore.Resources.Internal; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.TypeConversion; + +public sealed class RuntimeTypeConverterTests +{ + [Theory] + [InlineData(typeof(bool))] + [InlineData(typeof(byte))] + [InlineData(typeof(sbyte))] + [InlineData(typeof(char))] + [InlineData(typeof(short))] + [InlineData(typeof(ushort))] + [InlineData(typeof(int))] + [InlineData(typeof(uint))] + [InlineData(typeof(long))] + [InlineData(typeof(ulong))] + [InlineData(typeof(float))] + [InlineData(typeof(double))] + [InlineData(typeof(decimal))] + [InlineData(typeof(Guid))] + [InlineData(typeof(DateTime))] + [InlineData(typeof(DateTimeOffset))] + [InlineData(typeof(TimeSpan))] + [InlineData(typeof(DayOfWeek))] + public void Cannot_convert_null_to_value_type(Type type) + { + // Act + Action action = () => RuntimeTypeConverter.ConvertType(null, type); + + // Assert + action.Should().ThrowExactly().WithMessage($"Failed to convert 'null' to type '{type.Name}'."); + } + + [Theory] + [InlineData(typeof(bool?))] + [InlineData(typeof(byte?))] + [InlineData(typeof(sbyte?))] + [InlineData(typeof(char?))] + [InlineData(typeof(short?))] + [InlineData(typeof(ushort?))] + [InlineData(typeof(int?))] + [InlineData(typeof(uint?))] + [InlineData(typeof(long?))] + [InlineData(typeof(ulong?))] + [InlineData(typeof(float?))] + [InlineData(typeof(double?))] + [InlineData(typeof(decimal?))] + [InlineData(typeof(Guid?))] + [InlineData(typeof(DateTime?))] + [InlineData(typeof(DateTimeOffset?))] + [InlineData(typeof(TimeSpan?))] + [InlineData(typeof(DayOfWeek?))] + [InlineData(typeof(string))] + [InlineData(typeof(IFace))] + [InlineData(typeof(BaseType))] + [InlineData(typeof(DerivedType))] + public void Can_convert_null_to_nullable_type(Type type) + { + // Act + object? result = RuntimeTypeConverter.ConvertType(null, type); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void Returns_same_instance_for_exact_type() + { + // Arrange + var instance = new DerivedType(); + Type type = typeof(DerivedType); + + // Act + object? result = RuntimeTypeConverter.ConvertType(instance, type); + + // Assert + result.Should().Be(instance); + } + + [Fact] + public void Returns_same_instance_for_base_type() + { + // Arrange + var instance = new DerivedType(); + Type type = typeof(BaseType); + + // Act + object? result = RuntimeTypeConverter.ConvertType(instance, type); + + // Assert + result.Should().Be(instance); + } + + [Fact] + public void Returns_same_instance_for_interface() + { + // Arrange + var instance = new DerivedType(); + Type type = typeof(IFace); + + // Act + object? result = RuntimeTypeConverter.ConvertType(instance, type); + + // Assert + result.Should().Be(instance); + } + + [Theory] + [InlineData(typeof(bool), default(bool))] + [InlineData(typeof(bool?), null)] + [InlineData(typeof(byte), default(byte))] + [InlineData(typeof(byte?), null)] + [InlineData(typeof(sbyte), default(sbyte))] + [InlineData(typeof(sbyte?), null)] + [InlineData(typeof(char), default(char))] + [InlineData(typeof(char?), null)] + [InlineData(typeof(short), default(short))] + [InlineData(typeof(short?), null)] + [InlineData(typeof(ushort), default(ushort))] + [InlineData(typeof(ushort?), null)] + [InlineData(typeof(int), default(int))] + [InlineData(typeof(int?), null)] + [InlineData(typeof(uint), default(uint))] + [InlineData(typeof(uint?), null)] + [InlineData(typeof(long), default(long))] + [InlineData(typeof(long?), null)] + [InlineData(typeof(ulong), default(ulong))] + [InlineData(typeof(ulong?), null)] + [InlineData(typeof(float), default(float))] + [InlineData(typeof(float?), null)] + [InlineData(typeof(double), default(double))] + [InlineData(typeof(double?), null)] + [InlineData(typeof(decimal), 0)] + [InlineData(typeof(decimal?), null)] + [InlineData(typeof(DayOfWeek), DayOfWeek.Sunday)] + [InlineData(typeof(DayOfWeek?), null)] + [InlineData(typeof(string), "")] + [InlineData(typeof(IFace), null)] + [InlineData(typeof(BaseType), null)] + [InlineData(typeof(DerivedType), null)] + public void Returns_default_value_for_empty_string(Type type, object expectedValue) + { + // Act + object? result = RuntimeTypeConverter.ConvertType(string.Empty, type); + + // Assert + result.Should().Be(expectedValue); + } + + private interface IFace + { + } + + private class BaseType : IFace + { + } + + private sealed class DerivedType : BaseType + { + } +} diff --git a/test/UnitTests/Internal/RuntimeTypeConverterTests.cs b/test/UnitTests/Internal/RuntimeTypeConverterTests.cs deleted file mode 100644 index 5bbac8fc4a..0000000000 --- a/test/UnitTests/Internal/RuntimeTypeConverterTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using FluentAssertions; -using JsonApiDotNetCore.Resources.Internal; -using Xunit; - -namespace UnitTests.Internal; - -public sealed class RuntimeTypeConverterTests -{ - [Fact] - public void Can_Convert_DateTimeOffsets() - { - // Arrange - var dateTimeOffset = new DateTimeOffset(new DateTime(2002, 2, 2), TimeSpan.FromHours(4)); - string formattedString = dateTimeOffset.ToString("O"); - - // Act - object? result = RuntimeTypeConverter.ConvertType(formattedString, typeof(DateTimeOffset)); - - // Assert - result.Should().Be(dateTimeOffset); - } - - [Fact] - public void Bad_DateTimeOffset_String_Throws() - { - // Arrange - const string formattedString = "this_is_not_a_valid_dto"; - - // Act - Action action = () => RuntimeTypeConverter.ConvertType(formattedString, typeof(DateTimeOffset)); - - // Assert - action.Should().ThrowExactly(); - } - - [Fact] - public void Can_Convert_Enums() - { - // Arrange - const string formattedString = "1"; - - // Act - object? result = RuntimeTypeConverter.ConvertType(formattedString, typeof(TestEnum)); - - // Assert - result.Should().Be(TestEnum.Test); - } - - [Fact] - public void ConvertType_Returns_Value_If_Type_Is_Same() - { - // Arrange - var complexType = new ComplexType(); - Type type = complexType.GetType(); - - // Act - object? result = RuntimeTypeConverter.ConvertType(complexType, type); - - // Assert - result.Should().Be(complexType); - } - - [Fact] - public void ConvertType_Returns_Value_If_Type_Is_Assignable() - { - // Arrange - var complexType = new ComplexType(); - - Type baseType = typeof(BaseType); - Type iType = typeof(IType); - - // Act - object? baseResult = RuntimeTypeConverter.ConvertType(complexType, baseType); - object? iResult = RuntimeTypeConverter.ConvertType(complexType, iType); - - // Assert - baseResult.Should().Be(complexType); - iResult.Should().Be(complexType); - } - - [Fact] - public void ConvertType_Returns_Default_Value_For_Empty_Strings() - { - // Arrange - var data = new Dictionary - { - { typeof(int), 0 }, - { typeof(short), (short)0 }, - { typeof(long), (long)0 }, - { typeof(string), "" }, - { typeof(Guid), Guid.Empty } - }; - - foreach ((Type key, object value) in data) - { - // Act - object? result = RuntimeTypeConverter.ConvertType(string.Empty, key); - - // Assert - result.Should().Be(value); - } - } - - [Fact] - public void Can_Convert_TimeSpans() - { - // Arrange - TimeSpan timeSpan = TimeSpan.FromMinutes(45); - string stringSpan = timeSpan.ToString(); - - // Act - object? result = RuntimeTypeConverter.ConvertType(stringSpan, typeof(TimeSpan)); - - // Assert - result.Should().Be(timeSpan); - } - - [Fact] - public void Bad_TimeSpanString_Throws() - { - // Arrange - const string formattedString = "this_is_not_a_valid_timespan"; - - // Act - Action action = () => RuntimeTypeConverter.ConvertType(formattedString, typeof(TimeSpan)); - - // Assert - action.Should().ThrowExactly(); - } - - private enum TestEnum - { - Test = 1 - } - - private sealed class ComplexType : BaseType - { - } - - private class BaseType : IType - { - } - - private interface IType - { - } -}