Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#310 Add hard delete (purge) functionallity separate from soft delete. #483

Merged
merged 7 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public async Task<DeleteDialogResult> 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);

Expand All @@ -58,7 +59,7 @@ public async Task<DeleteDialogResult> Handle(DeleteDialogCommand request, Cancel

return saveResult.Match<DeleteDialogResult>(
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);
}
}
Original file line number Diff line number Diff line change
@@ -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<PurgeDialogResult>
{
public Guid Id { get; set; }
public Guid? IfMatchDialogRevision { get; set; }
}

[GenerateOneOf]
public partial class PurgeDialogResult : OneOfBase<Success, EntityNotFound, ConcurrencyError>;

internal sealed class PurgeDialogCommandHandler : IRequestHandler<PurgeDialogCommand, PurgeDialogResult>
{
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<PurgeDialogResult> 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<DialogEntity>(request.Id);
}

_db.Dialogs.HardRemove(dialog);
var saveResult = await _unitOfWork
.EnableConcurrencyCheck(dialog, request.IfMatchDialogRevision)
.SaveChangesAsync(cancellationToken);

return saveResult.Match<PurgeDialogResult>(
success => success,
domainError => throw new UnreachableException("Should never get a domain error when deleting a dialog"),
concurrencyError => concurrencyError);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PurgeDialogRequest>
{
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<ServiceOwnerGroup>();

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<PurgeDialogEndpoint>
{
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ private async Task BuildRespawnState()

public async Task PublishOutBoxMessages()
{
var outBoxMessages = GetDbEntities<OutboxMessage>();
var outBoxMessages = await GetDbEntities<OutboxMessage>();
var eventAssembly = typeof(OutboxMessage).Assembly;
foreach (var outboxMessage in outBoxMessages)
{
Expand All @@ -179,11 +179,14 @@ public List<CloudEvent> PopPublishedCloudEvents()
return events;
}

private List<T> GetDbEntities<T>() where T : class
public async Task<List<T>> GetDbEntities<T>() where T : class
{
using var scope = _rootProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<DialogDbContext>();
return db.Set<T>().ToList();
return await db
.Set<T>()
.AsNoTracking()
.ToListAsync();
}

private ReadOnlyCollection<Table> GetLookupTables()
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -191,14 +193,15 @@ 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);

cloudEvents.Should().ContainSingle(cloudEvent =>
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()
{
Expand Down Expand Up @@ -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)));
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<DialogEntity>();
dialogEntities.Should().BeEmpty();

var dialogElements = await Application.GetDbEntities<DialogElement>();
dialogElements.Should().BeEmpty();

var dialogActivities = await Application.GetDbEntities<DialogActivity>();
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();
}
}
Loading
Loading