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
Expand Up @@ -5,6 +5,7 @@
using AutoStack.Domain.Entities;
using AutoStack.Domain.Enums;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;

Expand All @@ -13,13 +14,17 @@ namespace AutoStack.Application.Tests.Features.Users.Commands.DeleteAccount;
public class DeleteAccountCommandHandlerTests : CommandHandlerTestBase
{
private readonly DeleteAccountCommandHandler _handler;
private readonly Mock<ILogger<DeleteAccountCommandHandler>> _mockLogger;

public DeleteAccountCommandHandlerTests()
{
_mockLogger = new Mock<ILogger<DeleteAccountCommandHandler>>();

_handler = new DeleteAccountCommandHandler(
MockUserRepository.Object,
MockUnitOfWork.Object,
MockAuditLog.Object
MockAuditLog.Object,
_mockLogger.Object
);
}

Expand Down Expand Up @@ -71,7 +76,7 @@ public async Task Handle_WithExistingUserId_ShouldReturnSuccess()
MockUnitOfWork.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
MockAuditLog.Verify(a => a.LogAsync(
It.Is<AuditLogRequest>(req =>
req.Level == LogLevel.Information &&
req.Level == Domain.Enums.LogLevel.Information &&
req.Category == LogCategory.User &&
req.Message == "Account deleted" &&
req.UserIdOverride == user.Id &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using AutoStack.Application.Tests.Common;
using AutoStack.Domain.Entities;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;

Expand All @@ -15,16 +16,19 @@ public class EditUserCommandHandlerTests : CommandHandlerTestBase
{
private readonly Mock<IPasswordHasher> _mockPasswordHasher;
private readonly EditUserCommandHandler _handler;
private readonly Mock<ILogger<EditUserCommandHandler>> _mockLogger;

public EditUserCommandHandlerTests()
{
_mockPasswordHasher = new Mock<IPasswordHasher>();
_mockLogger = new Mock<ILogger<EditUserCommandHandler>>();

_handler = new EditUserCommandHandler(
MockUserRepository.Object,
MockUnitOfWork.Object,
_mockPasswordHasher.Object,
MockAuditLog.Object
MockAuditLog.Object,
_mockLogger.Object
);
}

Expand Down
68 changes: 45 additions & 23 deletions AutoStack.Application/Common/Behaviours/ValidationBehaviour.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using AutoStack.Application.Common.Models;
using System.Collections.Concurrent;
using System.Linq.Expressions;
using AutoStack.Application.Common.Models;
using FluentValidation;
using MediatR;

Expand All @@ -9,11 +11,11 @@ namespace AutoStack.Application.Common.Behaviours;
/// </summary>
/// <typeparam name="TRequest">The request type (Command or Query)</typeparam>
/// <typeparam name="TResponse">The response type (must be a Result)</typeparam>
public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
where TResponse : Result
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
private static readonly ConcurrentDictionary<Type, Func<Dictionary<string, string[]>, object>> FailureFactoryCache = new();

/// <summary>
/// Constructor - MediatR DI automatically injects all validators for TRequest.
Expand All @@ -35,47 +37,67 @@ public async Task<TResponse> Handle(
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// If no validators are registered for this request type, skip validation
if (!_validators.Any())
{
return await next(cancellationToken);
}

var context = new ValidationContext<TRequest>(request);

// Run ALL validators for this request type in parallel
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));

// Collect all validation failures from all validators
var failures = validationResults
.Where(r => !r.IsValid) // Only failed validations
.SelectMany(r => r.Errors) // Get all error messages
.Where(r => !r.IsValid)
.SelectMany(r => r.Errors)
.ToList();

// If there are any validation failures, return error result
if (failures.Count == 0) return await next(cancellationToken);

// Group errors by property name for structured response
// Example: { "Email": ["Email is required", "Invalid format"], "Password": ["Too short"] }
if (failures.Count == 0)
return await next(cancellationToken);

var errors = failures
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);

var resultType = typeof(TResponse);
var failureMethod = resultType.GetMethod(
nameof(Result.Failure),
[typeof(Dictionary<string, string[]>)]
);
return CreateValidationFailureResult<TResponse>(errors);
}

private static TResult CreateValidationFailureResult<TResult>(Dictionary<string, string[]> errors)
{
var resultType = typeof(TResult);

if (failureMethod != null)
var factory = FailureFactoryCache.GetOrAdd(resultType, type =>
{
return (TResponse)failureMethod.Invoke(null, [errors])!;
}
if (type == typeof(Result))
{
return errorsDict => Result.Failure(errorsDict);
}

if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Result<>))
{
var failureMethod = type.GetMethod(nameof(Result.Failure), new[] { typeof(Dictionary<string, string[]>) });

if (failureMethod == null)
{
throw new InvalidOperationException($"Unable to find Failure method for type {type.Name}");
}

var errorsParam = Expression.Parameter(typeof(Dictionary<string, string[]>), "errors");
var callExpression = Expression.Call(failureMethod, errorsParam);
var lambda = Expression.Lambda<Func<Dictionary<string, string[]>, object>>(
Expression.Convert(callExpression, typeof(object)),
errorsParam
);

return lambda.Compile();
}

throw new InvalidOperationException($"Unable to create validation failure for type {type.Name}");
});

return await next(cancellationToken);
return (TResult)factory(errors);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using AutoStack.Application.Common.Models;
using AutoStack.Domain.Enums;
using AutoStack.Domain.Repositories;
using Microsoft.Extensions.Logging;

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

Expand All @@ -11,11 +12,14 @@ public class DeleteAccountCommandHandler : ICommandHandler<DeleteAccountCommand,
private readonly IUserRepository _userRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly IAuditLogService _auditLogService;
public DeleteAccountCommandHandler(IUserRepository userRepository, IUnitOfWork unitOfWork, IAuditLogService auditLogService)
private readonly ILogger<DeleteAccountCommandHandler> _logger;

public DeleteAccountCommandHandler(IUserRepository userRepository, IUnitOfWork unitOfWork, IAuditLogService auditLogService, ILogger<DeleteAccountCommandHandler> logger)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_auditLogService = auditLogService;
_logger = logger;
}

public async Task<Result<bool>> Handle(DeleteAccountCommand request, CancellationToken cancellationToken)
Expand All @@ -41,16 +45,16 @@ public async Task<Result<bool>> Handle(DeleteAccountCommand request, Cancellatio
{
await _auditLogService.LogAsync(new AuditLogRequest
{
Level = LogLevel.Information,
Level = Domain.Enums.LogLevel.Information,
Category = LogCategory.User,
Message = "Account deleted",
UserIdOverride = user.Id,
UsernameOverride = user.Username
}, cancellationToken);
}
catch
catch (Exception ex)
{
// Ignore logging failures
_logger.LogError(ex, "Failed to log account deletion audit for user {UserId}", user.Id);
}

return Result<bool>.Success(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using AutoStack.Domain.Entities;
using AutoStack.Domain.Enums;
using AutoStack.Domain.Repositories;
using Microsoft.Extensions.Logging;

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

Expand All @@ -15,13 +16,15 @@ public class EditUserCommandHandler : ICommandHandler<EditUserCommand, UserRespo
private readonly IUnitOfWork _unitOfWork;
private readonly IPasswordHasher _passwordHasher;
private readonly IAuditLogService _auditLogService;
private readonly ILogger<EditUserCommandHandler> _logger;

public EditUserCommandHandler(IUserRepository userRepository, IUnitOfWork unitOfWork, IPasswordHasher passwordHasher, IAuditLogService auditLogService)
public EditUserCommandHandler(IUserRepository userRepository, IUnitOfWork unitOfWork, IPasswordHasher passwordHasher, IAuditLogService auditLogService, ILogger<EditUserCommandHandler> logger)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_passwordHasher = passwordHasher;
_auditLogService = auditLogService;
_logger = logger;
}

public async Task<Result<UserResponse>> Handle(EditUserCommand request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -68,21 +71,20 @@ public async Task<Result<UserResponse>> Handle(EditUserCommand request, Cancella
var isPasswordValid = _passwordHasher.VerifyPassword(request.CurrentPassword, user.PasswordHash);
if (!isPasswordValid)
{
// Log failed password change attempt
try
{
await _auditLogService.LogAsync(new AuditLogRequest
{
Level = LogLevel.Warning,
Level = Domain.Enums.LogLevel.Warning,
Category = LogCategory.Security,
Message = "Failed password change attempt - incorrect current password",
UserIdOverride = user.Id,
UsernameOverride = user.Username
}, cancellationToken);
}
catch
catch (Exception ex)
{
// Ignore logging failures
_logger.LogError(ex, "Failed to log password change attempt for user {UserId}", user.Id);
}

return Result<UserResponse>.Failure("Current password is invalid");
Expand All @@ -101,25 +103,24 @@ await _auditLogService.LogAsync(new AuditLogRequest
await _userRepository.UpdateAsync(user, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);

// Log successful profile update
if (changedFields.Count > 0)
{
try
{
additionalData["ChangedFields"] = changedFields;
await _auditLogService.LogAsync(new AuditLogRequest
{
Level = LogLevel.Information,
Level = Domain.Enums.LogLevel.Information,
Category = LogCategory.User,
Message = $"User profile updated: {string.Join(", ", changedFields)}",
UserIdOverride = user.Id,
UsernameOverride = user.Username,
AdditionalData = additionalData
}, cancellationToken);
}
catch
catch (Exception ex)
{
// Ignore logging failures
_logger.LogError(ex, "Failed to log profile update audit for user {UserId}", user.Id);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using AutoStack.Application.DTOs.Users;
using AutoStack.Domain.Enums;
using AutoStack.Domain.Repositories;
using Microsoft.Extensions.Logging;

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

Expand All @@ -13,17 +14,20 @@ public class UploadAvatarCommandHandler : ICommandHandler<UploadAvatarCommand, U
private readonly IUnitOfWork _unitOfWork;
private readonly IFileStorageService _fileStorageService;
private readonly IAuditLogService _auditLogService;
private readonly ILogger<UploadAvatarCommandHandler> _logger;

public UploadAvatarCommandHandler(
IUserRepository userRepository,
IUnitOfWork unitOfWork,
IFileStorageService fileStorageService,
IAuditLogService auditLogService)
IAuditLogService auditLogService,
ILogger<UploadAvatarCommandHandler> logger)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_fileStorageService = fileStorageService;
_auditLogService = auditLogService;
_logger = logger;
}

public async Task<Result<UserResponse>> Handle(UploadAvatarCommand request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -62,17 +66,18 @@ public async Task<Result<UserResponse>> Handle(UploadAvatarCommand request, Canc
{
await _fileStorageService.DeleteAvatarAsync(oldAvatarUrl, cancellationToken);
}
catch
catch (Exception ex)
{
// Ignore deletion errors
_logger.LogWarning(ex, "Failed to delete old avatar {OldAvatarUrl} for user {UserId}", oldAvatarUrl, user.Id);
// Continue - deletion failure is non-critical
}
}

try
{
await _auditLogService.LogAsync(new AuditLogRequest
{
Level = LogLevel.Information,
Level = Domain.Enums.LogLevel.Information,
Category = LogCategory.User,
Message = "User avatar uploaded",
UserIdOverride = user.Id,
Expand All @@ -86,9 +91,9 @@ await _auditLogService.LogAsync(new AuditLogRequest
}
}, cancellationToken);
}
catch
catch (Exception ex)
{
// Ignore logging failures
_logger.LogError(ex, "Failed to log avatar upload audit for user {UserId}", user.Id);
}

var userResponse = new UserResponse(user.Id, user.Email, user.Username, user.AvatarUrl, user.EmailVerified);
Expand Down
9 changes: 6 additions & 3 deletions AutoStack.Infrastructure/Services/LocalFileStorageService.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
using AutoStack.Application.Common.Interfaces;
using AutoStack.Infrastructure.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace AutoStack.Infrastructure.Services;

public class LocalFileStorageService : IFileStorageService
{
private readonly FileStorageOptions _options;
private readonly ILogger<LocalFileStorageService> _logger;

public LocalFileStorageService(IOptions<FileStorageOptions> options)
public LocalFileStorageService(IOptions<FileStorageOptions> options, ILogger<LocalFileStorageService> logger)
{
_options = options.Value;
_logger = logger;

if (string.IsNullOrWhiteSpace(_options.BaseUrl))
{
Expand Down Expand Up @@ -58,9 +61,9 @@ public Task DeleteAvatarAsync(string fileUrl, CancellationToken cancellationToke
File.Delete(filePath);
}
}
catch
catch (Exception ex)
{
// Ignore deletion errors for now
_logger.LogWarning(ex, "Failed to delete avatar file from URL {FileUrl}", fileUrl);
}

return Task.CompletedTask;
Expand Down
Loading