405 lines
14 KiB
C#
405 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// EN: Unit tests for UploadFileCommandHandler.
|
|
/// VI: Unit tests cho UploadFileCommandHandler.
|
|
/// </summary>
|
|
public class UploadFileCommandHandlerTests
|
|
{
|
|
private readonly IFileRepository _fileRepository;
|
|
private readonly IQuotaRepository _quotaRepository;
|
|
private readonly IStorageProviderFactory _storageProviderFactory;
|
|
private readonly IStorageProvider _storageProvider;
|
|
private readonly IOptions<StorageSettings> _settings;
|
|
private readonly ILogger<UploadFileCommandHandler> _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()
|
|
{
|
|
// EN: Setup mocks / VI: Setup mocks
|
|
_fileRepository = Substitute.For<IFileRepository>();
|
|
_quotaRepository = Substitute.For<IQuotaRepository>();
|
|
_storageProviderFactory = Substitute.For<IStorageProviderFactory>();
|
|
_storageProvider = Substitute.For<IStorageProvider>();
|
|
_logger = Substitute.For<ILogger<UploadFileCommandHandler>>();
|
|
_unitOfWork = Substitute.For<IUnitOfWork>();
|
|
|
|
_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<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.UploadAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Stream>(),
|
|
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(StorageResult.Ok("private/user-123/20260115/abc123_document.pdf", TestFileSize, "checksum123"));
|
|
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.UploadAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Stream>(),
|
|
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(StorageResult.Ok("objectKey", TestFileSize));
|
|
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
|
|
|
|
// Act
|
|
await _handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await _fileRepository.Received(1).AddAsync(
|
|
Arg.Is<StorageFile>(f =>
|
|
f.FileName == TestFileName &&
|
|
f.UserId == TestUserId),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[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<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.UploadAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Stream>(),
|
|
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(StorageResult.Ok("objectKey", TestFileSize));
|
|
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
|
|
|
|
// Act
|
|
await _handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
quota.Received(1).AddUsage(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<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.UploadAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Stream>(),
|
|
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(StorageResult.Ok("objectKey", TestFileSize));
|
|
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
|
|
|
|
// Act
|
|
await _handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await _unitOfWork.Received(1).SaveEntitiesAsync(Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
#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, // Exceeds max
|
|
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<string>(), Arg.Any<string>(), Arg.Any<Stream>(),
|
|
Arg.Any<string>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
#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);
|
|
var quota = CreateQuotaWithSpace(0); // No space
|
|
|
|
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
|
|
.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 = CreateQuotaWithSpace(0);
|
|
|
|
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
|
|
.Returns(quota);
|
|
|
|
// Act
|
|
await _handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await _storageProvider.DidNotReceive().UploadAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Stream>(),
|
|
Arg.Any<string>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
#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<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.UploadAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Stream>(),
|
|
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.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<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.UploadAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Stream>(),
|
|
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(StorageResult.Fail("Failed"));
|
|
|
|
// Act
|
|
await _handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await _fileRepository.DidNotReceive().AddAsync(
|
|
Arg.Any<StorageFile>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
#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<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.UploadAsync(
|
|
Arg.Any<string>(),
|
|
Arg.Do<string>(key => capturedObjectKey = key),
|
|
Arg.Any<Stream>(),
|
|
Arg.Any<string>(),
|
|
Arg.Any<CancellationToken>())
|
|
.Returns(StorageResult.Ok("objectKey", TestFileSize));
|
|
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.UploadAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Stream>(),
|
|
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(StorageResult.Ok("objectKey", TestFileSize));
|
|
_fileRepository.AddAsync(Arg.Any<StorageFile>(), Arg.Any<CancellationToken>())
|
|
.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 availableSpace)
|
|
{
|
|
var quota = Substitute.For<UserStorageQuota>();
|
|
quota.CanUpload(Arg.Any<long>()).Returns(callInfo =>
|
|
{
|
|
var requestedSize = callInfo.Arg<long>();
|
|
return requestedSize <= availableSpace;
|
|
});
|
|
return quota;
|
|
}
|
|
|
|
#endregion
|
|
}
|