feat(application): Product handlers + DI registration, fix permiso count to 27 (PRD-002)
This commit is contained in:
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user