From 8b555e1f8bf2b3e11f447a599197f9fe8a5d9a06 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:02:42 -0300 Subject: [PATCH] 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); +}