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() { // EN: Setup mocks / VI: Setup mocks _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 = CreateMockStorageFile(); var quota = CreateMockQuota(); var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.DeleteAsync(TestBucketName, TestObjectKey, 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.Received(1).Delete(); _fileRepository.Received(1).Update(file); } [Fact] public async Task Handle_ValidDelete_UpdatesQuota() { // Arrange var file = CreateMockStorageFile(); var quota = CreateMockQuota(); var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any()) .Returns(true); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act await _handler.Handle(command, CancellationToken.None); // Assert quota.Received(1).RemoveUsage(TestFileSize); _quotaRepository.Received(1).Update(quota); } [Fact] public async Task Handle_ValidDelete_InvalidatesCache() { // Arrange var file = CreateMockStorageFile(); var quota = CreateMockQuota(); var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any()) .Returns(true); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act await _handler.Handle(command, CancellationToken.None); // Assert // EN: Should invalidate file metadata cache // VI: Nên invalidate file metadata cache await _cache.Received().DeleteAsync( Arg.Is(key => key.Contains(TestFileId.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 = CreateMockStorageFile(); file.UserId.Returns("different-user"); // Different owner var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, 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 = CreateMockStorageFile(); file.UserId.Returns("different-user"); var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) .Returns(file); // Act await _handler.Handle(command, CancellationToken.None); // Assert file.DidNotReceive().Delete(); 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 = CreateMockStorageFile(); var quota = CreateMockQuota(); var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any()) .Returns(false); // Storage delete fails _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert // EN: Should still succeed with soft delete even if storage delete fails // VI: Vẫn nên thành công với soft delete ngay cả khi storage delete thất bại result.Success.Should().BeTrue(); file.Received(1).Delete(); } #endregion #region Quota Not Found Tests [Fact] public async Task Handle_QuotaNotFound_StillDeletesFile() { // Arrange var file = CreateMockStorageFile(); var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns((UserStorageQuota?)null); _storageProvider.DeleteAsync(TestBucketName, TestObjectKey, 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.Received(1).Delete(); } #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 = CreateMockStorageFile(); var command = new DeleteFileCommand(TestFileId, TestUserId); _fileRepository.GetByIdAsync(TestFileId, 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 CreateMockStorageFile() { var file = Substitute.For(); file.Id.Returns(TestFileId); file.UserId.Returns(TestUserId); file.BucketName.Returns(TestBucketName); file.ObjectKey.Returns(TestObjectKey); file.FileSizeBytes.Returns(TestFileSize); file.Provider.Returns(StorageProvider.MinIO); return file; } private static UserStorageQuota CreateMockQuota() { var quota = Substitute.For(); return quota; } #endregion }