Skip to content
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
@@ -0,0 +1,144 @@
using AutoStack.Application.Common.Models;
using AutoStack.Application.Features.Users.Commands.DeleteAccount;
using AutoStack.Application.Tests.Builders;
using AutoStack.Application.Tests.Common;
using AutoStack.Domain.Entities;
using AutoStack.Domain.Enums;
using FluentAssertions;
using Moq;
using Xunit;

namespace AutoStack.Application.Tests.Features.Users.Commands.DeleteAccount;

public class DeleteAccountCommandHandlerTests : CommandHandlerTestBase
{
private readonly DeleteAccountCommandHandler _handler;

public DeleteAccountCommandHandlerTests()
{
_handler = new DeleteAccountCommandHandler(
MockUserRepository.Object,
MockUnitOfWork.Object,
MockAuditLog.Object
);
}

[Fact]
public async Task Handle_WithoutUserId_ShouldReturnFailure()
{
var command = new DeleteAccountCommand(null);

var result = await _handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeFalse();
result.Message.Should().Be("User ID is required");
}

[Fact]
public async Task Handle_WithNonExistingUserId_ShouldReturnFailure()
{
var userId = Guid.NewGuid();
var command = new DeleteAccountCommand(userId);

MockUserRepository.Setup(r => r.GetByIdAsync(userId, It.IsAny<CancellationToken>()))
.ReturnsAsync((User?)null);

var result = await _handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeFalse();
result.Message.Should().Be("User not found.");

// Makes sure DeleteAsync was not called
MockUserRepository.Verify(r => r.DeleteAsync(It.IsAny<User>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public async Task Handle_WithExistingUserId_ShouldReturnSuccess()
{
var user = new UserBuilder()
.WithUsername("testuser")
.WithEmail("test@example.com")
.Build();

MockUserRepository.Setup(r => r.GetByIdAsync(user.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(user);

var command = new DeleteAccountCommand(user.Id);
var result = await _handler.Handle(command, CancellationToken.None);
result.IsSuccess.Should().BeTrue();

MockUserRepository.Verify(r => r.DeleteAsync(user, It.IsAny<CancellationToken>()), Times.Once);
MockUnitOfWork.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
MockAuditLog.Verify(a => a.LogAsync(
It.Is<AuditLogRequest>(req =>
req.Level == LogLevel.Information &&
req.Category == LogCategory.User &&
req.Message == "Account deleted" &&
req.UserIdOverride == user.Id &&
req.UsernameOverride == user.Username),
It.IsAny<CancellationToken>()),
Times.Once);
}

[Fact]
public async Task Handle_WhenDeleteThrowsException_ShouldReturnFailure()
{
var user = new UserBuilder().Build();

MockUserRepository.Setup(r => r.GetByIdAsync(user.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(user);

MockUserRepository.Setup(r => r.DeleteAsync(user, It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Database error"));

var command = new DeleteAccountCommand(user.Id);
var result = await _handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeFalse();
result.Message.Should().Be("Database error");
MockUnitOfWork.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public async Task Handle_WhenSaveChangesThrowsException_ShouldReturnFailure()
{
var user = new UserBuilder()
.WithUsername("testuser")
.WithEmail("test@example.com")
.Build();

MockUserRepository.Setup(r => r.GetByIdAsync(user.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(user);

MockUnitOfWork.Setup(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Save failed"));

var command = new DeleteAccountCommand(user.Id);
var result = await _handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeFalse();
result.Message.Should().Be("Save failed");
}

[Fact]
public async Task Handle_WhenAuditLogFails_ShouldStillReturnSuccess()
{
var user = new UserBuilder()
.WithUsername("testuser")
.WithEmail("test@example.com")
.Build();

MockUserRepository.Setup(r => r.GetByIdAsync(user.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(user);

MockAuditLog.Setup(a => a.LogAsync(It.IsAny<AuditLogRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Logging failed"));

var command = new DeleteAccountCommand(user.Id);
var result = await _handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeTrue();
MockUserRepository.Verify(r => r.DeleteAsync(user, It.IsAny<CancellationToken>()), Times.Once);
MockUnitOfWork.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using AutoStack.Application.Features.Users.Commands.DeleteAccount;
using FluentAssertions;
using Xunit;

namespace AutoStack.Application.Tests.Features.Users.Commands.DeleteAccount;

public class DeleteAccountValidatorTests
{
private readonly DeleteAccountValidator _validator;

public DeleteAccountValidatorTests()
{
_validator = new DeleteAccountValidator();
}

[Fact]
public void Validate_WithValidUserId_ShouldPass()
{
var command = new DeleteAccountCommand(Guid.NewGuid());

var result = _validator.Validate(command);

result.IsValid.Should().BeTrue();
}

[Fact]
public void Validate_WithNullUserId_ShouldFail()
{
var command = new DeleteAccountCommand(null);

var result = _validator.Validate(command);

result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "UserId" && e.ErrorMessage == "UserId is required");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using AutoStack.Application.Common.Interfaces.Commands;

namespace AutoStack.Application.Features.Users.Commands.DeleteAccount;

public record DeleteAccountCommand(Guid? UserId) : ICommand<bool>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using AutoStack.Application.Common.Interfaces;
using AutoStack.Application.Common.Interfaces.Commands;
using AutoStack.Application.Common.Models;
using AutoStack.Domain.Enums;
using AutoStack.Domain.Repositories;

namespace AutoStack.Application.Features.Users.Commands.DeleteAccount;

public class DeleteAccountCommandHandler : ICommandHandler<DeleteAccountCommand, bool>
{
private readonly IUserRepository _userRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly IAuditLogService _auditLogService;
public DeleteAccountCommandHandler(IUserRepository userRepository, IUnitOfWork unitOfWork, IAuditLogService auditLogService)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_auditLogService = auditLogService;
}

public async Task<Result<bool>> Handle(DeleteAccountCommand request, CancellationToken cancellationToken)
{
try
{
if (!request.UserId.HasValue)
{
return Result<bool>.Failure("User ID is required");
}

var user = await _userRepository.GetByIdAsync(request.UserId.Value, cancellationToken);

if (user == null)
{
return Result<bool>.Failure("User not found.");
}

await _userRepository.DeleteAsync(user, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);

try
{
await _auditLogService.LogAsync(new AuditLogRequest
{
Level = LogLevel.Information,
Category = LogCategory.User,
Message = "Account deleted",
UserIdOverride = user.Id,
UsernameOverride = user.Username
}, cancellationToken);
}
catch
{
// Ignore logging failures
}

return Result<bool>.Success(true);
}
catch (Exception ex)
{
return Result<bool>.Failure(ex.Message);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using FluentValidation;

namespace AutoStack.Application.Features.Users.Commands.DeleteAccount;

public class DeleteAccountValidator : AbstractValidator<DeleteAccountCommand>
{
public DeleteAccountValidator()
{
RuleFor(da => da.UserId)
.NotEmpty().WithMessage("UserId is required");
}
}
2 changes: 1 addition & 1 deletion AutoStack.Domain/Common/IRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public interface IRepository<TAggregate, TId>
Task UpdateAsync(TAggregate aggregate, CancellationToken cancellationToken = default);

/// <summary>
/// Deletes an aggregate by its ID.
/// Deletes an aggregate.
/// </summary>
/// <param name="aggregate">The aggregate to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Expand Down
37 changes: 37 additions & 0 deletions AutoStack.Presentation/Endpoints/User/UserEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using AutoStack.Application.Common.Interfaces;
using AutoStack.Application.Common.Models;
using AutoStack.Application.Features.Users.Commands.DeleteAccount;
using AutoStack.Application.Features.Users.Commands.EditUser;
using AutoStack.Application.Features.Users.Commands.UploadAvatar;
using AutoStack.Application.Features.Users.Queries.GetUser;
Expand Down Expand Up @@ -36,6 +37,11 @@ public static void MapUserEndpoints(this IEndpointRouteBuilder app)
.RequireAuthorization()
.DisableAntiforgery()
.Accepts<IFormFile>("multipart/form-data");

group.MapGet("/deleteaccount", DeleteAccount)
.WithName("DeleteAccount")
.WithSummary("Delete account")
.RequireAuthorization();
}

private static async Task<IResult> GetCurrentUser(
Expand Down Expand Up @@ -217,4 +223,35 @@ private static async Task<IResult> UploadAvatar(
data = result.Value
});
}

private static async Task<IResult> DeleteAccount(
IMediator mediator,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var authenticatedUserIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;

if (string.IsNullOrEmpty(authenticatedUserIdClaim) || !Guid.TryParse(authenticatedUserIdClaim, out var authenticatedUserId))
{
return Results.Unauthorized();
}

var result = await mediator.Send(new DeleteAccountCommand(UserId: authenticatedUserId), cancellationToken);

if (!result.IsSuccess)
{
return Results.BadRequest(new
{
success = false,
message = result.Message,
errors = result.ValidationErrors
});
}

return Results.Ok(new
{
success = true,
data = result.Value
});
}
}
Loading