diff --git a/AutoStack.Application.Tests/Features/Users/Commands/DeleteAccount/DeleteAccountCommandHandlerTests.cs b/AutoStack.Application.Tests/Features/Users/Commands/DeleteAccount/DeleteAccountCommandHandlerTests.cs new file mode 100644 index 0000000..06e795d --- /dev/null +++ b/AutoStack.Application.Tests/Features/Users/Commands/DeleteAccount/DeleteAccountCommandHandlerTests.cs @@ -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())) + .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(), It.IsAny()), 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())) + .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()), Times.Once); + MockUnitOfWork.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Once); + MockAuditLog.Verify(a => a.LogAsync( + It.Is(req => + req.Level == LogLevel.Information && + req.Category == LogCategory.User && + req.Message == "Account deleted" && + req.UserIdOverride == user.Id && + req.UsernameOverride == user.Username), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Handle_WhenDeleteThrowsException_ShouldReturnFailure() + { + var user = new UserBuilder().Build(); + + MockUserRepository.Setup(r => r.GetByIdAsync(user.Id, It.IsAny())) + .ReturnsAsync(user); + + MockUserRepository.Setup(r => r.DeleteAsync(user, It.IsAny())) + .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()), 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())) + .ReturnsAsync(user); + + MockUnitOfWork.Setup(u => u.SaveChangesAsync(It.IsAny())) + .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())) + .ReturnsAsync(user); + + MockAuditLog.Setup(a => a.LogAsync(It.IsAny(), It.IsAny())) + .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()), Times.Once); + MockUnitOfWork.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/AutoStack.Application.Tests/Features/Users/Commands/DeleteAccount/DeleteAccountValidatorTests.cs b/AutoStack.Application.Tests/Features/Users/Commands/DeleteAccount/DeleteAccountValidatorTests.cs new file mode 100644 index 0000000..b86664b --- /dev/null +++ b/AutoStack.Application.Tests/Features/Users/Commands/DeleteAccount/DeleteAccountValidatorTests.cs @@ -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"); + } +} diff --git a/AutoStack.Application/Features/Users/Commands/DeleteAccount/DeleteAccountCommand.cs b/AutoStack.Application/Features/Users/Commands/DeleteAccount/DeleteAccountCommand.cs new file mode 100644 index 0000000..2c454ae --- /dev/null +++ b/AutoStack.Application/Features/Users/Commands/DeleteAccount/DeleteAccountCommand.cs @@ -0,0 +1,5 @@ +using AutoStack.Application.Common.Interfaces.Commands; + +namespace AutoStack.Application.Features.Users.Commands.DeleteAccount; + +public record DeleteAccountCommand(Guid? UserId) : ICommand; \ No newline at end of file diff --git a/AutoStack.Application/Features/Users/Commands/DeleteAccount/DeleteAccountCommandHandler.cs b/AutoStack.Application/Features/Users/Commands/DeleteAccount/DeleteAccountCommandHandler.cs new file mode 100644 index 0000000..47afa07 --- /dev/null +++ b/AutoStack.Application/Features/Users/Commands/DeleteAccount/DeleteAccountCommandHandler.cs @@ -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 +{ + 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> Handle(DeleteAccountCommand request, CancellationToken cancellationToken) + { + try + { + if (!request.UserId.HasValue) + { + return Result.Failure("User ID is required"); + } + + var user = await _userRepository.GetByIdAsync(request.UserId.Value, cancellationToken); + + if (user == null) + { + return Result.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.Success(true); + } + catch (Exception ex) + { + return Result.Failure(ex.Message); + } + } +} \ No newline at end of file diff --git a/AutoStack.Application/Features/Users/Commands/DeleteAccount/DeleteAccountValidator.cs b/AutoStack.Application/Features/Users/Commands/DeleteAccount/DeleteAccountValidator.cs new file mode 100644 index 0000000..55a772f --- /dev/null +++ b/AutoStack.Application/Features/Users/Commands/DeleteAccount/DeleteAccountValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace AutoStack.Application.Features.Users.Commands.DeleteAccount; + +public class DeleteAccountValidator : AbstractValidator +{ + public DeleteAccountValidator() + { + RuleFor(da => da.UserId) + .NotEmpty().WithMessage("UserId is required"); + } +} \ No newline at end of file diff --git a/AutoStack.Domain/Common/IRepository.cs b/AutoStack.Domain/Common/IRepository.cs index 65a7314..7f3db11 100644 --- a/AutoStack.Domain/Common/IRepository.cs +++ b/AutoStack.Domain/Common/IRepository.cs @@ -27,7 +27,7 @@ public interface IRepository Task UpdateAsync(TAggregate aggregate, CancellationToken cancellationToken = default); /// - /// Deletes an aggregate by its ID. + /// Deletes an aggregate. /// /// The aggregate to delete. /// Cancellation token. diff --git a/AutoStack.Presentation/Endpoints/User/UserEndpoints.cs b/AutoStack.Presentation/Endpoints/User/UserEndpoints.cs index 175a91e..c0a8548 100644 --- a/AutoStack.Presentation/Endpoints/User/UserEndpoints.cs +++ b/AutoStack.Presentation/Endpoints/User/UserEndpoints.cs @@ -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; @@ -36,6 +37,11 @@ public static void MapUserEndpoints(this IEndpointRouteBuilder app) .RequireAuthorization() .DisableAntiforgery() .Accepts("multipart/form-data"); + + group.MapGet("/deleteaccount", DeleteAccount) + .WithName("DeleteAccount") + .WithSummary("Delete account") + .RequireAuthorization(); } private static async Task GetCurrentUser( @@ -217,4 +223,35 @@ private static async Task UploadAvatar( data = result.Value }); } + + private static async Task 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 + }); + } } \ No newline at end of file