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); + } +}