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