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