Skip to content

Commit 75af11e

Browse files
authored
fix: Disallow search filter with CreatedAfter greater than CreatedBefore (#2019)
<!--- Provide a general summary of your changes in the Title above --> ## Description This PR disallows search filtering with `{dateProperty}After > {dateProperty}Before` for all available date filter types, * CreatedAt * DueAt * UpdatedAt * VisibleFrom (ServiceOwner only) Also adding tests to verify these filters work with normal use, and that validation errors are produced when the rule above is broken. ## Related Issue(s) - #2018 ## Verification - [x] **Your** code builds clean without any errors or warnings - [x] Manual testing done (required) - [x] Relevant automated test added (if you find this hard, leave it and we'll help out) ## Documentation - [ ] Documentation is updated (either in `docs`-directory, Altinnpedia or a separate linked PR in [altinn-studio-docs.](https://github.com/Altinn/altinn-studio-docs), if applicable)
1 parent 584b71c commit 75af11e

File tree

13 files changed

+713
-12
lines changed

13 files changed

+713
-12
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Digdir.Domain.Dialogporten.Application.Features.V1.Common;
2+
3+
internal static class ValidationErrorStrings
4+
{
5+
internal const string PropertyNameMustBeLessThanOrEqualToComparisonProperty =
6+
"'{PropertyName}' must be less than or equal to '{ComparisonProperty}'.";
7+
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQueryValidator.cs

+16
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Digdir.Domain.Dialogporten.Domain.Common;
66
using Digdir.Domain.Dialogporten.Domain.Localizations;
77
using FluentValidation;
8+
using static Digdir.Domain.Dialogporten.Application.Features.V1.Common.ValidationErrorStrings;
89

910
namespace Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Search;
1011

@@ -52,5 +53,20 @@ public SearchDialogQueryValidator()
5253

5354
RuleForEach(x => x.Status).IsInEnum();
5455
RuleForEach(x => x.SystemLabel).IsInEnum();
56+
57+
RuleFor(x => x.CreatedAfter)
58+
.LessThanOrEqualTo(x => x.CreatedBefore)
59+
.When(x => x.CreatedAfter is not null && x.CreatedBefore is not null)
60+
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);
61+
62+
RuleFor(x => x.DueAfter)
63+
.LessThanOrEqualTo(x => x.DueBefore)
64+
.When(x => x.DueAfter is not null && x.DueBefore is not null)
65+
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);
66+
67+
RuleFor(x => x.UpdatedAfter)
68+
.LessThanOrEqualTo(x => x.UpdatedBefore)
69+
.When(x => x.UpdatedAfter is not null && x.UpdatedBefore is not null)
70+
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);
5571
}
5672
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQueryValidator.cs

+21
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Digdir.Domain.Dialogporten.Domain.Parties;
88
using Digdir.Domain.Dialogporten.Domain.Parties.Abstractions;
99
using FluentValidation;
10+
using static Digdir.Domain.Dialogporten.Application.Features.V1.Common.ValidationErrorStrings;
1011

1112
namespace Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Search;
1213

@@ -58,5 +59,25 @@ public SearchDialogQueryValidator()
5859
.IsValidUri()
5960
.MaximumLength(Constants.DefaultMaxUriLength)
6061
.When(x => x.Process is not null);
62+
63+
RuleFor(x => x.CreatedAfter)
64+
.LessThanOrEqualTo(x => x.CreatedBefore)
65+
.When(x => x.CreatedAfter is not null && x.CreatedBefore is not null)
66+
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);
67+
68+
RuleFor(x => x.DueAfter)
69+
.LessThanOrEqualTo(x => x.DueBefore)
70+
.When(x => x.DueAfter is not null && x.DueBefore is not null)
71+
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);
72+
73+
RuleFor(x => x.UpdatedAfter)
74+
.LessThanOrEqualTo(x => x.UpdatedBefore)
75+
.When(x => x.UpdatedAfter is not null && x.UpdatedBefore is not null)
76+
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);
77+
78+
RuleFor(x => x.VisibleAfter)
79+
.LessThanOrEqualTo(x => x.VisibleBefore)
80+
.When(x => x.VisibleAfter is not null && x.VisibleBefore is not null)
81+
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);
6182
}
6283
}

src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs

+6
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public static CreateDialogCommand GenerateFakeCreateDialogCommand(
3535
DateTimeOffset? updatedAt = null,
3636
DateTimeOffset? dueAt = null,
3737
DateTimeOffset? expiresAt = null,
38+
DateTimeOffset? visibleFrom = null,
3839
string? process = null,
3940
DialogStatus.Values? status = null,
4041
ContentDto? content = null,
@@ -62,6 +63,7 @@ public static CreateDialogCommand GenerateFakeCreateDialogCommand(
6263
updatedAt,
6364
dueAt,
6465
expiresAt,
66+
visibleFrom,
6567
process,
6668
status,
6769
content,
@@ -86,6 +88,7 @@ public static CreateDialogDto GenerateFakeDialog(
8688
DateTimeOffset? updatedAt = null,
8789
DateTimeOffset? dueAt = null,
8890
DateTimeOffset? expiresAt = null,
91+
DateTimeOffset? visibleFrom = null,
8992
string? process = null,
9093
DialogStatus.Values? status = null,
9194
ContentDto? content = null,
@@ -111,6 +114,7 @@ public static CreateDialogDto GenerateFakeDialog(
111114
updatedAt,
112115
dueAt,
113116
expiresAt,
117+
visibleFrom,
114118
process,
115119
status,
116120
content,
@@ -137,6 +141,7 @@ public static List<CreateDialogDto> GenerateFakeDialogs(int? seed = null,
137141
DateTimeOffset? updatedAt = null,
138142
DateTimeOffset? dueAt = null,
139143
DateTimeOffset? expiresAt = null,
144+
DateTimeOffset? visibleFrom = null,
140145
string? process = null,
141146
DialogStatus.Values? status = null,
142147
ContentDto? content = null,
@@ -159,6 +164,7 @@ public static List<CreateDialogDto> GenerateFakeDialogs(int? seed = null,
159164
.RuleFor(o => o.UpdatedAt, f => updatedAt ?? default)
160165
.RuleFor(o => o.DueAt, f => dueAt ?? f.Date.Future(10, RefTime))
161166
.RuleFor(o => o.ExpiresAt, f => expiresAt ?? f.Date.Future(20, RefTime.AddYears(11)))
167+
.RuleFor(o => o.VisibleFrom, _ => visibleFrom ?? null)
162168
.RuleFor(o => o.Status, f => status ?? f.PickRandom<DialogStatus.Values>())
163169
.RuleFor(o => o.Content, _ => content ?? GenerateFakeDialogContent())
164170
.RuleFor(o => o.SearchTags, _ => searchTags ?? GenerateFakeSearchTags())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using Digdir.Domain.Dialogporten.Domain.Parties;
2+
using Digdir.Tool.Dialogporten.GenerateFakeData;
3+
using static Digdir.Domain.Dialogporten.Application.Integration.Tests.Common.Common;
4+
5+
namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Common;
6+
7+
public sealed class DateFilterTestData
8+
{
9+
public int? AfterYear { get; init; }
10+
public int? BeforeYear { get; init; }
11+
public int ExpectedCount { get; init; }
12+
public required int[] ExpectedYears { get; init; }
13+
}
14+
15+
internal static class Common
16+
{
17+
internal static DateTimeOffset CreateDateFromYear(int year) => new(year, 1, 1, 0, 0, 0, TimeSpan.Zero);
18+
19+
internal const string UpdatedAt = "UpdatedAt";
20+
internal const string VisibleFrom = "VisibleFrom";
21+
internal const string DueAt = "DueAt";
22+
internal const string CreatedAt = "CreatedAt";
23+
24+
// Any party will do, required for EndUser search validation
25+
internal static string Party => NorwegianPersonIdentifier.PrefixWithSeparator + "03886595947";
26+
}
27+
28+
internal static class ApplicationExtensions
29+
{
30+
internal static async Task<Guid> CreateDialogWithDateInYear(this DialogApplication application, int year, string dateType)
31+
{
32+
var date = CreateDateFromYear(year);
33+
var createDialogCommand = dateType switch
34+
{
35+
UpdatedAt => DialogGenerator.GenerateFakeCreateDialogCommand(
36+
// Requires CreatedAt to be earlier than UpdatedAt
37+
createdAt: CreateDateFromYear(year - 1), updatedAt: date),
38+
39+
VisibleFrom => DialogGenerator.GenerateFakeCreateDialogCommand(
40+
// Requires DueAt to be later than VisibleFrom
41+
dueAt: CreateDateFromYear(year + 1), visibleFrom: date),
42+
43+
DueAt => DialogGenerator.GenerateFakeCreateDialogCommand(dueAt: date),
44+
CreatedAt => DialogGenerator.GenerateFakeCreateDialogCommand(createdAt: date),
45+
_ => throw new ArgumentException("Invalid date type", nameof(dateType))
46+
};
47+
48+
createDialogCommand.Dto.Party = Party;
49+
50+
var createCommandResponse = await application.Send(createDialogCommand);
51+
return createCommandResponse.AsT0.DialogId;
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Search;
2+
using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common;
3+
using FluentAssertions;
4+
using static Digdir.Domain.Dialogporten.Application.Integration.Tests.Common.Common;
5+
6+
namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.EndUser.Dialogs.Queries.Search;
7+
8+
[Collection(nameof(DialogCqrsCollectionFixture))]
9+
public class CreatedAtFilterTests : ApplicationCollectionFixture
10+
{
11+
public CreatedAtFilterTests(DialogApplication application) : base(application) { }
12+
13+
[Theory]
14+
[InlineData(2022, null, 2, new[] { 2022, 2023 })]
15+
[InlineData(null, 2021, 2, new[] { 2020, 2021 })]
16+
[InlineData(2021, 2022, 2, new[] { 2021, 2022 })]
17+
public async Task Should_Filter_On_Created_Date(int? createdAfterYear, int? createdBeforeYear, int expectedCount, int[] expectedYears)
18+
{
19+
// Arrange
20+
var dialogIn2020 = await Application.CreateDialogWithDateInYear(2020, CreatedAt);
21+
var dialogIn2021 = await Application.CreateDialogWithDateInYear(2021, CreatedAt);
22+
var dialogIn2022 = await Application.CreateDialogWithDateInYear(2022, CreatedAt);
23+
var dialogIn2023 = await Application.CreateDialogWithDateInYear(2023, CreatedAt);
24+
25+
// Act
26+
var response = await Application.Send(new SearchDialogQuery
27+
{
28+
Party = [Party],
29+
CreatedAfter = createdAfterYear.HasValue ? CreateDateFromYear(createdAfterYear.Value) : null,
30+
CreatedBefore = createdBeforeYear.HasValue ? CreateDateFromYear(createdBeforeYear.Value) : null
31+
});
32+
33+
// Assert
34+
response.TryPickT0(out var result, out _).Should().BeTrue();
35+
result.Should().NotBeNull();
36+
37+
result.Items.Should().HaveCount(expectedCount);
38+
foreach (var year in expectedYears)
39+
{
40+
var dialogId = year switch
41+
{
42+
2020 => dialogIn2020,
43+
2021 => dialogIn2021,
44+
2022 => dialogIn2022,
45+
2023 => dialogIn2023,
46+
_ => throw new ArgumentOutOfRangeException()
47+
};
48+
49+
result.Items.Should().ContainSingle(x => x.Id == dialogId);
50+
}
51+
}
52+
53+
[Fact]
54+
public async Task Cannot_Filter_On_Created_After_With_Value_Greater_Than_Created_Before()
55+
{
56+
// Act
57+
var response = await Application.Send(new SearchDialogQuery
58+
{
59+
Party = [Party],
60+
CreatedAfter = CreateDateFromYear(2022),
61+
CreatedBefore = CreateDateFromYear(2021)
62+
});
63+
64+
// Assert
65+
response.TryPickT1(out var result, out _).Should().BeTrue();
66+
result.Should().NotBeNull();
67+
}
68+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Search;
2+
using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common;
3+
using FluentAssertions;
4+
using static Digdir.Domain.Dialogporten.Application.Integration.Tests.Common.Common;
5+
6+
namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.EndUser.Dialogs.Queries.Search;
7+
8+
[Collection(nameof(DialogCqrsCollectionFixture))]
9+
public class DueAtFilterTests : ApplicationCollectionFixture
10+
{
11+
public DueAtFilterTests(DialogApplication application) : base(application) { }
12+
13+
[Theory, MemberData(nameof(DueAtTestData))]
14+
public async Task Should_Filter_On_Due_Date(DateFilterTestData testData)
15+
{
16+
// Arrange
17+
var currentYear = DateTimeOffset.UtcNow.Year;
18+
19+
var oneYearInTheFuture = currentYear + 1;
20+
var twoYearsInTheFuture = currentYear + 2;
21+
var threeYearsInTheFuture = currentYear + 3;
22+
var fourYearsInTheFuture = currentYear + 4;
23+
24+
var dialogOneYearInTheFuture = await Application.CreateDialogWithDateInYear(oneYearInTheFuture, DueAt);
25+
var dialogTwoYearsInTheFuture = await Application.CreateDialogWithDateInYear(twoYearsInTheFuture, DueAt);
26+
var dialogThreeYearsInTheFuture = await Application.CreateDialogWithDateInYear(threeYearsInTheFuture, DueAt);
27+
var dialogFourYearsInTheFuture = await Application.CreateDialogWithDateInYear(fourYearsInTheFuture, DueAt);
28+
29+
// Act
30+
var response = await Application.Send(new SearchDialogQuery
31+
{
32+
Party = [Party],
33+
DueAfter = testData.AfterYear.HasValue ? CreateDateFromYear(testData.AfterYear.Value) : null,
34+
DueBefore = testData.BeforeYear.HasValue ? CreateDateFromYear(testData.BeforeYear.Value) : null
35+
});
36+
37+
// Assert
38+
response.TryPickT0(out var result, out _).Should().BeTrue();
39+
result.Should().NotBeNull();
40+
41+
result.Items.Should().HaveCount(testData.ExpectedCount);
42+
foreach (var year in testData.ExpectedYears)
43+
{
44+
var dialogId = year switch
45+
{
46+
_ when year == oneYearInTheFuture => dialogOneYearInTheFuture,
47+
_ when year == twoYearsInTheFuture => dialogTwoYearsInTheFuture,
48+
_ when year == threeYearsInTheFuture => dialogThreeYearsInTheFuture,
49+
_ when year == fourYearsInTheFuture => dialogFourYearsInTheFuture,
50+
_ => throw new ArgumentOutOfRangeException()
51+
};
52+
53+
result.Items.Should().ContainSingle(x => x.Id == dialogId);
54+
}
55+
}
56+
57+
[Fact]
58+
public async Task Cannot_Filter_On_DueAfter_With_Value_Greater_Than_DueBefore()
59+
{
60+
// Act
61+
var response = await Application.Send(new SearchDialogQuery
62+
{
63+
Party = [Party],
64+
DueAfter = CreateDateFromYear(2022),
65+
DueBefore = CreateDateFromYear(2021)
66+
});
67+
68+
// Assert
69+
response.TryPickT1(out var result, out _).Should().BeTrue();
70+
result.Should().NotBeNull();
71+
}
72+
73+
public static IEnumerable<object[]> DueAtTestData()
74+
{
75+
var currentYear = DateTimeOffset.UtcNow.Year;
76+
77+
// The numbers added to "currentYear" here represent future years relative to the current year.
78+
// This is done to create test data for dialogs that are due "soon" (1 to 4 years ahead).
79+
// This approach ensures that the tests remain valid and relevant regardless of the current date.
80+
return new List<object[]>
81+
{
82+
new object[]
83+
{
84+
new DateFilterTestData
85+
{
86+
AfterYear = currentYear + 3,
87+
BeforeYear = null,
88+
ExpectedCount = 2,
89+
ExpectedYears = [currentYear + 3, currentYear + 4]
90+
}
91+
},
92+
new object[]
93+
{
94+
new DateFilterTestData
95+
{
96+
AfterYear = null,
97+
BeforeYear = currentYear + 2,
98+
ExpectedCount = 2,
99+
ExpectedYears = [currentYear + 1, currentYear + 2]
100+
}
101+
},
102+
new object[]
103+
{
104+
new DateFilterTestData
105+
{
106+
AfterYear = currentYear + 1,
107+
BeforeYear = currentYear + 2,
108+
ExpectedCount = 2,
109+
ExpectedYears = [currentYear + 1, currentYear + 2]
110+
}
111+
}
112+
};
113+
}
114+
}

0 commit comments

Comments
 (0)