From 54b0265994770cdbc691b405aa10d26eb6d32af9 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 17:59:43 -0300 Subject: [PATCH] feat(domain): ProductPrice entity + exceptions (PRD-003) --- .../SIGCM2.Domain/Entities/ProductPrice.cs | 25 +++ .../ProductPriceForwardOnlyException.cs | 23 +++ .../ProductPriceInvalidException.cs | 19 +++ .../ProductSinPrecioActivoException.cs | 18 ++ .../Domain/ProductPriceExceptionTests.cs | 128 ++++++++++++++ .../Domain/ProductPriceTests.cs | 158 ++++++++++++++++++ 6 files changed, 371 insertions(+) create mode 100644 src/api/SIGCM2.Domain/Entities/ProductPrice.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/ProductPriceForwardOnlyException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/ProductPriceInvalidException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/ProductSinPrecioActivoException.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/ProductPriceExceptionTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs diff --git a/src/api/SIGCM2.Domain/Entities/ProductPrice.cs b/src/api/SIGCM2.Domain/Entities/ProductPrice.cs new file mode 100644 index 0000000..47fdacf --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/ProductPrice.cs @@ -0,0 +1,25 @@ +namespace SIGCM2.Domain.Entities; + +/// +/// PRD-003 — Immutable record representing a price snapshot for a Product. +/// Business dates are Cat2 (civil Argentina dates, DateOnly). Forward-only — no mutations. +/// PriceValidTo = null means the row is the currently active price. +/// +public sealed record ProductPrice( + long Id, + int ProductId, + decimal Price, + DateOnly PriceValidFrom, + DateOnly? PriceValidTo, + DateTime FechaCreacion) +{ + /// True if this row is the currently active price (PriceValidTo is null). + public bool IsActive => PriceValidTo is null; + + /// + /// True if this price's window covers the given civil date (inclusive on both ends). + /// An active row (PriceValidTo = null) covers any date on or after PriceValidFrom. + /// + public bool CoversDate(DateOnly date) + => PriceValidFrom <= date && (PriceValidTo is null || PriceValidTo >= date); +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductPriceForwardOnlyException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductPriceForwardOnlyException.cs new file mode 100644 index 0000000..5a5819b --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductPriceForwardOnlyException.cs @@ -0,0 +1,23 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to add a ProductPrice with a PriceValidFrom that is not strictly +/// greater than the currently active price's PriceValidFrom. → HTTP 409 +/// +public sealed class ProductPriceForwardOnlyException : DomainException +{ + public int ProductId { get; } + public DateOnly NewPriceValidFrom { get; } + public DateOnly ActivePriceValidFrom { get; } + + public ProductPriceForwardOnlyException( + int productId, + DateOnly newPriceValidFrom, + DateOnly activePriceValidFrom) + : base($"El nuevo PriceValidFrom ({newPriceValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al PriceValidFrom del precio activo ({activePriceValidFrom:yyyy-MM-dd}).") + { + ProductId = productId; + NewPriceValidFrom = newPriceValidFrom; + ActivePriceValidFrom = activePriceValidFrom; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductPriceInvalidException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductPriceInvalidException.cs new file mode 100644 index 0000000..6a2447e --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductPriceInvalidException.cs @@ -0,0 +1,19 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a ProductPrice value fails domain business validation +/// (e.g., Price <= 0, PriceValidFrom in the past). → HTTP 400 +/// Used as defense-in-depth alongside FluentValidation in the Application layer. +/// +public sealed class ProductPriceInvalidException : DomainException +{ + public string Field { get; } + public string Reason { get; } + + public ProductPriceInvalidException(string field, string reason) + : base($"Valor inválido para {field}: {reason}") + { + Field = field; + Reason = reason; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductSinPrecioActivoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductSinPrecioActivoException.cs new file mode 100644 index 0000000..490f040 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductSinPrecioActivoException.cs @@ -0,0 +1,18 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when no ProductPrice row covers the requested date for the given product. → HTTP 404 +/// Consumers of IProductPricingService may throw this when GetPriceAtAsync returns null. +/// +public sealed class ProductSinPrecioActivoException : DomainException +{ + public int ProductId { get; } + public DateOnly Date { get; } + + public ProductSinPrecioActivoException(int productId, DateOnly date) + : base($"No existe precio registrado para el producto {productId} aplicable a la fecha {date:yyyy-MM-dd}.") + { + ProductId = productId; + Date = date; + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductPriceExceptionTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductPriceExceptionTests.cs new file mode 100644 index 0000000..4f54e1d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/ProductPriceExceptionTests.cs @@ -0,0 +1,128 @@ +using FluentAssertions; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Domain; + +/// +/// PRD-003 — Domain unit tests for ProductPrice-related exceptions. +/// Verifies constructor props, message content, and DomainException inheritance. +/// +public class ProductPriceExceptionTests +{ + // ── ProductPriceForwardOnlyException ───────────────────────────────────── + + [Fact] + public void ProductPriceForwardOnlyException_SetsProperties() + { + var newPvf = new DateOnly(2026, 3, 1); + var activePvf = new DateOnly(2026, 4, 1); + + var ex = new ProductPriceForwardOnlyException(productId: 42, newPvf, activePvf); + + ex.ProductId.Should().Be(42); + ex.NewPriceValidFrom.Should().Be(newPvf); + ex.ActivePriceValidFrom.Should().Be(activePvf); + } + + [Fact] + public void ProductPriceForwardOnlyException_MessageContainsKeyDates() + { + var newPvf = new DateOnly(2026, 3, 1); + var activePvf = new DateOnly(2026, 4, 1); + + var ex = new ProductPriceForwardOnlyException(productId: 5, newPvf, activePvf); + + ex.Message.Should().Contain("2026-03-01"); + ex.Message.Should().Contain("2026-04-01"); + } + + [Fact] + public void ProductPriceForwardOnlyException_InheritsFromDomainException() + { + var ex = new ProductPriceForwardOnlyException( + productId: 1, + newPriceValidFrom: new DateOnly(2026, 1, 1), + activePriceValidFrom: new DateOnly(2026, 2, 1)); + + ex.Should().BeAssignableTo(); + } + + // ── ProductPriceInvalidException ───────────────────────────────────────── + + [Fact] + public void ProductPriceInvalidException_SetsProperties() + { + var ex = new ProductPriceInvalidException(field: "Price", reason: "must be > 0"); + + ex.Field.Should().Be("Price"); + ex.Reason.Should().Be("must be > 0"); + } + + [Fact] + public void ProductPriceInvalidException_MessageContainsFieldAndReason() + { + var ex = new ProductPriceInvalidException(field: "Price", reason: "must be > 0"); + + ex.Message.Should().Contain("Price"); + ex.Message.Should().Contain("must be > 0"); + } + + [Fact] + public void ProductPriceInvalidException_InheritsFromDomainException() + { + var ex = new ProductPriceInvalidException("Price", "invalid"); + + ex.Should().BeAssignableTo(); + } + + // ── ProductSinPrecioActivoException ────────────────────────────────────── + + [Fact] + public void ProductSinPrecioActivoException_SetsProperties() + { + var date = new DateOnly(2026, 4, 19); + + var ex = new ProductSinPrecioActivoException(productId: 99, date); + + ex.ProductId.Should().Be(99); + ex.Date.Should().Be(date); + } + + [Fact] + public void ProductSinPrecioActivoException_MessageContainsProductIdAndDate() + { + var date = new DateOnly(2026, 4, 19); + + var ex = new ProductSinPrecioActivoException(productId: 99, date); + + ex.Message.Should().Contain("99"); + ex.Message.Should().Contain("2026-04-19"); + } + + [Fact] + public void ProductSinPrecioActivoException_InheritsFromDomainException() + { + var ex = new ProductSinPrecioActivoException(1, new DateOnly(2026, 4, 1)); + + ex.Should().BeAssignableTo(); + } + + // ── ProductNotFoundException — already exists, verify it works ──────────── + + [Fact] + public void ProductNotFoundException_SetsProductIdAndMessage() + { + var ex = new ProductNotFoundException(77); + + ex.ProductId.Should().Be(77); + ex.Message.Should().Contain("77"); + } + + [Fact] + public void ProductNotFoundException_InheritsFromDomainException() + { + var ex = new ProductNotFoundException(1); + + ex.Should().BeAssignableTo(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs new file mode 100644 index 0000000..aa03dbc --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Domain; + +/// +/// PRD-003 — Domain unit tests for ProductPrice record. +/// Covers: §REQ-4.1 (IsActive), §REQ-4.2 (CoversDate inclusive bounds), +/// §REQ-4.4 (active row covers any date on or after PriceValidFrom). +/// +public class ProductPriceTests +{ + private static ProductPrice MakePrice( + long id = 1, + int productId = 10, + decimal price = 1500.00m, + DateOnly? from = null, + DateOnly? to = null, + DateTime? fechaCreacion = null) + => new( + Id: id, + ProductId: productId, + Price: price, + PriceValidFrom: from ?? new DateOnly(2026, 4, 1), + PriceValidTo: to, + FechaCreacion: fechaCreacion ?? new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + + // ── §REQ-4.1: IsActive ──────────────────────────────────────────────────── + + [Fact] + public void IsActive_TrueWhenPriceValidToIsNull() + { + var price = MakePrice(to: null); + + price.IsActive.Should().BeTrue(); + } + + [Fact] + public void IsActive_FalseWhenPriceValidToHasValue() + { + var price = MakePrice(to: new DateOnly(2026, 4, 30)); + + price.IsActive.Should().BeFalse(); + } + + // ── §REQ-4.2: CoversDate — inclusive lower bound ────────────────────────── + + [Fact] + public void CoversDate_InclusiveLowerBound_ReturnsTrue() + { + // Row: 2026-04-01 → 2026-04-30. Query date = PriceValidFrom. + var price = MakePrice(from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 30)); + + price.CoversDate(new DateOnly(2026, 4, 1)).Should().BeTrue(); + } + + // ── §REQ-4.2: CoversDate — inclusive upper bound ────────────────────────── + + [Fact] + public void CoversDate_InclusiveUpperBound_ReturnsTrue() + { + // Row: 2026-04-01 → 2026-04-30. Query date = PriceValidTo. + var price = MakePrice(from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 30)); + + price.CoversDate(new DateOnly(2026, 4, 30)).Should().BeTrue(); + } + + // ── §REQ-4.2: CoversDate — exclusive outside range ──────────────────────── + + [Fact] + public void CoversDate_DateBeforePriceValidFrom_ReturnsFalse() + { + var price = MakePrice(from: new DateOnly(2026, 4, 10), to: new DateOnly(2026, 4, 20)); + + price.CoversDate(new DateOnly(2026, 4, 9)).Should().BeFalse(); + } + + [Fact] + public void CoversDate_DateAfterPriceValidTo_ReturnsFalse() + { + var price = MakePrice(from: new DateOnly(2026, 4, 10), to: new DateOnly(2026, 4, 20)); + + price.CoversDate(new DateOnly(2026, 4, 21)).Should().BeFalse(); + } + + // ── §REQ-4.4: Active row (PriceValidTo = null) covers any date on or after PriceValidFrom ── + + [Fact] + public void ActiveRow_CoversAnyDateOnOrAfterPvf() + { + // Active row: PriceValidFrom = 2026-04-01, PriceValidTo = null + var price = MakePrice(from: new DateOnly(2026, 4, 1), to: null); + + // On PriceValidFrom itself + price.CoversDate(new DateOnly(2026, 4, 1)).Should().BeTrue(); + // Well in the future + price.CoversDate(new DateOnly(2030, 12, 31)).Should().BeTrue(); + } + + [Fact] + public void ActiveRow_DoesNotCoverDateBeforePvf() + { + var price = MakePrice(from: new DateOnly(2026, 4, 1), to: null); + + price.CoversDate(new DateOnly(2026, 3, 31)).Should().BeFalse(); + } + + // ── Record value equality (records compare by value) ───────────────────── + + [Fact] + public void Equality_TwoIdenticalRecords_AreEqual() + { + var a = MakePrice(id: 1, productId: 10, price: 1500m, + from: new DateOnly(2026, 4, 1), to: null, + fechaCreacion: new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + + var b = MakePrice(id: 1, productId: 10, price: 1500m, + from: new DateOnly(2026, 4, 1), to: null, + fechaCreacion: new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + + a.Should().Be(b); + (a == b).Should().BeTrue(); + } + + [Fact] + public void Equality_DifferentId_AreNotEqual() + { + var a = MakePrice(id: 1); + var b = MakePrice(id: 2); + + a.Should().NotBe(b); + } + + // ── Immutability: record positional props are init-only (no regular set) ─ + + [Fact] + public void Record_PropertiesAreInitOnly_NotMutableSet() + { + // C# records expose init-only accessors, not regular set. + // IsExternalInit attribute on the setter's return type distinguishes init from set. + var type = typeof(ProductPrice); + var mutableSetters = type.GetProperties() + .Where(p => + { + var setter = p.GetSetMethod(nonPublic: false); + if (setter is null) return false; + // init-only setters have a modreq on IsExternalInit in their signature; + // a simple way to detect them: they are NOT init-only when + // ReturnParameter.GetRequiredCustomModifiers() does NOT contain IsExternalInit. + var modifiers = setter.ReturnParameter.GetRequiredCustomModifiers(); + bool isInitOnly = modifiers.Any(m => m.Name == "IsExternalInit"); + return !isInitOnly; // only flag non-init setters as mutable + }) + .ToList(); + + mutableSetters.Should().BeEmpty("ProductPrice must be immutable — all setters should be init-only."); + } +}