From 16197cf2428a12db408d7f48e20657dad7d9fb99 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 12:59:58 -0300 Subject: [PATCH 01/14] feat(domain): Product entity + 5 domain exceptions (PRD-002) --- src/api/SIGCM2.Domain/Entities/Product.cs | 172 ++++++++++++++ ...ductNombreDuplicadoEnMedioTipoException.cs | 19 ++ .../Exceptions/ProductNotFoundException.cs | 15 ++ .../ProductTipoFlagsIncoherentesException.cs | 16 ++ .../ProductTypeInactivoException.cs | 15 ++ .../Exceptions/RubroInactivoException.cs | 15 ++ .../Domain/ProductTests.cs | 218 ++++++++++++++++++ .../Exceptions/ProductExceptionsTests.cs | 60 +++++ 8 files changed, 530 insertions(+) create mode 100644 src/api/SIGCM2.Domain/Entities/Product.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/ProductNombreDuplicadoEnMedioTipoException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/ProductTipoFlagsIncoherentesException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/ProductTypeInactivoException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/ProductTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Exceptions/ProductExceptionsTests.cs diff --git a/src/api/SIGCM2.Domain/Entities/Product.cs b/src/api/SIGCM2.Domain/Entities/Product.cs new file mode 100644 index 0000000..1962efb --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/Product.cs @@ -0,0 +1,172 @@ +namespace SIGCM2.Domain.Entities; + +/// +/// Immutable product entity for the commercial catalog. +/// Factory method ForCreation creates new products (Id=0). +/// Mutation methods (With*) return new instances — original is never modified. +/// Flag coherence (RequiresCategory/HasDuration) is enforced by Application handlers +/// at creation/update time against the ProductType, NOT here in the entity. +/// MedioId and ProductTypeId are immutable post-creation by design. +/// +public sealed class Product +{ + private const int NombreMaxLength = 300; + + public int Id { get; } + public string Nombre { get; } + public int MedioId { get; } + public int ProductTypeId { get; } + public int? RubroId { get; } + public decimal BasePrice { get; } + public int? PriceDurationDays { get; } + public bool IsActive { get; } + public DateTime FechaCreacion { get; } + public DateTime? FechaModificacion { get; } + + /// Full hydration constructor — used by the repository to reconstruct from DB rows. + public Product( + int id, + string nombre, + int medioId, + int productTypeId, + int? rubroId, + decimal basePrice, + int? priceDurationDays, + bool isActive, + DateTime fechaCreacion, + DateTime? fechaModificacion) + { + Id = id; + Nombre = nombre; + MedioId = medioId; + ProductTypeId = productTypeId; + RubroId = rubroId; + BasePrice = basePrice; + PriceDurationDays = priceDurationDays; + IsActive = isActive; + FechaCreacion = fechaCreacion; + FechaModificacion = fechaModificacion; + } + + /// + /// Factory for a new Product. Id=0 — DB assigns via IDENTITY. + /// IsActive=true, FechaModificacion=null. + /// + public static Product ForCreation( + string nombre, + int medioId, + int productTypeId, + int? rubroId, + decimal basePrice, + int? priceDurationDays, + TimeProvider timeProvider) + { + ValidateNombre(nombre); + ValidateMedioId(medioId); + ValidateProductTypeId(productTypeId); + ValidateRubroId(rubroId); + ValidateBasePrice(basePrice); + ValidatePriceDurationDays(priceDurationDays); + + return new Product( + id: 0, + nombre: nombre.Trim(), + medioId: medioId, + productTypeId: productTypeId, + rubroId: rubroId, + basePrice: basePrice, + priceDurationDays: priceDurationDays, + isActive: true, + fechaCreacion: timeProvider.GetUtcNow().UtcDateTime, + fechaModificacion: null); + } + + public Product WithRenamed(string nuevoNombre, TimeProvider timeProvider) + { + ValidateNombre(nuevoNombre); + return new Product(Id, nuevoNombre.Trim(), MedioId, ProductTypeId, RubroId, + BasePrice, PriceDurationDays, IsActive, FechaCreacion, + timeProvider.GetUtcNow().UtcDateTime); + } + + public Product WithUpdatedPrice(decimal basePrice, int? priceDurationDays, TimeProvider timeProvider) + { + ValidateBasePrice(basePrice); + ValidatePriceDurationDays(priceDurationDays); + return new Product(Id, Nombre, MedioId, ProductTypeId, RubroId, + basePrice, priceDurationDays, IsActive, FechaCreacion, + timeProvider.GetUtcNow().UtcDateTime); + } + + public Product WithUpdatedCategory(int? rubroId, TimeProvider timeProvider) + { + ValidateRubroId(rubroId); + return new Product(Id, Nombre, MedioId, ProductTypeId, rubroId, + BasePrice, PriceDurationDays, IsActive, FechaCreacion, + timeProvider.GetUtcNow().UtcDateTime); + } + + /// + /// Combo mutator: renames, updates price and category in one call. + /// Used by UpdateProductCommandHandler. + /// + public Product WithUpdated(string nombre, int? rubroId, decimal basePrice, int? priceDurationDays, TimeProvider timeProvider) + { + ValidateNombre(nombre); + ValidateRubroId(rubroId); + ValidateBasePrice(basePrice); + ValidatePriceDurationDays(priceDurationDays); + return new Product(Id, nombre.Trim(), MedioId, ProductTypeId, rubroId, + basePrice, priceDurationDays, IsActive, FechaCreacion, + timeProvider.GetUtcNow().UtcDateTime); + } + + public Product WithDeactivated(TimeProvider timeProvider) + => new(Id, Nombre, MedioId, ProductTypeId, RubroId, + BasePrice, PriceDurationDays, isActive: false, FechaCreacion, + timeProvider.GetUtcNow().UtcDateTime); + + // ── Private validators ──────────────────────────────────────────────────── + + private static void ValidateNombre(string nombre) + { + if (string.IsNullOrWhiteSpace(nombre)) + throw new ArgumentException( + "El Nombre del producto no puede estar vacío o ser solo espacios.", nameof(nombre)); + if (nombre.Length > NombreMaxLength) + throw new ArgumentException( + $"El Nombre del producto no puede superar los {NombreMaxLength} caracteres.", nameof(nombre)); + } + + private static void ValidateMedioId(int medioId) + { + if (medioId <= 0) + throw new ArgumentException("medioId debe ser un entero positivo.", nameof(medioId)); + } + + private static void ValidateProductTypeId(int productTypeId) + { + if (productTypeId <= 0) + throw new ArgumentException("productTypeId debe ser un entero positivo.", nameof(productTypeId)); + } + + private static void ValidateRubroId(int? rubroId) + { + if (rubroId.HasValue && rubroId.Value <= 0) + throw new ArgumentException( + "rubroId debe ser un entero positivo cuando no es nulo.", nameof(rubroId)); + } + + private static void ValidateBasePrice(decimal basePrice) + { + if (basePrice < 0m) + throw new ArgumentException("basePrice no puede ser negativo.", nameof(basePrice)); + } + + private static void ValidatePriceDurationDays(int? priceDurationDays) + { + if (priceDurationDays.HasValue && priceDurationDays.Value <= 0) + throw new ArgumentException( + "priceDurationDays debe ser >= 1 cuando se provee.", nameof(priceDurationDays)); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductNombreDuplicadoEnMedioTipoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductNombreDuplicadoEnMedioTipoException.cs new file mode 100644 index 0000000..7eed7b7 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductNombreDuplicadoEnMedioTipoException.cs @@ -0,0 +1,19 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a Product with the same Nombre already exists for a given MedioId+ProductTypeId. → HTTP 409 +/// +public sealed class ProductNombreDuplicadoEnMedioTipoException : DomainException +{ + public int MedioId { get; } + public int ProductTypeId { get; } + public string Nombre { get; } + + public ProductNombreDuplicadoEnMedioTipoException(int medioId, int productTypeId, string nombre) + : base($"Ya existe un producto activo con nombre '{nombre}' para medioId={medioId} y productTypeId={productTypeId}.") + { + MedioId = medioId; + ProductTypeId = productTypeId; + Nombre = nombre; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs new file mode 100644 index 0000000..912b362 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a requested Product does not exist. → HTTP 404 +/// +public sealed class ProductNotFoundException : DomainException +{ + public int ProductId { get; } + + public ProductNotFoundException(int id) + : base($"El producto con id={id} no existe.") + { + ProductId = id; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTipoFlagsIncoherentesException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTipoFlagsIncoherentesException.cs new file mode 100644 index 0000000..9cc9805 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductTipoFlagsIncoherentesException.cs @@ -0,0 +1,16 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a Product's field violates the flags coherence rules of its ProductType +/// (e.g. RequiresCategory=true but RubroId is null, or HasDuration=true but PriceDurationDays is null). → HTTP 422 +/// +public sealed class ProductTipoFlagsIncoherentesException : DomainException +{ + public string Field { get; } + + public ProductTipoFlagsIncoherentesException(string reason, string field) + : base($"Incoherencia de flags del tipo de producto: {reason}.") + { + Field = field; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTypeInactivoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTypeInactivoException.cs new file mode 100644 index 0000000..f853221 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductTypeInactivoException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to create/update a Product referencing an inactive ProductType. → HTTP 422 +/// +public sealed class ProductTypeInactivoException : DomainException +{ + public int ProductTypeId { get; } + + public ProductTypeInactivoException(int productTypeId) + : base($"El tipo de producto con id={productTypeId} está inactivo y no puede asignarse a un producto.") + { + ProductTypeId = productTypeId; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs b/src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs new file mode 100644 index 0000000..7bbea89 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to create/update a Product referencing an inactive Rubro. → HTTP 422 +/// +public sealed class RubroInactivoException : DomainException +{ + public int RubroId { get; } + + public RubroInactivoException(int rubroId) + : base($"El rubro con id={rubroId} está inactivo y no puede asignarse a un producto.") + { + RubroId = rubroId; + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductTests.cs new file mode 100644 index 0000000..d7e6e41 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/ProductTests.cs @@ -0,0 +1,218 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Domain; + +/// +/// PRD-002 — Domain entity tests for Product. +/// Validates factory method, mutation methods, and validation rules. +/// +public class ProductTests +{ + private static readonly FakeTimeProvider _time = + new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero)); + + private static Product ValidProduct(int? rubroId = null, int? priceDurationDays = null) + => Product.ForCreation( + nombre: "Clasificado Estándar", + medioId: 1, + productTypeId: 2, + rubroId: rubroId, + basePrice: 100.50m, + priceDurationDays: priceDurationDays, + timeProvider: _time); + + // ── R1-S1: ForCreation happy path ───────────────────────────────────────── + + [Fact] + public void ForCreation_ValidData_ReturnsEntityWithDefaults() + { + var p = ValidProduct(); + + p.IsActive.Should().BeTrue(); + p.Id.Should().Be(0); + p.FechaCreacion.Should().Be(_time.GetUtcNow().UtcDateTime); + p.FechaModificacion.Should().BeNull(); + p.Nombre.Should().Be("Clasificado Estándar"); + p.MedioId.Should().Be(1); + p.ProductTypeId.Should().Be(2); + p.RubroId.Should().BeNull(); + p.BasePrice.Should().Be(100.50m); + p.PriceDurationDays.Should().BeNull(); + } + + // ── R1-S2: Nombre vacío ──────────────────────────────────────────────────── + + [Fact] + public void ForCreation_EmptyNombre_ThrowsArgumentException() + { + var act = () => Product.ForCreation("", 1, 2, null, 10m, null, _time); + + act.Should().Throw() + .WithMessage("*Nombre*"); + } + + // ── R1-S3: Nombre solo espacios ──────────────────────────────────────────── + + [Fact] + public void ForCreation_WhitespaceNombre_ThrowsArgumentException() + { + var act = () => Product.ForCreation(" ", 1, 2, null, 10m, null, _time); + + act.Should().Throw(); + } + + // ── R1-S4: BasePrice negativo ────────────────────────────────────────────── + + [Fact] + public void ForCreation_NegativeBasePrice_ThrowsArgumentException() + { + var act = () => Product.ForCreation("Test", 1, 2, null, -1m, null, _time); + + act.Should().Throw() + .WithMessage("*basePrice*"); + } + + // ── R1-S5: BasePrice = 0 es válido ───────────────────────────────────────── + + [Fact] + public void ForCreation_ZeroBasePrice_DoesNotThrow() + { + var act = () => Product.ForCreation("Test", 1, 2, null, 0m, null, _time); + + act.Should().NotThrow(); + } + + // ── R1-S6: PriceDurationDays = 0 ────────────────────────────────────────── + + [Fact] + public void ForCreation_ZeroPriceDurationDays_ThrowsArgumentException() + { + var act = () => Product.ForCreation("Test", 1, 2, null, 10m, 0, _time); + + act.Should().Throw() + .WithMessage("*priceDurationDays*"); + } + + // ── R1-S7: PriceDurationDays negativo ───────────────────────────────────── + + [Fact] + public void ForCreation_NegativePriceDurationDays_ThrowsArgumentException() + { + var act = () => Product.ForCreation("Test", 1, 2, null, 10m, -5, _time); + + act.Should().Throw(); + } + + // ── R1-S8: PriceDurationDays válido ─────────────────────────────────────── + + [Fact] + public void ForCreation_ValidPriceDurationDays_SetsValue() + { + var p = Product.ForCreation("Test", 1, 2, null, 10m, 30, _time); + + p.PriceDurationDays.Should().Be(30); + } + + // ── R1-S9: WithDeactivated ───────────────────────────────────────────────── + + [Fact] + public void WithDeactivated_ActiveProduct_ReturnsInactiveWithModDate() + { + var product = ValidProduct(); + var tp2 = new FakeTimeProvider(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero)); + + var deactivated = product.WithDeactivated(tp2); + + deactivated.IsActive.Should().BeFalse(); + deactivated.FechaModificacion.Should().Be(tp2.GetUtcNow().UtcDateTime); + // Immutability: original unchanged + product.IsActive.Should().BeTrue(); + } + + // ── R1-S10: WithDeactivated idempotente ─────────────────────────────────── + + [Fact] + public void WithDeactivated_AlreadyInactive_ReturnsInactiveNoProblem() + { + var product = ValidProduct(); + var deactivated = product.WithDeactivated(_time); + + var act = () => deactivated.WithDeactivated(_time); + + act.Should().NotThrow(); + act().IsActive.Should().BeFalse(); + } + + // ── R1-S11: WithUpdated ─────────────────────────────────────────────────── + + [Fact] + public void WithUpdated_ValidFields_ReturnsNewInstanceWithUpdatedValues() + { + var product = ValidProduct(); + var tp2 = new FakeTimeProvider(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero)); + + var updated = product.WithUpdated("Nuevo Nombre", rubroId: null, basePrice: 200m, priceDurationDays: 15, tp2); + + updated.Nombre.Should().Be("Nuevo Nombre"); + updated.BasePrice.Should().Be(200m); + updated.PriceDurationDays.Should().Be(15); + updated.FechaModificacion.Should().Be(tp2.GetUtcNow().UtcDateTime); + // Immutability: original unchanged + product.Nombre.Should().Be("Clasificado Estándar"); + product.BasePrice.Should().Be(100.50m); + } + + // ── Immutability: With* return new instances ────────────────────────────── + + [Fact] + public void WithRenamed_ReturnsNewInstance() + { + var p = ValidProduct(); + var renamed = p.WithRenamed("Nuevo", _time); + + renamed.Should().NotBeSameAs(p); + renamed.Nombre.Should().Be("Nuevo"); + } + + [Fact] + public void WithUpdatedPrice_ReturnsNewInstance() + { + var p = ValidProduct(); + var updated = p.WithUpdatedPrice(999m, null, _time); + + updated.Should().NotBeSameAs(p); + updated.BasePrice.Should().Be(999m); + } + + [Fact] + public void WithUpdatedCategory_ReturnsNewInstance() + { + var p = ValidProduct(); + var updated = p.WithUpdatedCategory(5, _time); + + updated.Should().NotBeSameAs(p); + updated.RubroId.Should().Be(5); + } + + // ── MedioId and ProductTypeId are immutable ─────────────────────────────── + + [Fact] + public void Product_HasNoMethodToChangeMedioId() + { + var type = typeof(Product); + var methods = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + + methods.Should().NotContain(m => m.Name.Contains("Medio") && m.Name.StartsWith("With")); + } + + [Fact] + public void Product_HasNoMethodToChangeProductTypeId() + { + var type = typeof(Product); + var methods = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + + methods.Should().NotContain(m => m.Name.Contains("ProductType") && m.Name.StartsWith("With")); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Exceptions/ProductExceptionsTests.cs b/tests/SIGCM2.Application.Tests/Products/Exceptions/ProductExceptionsTests.cs new file mode 100644 index 0000000..43d3de4 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Exceptions/ProductExceptionsTests.cs @@ -0,0 +1,60 @@ +using FluentAssertions; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Products.Exceptions; + +/// +/// PRD-002 — Tests for new Product domain exceptions. +/// Verifies message content and property values. +/// +public class ProductExceptionsTests +{ + [Fact] + public void ProductNotFoundException_ContainsId_InMessage() + { + var ex = new ProductNotFoundException(5); + + ex.Message.Should().Contain("5"); + ex.ProductId.Should().Be(5); + } + + [Fact] + public void ProductNombreDuplicadoEnMedioTipoException_ContainsDetails() + { + var ex = new ProductNombreDuplicadoEnMedioTipoException(2, 3, "Clasificado"); + + ex.Message.Should().Contain("Clasificado"); + ex.Message.Should().Contain("2"); + ex.Message.Should().Contain("3"); + ex.MedioId.Should().Be(2); + ex.ProductTypeId.Should().Be(3); + ex.Nombre.Should().Be("Clasificado"); + } + + [Fact] + public void ProductTipoFlagsIncoherentesException_FieldAndMessage() + { + var ex = new ProductTipoFlagsIncoherentesException("requiere RubroId", "rubroId"); + + ex.Field.Should().Be("rubroId"); + ex.Message.Should().Contain("requiere RubroId"); + } + + [Fact] + public void ProductTypeInactivoException_ContainsProductTypeId() + { + var ex = new ProductTypeInactivoException(7); + + ex.Message.Should().Contain("7"); + ex.ProductTypeId.Should().Be(7); + } + + [Fact] + public void RubroInactivoException_ContainsRubroId() + { + var ex = new RubroInactivoException(12); + + ex.Message.Should().Contain("12"); + ex.RubroId.Should().Be(12); + } +} From 8b555e1f8bf2b3e11f447a599197f9fe8a5d9a06 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:02:42 -0300 Subject: [PATCH 02/14] feat(application): Product commands, DTOs, IProductRepository, validators (PRD-002) --- .../Persistence/IProductRepository.cs | 29 ++++ .../Common/ProductsQuery.cs | 13 ++ .../Products/Create/CreateProductCommand.cs | 9 ++ .../Create/CreateProductCommandValidator.cs | 26 ++++ .../Products/Create/ProductCreatedDto.cs | 12 ++ .../Deactivate/DeactivateProductCommand.cs | 3 + .../Products/Deactivate/ProductStatusDto.cs | 6 + .../Products/GetById/GetProductByIdQuery.cs | 3 + .../Products/GetById/ProductDetailDto.cs | 13 ++ .../Products/List/ListProductsQuery.cs | 10 ++ .../Products/List/ProductListItemDto.cs | 11 ++ .../Products/Update/ProductUpdatedDto.cs | 13 ++ .../Products/Update/UpdateProductCommand.cs | 8 ++ .../Update/UpdateProductCommandValidator.cs | 23 +++ .../Validators/ProductValidatorsTests.cs | 132 ++++++++++++++++++ 15 files changed, 311 insertions(+) create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IProductRepository.cs create mode 100644 src/api/SIGCM2.Application/Common/ProductsQuery.cs create mode 100644 src/api/SIGCM2.Application/Products/Create/CreateProductCommand.cs create mode 100644 src/api/SIGCM2.Application/Products/Create/CreateProductCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Products/Create/ProductCreatedDto.cs create mode 100644 src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommand.cs create mode 100644 src/api/SIGCM2.Application/Products/Deactivate/ProductStatusDto.cs create mode 100644 src/api/SIGCM2.Application/Products/GetById/GetProductByIdQuery.cs create mode 100644 src/api/SIGCM2.Application/Products/GetById/ProductDetailDto.cs create mode 100644 src/api/SIGCM2.Application/Products/List/ListProductsQuery.cs create mode 100644 src/api/SIGCM2.Application/Products/List/ProductListItemDto.cs create mode 100644 src/api/SIGCM2.Application/Products/Update/ProductUpdatedDto.cs create mode 100644 src/api/SIGCM2.Application/Products/Update/UpdateProductCommand.cs create mode 100644 src/api/SIGCM2.Application/Products/Update/UpdateProductCommandValidator.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Validators/ProductValidatorsTests.cs diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductRepository.cs new file mode 100644 index 0000000..3d7ff94 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductRepository.cs @@ -0,0 +1,29 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +/// +/// Write-side repository for Product. +/// All reads needed by write handlers are included here. +/// +public interface IProductRepository +{ + /// Inserts a new Product and returns the DB-assigned Id. + Task AddAsync(Product product, CancellationToken ct = default); + + /// Returns the Product with the given Id, or null if not found. + Task GetByIdAsync(int id, CancellationToken ct = default); + + /// Returns a paged result of Products matching the query. + Task> GetPagedAsync(ProductsQuery query, CancellationToken ct = default); + + /// Persists all changes to an existing Product row. + Task UpdateAsync(Product product, CancellationToken ct = default); + + /// + /// Returns true if an active Product with the same Nombre exists for the given MedioId+ProductTypeId combination. + /// Pass excludeId to skip the self-comparison during rename (update scenario). + /// + Task ExistsByNombreAsync(string nombre, int medioId, int productTypeId, int? excludeId = null, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Common/ProductsQuery.cs b/src/api/SIGCM2.Application/Common/ProductsQuery.cs new file mode 100644 index 0000000..c2ab7cb --- /dev/null +++ b/src/api/SIGCM2.Application/Common/ProductsQuery.cs @@ -0,0 +1,13 @@ +namespace SIGCM2.Application.Common; + +/// +/// Query parameters for listing Products (used by IProductRepository.GetPagedAsync). +/// +public sealed record ProductsQuery( + int Page = 1, + int PageSize = 20, + bool? Activo = true, + string? Search = null, + int? MedioId = null, + int? ProductTypeId = null, + int? RubroId = null); diff --git a/src/api/SIGCM2.Application/Products/Create/CreateProductCommand.cs b/src/api/SIGCM2.Application/Products/Create/CreateProductCommand.cs new file mode 100644 index 0000000..1645b22 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Create/CreateProductCommand.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Products.Create; + +public sealed record CreateProductCommand( + string Nombre, + int MedioId, + int ProductTypeId, + int? RubroId, + decimal BasePrice, + int? PriceDurationDays); diff --git a/src/api/SIGCM2.Application/Products/Create/CreateProductCommandValidator.cs b/src/api/SIGCM2.Application/Products/Create/CreateProductCommandValidator.cs new file mode 100644 index 0000000..8851383 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Create/CreateProductCommandValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; + +namespace SIGCM2.Application.Products.Create; + +public sealed class CreateProductCommandValidator : AbstractValidator +{ + public CreateProductCommandValidator() + { + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre del producto es requerido.") + .MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres."); + + RuleFor(x => x.MedioId) + .GreaterThan(0).WithMessage("MedioId debe ser un entero positivo."); + + RuleFor(x => x.ProductTypeId) + .GreaterThan(0).WithMessage("ProductTypeId debe ser un entero positivo."); + + RuleFor(x => x.BasePrice) + .GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo."); + + RuleFor(x => x.PriceDurationDays) + .GreaterThan(0).When(x => x.PriceDurationDays.HasValue) + .WithMessage("PriceDurationDays debe ser >= 1 cuando se provee."); + } +} diff --git a/src/api/SIGCM2.Application/Products/Create/ProductCreatedDto.cs b/src/api/SIGCM2.Application/Products/Create/ProductCreatedDto.cs new file mode 100644 index 0000000..0ddeec7 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Create/ProductCreatedDto.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Application.Products.Create; + +public sealed record ProductCreatedDto( + int Id, + string Nombre, + int MedioId, + int ProductTypeId, + int? RubroId, + decimal BasePrice, + int? PriceDurationDays, + bool IsActive, + DateTime FechaCreacion); diff --git a/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommand.cs b/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommand.cs new file mode 100644 index 0000000..e963cb6 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Products.Deactivate; + +public sealed record DeactivateProductCommand(int Id); diff --git a/src/api/SIGCM2.Application/Products/Deactivate/ProductStatusDto.cs b/src/api/SIGCM2.Application/Products/Deactivate/ProductStatusDto.cs new file mode 100644 index 0000000..26885b9 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Deactivate/ProductStatusDto.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Products.Deactivate; + +public sealed record ProductStatusDto( + int Id, + bool IsActive, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQuery.cs b/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQuery.cs new file mode 100644 index 0000000..e4e103a --- /dev/null +++ b/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Products.GetById; + +public sealed record GetProductByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/Products/GetById/ProductDetailDto.cs b/src/api/SIGCM2.Application/Products/GetById/ProductDetailDto.cs new file mode 100644 index 0000000..315a9e3 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/GetById/ProductDetailDto.cs @@ -0,0 +1,13 @@ +namespace SIGCM2.Application.Products.GetById; + +public sealed record ProductDetailDto( + int Id, + string Nombre, + int MedioId, + int ProductTypeId, + int? RubroId, + decimal BasePrice, + int? PriceDurationDays, + bool IsActive, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/Products/List/ListProductsQuery.cs b/src/api/SIGCM2.Application/Products/List/ListProductsQuery.cs new file mode 100644 index 0000000..9db9d65 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/List/ListProductsQuery.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Products.List; + +public sealed record ListProductsQuery( + int Page = 1, + int PageSize = 20, + bool? Activo = true, + string? Search = null, + int? MedioId = null, + int? ProductTypeId = null, + int? RubroId = null); diff --git a/src/api/SIGCM2.Application/Products/List/ProductListItemDto.cs b/src/api/SIGCM2.Application/Products/List/ProductListItemDto.cs new file mode 100644 index 0000000..3c4bfc5 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/List/ProductListItemDto.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.Products.List; + +public sealed record ProductListItemDto( + int Id, + string Nombre, + int MedioId, + int ProductTypeId, + int? RubroId, + decimal BasePrice, + int? PriceDurationDays, + bool IsActive); diff --git a/src/api/SIGCM2.Application/Products/Update/ProductUpdatedDto.cs b/src/api/SIGCM2.Application/Products/Update/ProductUpdatedDto.cs new file mode 100644 index 0000000..8c1e5a5 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Update/ProductUpdatedDto.cs @@ -0,0 +1,13 @@ +namespace SIGCM2.Application.Products.Update; + +public sealed record ProductUpdatedDto( + int Id, + string Nombre, + int MedioId, + int ProductTypeId, + int? RubroId, + decimal BasePrice, + int? PriceDurationDays, + bool IsActive, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/Products/Update/UpdateProductCommand.cs b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommand.cs new file mode 100644 index 0000000..b80c0d9 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommand.cs @@ -0,0 +1,8 @@ +namespace SIGCM2.Application.Products.Update; + +public sealed record UpdateProductCommand( + int Id, + string Nombre, + int? RubroId, + decimal BasePrice, + int? PriceDurationDays); diff --git a/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandValidator.cs b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandValidator.cs new file mode 100644 index 0000000..daa5934 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; + +namespace SIGCM2.Application.Products.Update; + +public sealed class UpdateProductCommandValidator : AbstractValidator +{ + public UpdateProductCommandValidator() + { + RuleFor(x => x.Id) + .GreaterThan(0).WithMessage("Id debe ser un entero positivo."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre del producto es requerido.") + .MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres."); + + RuleFor(x => x.BasePrice) + .GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo."); + + RuleFor(x => x.PriceDurationDays) + .GreaterThan(0).When(x => x.PriceDurationDays.HasValue) + .WithMessage("PriceDurationDays debe ser >= 1 cuando se provee."); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Validators/ProductValidatorsTests.cs b/tests/SIGCM2.Application.Tests/Products/Validators/ProductValidatorsTests.cs new file mode 100644 index 0000000..c39d444 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Validators/ProductValidatorsTests.cs @@ -0,0 +1,132 @@ +using FluentValidation.TestHelper; +using SIGCM2.Application.Products.Create; +using SIGCM2.Application.Products.Update; + +namespace SIGCM2.Application.Tests.Products.Validators; + +/// +/// PRD-002 — Validator tests for CreateProductCommand and UpdateProductCommand. +/// +public class ProductValidatorsTests +{ + private readonly CreateProductCommandValidator _createValidator = new(); + private readonly UpdateProductCommandValidator _updateValidator = new(); + + // ── Create: Nombre ──────────────────────────────────────────────────────── + + [Fact] + public void Create_NombreEmpty_FailsValidation() + { + var cmd = ValidCreate() with { Nombre = "" }; + _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre); + } + + [Fact] + public void Create_NombreWhitespace_FailsValidation() + { + var cmd = ValidCreate() with { Nombre = " " }; + _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre); + } + + [Fact] + public void Create_NombreOver300Chars_FailsValidation() + { + var cmd = ValidCreate() with { Nombre = new string('X', 301) }; + _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre); + } + + // ── Create: MedioId / ProductTypeId ─────────────────────────────────────── + + [Fact] + public void Create_MedioIdZero_FailsValidation() + { + var cmd = ValidCreate() with { MedioId = 0 }; + _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.MedioId); + } + + [Fact] + public void Create_ProductTypeIdZero_FailsValidation() + { + var cmd = ValidCreate() with { ProductTypeId = 0 }; + _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ProductTypeId); + } + + // ── Create: BasePrice ───────────────────────────────────────────────────── + + [Fact] + public void Create_NegativeBasePrice_FailsValidation() + { + var cmd = ValidCreate() with { BasePrice = -0.01m }; + _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.BasePrice); + } + + [Fact] + public void Create_ZeroBasePrice_Passes() + { + var cmd = ValidCreate() with { BasePrice = 0m }; + _createValidator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.BasePrice); + } + + // ── Create: PriceDurationDays ────────────────────────────────────────────── + + [Fact] + public void Create_PriceDurationDaysZero_FailsValidation() + { + var cmd = ValidCreate() with { PriceDurationDays = 0 }; + _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PriceDurationDays); + } + + [Fact] + public void Create_PriceDurationDaysNegative_FailsValidation() + { + var cmd = ValidCreate() with { PriceDurationDays = -1 }; + _createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PriceDurationDays); + } + + [Fact] + public void Create_PriceDurationDaysNull_Passes() + { + var cmd = ValidCreate() with { PriceDurationDays = null }; + _createValidator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PriceDurationDays); + } + + // ── Create: valid ───────────────────────────────────────────────────────── + + [Fact] + public void Create_ValidCommand_Passes() + { + _createValidator.TestValidate(ValidCreate()).ShouldNotHaveAnyValidationErrors(); + } + + // ── Update: Id must be > 0 ──────────────────────────────────────────────── + + [Fact] + public void Update_IdZero_FailsValidation() + { + var cmd = ValidUpdate() with { Id = 0 }; + _updateValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Id); + } + + [Fact] + public void Update_ValidCommand_Passes() + { + _updateValidator.TestValidate(ValidUpdate()).ShouldNotHaveAnyValidationErrors(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static CreateProductCommand ValidCreate() => new( + Nombre: "Clasificado Estándar", + MedioId: 1, + ProductTypeId: 2, + RubroId: null, + BasePrice: 100.50m, + PriceDurationDays: null); + + private static UpdateProductCommand ValidUpdate() => new( + Id: 1, + Nombre: "Clasificado Estándar", + RubroId: null, + BasePrice: 100.50m, + PriceDurationDays: null); +} From bb455be745d164cf88f00ecdd5c73ac0dcb10ace Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:07:59 -0300 Subject: [PATCH 03/14] feat(application): Product handlers + DI registration, fix permiso count to 27 (PRD-002) --- .../SIGCM2.Application/DependencyInjection.cs | 12 + .../Create/CreateProductCommandHandler.cs | 112 ++++++++ .../DeactivateProductCommandHandler.cs | 62 +++++ .../GetById/GetProductByIdQueryHandler.cs | 29 +++ .../Products/List/ListProductsQueryHandler.cs | 35 +++ .../Update/UpdateProductCommandHandler.cs | 97 +++++++ .../Integration/PermisoRepositoryTests.cs | 5 +- .../Integration/RolPermisoRepositoryTests.cs | 7 +- .../CreateProductCommandHandlerTests.cs | 246 ++++++++++++++++++ .../DeactivateProductCommandHandlerTests.cs | 133 ++++++++++ .../GetProductByIdQueryHandlerTests.cs | 60 +++++ .../List/ListProductsQueryHandlerTests.cs | 88 +++++++ .../UpdateProductCommandHandlerTests.cs | 149 +++++++++++ 13 files changed, 1030 insertions(+), 5 deletions(-) create mode 100644 src/api/SIGCM2.Application/Products/Create/CreateProductCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Products/GetById/GetProductByIdQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Products/List/ListProductsQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Products/Update/UpdateProductCommandHandler.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Create/CreateProductCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Deactivate/DeactivateProductCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/GetById/GetProductByIdQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/List/ListProductsQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Update/UpdateProductCommandHandlerTests.cs diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 49bd760..e67fec9 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -70,6 +70,11 @@ using SIGCM2.Application.Rubros.Dtos; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Avisos; using SIGCM2.Application.Products; +using SIGCM2.Application.Products.Create; +using SIGCM2.Application.Products.Update; +using SIGCM2.Application.Products.Deactivate; +using SIGCM2.Application.Products.GetById; +using SIGCM2.Application.Products.List; using SIGCM2.Application.ProductTypes.Create; using SIGCM2.Application.ProductTypes.Update; using SIGCM2.Application.ProductTypes.Deactivate; @@ -171,6 +176,13 @@ public static class DependencyInjection services.AddScoped>, GetRubroTreeQueryHandler>(); services.AddScoped, GetRubroByIdQueryHandler>(); + // Products (PRD-002) + services.AddScoped, CreateProductCommandHandler>(); + services.AddScoped, UpdateProductCommandHandler>(); + services.AddScoped, DeactivateProductCommandHandler>(); + services.AddScoped, GetProductByIdQueryHandler>(); + services.AddScoped>, ListProductsQueryHandler>(); + // ProductTypes (PRD-001) // PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product. services.AddScoped(); diff --git a/src/api/SIGCM2.Application/Products/Create/CreateProductCommandHandler.cs b/src/api/SIGCM2.Application/Products/Create/CreateProductCommandHandler.cs new file mode 100644 index 0000000..cb47215 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Create/CreateProductCommandHandler.cs @@ -0,0 +1,112 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.Create; + +public sealed class CreateProductCommandHandler + : ICommandHandler +{ + private readonly IProductRepository _repo; + private readonly IProductTypeRepository _ptRepo; + private readonly IMedioRepository _medioRepo; + private readonly IRubroRepository _rubroRepo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public CreateProductCommandHandler( + IProductRepository repo, + IProductTypeRepository ptRepo, + IMedioRepository medioRepo, + IRubroRepository rubroRepo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _ptRepo = ptRepo; + _medioRepo = medioRepo; + _rubroRepo = rubroRepo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(CreateProductCommand command) + { + // 1. Validate Medio exists and is active + var medio = await _medioRepo.GetByIdAsync(command.MedioId) + ?? throw new MedioNotFoundException(command.MedioId); + if (!medio.Activo) + throw new MedioInactivoException(command.MedioId); + + // 2. Validate ProductType exists and is active + var productType = await _ptRepo.GetByIdAsync(command.ProductTypeId) + ?? throw new ProductTypeNotFoundException(command.ProductTypeId); + if (!productType.IsActive) + throw new ProductTypeInactivoException(command.ProductTypeId); + + // 3. Flags coherence: RequiresCategory → RubroId required + if (productType.RequiresCategory && !command.RubroId.HasValue) + throw new ProductTipoFlagsIncoherentesException( + $"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId"); + + // 4. Flags coherence: HasDuration → PriceDurationDays required + if (productType.HasDuration && !command.PriceDurationDays.HasValue) + throw new ProductTipoFlagsIncoherentesException( + $"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays"); + + // 5. Validate Rubro if provided: must be active + if (command.RubroId.HasValue) + { + var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value); + if (rubro == null || !rubro.Activo) + throw new RubroInactivoException(command.RubroId.Value); + } + + // 6. Duplicate nombre check (filtered on IsActive=1 — allows reuse after soft-delete) + var exists = await _repo.ExistsByNombreAsync(command.Nombre, command.MedioId, command.ProductTypeId, excludeId: null); + if (exists) + throw new ProductNombreDuplicadoEnMedioTipoException(command.MedioId, command.ProductTypeId, command.Nombre); + + // 7. Build entity + var entity = Product.ForCreation( + command.Nombre, command.MedioId, command.ProductTypeId, + command.RubroId, command.BasePrice, command.PriceDurationDays, + _timeProvider); + + // 8. Persist + audit (fail-closed) + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + var newId = await _repo.AddAsync(entity); + + await _audit.LogAsync( + action: "producto.created", + targetType: "Product", + targetId: newId.ToString(), + metadata: new + { + after = new + { + entity.Nombre, + entity.MedioId, + entity.ProductTypeId, + entity.RubroId, + entity.BasePrice, + entity.PriceDurationDays, + } + }); + + tx.Complete(); + + return new ProductCreatedDto( + newId, entity.Nombre, + entity.MedioId, entity.ProductTypeId, entity.RubroId, + entity.BasePrice, entity.PriceDurationDays, + entity.IsActive, entity.FechaCreacion); + } +} diff --git a/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommandHandler.cs b/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommandHandler.cs new file mode 100644 index 0000000..87d2672 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Deactivate/DeactivateProductCommandHandler.cs @@ -0,0 +1,62 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.Deactivate; + +public sealed class DeactivateProductCommandHandler + : ICommandHandler +{ + private readonly IProductRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public DeactivateProductCommandHandler( + IProductRepository repo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(DeactivateProductCommand command) + { + // 1. Load entity + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new ProductNotFoundException(command.Id); + + // 2. Idempotent: already inactive → return without side effects + if (!target.IsActive) + return new ProductStatusDto(command.Id, false, target.FechaModificacion); + + // 3. Deactivate (immutable) + var deactivated = target.WithDeactivated(_timeProvider); + + // 4. Persist + audit (fail-closed) + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(deactivated); + + await _audit.LogAsync( + action: "producto.deactivated", + targetType: "Product", + targetId: command.Id.ToString(), + metadata: new + { + productId = command.Id, + nombre = target.Nombre, + }); + + tx.Complete(); + + return new ProductStatusDto(deactivated.Id, deactivated.IsActive, deactivated.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQueryHandler.cs b/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQueryHandler.cs new file mode 100644 index 0000000..101c7c0 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/GetById/GetProductByIdQueryHandler.cs @@ -0,0 +1,29 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.GetById; + +public sealed class GetProductByIdQueryHandler + : ICommandHandler +{ + private readonly IProductRepository _repo; + + public GetProductByIdQueryHandler(IProductRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetProductByIdQuery query) + { + var product = await _repo.GetByIdAsync(query.Id) + ?? throw new ProductNotFoundException(query.Id); + + return new ProductDetailDto( + product.Id, product.Nombre, + product.MedioId, product.ProductTypeId, product.RubroId, + product.BasePrice, product.PriceDurationDays, + product.IsActive, + product.FechaCreacion, product.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Products/List/ListProductsQueryHandler.cs b/src/api/SIGCM2.Application/Products/List/ListProductsQueryHandler.cs new file mode 100644 index 0000000..91aeb2a --- /dev/null +++ b/src/api/SIGCM2.Application/Products/List/ListProductsQueryHandler.cs @@ -0,0 +1,35 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Products.List; + +public sealed class ListProductsQueryHandler + : ICommandHandler> +{ + private readonly IProductRepository _repo; + + public ListProductsQueryHandler(IProductRepository repo) + { + _repo = repo; + } + + public async Task> Handle(ListProductsQuery query) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var repoQuery = new ProductsQuery( + page, pageSize, query.Activo, query.Search, + query.MedioId, query.ProductTypeId, query.RubroId); + var paged = await _repo.GetPagedAsync(repoQuery); + + var items = paged.Items.Select(p => new ProductListItemDto( + p.Id, p.Nombre, + p.MedioId, p.ProductTypeId, p.RubroId, + p.BasePrice, p.PriceDurationDays, + p.IsActive)).ToList(); + + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandHandler.cs b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandHandler.cs new file mode 100644 index 0000000..5742c28 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Update/UpdateProductCommandHandler.cs @@ -0,0 +1,97 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.Update; + +public sealed class UpdateProductCommandHandler + : ICommandHandler +{ + private readonly IProductRepository _repo; + private readonly IProductTypeRepository _ptRepo; + private readonly IRubroRepository _rubroRepo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public UpdateProductCommandHandler( + IProductRepository repo, + IProductTypeRepository ptRepo, + IRubroRepository rubroRepo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _ptRepo = ptRepo; + _rubroRepo = rubroRepo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(UpdateProductCommand command) + { + // 1. Load entity + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new ProductNotFoundException(command.Id); + + // 2. Load ProductType (MedioId + ProductTypeId are immutable post-creation) + var productType = await _ptRepo.GetByIdAsync(target.ProductTypeId) + ?? throw new ProductTypeNotFoundException(target.ProductTypeId); + + // 3. Flags coherence + if (productType.RequiresCategory && !command.RubroId.HasValue) + throw new ProductTipoFlagsIncoherentesException( + $"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId"); + + if (productType.HasDuration && !command.PriceDurationDays.HasValue) + throw new ProductTipoFlagsIncoherentesException( + $"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays"); + + // 4. Validate Rubro if provided: must be active + if (command.RubroId.HasValue) + { + var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value); + if (rubro == null || !rubro.Activo) + throw new RubroInactivoException(command.RubroId.Value); + } + + // 5. Duplicate nombre check (skip if name unchanged — optimization) + if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase)) + { + var exists = await _repo.ExistsByNombreAsync(command.Nombre, target.MedioId, target.ProductTypeId, excludeId: command.Id); + if (exists) + throw new ProductNombreDuplicadoEnMedioTipoException(target.MedioId, target.ProductTypeId, command.Nombre); + } + + // 6. Apply mutation (immutable) + var updated = target.WithUpdated(command.Nombre, command.RubroId, command.BasePrice, command.PriceDurationDays, _timeProvider); + + // 7. Persist + audit + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "producto.updated", + targetType: "Product", + targetId: command.Id.ToString(), + metadata: new + { + before = new { target.Nombre, target.RubroId, target.BasePrice, target.PriceDurationDays }, + after = new { updated.Nombre, updated.RubroId, updated.BasePrice, updated.PriceDurationDays } + }); + + tx.Complete(); + + return new ProductUpdatedDto( + updated.Id, updated.Nombre, + updated.MedioId, updated.ProductTypeId, updated.RubroId, + updated.BasePrice, updated.PriceDurationDays, + updated.IsActive, updated.FechaCreacion, updated.FechaModificacion); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs index cf91367..1f1f9e4 100644 --- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -82,8 +82,9 @@ public class PermisoRepositoryTests : IAsyncLifetime // + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' // + V014 (ADM-009) adds 'administracion:fiscal:gestionar' // + V016 (CAT-001) adds 'catalogo:rubros:gestionar' - // + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total - Assert.Equal(26, list.Count); + // + V017 (PRD-001) adds 'catalogo:tipos:gestionar' + // + V018 (PRD-002) adds 'catalogo:productos:gestionar' = 27 total + Assert.Equal(27, list.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs index 71fdfd7..dc21349 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -173,17 +173,18 @@ public class RolPermisoRepositoryTests : IAsyncLifetime // ── GetByRolCodigoAsync ────────────────────────────────────────────────── [Fact] - public async Task GetByRolCodigoAsync_Admin_Returns26Permisos() + public async Task GetByRolCodigoAsync_Admin_Returns27Permisos() { // admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006) // + 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' - // + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' = 26 total + // + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' + // + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' = 27 total var permisos = await _repository.GetByRolCodigoAsync("admin"); - Assert.Equal(26, permisos.Count); + Assert.Equal(27, permisos.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Products/Create/CreateProductCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Create/CreateProductCommandHandlerTests.cs new file mode 100644 index 0000000..83f8f8e --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Create/CreateProductCommandHandlerTests.cs @@ -0,0 +1,246 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Products.Create; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Products.Create; + +/// +/// PRD-002 — CreateProductCommandHandler tests. +/// Covers: happy path, flags coherence, duplicate nombre, inactive Medio/ProductType/Rubro, +/// audit, immutability, rollback. +/// +public class CreateProductCommandHandlerTests +{ + private readonly IProductRepository _repo = Substitute.For(); + private readonly IProductTypeRepository _ptRepo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IRubroRepository _rubroRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero)); + private readonly CreateProductCommandHandler _handler; + + private static readonly ProductType _activePtNoFlags = new( + id: 2, nombre: "Clasificado", + hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, + allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, + isActive: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); + + private static readonly ProductType _activePtRequiresCategory = new( + id: 3, nombre: "Con Rubro", + hasDuration: false, requiresText: false, requiresCategory: true, isBundle: false, + allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, + isActive: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); + + private static readonly ProductType _activePtHasDuration = new( + id: 4, nombre: "Con Duración", + hasDuration: true, requiresText: false, requiresCategory: false, isBundle: false, + allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, + isActive: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); + + private static readonly ProductType _inactivePt = new( + id: 5, nombre: "Inactivo", + hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, + allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, + isActive: false, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); + + private static Medio ActiveMedio(int id = 1) => new( + id: id, codigo: "ELD", nombre: "El Día", + tipo: TipoMedio.Diario, plataformaEmpresaId: null, + activo: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); + + private static Medio InactiveMedio(int id = 1) => new( + id: id, codigo: "ELD", nombre: "El Día", + tipo: TipoMedio.Diario, plataformaEmpresaId: null, + activo: false, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); + + private static Rubro ActiveRubro(int id = 10) => new( + id: id, parentId: null, nombre: "Clasificados", orden: 1, + activo: true, tarifarioBaseId: null, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); + + private static Rubro InactiveRubro(int id = 10) => new( + id: id, parentId: null, nombre: "Clasificados", orden: 1, + activo: false, tarifarioBaseId: null, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); + + public CreateProductCommandHandlerTests() + { + _medioRepo.GetByIdAsync(1, Arg.Any()).Returns(ActiveMedio(1)); + _ptRepo.GetByIdAsync(2, Arg.Any()).Returns(_activePtNoFlags); + _repo.ExistsByNombreAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(1); + _handler = new CreateProductCommandHandler(_repo, _ptRepo, _medioRepo, _rubroRepo, _audit, _time); + } + + private static CreateProductCommand ValidCmd(int productTypeId = 2) => new( + Nombre: "Clasificado Estándar", + MedioId: 1, + ProductTypeId: productTypeId, + RubroId: null, + BasePrice: 100.50m, + PriceDurationDays: null); + + // ── Happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ValidCommand_InsertsAndReturnsDto() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(42); + + var result = await _handler.Handle(ValidCmd()); + + result.Id.Should().Be(42); + result.Nombre.Should().Be("Clasificado Estándar"); + result.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Handle_ValidCommand_LogsAuditEvent_ProductoCreated() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(7); + + await _handler.Handle(ValidCmd()); + + await _audit.Received(1).LogAsync( + action: "producto.created", + targetType: "Product", + targetId: "7", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_UsesTimeProvider_NotDateTimeNow() + { + var expectedDate = _time.GetUtcNow().UtcDateTime; + + await _handler.Handle(ValidCmd()); + + await _repo.Received(1).AddAsync( + Arg.Is(p => p.FechaCreacion == expectedDate), + Arg.Any()); + } + + // ── Medio not found / inactive ──────────────────────────────────────────── + + [Fact] + public async Task Handle_MedioNotFound_ThrowsMedioNotFoundException() + { + _medioRepo.GetByIdAsync(1, Arg.Any()).Returns((Medio?)null); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_MedioInactivo_ThrowsMedioInactivoException() + { + _medioRepo.GetByIdAsync(1, Arg.Any()).Returns(InactiveMedio(1)); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync(); + } + + // ── ProductType not found / inactive ────────────────────────────────────── + + [Fact] + public async Task Handle_ProductTypeNotFound_ThrowsProductTypeNotFoundException() + { + _ptRepo.GetByIdAsync(2, Arg.Any()).Returns((ProductType?)null); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_ProductTypeInactivo_ThrowsProductTypeInactivoException() + { + _ptRepo.GetByIdAsync(5, Arg.Any()).Returns(_inactivePt); + _medioRepo.GetByIdAsync(1, Arg.Any()).Returns(ActiveMedio(1)); + + var act = async () => await _handler.Handle(ValidCmd(productTypeId: 5)); + + await act.Should().ThrowAsync(); + } + + // ── Flags coherence ─────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_RequiresCategoryTrue_RubroIdNull_ThrowsFlagsException() + { + _ptRepo.GetByIdAsync(3, Arg.Any()).Returns(_activePtRequiresCategory); + var cmd = ValidCmd(productTypeId: 3) with { RubroId = null }; + + var act = async () => await _handler.Handle(cmd); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_HasDurationTrue_PriceDurationDaysNull_ThrowsFlagsException() + { + _ptRepo.GetByIdAsync(4, Arg.Any()).Returns(_activePtHasDuration); + var cmd = ValidCmd(productTypeId: 4) with { PriceDurationDays = null }; + + var act = async () => await _handler.Handle(cmd); + + await act.Should().ThrowAsync(); + } + + // ── Rubro validation ────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_RubroProvided_RubroInactivo_ThrowsRubroInactivoException() + { + _ptRepo.GetByIdAsync(3, Arg.Any()).Returns(_activePtRequiresCategory); + _rubroRepo.GetByIdAsync(10, Arg.Any()).Returns(InactiveRubro(10)); + var cmd = ValidCmd(productTypeId: 3) with { RubroId = 10 }; + + var act = async () => await _handler.Handle(cmd); + + await act.Should().ThrowAsync(); + } + + // ── Duplicate nombre ────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NombreDuplicadoEnMedioTipo_ThrowsProductNombreDuplicadoException() + { + _repo.ExistsByNombreAsync("Clasificado Estándar", 1, 2, null, Arg.Any()).Returns(true); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync(); + } + + // ── Rollback ────────────────────────────────────────────────────────────── + + [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(ValidCmd()); + await act.Should().ThrowAsync(); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Deactivate/DeactivateProductCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Deactivate/DeactivateProductCommandHandlerTests.cs new file mode 100644 index 0000000..1b08bef --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Deactivate/DeactivateProductCommandHandlerTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Products.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Products.Deactivate; + +/// +/// PRD-002 — DeactivateProductCommandHandler tests. +/// +public class DeactivateProductCommandHandlerTests +{ + private readonly IProductRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 14, 0, 0, TimeSpan.Zero)); + private readonly DeactivateProductCommandHandler _handler; + + public DeactivateProductCommandHandlerTests() + { + _handler = new DeactivateProductCommandHandler(_repo, _audit, _time); + } + + private static Product ActiveProduct(int id = 1) => new( + id: id, nombre: "Clasificado Estándar", + medioId: 1, productTypeId: 2, rubroId: null, + basePrice: 100.50m, priceDurationDays: null, + isActive: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + fechaModificacion: null); + + private static Product InactiveProduct(int id = 1) => new( + id: id, nombre: "Clasificado Estándar", + medioId: 1, productTypeId: 2, rubroId: null, + basePrice: 100.50m, priceDurationDays: null, + isActive: false, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + fechaModificacion: null); + + // ── Not found ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsProductNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((Product?)null); + + var act = async () => await _handler.Handle(new DeactivateProductCommand(99)); + + await act.Should().ThrowAsync() + .Where(e => e.ProductId == 99); + } + + // ── Already inactive (idempotent) ───────────────────────────────────────── + + [Fact] + public async Task Handle_AlreadyInactive_ReturnsDto_NoAudit_NoRepoUpdate() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(InactiveProduct()); + + var result = await _handler.Handle(new DeactivateProductCommand(1)); + + result.Id.Should().Be(1); + result.IsActive.Should().BeFalse(); + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + // ── Happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ActiveProduct_DeactivatesAndAudits() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveProduct()); + + await _handler.Handle(new DeactivateProductCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(p => !p.IsActive), + Arg.Any()); + await _audit.Received(1).LogAsync( + action: "producto.deactivated", + targetType: "Product", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_UsesTimeProviderInDeactivate() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveProduct()); + var expectedDate = _time.GetUtcNow().UtcDateTime; + + await _handler.Handle(new DeactivateProductCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(p => p.FechaModificacion == expectedDate), + Arg.Any()); + } + + [Fact] + public async Task Handle_ReturnsDtoWithIsActiveFalse() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveProduct()); + + var result = await _handler.Handle(new DeactivateProductCommand(1)); + + result.IsActive.Should().BeFalse(); + } + + // ── Rollback ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_RepoThrows_AuditNotCalled() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveProduct()); + _repo.UpdateAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("DB error")); + + var act = async () => await _handler.Handle(new DeactivateProductCommand(1)); + await act.Should().ThrowAsync(); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/GetById/GetProductByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/GetById/GetProductByIdQueryHandlerTests.cs new file mode 100644 index 0000000..8cdec55 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/GetById/GetProductByIdQueryHandlerTests.cs @@ -0,0 +1,60 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Products.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Products.GetById; + +/// +/// PRD-002 — GetProductByIdQueryHandler tests. +/// +public class GetProductByIdQueryHandlerTests +{ + private readonly IProductRepository _repo = Substitute.For(); + private readonly GetProductByIdQueryHandler _handler; + + public GetProductByIdQueryHandlerTests() + { + _handler = new GetProductByIdQueryHandler(_repo); + } + + private static Product AProduct(int id = 1) => new( + id: id, + nombre: "Clasificado Estándar", + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 100.50m, + priceDurationDays: null, + isActive: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + fechaModificacion: null); + + [Fact] + public async Task Handle_ExistingId_ReturnsMappedDto() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(AProduct(1)); + + var result = await _handler.Handle(new GetProductByIdQuery(1)); + + result.Id.Should().Be(1); + result.Nombre.Should().Be("Clasificado Estándar"); + result.MedioId.Should().Be(1); + result.ProductTypeId.Should().Be(2); + result.BasePrice.Should().Be(100.50m); + result.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Handle_NotFound_ThrowsProductNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((Product?)null); + + var act = async () => await _handler.Handle(new GetProductByIdQuery(99)); + + await act.Should().ThrowAsync() + .Where(e => e.ProductId == 99); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/List/ListProductsQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/List/ListProductsQueryHandlerTests.cs new file mode 100644 index 0000000..b4744dd --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/List/ListProductsQueryHandlerTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Products.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Products.List; + +/// +/// PRD-002 — ListProductsQueryHandler tests. +/// +public class ListProductsQueryHandlerTests +{ + private readonly IProductRepository _repo = Substitute.For(); + private readonly ListProductsQueryHandler _handler; + + public ListProductsQueryHandlerTests() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + _handler = new ListProductsQueryHandler(_repo); + } + + private static Product AProduct(int id = 1) => new( + id: id, + nombre: "Clasificado", + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 50m, + priceDurationDays: null, + isActive: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + fechaModificacion: null); + + [Fact] + public async Task Handle_EmptyPage_ReturnsPaged_WithZeroTotal() + { + var result = await _handler.Handle(new ListProductsQuery()); + + result.Total.Should().Be(0); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task Handle_WithItems_ReturnsMappedListItemDtos() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([AProduct(1), AProduct(2)], 1, 20, 2)); + + var result = await _handler.Handle(new ListProductsQuery()); + + result.Items.Should().HaveCount(2); + result.Items[0].Id.Should().Be(1); + result.Items[1].Id.Should().Be(2); + } + + [Fact] + public async Task Handle_PageNormalization_ClampsPageSizeTo100() + { + await _handler.Handle(new ListProductsQuery(Page: 1, PageSize: 200)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_PageBelowOne_NormalizesToOne() + { + await _handler.Handle(new ListProductsQuery(Page: -1)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } + + [Fact] + public async Task Handle_PassesFiltersToRepo() + { + await _handler.Handle(new ListProductsQuery(MedioId: 5, ProductTypeId: 3, RubroId: 7)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.MedioId == 5 && q.ProductTypeId == 3 && q.RubroId == 7), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Update/UpdateProductCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Update/UpdateProductCommandHandlerTests.cs new file mode 100644 index 0000000..8104953 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Update/UpdateProductCommandHandlerTests.cs @@ -0,0 +1,149 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Products.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Products.Update; + +/// +/// PRD-002 — UpdateProductCommandHandler tests. +/// +public class UpdateProductCommandHandlerTests +{ + private readonly IProductRepository _repo = Substitute.For(); + private readonly IProductTypeRepository _ptRepo = Substitute.For(); + private readonly IRubroRepository _rubroRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero)); + private readonly UpdateProductCommandHandler _handler; + + private static readonly ProductType _activePtNoFlags = new( + id: 2, nombre: "Clasificado", + hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, + allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, + isActive: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); + + private static readonly ProductType _activePtRequiresCategory = new( + id: 3, nombre: "Con Rubro", + hasDuration: false, requiresText: false, requiresCategory: true, isBundle: false, + allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null, + isActive: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null); + + private static Product AProduct(int id = 1, int productTypeId = 2) => new( + id: id, nombre: "Clasificado Estándar", + medioId: 1, productTypeId: productTypeId, rubroId: null, + basePrice: 100.50m, priceDurationDays: null, + isActive: true, + fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + fechaModificacion: null); + + public UpdateProductCommandHandlerTests() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(AProduct(1)); + _ptRepo.GetByIdAsync(2, Arg.Any()).Returns(_activePtNoFlags); + _repo.ExistsByNombreAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); + _handler = new UpdateProductCommandHandler(_repo, _ptRepo, _rubroRepo, _audit, _time); + } + + private static UpdateProductCommand ValidCmd() => new( + Id: 1, + Nombre: "Nuevo Nombre", + RubroId: null, + BasePrice: 200m, + PriceDurationDays: null); + + // ── Happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ValidCommand_UpdatesAndReturnsDto() + { + var result = await _handler.Handle(ValidCmd()); + + result.Id.Should().Be(1); + result.Nombre.Should().Be("Nuevo Nombre"); + result.BasePrice.Should().Be(200m); + } + + [Fact] + public async Task Handle_ValidCommand_CallsUpdateAsync() + { + await _handler.Handle(ValidCmd()); + + await _repo.Received(1).UpdateAsync( + Arg.Is(p => p.Nombre == "Nuevo Nombre"), + Arg.Any()); + } + + [Fact] + public async Task Handle_ValidCommand_LogsAuditEvent_ProductoUpdated() + { + await _handler.Handle(ValidCmd()); + + await _audit.Received(1).LogAsync( + action: "producto.updated", + targetType: "Product", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── Not found ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsProductNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((Product?)null); + + var act = async () => await _handler.Handle(ValidCmd() with { Id = 99 }); + + await act.Should().ThrowAsync(); + } + + // ── Flags coherence ─────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_RequiresCategoryTrue_RubroIdNull_ThrowsFlagsException() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(AProduct(1, productTypeId: 3)); + _ptRepo.GetByIdAsync(3, Arg.Any()).Returns(_activePtRequiresCategory); + + var act = async () => await _handler.Handle(ValidCmd() with { RubroId = null }); + + await act.Should().ThrowAsync(); + } + + // ── Duplicate nombre ────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NombreDuplicado_ThrowsProductNombreDuplicadoException() + { + _repo.ExistsByNombreAsync("Nuevo Nombre", 1, 2, 1, Arg.Any()).Returns(true); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync(); + } + + // ── Rollback ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_RepoThrows_AuditNotCalled() + { + _repo.UpdateAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("DB error")); + + var act = async () => await _handler.Handle(ValidCmd()); + await act.Should().ThrowAsync(); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +} From 8c9a50504d686e74cef38d77ef9f5844ddc9c744 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:10:21 -0300 Subject: [PATCH 04/14] feat(infrastructure): ProductRepository + ProductQueryRepository, DI swap activates guard (PRD-002) --- .../SIGCM2.Application/DependencyInjection.cs | 4 +- .../DeactivateProductTypeCommandHandler.cs | 6 +- .../DependencyInjection.cs | 3 + .../Persistence/ProductQueryRepository.cs | 34 +++ .../Persistence/ProductRepository.cs | 201 ++++++++++++++++++ .../Repository/ProductQueryRepositoryTests.cs | 120 +++++++++++ 6 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index e67fec9..d0e64ec 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -69,7 +69,6 @@ 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.Products.Create; using SIGCM2.Application.Products.Update; using SIGCM2.Application.Products.Deactivate; @@ -184,8 +183,7 @@ public static class DependencyInjection services.AddScoped>, ListProductsQueryHandler>(); // ProductTypes (PRD-001) - // PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product. - services.AddScoped(); + // IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product. services.AddScoped, CreateProductTypeCommandHandler>(); services.AddScoped, UpdateProductTypeCommandHandler>(); diff --git a/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs index 66c4781..79aa42f 100644 --- a/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs +++ b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs @@ -37,12 +37,10 @@ public sealed class DeactivateProductTypeCommandHandler if (!target.IsActive) return new ProductTypeStatusDto(command.Id, false); - // 3. Guard: check if any active product uses this type (guard before audit — ordering matters) + // 3. Guard: check if any active product uses this type 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. + throw new ProductTypeEnUsoException(command.Id, productsActivos: 1); // 4. Deactivate (immutable — returns new instance) var deactivated = target.WithDeactivated(_timeProvider); diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 809c334..0797ca3 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -40,6 +40,9 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + // PRD-002: replaces NullProductQueryRepository from Application DI + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs new file mode 100644 index 0000000..24fc3b6 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs @@ -0,0 +1,34 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Infrastructure.Persistence; + +/// +/// PRD-002 — Real Dapper implementation of IProductQueryRepository against dbo.Product. +/// Replaces NullProductQueryRepository which was bound during PRD-001. +/// +public sealed class ProductQueryRepository : IProductQueryRepository +{ + private readonly SqlConnectionFactory _factory; + + public ProductQueryRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + public async Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) + FROM dbo.Product + WHERE ProductTypeId = @ProductTypeId + AND IsActive = 1 + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { ProductTypeId = productTypeId }); + return count > 0; + } +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs new file mode 100644 index 0000000..dbf2527 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs @@ -0,0 +1,201 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +/// +/// PRD-002 — Dapper implementation of IProductRepository against dbo.Product. +/// Full implementation in Batch 6. +/// +public sealed class ProductRepository : IProductRepository +{ + private readonly SqlConnectionFactory _factory; + + public ProductRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + public async Task AddAsync(Product product, CancellationToken ct = default) + { + const string sql = """ + INSERT INTO dbo.Product ( + Nombre, MedioId, ProductTypeId, RubroId, BasePrice, PriceDurationDays, + IsActive, FechaCreacion + ) + OUTPUT INSERTED.Id + VALUES ( + @Nombre, @MedioId, @ProductTypeId, @RubroId, @BasePrice, @PriceDurationDays, + 1, @FechaCreacion + ) + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new + { + product.Nombre, + product.MedioId, + product.ProductTypeId, + product.RubroId, + product.BasePrice, + product.PriceDurationDays, + product.FechaCreacion, + }); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Nombre, MedioId, ProductTypeId, RubroId, + BasePrice, PriceDurationDays, IsActive, FechaCreacion, FechaModificacion + FROM dbo.Product + WHERE Id = @Id + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task> GetPagedAsync(ProductsQuery query, CancellationToken ct = default) + { + var conditions = new List(); + if (query.Activo.HasValue) + conditions.Add("IsActive = @Activo"); + if (!string.IsNullOrWhiteSpace(query.Search)) + conditions.Add("Nombre LIKE '%' + @Search + '%'"); + if (query.MedioId.HasValue) + conditions.Add("MedioId = @MedioId"); + if (query.ProductTypeId.HasValue) + conditions.Add("ProductTypeId = @ProductTypeId"); + if (query.RubroId.HasValue) + conditions.Add("RubroId = @RubroId"); + + var where = conditions.Count > 0 + ? "WHERE " + string.Join(" AND ", conditions) + : string.Empty; + + var countSql = $"SELECT COUNT(1) FROM dbo.Product {where}"; + var dataSql = $""" + SELECT Id, Nombre, MedioId, ProductTypeId, RubroId, + BasePrice, PriceDurationDays, IsActive, FechaCreacion, FechaModificacion + FROM dbo.Product + {where} + ORDER BY Nombre + OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY + """; + + var offset = (query.Page - 1) * query.PageSize; + var parameters = new + { + Activo = query.Activo.HasValue ? (object)(query.Activo.Value ? 1 : 0) : null, + Search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search, + query.MedioId, + query.ProductTypeId, + query.RubroId, + Offset = offset, + PageSize = query.PageSize, + }; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var total = await connection.ExecuteScalarAsync(countSql, parameters); + var rows = await connection.QueryAsync(dataSql, parameters); + var items = rows.Select(MapRow).ToList(); + + return new PagedResult(items, query.Page, query.PageSize, total); + } + + public async Task UpdateAsync(Product product, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Product + SET Nombre = @Nombre, + RubroId = @RubroId, + BasePrice = @BasePrice, + PriceDurationDays = @PriceDurationDays, + IsActive = @IsActive, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + await connection.ExecuteAsync(sql, new + { + product.Nombre, + product.RubroId, + product.BasePrice, + product.PriceDurationDays, + IsActive = product.IsActive ? 1 : 0, + product.FechaModificacion, + product.Id, + }); + } + + public async Task ExistsByNombreAsync( + string nombre, + int medioId, + int productTypeId, + int? excludeId = null, + CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) + FROM dbo.Product + WHERE Nombre = @Nombre + AND MedioId = @MedioId + AND ProductTypeId = @ProductTypeId + AND IsActive = 1 + AND (@ExcludeId IS NULL OR Id <> @ExcludeId) + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new + { + Nombre = nombre, + MedioId = medioId, + ProductTypeId = productTypeId, + ExcludeId = excludeId, + }); + + return count > 0; + } + + // ── Mapping ─────────────────────────────────────────────────────────────── + + private static Product MapRow(ProductRow r) + => new( + id: r.Id, + nombre: r.Nombre, + medioId: r.MedioId, + productTypeId: r.ProductTypeId, + rubroId: r.RubroId, + basePrice: r.BasePrice, + priceDurationDays: r.PriceDurationDays, + isActive: r.IsActive, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private sealed record ProductRow( + int Id, + string Nombre, + int MedioId, + int ProductTypeId, + int? RubroId, + decimal BasePrice, + int? PriceDurationDays, + bool IsActive, + DateTime FechaCreacion, + DateTime? FechaModificacion); +} diff --git a/tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs new file mode 100644 index 0000000..309e8e3 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs @@ -0,0 +1,120 @@ +using Dapper; +using FluentAssertions; +using SIGCM2.Infrastructure.Persistence; +using SIGCM2.TestSupport; + +namespace SIGCM2.Application.Tests.Products.Repository; + +/// +/// PRD-002 — Integration tests for ProductQueryRepository against SIGCM2_Test_App. +/// These tests verify the real Dapper implementation replaces NullProductQueryRepository. +/// +[Collection("Database")] +public class ProductQueryRepositoryTests : IAsyncLifetime +{ + private readonly SqlTestFixture _db; + private ProductQueryRepository _repository = null!; + + public ProductQueryRepositoryTests(SqlTestFixture db) + { + _db = db; + } + + public async Task InitializeAsync() + { + await _db.ResetAndSeedAsync(); + var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb); + _repository = new ProductQueryRepository(factory); + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ── ExistsActiveByProductTypeAsync ─────────────────────────────────────── + + [Fact] + public async Task ExistsActiveByProductTypeAsync_NoProducts_ReturnsFalse() + { + var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId: 999); + + result.Should().BeFalse(); + } + + [Fact] + public async Task ExistsActiveByProductTypeAsync_WithActiveProduct_ReturnsTrue() + { + // Arrange: insert a ProductType and an active Product referencing it + var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); + await InsertActiveProductAsync(medioId, productTypeId); + + var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId); + + result.Should().BeTrue(); + } + + [Fact] + public async Task ExistsActiveByProductTypeAsync_WithOnlyInactiveProduct_ReturnsFalse() + { + // Arrange: insert an inactive product + var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); + await InsertInactiveProductAsync(medioId, productTypeId); + + var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId); + + result.Should().BeFalse(); + } + + [Fact] + public async Task ExistsActiveByProductTypeAsync_DifferentProductType_ReturnsFalse() + { + // Arrange: insert active product for productTypeId=A, query for productTypeId=B + var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); + await InsertActiveProductAsync(medioId, productTypeId); + var otherProductTypeId = productTypeId + 100; + + var result = await _repository.ExistsActiveByProductTypeAsync(otherProductTypeId); + + result.Should().BeFalse(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task<(int MedioId, int ProductTypeId)> InsertMedioAndProductTypeAsync() + { + await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + var medioId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + OUTPUT INSERTED.Id + VALUES ('TM', 'Test Medio', 1, 1) + """); + + var productTypeId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages) + OUTPUT INSERTED.Id + VALUES ('Test Type', 0, 0, 0, 0, 0) + """); + + return (medioId, productTypeId); + } + + private async Task InsertActiveProductAsync(int medioId, int productTypeId) + { + await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion) + VALUES ('Producto Activo', @MedioId, @ProductTypeId, 100, 1, SYSUTCDATETIME()) + """, new { MedioId = medioId, ProductTypeId = productTypeId }); + } + + private async Task InsertInactiveProductAsync(int medioId, int productTypeId) + { + await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion) + VALUES ('Producto Inactivo', @MedioId, @ProductTypeId, 100, 0, SYSUTCDATETIME()) + """, new { MedioId = medioId, ProductTypeId = productTypeId }); + } +} From 733ca0e2e2b40fa579ca88726d6394d1bd2ec381 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:11:21 -0300 Subject: [PATCH 05/14] =?UTF-8?q?test(infrastructure):=20ProductRepository?= =?UTF-8?q?=20integration=20tests=20=E2=80=94=20roundtrip,=20update,=20dea?= =?UTF-8?q?ctivate=20history,=20UQ=20(PRD-002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/ProductRepositoryTests.cs | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 tests/SIGCM2.Application.Tests/Products/Repository/ProductRepositoryTests.cs diff --git a/tests/SIGCM2.Application.Tests/Products/Repository/ProductRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Products/Repository/ProductRepositoryTests.cs new file mode 100644 index 0000000..f812e45 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Repository/ProductRepositoryTests.cs @@ -0,0 +1,205 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Persistence; +using SIGCM2.TestSupport; + +namespace SIGCM2.Application.Tests.Products.Repository; + +/// +/// PRD-002 — Integration tests for ProductRepository against SIGCM2_Test_App. +/// Uses shared SqlTestFixture via [Collection("Database")] — fixture manages Respawn + seeds. +/// Verifies full CRUD, paged listing, UQ constraint, and temporal history. +/// +[Collection("Database")] +public class ProductRepositoryTests : IAsyncLifetime +{ + private readonly SqlTestFixture _db; + private ProductRepository _repository = null!; + private int _defaultMedioId; + private int _defaultProductTypeId; + + public ProductRepositoryTests(SqlTestFixture db) + { + _db = db; + } + + public async Task InitializeAsync() + { + await _db.ResetAndSeedAsync(); + + var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb); + _repository = new ProductRepository(factory); + + // Insert Medio and ProductType for use across all tests + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + _defaultMedioId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + OUTPUT INSERTED.Id + VALUES ('PR', 'Prueba Medio', 1, 1) + """); + + _defaultProductTypeId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages) + OUTPUT INSERTED.Id + VALUES ('Tipo Prueba', 0, 0, 0, 0, 0) + """); + } + + public Task DisposeAsync() => Task.CompletedTask; + + private Product AProduct(string nombre = "Clasificado Test") => + Product.ForCreation( + nombre: nombre, + medioId: _defaultMedioId, + productTypeId: _defaultProductTypeId, + rubroId: null, + basePrice: 100.50m, + priceDurationDays: null, + timeProvider: TimeProvider.System); + + // ── AddAsync + GetByIdAsync roundtrip ───────────────────────────────────── + + [Fact] + public async Task AddAsync_AndGetById_ReturnsAllFields() + { + var product = AProduct("Mi Producto"); + var id = await _repository.AddAsync(product); + var result = await _repository.GetByIdAsync(id); + + result.Should().NotBeNull(); + result!.Id.Should().Be(id); + result.Nombre.Should().Be("Mi Producto"); + result.MedioId.Should().Be(_defaultMedioId); + result.ProductTypeId.Should().Be(_defaultProductTypeId); + result.RubroId.Should().BeNull(); + result.BasePrice.Should().Be(100.50m); + result.PriceDurationDays.Should().BeNull(); + result.IsActive.Should().BeTrue(); + result.FechaCreacion.Should().BeAfter(DateTime.MinValue); + result.FechaModificacion.Should().BeNull(); + } + + // ── GetByIdAsync null for unknown ───────────────────────────────────────── + + [Fact] + public async Task GetByIdAsync_UnknownId_ReturnsNull() + { + var result = await _repository.GetByIdAsync(999999); + + result.Should().BeNull(); + } + + // ── UpdateAsync ──────────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_ChangesNombreAndBasePrice() + { + var id = await _repository.AddAsync(AProduct("Original")); + var product = await _repository.GetByIdAsync(id); + var updated = product!.WithUpdated("Renombrado", null, 200m, null, TimeProvider.System); + + await _repository.UpdateAsync(updated); + var result = await _repository.GetByIdAsync(id); + + result!.Nombre.Should().Be("Renombrado"); + result.BasePrice.Should().Be(200m); + result.FechaModificacion.Should().NotBeNull(); + } + + // ── WithDeactivated creates history row ──────────────────────────────────── + + [Fact] + public async Task UpdateAsync_Deactivate_CreatesHistoryRow() + { + var id = await _repository.AddAsync(AProduct("Para Desactivar")); + var product = await _repository.GetByIdAsync(id); + var deactivated = product!.WithDeactivated(TimeProvider.System); + + await _repository.UpdateAsync(deactivated); + + // Verify temporal history: ProductType_History should have at least 1 row + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + var historyCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.Product_History WHERE Id = @Id", new { Id = id }); + historyCount.Should().BeGreaterThanOrEqualTo(1); + } + + // ── GetPagedAsync ───────────────────────────────────────────────────────── + + [Fact] + public async Task GetPagedAsync_DefaultQuery_ReturnsActiveProducts() + { + await _repository.AddAsync(AProduct("Producto A")); + await _repository.AddAsync(AProduct("Producto B")); + + var result = await _repository.GetPagedAsync(new ProductsQuery(Page: 1, PageSize: 20, Activo: true)); + + result.Items.Should().HaveCountGreaterThanOrEqualTo(2); + result.Items.Should().AllSatisfy(p => p.IsActive.Should().BeTrue()); + } + + [Fact] + public async Task GetPagedAsync_FilterByMedioId_ReturnsOnlyMatching() + { + await _repository.AddAsync(AProduct("Producto Filtrado")); + + var result = await _repository.GetPagedAsync( + new ProductsQuery(Page: 1, PageSize: 20, Activo: null, MedioId: _defaultMedioId)); + + result.Items.Should().AllSatisfy(p => p.MedioId.Should().Be(_defaultMedioId)); + } + + // ── ExistsByNombreAsync ──────────────────────────────────────────────────── + + [Fact] + public async Task ExistsByNombreAsync_ExistingActiveProduct_ReturnsTrue() + { + var nombre = "Nombre Unico Test"; + await _repository.AddAsync(AProduct(nombre)); + + var result = await _repository.ExistsByNombreAsync(nombre, _defaultMedioId, _defaultProductTypeId); + + result.Should().BeTrue(); + } + + [Fact] + public async Task ExistsByNombreAsync_ExcludeSelf_ReturnsFalse() + { + var nombre = "Nombre Self Excluido"; + var id = await _repository.AddAsync(AProduct(nombre)); + + var result = await _repository.ExistsByNombreAsync(nombre, _defaultMedioId, _defaultProductTypeId, excludeId: id); + + result.Should().BeFalse(); + } + + [Fact] + public async Task ExistsByNombreAsync_NonExisting_ReturnsFalse() + { + var result = await _repository.ExistsByNombreAsync("Nombre Que No Existe XYZ", _defaultMedioId, _defaultProductTypeId); + + result.Should().BeFalse(); + } + + // ── UQ index: deactivated allows reuse of name ──────────────────────────── + + [Fact] + public async Task ExistsByNombreAsync_DeactivatedProduct_ReturnsFalse_AllowsReuse() + { + var nombre = "Nombre Reutilizable"; + var id = await _repository.AddAsync(AProduct(nombre)); + var product = await _repository.GetByIdAsync(id); + await _repository.UpdateAsync(product!.WithDeactivated(TimeProvider.System)); + + // After deactivation, name should be available again + var result = await _repository.ExistsByNombreAsync(nombre, _defaultMedioId, _defaultProductTypeId); + + result.Should().BeFalse(); + } +} From 165abc82451088716170eedd909ab49e683b43d3 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:17:31 -0300 Subject: [PATCH 06/14] feat(api): ProductsController + ExceptionFilter Product cases, fix permiso count to 27 (PRD-002) --- .../Controllers/ProductsController.cs | 169 +++++++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 62 ++++ .../Auth/AuthControllerTests.cs | 5 +- .../Permisos/PermisosEndpointTests.cs | 14 +- .../Products/ProductsControllerTests.cs | 339 ++++++++++++++++++ 5 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 src/api/SIGCM2.Api/Controllers/ProductsController.cs create mode 100644 tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/ProductsController.cs b/src/api/SIGCM2.Api/Controllers/ProductsController.cs new file mode 100644 index 0000000..d4ae3d2 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/ProductsController.cs @@ -0,0 +1,169 @@ +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.Products.Create; +using SIGCM2.Application.Products.Deactivate; +using SIGCM2.Application.Products.GetById; +using SIGCM2.Application.Products.List; +using SIGCM2.Application.Products.Update; + +namespace SIGCM2.Api.Controllers; + +/// +/// PRD-002: Product catalog management. +/// Read endpoints at /api/v1/products — require authentication (any role). +/// Write endpoints at /api/v1/admin/products — require 'catalogo:productos:gestionar'. +/// +[ApiController] +public sealed class ProductsController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public ProductsController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + // ── READ endpoints ───────────────────────────────────────────────────────── + + /// Returns a paginated list of Products. Requires authentication. + [HttpGet("api/v1/products")] + [Authorize] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task ListProducts( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] bool? activo = true, + [FromQuery] string? search = null, + [FromQuery] int? medioId = null, + [FromQuery] int? productTypeId = null, + [FromQuery] int? rubroId = null) + { + var query = new ListProductsQuery(page, pageSize, activo, search, medioId, productTypeId, rubroId); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Returns a single Product by id. Requires authentication. + [HttpGet("api/v1/products/{id:int}")] + [Authorize] + [ProducesResponseType(typeof(ProductDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProductById([FromRoute] int id) + { + var query = new GetProductByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + // ── WRITE endpoints ──────────────────────────────────────────────────────── + + /// Creates a new Product. Requires catalogo:productos:gestionar. + [HttpPost("api/v1/admin/products")] + [RequirePermission("catalogo:productos:gestionar")] + [ProducesResponseType(typeof(ProductCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task CreateProduct([FromBody] CreateProductRequest request) + { + var command = new CreateProductCommand( + Nombre: request.Nombre ?? string.Empty, + MedioId: request.MedioId, + ProductTypeId: request.ProductTypeId, + RubroId: request.RubroId, + BasePrice: request.BasePrice, + PriceDurationDays: request.PriceDurationDays); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result); + } + + /// Updates a Product. Requires catalogo:productos:gestionar. + [HttpPut("api/v1/admin/products/{id:int}")] + [RequirePermission("catalogo:productos:gestionar")] + [ProducesResponseType(typeof(ProductUpdatedDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task UpdateProduct([FromRoute] int id, [FromBody] UpdateProductRequest request) + { + var command = new UpdateProductCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + RubroId: request.RubroId, + BasePrice: request.BasePrice, + PriceDurationDays: request.PriceDurationDays); + + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Soft-deletes (deactivates) a Product. Requires catalogo:productos:gestionar. + [HttpDelete("api/v1/admin/products/{id:int}")] + [RequirePermission("catalogo:productos:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateProduct([FromRoute] int id) + { + var command = new DeactivateProductCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// PRD-002: Create Product request body. +public sealed record CreateProductRequest( + string? Nombre, + int MedioId = 0, + int ProductTypeId = 0, + int? RubroId = null, + decimal BasePrice = 0m, + int? PriceDurationDays = null); + +/// PRD-002: Update Product request body. +public sealed record UpdateProductRequest( + string? Nombre, + int? RubroId = null, + decimal BasePrice = 0m, + int? PriceDurationDays = null); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index b40f2d3..e5c8c73 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -463,6 +463,68 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // PRD-002: Product exceptions + case ProductNotFoundException productNotFoundEx: + context.Result = new ObjectResult(new + { + error = "product_not_found", + message = productNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + case ProductNombreDuplicadoEnMedioTipoException productDupEx: + context.Result = new ObjectResult(new + { + error = "product_nombre_duplicado", + message = productDupEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case ProductTipoFlagsIncoherentesException productFlagsEx: + context.Result = new ObjectResult(new + { + error = "product_flags_incoherentes", + field = productFlagsEx.Field, + message = productFlagsEx.Message + }) + { + StatusCode = StatusCodes.Status422UnprocessableEntity + }; + context.ExceptionHandled = true; + break; + + case ProductTypeInactivoException productTypeInactivoEx: + context.Result = new ObjectResult(new + { + error = "product_type_inactivo", + message = productTypeInactivoEx.Message + }) + { + StatusCode = StatusCodes.Status422UnprocessableEntity + }; + context.ExceptionHandled = true; + break; + + case RubroInactivoException rubroInactivoEx: + context.Result = new ObjectResult(new + { + error = "rubro_inactivo", + message = rubroInactivoEx.Message + }) + { + StatusCode = StatusCodes.Status422UnprocessableEntity + }; + context.ExceptionHandled = true; + break; + // ADM-008: PuntoDeVenta exceptions case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx: context.Result = new ObjectResult(new diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs index 062b771..7eeb4b8 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -51,8 +51,9 @@ public class AuthControllerTests // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 - // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total - Assert.Equal(26, permisos.GetArrayLength()); + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total + Assert.Equal(27, permisos.GetArrayLength()); } // Scenario: invalid credentials return 401 with opaque error diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs index 372509b..a363606 100644 --- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/permisos — catalog ─────────────────────────────────────── [Fact] - public async Task GetPermisos_WithAdmin_Returns200With26Items() + public async Task GetPermisos_WithAdmin_Returns200With27Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token); @@ -141,8 +141,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 - // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total - Assert.Equal(26, list.GetArrayLength()); + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total + Assert.Equal(27, list.GetArrayLength()); } [Fact] @@ -185,7 +186,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/roles/{codigo}/permisos ────────────────────────────────── [Fact] - public async Task GetRolPermisos_AdminRol_Returns200With26Items() + public async Task GetRolPermisos_AdminRol_Returns200With27Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token); @@ -197,8 +198,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 - // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total - Assert.Equal(26, list.GetArrayLength()); + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 + // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total + Assert.Equal(27, list.GetArrayLength()); } [Fact] diff --git a/tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs b/tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs new file mode 100644 index 0000000..bfb795b --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs @@ -0,0 +1,339 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Products; + +/// +/// PRD-002 — Integration tests for /api/v1/products and /api/v1/admin/products. +/// Read endpoints require authentication (any role). +/// Write endpoints require permission 'catalogo:productos:gestionar'. +/// Verifies HTTP status codes, response shapes, and ExceptionFilter mappings. +/// +[Collection("ApiIntegration")] +public sealed class ProductsControllerTests : IAsyncLifetime +{ + private const string ReadEndpoint = "/api/v1/products"; + private const string AdminEndpoint = "/api/v1/admin/products"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public ProductsControllerTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task DisposeAsync() => Task.CompletedTask; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null) + { + var request = new HttpRequestMessage(method, url); + if (bearerToken is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + if (body is not null) + request.Content = JsonContent.Create(body); + return request; + } + + private async Task<(int MedioId, int ProductTypeId)> EnsureMedioAndProductTypeAsync(string token) + { + // Create a Medio via SQL (we don't have a Medio controller endpoint available here) + // Use product-types endpoint to create a ProductType and insert Medio directly + var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new + { + codigo = $"PM{Guid.NewGuid():N}"[..6], + nombre = $"Medio Test {Guid.NewGuid():N}"[..30], + tipo = 1 + }, token)); + medioResp.EnsureSuccessStatusCode(); + var medioJson = await medioResp.Content.ReadFromJsonAsync(); + var medioId = medioJson.GetProperty("id").GetInt32(); + + var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new + { + nombre = $"PT_{Guid.NewGuid():N}"[..30], + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token)); + ptResp.EnsureSuccessStatusCode(); + var ptJson = await ptResp.Content.ReadFromJsonAsync(); + var productTypeId = ptJson.GetProperty("id").GetInt32(); + + return (medioId, productTypeId); + } + + // ── 401 guards on READ endpoints ─────────────────────────────────────────── + + [Fact] + public async Task List_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Get, ReadEndpoint); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task GetById_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999"); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + // ── 401 guards on WRITE endpoints ────────────────────────────────────────── + + [Fact] + public async Task Create_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Test" }); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task Update_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/1", new { nombre = "Test", basePrice = 10m }); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task Deactivate_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/1"); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + // ── POST /api/v1/admin/products ─────────────────────────────────────────── + + [Fact] + public async Task Create_WithAdmin_Returns201WithId() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + var uniqueName = $"Prod_{Guid.NewGuid():N}"[..30]; + + using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, + medioId, + productTypeId, + basePrice = 100.50m + }, token); + + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.GetProperty("id").GetInt32() > 0); + Assert.Equal(uniqueName, json.GetProperty("nombre").GetString()); + Assert.True(json.GetProperty("isActive").GetBoolean()); + } + + [Fact] + public async Task Create_InvalidBody_Returns400() + { + var token = await GetAdminTokenAsync(); + + using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = string.Empty, // invalid + medioId = 1, + productTypeId = 1, + basePrice = 10m + }, token); + + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + [Fact] + public async Task Create_DuplicateNombre_Returns409() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + var uniqueName = $"Dup_{Guid.NewGuid():N}"[..30]; + + var body = new { nombre = uniqueName, medioId, productTypeId, basePrice = 50m }; + + using var req1 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token); + var resp1 = await _client.SendAsync(req1); + Assert.Equal(HttpStatusCode.Created, resp1.StatusCode); + + using var req2 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token); + var resp2 = await _client.SendAsync(req2); + + Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode); + } + + [Fact] + public async Task Create_MedioNotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + + using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = $"Prod_{Guid.NewGuid():N}"[..30], + medioId = 999999, + productTypeId = 1, + basePrice = 50m + }, token); + + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + // ── GET /api/v1/products ────────────────────────────────────────────────── + + [Fact] + public async Task List_WithAuth_Returns200WithPaginatedResult() + { + var token = await GetAdminTokenAsync(); + + using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}?page=1&pageSize=10", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _)); + Assert.True(json.TryGetProperty("total", out _)); + } + + // ── GET /api/v1/products/{id} ───────────────────────────────────────────── + + [Fact] + public async Task GetById_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999999", bearerToken: token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + [Fact] + public async Task GetById_ExistingId_Returns200() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + var uniqueName = $"GetById_{Guid.NewGuid():N}"[..28]; + + using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, medioId, productTypeId, basePrice = 75m + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + + var createJson = await createResp.Content.ReadFromJsonAsync(); + var productId = createJson.GetProperty("id").GetInt32(); + + using var getReq = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/{productId}", bearerToken: token); + var getResp = await _client.SendAsync(getReq); + + Assert.Equal(HttpStatusCode.OK, getResp.StatusCode); + var getJson = await getResp.Content.ReadFromJsonAsync(); + Assert.Equal(productId, getJson.GetProperty("id").GetInt32()); + Assert.Equal(uniqueName, getJson.GetProperty("nombre").GetString()); + } + + // ── DELETE /api/v1/admin/products/{id} ──────────────────────────────────── + + [Fact] + public async Task Deactivate_ExistingId_Returns204() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + var uniqueName = $"Del_{Guid.NewGuid():N}"[..28]; + + using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, medioId, productTypeId, basePrice = 50m + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + + var createJson = await createResp.Content.ReadFromJsonAsync(); + var productId = createJson.GetProperty("id").GetInt32(); + + using var delReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{productId}", bearerToken: token); + var delResp = await _client.SendAsync(delReq); + + Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode); + } + + [Fact] + public async Task Deactivate_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/999999999", bearerToken: token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + // ── PUT /api/v1/admin/products/{id} ─────────────────────────────────────── + + [Fact] + public async Task Update_ExistingProduct_Returns200() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + var uniqueName = $"Upd_{Guid.NewGuid():N}"[..28]; + + using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, medioId, productTypeId, basePrice = 50m + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + + var createJson = await createResp.Content.ReadFromJsonAsync(); + var productId = createJson.GetProperty("id").GetInt32(); + + var newName = $"Upd2_{Guid.NewGuid():N}"[..28]; + using var updateReq = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/{productId}", new + { + nombre = newName, + basePrice = 200m + }, token); + var updateResp = await _client.SendAsync(updateReq); + + Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode); + var updateJson = await updateResp.Content.ReadFromJsonAsync(); + Assert.Equal(newName, updateJson.GetProperty("nombre").GetString()); + } + + [Fact] + public async Task Update_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/999999999", new + { + nombre = "Test", basePrice = 10m + }, token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } +} From a41a4ea341a9240c9f976d02e4173a1e287d02ea Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:18:21 -0300 Subject: [PATCH 07/14] =?UTF-8?q?test(api):=20guard=20proof=20=E2=80=94=20?= =?UTF-8?q?ProductType=20deactivation=20returns=20409=20when=20active=20Pr?= =?UTF-8?q?oducts=20exist=20(PRD-002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProductTypeDeactivationGuardTests.cs | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Products/ProductTypeDeactivationGuardTests.cs diff --git a/tests/SIGCM2.Api.Tests/Products/ProductTypeDeactivationGuardTests.cs b/tests/SIGCM2.Api.Tests/Products/ProductTypeDeactivationGuardTests.cs new file mode 100644 index 0000000..1d244ed --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Products/ProductTypeDeactivationGuardTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Products; + +/// +/// PRD-002 Batch 8 — Guard proof: verifies that deactivating a ProductType with active Products +/// returns HTTP 409 Conflict. This test closes the W1 (dormant guard) issue from PRD-001: +/// - PRD-001: NullProductQueryRepository always returned false → guard never fired +/// - PRD-002: ProductQueryRepository now queries dbo.Product → guard fires correctly +/// +[Collection("ApiIntegration")] +public sealed class ProductTypeDeactivationGuardTests : IAsyncLifetime +{ + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public ProductTypeDeactivationGuardTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task DisposeAsync() => Task.CompletedTask; + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null) + { + var request = new HttpRequestMessage(method, url); + if (bearerToken is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + if (body is not null) + request.Content = JsonContent.Create(body); + return request; + } + + /// + /// E2E proof: seed Medio + ProductType + active Product → DELETE product-type → expect 409. + /// This verifies the IProductQueryRepository guard fires against real data in dbo.Product. + /// + [Fact] + public async Task DeactivateProductType_WithActiveProducts_Returns409Conflict() + { + var token = await GetAdminTokenAsync(); + + // 1. Create a Medio + var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new + { + codigo = $"GD{Guid.NewGuid():N}"[..6], + nombre = $"Guarda Medio {Guid.NewGuid():N}"[..30], + tipo = 1 + }, token)); + medioResp.EnsureSuccessStatusCode(); + var medioJson = await medioResp.Content.ReadFromJsonAsync(); + var medioId = medioJson.GetProperty("id").GetInt32(); + + // 2. Create a ProductType + var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new + { + nombre = $"Guardado PT {Guid.NewGuid():N}"[..30], + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token)); + ptResp.EnsureSuccessStatusCode(); + var ptJson = await ptResp.Content.ReadFromJsonAsync(); + var productTypeId = ptJson.GetProperty("id").GetInt32(); + + // 3. Create an active Product for this ProductType + var prodResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/products", new + { + nombre = $"Prod Guarda {Guid.NewGuid():N}"[..28], + medioId, + productTypeId, + basePrice = 100m + }, token)); + prodResp.EnsureSuccessStatusCode(); + + // 4. Attempt to deactivate the ProductType — should be blocked by guard + using var deactivateReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token); + var deactivateResp = await _client.SendAsync(deactivateReq); + + // CRITICAL ASSERTION: 409 Conflict — guard fires because Product is active + Assert.Equal(HttpStatusCode.Conflict, deactivateResp.StatusCode); + + var body = await deactivateResp.Content.ReadFromJsonAsync(); + Assert.Equal("product_type_en_uso", body.GetProperty("error").GetString()); + } + + /// + /// Verify guard does NOT block deactivation when Products exist but are all inactive. + /// + [Fact] + public async Task DeactivateProductType_WithOnlyInactiveProducts_Returns204() + { + var token = await GetAdminTokenAsync(); + + // 1. Create Medio + var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new + { + codigo = $"GI{Guid.NewGuid():N}"[..6], + nombre = $"Guarda Inact {Guid.NewGuid():N}"[..28], + tipo = 1 + }, token)); + medioResp.EnsureSuccessStatusCode(); + var medioJson = await medioResp.Content.ReadFromJsonAsync(); + var medioId = medioJson.GetProperty("id").GetInt32(); + + // 2. Create ProductType + var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new + { + nombre = $"PT Inact {Guid.NewGuid():N}"[..28], + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token)); + ptResp.EnsureSuccessStatusCode(); + var ptJson = await ptResp.Content.ReadFromJsonAsync(); + var productTypeId = ptJson.GetProperty("id").GetInt32(); + + // 3. Create then deactivate Product + var prodResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/products", new + { + nombre = $"Prod Inact {Guid.NewGuid():N}"[..28], + medioId, + productTypeId, + basePrice = 50m + }, token)); + prodResp.EnsureSuccessStatusCode(); + var prodJson = await prodResp.Content.ReadFromJsonAsync(); + var productId = prodJson.GetProperty("id").GetInt32(); + + // Deactivate the Product first + using var deactivateProdReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/products/{productId}", bearerToken: token); + var deactivateProdResp = await _client.SendAsync(deactivateProdReq); + Assert.Equal(HttpStatusCode.NoContent, deactivateProdResp.StatusCode); + + // 4. Now deactivate ProductType — should succeed since no active products + using var deactivatePtReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token); + var deactivatePtResp = await _client.SendAsync(deactivatePtReq); + + Assert.Equal(HttpStatusCode.NoContent, deactivatePtResp.StatusCode); + } +} From 08a4738daf1a24815228dc7f0c13fc6528386580 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:24:42 -0300 Subject: [PATCH 08/14] =?UTF-8?q?feat(frontend):=20Products=20feature=20?= =?UTF-8?q?=E2=80=94=20CRUD=20page,=20form,=20dialogs,=20hooks=20(PRD-002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full frontend for PRD-002: 5 API fns, 5 hooks (useProducts, useCreateProduct, useUpdateProduct, useDeactivateProduct), ProductForm, ProductFormDialog, DeactivateProductDialog, ProductsPage with CanPerform gating. Router entry at /admin/products and sidebar link added. 19 Vitest tests GREEN (api, hooks, page). --- src/web/src/components/layout/AppSidebar.tsx | 7 + .../features/products/api/createProduct.ts | 12 + .../products/api/deactivateProduct.ts | 5 + .../features/products/api/getProductById.ts | 7 + .../src/features/products/api/listProducts.ts | 12 + .../features/products/api/updateProduct.ts | 13 + .../components/DeactivateProductDialog.tsx | 87 ++++++ .../products/components/ProductForm.tsx | 280 ++++++++++++++++++ .../products/components/ProductFormDialog.tsx | 126 ++++++++ .../products/hooks/useCreateProduct.ts | 13 + .../products/hooks/useDeactivateProduct.ts | 12 + .../features/products/hooks/useProducts.ts | 10 + .../products/hooks/useUpdateProduct.ts | 14 + src/web/src/features/products/index.ts | 9 + .../features/products/pages/ProductsPage.tsx | 177 +++++++++++ src/web/src/features/products/types.ts | 58 ++++ src/web/src/router.tsx | 11 + .../features/products/ProductsPage.test.tsx | 187 ++++++++++++ .../src/tests/features/products/api.test.ts | 131 ++++++++ .../src/tests/features/products/hooks.test.ts | 143 +++++++++ 20 files changed, 1314 insertions(+) create mode 100644 src/web/src/features/products/api/createProduct.ts create mode 100644 src/web/src/features/products/api/deactivateProduct.ts create mode 100644 src/web/src/features/products/api/getProductById.ts create mode 100644 src/web/src/features/products/api/listProducts.ts create mode 100644 src/web/src/features/products/api/updateProduct.ts create mode 100644 src/web/src/features/products/components/DeactivateProductDialog.tsx create mode 100644 src/web/src/features/products/components/ProductForm.tsx create mode 100644 src/web/src/features/products/components/ProductFormDialog.tsx create mode 100644 src/web/src/features/products/hooks/useCreateProduct.ts create mode 100644 src/web/src/features/products/hooks/useDeactivateProduct.ts create mode 100644 src/web/src/features/products/hooks/useProducts.ts create mode 100644 src/web/src/features/products/hooks/useUpdateProduct.ts create mode 100644 src/web/src/features/products/index.ts create mode 100644 src/web/src/features/products/pages/ProductsPage.tsx create mode 100644 src/web/src/features/products/types.ts create mode 100644 src/web/src/tests/features/products/ProductsPage.test.tsx create mode 100644 src/web/src/tests/features/products/api.test.ts create mode 100644 src/web/src/tests/features/products/hooks.test.ts diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 3331aa9..aa89dcf 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -17,6 +17,7 @@ import { Store, Tag, Layers, + Package, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -82,6 +83,12 @@ const adminItems: NavItem[] = [ icon: Layers, requiredPermission: 'catalogo:tipos:gestionar', }, + { + label: 'Productos', + href: '/admin/products', + icon: Package, + requiredPermission: 'catalogo:productos:gestionar', + }, ] interface SidebarNavProps { diff --git a/src/web/src/features/products/api/createProduct.ts b/src/web/src/features/products/api/createProduct.ts new file mode 100644 index 0000000..845bcfb --- /dev/null +++ b/src/web/src/features/products/api/createProduct.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '@/api/axiosClient' +import type { CreateProductRequest, ProductDetail } from '../types' + +export async function createProduct( + payload: CreateProductRequest, +): Promise { + const response = await axiosClient.post( + '/api/v1/admin/products', + payload, + ) + return response.data +} diff --git a/src/web/src/features/products/api/deactivateProduct.ts b/src/web/src/features/products/api/deactivateProduct.ts new file mode 100644 index 0000000..1522a70 --- /dev/null +++ b/src/web/src/features/products/api/deactivateProduct.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateProduct(id: number): Promise { + await axiosClient.delete(`/api/v1/admin/products/${id}`) +} diff --git a/src/web/src/features/products/api/getProductById.ts b/src/web/src/features/products/api/getProductById.ts new file mode 100644 index 0000000..039d7c2 --- /dev/null +++ b/src/web/src/features/products/api/getProductById.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ProductDetail } from '../types' + +export async function getProductById(id: number): Promise { + const response = await axiosClient.get(`/api/v1/products/${id}`) + return response.data +} diff --git a/src/web/src/features/products/api/listProducts.ts b/src/web/src/features/products/api/listProducts.ts new file mode 100644 index 0000000..5fbd8d6 --- /dev/null +++ b/src/web/src/features/products/api/listProducts.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ListProductsParams, PagedResult, ProductListItem } from '../types' + +export async function listProducts( + params?: ListProductsParams, +): Promise> { + const response = await axiosClient.get>( + '/api/v1/products', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/products/api/updateProduct.ts b/src/web/src/features/products/api/updateProduct.ts new file mode 100644 index 0000000..b626359 --- /dev/null +++ b/src/web/src/features/products/api/updateProduct.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UpdateProductRequest, ProductDetail } from '../types' + +export async function updateProduct( + id: number, + payload: UpdateProductRequest, +): Promise { + const response = await axiosClient.put( + `/api/v1/admin/products/${id}`, + payload, + ) + return response.data +} diff --git a/src/web/src/features/products/components/DeactivateProductDialog.tsx b/src/web/src/features/products/components/DeactivateProductDialog.tsx new file mode 100644 index 0000000..4a39ae1 --- /dev/null +++ b/src/web/src/features/products/components/DeactivateProductDialog.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' +import { AlertCircle } from 'lucide-react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Alert, AlertDescription } from '@/components/ui/alert' +import type { ProductListItem } from '../types' + +// ─── Error resolver ──────────────────────────────────────────────────────────── + +function resolveDeactivateError(err: unknown): string | null { + if (!err) return null + const errObj = err as { response?: { status?: number; data?: { message?: string } } } + if (errObj?.response?.data?.message) { + return errObj.response.data.message + } + return 'Error al desactivar el producto' +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface DeactivateProductDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + product: ProductListItem + onConfirm: (id: number) => Promise | void +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function DeactivateProductDialog({ + open, + onOpenChange, + product, + onConfirm, +}: DeactivateProductDialogProps) { + const [error, setError] = useState(null) + const [isPending, setIsPending] = useState(false) + + async function handleConfirm() { + setError(null) + setIsPending(true) + try { + await onConfirm(product.id) + onOpenChange(false) + } catch (err) { + setError(resolveDeactivateError(err)) + } finally { + setIsPending(false) + } + } + + return ( + + + + Desactivar producto + + ¿Desactivar el producto “{product.nombre}”? El producto no + aparecerá en los listados activos. + + + + {error && ( + + + {error} + + )} + + + Cancelar + + {isPending ? 'Procesando...' : 'Desactivar'} + + + + + ) +} diff --git a/src/web/src/features/products/components/ProductForm.tsx b/src/web/src/features/products/components/ProductForm.tsx new file mode 100644 index 0000000..df48dab --- /dev/null +++ b/src/web/src/features/products/components/ProductForm.tsx @@ -0,0 +1,280 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' + +// ─── Schema ─────────────────────────────────────────────────────────────────── + +function nullablePositiveInt() { + return z + .string() + .optional() + .transform((v) => (v === '' || v == null ? null : Number(v))) + .pipe(z.number().int().positive().nullable()) +} + +const productFormSchema = z.object({ + nombre: z.string().trim().min(1, 'Nombre requerido').max(300, 'Máximo 300 caracteres'), + medioId: z + .string() + .min(1, 'Medio requerido') + .transform((v) => Number(v)) + .pipe(z.number().int().positive('Medio requerido')), + productTypeId: z + .string() + .min(1, 'Tipo de producto requerido') + .transform((v) => Number(v)) + .pipe(z.number().int().positive('Tipo de producto requerido')), + rubroId: nullablePositiveInt(), + basePrice: z + .string() + .min(1, 'Precio requerido') + .transform((v) => Number(v)) + .pipe(z.number().min(0, 'El precio no puede ser negativo')), + priceDurationDays: nullablePositiveInt(), +}) + +// Raw form field types (strings before zod transforms) +type ProductFormRaw = { + nombre: string + medioId: string + productTypeId: string + rubroId: string + basePrice: string + priceDurationDays: string +} + +// Output type after zod transforms (what onSubmit receives at runtime) +export type ProductFormOutput = { + nombre: string + medioId: number + productTypeId: number + rubroId: number | null + basePrice: number + priceDurationDays: number | null +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface ProductFormDefaultValues { + nombre?: string + medioId?: number | null + productTypeId?: number | null + rubroId?: number | null + basePrice?: number | null + priceDurationDays?: number | null +} + +interface ProductFormProps { + defaultValues?: ProductFormDefaultValues + onSubmit: (values: ProductFormOutput) => void + onCancel: () => void + isPending?: boolean + isEdit?: boolean +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ProductForm({ + defaultValues, + onSubmit, + onCancel, + isPending = false, + isEdit = false, +}: ProductFormProps) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const form = useForm({ + resolver: zodResolver(productFormSchema) as any, + defaultValues: { + nombre: defaultValues?.nombre ?? '', + medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '', + productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '', + rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '', + basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '', + priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '', + }, + }) + + useEffect(() => { + form.reset({ + nombre: defaultValues?.nombre ?? '', + medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '', + productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '', + rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '', + basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '', + priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '', + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultValues?.nombre, defaultValues?.medioId, defaultValues?.productTypeId]) + + function handleSubmit(data: ProductFormOutput) { + onSubmit(data) + } + + return ( +
+ [0], + )} + className="space-y-4" + noValidate + > + {/* Nombre */} + ( + + Nombre + + + + + + )} + /> + + {/* Medio ID */} + ( + + ID de Medio + + + + + + )} + /> + + {/* Product Type ID */} + ( + + ID de Tipo de Producto + + + + + + )} + /> + + {/* Rubro ID (optional) */} + ( + + ID de Rubro (opcional) + + + + + + )} + /> + + {/* Base Price */} + ( + + Precio base + + + + + + )} + /> + + {/* Price Duration Days (optional) */} + ( + + Días de duración del precio (opcional) + + + + + + )} + /> + +
+ + +
+ + + ) +} diff --git a/src/web/src/features/products/components/ProductFormDialog.tsx b/src/web/src/features/products/components/ProductFormDialog.tsx new file mode 100644 index 0000000..1f53d49 --- /dev/null +++ b/src/web/src/features/products/components/ProductFormDialog.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { ProductForm } from './ProductForm' +import type { ProductFormOutput } from './ProductForm' +import { useCreateProduct } from '../hooks/useCreateProduct' +import { useUpdateProduct } from '../hooks/useUpdateProduct' +import type { ProductDetail } from '../types' + +// ─── Error resolver ──────────────────────────────────────────────────────────── + +function resolveBackendError(err: unknown): string | null { + if (!err) return null + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { error?: string; message?: string } + return data.message ?? data.error ?? 'Error al guardar el producto' + } + const errObj = err as { response?: { data?: { message?: string } } } + if (errObj?.response?.data?.message) { + return errObj.response.data.message + } + return 'Error al guardar el producto' +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface ProductFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + product?: ProductDetail + onSuccess?: () => void +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ProductFormDialog({ + open, + onOpenChange, + product, + onSuccess, +}: ProductFormDialogProps) { + const [backendError, setBackendError] = useState(null) + + const isEdit = !!product + const { mutateAsync: createProduct, isPending: creating } = useCreateProduct() + const { mutateAsync: updateProduct, isPending: updating } = useUpdateProduct() + const isPending = creating || updating + + async function handleSubmit(values: ProductFormOutput) { + setBackendError(null) + try { + if (isEdit) { + await updateProduct({ + id: product.id, + data: { + nombre: values.nombre, + rubroId: values.rubroId, + basePrice: values.basePrice, + priceDurationDays: values.priceDurationDays, + }, + }) + toast.success('Producto actualizado') + } else { + await createProduct({ + nombre: values.nombre, + medioId: values.medioId, + productTypeId: values.productTypeId, + rubroId: values.rubroId, + basePrice: values.basePrice, + priceDurationDays: values.priceDurationDays, + }) + toast.success('Producto creado') + } + onOpenChange(false) + onSuccess?.() + } catch (err) { + const msg = resolveBackendError(err) + setBackendError(msg) + if ( + !isAxiosError(err) || + (err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400) + ) { + toast.error(isEdit ? 'Error al actualizar producto' : 'Error al crear producto') + } + } + } + + return ( + + + + {isEdit ? 'Editar producto' : 'Nuevo producto'} + + {isEdit + ? `Modificá los datos del producto "${product?.nombre ?? ''}".` + : 'Completá los datos para crear un nuevo producto.'} + + + + {backendError && ( + + + {backendError} + + )} + + onOpenChange(false)} + isPending={isPending} + isEdit={isEdit} + /> + + + ) +} diff --git a/src/web/src/features/products/hooks/useCreateProduct.ts b/src/web/src/features/products/hooks/useCreateProduct.ts new file mode 100644 index 0000000..f8648f1 --- /dev/null +++ b/src/web/src/features/products/hooks/useCreateProduct.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createProduct } from '../api/createProduct' +import type { CreateProductRequest } from '../types' + +export function useCreateProduct() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateProductRequest) => createProduct(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['products'] }) + }, + }) +} diff --git a/src/web/src/features/products/hooks/useDeactivateProduct.ts b/src/web/src/features/products/hooks/useDeactivateProduct.ts new file mode 100644 index 0000000..221e39d --- /dev/null +++ b/src/web/src/features/products/hooks/useDeactivateProduct.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateProduct } from '../api/deactivateProduct' + +export function useDeactivateProduct() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deactivateProduct(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['products'] }) + }, + }) +} diff --git a/src/web/src/features/products/hooks/useProducts.ts b/src/web/src/features/products/hooks/useProducts.ts new file mode 100644 index 0000000..bec5e76 --- /dev/null +++ b/src/web/src/features/products/hooks/useProducts.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { listProducts } from '../api/listProducts' +import type { ListProductsParams } from '../types' + +export function useProducts(params?: ListProductsParams) { + return useQuery({ + queryKey: ['products', params], + queryFn: () => listProducts(params), + }) +} diff --git a/src/web/src/features/products/hooks/useUpdateProduct.ts b/src/web/src/features/products/hooks/useUpdateProduct.ts new file mode 100644 index 0000000..4caa075 --- /dev/null +++ b/src/web/src/features/products/hooks/useUpdateProduct.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateProduct } from '../api/updateProduct' +import type { UpdateProductRequest } from '../types' + +export function useUpdateProduct() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateProductRequest }) => + updateProduct(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['products'] }) + }, + }) +} diff --git a/src/web/src/features/products/index.ts b/src/web/src/features/products/index.ts new file mode 100644 index 0000000..a9f4708 --- /dev/null +++ b/src/web/src/features/products/index.ts @@ -0,0 +1,9 @@ +export { ProductsPage } from './pages/ProductsPage' +export type { + ProductListItem, + ProductDetail, + CreateProductRequest, + UpdateProductRequest, + PagedResult, + ListProductsParams, +} from './types' diff --git a/src/web/src/features/products/pages/ProductsPage.tsx b/src/web/src/features/products/pages/ProductsPage.tsx new file mode 100644 index 0000000..fcb9335 --- /dev/null +++ b/src/web/src/features/products/pages/ProductsPage.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react' +import { AlertCircle, Plus } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { CanPerform } from '@/components/auth/CanPerform' +import { useProducts } from '../hooks/useProducts' +import { useDeactivateProduct } from '../hooks/useDeactivateProduct' +import { ProductFormDialog } from '../components/ProductFormDialog' +import { DeactivateProductDialog } from '../components/DeactivateProductDialog' +import type { ProductListItem, ProductDetail } from '../types' + +export function ProductsPage() { + // ── Create dialog state ────────────────────────────────────────────────── + const [createOpen, setCreateOpen] = useState(false) + + // ── Edit dialog state ──────────────────────────────────────────────────── + const [editOpen, setEditOpen] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) + + // ── Deactivate dialog state ────────────────────────────────────────────── + const [deactivateOpen, setDeactivateOpen] = useState(false) + const [deactivatingProduct, setDeactivatingProduct] = useState(null) + + const { data: paged, isLoading, isError } = useProducts({ activo: true }) + const { mutateAsync: deactivateProduct } = useDeactivateProduct() + + // ── Handlers ───────────────────────────────────────────────────────────── + + function openCreate() { + setCreateOpen(true) + } + + function openEdit(p: ProductListItem) { + const detail: ProductDetail = { + ...p, + fechaCreacion: '', + fechaModificacion: null, + } + setEditingProduct(detail) + setEditOpen(true) + } + + function openDeactivate(p: ProductListItem) { + setDeactivatingProduct(p) + setDeactivateOpen(true) + } + + async function handleDeactivate(id: number) { + await deactivateProduct(id) + toast.success('Producto desactivado') + } + + // ── Loading / Error ─────────────────────────────────────────────────────── + + if (isLoading) { + return ( +
+ + +
+ ) + } + + if (isError) { + return ( + + + Error al cargar productos. + + ) + } + + const isEmpty = !paged?.items.length + + return ( +
+
+

Productos

+ + + +
+ + {isEmpty ? ( +
+

No hay productos.

+ + + +
+ ) : ( +
+ + + + + + + + + + + + {paged.items.map((p: ProductListItem) => ( + + + + + + + + + ))} + +
NombreMedioTipoPrecio baseActivo +
{p.nombre}{p.medioId}{p.productTypeId}{p.basePrice} + + {p.isActive ? 'Activo' : 'Inactivo'} + + + +
+ + +
+
+
+
+ )} + + {/* Create dialog */} + + + {/* Edit dialog */} + {editingProduct && ( + + )} + + {/* Deactivate confirmation dialog */} + {deactivatingProduct && ( + + )} +
+ ) +} diff --git a/src/web/src/features/products/types.ts b/src/web/src/features/products/types.ts new file mode 100644 index 0000000..5ff1369 --- /dev/null +++ b/src/web/src/features/products/types.ts @@ -0,0 +1,58 @@ +// PRD-002 — shared types for products feature + +export interface ProductListItem { + id: number + nombre: string + medioId: number + productTypeId: number + rubroId: number | null + basePrice: number + priceDurationDays: number | null + isActive: boolean +} + +export interface ProductDetail { + id: number + nombre: string + medioId: number + productTypeId: number + rubroId: number | null + basePrice: number + priceDurationDays: number | null + isActive: boolean + fechaCreacion: string + fechaModificacion: string | null +} + +export interface CreateProductRequest { + nombre: string + medioId: number + productTypeId: number + rubroId?: number | null + basePrice: number + priceDurationDays?: number | null +} + +export interface UpdateProductRequest { + nombre: string + rubroId?: number | null + basePrice: number + priceDurationDays?: number | null +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} + +export interface ListProductsParams { + page?: number + pageSize?: number + activo?: boolean | null + search?: string + medioId?: number + productTypeId?: number + rubroId?: number +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 992cc76..6ce3ea5 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -29,6 +29,7 @@ import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage' import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage' import { RubrosPage } from './features/rubros/pages/RubrosPage' import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage' +import { ProductsPage } from './features/products/pages/ProductsPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -320,6 +321,16 @@ export function AppRoutes() { } /> + {/* Products routes — PRD-002 */} + + + + } + /> + } /> ) diff --git a/src/web/src/tests/features/products/ProductsPage.test.tsx b/src/web/src/tests/features/products/ProductsPage.test.tsx new file mode 100644 index 0000000..0fdd1a3 --- /dev/null +++ b/src/web/src/tests/features/products/ProductsPage.test.tsx @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import React from 'react' +import { ProductsPage } from '../../../features/products/pages/ProductsPage' +import { useAuthStore } from '../../../stores/authStore' +import type { ProductListItem, PagedResult } from '../../../features/products/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const adminUser = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['catalogo:productos:gestionar'], + mustChangePassword: false, +} + +const regularUser = { + id: 2, + username: 'viewer', + nombre: 'Viewer', + rol: 'viewer', + permisos: [], + mustChangePassword: false, +} + +const mockItem: ProductListItem = { + id: 1, + nombre: 'Clasificado Estándar', + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 100.50, + priceDurationDays: null, + isActive: true, +} + +const mockPaged: PagedResult = { + items: [mockItem], + page: 1, + pageSize: 20, + total: 1, +} + +const emptyPaged: PagedResult = { + items: [], + page: 1, + pageSize: 20, + total: 0, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(user = adminUser) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +// ─── Loading / Error / Data states ────────────────────────────────────────── + +describe('ProductsPage — loading and error states', () => { + it('renders loading skeleton while fetching', () => { + server.use( + http.get(`${API_URL}/api/v1/products`, async () => { + await new Promise(() => {}) + return HttpResponse.json(emptyPaged) + }), + ) + renderPage() + const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse') + expect(skeletons.length).toBeGreaterThan(0) + }) + + it('renders data when loaded', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)), + ) + renderPage() + await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument()) + }) + + it('shows error state on fetch failure', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => + HttpResponse.json({ error: 'server_error' }, { status: 500 }), + ), + ) + renderPage() + await waitFor(() => + expect(screen.getByText(/error al cargar productos/i)).toBeInTheDocument(), + ) + }) + + it('shows empty state when no products', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)), + ) + renderPage() + await waitFor(() => + expect(screen.getByText(/no hay productos/i)).toBeInTheDocument(), + ) + }) +}) + +// ─── Permission gating ─────────────────────────────────────────────────────── + +describe('ProductsPage — permission gating', () => { + it('shows "Nuevo Producto" button when user has permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)), + ) + renderPage(adminUser) + await waitFor(() => + expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument(), + ) + }) + + it('hides "Nuevo Producto" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)), + ) + renderPage(regularUser) + await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: /nuevo producto/i })).not.toBeInTheDocument() + }) +}) + +// ─── Create dialog ─────────────────────────────────────────────────────────── + +describe('ProductsPage — create dialog', () => { + it('opens create dialog when "Nuevo Producto" button is clicked', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /nuevo producto/i })) + await waitFor(() => + expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument(), + ) + }) +}) + +// ─── Deactivate dialog ─────────────────────────────────────────────────────── + +describe('ProductsPage — deactivate dialog', () => { + it('opens deactivate confirmation dialog when Desactivar is clicked', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument(), + ) + }) +}) diff --git a/src/web/src/tests/features/products/api.test.ts b/src/web/src/tests/features/products/api.test.ts new file mode 100644 index 0000000..65bd7bd --- /dev/null +++ b/src/web/src/tests/features/products/api.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { listProducts } from '../../../features/products/api/listProducts' +import { getProductById } from '../../../features/products/api/getProductById' +import { createProduct } from '../../../features/products/api/createProduct' +import { updateProduct } from '../../../features/products/api/updateProduct' +import { deactivateProduct } from '../../../features/products/api/deactivateProduct' +import type { ProductListItem, ProductDetail, PagedResult } from '../../../features/products/types' + +const API_URL = 'http://localhost:5000' + +const mockListItem: ProductListItem = { + id: 1, + nombre: 'Clasificado Estándar', + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 100.50, + priceDurationDays: null, + isActive: true, +} + +const mockDetail: ProductDetail = { + id: 1, + nombre: 'Clasificado Estándar', + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 100.50, + priceDurationDays: null, + isActive: true, + fechaCreacion: '2026-04-19T00:00:00Z', + fechaModificacion: null, +} + +const mockPaged: PagedResult = { + items: [mockListItem], + page: 1, + pageSize: 20, + total: 1, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('listProducts', () => { + it('calls GET /api/v1/products and returns paged result', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)), + ) + const result = await listProducts() + expect(result).toEqual(mockPaged) + }) + + it('passes query params when provided', async () => { + let capturedUrl = '' + server.use( + http.get(`${API_URL}/api/v1/products`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json(mockPaged) + }), + ) + await listProducts({ page: 2, pageSize: 10, activo: true, medioId: 5 }) + expect(capturedUrl).toContain('page=2') + expect(capturedUrl).toContain('pageSize=10') + expect(capturedUrl).toContain('medioId=5') + }) +}) + +describe('getProductById', () => { + it('calls GET /api/v1/products/:id and returns detail', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1`, () => HttpResponse.json(mockDetail)), + ) + const result = await getProductById(1) + expect(result).toEqual(mockDetail) + }) +}) + +describe('createProduct', () => { + it('calls POST /api/v1/admin/products with payload', async () => { + let capturedBody: unknown = null + server.use( + http.post(`${API_URL}/api/v1/admin/products`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockDetail, { status: 201 }) + }), + ) + const req = { + nombre: 'Clasificado Estándar', + medioId: 1, + productTypeId: 2, + basePrice: 100.50, + } + await createProduct(req) + expect(capturedBody).toEqual(req) + }) +}) + +describe('updateProduct', () => { + it('calls PUT /api/v1/admin/products/:id with payload', async () => { + let capturedBody: unknown = null + server.use( + http.put(`${API_URL}/api/v1/admin/products/1`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockDetail) + }), + ) + const req = { nombre: 'Modificado', basePrice: 200 } + await updateProduct(1, req) + expect(capturedBody).toEqual(req) + }) +}) + +describe('deactivateProduct', () => { + it('calls DELETE /api/v1/admin/products/:id', async () => { + let called = false + server.use( + http.delete(`${API_URL}/api/v1/admin/products/1`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + await deactivateProduct(1) + expect(called).toBe(true) + }) +}) diff --git a/src/web/src/tests/features/products/hooks.test.ts b/src/web/src/tests/features/products/hooks.test.ts new file mode 100644 index 0000000..8555c1a --- /dev/null +++ b/src/web/src/tests/features/products/hooks.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { useProducts } from '../../../features/products/hooks/useProducts' +import { useCreateProduct } from '../../../features/products/hooks/useCreateProduct' +import { useUpdateProduct } from '../../../features/products/hooks/useUpdateProduct' +import { useDeactivateProduct } from '../../../features/products/hooks/useDeactivateProduct' +import type { ProductListItem, PagedResult } from '../../../features/products/types' + +const API_URL = 'http://localhost:5000' + +const mockItem: ProductListItem = { + id: 1, + nombre: 'Clasificado', + medioId: 1, + productTypeId: 2, + rubroId: null, + basePrice: 50, + priceDurationDays: null, + isActive: true, +} + +const mockPaged: PagedResult = { + items: [mockItem], + page: 1, + pageSize: 20, + total: 1, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function makeWrapper() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) +} + +describe('useProducts', () => { + it('returns paged data on success', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)), + ) + const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(mockPaged) + }) + + it('returns error state on failure', async () => { + server.use( + http.get(`${API_URL}/api/v1/products`, () => + HttpResponse.json({ error: 'server_error' }, { status: 500 }), + ), + ) + const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +}) + +describe('useCreateProduct', () => { + it('calls create and invalidates products queries on success', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/products`, () => + HttpResponse.json(mockItem, { status: 201 }), + ), + ) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) + + const { result } = renderHook(() => useCreateProduct(), { wrapper }) + await act(async () => { + result.current.mutate({ + nombre: 'Nuevo Producto', + medioId: 1, + productTypeId: 2, + basePrice: 100, + }) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] }) + }) +}) + +describe('useUpdateProduct', () => { + it('calls update and invalidates products queries on success', async () => { + server.use( + http.put(`${API_URL}/api/v1/admin/products/1`, () => + HttpResponse.json(mockItem), + ), + ) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) + + const { result } = renderHook(() => useUpdateProduct(), { wrapper }) + await act(async () => { + result.current.mutate({ id: 1, data: { nombre: 'Modificado', basePrice: 200 } }) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] }) + }) +}) + +describe('useDeactivateProduct', () => { + it('calls deactivate and invalidates products queries on success', async () => { + server.use( + http.delete(`${API_URL}/api/v1/admin/products/1`, () => + new HttpResponse(null, { status: 204 }), + ), + ) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) + + const { result } = renderHook(() => useDeactivateProduct(), { wrapper }) + await act(async () => { + result.current.mutate(1) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] }) + }) +}) From d262454b2827f71ee817324cfb35b9a7f24c93da Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:31:38 -0300 Subject: [PATCH 09/14] fix(api): ExceptionFilter 409 para ProductTypeInactivo y RubroInactivo (PRD-002 W1) --- src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 4 +-- .../Products/ProductsControllerTests.cs | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index e5c8c73..decc84d 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -508,7 +508,7 @@ public sealed class ExceptionFilter : IExceptionFilter message = productTypeInactivoEx.Message }) { - StatusCode = StatusCodes.Status422UnprocessableEntity + StatusCode = StatusCodes.Status409Conflict }; context.ExceptionHandled = true; break; @@ -520,7 +520,7 @@ public sealed class ExceptionFilter : IExceptionFilter message = rubroInactivoEx.Message }) { - StatusCode = StatusCodes.Status422UnprocessableEntity + StatusCode = StatusCodes.Status409Conflict }; context.ExceptionHandled = true; break; diff --git a/tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs b/tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs index bfb795b..6b1f8ec 100644 --- a/tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs @@ -336,4 +336,32 @@ public sealed class ProductsControllerTests : IAsyncLifetime var resp = await _client.SendAsync(req); Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); } + + // ── PRD-002 W1: ExceptionFilter 409 for ProductTypeInactivo ────────────── + + [Fact] + public async Task Create_WithInactiveProductType_Returns409() + { + var token = await GetAdminTokenAsync(); + var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token); + + // Deactivate the ProductType first + using var deactivatePtReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token); + var deactivatePtResp = await _client.SendAsync(deactivatePtReq); + Assert.Equal(HttpStatusCode.NoContent, deactivatePtResp.StatusCode); + + // Now attempt to create a product with the inactive ProductType + using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = $"Prod_{Guid.NewGuid():N}"[..28], + medioId, + productTypeId, + basePrice = 50m + }, token); + var createResp = await _client.SendAsync(createReq); + + Assert.Equal(HttpStatusCode.Conflict, createResp.StatusCode); + var body = await createResp.Content.ReadFromJsonAsync(); + Assert.Equal("product_type_inactivo", body.GetProperty("error").GetString()); + } } From 2b79b6f7692906e1062d8b5d80249164f29ae4bf Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:33:53 -0300 Subject: [PATCH 10/14] feat(frontend): ProductForm reactivo a flags ProductType (PRD-002 W2) --- .../products/components/ProductForm.tsx | 106 ++++++---- .../products/components/ProductFormDialog.tsx | 6 + .../features/products/ProductForm.test.tsx | 193 ++++++++++++++++++ 3 files changed, 262 insertions(+), 43 deletions(-) create mode 100644 src/web/src/tests/features/products/ProductForm.test.tsx diff --git a/src/web/src/features/products/components/ProductForm.tsx b/src/web/src/features/products/components/ProductForm.tsx index df48dab..1c6679e 100644 --- a/src/web/src/features/products/components/ProductForm.tsx +++ b/src/web/src/features/products/components/ProductForm.tsx @@ -12,6 +12,7 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import type { ProductTypeListItem } from '@/features/product-types/types' // ─── Schema ─────────────────────────────────────────────────────────────────── @@ -76,6 +77,7 @@ export interface ProductFormDefaultValues { } interface ProductFormProps { + productTypes: ProductTypeListItem[] defaultValues?: ProductFormDefaultValues onSubmit: (values: ProductFormOutput) => void onCancel: () => void @@ -86,6 +88,7 @@ interface ProductFormProps { // ─── Component ──────────────────────────────────────────────────────────────── export function ProductForm({ + productTypes, defaultValues, onSubmit, onCancel, @@ -117,8 +120,21 @@ export function ProductForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultValues?.nombre, defaultValues?.medioId, defaultValues?.productTypeId]) + // Derive selected ProductType flags + const productTypeIdStr = form.watch('productTypeId') + const selectedProductTypeId = productTypeIdStr ? Number(productTypeIdStr) : null + const selectedProductType = productTypes.find((pt) => pt.id === selectedProductTypeId) ?? null + const requiresCategory = selectedProductType?.requiresCategory ?? false + const hasDuration = selectedProductType?.hasDuration ?? false + function handleSubmit(data: ProductFormOutput) { - onSubmit(data) + // Normalize conditional fields to null when not applicable + const normalized: ProductFormOutput = { + ...data, + rubroId: requiresCategory ? data.rubroId : null, + priceDurationDays: hasDuration ? data.priceDurationDays : null, + } + onSubmit(normalized) } return ( @@ -194,27 +210,29 @@ export function ProductForm({ )} /> - {/* Rubro ID (optional) */} - ( - - ID de Rubro (opcional) - - - - - - )} - /> + {/* Rubro ID — only shown when requiresCategory=true */} + {requiresCategory && ( + ( + + ID de Rubro + + + + + + )} + /> + )} {/* Base Price */} - {/* Price Duration Days (optional) */} - ( - - Días de duración del precio (opcional) - - - - - - )} - /> + {/* Price Duration Days — only shown when hasDuration=true */} + {hasDuration && ( + ( + + Días de duración del precio + + + + + + )} + /> + )}
+ {/* Filters */} +
+ +
+ {isEmpty ? (

No hay productos.

@@ -148,6 +180,33 @@ export function ProductsPage() {
)} + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Página {page} de {totalPages} + + +
+ )} + {/* Create dialog */} { ) }) }) + +// ─── Pagination ────────────────────────────────────────────────────────────── + +describe('ProductsPage — pagination', () => { + it('shows pagination controls when total > pageSize', async () => { + // 21 total items but only 1 in this page → totalPages=2 → controls visible + const pagedWith21Total: PagedResult = { + items: [mockItem], + page: 1, + pageSize: 20, + total: 21, + } + server.use( + http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(pagedWith21Total)), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument() + }) + + it('navigates to page 2 when Siguiente is clicked and sends page=2 to API', async () => { + const capturedRequests: URL[] = [] + + const pagedPage1: PagedResult = { + items: [mockItem], + page: 1, + pageSize: 20, + total: 21, + } + const pagedPage2: PagedResult = { + items: [{ ...mockItem, id: 2, nombre: 'Producto Página 2' }], + page: 2, + pageSize: 20, + total: 21, + } + + server.use( + http.get(`${API_URL}/api/v1/products`, ({ request }) => { + capturedRequests.push(new URL(request.url)) + const url = new URL(request.url) + const page = Number(url.searchParams.get('page') ?? '1') + return HttpResponse.json(page === 2 ? pagedPage2 : pagedPage1) + }), + ) + + renderPage(adminUser) + await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /siguiente/i })) + + await waitFor(() => { + expect(screen.getByText('Producto Página 2')).toBeInTheDocument() + }) + // Verify that at least one request was made with page=2 + const page2Requests = capturedRequests.filter((u) => u.searchParams.get('page') === '2') + expect(page2Requests.length).toBeGreaterThan(0) + }) +}) + +// ─── Filter by Medio ───────────────────────────────────────────────────────── + +describe('ProductsPage — filter by Medio', () => { + it('re-fetches with medioId when Medio filter is changed', async () => { + const capturedRequests: URL[] = [] + + const filteredPaged: PagedResult = { + items: [{ ...mockItem, medioId: 5, nombre: 'Producto Medio 5' }], + page: 1, + pageSize: 20, + total: 1, + } + + server.use( + http.get(`${API_URL}/api/v1/products`, ({ request }) => { + capturedRequests.push(new URL(request.url)) + const url = new URL(request.url) + const medioId = url.searchParams.get('medioId') + return HttpResponse.json(medioId === '5' ? filteredPaged : emptyPaged) + }), + ) + + renderPage(adminUser) + // Wait for initial empty state + await waitFor(() => expect(screen.getByText(/no hay productos/i)).toBeInTheDocument()) + + const filterInput = screen.getByLabelText(/filtrar por id de medio/i) + await userEvent.type(filterInput, '5') + + await waitFor(() => { + expect(screen.getByText('Producto Medio 5')).toBeInTheDocument() + }) + const filteredRequests = capturedRequests.filter((u) => u.searchParams.get('medioId') === '5') + expect(filteredRequests.length).toBeGreaterThan(0) + }) +}) From b4f17d6961cf3dabc5cb8f488929fcde46e22fd7 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:37:10 -0300 Subject: [PATCH 13/14] refactor: eliminar NullProductQueryRepository dead code + EXISTS en ProductQueryRepository (PRD-002 S1 S2) --- .../Products/NullProductQueryRepository.cs | 14 ---------- .../Persistence/ProductQueryRepository.cs | 15 ++++++----- .../NullProductQueryRepositoryTests.cs | 26 ------------------- 3 files changed, 9 insertions(+), 46 deletions(-) delete mode 100644 src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs delete mode 100644 tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs diff --git a/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs b/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs deleted file mode 100644 index 0f9b509..0000000 --- a/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SIGCM2.Application.Abstractions.Persistence; - -namespace SIGCM2.Application.Products; - -/// -/// STUB — PRD-002 replaces the DI binding with a real Dapper impl against dbo.Product. -/// Returns false for all queries so DeactivateProductTypeCommandHandler guard always passes. -/// This is intentional for PRD-001: the mechanism is installed; the data feed arrives in PRD-002. -/// -public sealed class NullProductQueryRepository : IProductQueryRepository -{ - public Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) - => Task.FromResult(false); -} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs index 24fc3b6..4ee619b 100644 --- a/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs +++ b/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs @@ -19,16 +19,19 @@ public sealed class ProductQueryRepository : IProductQueryRepository public async Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) { const string sql = """ - SELECT COUNT(1) - FROM dbo.Product - WHERE ProductTypeId = @ProductTypeId - AND IsActive = 1 + SELECT CASE + WHEN EXISTS ( + SELECT 1 FROM dbo.Product + WHERE ProductTypeId = @ProductTypeId + AND IsActive = 1 + ) THEN 1 ELSE 0 + END """; await using var connection = _factory.CreateConnection(); await connection.OpenAsync(ct); - var count = await connection.ExecuteScalarAsync(sql, new { ProductTypeId = productTypeId }); - return count > 0; + var result = await connection.ExecuteScalarAsync(sql, new { ProductTypeId = productTypeId }); + return result == 1; } } diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs deleted file mode 100644 index 175b4cc..0000000 --- a/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FluentAssertions; -using SIGCM2.Application.Products; - -namespace SIGCM2.Application.Tests.ProductTypes; - -public class NullProductQueryRepositoryTests -{ - private readonly NullProductQueryRepository _sut = new(); - - [Fact] - public async Task ExistsActiveByProductTypeAsync_AlwaysReturnsFalse() - { - var result = await _sut.ExistsActiveByProductTypeAsync(productTypeId: 1); - - result.Should().BeFalse(); - } - - [Fact] - public async Task ExistsActiveByProductTypeAsync_WithCancellationToken_DoesNotThrow() - { - using var cts = new CancellationTokenSource(); - var act = async () => await _sut.ExistsActiveByProductTypeAsync(productTypeId: 999, ct: cts.Token); - - await act.Should().NotThrowAsync(); - } -} From d7fb3105fae7cd20fbe2c93dc9f4855276410aa2 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:46:11 -0300 Subject: [PATCH 14/14] feat(bd): V018 crea dbo.Product + SqlTestFixture consolida V018 + permisos catalogo (PRD-002 W6) --- database/migrations/V018_ROLLBACK.sql | 67 ++++++++ database/migrations/V018__create_product.sql | 172 +++++++++++++++++++ tests/SIGCM2.TestSupport/SqlTestFixture.cs | 113 +++++++++++- 3 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 database/migrations/V018_ROLLBACK.sql create mode 100644 database/migrations/V018__create_product.sql diff --git a/database/migrations/V018_ROLLBACK.sql b/database/migrations/V018_ROLLBACK.sql new file mode 100644 index 0000000..562e157 --- /dev/null +++ b/database/migrations/V018_ROLLBACK.sql @@ -0,0 +1,67 @@ +-- V018_ROLLBACK.sql +-- Reversa de V018__create_product.sql — PRD-002. +-- +-- Idempotente: cada paso usa IF EXISTS guards. +-- ADVERTENCIA: Ejecutar antes de V017_ROLLBACK (FK desde Product hacia ProductType). + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- 1. SYSTEM_VERSIONING OFF +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Product SET (SYSTEM_VERSIONING = OFF); + PRINT 'Product: SYSTEM_VERSIONING = OFF.'; +END +GO + +-- 2. DROP PERIOD +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Product')) +BEGIN + ALTER TABLE dbo.Product DROP PERIOD FOR SYSTEM_TIME; + PRINT 'Product: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +-- 3. Drop HIDDEN columns + default constraints +IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NOT NULL +BEGIN + ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidFrom; + ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidTo; + ALTER TABLE dbo.Product DROP COLUMN ValidFrom, ValidTo; + PRINT 'Product: ValidFrom/ValidTo columns dropped.'; +END +GO + +-- 4. Drop history +IF OBJECT_ID(N'dbo.Product_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Product_History; + PRINT 'Table dbo.Product_History dropped.'; +END +GO + +-- 5. Drop main +IF OBJECT_ID(N'dbo.Product', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Product; + PRINT 'Table dbo.Product dropped.'; +END +GO + +-- 6. Remove RolPermiso / Permiso +DELETE rp FROM dbo.RolPermiso rp + JOIN dbo.Permiso p ON p.Id = rp.PermisoId + WHERE p.Codigo = 'catalogo:productos:gestionar'; +PRINT 'RolPermiso rows for catalogo:productos:gestionar deleted.'; +GO + +DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:productos:gestionar'; +PRINT 'Permiso catalogo:productos:gestionar deleted.'; +GO + +PRINT ''; +PRINT 'V018 rolled back successfully.'; +GO diff --git a/database/migrations/V018__create_product.sql b/database/migrations/V018__create_product.sql new file mode 100644 index 0000000..08bbfb7 --- /dev/null +++ b/database/migrations/V018__create_product.sql @@ -0,0 +1,172 @@ +-- V018__create_product.sql +-- PRD-002: Product — entidad vendible concreta del catálogo comercial. +-- +-- Cambios: +-- 1. dbo.Product (FK Medio/ProductType/Rubro, SYSTEM_VERSIONING ON, retention 10 años). +-- 2. Índices: filtered UQ por (MedioId, ProductTypeId, Nombre) activos; cover por ProductTypeId +-- (para IProductQueryRepository); cover por MedioId; cover filtrado por RubroId. +-- 3. Permiso 'catalogo:productos:gestionar' + asignación a rol 'admin'. +-- +-- Patrón: V017 (dbo.ProductType con SYSTEM_VERSIONING + PAGE compression + MERGE permisos). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V018_ROLLBACK.sql. +-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). +-- +-- Notas: +-- - SIN seed de datos — PRD-008 (V019) seedea los 12 productos legacy. +-- - Validación de flags (RequiresCategory, HasDuration) vive en Application layer: +-- un ProductType puede cambiar flags; la Product queda en estado snapshot. +-- - UQ filtered WHERE IsActive=1: permite reusar nombres tras soft-delete. +-- +-- SDD Design: engram sdd/prd-002-product-crud/design + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. dbo.Product +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.Product', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Product ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY, + Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL, + MedioId INT NOT NULL, + ProductTypeId INT NOT NULL, + RubroId INT NULL, + BasePrice DECIMAL(18,4) NOT NULL, + PriceDurationDays INT NULL, + IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION, + CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION, + CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0), + CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1) + ); + PRINT 'Table dbo.Product created.'; +END +ELSE + PRINT 'Table dbo.Product already exists — skip.'; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. SYSTEM_VERSIONING — Product +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.Product + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'Product: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Product + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Product_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'Product: SYSTEM_VERSIONING = ON (history: dbo.Product_History, retention: 10 years).'; +END +ELSE + PRINT 'Product: SYSTEM_VERSIONING already ON — skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Product_History' AND schema_id = SCHEMA_ID('dbo')) + AND NOT EXISTS ( + SELECT 1 FROM sys.partitions p + JOIN sys.tables t ON t.object_id = p.object_id + WHERE t.name = 'Product_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.Product_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'Product_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. Índices +-- ═══════════════════════════════════════════════════════════════════════ + +-- Filtered UQ: unicidad activa por (Medio, Tipo, Nombre). Permite reusar nombres tras soft-delete. +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product')) +BEGIN + CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active + ON dbo.Product (MedioId, ProductTypeId, Nombre) + WHERE IsActive = 1; + PRINT 'Index UQ_Product_MedioId_ProductTypeId_Nombre_Active created.'; +END +GO + +-- Cover para IProductQueryRepository.ExistsActiveByProductTypeAsync +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) +BEGIN + CREATE INDEX IX_Product_ProductTypeId_IsActive + ON dbo.Product (ProductTypeId, IsActive); + PRINT 'Index IX_Product_ProductTypeId_IsActive created.'; +END +GO + +-- Cover para list filtered by MedioId +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) +BEGIN + CREATE INDEX IX_Product_MedioId_IsActive + ON dbo.Product (MedioId, IsActive); + PRINT 'Index IX_Product_MedioId_IsActive created.'; +END +GO + +-- Cover para list filtered by RubroId +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) +BEGIN + CREATE INDEX IX_Product_RubroId_IsActive + ON dbo.Product (RubroId, IsActive) + WHERE RubroId IS NOT NULL; + PRINT 'Index IX_Product_RubroId_IsActive created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. Permiso: catalogo:productos:gestionar + asignación a rol 'admin' +-- ═══════════════════════════════════════════════════════════════════════ + +MERGE dbo.Permiso AS t +USING (VALUES + ('catalogo:productos:gestionar', + N'Gestionar productos del catálogo', + N'Crear, editar y desactivar productos del catálogo comercial', + 'catalogo') +) AS s (Codigo, Nombre, Descripcion, Modulo) +ON t.Codigo = s.Codigo +WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Modulo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo); +GO + +MERGE dbo.RolPermiso AS t +USING ( + SELECT r.Id AS RolId, p.Id AS PermisoId + FROM (VALUES ('admin', 'catalogo:productos:gestionar')) AS x (RolCodigo, PermisoCodigo) + JOIN dbo.Rol r ON r.Codigo = x.RolCodigo + JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo +) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId +WHEN NOT MATCHED BY TARGET THEN + INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId); +GO + +PRINT ''; +PRINT 'V018 applied — dbo.Product (temporal, retention 10y) + permiso catalogo:productos:gestionar.'; +PRINT 'Next: V019 (PRD-008 — seed 12 productos legacy).'; +GO diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index f2737da..821813a 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -63,6 +63,9 @@ public sealed class SqlTestFixture : IAsyncLifetime // V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'. await EnsureV017SchemaAsync(); + // V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'. + await EnsureV018SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -91,6 +94,8 @@ public sealed class SqlTestFixture : IAsyncLifetime 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"), + // PRD-002 (V018): Product es temporal — history no puede deletearse directo. + new Respawn.Graph.Table("dbo", "Product_History"), ] }); @@ -213,7 +218,11 @@ public sealed class SqlTestFixture : IAsyncLifetime -- V014 (ADM-009): permiso para tablas fiscales ('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'), -- V016 (CAT-001): permiso para gestionar árbol de rubros - ('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo') + ('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo'), + -- V017 (PRD-001): permiso para gestionar tipos de producto + ('catalogo:tipos:gestionar', N'Gestionar tipos de producto', N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)', 'catalogo'), + -- V018 (PRD-002): permiso para gestionar productos del catálogo + ('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo') ) AS s (Codigo, Nombre, Descripcion, Modulo) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN @@ -261,6 +270,10 @@ public sealed class SqlTestFixture : IAsyncLifetime ('admin', 'administracion:fiscal:gestionar'), -- V016 (CAT-001) ('admin', 'catalogo:rubros:gestionar'), + -- V017 (PRD-001) + ('admin', 'catalogo:tipos:gestionar'), + -- V018 (PRD-002) + ('admin', 'catalogo:productos:gestionar'), ('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:cobrar'), @@ -1011,4 +1024,102 @@ public sealed class SqlTestFixture : IAsyncLifetime await _connection.ExecuteAsync(createUqIndex); await _connection.ExecuteAsync(createCoveringIndex); } + + /// + /// PRD-002 (V018): applies dbo.Product schema + temporal + filtered UQ + covering indexes + /// idempotentemente. Mirrors V018__create_product.sql. + /// Permiso 'catalogo:productos:gestionar' y asignación a admin se siembran + /// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). + /// + public async Task EnsureV018SchemaAsync() + { + const string createProduct = """ + IF OBJECT_ID(N'dbo.Product', N'U') IS NULL + BEGIN + CREATE TABLE dbo.Product ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY, + Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL, + MedioId INT NOT NULL, + ProductTypeId INT NOT NULL, + RubroId INT NULL, + BasePrice DECIMAL(18,4) NOT NULL, + PriceDurationDays INT NULL, + IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION, + CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION, + CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0), + CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1) + ); + END + """; + + const string addProductPeriod = """ + IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL + BEGIN + ALTER TABLE dbo.Product + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + END + """; + + const string setProductVersioning = """ + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2) + BEGIN + ALTER TABLE dbo.Product + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Product_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + END + """; + + const string createUqIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product')) + BEGIN + CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active + ON dbo.Product (MedioId, ProductTypeId, Nombre) + WHERE IsActive = 1; + END + """; + + const string createProductTypeIdx = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) + BEGIN + CREATE INDEX IX_Product_ProductTypeId_IsActive + ON dbo.Product (ProductTypeId, IsActive); + END + """; + + const string createMedioIdx = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) + BEGIN + CREATE INDEX IX_Product_MedioId_IsActive + ON dbo.Product (MedioId, IsActive); + END + """; + + const string createRubroIdx = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product')) + BEGIN + CREATE INDEX IX_Product_RubroId_IsActive + ON dbo.Product (RubroId, IsActive) + WHERE RubroId IS NOT NULL; + END + """; + + await _connection.ExecuteAsync(createProduct); + await _connection.ExecuteAsync(addProductPeriod); + await _connection.ExecuteAsync(setProductVersioning); + await _connection.ExecuteAsync(createUqIndex); + await _connection.ExecuteAsync(createProductTypeIdx); + await _connection.ExecuteAsync(createMedioIdx); + await _connection.ExecuteAsync(createRubroIdx); + } }