Skip to content

Commit

Permalink
fix: Disallow search filter with CreatedAfter greater than CreatedBef…
Browse files Browse the repository at this point in the history
…ore (#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)
  • Loading branch information
oskogstad authored Mar 7, 2025
1 parent 584b71c commit 75af11e
Show file tree
Hide file tree
Showing 13 changed files with 713 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Digdir.Domain.Dialogporten.Application.Features.V1.Common;

internal static class ValidationErrorStrings
{
internal const string PropertyNameMustBeLessThanOrEqualToComparisonProperty =
"'{PropertyName}' must be less than or equal to '{ComparisonProperty}'.";
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Digdir.Domain.Dialogporten.Domain.Common;
using Digdir.Domain.Dialogporten.Domain.Localizations;
using FluentValidation;
using static Digdir.Domain.Dialogporten.Application.Features.V1.Common.ValidationErrorStrings;

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

Expand Down Expand Up @@ -52,5 +53,20 @@ public SearchDialogQueryValidator()

RuleForEach(x => x.Status).IsInEnum();
RuleForEach(x => x.SystemLabel).IsInEnum();

RuleFor(x => x.CreatedAfter)
.LessThanOrEqualTo(x => x.CreatedBefore)
.When(x => x.CreatedAfter is not null && x.CreatedBefore is not null)
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);

RuleFor(x => x.DueAfter)
.LessThanOrEqualTo(x => x.DueBefore)
.When(x => x.DueAfter is not null && x.DueBefore is not null)
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);

RuleFor(x => x.UpdatedAfter)
.LessThanOrEqualTo(x => x.UpdatedBefore)
.When(x => x.UpdatedAfter is not null && x.UpdatedBefore is not null)
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Digdir.Domain.Dialogporten.Domain.Parties;
using Digdir.Domain.Dialogporten.Domain.Parties.Abstractions;
using FluentValidation;
using static Digdir.Domain.Dialogporten.Application.Features.V1.Common.ValidationErrorStrings;

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

Expand Down Expand Up @@ -58,5 +59,25 @@ public SearchDialogQueryValidator()
.IsValidUri()
.MaximumLength(Constants.DefaultMaxUriLength)
.When(x => x.Process is not null);

RuleFor(x => x.CreatedAfter)
.LessThanOrEqualTo(x => x.CreatedBefore)
.When(x => x.CreatedAfter is not null && x.CreatedBefore is not null)
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);

RuleFor(x => x.DueAfter)
.LessThanOrEqualTo(x => x.DueBefore)
.When(x => x.DueAfter is not null && x.DueBefore is not null)
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);

RuleFor(x => x.UpdatedAfter)
.LessThanOrEqualTo(x => x.UpdatedBefore)
.When(x => x.UpdatedAfter is not null && x.UpdatedBefore is not null)
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);

RuleFor(x => x.VisibleAfter)
.LessThanOrEqualTo(x => x.VisibleBefore)
.When(x => x.VisibleAfter is not null && x.VisibleBefore is not null)
.WithMessage(PropertyNameMustBeLessThanOrEqualToComparisonProperty);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public static CreateDialogCommand GenerateFakeCreateDialogCommand(
DateTimeOffset? updatedAt = null,
DateTimeOffset? dueAt = null,
DateTimeOffset? expiresAt = null,
DateTimeOffset? visibleFrom = null,
string? process = null,
DialogStatus.Values? status = null,
ContentDto? content = null,
Expand Down Expand Up @@ -62,6 +63,7 @@ public static CreateDialogCommand GenerateFakeCreateDialogCommand(
updatedAt,
dueAt,
expiresAt,
visibleFrom,
process,
status,
content,
Expand All @@ -86,6 +88,7 @@ public static CreateDialogDto GenerateFakeDialog(
DateTimeOffset? updatedAt = null,
DateTimeOffset? dueAt = null,
DateTimeOffset? expiresAt = null,
DateTimeOffset? visibleFrom = null,
string? process = null,
DialogStatus.Values? status = null,
ContentDto? content = null,
Expand All @@ -111,6 +114,7 @@ public static CreateDialogDto GenerateFakeDialog(
updatedAt,
dueAt,
expiresAt,
visibleFrom,
process,
status,
content,
Expand All @@ -137,6 +141,7 @@ public static List<CreateDialogDto> GenerateFakeDialogs(int? seed = null,
DateTimeOffset? updatedAt = null,
DateTimeOffset? dueAt = null,
DateTimeOffset? expiresAt = null,
DateTimeOffset? visibleFrom = null,
string? process = null,
DialogStatus.Values? status = null,
ContentDto? content = null,
Expand All @@ -159,6 +164,7 @@ public static List<CreateDialogDto> GenerateFakeDialogs(int? seed = null,
.RuleFor(o => o.UpdatedAt, f => updatedAt ?? default)
.RuleFor(o => o.DueAt, f => dueAt ?? f.Date.Future(10, RefTime))
.RuleFor(o => o.ExpiresAt, f => expiresAt ?? f.Date.Future(20, RefTime.AddYears(11)))
.RuleFor(o => o.VisibleFrom, _ => visibleFrom ?? null)
.RuleFor(o => o.Status, f => status ?? f.PickRandom<DialogStatus.Values>())
.RuleFor(o => o.Content, _ => content ?? GenerateFakeDialogContent())
.RuleFor(o => o.SearchTags, _ => searchTags ?? GenerateFakeSearchTags())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using Digdir.Domain.Dialogporten.Domain.Parties;
using Digdir.Tool.Dialogporten.GenerateFakeData;
using static Digdir.Domain.Dialogporten.Application.Integration.Tests.Common.Common;

namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Common;

public sealed class DateFilterTestData
{
public int? AfterYear { get; init; }
public int? BeforeYear { get; init; }
public int ExpectedCount { get; init; }
public required int[] ExpectedYears { get; init; }
}

internal static class Common
{
internal static DateTimeOffset CreateDateFromYear(int year) => new(year, 1, 1, 0, 0, 0, TimeSpan.Zero);

internal const string UpdatedAt = "UpdatedAt";
internal const string VisibleFrom = "VisibleFrom";
internal const string DueAt = "DueAt";
internal const string CreatedAt = "CreatedAt";

// Any party will do, required for EndUser search validation
internal static string Party => NorwegianPersonIdentifier.PrefixWithSeparator + "03886595947";
}

internal static class ApplicationExtensions
{
internal static async Task<Guid> CreateDialogWithDateInYear(this DialogApplication application, int year, string dateType)
{
var date = CreateDateFromYear(year);
var createDialogCommand = dateType switch
{
UpdatedAt => DialogGenerator.GenerateFakeCreateDialogCommand(
// Requires CreatedAt to be earlier than UpdatedAt
createdAt: CreateDateFromYear(year - 1), updatedAt: date),

VisibleFrom => DialogGenerator.GenerateFakeCreateDialogCommand(
// Requires DueAt to be later than VisibleFrom
dueAt: CreateDateFromYear(year + 1), visibleFrom: date),

DueAt => DialogGenerator.GenerateFakeCreateDialogCommand(dueAt: date),
CreatedAt => DialogGenerator.GenerateFakeCreateDialogCommand(createdAt: date),
_ => throw new ArgumentException("Invalid date type", nameof(dateType))
};

createDialogCommand.Dto.Party = Party;

var createCommandResponse = await application.Send(createDialogCommand);
return createCommandResponse.AsT0.DialogId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Search;
using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common;
using FluentAssertions;
using static Digdir.Domain.Dialogporten.Application.Integration.Tests.Common.Common;

namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.EndUser.Dialogs.Queries.Search;

[Collection(nameof(DialogCqrsCollectionFixture))]
public class CreatedAtFilterTests : ApplicationCollectionFixture
{
public CreatedAtFilterTests(DialogApplication application) : base(application) { }

[Theory]
[InlineData(2022, null, 2, new[] { 2022, 2023 })]
[InlineData(null, 2021, 2, new[] { 2020, 2021 })]
[InlineData(2021, 2022, 2, new[] { 2021, 2022 })]
public async Task Should_Filter_On_Created_Date(int? createdAfterYear, int? createdBeforeYear, int expectedCount, int[] expectedYears)
{
// Arrange
var dialogIn2020 = await Application.CreateDialogWithDateInYear(2020, CreatedAt);
var dialogIn2021 = await Application.CreateDialogWithDateInYear(2021, CreatedAt);
var dialogIn2022 = await Application.CreateDialogWithDateInYear(2022, CreatedAt);
var dialogIn2023 = await Application.CreateDialogWithDateInYear(2023, CreatedAt);

// Act
var response = await Application.Send(new SearchDialogQuery
{
Party = [Party],
CreatedAfter = createdAfterYear.HasValue ? CreateDateFromYear(createdAfterYear.Value) : null,
CreatedBefore = createdBeforeYear.HasValue ? CreateDateFromYear(createdBeforeYear.Value) : null
});

// Assert
response.TryPickT0(out var result, out _).Should().BeTrue();
result.Should().NotBeNull();

result.Items.Should().HaveCount(expectedCount);
foreach (var year in expectedYears)
{
var dialogId = year switch
{
2020 => dialogIn2020,
2021 => dialogIn2021,
2022 => dialogIn2022,
2023 => dialogIn2023,
_ => throw new ArgumentOutOfRangeException()
};

result.Items.Should().ContainSingle(x => x.Id == dialogId);
}
}

[Fact]
public async Task Cannot_Filter_On_Created_After_With_Value_Greater_Than_Created_Before()
{
// Act
var response = await Application.Send(new SearchDialogQuery
{
Party = [Party],
CreatedAfter = CreateDateFromYear(2022),
CreatedBefore = CreateDateFromYear(2021)
});

// Assert
response.TryPickT1(out var result, out _).Should().BeTrue();
result.Should().NotBeNull();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Search;
using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common;
using FluentAssertions;
using static Digdir.Domain.Dialogporten.Application.Integration.Tests.Common.Common;

namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.EndUser.Dialogs.Queries.Search;

[Collection(nameof(DialogCqrsCollectionFixture))]
public class DueAtFilterTests : ApplicationCollectionFixture
{
public DueAtFilterTests(DialogApplication application) : base(application) { }

[Theory, MemberData(nameof(DueAtTestData))]
public async Task Should_Filter_On_Due_Date(DateFilterTestData testData)
{
// Arrange
var currentYear = DateTimeOffset.UtcNow.Year;

var oneYearInTheFuture = currentYear + 1;
var twoYearsInTheFuture = currentYear + 2;
var threeYearsInTheFuture = currentYear + 3;
var fourYearsInTheFuture = currentYear + 4;

var dialogOneYearInTheFuture = await Application.CreateDialogWithDateInYear(oneYearInTheFuture, DueAt);
var dialogTwoYearsInTheFuture = await Application.CreateDialogWithDateInYear(twoYearsInTheFuture, DueAt);
var dialogThreeYearsInTheFuture = await Application.CreateDialogWithDateInYear(threeYearsInTheFuture, DueAt);
var dialogFourYearsInTheFuture = await Application.CreateDialogWithDateInYear(fourYearsInTheFuture, DueAt);

// Act
var response = await Application.Send(new SearchDialogQuery
{
Party = [Party],
DueAfter = testData.AfterYear.HasValue ? CreateDateFromYear(testData.AfterYear.Value) : null,
DueBefore = testData.BeforeYear.HasValue ? CreateDateFromYear(testData.BeforeYear.Value) : null
});

// Assert
response.TryPickT0(out var result, out _).Should().BeTrue();
result.Should().NotBeNull();

result.Items.Should().HaveCount(testData.ExpectedCount);
foreach (var year in testData.ExpectedYears)
{
var dialogId = year switch
{
_ when year == oneYearInTheFuture => dialogOneYearInTheFuture,
_ when year == twoYearsInTheFuture => dialogTwoYearsInTheFuture,
_ when year == threeYearsInTheFuture => dialogThreeYearsInTheFuture,
_ when year == fourYearsInTheFuture => dialogFourYearsInTheFuture,
_ => throw new ArgumentOutOfRangeException()
};

result.Items.Should().ContainSingle(x => x.Id == dialogId);
}
}

[Fact]
public async Task Cannot_Filter_On_DueAfter_With_Value_Greater_Than_DueBefore()
{
// Act
var response = await Application.Send(new SearchDialogQuery
{
Party = [Party],
DueAfter = CreateDateFromYear(2022),
DueBefore = CreateDateFromYear(2021)
});

// Assert
response.TryPickT1(out var result, out _).Should().BeTrue();
result.Should().NotBeNull();
}

public static IEnumerable<object[]> DueAtTestData()
{
var currentYear = DateTimeOffset.UtcNow.Year;

// The numbers added to "currentYear" here represent future years relative to the current year.
// This is done to create test data for dialogs that are due "soon" (1 to 4 years ahead).
// This approach ensures that the tests remain valid and relevant regardless of the current date.
return new List<object[]>
{
new object[]
{
new DateFilterTestData
{
AfterYear = currentYear + 3,
BeforeYear = null,
ExpectedCount = 2,
ExpectedYears = [currentYear + 3, currentYear + 4]
}
},
new object[]
{
new DateFilterTestData
{
AfterYear = null,
BeforeYear = currentYear + 2,
ExpectedCount = 2,
ExpectedYears = [currentYear + 1, currentYear + 2]
}
},
new object[]
{
new DateFilterTestData
{
AfterYear = currentYear + 1,
BeforeYear = currentYear + 2,
ExpectedCount = 2,
ExpectedYears = [currentYear + 1, currentYear + 2]
}
}
};
}
}
Loading

0 comments on commit 75af11e

Please sign in to comment.