using FluentAssertions; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; using StorageService.API.Application.Commands; using StorageService.Domain.AggregatesModel.FileAggregate; using StorageService.Domain.AggregatesModel.QuotaAggregate; using StorageService.Domain.SeedWork; using StorageService.Infrastructure.Caching; using StorageService.Infrastructure.Storage; using Xunit; namespace StorageService.UnitTests.Handlers; /// /// EN: Unit tests for DeleteFileCommandHandler. /// VI: Unit tests cho DeleteFileCommandHandler. /// public class DeleteFileCommandHandlerTests { private readonly IFileRepository _fileRepository; private readonly IQuotaRepository _quotaRepository; private readonly IStorageProviderFactory _storageProviderFactory; private readonly IStorageProvider _storageProvider; private readonly IRedisCacheService _cache; private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly DeleteFileCommandHandler _handler; private static readonly Guid TestFileId = Guid.NewGuid(); private const string TestUserId = "user-123"; private const string TestBucketName = "storage-bucket"; private const string TestObjectKey = "private/user-123/20260115/abc123_document.pdf"; private const long TestFileSize = 1024 * 1024; // 1MB public DeleteFileCommandHandlerTests() { _fileRepository = Substitute.For(); _quotaRepository = Substitute.For(); _storageProviderFactory = Substitute.For(); _storageProvider = Substitute.For(); _cache = Substitute.For(); _logger = Substitute.For>(); _unitOfWork = Substitute.For(); _storageProviderFactory.GetProvider(Arg.Any()).Returns(_storageProvider); _fileRepository.UnitOfWork.Returns(_unitOfWork); _handler = new DeleteFileCommandHandler( _fileRepository, _quotaRepository, _storageProviderFactory, _cache, _logger); } #region Happy Path Tests [Fact] public async Task Handle_FileExists_SoftDeletesFile() { // Arrange var file = CreateRealStorageFile(); var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize * 10); quota.AddUsage(TestFileSize); var command = new DeleteFileCommand(file.Id, TestUserId); _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.DeleteAsync(TestBucketName, Arg.Any(), Arg.Any()) .Returns(true); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeTrue(); result.Error.Should().BeNull(); file.IsDeleted.Should().BeTrue(); _fileRepository.Received(1).Update(file); } [Fact] public async Task Handle_ValidDelete_UpdatesQuota() { // Arrange var file = CreateRealStorageFile(); var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize * 10); quota.AddUsage(TestFileSize); var initialUsage = quota.UsedStorageBytes; var command = new DeleteFileCommand(file.Id, TestUserId); _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.DeleteAsync(TestBucketName, Arg.Any(), Arg.Any()) .Returns(true); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act await _handler.Handle(command, CancellationToken.None); // Assert quota.UsedStorageBytes.Should().BeLessThan(initialUsage); _quotaRepository.Received(1).Update(quota); } [Fact] public async Task Handle_ValidDelete_InvalidatesCache() { // Arrange var file = CreateRealStorageFile(); var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize * 10); var command = new DeleteFileCommand(file.Id, TestUserId); _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.DeleteAsync(TestBucketName, Arg.Any(), Arg.Any()) .Returns(true); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _cache.Received().DeleteAsync( Arg.Is(key => key.Contains(file.Id.ToString())), Arg.Any()); } #endregion #region File Not Found Tests [Fact] public async Task Handle_FileNotFound_ReturnsNotFoundError() { // Arrange var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) .Returns((StorageFile?)null); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeFalse(); result.Error.Should().Contain("not found"); } [Fact] public async Task Handle_FileNotFound_DoesNotDeleteFromStorage() { // Arrange var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) .Returns((StorageFile?)null); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _storageProvider.DidNotReceive().DeleteAsync( Arg.Any(), Arg.Any(), Arg.Any()); } #endregion #region Permission Tests [Fact] public async Task Handle_NotOwner_ReturnsPermissionError() { // Arrange var file = new StorageFile( "document.pdf", TestBucketName, TestObjectKey, "application/pdf", TestFileSize, "different-user", // Different owner StorageProvider.MinIO); var command = new DeleteFileCommand(file.Id, TestUserId); _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeFalse(); result.Error.Should().Contain("permission"); } [Fact] public async Task Handle_NotOwner_DoesNotDeleteFile() { // Arrange var file = new StorageFile( "document.pdf", TestBucketName, TestObjectKey, "application/pdf", TestFileSize, "different-user", StorageProvider.MinIO); var command = new DeleteFileCommand(file.Id, TestUserId); _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); // Act await _handler.Handle(command, CancellationToken.None); // Assert file.IsDeleted.Should().BeFalse(); await _storageProvider.DidNotReceive().DeleteAsync( Arg.Any(), Arg.Any(), Arg.Any()); } #endregion #region Storage Delete Failure Tests [Fact] public async Task Handle_StorageDeleteFails_StillSoftDeletesFile() { // Arrange var file = CreateRealStorageFile(); var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize * 10); var command = new DeleteFileCommand(file.Id, TestUserId); _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.DeleteAsync(TestBucketName, Arg.Any(), Arg.Any()) .Returns(false); // Storage delete fails _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeTrue(); file.IsDeleted.Should().BeTrue(); } #endregion #region Quota Not Found Tests [Fact] public async Task Handle_QuotaNotFound_StillDeletesFile() { // Arrange var file = CreateRealStorageFile(); var command = new DeleteFileCommand(file.Id, TestUserId); _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns((UserStorageQuota?)null); _storageProvider.DeleteAsync(TestBucketName, Arg.Any(), Arg.Any()) .Returns(true); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeTrue(); file.IsDeleted.Should().BeTrue(); } #endregion #region Exception Handling Tests [Fact] public async Task Handle_RepositoryThrows_ReturnsFailure() { // Arrange var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) .ThrowsAsync(new Exception("Database connection failed")); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeFalse(); result.Error.Should().NotBeNullOrEmpty(); } [Fact] public async Task Handle_StorageProviderThrows_ReturnsFailure() { // Arrange var file = CreateRealStorageFile(); var command = new DeleteFileCommand(file.Id, TestUserId); _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _storageProvider.DeleteAsync(Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Storage connection failed")); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeFalse(); } #endregion #region Helper Methods private static StorageFile CreateRealStorageFile() { return new StorageFile( "document.pdf", TestBucketName, TestObjectKey, "application/pdf", TestFileSize, TestUserId, StorageProvider.MinIO); } #endregion }