219 lines
8.1 KiB
C#
219 lines
8.1 KiB
C#
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"));
|
|
}
|
|
}
|