diff --git a/src/Digdir.Domain.Dialogporten.Application/ApplicationExtensions.cs b/src/Digdir.Domain.Dialogporten.Application/ApplicationExtensions.cs index 0dcc770dd..4b71fbd39 100644 --- a/src/Digdir.Domain.Dialogporten.Application/ApplicationExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.Application/ApplicationExtensions.cs @@ -2,7 +2,6 @@ using Digdir.Domain.Dialogporten.Application.Common.Behaviours; using Digdir.Domain.Dialogporten.Application.Common.Extensions; using Digdir.Domain.Dialogporten.Application.Common.Extensions.OptionExtensions; -using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations; using FluentValidation; using MediatR; using Microsoft.Extensions.Configuration; diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Localizations/MappingProfile.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Localizations/MappingProfile.cs index 3eb08b27d..49c177bec 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Localizations/MappingProfile.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Localizations/MappingProfile.cs @@ -1,5 +1,4 @@ -using System.Collections; -using AutoMapper; +using AutoMapper; using Digdir.Domain.Dialogporten.Domain.Localizations; namespace Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations; diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Delete/DeleteDialogCommand.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Delete/DeleteDialogCommand.cs index a1e962c19..024a47681 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Delete/DeleteDialogCommand.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Delete/DeleteDialogCommand.cs @@ -43,6 +43,7 @@ public async Task Handle(DeleteDialogCommand request, Cancel var dialog = await _db.Dialogs // Load the elements so that we notify them of their deletion. (This won't work due to https://github.com/digdir/dialogporten/issues/288) .Include(x => x.Elements) + .Include(x => x.Activities) .Where(x => resourceIds.Contains(x.ServiceResource)) .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); @@ -58,7 +59,7 @@ public async Task Handle(DeleteDialogCommand request, Cancel return saveResult.Match( success => success, - domainError => throw new UnreachableException("Should never get a domain error when creating a new dialog"), + domainError => throw new UnreachableException("Should never get a domain error when deleting a dialog"), concurrencyError => concurrencyError); } } diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Purge/PurgeDialogCommand.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Purge/PurgeDialogCommand.cs new file mode 100644 index 000000000..7ae2079f8 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Purge/PurgeDialogCommand.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; +using Digdir.Domain.Dialogporten.Application.Common; +using Digdir.Domain.Dialogporten.Application.Common.ReturnTypes; +using Digdir.Domain.Dialogporten.Application.Externals; +using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; +using Digdir.Library.Entity.EntityFrameworkCore.Features.SoftDeletable; +using MediatR; +using Microsoft.EntityFrameworkCore; +using OneOf; +using OneOf.Types; + +namespace Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Purge; +public sealed class PurgeDialogCommand : IRequest +{ + public Guid Id { get; set; } + public Guid? IfMatchDialogRevision { get; set; } +} + +[GenerateOneOf] +public partial class PurgeDialogResult : OneOfBase; + +internal sealed class PurgeDialogCommandHandler : IRequestHandler +{ + private readonly IDialogDbContext _db; + private readonly IUnitOfWork _unitOfWork; + private readonly IUserResourceRegistry _userResourceRegistry; + + public PurgeDialogCommandHandler( + IDialogDbContext db, + IUnitOfWork unitOfWork, + IUserResourceRegistry userResourceRegistry) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); + _userResourceRegistry = userResourceRegistry ?? throw new ArgumentNullException(nameof(userResourceRegistry)); + } + + public async Task Handle(PurgeDialogCommand request, CancellationToken cancellationToken) + { + var resourceIds = await _userResourceRegistry.GetCurrentUserResourceIds(cancellationToken); + + var dialog = await _db.Dialogs + .Include(x => x.Elements) + .Include(x => x.Activities) + .Where(x => resourceIds.Contains(x.ServiceResource)) + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + if (dialog is null) + { + return new EntityNotFound(request.Id); + } + + _db.Dialogs.HardRemove(dialog); + var saveResult = await _unitOfWork + .EnableConcurrencyCheck(dialog, request.IfMatchDialogRevision) + .SaveChangesAsync(cancellationToken); + + return saveResult.Match( + success => success, + domainError => throw new UnreachableException("Should never get a domain error when deleting a dialog"), + concurrencyError => concurrencyError); + } +} diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommand.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommand.cs index ee9b7eba6..41f553d82 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommand.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommand.cs @@ -3,13 +3,11 @@ using Digdir.Domain.Dialogporten.Application.Common.Extensions.Enumerables; using Digdir.Domain.Dialogporten.Application.Common.ReturnTypes; using Digdir.Domain.Dialogporten.Application.Externals; -using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Actions; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Content; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Elements; -using Digdir.Library.Entity.Abstractions; using MediatR; using Microsoft.EntityFrameworkCore; using OneOf; diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/Dialogs/PurgeDialogEndpoint.cs b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/Dialogs/PurgeDialogEndpoint.cs new file mode 100644 index 000000000..1a6baa7ca --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/Dialogs/PurgeDialogEndpoint.cs @@ -0,0 +1,69 @@ +using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Purge; +using Digdir.Domain.Dialogporten.WebApi.Common; +using Digdir.Domain.Dialogporten.WebApi.Common.Authorization; +using Digdir.Domain.Dialogporten.WebApi.Common.Extensions; +using FastEndpoints; +using MediatR; + +namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.Dialogs; + +public sealed class PurgeDialogEndpoint : Endpoint +{ + private readonly ISender _sender; + + public PurgeDialogEndpoint(ISender sender) + { + _sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public override void Configure() + { + Post("dialogs/{dialogId}/actions/purge"); + Policies(AuthorizationPolicy.ServiceProvider); + Group(); + + Description(b => b + .OperationId("PurgeDialog") + .ProducesOneOf( + StatusCodes.Status204NoContent, + StatusCodes.Status404NotFound, + StatusCodes.Status412PreconditionFailed) + ); + } + + public override async Task HandleAsync(PurgeDialogRequest req, CancellationToken ct) + { + var command = new PurgeDialogCommand { Id = req.DialogId, IfMatchDialogRevision = req.IfMatchDialogRevision }; + var result = await _sender.Send(command, ct); + await result.Match( + success => SendNoContentAsync(ct), + notFound => this.NotFoundAsync(notFound, ct), + concurrencyError => this.PreconditionFailed(ct)); + } +} + +public sealed class PurgeDialogRequest +{ + public Guid DialogId { get; set; } + + [FromHeader(headerName: Constants.IfMatch, isRequired: false, removeFromSchema: true)] + public Guid? IfMatchDialogRevision { get; set; } +} + +public sealed class PurgeDialogEndpointSummary : Summary +{ + public PurgeDialogEndpointSummary() + { + Summary = "Permanently deletes a dialog"; + Description = """ + Deletes a given dialog (hard delete). For more information see the documentation (link TBD). + + Optimistic concurrency control is implemented using the If-Match header. Supply the Revision value from the GetDialog endpoint to ensure that the dialog is not deleted by another request in the meantime. + """; + Responses[StatusCodes.Status204NoContent] = Constants.SwaggerSummary.Deleted.FormatInvariant("aggregate"); + Responses[StatusCodes.Status401Unauthorized] = Constants.SwaggerSummary.ServiceOwnerAuthenticationFailure.FormatInvariant(AuthorizationScope.ServiceProvider); + Responses[StatusCodes.Status403Forbidden] = Constants.SwaggerSummary.AccessDeniedToDialog.FormatInvariant("delete"); + Responses[StatusCodes.Status404NotFound] = Constants.SwaggerSummary.DialogNotFound; + Responses[StatusCodes.Status412PreconditionFailed] = Constants.SwaggerSummary.RevisionMismatch; + } +} diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Common/DialogApplication.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Common/DialogApplication.cs index f9397be79..8ebfe89b0 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Common/DialogApplication.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Common/DialogApplication.cs @@ -158,7 +158,7 @@ private async Task BuildRespawnState() public async Task PublishOutBoxMessages() { - var outBoxMessages = GetDbEntities(); + var outBoxMessages = await GetDbEntities(); var eventAssembly = typeof(OutboxMessage).Assembly; foreach (var outboxMessage in outBoxMessages) { @@ -179,11 +179,14 @@ public List PopPublishedCloudEvents() return events; } - private List GetDbEntities() where T : class + public async Task> GetDbEntities() where T : class { using var scope = _rootProvider.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - return db.Set().ToList(); + return await db + .Set() + .AsNoTracking() + .ToListAsync(); } private ReadOnlyCollection GetLookupTables() diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs index 8f3d55bc5..89a895557 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs @@ -1,4 +1,5 @@ using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Events; +using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Purge; using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Update; using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Get; using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; @@ -159,6 +160,7 @@ public async Task Creates_CloudEvent_When_DialogElement_Updates() cloudEvent.Type == CloudEventTypes.Get(nameof(DialogElementUpdatedDomainEvent))); } + // Throws NRE on parent Dialog [Fact(Skip = "This is currently broken, will be fixed/rewritten in https://github.com/digdir/dialogporten/pull/406")] public async Task Creates_CloudEvents_When_Deleting_DialogElement() { @@ -191,7 +193,7 @@ public async Task Creates_CloudEvents_When_Deleting_DialogElement() var cloudEvents = Application.PopPublishedCloudEvents(); // Assert - cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.ResourceInstance == createDialogCommand.Elements[0].Id.ToString()); + cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.ResourceInstance == dialogId.ToString()); cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.Resource == createDialogCommand.ServiceResource); cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.Subject == createDialogCommand.Party); @@ -199,6 +201,7 @@ public async Task Creates_CloudEvents_When_Deleting_DialogElement() cloudEvent.Type == CloudEventTypes.Get(nameof(DialogElementDeletedDomainEvent))); } + // Creates DialogUpdatedDomainEvent instead of DialogDeletedDomainEvent [Fact(Skip = "This is currently broken, will be fixed/rewritten in https://github.com/digdir/dialogporten/pull/406")] public async Task Creates_CloudEvents_When_Dialog_Deleted() { @@ -226,4 +229,64 @@ public async Task Creates_CloudEvents_When_Dialog_Deleted() cloudEvents.Should().ContainSingle(cloudEvent => cloudEvent.Type == CloudEventTypes.Get(nameof(DialogDeletedDomainEvent))); } + + [Fact] + public async Task Creates_DialogDeletedEvent_When_Dialog_Purged() + { + // Arrange + var dialogId = Guid.NewGuid(); + var createDialogCommand = DialogGenerator.GenerateFakeDialog(id: dialogId, elements: [], activities: []); + + _ = await Application.Send(createDialogCommand); + + // Act + var purgeCommand = new PurgeDialogCommand + { + Id = dialogId + }; + + await Application.Send(purgeCommand); + await Application.PublishOutBoxMessages(); + var cloudEvents = Application.PopPublishedCloudEvents(); + + // Assert + cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.ResourceInstance == dialogId.ToString()); + cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.Resource == createDialogCommand.ServiceResource); + cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.Subject == createDialogCommand.Party); + + cloudEvents.Should().ContainSingle(cloudEvent => + cloudEvent.Type == CloudEventTypes.Get(nameof(DialogDeletedDomainEvent))); + } + + [Fact] + public async Task Creates_DialogElementDeleted_CloudEvent_When_Purging_Dialog() + { + // Arrange + var dialogId = Guid.NewGuid(); + var createDialogCommand = DialogGenerator.GenerateFakeDialog( + id: dialogId, + activities: [], + elements: [DialogGenerator.GenerateFakeDialogElement()]); + + await Application.Send(createDialogCommand); + + // Act + var purgeCommand = new PurgeDialogCommand + { + Id = dialogId + }; + + await Application.Send(purgeCommand); + + await Application.PublishOutBoxMessages(); + var cloudEvents = Application.PopPublishedCloudEvents(); + + // Assert + cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.ResourceInstance == dialogId.ToString()); + cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.Resource == createDialogCommand.ServiceResource); + cloudEvents.Should().OnlyContain(cloudEvent => cloudEvent.Subject == createDialogCommand.Party); + + cloudEvents.Should().ContainSingle(cloudEvent => + cloudEvent.Type == CloudEventTypes.Get(nameof(DialogElementDeletedDomainEvent))); + } } diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Commands/CreateDialogTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Commands/CreateDialogTests.cs index dc00857fc..109c15fce 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Commands/CreateDialogTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Commands/CreateDialogTests.cs @@ -1,11 +1,4 @@ -using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Create; -using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; -using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; -using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Actions; -using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities; -using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Content; -using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Elements; -using Digdir.Domain.Dialogporten.Domain.Http; +using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions; diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Commands/PurgeDialogTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Commands/PurgeDialogTests.cs new file mode 100644 index 000000000..e59a20876 --- /dev/null +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Commands/PurgeDialogTests.cs @@ -0,0 +1,73 @@ +using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Purge; +using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; +using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; +using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities; +using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Elements; +using Digdir.Tool.Dialogporten.GenerateFakeData; +using FluentAssertions; + +namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.Dialogs.Commands; + +[Collection(nameof(DialogCqrsCollectionFixture))] +public class PurgeDialogTests(DialogApplication application) : ApplicationCollectionFixture(application) +{ + [Fact] + public async Task Purge_RemovesDialog_FromDatabase() + { + // Arrange + var expectedDialogId = Guid.NewGuid(); + var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); + var createResponse = await Application.Send(createCommand); + createResponse.TryPickT0(out _, out _).Should().BeTrue(); + + // Act + var purgeCommand = new PurgeDialogCommand { Id = expectedDialogId }; + var purgeResponse = await Application.Send(purgeCommand); + + // Assert + purgeResponse.TryPickT0(out _, out _).Should().BeTrue(); + + var dialogEntities = await Application.GetDbEntities(); + dialogEntities.Should().BeEmpty(); + + var dialogElements = await Application.GetDbEntities(); + dialogElements.Should().BeEmpty(); + + var dialogActivities = await Application.GetDbEntities(); + dialogActivities.Should().BeEmpty(); + } + + [Fact] + public async Task Purge_ReturnsConcurrencyError_OnIfMatchDialogRevisionMismatch() + { + // Arrange + var expectedDialogId = Guid.NewGuid(); + var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); + var createResponse = await Application.Send(createCommand); + createResponse.TryPickT0(out _, out _).Should().BeTrue(); + + // Act + var purgeCommand = new PurgeDialogCommand { Id = expectedDialogId, IfMatchDialogRevision = Guid.NewGuid() }; + var purgeResponse = await Application.Send(purgeCommand); + + // Assert + purgeResponse.TryPickT2(out _, out _).Should().BeTrue(); + } + + [Fact] + public async Task Purge_ReturnsNotFound_OnNonExistingDialog() + { + // Arrange + var expectedDialogId = Guid.NewGuid(); + var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); + await Application.Send(createCommand); + var purgeCommand = new PurgeDialogCommand { Id = expectedDialogId }; + await Application.Send(purgeCommand); + + // Act + var purgeResponse = await Application.Send(purgeCommand); + + // Assert + purgeResponse.TryPickT1(out _, out _).Should().BeTrue(); + } +} diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Queries/GetDialogTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Queries/GetDialogTests.cs index a763b0e3b..24adde274 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Queries/GetDialogTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Dialogs/Queries/GetDialogTests.cs @@ -1,12 +1,5 @@ -using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Create; -using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Get; +using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Get; using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; -using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; -using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Actions; -using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities; -using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Content; -using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Elements; -using Digdir.Domain.Dialogporten.Domain.Http; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions;