343 lines
11 KiB
C#
343 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// EN: Unit tests for DeleteFileCommandHandler.
|
|
/// VI: Unit tests cho DeleteFileCommandHandler.
|
|
/// </summary>
|
|
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<DeleteFileCommandHandler> _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<IFileRepository>();
|
|
_quotaRepository = Substitute.For<IQuotaRepository>();
|
|
_storageProviderFactory = Substitute.For<IStorageProviderFactory>();
|
|
_storageProvider = Substitute.For<IStorageProvider>();
|
|
_cache = Substitute.For<IRedisCacheService>();
|
|
_logger = Substitute.For<ILogger<DeleteFileCommandHandler>>();
|
|
_unitOfWork = Substitute.For<IUnitOfWork>();
|
|
|
|
_storageProviderFactory.GetProvider(Arg.Any<StorageProvider>()).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<CancellationToken>())
|
|
.Returns(file);
|
|
_quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.DeleteAsync(TestBucketName, Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(true);
|
|
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
|
|
.Returns(file);
|
|
_quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.DeleteAsync(TestBucketName, Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(true);
|
|
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
|
|
.Returns(file);
|
|
_quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.DeleteAsync(TestBucketName, Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(true);
|
|
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
|
|
|
|
// Act
|
|
await _handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await _cache.Received().DeleteAsync(
|
|
Arg.Is<string>(key => key.Contains(file.Id.ToString())),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
#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<CancellationToken>())
|
|
.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<CancellationToken>())
|
|
.Returns((StorageFile?)null);
|
|
|
|
// Act
|
|
await _handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await _storageProvider.DidNotReceive().DeleteAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
#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<CancellationToken>())
|
|
.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<CancellationToken>())
|
|
.Returns(file);
|
|
|
|
// Act
|
|
await _handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
file.IsDeleted.Should().BeFalse();
|
|
await _storageProvider.DidNotReceive().DeleteAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
#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<CancellationToken>())
|
|
.Returns(file);
|
|
_quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any<CancellationToken>())
|
|
.Returns(quota);
|
|
_storageProvider.DeleteAsync(TestBucketName, Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(false); // Storage delete fails
|
|
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
|
|
.Returns(file);
|
|
_quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any<CancellationToken>())
|
|
.Returns((UserStorageQuota?)null);
|
|
_storageProvider.DeleteAsync(TestBucketName, Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(true);
|
|
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
|
|
.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<CancellationToken>())
|
|
.Returns(file);
|
|
_storageProvider.DeleteAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.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
|
|
}
|