feat: PRD-002 Product CRUD #40
@@ -0,0 +1,29 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write-side repository for Product.
|
||||||
|
/// All reads needed by write handlers are included here.
|
||||||
|
/// </summary>
|
||||||
|
public interface IProductRepository
|
||||||
|
{
|
||||||
|
/// <summary>Inserts a new Product and returns the DB-assigned Id.</summary>
|
||||||
|
Task<int> AddAsync(Product product, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns the Product with the given Id, or null if not found.</summary>
|
||||||
|
Task<Product?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns a paged result of Products matching the query.</summary>
|
||||||
|
Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Persists all changes to an existing Product row.</summary>
|
||||||
|
Task UpdateAsync(Product product, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> ExistsByNombreAsync(string nombre, int medioId, int productTypeId, int? excludeId = null, CancellationToken ct = default);
|
||||||
|
}
|
||||||
13
src/api/SIGCM2.Application/Common/ProductsQuery.cs
Normal file
13
src/api/SIGCM2.Application/Common/ProductsQuery.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query parameters for listing Products (used by IProductRepository.GetPagedAsync).
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Products.Create;
|
||||||
|
|
||||||
|
public sealed class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
|
||||||
|
{
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateProductCommand(int Id);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Deactivate;
|
||||||
|
|
||||||
|
public sealed record ProductStatusDto(
|
||||||
|
int Id,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Products.GetById;
|
||||||
|
|
||||||
|
public sealed record GetProductByIdQuery(int Id);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Update;
|
||||||
|
|
||||||
|
public sealed record UpdateProductCommand(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
int? RubroId,
|
||||||
|
decimal BasePrice,
|
||||||
|
int? PriceDurationDays);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Products.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateProductCommandValidator : AbstractValidator<UpdateProductCommand>
|
||||||
|
{
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using FluentValidation.TestHelper;
|
||||||
|
using SIGCM2.Application.Products.Create;
|
||||||
|
using SIGCM2.Application.Products.Update;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Products.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-002 — Validator tests for CreateProductCommand and UpdateProductCommand.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user