From 5c8f19bf3927f1abd8ade8990f96dd3439d8f104 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 09:46:31 -0300 Subject: [PATCH] feat(application): CRUD handlers + validators + DI de ProductType (PRD-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create/Update/Deactivate handlers con TransactionScope + audit; validators FluentValidation; DI wiring NullProductQueryRepository + 5 handlers; SqlTestFixture V017 + permiso count 25→26. --- .../SIGCM2.Application/DependencyInjection.cs | 16 ++ .../Create/CreateProductTypeCommand.cs | 13 ++ .../Create/CreateProductTypeCommandHandler.cs | 80 ++++++++ .../CreateProductTypeCommandValidator.cs | 29 +++ .../Create/ProductTypeCreatedDto.cs | 15 ++ .../DeactivateProductTypeCommand.cs | 3 + .../DeactivateProductTypeCommandHandler.cs | 72 +++++++ .../Deactivate/ProductTypeStatusDto.cs | 3 + .../Update/ProductTypeUpdatedDto.cs | 16 ++ .../Update/UpdateProductTypeCommand.cs | 14 ++ .../Update/UpdateProductTypeCommandHandler.cs | 74 +++++++ .../UpdateProductTypeCommandValidator.cs | 32 +++ .../Integration/PermisoRepositoryTests.cs | 7 +- .../Integration/RolPermisoRepositoryTests.cs | 7 +- .../CreateProductTypeCommandHandlerTests.cs | 182 ++++++++++++++++++ ...eactivateProductTypeCommandHandlerTests.cs | 159 +++++++++++++++ .../UpdateProductTypeCommandHandlerTests.cs | 179 +++++++++++++++++ .../Validators/ProductTypeValidatorsTests.cs | 130 +++++++++++++ tests/SIGCM2.TestSupport/SqlTestFixture.cs | 78 ++++++++ 19 files changed, 1103 insertions(+), 6 deletions(-) create mode 100644 src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommand.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/Create/ProductTypeCreatedDto.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommand.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/Deactivate/ProductTypeStatusDto.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/Update/ProductTypeUpdatedDto.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommand.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandValidator.cs create mode 100644 tests/SIGCM2.Application.Tests/ProductTypes/Create/CreateProductTypeCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/ProductTypes/Deactivate/DeactivateProductTypeCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/ProductTypes/Update/UpdateProductTypeCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/ProductTypes/Validators/ProductTypeValidatorsTests.cs diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 2b10fbb..49bd760 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -69,6 +69,12 @@ using SIGCM2.Application.Rubros.GetById; using SIGCM2.Application.Rubros.Dtos; using SIGCM2.Application.Abstractions.Persistence; 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; @@ -165,6 +171,16 @@ public static class DependencyInjection services.AddScoped>, GetRubroTreeQueryHandler>(); services.AddScoped, GetRubroByIdQueryHandler>(); + // ProductTypes (PRD-001) + // PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product. + services.AddScoped(); + + services.AddScoped, CreateProductTypeCommandHandler>(); + services.AddScoped, UpdateProductTypeCommandHandler>(); + services.AddScoped, DeactivateProductTypeCommandHandler>(); + services.AddScoped>, ListProductTypesQueryHandler>(); + services.AddScoped, GetProductTypeByIdQueryHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommand.cs b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommand.cs new file mode 100644 index 0000000..dfb1c23 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommand.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandHandler.cs b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandHandler.cs new file mode 100644 index 0000000..23c5d1c --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandHandler.cs @@ -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 +{ + 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 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); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandValidator.cs b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandValidator.cs new file mode 100644 index 0000000..0c4315d --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; + +namespace SIGCM2.Application.ProductTypes.Create; + +public sealed class CreateProductTypeCommandValidator : AbstractValidator +{ + 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)."); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/Create/ProductTypeCreatedDto.cs b/src/api/SIGCM2.Application/ProductTypes/Create/ProductTypeCreatedDto.cs new file mode 100644 index 0000000..53a0fc9 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Create/ProductTypeCreatedDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommand.cs b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommand.cs new file mode 100644 index 0000000..d60fc33 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.ProductTypes.Deactivate; + +public sealed record DeactivateProductTypeCommand(int Id); diff --git a/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs new file mode 100644 index 0000000..66c4781 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs @@ -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 +{ + 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 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); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/Deactivate/ProductTypeStatusDto.cs b/src/api/SIGCM2.Application/ProductTypes/Deactivate/ProductTypeStatusDto.cs new file mode 100644 index 0000000..6abfb6a --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Deactivate/ProductTypeStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.ProductTypes.Deactivate; + +public sealed record ProductTypeStatusDto(int Id, bool IsActive); diff --git a/src/api/SIGCM2.Application/ProductTypes/Update/ProductTypeUpdatedDto.cs b/src/api/SIGCM2.Application/ProductTypes/Update/ProductTypeUpdatedDto.cs new file mode 100644 index 0000000..004878b --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Update/ProductTypeUpdatedDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommand.cs b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommand.cs new file mode 100644 index 0000000..990b7c9 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommand.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandHandler.cs b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandHandler.cs new file mode 100644 index 0000000..d2d6745 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandHandler.cs @@ -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 +{ + 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 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); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandValidator.cs b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandValidator.cs new file mode 100644 index 0000000..1ce8b9a --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandValidator.cs @@ -0,0 +1,32 @@ +using FluentValidation; + +namespace SIGCM2.Application.ProductTypes.Update; + +public sealed class UpdateProductTypeCommandValidator : AbstractValidator +{ + 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)."); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs index a30e5a2..cf91367 100644 --- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -73,7 +73,7 @@ public class PermisoRepositoryTests : IAsyncLifetime // ── ListAsync ──────────────────────────────────────────────────────────── [Fact] - public async Task ListAsync_Returns25CanonicalSeeds() + public async Task ListAsync_Returns26CanonicalSeeds() { var list = await _repository.ListAsync(); @@ -81,8 +81,9 @@ public class PermisoRepositoryTests : IAsyncLifetime // + V011 (ADM-001) adds 'administracion:secciones:gestionar' // + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' // + V014 (ADM-009) adds 'administracion:fiscal:gestionar' - // + V016 (CAT-001) adds 'catalogo:rubros:gestionar' = 25 total - Assert.Equal(25, list.Count); + // + V016 (CAT-001) adds 'catalogo:rubros:gestionar' + // + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total + Assert.Equal(26, list.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs index a387abc..71fdfd7 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -173,16 +173,17 @@ public class RolPermisoRepositoryTests : IAsyncLifetime // ── GetByRolCodigoAsync ────────────────────────────────────────────────── [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) // + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' // + 1 from V013 (ADM-008): 'administracion:puntos_de_venta: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"); - Assert.Equal(25, permisos.Count); + Assert.Equal(26, permisos.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/Create/CreateProductTypeCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/Create/CreateProductTypeCommandHandlerTests.cs new file mode 100644 index 0000000..98ab0a7 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/Create/CreateProductTypeCommandHandlerTests.cs @@ -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(); + private readonly IAuditLogger _audit = Substitute.For(); + 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(), Arg.Any(), Arg.Any()).Returns(false); + _repo.AddAsync(Arg.Any(), Arg.Any()).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(), Arg.Any()).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(pt => pt.Nombre == "Clasificados"), + Arg.Any()); + } + + [Fact] + public async Task Handle_SuccessfulInsert_LogsAuditEventProductoTipoCreated() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(7); + + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "producto_tipo.created", + targetType: "ProductType", + targetId: "7", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [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(pt => pt.HasDuration && pt.IsBundle), + Arg.Any()); + } + + [Fact] + public async Task Handle_AllowImagesTrue_WithAllMaxNull_PersistsAllNull() + { + var cmd = ValidCommand(allowImages: true); + + await _handler.Handle(cmd); + + await _repo.Received(1).AddAsync( + Arg.Is(pt => pt.AllowImages && pt.MaxImages == null), + Arg.Any()); + } + + [Fact] + public async Task Handle_UsesTimeProvider_NotDateTimeNow() + { + var expectedDate = _timeProvider.GetUtcNow().UtcDateTime; + + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).AddAsync( + Arg.Is(pt => pt.FechaCreacion == expectedDate), + Arg.Any()); + } + + [Fact] + public async Task Handle_ReturnsCreatedDtoWithPersistedId() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).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(pt => pt.AllowImages == false && pt.MaxImages == null), + Arg.Any()); + } + + // ── Nombre duplicado ───────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NombreDuplicado_ThrowsProductTypeNombreDuplicadoException() + { + _repo.ExistsByNombreAsync("Clasificados", Arg.Any(), Arg.Any()).Returns(true); + + var act = async () => await _handler.Handle(ValidCommand()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_NombreDuplicado_CheckedBeforeFactory() + { + // If duplicate check throws, AddAsync should never be called + _repo.ExistsByNombreAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + + try { await _handler.Handle(ValidCommand()); } catch (ProductTypeNombreDuplicadoException) { } + + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + // ── Rollback scenarios ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_RepoThrows_AuditNotCalled_TransactionRollback() + { + _repo.AddAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("DB error")); + + var act = async () => await _handler.Handle(ValidCommand()); + await act.Should().ThrowAsync(); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AuditThrows_TransactionRollback() + { + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit error")); + + var act = async () => await _handler.Handle(ValidCommand()); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/Deactivate/DeactivateProductTypeCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/Deactivate/DeactivateProductTypeCommandHandlerTests.cs new file mode 100644 index 0000000..c1a0db7 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/Deactivate/DeactivateProductTypeCommandHandlerTests.cs @@ -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(); + private readonly IProductQueryRepository _productQuery = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + 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(), Arg.Any()).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()).Returns((ProductType?)null); + + var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(99)); + + await act.Should().ThrowAsync() + .Where(e => e.ProductTypeId == 99); + } + + // ── Already inactive (idempotent) ───────────────────────────────────────── + + [Fact] + public async Task Handle_AlreadyInactive_ReturnsIdempotentDto_NoAudit_NoRepoUpdate() + { + _repo.GetByIdAsync(1, Arg.Any()).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(), Arg.Any()); + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + // ── In use guard ───────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_InUseGuardReturnsTrue_ThrowsProductTypeEnUsoException() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveType()); + _productQuery.ExistsActiveByProductTypeAsync(1, Arg.Any()).Returns(true); + + var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await act.Should().ThrowAsync() + .Where(e => e.ProductTypeId == 1); + } + + [Fact] + public async Task Handle_CallsIProductQueryRepository_Received1() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveType()); + + await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await _productQuery.Received(1).ExistsActiveByProductTypeAsync(1, Arg.Any()); + } + + // ── Happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ValidDeactivation_UpdatesAndAuditsProductoTipoDeactivated() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveType()); + + await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(pt => !pt.IsActive), + Arg.Any()); + await _audit.Received(1).LogAsync( + action: "producto_tipo.deactivated", + targetType: "ProductType", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_UsesTimeProviderInDeactivate() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveType()); + var expectedDate = _timeProvider.GetUtcNow().UtcDateTime; + + await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(pt => pt.FechaModificacion == expectedDate), + Arg.Any()); + } + + [Fact] + public async Task Handle_ReturnsDtoWithIdAndIsActiveFalse() + { + _repo.GetByIdAsync(1, Arg.Any()).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()).Returns(ActiveType()); + _repo.UpdateAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("DB")); + + var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_AuditThrows_Rollback() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveType()); + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit")); + + var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/Update/UpdateProductTypeCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/Update/UpdateProductTypeCommandHandlerTests.cs new file mode 100644 index 0000000..b6f0b58 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/Update/UpdateProductTypeCommandHandlerTests.cs @@ -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(); + private readonly IAuditLogger _audit = Substitute.For(); + 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(), Arg.Any(), Arg.Any()).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()).Returns((ProductType?)null); + + var act = async () => await _handler.Handle(ValidCommand()); + + await act.Should().ThrowAsync() + .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()).Returns(ExistingType(nombre: "Clasificados")); + + await _handler.Handle(ValidCommand(nombre: "Clasificados")); + + await _repo.DidNotReceive().ExistsByNombreAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_RenameToExistingActiveName_ThrowsProductTypeNombreDuplicadoException() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType(nombre: "Clasificados")); + _repo.ExistsByNombreAsync("Notables", 1, Arg.Any()).Returns(true); + + var act = async () => await _handler.Handle(ValidCommand(nombre: "Notables")); + + await act.Should().ThrowAsync(); + } + + // ── Happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ValidUpdate_PersistsAndAuditsProductoTipoUpdated() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType()); + + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + await _audit.Received(1).LogAsync( + action: "producto_tipo.updated", + targetType: "ProductType", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [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()).Returns(existing); + + var cmd = ValidCommand() with { AllowImages = false }; + await _handler.Handle(cmd); + + await _repo.Received(1).UpdateAsync( + Arg.Is(pt => !pt.AllowImages && pt.MaxImages == null), + Arg.Any()); + } + + [Fact] + public async Task Handle_UpdatesFechaModificacion_WithTimeProvider() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType()); + var expectedDate = _timeProvider.GetUtcNow().UtcDateTime; + + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).UpdateAsync( + Arg.Is(pt => pt.FechaModificacion == expectedDate), + Arg.Any()); + } + + [Fact] + public async Task Handle_PreservesIsActiveFromTarget() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType(isActive: true)); + + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).UpdateAsync( + Arg.Is(pt => pt.IsActive), + Arg.Any()); + } + + // ── Rollback scenarios ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_RepoThrows_NoAudit_Rollback() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType()); + _repo.UpdateAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("DB")); + + var act = async () => await _handler.Handle(ValidCommand()); + await act.Should().ThrowAsync(); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AuditThrows_Rollback() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType()); + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit")); + + var act = async () => await _handler.Handle(ValidCommand()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_AuditLoggerLogsBeforeAndAfter() + { + _repo.GetByIdAsync(1, Arg.Any()).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(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/Validators/ProductTypeValidatorsTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/Validators/ProductTypeValidatorsTests.cs new file mode 100644 index 0000000..a4d1f7d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/Validators/ProductTypeValidatorsTests.cs @@ -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); +} diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index c6bc106..f2737da 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -60,6 +60,9 @@ public sealed class SqlTestFixture : IAsyncLifetime // V016 (CAT-001): ensure dbo.Rubro + temporal + permiso 'catalogo:rubros:gestionar'. await EnsureV016SchemaAsync(); + // V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'. + await EnsureV017SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -86,6 +89,8 @@ public sealed class SqlTestFixture : IAsyncLifetime new Respawn.Graph.Table("dbo", "IngresosBrutos"), // CAT-001 (V016): Rubro es temporal — history no puede deletearse directo. 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(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); + } }