feat: PRD-003 ProductPrices históricos (ValidFrom/ValidTo) #45
25
src/api/SIGCM2.Domain/Entities/ProductPrice.cs
Normal file
25
src/api/SIGCM2.Domain/Entities/ProductPrice.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace SIGCM2.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed record ProductPrice(
|
||||
long Id,
|
||||
int ProductId,
|
||||
decimal Price,
|
||||
DateOnly PriceValidFrom,
|
||||
DateOnly? PriceValidTo,
|
||||
DateTime FechaCreacion)
|
||||
{
|
||||
/// <summary>True if this row is the currently active price (PriceValidTo is null).</summary>
|
||||
public bool IsActive => PriceValidTo is null;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool CoversDate(DateOnly date)
|
||||
=> PriceValidFrom <= date && (PriceValidTo is null || PriceValidTo >= date);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when attempting to add a ProductPrice with a PriceValidFrom that is not strictly
|
||||
/// greater than the currently active price's PriceValidFrom. → HTTP 409
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using FluentAssertions;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — Domain unit tests for ProductPrice-related exceptions.
|
||||
/// Verifies constructor props, message content, and DomainException inheritance.
|
||||
/// </summary>
|
||||
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<DomainException>();
|
||||
}
|
||||
|
||||
// ── 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<DomainException>();
|
||||
}
|
||||
|
||||
// ── 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<DomainException>();
|
||||
}
|
||||
|
||||
// ── 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<DomainException>();
|
||||
}
|
||||
}
|
||||
158
tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs
Normal file
158
tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using FluentAssertions;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user