247 lines
11 KiB
C#
247 lines
11 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.Create;
|
||
|
|
using SIGCM2.Domain.Entities;
|
||
|
|
using SIGCM2.Domain.Exceptions;
|
||
|
|
|
||
|
|
namespace SIGCM2.Application.Tests.Products.Create;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// PRD-002 — CreateProductCommandHandler tests.
|
||
|
|
/// Covers: happy path, flags coherence, duplicate nombre, inactive Medio/ProductType/Rubro,
|
||
|
|
/// audit, immutability, rollback.
|
||
|
|
/// </summary>
|
||
|
|
public class CreateProductCommandHandlerTests
|
||
|
|
{
|
||
|
|
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
|
||
|
|
private readonly IProductTypeRepository _ptRepo = Substitute.For<IProductTypeRepository>();
|
||
|
|
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
|
||
|
|
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 CreateProductCommandHandler _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 readonly ProductType _activePtHasDuration = new(
|
||
|
|
id: 4, nombre: "Con Duración",
|
||
|
|
hasDuration: true, 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 _inactivePt = new(
|
||
|
|
id: 5, nombre: "Inactivo",
|
||
|
|
hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false,
|
||
|
|
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||
|
|
isActive: false,
|
||
|
|
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||
|
|
|
||
|
|
private static Medio ActiveMedio(int id = 1) => new(
|
||
|
|
id: id, codigo: "ELD", nombre: "El Día",
|
||
|
|
tipo: TipoMedio.Diario, plataformaEmpresaId: null,
|
||
|
|
activo: true,
|
||
|
|
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||
|
|
|
||
|
|
private static Medio InactiveMedio(int id = 1) => new(
|
||
|
|
id: id, codigo: "ELD", nombre: "El Día",
|
||
|
|
tipo: TipoMedio.Diario, plataformaEmpresaId: null,
|
||
|
|
activo: false,
|
||
|
|
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||
|
|
|
||
|
|
private static Rubro ActiveRubro(int id = 10) => new(
|
||
|
|
id: id, parentId: null, nombre: "Clasificados", orden: 1,
|
||
|
|
activo: true, tarifarioBaseId: null,
|
||
|
|
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||
|
|
|
||
|
|
private static Rubro InactiveRubro(int id = 10) => new(
|
||
|
|
id: id, parentId: null, nombre: "Clasificados", orden: 1,
|
||
|
|
activo: false, tarifarioBaseId: null,
|
||
|
|
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||
|
|
|
||
|
|
public CreateProductCommandHandlerTests()
|
||
|
|
{
|
||
|
|
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveMedio(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);
|
||
|
|
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>()).Returns(1);
|
||
|
|
_handler = new CreateProductCommandHandler(_repo, _ptRepo, _medioRepo, _rubroRepo, _audit, _time);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static CreateProductCommand ValidCmd(int productTypeId = 2) => new(
|
||
|
|
Nombre: "Clasificado Estándar",
|
||
|
|
MedioId: 1,
|
||
|
|
ProductTypeId: productTypeId,
|
||
|
|
RubroId: null,
|
||
|
|
BasePrice: 100.50m,
|
||
|
|
PriceDurationDays: null);
|
||
|
|
|
||
|
|
// ── Happy path ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_ValidCommand_InsertsAndReturnsDto()
|
||
|
|
{
|
||
|
|
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>()).Returns(42);
|
||
|
|
|
||
|
|
var result = await _handler.Handle(ValidCmd());
|
||
|
|
|
||
|
|
result.Id.Should().Be(42);
|
||
|
|
result.Nombre.Should().Be("Clasificado Estándar");
|
||
|
|
result.IsActive.Should().BeTrue();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_ValidCommand_LogsAuditEvent_ProductoCreated()
|
||
|
|
{
|
||
|
|
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>()).Returns(7);
|
||
|
|
|
||
|
|
await _handler.Handle(ValidCmd());
|
||
|
|
|
||
|
|
await _audit.Received(1).LogAsync(
|
||
|
|
action: "producto.created",
|
||
|
|
targetType: "Product",
|
||
|
|
targetId: "7",
|
||
|
|
metadata: Arg.Any<object?>(),
|
||
|
|
ct: Arg.Any<CancellationToken>());
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_UsesTimeProvider_NotDateTimeNow()
|
||
|
|
{
|
||
|
|
var expectedDate = _time.GetUtcNow().UtcDateTime;
|
||
|
|
|
||
|
|
await _handler.Handle(ValidCmd());
|
||
|
|
|
||
|
|
await _repo.Received(1).AddAsync(
|
||
|
|
Arg.Is<Product>(p => p.FechaCreacion == expectedDate),
|
||
|
|
Arg.Any<CancellationToken>());
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Medio not found / inactive ────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_MedioNotFound_ThrowsMedioNotFoundException()
|
||
|
|
{
|
||
|
|
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns((Medio?)null);
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(ValidCmd());
|
||
|
|
|
||
|
|
await act.Should().ThrowAsync<MedioNotFoundException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_MedioInactivo_ThrowsMedioInactivoException()
|
||
|
|
{
|
||
|
|
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(InactiveMedio(1));
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(ValidCmd());
|
||
|
|
|
||
|
|
await act.Should().ThrowAsync<MedioInactivoException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── ProductType not found / inactive ──────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_ProductTypeNotFound_ThrowsProductTypeNotFoundException()
|
||
|
|
{
|
||
|
|
_ptRepo.GetByIdAsync(2, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(ValidCmd());
|
||
|
|
|
||
|
|
await act.Should().ThrowAsync<ProductTypeNotFoundException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_ProductTypeInactivo_ThrowsProductTypeInactivoException()
|
||
|
|
{
|
||
|
|
_ptRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(_inactivePt);
|
||
|
|
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveMedio(1));
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(ValidCmd(productTypeId: 5));
|
||
|
|
|
||
|
|
await act.Should().ThrowAsync<ProductTypeInactivoException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Flags coherence ───────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_RequiresCategoryTrue_RubroIdNull_ThrowsFlagsException()
|
||
|
|
{
|
||
|
|
_ptRepo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(_activePtRequiresCategory);
|
||
|
|
var cmd = ValidCmd(productTypeId: 3) with { RubroId = null };
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(cmd);
|
||
|
|
|
||
|
|
await act.Should().ThrowAsync<ProductTipoFlagsIncoherentesException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_HasDurationTrue_PriceDurationDaysNull_ThrowsFlagsException()
|
||
|
|
{
|
||
|
|
_ptRepo.GetByIdAsync(4, Arg.Any<CancellationToken>()).Returns(_activePtHasDuration);
|
||
|
|
var cmd = ValidCmd(productTypeId: 4) with { PriceDurationDays = null };
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(cmd);
|
||
|
|
|
||
|
|
await act.Should().ThrowAsync<ProductTipoFlagsIncoherentesException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Rubro validation ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_RubroProvided_RubroInactivo_ThrowsRubroInactivoException()
|
||
|
|
{
|
||
|
|
_ptRepo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(_activePtRequiresCategory);
|
||
|
|
_rubroRepo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(InactiveRubro(10));
|
||
|
|
var cmd = ValidCmd(productTypeId: 3) with { RubroId = 10 };
|
||
|
|
|
||
|
|
var act = async () => await _handler.Handle(cmd);
|
||
|
|
|
||
|
|
await act.Should().ThrowAsync<RubroInactivoException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Duplicate nombre ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Handle_NombreDuplicadoEnMedioTipo_ThrowsProductNombreDuplicadoException()
|
||
|
|
{
|
||
|
|
_repo.ExistsByNombreAsync("Clasificado Estándar", 1, 2, null, 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_TransactionRollback()
|
||
|
|
{
|
||
|
|
_repo.AddAsync(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>());
|
||
|
|
}
|
||
|
|
}
|