feat(domain): Product entity + 5 domain exceptions (PRD-002)

This commit is contained in:
2026-04-19 12:59:58 -03:00
parent 0462970ea1
commit 16197cf242
8 changed files with 530 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>Full hydration constructor — used by the repository to reconstruct from DB rows.</summary>
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;
}
/// <summary>
/// Factory for a new Product. Id=0 — DB assigns via IDENTITY.
/// IsActive=true, FechaModificacion=null.
/// </summary>
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);
}
/// <summary>
/// Combo mutator: renames, updates price and category in one call.
/// Used by UpdateProductCommandHandler.
/// </summary>
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));
}
}

View File

@@ -0,0 +1,19 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a Product with the same Nombre already exists for a given MedioId+ProductTypeId. → HTTP 409
/// </summary>
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;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested Product does not exist. → HTTP 404
/// </summary>
public sealed class ProductNotFoundException : DomainException
{
public int ProductId { get; }
public ProductNotFoundException(int id)
: base($"El producto con id={id} no existe.")
{
ProductId = id;
}
}

View File

@@ -0,0 +1,16 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// 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
/// </summary>
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;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create/update a Product referencing an inactive ProductType. → HTTP 422
/// </summary>
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;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create/update a Product referencing an inactive Rubro. → HTTP 422
/// </summary>
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;
}
}

View File

@@ -0,0 +1,218 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Domain;
/// <summary>
/// PRD-002 — Domain entity tests for Product.
/// Validates factory method, mutation methods, and validation rules.
/// </summary>
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<ArgumentException>()
.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<ArgumentException>();
}
// ── R1-S4: BasePrice negativo ──────────────────────────────────────────────
[Fact]
public void ForCreation_NegativeBasePrice_ThrowsArgumentException()
{
var act = () => Product.ForCreation("Test", 1, 2, null, -1m, null, _time);
act.Should().Throw<ArgumentException>()
.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<ArgumentException>()
.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<ArgumentException>();
}
// ── 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"));
}
}

View File

@@ -0,0 +1,60 @@
using FluentAssertions;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Products.Exceptions;
/// <summary>
/// PRD-002 — Tests for new Product domain exceptions.
/// Verifies message content and property values.
/// </summary>
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);
}
}