diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs index ca76bf84f..b85799516 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs @@ -31,6 +31,20 @@ public CreateDialogCommandValidator( .IsValidUuidV7() .UuidV7TimestampIsInPast(); + RuleFor(x => x.CreatedAt) + .IsInPast(); + + RuleFor(x => x.CreatedAt) + .NotEmpty() + .WithMessage($"{{PropertyName}} must not be empty when '{nameof(CreateDialogCommand.UpdatedAt)} is set.") + .When(x => x.UpdatedAt != default); + + RuleFor(x => x.UpdatedAt) + .IsInPast() + .GreaterThanOrEqualTo(x => x.CreatedAt) + .WithMessage($"'{{PropertyName}}' must be greater than or equal to '{nameof(CreateDialogCommand.CreatedAt)}'.") + .When(x => x.CreatedAt != default && x.UpdatedAt != default); + RuleFor(x => x.ServiceResource) .NotNull() .IsValidUri() diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogDto.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogDto.cs index 0aaaf1312..036139396 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogDto.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogDto.cs @@ -95,7 +95,7 @@ public class CreateDialogDto /// If not supplied, the current date /time will be used. /// /// 2022-12-31T23:59:59Z - public DateTimeOffset UpdatedAt { get; set; } // TODO: This does not work, as UpdatedAt is always overwritten + public DateTimeOffset UpdatedAt { get; set; } /// /// The aggregated status of the dialog. diff --git a/src/Digdir.Library.Entity.Abstractions/Features/Aggregate/AggregateNode.cs b/src/Digdir.Library.Entity.Abstractions/Features/Aggregate/AggregateNode.cs index 9c729f4f2..297dca3fc 100644 --- a/src/Digdir.Library.Entity.Abstractions/Features/Aggregate/AggregateNode.cs +++ b/src/Digdir.Library.Entity.Abstractions/Features/Aggregate/AggregateNode.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Digdir.Library.Entity.Abstractions.Features.Updatable; namespace Digdir.Library.Entity.Abstractions.Features.Aggregate; @@ -88,6 +89,34 @@ internal static AggregateNode Create(Type type, object entity, AggregateNodeStat /// Convenience method to check if the state of the node is and not by a child node. /// public bool IsDirectlyModified() => State is AggregateNodeState.Modified && !ModifiedByChild; + + /// + /// Convenience method to check if the state of the node is . + /// + public bool IsAdded() => State is AggregateNodeState.Added; + + /// + /// Convenience method to check if the state of the node is . + /// + public bool IsModified() => State is AggregateNodeState.Modified; + + /// + /// Convenience method to check if the state of the node is . + /// + public bool IsRestored() => State is AggregateNodeState.Restored; + + /// + /// Convenience method to check if the state of the node is . + /// + public bool IsDeleted() => State is AggregateNodeState.Deleted; + + /// + /// Convenience method to check if the state of the node is , + /// and that UpdatedAt is the default value. + /// + /// + public bool IsAddedWithDefaultUpdatedAt(IUpdateableEntity updatable) + => IsAdded() && updatable.UpdatedAt == default; } /// diff --git a/src/Digdir.Library.Entity.EntityFrameworkCore/Features/Aggregate/AggregateExtensions.cs b/src/Digdir.Library.Entity.EntityFrameworkCore/Features/Aggregate/AggregateExtensions.cs index 6b3c38a78..3a6085906 100644 --- a/src/Digdir.Library.Entity.EntityFrameworkCore/Features/Aggregate/AggregateExtensions.cs +++ b/src/Digdir.Library.Entity.EntityFrameworkCore/Features/Aggregate/AggregateExtensions.cs @@ -24,29 +24,32 @@ internal static async Task HandleAggregateEntities(this ChangeTracker changeTrac foreach (var (_, aggregateNode) in aggregateNodeByEntry) { - if (aggregateNode.Entity is IAggregateCreatedHandler created && aggregateNode.State is AggregateNodeState.Added) + if (aggregateNode.Entity is IAggregateCreatedHandler created && aggregateNode.IsAdded()) { created.OnCreate(aggregateNode, utcNow); } - if (aggregateNode.Entity is IAggregateUpdatedHandler updated && aggregateNode.State is AggregateNodeState.Modified) + if (aggregateNode.Entity is IAggregateUpdatedHandler updated && aggregateNode.IsModified()) { updated.OnUpdate(aggregateNode, utcNow); } - if (aggregateNode.Entity is IAggregateDeletedHandler deleted && aggregateNode.State is AggregateNodeState.Deleted) + if (aggregateNode.Entity is IAggregateDeletedHandler deleted && aggregateNode.IsDeleted()) { deleted.OnDelete(aggregateNode, utcNow); } - if (aggregateNode.Entity is IAggregateRestoredHandler restored && aggregateNode.State is AggregateNodeState.Restored) + if (aggregateNode.Entity is IAggregateRestoredHandler restored && aggregateNode.IsRestored()) { restored.OnRestore(aggregateNode, utcNow); } if (aggregateNode.Entity is IUpdateableEntity updatable) { - updatable.Update(utcNow); + if (aggregateNode.IsModified() || aggregateNode.IsAddedWithDefaultUpdatedAt(updatable)) + { + updatable.Update(utcNow); + } } if (aggregateNode.Entity is IVersionableEntity versionable) diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs index 795c643cd..db17226ed 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs @@ -1,4 +1,5 @@ -using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; +using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Get; +using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions; using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; @@ -108,5 +109,98 @@ public async Task Create_CreateDialog_WhenDialogIsComplex() success.Value.Should().Be(expectedDialogId); } - // TODO: Add tests + [Fact] + public async Task Can_Create_Dialog_With_UpdatedAt_Supplied() + { + // Arrange + var dialogId = GenerateBigEndianUuidV7(); + var createdAt = DateTimeOffset.UtcNow.AddYears(-20); + var updatedAt = DateTimeOffset.UtcNow.AddYears(-15); + var createDialogCommand = DialogGenerator.GenerateFakeDialog(id: dialogId, updatedAt: updatedAt, createdAt: createdAt); + + // Act + var createDialogResult = await Application.Send(createDialogCommand); + var getDialogQuery = new GetDialogQuery + { + DialogId = dialogId + }; + + var getDialogResponse = await Application.Send(getDialogQuery); + + // Assert + createDialogResult.TryPickT0(out var dialogCreatedSuccess, out _).Should().BeTrue(); + dialogCreatedSuccess.Value.Should().Be(dialogId); + + getDialogQuery.Should().NotBeNull(); + getDialogResponse.TryPickT0(out var dialog, out _).Should().BeTrue(); + dialog.Should().NotBeNull(); + dialog.CreatedAt.Should().BeCloseTo(createdAt, precision: TimeSpan.FromMicroseconds(10)); + dialog.UpdatedAt.Should().BeCloseTo(updatedAt, precision: TimeSpan.FromMicroseconds(10)); + } + + [Fact] + public async Task Cant_Create_Dialog_With_UpdatedAt_Supplied_Without_CreatedAt_Supplied() + { + // Arrange + var updatedAt = DateTimeOffset.UtcNow.AddYears(-15); + var createDialogCommand = DialogGenerator.GenerateFakeDialog(updatedAt: updatedAt); + + // Act + var response = await Application.Send(createDialogCommand); + + // Assert + response.TryPickT2(out var validationError, out _).Should().BeTrue(); + validationError.Should().NotBeNull(); + validationError.Errors.Should().Contain(e => e.ErrorMessage.Contains(nameof(createDialogCommand.UpdatedAt))); + } + + [Fact] + public async Task Cant_Create_Dialog_With_UpdatedAt_Date_Earlier_Than_CreatedAt_Date() + { + // Arrange + var createdAt = DateTimeOffset.UtcNow.AddYears(-10); + var updatedAt = DateTimeOffset.UtcNow.AddYears(-15); + var createDialogCommand = DialogGenerator.GenerateFakeDialog(updatedAt: updatedAt, createdAt: createdAt); + + // Act + var response = await Application.Send(createDialogCommand); + + // Assert + response.TryPickT2(out var validationError, out _).Should().BeTrue(); + validationError.Should().NotBeNull(); + validationError.Errors.Should().Contain(e => e.ErrorMessage.Contains(nameof(createDialogCommand.CreatedAt))); + } + + [Fact] + public async Task Cant_Create_Dialog_With_UpdatedAt_Or_CreatedAt_In_The_Future() + { + // Arrange + var aYearFromNow = DateTimeOffset.UtcNow.AddYears(1); + var createDialogCommand = DialogGenerator + .GenerateFakeDialog(updatedAt: aYearFromNow, createdAt: aYearFromNow); + + // Act + var response = await Application.Send(createDialogCommand); + + // + response.TryPickT2(out var validationError, out _).Should().BeTrue(); + validationError.Should().NotBeNull(); + validationError.Errors.Should().Contain(e => e.ErrorMessage.Contains("in the past")); + } + + [Fact] + public async Task Can_Create_Dialog_With_UpdatedAt_And_CreatedAt_Being_Equal() + { + // Arrange + var aYearAgo = DateTimeOffset.UtcNow.AddYears(-1); + var createDialogCommand = DialogGenerator + .GenerateFakeDialog(updatedAt: aYearAgo, createdAt: aYearAgo); + + // Act + var response = await Application.Send(createDialogCommand); + + // Assert + response.TryPickT0(out var success, out _).Should().BeTrue(); + success.Should().NotBeNull(); + } }