feat: PRD-002 Product CRUD #40

Merged
dmolinari merged 14 commits from feature/PRD-002 into main 2026-04-19 16:49:58 +00:00
13 changed files with 1030 additions and 5 deletions
Showing only changes of commit bb455be745 - Show all commits

View File

@@ -70,6 +70,11 @@ using SIGCM2.Application.Rubros.Dtos;
using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Avisos; using SIGCM2.Application.Avisos;
using SIGCM2.Application.Products; using SIGCM2.Application.Products;
using SIGCM2.Application.Products.Create;
using SIGCM2.Application.Products.Update;
using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Application.Products.GetById;
using SIGCM2.Application.Products.List;
using SIGCM2.Application.ProductTypes.Create; using SIGCM2.Application.ProductTypes.Create;
using SIGCM2.Application.ProductTypes.Update; using SIGCM2.Application.ProductTypes.Update;
using SIGCM2.Application.ProductTypes.Deactivate; using SIGCM2.Application.ProductTypes.Deactivate;
@@ -171,6 +176,13 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>(); services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>(); services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
// Products (PRD-002)
services.AddScoped<ICommandHandler<CreateProductCommand, ProductCreatedDto>, CreateProductCommandHandler>();
services.AddScoped<ICommandHandler<UpdateProductCommand, ProductUpdatedDto>, UpdateProductCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateProductCommand, ProductStatusDto>, DeactivateProductCommandHandler>();
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
// ProductTypes (PRD-001) // ProductTypes (PRD-001)
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product. // PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>(); services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();

View File

@@ -0,0 +1,112 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.Create;
public sealed class CreateProductCommandHandler
: ICommandHandler<CreateProductCommand, ProductCreatedDto>
{
private readonly IProductRepository _repo;
private readonly IProductTypeRepository _ptRepo;
private readonly IMedioRepository _medioRepo;
private readonly IRubroRepository _rubroRepo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public CreateProductCommandHandler(
IProductRepository repo,
IProductTypeRepository ptRepo,
IMedioRepository medioRepo,
IRubroRepository rubroRepo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_ptRepo = ptRepo;
_medioRepo = medioRepo;
_rubroRepo = rubroRepo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductCreatedDto> Handle(CreateProductCommand command)
{
// 1. Validate Medio exists and is active
var medio = await _medioRepo.GetByIdAsync(command.MedioId)
?? throw new MedioNotFoundException(command.MedioId);
if (!medio.Activo)
throw new MedioInactivoException(command.MedioId);
// 2. Validate ProductType exists and is active
var productType = await _ptRepo.GetByIdAsync(command.ProductTypeId)
?? throw new ProductTypeNotFoundException(command.ProductTypeId);
if (!productType.IsActive)
throw new ProductTypeInactivoException(command.ProductTypeId);
// 3. Flags coherence: RequiresCategory → RubroId required
if (productType.RequiresCategory && !command.RubroId.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
// 4. Flags coherence: HasDuration → PriceDurationDays required
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
// 5. Validate Rubro if provided: must be active
if (command.RubroId.HasValue)
{
var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value);
if (rubro == null || !rubro.Activo)
throw new RubroInactivoException(command.RubroId.Value);
}
// 6. Duplicate nombre check (filtered on IsActive=1 — allows reuse after soft-delete)
var exists = await _repo.ExistsByNombreAsync(command.Nombre, command.MedioId, command.ProductTypeId, excludeId: null);
if (exists)
throw new ProductNombreDuplicadoEnMedioTipoException(command.MedioId, command.ProductTypeId, command.Nombre);
// 7. Build entity
var entity = Product.ForCreation(
command.Nombre, command.MedioId, command.ProductTypeId,
command.RubroId, command.BasePrice, command.PriceDurationDays,
_timeProvider);
// 8. Persist + audit (fail-closed)
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
var newId = await _repo.AddAsync(entity);
await _audit.LogAsync(
action: "producto.created",
targetType: "Product",
targetId: newId.ToString(),
metadata: new
{
after = new
{
entity.Nombre,
entity.MedioId,
entity.ProductTypeId,
entity.RubroId,
entity.BasePrice,
entity.PriceDurationDays,
}
});
tx.Complete();
return new ProductCreatedDto(
newId, entity.Nombre,
entity.MedioId, entity.ProductTypeId, entity.RubroId,
entity.BasePrice, entity.PriceDurationDays,
entity.IsActive, entity.FechaCreacion);
}
}

View File

@@ -0,0 +1,62 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.Deactivate;
public sealed class DeactivateProductCommandHandler
: ICommandHandler<DeactivateProductCommand, ProductStatusDto>
{
private readonly IProductRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public DeactivateProductCommandHandler(
IProductRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductStatusDto> Handle(DeactivateProductCommand command)
{
// 1. Load entity
var target = await _repo.GetByIdAsync(command.Id)
?? throw new ProductNotFoundException(command.Id);
// 2. Idempotent: already inactive → return without side effects
if (!target.IsActive)
return new ProductStatusDto(command.Id, false, target.FechaModificacion);
// 3. Deactivate (immutable)
var deactivated = target.WithDeactivated(_timeProvider);
// 4. Persist + audit (fail-closed)
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(deactivated);
await _audit.LogAsync(
action: "producto.deactivated",
targetType: "Product",
targetId: command.Id.ToString(),
metadata: new
{
productId = command.Id,
nombre = target.Nombre,
});
tx.Complete();
return new ProductStatusDto(deactivated.Id, deactivated.IsActive, deactivated.FechaModificacion);
}
}

View File

@@ -0,0 +1,29 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.GetById;
public sealed class GetProductByIdQueryHandler
: ICommandHandler<GetProductByIdQuery, ProductDetailDto>
{
private readonly IProductRepository _repo;
public GetProductByIdQueryHandler(IProductRepository repo)
{
_repo = repo;
}
public async Task<ProductDetailDto> Handle(GetProductByIdQuery query)
{
var product = await _repo.GetByIdAsync(query.Id)
?? throw new ProductNotFoundException(query.Id);
return new ProductDetailDto(
product.Id, product.Nombre,
product.MedioId, product.ProductTypeId, product.RubroId,
product.BasePrice, product.PriceDurationDays,
product.IsActive,
product.FechaCreacion, product.FechaModificacion);
}
}

View File

@@ -0,0 +1,35 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Products.List;
public sealed class ListProductsQueryHandler
: ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>
{
private readonly IProductRepository _repo;
public ListProductsQueryHandler(IProductRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<ProductListItemDto>> Handle(ListProductsQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new ProductsQuery(
page, pageSize, query.Activo, query.Search,
query.MedioId, query.ProductTypeId, query.RubroId);
var paged = await _repo.GetPagedAsync(repoQuery);
var items = paged.Items.Select(p => new ProductListItemDto(
p.Id, p.Nombre,
p.MedioId, p.ProductTypeId, p.RubroId,
p.BasePrice, p.PriceDurationDays,
p.IsActive)).ToList();
return new PagedResult<ProductListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -0,0 +1,97 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.Update;
public sealed class UpdateProductCommandHandler
: ICommandHandler<UpdateProductCommand, ProductUpdatedDto>
{
private readonly IProductRepository _repo;
private readonly IProductTypeRepository _ptRepo;
private readonly IRubroRepository _rubroRepo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public UpdateProductCommandHandler(
IProductRepository repo,
IProductTypeRepository ptRepo,
IRubroRepository rubroRepo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_ptRepo = ptRepo;
_rubroRepo = rubroRepo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductUpdatedDto> Handle(UpdateProductCommand command)
{
// 1. Load entity
var target = await _repo.GetByIdAsync(command.Id)
?? throw new ProductNotFoundException(command.Id);
// 2. Load ProductType (MedioId + ProductTypeId are immutable post-creation)
var productType = await _ptRepo.GetByIdAsync(target.ProductTypeId)
?? throw new ProductTypeNotFoundException(target.ProductTypeId);
// 3. Flags coherence
if (productType.RequiresCategory && !command.RubroId.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
// 4. Validate Rubro if provided: must be active
if (command.RubroId.HasValue)
{
var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value);
if (rubro == null || !rubro.Activo)
throw new RubroInactivoException(command.RubroId.Value);
}
// 5. Duplicate nombre check (skip if name unchanged — optimization)
if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase))
{
var exists = await _repo.ExistsByNombreAsync(command.Nombre, target.MedioId, target.ProductTypeId, excludeId: command.Id);
if (exists)
throw new ProductNombreDuplicadoEnMedioTipoException(target.MedioId, target.ProductTypeId, command.Nombre);
}
// 6. Apply mutation (immutable)
var updated = target.WithUpdated(command.Nombre, command.RubroId, command.BasePrice, command.PriceDurationDays, _timeProvider);
// 7. Persist + audit
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "producto.updated",
targetType: "Product",
targetId: command.Id.ToString(),
metadata: new
{
before = new { target.Nombre, target.RubroId, target.BasePrice, target.PriceDurationDays },
after = new { updated.Nombre, updated.RubroId, updated.BasePrice, updated.PriceDurationDays }
});
tx.Complete();
return new ProductUpdatedDto(
updated.Id, updated.Nombre,
updated.MedioId, updated.ProductTypeId, updated.RubroId,
updated.BasePrice, updated.PriceDurationDays,
updated.IsActive, updated.FechaCreacion, updated.FechaModificacion);
}
}

View File

@@ -82,8 +82,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' // + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar'
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar' // + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar' // + V016 (CAT-001) adds 'catalogo:rubros:gestionar'
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total // + V017 (PRD-001) adds 'catalogo:tipos:gestionar'
Assert.Equal(26, list.Count); // + V018 (PRD-002) adds 'catalogo:productos:gestionar' = 27 total
Assert.Equal(27, list.Count);
} }
[Fact] [Fact]

View File

@@ -173,17 +173,18 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
// ── GetByRolCodigoAsync ────────────────────────────────────────────────── // ── GetByRolCodigoAsync ──────────────────────────────────────────────────
[Fact] [Fact]
public async Task GetByRolCodigoAsync_Admin_Returns26Permisos() public async Task GetByRolCodigoAsync_Admin_Returns27Permisos()
{ {
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006) // admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' // + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar' // + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar'
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar' // + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar' // + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar'
// + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' = 26 total // + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar'
// + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' = 27 total
var permisos = await _repository.GetByRolCodigoAsync("admin"); var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(26, permisos.Count); Assert.Equal(27, permisos.Count);
} }
[Fact] [Fact]

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>());
}
}