Skip to content

Commit 1400248

Browse files
committed
--wip-added-tests
1 parent f1c9223 commit 1400248

File tree

11 files changed

+453
-0
lines changed

11 files changed

+453
-0
lines changed

src/JsonApiDotNetCore/QueryStrings/IFilterValueConverter.cs

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using JetBrains.Annotations;
12
using JsonApiDotNetCore.Queries.Expressions;
23
using JsonApiDotNetCore.Queries.Internal.Parsing;
34
using JsonApiDotNetCore.Resources;
@@ -8,6 +9,7 @@ namespace JsonApiDotNetCore.QueryStrings;
89
/// <summary>
910
/// Provides conversion of a single-quoted value that occurs in a filter function of a query string.
1011
/// </summary>
12+
[PublicAPI]
1113
public interface IFilterValueConverter
1214
{
1315
/// <summary>

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs

+3
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ public sealed class Appointment : Identifiable<int>
1919

2020
[Attr]
2121
public DateTimeOffset EndTime { get; set; }
22+
23+
[HasMany]
24+
public IList<Reminder> Reminders { get; set; } = new List<Reminder>();
2225
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Queries.Expressions;
4+
using JsonApiDotNetCore.Resources;
5+
6+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion;
7+
8+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
9+
public class FilterRewritingResourceDefinition<TResource, TId> : JsonApiResourceDefinition<TResource, TId>
10+
where TResource : class, IIdentifiable<TId>
11+
{
12+
public FilterRewritingResourceDefinition(IResourceGraph resourceGraph)
13+
: base(resourceGraph)
14+
{
15+
}
16+
17+
public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter)
18+
{
19+
if (existingFilter != null)
20+
{
21+
var rewriter = new FilterTimeRangeRewriter();
22+
return (FilterExpression)rewriter.Visit(existingFilter, null)!;
23+
}
24+
25+
return existingFilter;
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using JsonApiDotNetCore.Queries.Expressions;
2+
3+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion;
4+
5+
internal sealed class FilterTimeRangeRewriter : QueryExpressionRewriter<object?>
6+
{
7+
private static readonly Dictionary<ComparisonOperator, ComparisonOperator> InverseComparisonOperatorTable = new()
8+
{
9+
[ComparisonOperator.GreaterThan] = ComparisonOperator.LessThan,
10+
[ComparisonOperator.GreaterOrEqual] = ComparisonOperator.LessOrEqual,
11+
[ComparisonOperator.Equals] = ComparisonOperator.Equals,
12+
[ComparisonOperator.LessThan] = ComparisonOperator.GreaterThan,
13+
[ComparisonOperator.LessOrEqual] = ComparisonOperator.GreaterOrEqual
14+
};
15+
16+
public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument)
17+
{
18+
if (expression.Right is LiteralConstantExpression { TypedValue: TimeRange timeRange })
19+
{
20+
var offsetComparison =
21+
new ComparisonExpression(timeRange.Offset < TimeSpan.Zero ? InverseComparisonOperatorTable[expression.Operator] : expression.Operator,
22+
expression.Left, new LiteralConstantExpression(timeRange.Time + timeRange.Offset));
23+
24+
ComparisonExpression? timeComparison = expression.Operator is ComparisonOperator.LessThan or ComparisonOperator.LessOrEqual
25+
? new ComparisonExpression(timeRange.Offset < TimeSpan.Zero ? ComparisonOperator.LessOrEqual : ComparisonOperator.GreaterOrEqual,
26+
expression.Left, new LiteralConstantExpression(timeRange.Time))
27+
: null;
28+
29+
return timeComparison == null ? offsetComparison : new LogicalExpression(LogicalOperator.And, offsetComparison, timeComparison);
30+
}
31+
32+
return base.VisitComparison(expression, argument);
33+
}
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using JsonApiDotNetCore.Queries.Expressions;
2+
using JsonApiDotNetCore.Queries.Internal.Parsing;
3+
using JsonApiDotNetCore.QueryStrings;
4+
using JsonApiDotNetCore.Resources.Annotations;
5+
using JsonApiDotNetCore.Resources.Internal;
6+
using Microsoft.AspNetCore.Authentication;
7+
8+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion;
9+
10+
internal sealed class RelativeTimeFilterValueConverter : IFilterValueConverter
11+
{
12+
private readonly ISystemClock _systemClock;
13+
14+
public RelativeTimeFilterValueConverter(ISystemClock systemClock)
15+
{
16+
_systemClock = systemClock;
17+
}
18+
19+
public bool CanConvert(AttrAttribute attribute)
20+
{
21+
return attribute.Type.ClrType == typeof(Reminder) && attribute.Property.PropertyType == typeof(DateTime);
22+
}
23+
24+
public object Convert(AttrAttribute attribute, string value, Type outerExpressionType)
25+
{
26+
// A leading +/- indicates a relative value, based on the current time.
27+
28+
if (value.Length > 1 && value[0] is '+' or '-')
29+
{
30+
if (outerExpressionType != typeof(ComparisonExpression))
31+
{
32+
throw new QueryParseException("A relative time can only be used in a comparison function.");
33+
}
34+
35+
var timeSpan = ConvertStringValueTo<TimeSpan>(value[1..]);
36+
TimeSpan offset = value[0] == '-' ? -timeSpan : timeSpan;
37+
return new TimeRange(_systemClock.UtcNow.UtcDateTime, offset);
38+
}
39+
40+
return ConvertStringValueTo<DateTime>(value);
41+
}
42+
43+
private static T ConvertStringValueTo<T>(string value)
44+
{
45+
try
46+
{
47+
return (T)RuntimeTypeConverter.ConvertType(value, typeof(T))!;
48+
}
49+
catch (FormatException exception)
50+
{
51+
throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{typeof(T).Name}'.", exception);
52+
}
53+
}
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
using System.Net;
2+
using FluentAssertions;
3+
using FluentAssertions.Extensions;
4+
using Humanizer;
5+
using JsonApiDotNetCore.Queries.Expressions;
6+
using JsonApiDotNetCore.QueryStrings;
7+
using JsonApiDotNetCore.Resources;
8+
using JsonApiDotNetCore.Serialization.Objects;
9+
using Microsoft.AspNetCore.Authentication;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using TestBuildingBlocks;
12+
using Xunit;
13+
14+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion;
15+
16+
public sealed class RelativeTimeTests : IClassFixture<IntegrationTestContext<TestableStartup<QueryStringDbContext>, QueryStringDbContext>>
17+
{
18+
private readonly IntegrationTestContext<TestableStartup<QueryStringDbContext>, QueryStringDbContext> _testContext;
19+
private readonly QueryStringFakers _fakers = new();
20+
21+
public RelativeTimeTests(IntegrationTestContext<TestableStartup<QueryStringDbContext>, QueryStringDbContext> testContext)
22+
{
23+
_testContext = testContext;
24+
25+
testContext.UseController<CalendarsController>();
26+
testContext.UseController<RemindersController>();
27+
28+
testContext.ConfigureServicesBeforeStartup(services =>
29+
{
30+
services.AddSingleton<ISystemClock, FrozenSystemClock>();
31+
services.AddSingleton<IFilterValueConverter, RelativeTimeFilterValueConverter>();
32+
services.AddScoped(typeof(IResourceDefinition<,>), typeof(FilterRewritingResourceDefinition<,>));
33+
});
34+
}
35+
36+
[Theory]
37+
[InlineData("-0:10:00", ComparisonOperator.GreaterThan, "0")] // more than 10 minutes ago
38+
[InlineData("-0:10:00", ComparisonOperator.GreaterOrEqual, "0,1")] // at least 10 minutes ago
39+
[InlineData("-0:10:00", ComparisonOperator.Equals, "1")] // exactly 10 minutes ago
40+
[InlineData("-0:10:00", ComparisonOperator.LessThan, "2,3")] // less than 10 minutes ago
41+
[InlineData("-0:10:00", ComparisonOperator.LessOrEqual, "1,2,3")] // at most 10 minutes ago
42+
[InlineData("+0:10:00", ComparisonOperator.GreaterThan, "6")] // more than 10 minutes in the future
43+
[InlineData("+0:10:00", ComparisonOperator.GreaterOrEqual, "5,6")] // at least 10 minutes in the future
44+
[InlineData("+0:10:00", ComparisonOperator.Equals, "5")] // in exactly 10 minutes
45+
[InlineData("+0:10:00", ComparisonOperator.LessThan, "3,4")] // less than 10 minutes in the future
46+
[InlineData("+0:10:00", ComparisonOperator.LessOrEqual, "3,4,5")] // at most 10 minutes in the future
47+
public async Task Can_filter_comparison_on_relative_time(string filterValue, ComparisonOperator comparisonOperator, string matchingRowsExpected)
48+
{
49+
// Arrange
50+
var clock = _testContext.Factory.Services.GetRequiredService<ISystemClock>();
51+
52+
List<Reminder> reminders = _fakers.Reminder.Generate(7);
53+
reminders[0].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-15)).DateTime.AsUtc();
54+
reminders[1].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-10)).DateTime.AsUtc();
55+
reminders[2].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-5)).DateTime.AsUtc();
56+
reminders[3].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(0)).DateTime.AsUtc();
57+
reminders[4].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(5)).DateTime.AsUtc();
58+
reminders[5].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(10)).DateTime.AsUtc();
59+
reminders[6].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(15)).DateTime.AsUtc();
60+
61+
await _testContext.RunOnDatabaseAsync(async dbContext =>
62+
{
63+
await dbContext.ClearTableAsync<Reminder>();
64+
dbContext.Reminders.AddRange(reminders);
65+
await dbContext.SaveChangesAsync();
66+
});
67+
68+
string route = $"/reminders?filter={comparisonOperator.ToString().Camelize()}(remindsAt,'{filterValue.Replace("+", "%2B")}')";
69+
70+
// Act
71+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
72+
73+
// Assert
74+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
75+
76+
int[] matchingRowIndices = matchingRowsExpected.Split(',').Select(int.Parse).ToArray();
77+
responseDocument.Data.ManyValue.ShouldHaveCount(matchingRowIndices.Length);
78+
79+
foreach (int rowIndex in matchingRowIndices)
80+
{
81+
responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == reminders[rowIndex].StringId);
82+
}
83+
}
84+
85+
[Fact]
86+
public async Task Cannot_filter_comparison_on_invalid_relative_time()
87+
{
88+
// Arrange
89+
const string route = "/reminders?filter=equals(remindsAt,'-*')";
90+
91+
// Act
92+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
93+
94+
// Assert
95+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
96+
97+
responseDocument.Errors.ShouldHaveCount(1);
98+
99+
ErrorObject error = responseDocument.Errors[0];
100+
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
101+
error.Title.Should().Be("The specified filter is invalid.");
102+
error.Detail.Should().Be("Failed to convert '*' of type 'String' to type 'TimeSpan'.");
103+
error.Source.ShouldNotBeNull();
104+
error.Source.Parameter.Should().Be("filter");
105+
}
106+
107+
[Fact]
108+
public async Task Cannot_filter_any_on_relative_time()
109+
{
110+
// Arrange
111+
const string route = "/reminders?filter=any(remindsAt,'-0:10:00')";
112+
113+
// Act
114+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
115+
116+
// Assert
117+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
118+
119+
responseDocument.Errors.ShouldHaveCount(1);
120+
121+
ErrorObject error = responseDocument.Errors[0];
122+
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
123+
error.Title.Should().Be("The specified filter is invalid.");
124+
error.Detail.Should().Be("A relative time can only be used in a comparison function.");
125+
error.Source.ShouldNotBeNull();
126+
error.Source.Parameter.Should().Be("filter");
127+
}
128+
129+
[Fact]
130+
public async Task Cannot_filter_text_match_on_relative_time()
131+
{
132+
// Arrange
133+
const string route = "/reminders?filter=startsWith(remindsAt,'-0:10:00')";
134+
135+
// Act
136+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
137+
138+
// Assert
139+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
140+
141+
responseDocument.Errors.ShouldHaveCount(1);
142+
143+
ErrorObject error = responseDocument.Errors[0];
144+
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
145+
error.Title.Should().Be("The specified filter is invalid.");
146+
error.Detail.Should().Be("A relative time can only be used in a comparison function.");
147+
error.Source.ShouldNotBeNull();
148+
error.Source.Parameter.Should().Be("filter");
149+
}
150+
151+
[Fact]
152+
public async Task Can_filter_comparison_on_relative_time_in_nested_expression()
153+
{
154+
// Arrange
155+
var clock = _testContext.Factory.Services.GetRequiredService<ISystemClock>();
156+
157+
Calendar calendar = _fakers.Calendar.Generate();
158+
calendar.Appointments = _fakers.Appointment.Generate(2).ToHashSet();
159+
160+
calendar.Appointments.ElementAt(0).Reminders = _fakers.Reminder.Generate(1);
161+
calendar.Appointments.ElementAt(0).Reminders[0].RemindsAt = clock.UtcNow.DateTime.AsUtc();
162+
163+
calendar.Appointments.ElementAt(1).Reminders = _fakers.Reminder.Generate(1);
164+
calendar.Appointments.ElementAt(1).Reminders[0].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(30)).DateTime.AsUtc();
165+
166+
await _testContext.RunOnDatabaseAsync(async dbContext =>
167+
{
168+
dbContext.Calendars.Add(calendar);
169+
await dbContext.SaveChangesAsync();
170+
});
171+
172+
string route = $"/calendars/{calendar.StringId}/appointments?filter=has(reminders,equals(remindsAt,'%2B0:30:00'))";
173+
174+
// Act
175+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
176+
177+
// Assert
178+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
179+
180+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
181+
182+
responseDocument.Data.ManyValue[0].Id.Should().Be(calendar.Appointments.ElementAt(1).StringId);
183+
}
184+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion;
2+
3+
internal sealed class TimeRange
4+
{
5+
public DateTime Time { get; }
6+
public TimeSpan Offset { get; }
7+
8+
public TimeRange(DateTime time, TimeSpan offset)
9+
{
10+
Time = time;
11+
Offset = offset;
12+
}
13+
}

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public sealed class QueryStringDbContext : TestableDbContext
2121
public DbSet<LoginAttempt> LoginAttempts => Set<LoginAttempt>();
2222
public DbSet<Calendar> Calendars => Set<Calendar>();
2323
public DbSet<Appointment> Appointments => Set<Appointment>();
24+
public DbSet<Reminder> Reminders => Set<Reminder>();
2425

2526
public QueryStringDbContext(DbContextOptions<QueryStringDbContext> options)
2627
: base(options)

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs

+7
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ internal sealed class QueryStringFakers : FakerContainer
7070
.TruncateToWholeMilliseconds())
7171
.RuleFor(appointment => appointment.EndTime, (faker, appointment) => appointment.StartTime.AddHours(faker.Random.Double(1, 4))));
7272

73+
private readonly Lazy<Faker<Reminder>> _lazyReminderFaker = new(() =>
74+
new Faker<Reminder>()
75+
.UseSeed(GetFakerSeed())
76+
.RuleFor(reminder => reminder.RemindsAt, faker => faker.Date.Future()
77+
.TruncateToWholeMilliseconds()));
78+
7379
public Faker<Blog> Blog => _lazyBlogFaker.Value;
7480
public Faker<BlogPost> BlogPost => _lazyBlogPostFaker.Value;
7581
public Faker<Label> Label => _lazyLabelFaker.Value;
@@ -79,4 +85,5 @@ internal sealed class QueryStringFakers : FakerContainer
7985
public Faker<AccountPreferences> AccountPreferences => _lazyAccountPreferencesFaker.Value;
8086
public Faker<Calendar> Calendar => _lazyCalendarFaker.Value;
8187
public Faker<Appointment> Appointment => _lazyAppointmentFaker.Value;
88+
public Faker<Reminder> Reminder => _lazyReminderFaker.Value;
8289
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")]
9+
public sealed class Reminder : Identifiable<int>
10+
{
11+
[Attr]
12+
public DateTime RemindsAt { get; set; }
13+
}

0 commit comments

Comments
 (0)