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.Infrastructure.Configuration; using StorageService.Infrastructure.Storage; using Xunit; namespace StorageService.UnitTests.Handlers; /// /// EN: Unit tests for SignUploadCommandHandler. /// VI: Unit tests cho SignUploadCommandHandler. /// public class SignUploadCommandHandlerTests { private readonly IQuotaRepository _quotaRepository; private readonly IStorageProviderFactory _storageProviderFactory; private readonly IStorageProvider _storageProvider; private readonly IOptions _settings; private readonly ILogger _logger; private readonly SignUploadCommandHandler _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 SignUploadCommandHandlerTests() { _quotaRepository = Substitute.For(); _storageProviderFactory = Substitute.For(); _storageProvider = Substitute.For(); _logger = Substitute.For>(); _settings = Options.Create(new StorageSettings { MaxFileSizeBytes = MaxFileSize, DefaultBucket = "storage-bucket", PreSignedUrlExpirationSeconds = 3600 }); _storageProviderFactory.GetProvider().Returns(_storageProvider); _handler = new SignUploadCommandHandler( _quotaRepository, _storageProviderFactory, _settings, _logger); } #region Happy Path Tests [Fact] public async Task Handle_ValidRequest_ReturnsSuccessWithPresignedUrl() { // Arrange var command = CreateValidCommand(); var quota = CreateQuotaWithSpace(MaxQuotaBytes); var expectedUrl = "https://minio.example.com/bucket/object?X-Amz-Signature=..."; _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(expectedUrl); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeTrue(); result.UploadUrl.Should().Be(expectedUrl); result.ObjectKey.Should().NotBeNullOrEmpty(); result.ExpiresAt.Should().BeAfter(DateTime.UtcNow); result.Error.Should().BeNull(); } [Fact] public async Task Handle_ValidRequest_EnsuresBucketExists() { // Arrange var command = CreateValidCommand(); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _storageProvider.Received(1).EnsureBucketExistsAsync( "storage-bucket", Arg.Any()); } #endregion #region File Size Validation Tests [Fact] public async Task Handle_ExceedsMaxFileSize_ReturnsFailure() { // Arrange var command = new SignUploadCommand( TestUserId, TestFileName, TestContentType, MaxFileSize + 1); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeFalse(); result.Error.Should().Contain("exceeds maximum"); result.UploadUrl.Should().BeNull(); result.ObjectKey.Should().BeNull(); } [Fact] public async Task Handle_FileSizeAtLimit_ReturnsSuccess() { // Arrange var command = new SignUploadCommand( TestUserId, TestFileName, TestContentType, MaxFileSize); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Success.Should().BeTrue(); } #endregion #region Quota Validation Tests [Fact] public async Task Handle_QuotaExceeded_ReturnsFailure() { // Arrange var command = CreateValidCommand(); // Create quota with no space left 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"); result.UploadUrl.Should().BeNull(); } [Fact] public async Task Handle_ValidQuota_ChecksQuotaRepository() { // Arrange var command = CreateValidCommand(); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); // Act await _handler.Handle(command, CancellationToken.None); // Assert await _quotaRepository.Received(1).GetOrCreateAsync(TestUserId, Arg.Any()); } #endregion #region Object Key Generation Tests [Fact] public async Task Handle_PublicAccessLevel_GeneratesPublicPrefix() { // Arrange var command = new SignUploadCommand( TestUserId, TestFileName, TestContentType, TestFileSize, FileAccessLevel.Public); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.ObjectKey.Should().StartWith("public/"); } [Fact] public async Task Handle_PrivateAccessLevel_GeneratesPrivatePrefix() { // Arrange var command = new SignUploadCommand( TestUserId, TestFileName, TestContentType, TestFileSize, FileAccessLevel.Private); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.ObjectKey.Should().StartWith("private/"); } [Fact] public async Task Handle_SharedAccessLevel_GeneratesSharedPrefix() { // Arrange var command = new SignUploadCommand( TestUserId, TestFileName, TestContentType, TestFileSize, FileAccessLevel.Shared); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.ObjectKey.Should().StartWith("shared/"); } [Fact] public async Task Handle_ValidRequest_ObjectKeyContainsUserId() { // Arrange var command = CreateValidCommand(); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.ObjectKey.Should().Contain(TestUserId); } [Fact] public async Task Handle_ValidRequest_ObjectKeyContainsFileName() { // Arrange var command = CreateValidCommand(); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.ObjectKey.Should().EndWith(".pdf"); result.ObjectKey.Should().Contain("document"); } #endregion #region Exception Handling Tests [Fact] public async Task Handle_StorageProviderThrows_ReturnsFailure() { // Arrange var command = CreateValidCommand(); var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), 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(); result.Error.Should().NotBeNullOrEmpty(); } [Fact] public async Task Handle_QuotaRepositoryThrows_ReturnsFailure() { // Arrange var command = CreateValidCommand(); _quotaRepository.GetOrCreateAsync(TestUserId, 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(); } #endregion #region Helper Methods private static SignUploadCommand CreateValidCommand() { return new SignUploadCommand( TestUserId, TestFileName, TestContentType, TestFileSize); } /// /// EN: Create a real UserStorageQuota with available space. /// VI: Tạo real UserStorageQuota với dung lượng khả dụng. /// private static UserStorageQuota CreateQuotaWithSpace(long maxStorage) { return new UserStorageQuota(TestUserId, maxStorageBytes: maxStorage); } #endregion }