150 lines
6.4 KiB
C#
150 lines
6.4 KiB
C#
|
|
using FluentAssertions;
|
||
|
|
using Microsoft.Extensions.Time.Testing;
|
||
|
|
using NSubstitute;
|
||
|
|
using NSubstitute.ExceptionExtensions;
|
||
|
|
using SIGCM2.Application.Abstractions.Persistence;
|
||
|
|
using SIGCM2.Application.Audit;
|
||
|
|
using SIGCM2.Application.Products.Update;
|
||
|
|
using SIGCM2.Domain.Entities;
|
||
|
|
using SIGCM2.Domain.Exceptions;
|
||
|
|
|
||
|
|
namespace SIGCM2.Application.Tests.Products.Update;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// PRD-002 — UpdateProductCommandHandler tests.
|
||
|
|
/// </summary>
|
||
|
|
public class UpdateProductCommandHandlerTests
|
||
|
|
{
|
||
|
|
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
|
||
|
|
private readonly IProductTypeRepository _ptRepo = Substitute.For<IProductTypeRepository>();
|
||
|
|
private readonly IRubroRepository _rubroRepo = Substitute.For<IRubroRepository>();
|
||
|
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||
|
|
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
|
||
|
|
private readonly UpdateProductCommandHandler _handler;
|
||
|
|
|
||
|
|
private static readonly ProductType _activePtNoFlags = new(
|
||
|
|
id: 2, nombre: "Clasificado",
|
||
|
|
hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false,
|
||
|
|
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||
|
|
isActive: true,
|
||
|
|
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||
|
|
|
||
|
|
private static readonly ProductType _activePtRequiresCategory = new(
|
||
|
|
id: 3, nombre: "Con Rubro",
|
||
|
|
hasDuration: false, requiresText: false, requiresCategory: true, isBundle: false,
|
||
|
|
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||
|
|
isActive: true,
|
||
|
|
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||
|
|
|
||
|
|
private static Product AProduct(int id = 1, int productTypeId = 2) => new(
|
||
|
|
id: id, nombre: "Clasificado Estándar",
|
||
|
|
medioId: 1, productTypeId: productTypeId, rubroId: null,
|
||
|
|
basePrice: 100.50m, priceDurationDays: null,
|
||
|
|
isActive: true,
|
||
|
|
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||
|
|
fechaModificacion: null);
|
||
|
|
|
||
|
|
public UpdateProductCommandHandlerTests()
|
||
|
|
{
|
||
|
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(AProduct(1));
|
||
|
|
_ptRepo.GetByIdAsync(2, Arg.Any<CancellationToken>()).Returns(_activePtNoFlags);
|
||
|
|
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
|
||
|
|
_handler = new UpdateProductCommandHandler(_repo, _ptRepo, _rubroRepo, _audit, _time);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static UpdateProductCommand ValidCmd() => new(
|
||
|
|
Id: 1,
|
||
|
|
Nombre: "Nuevo Nombre",
|
||
|
|
RubroId: null,
|
||
|
|
BasePrice: 200m,
|
||
|
|
PriceDurationDays: null);
|
||
|
|
|
||
|
|
// ── Happy path ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_ValidCommand_UpdatesAndReturnsDto()
|
||
|
|
{
|
||
|
|
var result = await _handler.Handle(ValidCmd());
|
||
|
|
|
||
|
|
result.Id.Should().Be(1);
|
||
|
|
result.Nombre.Should().Be("Nuevo Nombre");
|
||
|
|
result.BasePrice.Should().Be(200m);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_ValidCommand_CallsUpdateAsync()
|
||
|
|
{
|
||
|
|
await _handler.Handle(ValidCmd());
|
||
|
|
|
||
|
|
await _repo.Received(1).UpdateAsync(
|
||
|
|
Arg.Is<Product>(p => p.Nombre == "Nuevo Nombre"),
|
||
|
|
Arg.Any<CancellationToken>());
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_ValidCommand_LogsAuditEvent_ProductoUpdated()
|
||
|
|
{
|
||
|
|
await _handler.Handle(ValidCmd());
|
||
|
|
|
||
|
|
await _audit.Received(1).LogAsync(
|
||
|
|
action: "producto.updated",
|
||
|
|
targetType: "Product",
|
||
|
|
targetId: "1",
|
||
|
|
metadata: Arg.Any<object?>(),
|
||
|
|
ct: Arg.Any<CancellationToken>());
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Not found ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_NotFound_ThrowsProductNotFoundException()
|
||
|
|
{
|
||
|
|
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Product?)null);
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(ValidCmd() with { Id = 99 });
|
||
|
|
|
||
|
|
await act.Should().ThrowAsync<ProductNotFoundException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Flags coherence ───────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_RequiresCategoryTrue_RubroIdNull_ThrowsFlagsException()
|
||
|
|
{
|
||
|
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(AProduct(1, productTypeId: 3));
|
||
|
|
_ptRepo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(_activePtRequiresCategory);
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(ValidCmd() with { RubroId = null });
|
||
|
|
|
||
|
|
await act.Should().ThrowAsync<ProductTipoFlagsIncoherentesException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Duplicate nombre ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_NombreDuplicado_ThrowsProductNombreDuplicadoException()
|
||
|
|
{
|
||
|
|
_repo.ExistsByNombreAsync("Nuevo Nombre", 1, 2, 1, Arg.Any<CancellationToken>()).Returns(true);
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(ValidCmd());
|
||
|
|
|
||
|
|
await act.Should().ThrowAsync<ProductNombreDuplicadoEnMedioTipoException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Rollback ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_RepoThrows_AuditNotCalled()
|
||
|
|
{
|
||
|
|
_repo.UpdateAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>())
|
||
|
|
.ThrowsAsync(new InvalidOperationException("DB error"));
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(ValidCmd());
|
||
|
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||
|
|
|
||
|
|
await _audit.DidNotReceive().LogAsync(
|
||
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||
|
|
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||
|
|
}
|
||
|
|
}
|