feat(application): Product handlers + DI registration, fix permiso count to 27 (PRD-002)

This commit is contained in:
2026-04-19 13:07:59 -03:00
parent 8b555e1f8b
commit bb455be745
13 changed files with 1030 additions and 5 deletions

View File

@@ -0,0 +1,246 @@
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>());
}
}

View File

@@ -0,0 +1,133 @@
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.Deactivate;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Products.Deactivate;
/// <summary>
/// PRD-002 — DeactivateProductCommandHandler tests.
/// </summary>
public class DeactivateProductCommandHandlerTests
{
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 14, 0, 0, TimeSpan.Zero));
private readonly DeactivateProductCommandHandler _handler;
public DeactivateProductCommandHandlerTests()
{
_handler = new DeactivateProductCommandHandler(_repo, _audit, _time);
}
private static Product ActiveProduct(int id = 1) => new(
id: id, nombre: "Clasificado Estándar",
medioId: 1, productTypeId: 2, rubroId: null,
basePrice: 100.50m, priceDurationDays: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
fechaModificacion: null);
private static Product InactiveProduct(int id = 1) => new(
id: id, nombre: "Clasificado Estándar",
medioId: 1, productTypeId: 2, rubroId: null,
basePrice: 100.50m, priceDurationDays: null,
isActive: false,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
fechaModificacion: null);
// ── Not found ────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_NotFound_ThrowsProductNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Product?)null);
var act = async () => await _handler.Handle(new DeactivateProductCommand(99));
await act.Should().ThrowAsync<ProductNotFoundException>()
.Where(e => e.ProductId == 99);
}
// ── Already inactive (idempotent) ─────────────────────────────────────────
[Fact]
public async Task Handle_AlreadyInactive_ReturnsDto_NoAudit_NoRepoUpdate()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(InactiveProduct());
var result = await _handler.Handle(new DeactivateProductCommand(1));
result.Id.Should().Be(1);
result.IsActive.Should().BeFalse();
await _repo.DidNotReceive().UpdateAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>());
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
// ── Happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_ActiveProduct_DeactivatesAndAudits()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
await _handler.Handle(new DeactivateProductCommand(1));
await _repo.Received(1).UpdateAsync(
Arg.Is<Product>(p => !p.IsActive),
Arg.Any<CancellationToken>());
await _audit.Received(1).LogAsync(
action: "producto.deactivated",
targetType: "Product",
targetId: "1",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_UsesTimeProviderInDeactivate()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
var expectedDate = _time.GetUtcNow().UtcDateTime;
await _handler.Handle(new DeactivateProductCommand(1));
await _repo.Received(1).UpdateAsync(
Arg.Is<Product>(p => p.FechaModificacion == expectedDate),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ReturnsDtoWithIsActiveFalse()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
var result = await _handler.Handle(new DeactivateProductCommand(1));
result.IsActive.Should().BeFalse();
}
// ── Rollback ──────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_RepoThrows_AuditNotCalled()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
_repo.UpdateAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB error"));
var act = async () => await _handler.Handle(new DeactivateProductCommand(1));
await act.Should().ThrowAsync<InvalidOperationException>();
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,60 @@
using FluentAssertions;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Products.GetById;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Products.GetById;
/// <summary>
/// PRD-002 — GetProductByIdQueryHandler tests.
/// </summary>
public class GetProductByIdQueryHandlerTests
{
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
private readonly GetProductByIdQueryHandler _handler;
public GetProductByIdQueryHandlerTests()
{
_handler = new GetProductByIdQueryHandler(_repo);
}
private static Product AProduct(int id = 1) => new(
id: id,
nombre: "Clasificado Estándar",
medioId: 1,
productTypeId: 2,
rubroId: null,
basePrice: 100.50m,
priceDurationDays: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
fechaModificacion: null);
[Fact]
public async Task Handle_ExistingId_ReturnsMappedDto()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(AProduct(1));
var result = await _handler.Handle(new GetProductByIdQuery(1));
result.Id.Should().Be(1);
result.Nombre.Should().Be("Clasificado Estándar");
result.MedioId.Should().Be(1);
result.ProductTypeId.Should().Be(2);
result.BasePrice.Should().Be(100.50m);
result.IsActive.Should().BeTrue();
}
[Fact]
public async Task Handle_NotFound_ThrowsProductNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Product?)null);
var act = async () => await _handler.Handle(new GetProductByIdQuery(99));
await act.Should().ThrowAsync<ProductNotFoundException>()
.Where(e => e.ProductId == 99);
}
}

View File

@@ -0,0 +1,88 @@
using FluentAssertions;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.List;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Products.List;
/// <summary>
/// PRD-002 — ListProductsQueryHandler tests.
/// </summary>
public class ListProductsQueryHandlerTests
{
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
private readonly ListProductsQueryHandler _handler;
public ListProductsQueryHandlerTests()
{
_repo.GetPagedAsync(Arg.Any<ProductsQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<Product>([], 1, 20, 0));
_handler = new ListProductsQueryHandler(_repo);
}
private static Product AProduct(int id = 1) => new(
id: id,
nombre: "Clasificado",
medioId: 1,
productTypeId: 2,
rubroId: null,
basePrice: 50m,
priceDurationDays: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
fechaModificacion: null);
[Fact]
public async Task Handle_EmptyPage_ReturnsPaged_WithZeroTotal()
{
var result = await _handler.Handle(new ListProductsQuery());
result.Total.Should().Be(0);
result.Items.Should().BeEmpty();
}
[Fact]
public async Task Handle_WithItems_ReturnsMappedListItemDtos()
{
_repo.GetPagedAsync(Arg.Any<ProductsQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<Product>([AProduct(1), AProduct(2)], 1, 20, 2));
var result = await _handler.Handle(new ListProductsQuery());
result.Items.Should().HaveCount(2);
result.Items[0].Id.Should().Be(1);
result.Items[1].Id.Should().Be(2);
}
[Fact]
public async Task Handle_PageNormalization_ClampsPageSizeTo100()
{
await _handler.Handle(new ListProductsQuery(Page: 1, PageSize: 200));
await _repo.Received(1).GetPagedAsync(
Arg.Is<ProductsQuery>(q => q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PageBelowOne_NormalizesToOne()
{
await _handler.Handle(new ListProductsQuery(Page: -1));
await _repo.Received(1).GetPagedAsync(
Arg.Is<ProductsQuery>(q => q.Page == 1),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PassesFiltersToRepo()
{
await _handler.Handle(new ListProductsQuery(MedioId: 5, ProductTypeId: 3, RubroId: 7));
await _repo.Received(1).GetPagedAsync(
Arg.Is<ProductsQuery>(q => q.MedioId == 5 && q.ProductTypeId == 3 && q.RubroId == 7),
Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,149 @@
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>());
}
}