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