using FluentAssertions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; 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.Configuration; using StorageService.Infrastructure.Storage; using Xunit; namespace StorageService.UnitTests.Handlers; /// /// EN: Unit tests for UploadFileCommandHandler. /// VI: Unit tests cho UploadFileCommandHandler. /// public class UploadFileCommandHandlerTests { private readonly IFileRepository _fileRepository; private readonly IQuotaRepository _quotaRepository; private readonly IStorageProviderFactory _storageProviderFactory; private readonly IStorageProvider _storageProvider; private readonly IOptions _settings; private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly UploadFileCommandHandler _handler; private const string TestUserId = "user-123"; private const string TestFileName = "document.pdf"; private const string TestContentType = "application/pdf"; private const long TestFileSize = 1024 * 1024; // 1MB private const long MaxFileSize = 100 * 1024 * 1024; // 100MB private const long MaxQuotaBytes = 1024 * 1024 * 1024; // 1GB public UploadFileCommandHandlerTests() { _fileRepository = Substitute.For(); _quotaRepository = Substitute.For(); _storageProviderFactory = Substitute.For(); _storageProvider = Substitute.For(); _logger = Substitute.For>(); _unitOfWork = Substitute.For(); _settings = Options.Create(new StorageSettings { MaxFileSizeBytes = MaxFileSize, DefaultBucket = "storage-bucket", PreSignedUrlExpirationSeconds = 3600 }); _storageProviderFactory.GetProvider().Returns(_storageProvider); _storageProvider.ProviderType.Returns(StorageProvider.MinIO); _fileRepository.UnitOfWork.Returns(_unitOfWork); _handler = new UploadFileCommandHandler( _fileRepository, _quotaRepository, _storageProviderFactory, _settings, _logger); } #region Happy Path Tests [Fact] public async Task Handle_ValidRequest_UploadsFileAndReturnsSuccess() { // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.UploadAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(StorageResult.Ok("private/user-123/20260115/abc123_document.pdf", TestFileSize, "checksum123")); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeTrue(); result.FileId.Should().NotBeNull(); result.ObjectKey.Should().NotBeNullOrEmpty(); result.Error.Should().BeNull(); } [Fact] public async Task Handle_ValidUpload_SavesFileToRepository() { // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.UploadAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(StorageResult.Ok("objectKey", TestFileSize)); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _fileRepository.Received(1).AddAsync( Arg.Is(f => f.FileName == TestFileName && f.UserId == TestUserId), Arg.Any()); } [Fact] public async Task Handle_ValidUpload_UpdatesQuota() { // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.UploadAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(StorageResult.Ok("objectKey", TestFileSize)); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act await _handler.Handle(command, CancellationToken.None); // Assert // EN: Verify quota usage was added / VI: Xác minh usage đã được thêm quota.UsedStorageBytes.Should().Be(TestFileSize); _quotaRepository.Received(1).Update(quota); } [Fact] public async Task Handle_ValidUpload_CallsSaveEntities() { // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.UploadAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(StorageResult.Ok("objectKey", TestFileSize)); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _unitOfWork.Received(1).SaveEntitiesAsync(Arg.Any()); } #endregion #region File Size Validation Tests [Fact] public async Task Handle_ExceedsMaxSize_ReturnsFailure() { // Arrange using var stream = new MemoryStream(new byte[100]); var command = new UploadFileCommand( stream, TestFileName, TestContentType, MaxFileSize + 1, TestUserId); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeFalse(); result.Error.Should().Contain("exceeds maximum"); result.FileId.Should().BeNull(); } [Fact] public async Task Handle_ExceedsMaxSize_DoesNotUpload() { // Arrange using var stream = new MemoryStream(new byte[100]); var command = new UploadFileCommand( stream, TestFileName, TestContentType, MaxFileSize + 1, TestUserId); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _storageProvider.DidNotReceive().UploadAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } #endregion #region Quota Validation Tests [Fact] public async Task Handle_QuotaExceeded_ReturnsFailure() { // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); // Create quota with no space var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize - 1); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeFalse(); result.Error.Should().Contain("Quota"); } [Fact] public async Task Handle_QuotaExceeded_DoesNotUpload() { // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize - 1); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _storageProvider.DidNotReceive().UploadAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } #endregion #region Storage Provider Tests [Fact] public async Task Handle_StorageUploadFails_ReturnsFailure() { // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.UploadAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(StorageResult.Fail("Storage connectivity issue")); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeFalse(); result.Error.Should().Be("Storage connectivity issue"); } [Fact] public async Task Handle_StorageUploadFails_DoesNotSaveFile() { // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.UploadAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(StorageResult.Fail("Failed")); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _fileRepository.DidNotReceive().AddAsync( Arg.Any(), Arg.Any()); } #endregion #region Access Level Tests [Theory] [InlineData(FileAccessLevel.Public, "public/")] [InlineData(FileAccessLevel.Private, "private/")] [InlineData(FileAccessLevel.Shared, "shared/")] public async Task Handle_AccessLevel_GeneratesCorrectPrefix(FileAccessLevel accessLevel, string expectedPrefix) { // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = new UploadFileCommand( stream, TestFileName, TestContentType, TestFileSize, TestUserId, AccessLevel: accessLevel); var quota = CreateQuotaWithSpace(MaxQuotaBytes); string? capturedObjectKey = null; _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.UploadAsync( Arg.Any(), Arg.Do(key => capturedObjectKey = key), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(StorageResult.Ok("objectKey", TestFileSize)); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); // Act await _handler.Handle(command, CancellationToken.None); // Assert capturedObjectKey.Should().StartWith(expectedPrefix); } #endregion #region Exception Handling Tests [Fact] public async Task Handle_RepositoryThrows_ReturnsFailure() { // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.UploadAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(StorageResult.Ok("objectKey", TestFileSize)); _fileRepository.AddAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Database error")); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeFalse(); result.Error.Should().NotBeNullOrEmpty(); } #endregion #region Helper Methods private static UploadFileCommand CreateValidCommand(Stream stream) { return new UploadFileCommand( stream, TestFileName, TestContentType, TestFileSize, TestUserId); } private static UserStorageQuota CreateQuotaWithSpace(long maxStorage) { return new UserStorageQuota(TestUserId, maxStorageBytes: maxStorage); } #endregion }