feat: PRD-001 ProductType (flags + multimedia) #38
@@ -69,6 +69,12 @@ using SIGCM2.Application.Rubros.GetById;
|
|||||||
using SIGCM2.Application.Rubros.Dtos;
|
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.ProductTypes.Create;
|
||||||
|
using SIGCM2.Application.ProductTypes.Update;
|
||||||
|
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
using SIGCM2.Application.ProductTypes.List;
|
||||||
|
using SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
|
||||||
namespace SIGCM2.Application;
|
namespace SIGCM2.Application;
|
||||||
|
|
||||||
@@ -165,6 +171,16 @@ 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>();
|
||||||
|
|
||||||
|
// ProductTypes (PRD-001)
|
||||||
|
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
|
||||||
|
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();
|
||||||
|
|
||||||
|
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>, DeactivateProductTypeCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
|
||||||
|
|
||||||
// FluentValidation validators (scans entire Application assembly)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Create;
|
||||||
|
|
||||||
|
public sealed record CreateProductTypeCommand(
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
int? MaxImages,
|
||||||
|
decimal? MaxImageSizeMB,
|
||||||
|
int? MaxImageWidth,
|
||||||
|
int? MaxImageHeight);
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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.ProductTypes.Create;
|
||||||
|
|
||||||
|
public sealed class CreateProductTypeCommandHandler
|
||||||
|
: ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public CreateProductTypeCommandHandler(
|
||||||
|
IProductTypeRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProductTypeCreatedDto> Handle(CreateProductTypeCommand command)
|
||||||
|
{
|
||||||
|
// 1. Duplicate name check (before factory — avoids wasting domain allocation on error)
|
||||||
|
var exists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: null);
|
||||||
|
if (exists)
|
||||||
|
throw new ProductTypeNombreDuplicadoException(command.Nombre);
|
||||||
|
|
||||||
|
// 2. Build entity (factory normalizes multimedia if AllowImages=false)
|
||||||
|
var entity = ProductType.ForCreation(
|
||||||
|
command.Nombre,
|
||||||
|
command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle,
|
||||||
|
command.AllowImages,
|
||||||
|
command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight,
|
||||||
|
_timeProvider);
|
||||||
|
|
||||||
|
// 3. Persist + audit (fail-closed: if audit throws, TX rolls back)
|
||||||
|
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_tipo.created",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: newId.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
after = new
|
||||||
|
{
|
||||||
|
entity.Nombre,
|
||||||
|
entity.HasDuration,
|
||||||
|
entity.RequiresText,
|
||||||
|
entity.RequiresCategory,
|
||||||
|
entity.IsBundle,
|
||||||
|
entity.AllowImages,
|
||||||
|
entity.MaxImages,
|
||||||
|
entity.MaxImageSizeMB,
|
||||||
|
entity.MaxImageWidth,
|
||||||
|
entity.MaxImageHeight,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new ProductTypeCreatedDto(
|
||||||
|
newId, entity.Nombre,
|
||||||
|
entity.HasDuration, entity.RequiresText, entity.RequiresCategory, entity.IsBundle,
|
||||||
|
entity.AllowImages,
|
||||||
|
entity.MaxImages, entity.MaxImageSizeMB, entity.MaxImageWidth, entity.MaxImageHeight,
|
||||||
|
entity.IsActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.ProductTypes.Create;
|
||||||
|
|
||||||
|
public sealed class CreateProductTypeCommandValidator : AbstractValidator<CreateProductTypeCommand>
|
||||||
|
{
|
||||||
|
public CreateProductTypeCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Nombre)
|
||||||
|
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
|
||||||
|
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImages)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImages.HasValue)
|
||||||
|
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageSizeMB)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
|
||||||
|
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageWidth)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
|
||||||
|
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageHeight)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
|
||||||
|
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Create;
|
||||||
|
|
||||||
|
public sealed record ProductTypeCreatedDto(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
int? MaxImages,
|
||||||
|
decimal? MaxImageSizeMB,
|
||||||
|
int? MaxImageWidth,
|
||||||
|
int? MaxImageHeight,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateProductTypeCommand(int Id);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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.ProductTypes.Deactivate;
|
||||||
|
|
||||||
|
public sealed class DeactivateProductTypeCommandHandler
|
||||||
|
: ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo;
|
||||||
|
private readonly IProductQueryRepository _productQuery;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public DeactivateProductTypeCommandHandler(
|
||||||
|
IProductTypeRepository repo,
|
||||||
|
IProductQueryRepository productQuery,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_productQuery = productQuery;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProductTypeStatusDto> Handle(DeactivateProductTypeCommand command)
|
||||||
|
{
|
||||||
|
// 1. Load entity
|
||||||
|
var target = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new ProductTypeNotFoundException(command.Id);
|
||||||
|
|
||||||
|
// 2. Idempotent: already inactive → return without side effects (I7)
|
||||||
|
if (!target.IsActive)
|
||||||
|
return new ProductTypeStatusDto(command.Id, false);
|
||||||
|
|
||||||
|
// 3. Guard: check if any active product uses this type (guard before audit — ordering matters)
|
||||||
|
var inUse = await _productQuery.ExistsActiveByProductTypeAsync(command.Id);
|
||||||
|
if (inUse)
|
||||||
|
throw new ProductTypeEnUsoException(command.Id, productsActivos: -1);
|
||||||
|
// Note: count=-1 sentinel because Products table doesn't exist in PRD-001.
|
||||||
|
// PRD-002 will update this with the actual count.
|
||||||
|
|
||||||
|
// 4. Deactivate (immutable — returns new instance)
|
||||||
|
var deactivated = target.WithDeactivated(_timeProvider);
|
||||||
|
|
||||||
|
// 5. 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_tipo.deactivated",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
productTypeId = command.Id,
|
||||||
|
nombre = target.Nombre,
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new ProductTypeStatusDto(deactivated.Id, deactivated.IsActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
|
||||||
|
public sealed record ProductTypeStatusDto(int Id, bool IsActive);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Update;
|
||||||
|
|
||||||
|
public sealed record ProductTypeUpdatedDto(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
int? MaxImages,
|
||||||
|
decimal? MaxImageSizeMB,
|
||||||
|
int? MaxImageWidth,
|
||||||
|
int? MaxImageHeight,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Update;
|
||||||
|
|
||||||
|
public sealed record UpdateProductTypeCommand(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
int? MaxImages,
|
||||||
|
decimal? MaxImageSizeMB,
|
||||||
|
int? MaxImageWidth,
|
||||||
|
int? MaxImageHeight);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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.ProductTypes.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateProductTypeCommandHandler
|
||||||
|
: ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public UpdateProductTypeCommandHandler(
|
||||||
|
IProductTypeRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProductTypeUpdatedDto> Handle(UpdateProductTypeCommand command)
|
||||||
|
{
|
||||||
|
// 1. Load entity (throws if not found)
|
||||||
|
var target = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new ProductTypeNotFoundException(command.Id);
|
||||||
|
|
||||||
|
// 2. If nombre changed, check for duplicate (skip call when same name — optimization)
|
||||||
|
if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var duplicateExists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: command.Id);
|
||||||
|
if (duplicateExists)
|
||||||
|
throw new ProductTypeNombreDuplicadoException(command.Nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build updated entity via With* methods (immutable, each returns new instance)
|
||||||
|
var updated = target
|
||||||
|
.WithRenamed(command.Nombre, _timeProvider)
|
||||||
|
.WithUpdatedFlags(command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle, _timeProvider)
|
||||||
|
.WithUpdatedMultimedia(command.AllowImages, command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight, _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(updated);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "producto_tipo.updated",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
before = new { target.Nombre, target.HasDuration, target.RequiresText, target.RequiresCategory, target.IsBundle, target.AllowImages },
|
||||||
|
after = new { updated.Nombre, updated.HasDuration, updated.RequiresText, updated.RequiresCategory, updated.IsBundle, updated.AllowImages }
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new ProductTypeUpdatedDto(
|
||||||
|
updated.Id, updated.Nombre,
|
||||||
|
updated.HasDuration, updated.RequiresText, updated.RequiresCategory, updated.IsBundle,
|
||||||
|
updated.AllowImages,
|
||||||
|
updated.MaxImages, updated.MaxImageSizeMB, updated.MaxImageWidth, updated.MaxImageHeight,
|
||||||
|
updated.IsActive, updated.FechaModificacion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.ProductTypes.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateProductTypeCommandValidator : AbstractValidator<UpdateProductTypeCommand>
|
||||||
|
{
|
||||||
|
public UpdateProductTypeCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0).WithMessage("El Id debe ser un entero positivo.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Nombre)
|
||||||
|
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
|
||||||
|
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImages)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImages.HasValue)
|
||||||
|
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageSizeMB)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
|
||||||
|
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageWidth)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
|
||||||
|
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageHeight)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
|
||||||
|
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ public class PermisoRepositoryTests : IAsyncLifetime
|
|||||||
// ── ListAsync ────────────────────────────────────────────────────────────
|
// ── ListAsync ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListAsync_Returns25CanonicalSeeds()
|
public async Task ListAsync_Returns26CanonicalSeeds()
|
||||||
{
|
{
|
||||||
var list = await _repository.ListAsync();
|
var list = await _repository.ListAsync();
|
||||||
|
|
||||||
@@ -81,8 +81,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
|
|||||||
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
|
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
|
||||||
// + 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' = 25 total
|
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar'
|
||||||
Assert.Equal(25, list.Count);
|
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total
|
||||||
|
Assert.Equal(26, list.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -173,16 +173,17 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
|
|||||||
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
|
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetByRolCodigoAsync_Admin_Returns25Permisos()
|
public async Task GetByRolCodigoAsync_Admin_Returns26Permisos()
|
||||||
{
|
{
|
||||||
// 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' = 25 total
|
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar'
|
||||||
|
// + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' = 26 total
|
||||||
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
||||||
|
|
||||||
Assert.Equal(25, permisos.Count);
|
Assert.Equal(26, permisos.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.ProductTypes.Create;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes.Create;
|
||||||
|
|
||||||
|
public class CreateProductTypeCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
|
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
|
||||||
|
private readonly CreateProductTypeCommandHandler _handler;
|
||||||
|
|
||||||
|
public CreateProductTypeCommandHandlerTests()
|
||||||
|
{
|
||||||
|
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||||
|
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(1);
|
||||||
|
_handler = new CreateProductTypeCommandHandler(_repo, _audit, _timeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateProductTypeCommand ValidCommand(bool allowImages = false) => new(
|
||||||
|
Nombre: "Clasificados",
|
||||||
|
HasDuration: true, RequiresText: true, RequiresCategory: false, IsBundle: false,
|
||||||
|
AllowImages: allowImages,
|
||||||
|
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
|
||||||
|
|
||||||
|
// ── Happy path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ValidCommand_InsertsAndReturnsDto()
|
||||||
|
{
|
||||||
|
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(42);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
result.Id.Should().Be(42);
|
||||||
|
result.Nombre.Should().Be("Clasificados");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ValidCommand_CallsAddAsync()
|
||||||
|
{
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repo.Received(1).AddAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.Nombre == "Clasificados"),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SuccessfulInsert_LogsAuditEventProductoTipoCreated()
|
||||||
|
{
|
||||||
|
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(7);
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "producto_tipo.created",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: "7",
|
||||||
|
metadata: Arg.Any<object?>(),
|
||||||
|
ct: Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PreservesFlagsAsProvided()
|
||||||
|
{
|
||||||
|
var cmd = ValidCommand() with { HasDuration = true, IsBundle = true };
|
||||||
|
|
||||||
|
await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
await _repo.Received(1).AddAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.HasDuration && pt.IsBundle),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AllowImagesTrue_WithAllMaxNull_PersistsAllNull()
|
||||||
|
{
|
||||||
|
var cmd = ValidCommand(allowImages: true);
|
||||||
|
|
||||||
|
await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
await _repo.Received(1).AddAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.AllowImages && pt.MaxImages == null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_UsesTimeProvider_NotDateTimeNow()
|
||||||
|
{
|
||||||
|
var expectedDate = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repo.Received(1).AddAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.FechaCreacion == expectedDate),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsCreatedDtoWithPersistedId()
|
||||||
|
{
|
||||||
|
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(99);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
result.Id.Should().Be(99);
|
||||||
|
result.IsActive.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AllowImages=false normalization ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AllowImagesFalse_MaxImagesInput5_PersistsWithMaxImagesNull()
|
||||||
|
{
|
||||||
|
var cmd = ValidCommand() with { AllowImages = false, MaxImages = 5 };
|
||||||
|
|
||||||
|
await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
await _repo.Received(1).AddAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.AllowImages == false && pt.MaxImages == null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nombre duplicado ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NombreDuplicado_ThrowsProductTypeNombreDuplicadoException()
|
||||||
|
{
|
||||||
|
_repo.ExistsByNombreAsync("Clasificados", Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeNombreDuplicadoException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NombreDuplicado_CheckedBeforeFactory()
|
||||||
|
{
|
||||||
|
// If duplicate check throws, AddAsync should never be called
|
||||||
|
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||||
|
|
||||||
|
try { await _handler.Handle(ValidCommand()); } catch (ProductTypeNombreDuplicadoException) { }
|
||||||
|
|
||||||
|
await _repo.DidNotReceive().AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rollback scenarios ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_RepoThrows_AuditNotCalled_TransactionRollback()
|
||||||
|
{
|
||||||
|
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("DB error"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
|
||||||
|
await _audit.DidNotReceive().LogAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditThrows_TransactionRollback()
|
||||||
|
{
|
||||||
|
_audit.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Audit error"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes.Deactivate;
|
||||||
|
|
||||||
|
public class DeactivateProductTypeCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
|
||||||
|
private readonly IProductQueryRepository _productQuery = Substitute.For<IProductQueryRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
|
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 19, 14, 0, 0, TimeSpan.Zero));
|
||||||
|
private readonly DeactivateProductTypeCommandHandler _handler;
|
||||||
|
|
||||||
|
public DeactivateProductTypeCommandHandlerTests()
|
||||||
|
{
|
||||||
|
_productQuery.ExistsActiveByProductTypeAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||||
|
_handler = new DeactivateProductTypeCommandHandler(_repo, _productQuery, _audit, _timeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProductType ActiveType(int id = 1) =>
|
||||||
|
new(id, "Clasificados", false, false, false, false, false, null, null, null, null,
|
||||||
|
isActive: true, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), null);
|
||||||
|
|
||||||
|
private static ProductType InactiveType(int id = 1) =>
|
||||||
|
new(id, "Clasificados", false, false, false, false, false, null, null, null, null,
|
||||||
|
isActive: false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), null);
|
||||||
|
|
||||||
|
// ── Not found ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NotFound_ThrowsProductTypeNotFoundException()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(99));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeNotFoundException>()
|
||||||
|
.Where(e => e.ProductTypeId == 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Already inactive (idempotent) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AlreadyInactive_ReturnsIdempotentDto_NoAudit_NoRepoUpdate()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(InactiveType());
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
result.Id.Should().Be(1);
|
||||||
|
result.IsActive.Should().BeFalse();
|
||||||
|
await _repo.DidNotReceive().UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>());
|
||||||
|
await _audit.DidNotReceive().LogAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── In use guard ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_InUseGuardReturnsTrue_ThrowsProductTypeEnUsoException()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
_productQuery.ExistsActiveByProductTypeAsync(1, Arg.Any<CancellationToken>()).Returns(true);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeEnUsoException>()
|
||||||
|
.Where(e => e.ProductTypeId == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_CallsIProductQueryRepository_Received1()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
|
||||||
|
await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await _productQuery.Received(1).ExistsActiveByProductTypeAsync(1, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Happy path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ValidDeactivation_UpdatesAndAuditsProductoTipoDeactivated()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
|
||||||
|
await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<ProductType>(pt => !pt.IsActive),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "producto_tipo.deactivated",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: "1",
|
||||||
|
metadata: Arg.Any<object?>(),
|
||||||
|
ct: Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_UsesTimeProviderInDeactivate()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
var expectedDate = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
|
await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.FechaModificacion == expectedDate),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsDtoWithIdAndIsActiveFalse()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
result.Id.Should().Be(1);
|
||||||
|
result.IsActive.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rollback scenarios ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_RepoThrows_Rollback()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
_repo.UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("DB"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditThrows_Rollback()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
_audit.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Audit"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.ProductTypes.Update;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes.Update;
|
||||||
|
|
||||||
|
public class UpdateProductTypeCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
|
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 19, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
private readonly UpdateProductTypeCommandHandler _handler;
|
||||||
|
|
||||||
|
public UpdateProductTypeCommandHandlerTests()
|
||||||
|
{
|
||||||
|
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||||
|
_handler = new UpdateProductTypeCommandHandler(_repo, _audit, _timeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProductType ExistingType(int id = 1, string nombre = "Clasificados", bool isActive = true) =>
|
||||||
|
new(id, nombre, false, false, false, false, false, null, null, null, null,
|
||||||
|
isActive, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), null);
|
||||||
|
|
||||||
|
private static UpdateProductTypeCommand ValidCommand(int id = 1, string nombre = "Clasificados Actualizado") => new(
|
||||||
|
Id: id,
|
||||||
|
Nombre: nombre,
|
||||||
|
HasDuration: true, RequiresText: false, RequiresCategory: true, IsBundle: false,
|
||||||
|
AllowImages: false,
|
||||||
|
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
|
||||||
|
|
||||||
|
// ── Not found ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NotFound_ThrowsProductTypeNotFoundException()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeNotFoundException>()
|
||||||
|
.Where(e => e.ProductTypeId == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nombre duplicado ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_RenameToSameName_DoesNotCheckDuplicate()
|
||||||
|
{
|
||||||
|
// Same nombre → no duplicate check needed
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(nombre: "Clasificados"));
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand(nombre: "Clasificados"));
|
||||||
|
|
||||||
|
await _repo.DidNotReceive().ExistsByNombreAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_RenameToExistingActiveName_ThrowsProductTypeNombreDuplicadoException()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(nombre: "Clasificados"));
|
||||||
|
_repo.ExistsByNombreAsync("Notables", 1, Arg.Any<CancellationToken>()).Returns(true);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand(nombre: "Notables"));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeNombreDuplicadoException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Happy path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ValidUpdate_PersistsAndAuditsProductoTipoUpdated()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>());
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "producto_tipo.updated",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: "1",
|
||||||
|
metadata: Arg.Any<object?>(),
|
||||||
|
ct: Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_TurnOffAllowImages_NullifiesMultimedia()
|
||||||
|
{
|
||||||
|
var existing = new ProductType(
|
||||||
|
1, "Tipo", false, false, false, false,
|
||||||
|
allowImages: true, maxImages: 5, maxImageSizeMB: 2.0m, maxImageWidth: 800, maxImageHeight: 600,
|
||||||
|
isActive: true, DateTime.UtcNow, null);
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||||
|
|
||||||
|
var cmd = ValidCommand() with { AllowImages = false };
|
||||||
|
await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<ProductType>(pt => !pt.AllowImages && pt.MaxImages == null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_UpdatesFechaModificacion_WithTimeProvider()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
|
||||||
|
var expectedDate = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.FechaModificacion == expectedDate),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PreservesIsActiveFromTarget()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(isActive: true));
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.IsActive),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rollback scenarios ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_RepoThrows_NoAudit_Rollback()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
|
||||||
|
_repo.UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("DB"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
|
||||||
|
await _audit.DidNotReceive().LogAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditThrows_Rollback()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
|
||||||
|
_audit.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Audit"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditLoggerLogsBeforeAndAfter()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(nombre: "Antes"));
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand(nombre: "Despues"));
|
||||||
|
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "producto_tipo.updated",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: "1",
|
||||||
|
metadata: Arg.Any<object?>(),
|
||||||
|
ct: Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using FluentValidation.TestHelper;
|
||||||
|
using SIGCM2.Application.ProductTypes.Create;
|
||||||
|
using SIGCM2.Application.ProductTypes.Update;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes.Validators;
|
||||||
|
|
||||||
|
public class ProductTypeValidatorsTests
|
||||||
|
{
|
||||||
|
private readonly CreateProductTypeCommandValidator _createValidator = new();
|
||||||
|
private readonly UpdateProductTypeCommandValidator _updateValidator = new();
|
||||||
|
|
||||||
|
// ── Create: Nombre validations ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_NombreEmpty_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { Nombre = "" };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.Nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_NombreWhitespace_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { Nombre = " " };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.Nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_NombreOver200Chars_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { Nombre = new string('A', 201) };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.Nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create: MaxImages validations ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_MaxImagesZero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { AllowImages = true, MaxImages = 0 };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.MaxImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_MaxImagesNegative_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { AllowImages = true, MaxImages = -1 };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.MaxImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_MaxImageSizeMBZero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { MaxImageSizeMB = 0.0m };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.MaxImageSizeMB);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_MaxImageWidthZero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { MaxImageWidth = 0 };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.MaxImageWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_MaxImageHeightZero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { MaxImageHeight = 0 };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.MaxImageHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create: valid commands pass ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_ValidCommand_Passes()
|
||||||
|
{
|
||||||
|
var result = _createValidator.TestValidate(ValidCreateCommand());
|
||||||
|
result.ShouldNotHaveAnyValidationErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_AllowImagesFalse_WithMaxImagesSet_Passes()
|
||||||
|
{
|
||||||
|
// Normalization is handler's responsibility, not validator's
|
||||||
|
var cmd = ValidCreateCommand() with { AllowImages = false, MaxImages = 5 };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldNotHaveAnyValidationErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update: inherits same rules + Id > 0 ─────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Update_IdZero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidUpdateCommand() with { Id = 0 };
|
||||||
|
var result = _updateValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Update_ValidCommand_Passes()
|
||||||
|
{
|
||||||
|
var result = _updateValidator.TestValidate(ValidUpdateCommand());
|
||||||
|
result.ShouldNotHaveAnyValidationErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static CreateProductTypeCommand ValidCreateCommand() => new(
|
||||||
|
Nombre: "Clasificados",
|
||||||
|
HasDuration: false, RequiresText: false, RequiresCategory: false, IsBundle: false,
|
||||||
|
AllowImages: false,
|
||||||
|
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
|
||||||
|
|
||||||
|
private static UpdateProductTypeCommand ValidUpdateCommand() => new(
|
||||||
|
Id: 1,
|
||||||
|
Nombre: "Clasificados",
|
||||||
|
HasDuration: false, RequiresText: false, RequiresCategory: false, IsBundle: false,
|
||||||
|
AllowImages: false,
|
||||||
|
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
|
||||||
|
}
|
||||||
@@ -60,6 +60,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
// V016 (CAT-001): ensure dbo.Rubro + temporal + permiso 'catalogo:rubros:gestionar'.
|
// V016 (CAT-001): ensure dbo.Rubro + temporal + permiso 'catalogo:rubros:gestionar'.
|
||||||
await EnsureV016SchemaAsync();
|
await EnsureV016SchemaAsync();
|
||||||
|
|
||||||
|
// V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'.
|
||||||
|
await EnsureV017SchemaAsync();
|
||||||
|
|
||||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||||
{
|
{
|
||||||
DbAdapter = DbAdapter.SqlServer,
|
DbAdapter = DbAdapter.SqlServer,
|
||||||
@@ -86,6 +89,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
|
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
|
||||||
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
|
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
|
||||||
new Respawn.Graph.Table("dbo", "Rubro_History"),
|
new Respawn.Graph.Table("dbo", "Rubro_History"),
|
||||||
|
// PRD-001 (V017): ProductType es temporal — history no puede deletearse directo.
|
||||||
|
new Respawn.Graph.Table("dbo", "ProductType_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -933,4 +938,77 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
await _connection.ExecuteAsync(createUqIndex);
|
await _connection.ExecuteAsync(createUqIndex);
|
||||||
await _connection.ExecuteAsync(createCoveringIndex);
|
await _connection.ExecuteAsync(createCoveringIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task EnsureV017SchemaAsync()
|
||||||
|
{
|
||||||
|
const string createProductType = """
|
||||||
|
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.ProductType (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductType PRIMARY KEY,
|
||||||
|
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||||
|
HasDuration BIT NOT NULL CONSTRAINT DF_ProductType_HasDuration DEFAULT(0),
|
||||||
|
RequiresText BIT NOT NULL CONSTRAINT DF_ProductType_RequiresText DEFAULT(0),
|
||||||
|
RequiresCategory BIT NOT NULL CONSTRAINT DF_ProductType_RequiresCategory DEFAULT(0),
|
||||||
|
IsBundle BIT NOT NULL CONSTRAINT DF_ProductType_IsBundle DEFAULT(0),
|
||||||
|
AllowImages BIT NOT NULL CONSTRAINT DF_ProductType_AllowImages DEFAULT(0),
|
||||||
|
MaxImages INT NULL,
|
||||||
|
MaxImageSizeMB DECIMAL(10,2) NULL,
|
||||||
|
MaxImageWidth INT NULL,
|
||||||
|
MaxImageHeight INT NULL,
|
||||||
|
IsActive BIT NOT NULL CONSTRAINT DF_ProductType_IsActive DEFAULT(1),
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductType_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL
|
||||||
|
);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string addProductTypePeriod = """
|
||||||
|
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductType
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductType_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductType_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string setProductTypeVersioning = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductType
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.ProductType_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string createUqIndex = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_ProductType_Nombre_Activo' AND object_id = OBJECT_ID('dbo.ProductType'))
|
||||||
|
BEGIN
|
||||||
|
CREATE UNIQUE INDEX UQ_ProductType_Nombre_Activo
|
||||||
|
ON dbo.ProductType(Nombre)
|
||||||
|
WHERE IsActive = 1;
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string createCoveringIndex = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductType_IsActive_Cover' AND object_id = OBJECT_ID('dbo.ProductType'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_ProductType_IsActive_Cover
|
||||||
|
ON dbo.ProductType(IsActive)
|
||||||
|
INCLUDE (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
await _connection.ExecuteAsync(createProductType);
|
||||||
|
await _connection.ExecuteAsync(addProductTypePeriod);
|
||||||
|
await _connection.ExecuteAsync(setProductTypeVersioning);
|
||||||
|
await _connection.ExecuteAsync(createUqIndex);
|
||||||
|
await _connection.ExecuteAsync(createCoveringIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user