234 lines
8.7 KiB
C#
234 lines
8.7 KiB
C#
|
|
using FluentAssertions;
|
||
|
|
using Microsoft.Extensions.Time.Testing;
|
||
|
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||
|
|
using SIGCM2.Domain.Pricing.Exceptions;
|
||
|
|
|
||
|
|
namespace SIGCM2.Application.Tests.Domain.Pricing.ChargeableChars;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// PRC-001 — T2.3 Domain unit tests for ChargeableCharConfig entity.
|
||
|
|
/// Covers: factory invariants, ScheduleNewPrice forward-only, Deactivate idempotency.
|
||
|
|
/// </summary>
|
||
|
|
public sealed class ChargeableCharConfigTests
|
||
|
|
{
|
||
|
|
// Fixed "today" for Argentina: 2026-04-20
|
||
|
|
private static FakeTimeProvider MakeFakeTimeProvider(DateOnly argentinaToday)
|
||
|
|
{
|
||
|
|
var fp = new FakeTimeProvider();
|
||
|
|
// Argentina is UTC-3. Set UTC to 12:00 of that day → Argentina 09:00 = same day.
|
||
|
|
fp.SetUtcNow(new DateTimeOffset(
|
||
|
|
argentinaToday.Year, argentinaToday.Month, argentinaToday.Day,
|
||
|
|
12, 0, 0, TimeSpan.Zero));
|
||
|
|
return fp;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static DateOnly Today => new DateOnly(2026, 4, 20);
|
||
|
|
private static FakeTimeProvider FakeToday => MakeFakeTimeProvider(Today);
|
||
|
|
|
||
|
|
// ── Create factory — invariant violations ─────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Create_ThrowsInvalid_WhenSymbolIsEmpty()
|
||
|
|
{
|
||
|
|
var act = () => ChargeableCharConfig.Create(null, "", "Currency", 1.0m, Today);
|
||
|
|
|
||
|
|
act.Should().Throw<ChargeableCharConfigInvalidException>()
|
||
|
|
.Which.Field.Should().Be("Symbol");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Create_ThrowsInvalid_WhenSymbolIsWhitespace()
|
||
|
|
{
|
||
|
|
var act = () => ChargeableCharConfig.Create(null, " ", "Currency", 1.0m, Today);
|
||
|
|
|
||
|
|
act.Should().Throw<ChargeableCharConfigInvalidException>()
|
||
|
|
.Which.Field.Should().Be("Symbol");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Create_ThrowsInvalid_WhenSymbolExceedsFourChars()
|
||
|
|
{
|
||
|
|
var act = () => ChargeableCharConfig.Create(null, "$$$$$", "Currency", 1.0m, Today);
|
||
|
|
|
||
|
|
act.Should().Throw<ChargeableCharConfigInvalidException>()
|
||
|
|
.Which.Field.Should().Be("Symbol");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Create_ThrowsInvalid_WhenPricePerUnitIsZero()
|
||
|
|
{
|
||
|
|
var act = () => ChargeableCharConfig.Create(null, "$", "Currency", 0m, Today);
|
||
|
|
|
||
|
|
act.Should().Throw<ChargeableCharConfigInvalidException>()
|
||
|
|
.Which.Field.Should().Be("PricePerUnit");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Create_ThrowsInvalid_WhenPricePerUnitIsNegative()
|
||
|
|
{
|
||
|
|
var act = () => ChargeableCharConfig.Create(null, "$", "Currency", -1.5m, Today);
|
||
|
|
|
||
|
|
act.Should().Throw<ChargeableCharConfigInvalidException>()
|
||
|
|
.Which.Field.Should().Be("PricePerUnit");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Create_ThrowsInvalid_WhenCategoryIsUnknown()
|
||
|
|
{
|
||
|
|
var act = () => ChargeableCharConfig.Create(null, "$", "INVALID_CAT", 1.0m, Today);
|
||
|
|
|
||
|
|
act.Should().Throw<ChargeableCharConfigInvalidException>()
|
||
|
|
.Which.Field.Should().Be("Category");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Create factory — happy path ───────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Create_HappyPath_ReturnsEntityWithIsActiveTrue_AndIdZero()
|
||
|
|
{
|
||
|
|
var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.5m, Today);
|
||
|
|
|
||
|
|
entity.Id.Should().Be(0);
|
||
|
|
entity.IsActive.Should().BeTrue();
|
||
|
|
entity.ValidTo.Should().BeNull();
|
||
|
|
entity.Symbol.Should().Be("$");
|
||
|
|
entity.Category.Should().Be("Currency");
|
||
|
|
entity.PricePerUnit.Should().Be(1.5m);
|
||
|
|
entity.ValidFrom.Should().Be(Today);
|
||
|
|
entity.MedioId.Should().BeNull();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Create_WithMedioId_SetsCorrectly()
|
||
|
|
{
|
||
|
|
var entity = ChargeableCharConfig.Create(5, "$", "Currency", 2.0m, Today);
|
||
|
|
|
||
|
|
entity.MedioId.Should().Be(5);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Create_FourCharSymbol_IsValid()
|
||
|
|
{
|
||
|
|
var act = () => ChargeableCharConfig.Create(null, "$$$$", "Currency", 1.0m, Today);
|
||
|
|
|
||
|
|
act.Should().NotThrow();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── ScheduleNewPrice — forward-only ───────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void ScheduleNewPrice_ThrowsForwardOnly_WhenNewValidFromEqualToCurrent()
|
||
|
|
{
|
||
|
|
var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
|
||
|
|
|
||
|
|
var act = () => entity.ScheduleNewPrice(2.0m, Today, FakeToday);
|
||
|
|
|
||
|
|
act.Should().Throw<ChargeableCharConfigForwardOnlyException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void ScheduleNewPrice_ThrowsForwardOnly_WhenNewValidFromBeforeCurrent()
|
||
|
|
{
|
||
|
|
var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
|
||
|
|
var pastDate = Today.AddDays(-1);
|
||
|
|
var fp = MakeFakeTimeProvider(Today.AddDays(-2)); // today is 2 days ago so pastDate is still "future" relative to fake today
|
||
|
|
// But we want to test forward-only (new <= current ValidFrom), not past date
|
||
|
|
// Use a fake today that makes pastDate pass the ">=today" check, but still fail forward-only
|
||
|
|
var fpPast = MakeFakeTimeProvider(Today.AddDays(-10)); // today = Apr 10, pastDate = Apr 19 is future
|
||
|
|
|
||
|
|
var act = () => entity.ScheduleNewPrice(2.0m, pastDate, fpPast);
|
||
|
|
|
||
|
|
act.Should().Throw<ChargeableCharConfigForwardOnlyException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void ScheduleNewPrice_ThrowsInvalid_WhenNewValidFromBeforeTodayAR()
|
||
|
|
{
|
||
|
|
var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today.AddDays(-10));
|
||
|
|
// today in fake = Apr 20; newValidFrom = Apr 18 < today → invalid (past date)
|
||
|
|
var pastDate = Today.AddDays(-2);
|
||
|
|
|
||
|
|
var act = () => entity.ScheduleNewPrice(2.0m, pastDate, FakeToday);
|
||
|
|
|
||
|
|
act.Should().Throw<ChargeableCharConfigInvalidException>()
|
||
|
|
.Which.Field.Should().Be("ValidFrom");
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void ScheduleNewPrice_HappyPath_ReturnsNewEntity_DoesNotMutateThis()
|
||
|
|
{
|
||
|
|
var original = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
|
||
|
|
var futureDate = Today.AddDays(30);
|
||
|
|
|
||
|
|
var newEntity = original.ScheduleNewPrice(2.0m, futureDate, FakeToday);
|
||
|
|
|
||
|
|
// new entity has updated price and validFrom
|
||
|
|
newEntity.PricePerUnit.Should().Be(2.0m);
|
||
|
|
newEntity.ValidFrom.Should().Be(futureDate);
|
||
|
|
newEntity.Symbol.Should().Be("$");
|
||
|
|
newEntity.Category.Should().Be("Currency");
|
||
|
|
newEntity.IsActive.Should().BeTrue();
|
||
|
|
|
||
|
|
// original is unchanged (forward-only semantics)
|
||
|
|
original.PricePerUnit.Should().Be(1.0m);
|
||
|
|
original.ValidFrom.Should().Be(Today);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void ScheduleNewPrice_ThrowsInvalid_WhenNewPriceIsZero()
|
||
|
|
{
|
||
|
|
var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
|
||
|
|
var futureDate = Today.AddDays(5);
|
||
|
|
|
||
|
|
var act = () => entity.ScheduleNewPrice(0m, futureDate, FakeToday);
|
||
|
|
|
||
|
|
act.Should().Throw<ChargeableCharConfigInvalidException>();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Deactivate ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Deactivate_SetsIsActiveFalseAndValidToToday()
|
||
|
|
{
|
||
|
|
var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
|
||
|
|
|
||
|
|
entity.Deactivate(Today);
|
||
|
|
|
||
|
|
entity.IsActive.Should().BeFalse();
|
||
|
|
entity.ValidTo.Should().Be(Today);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Deactivate_Idempotent_WhenAlreadyInactive()
|
||
|
|
{
|
||
|
|
var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
|
||
|
|
entity.Deactivate(Today);
|
||
|
|
|
||
|
|
// Second deactivate on same entity — no exception, no change
|
||
|
|
var act = () => entity.Deactivate(Today);
|
||
|
|
|
||
|
|
act.Should().NotThrow();
|
||
|
|
entity.IsActive.Should().BeFalse();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Rehydrate (reconstructor) ─────────────────────────────────────────────
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Rehydrate_SetsAllPropertiesWithoutValidation()
|
||
|
|
{
|
||
|
|
// Rehydrate can create entities that would fail Create (e.g., IsActive=false)
|
||
|
|
var entity = ChargeableCharConfig.Rehydrate(
|
||
|
|
id: 42, medioId: 5, symbol: "$", category: "Currency",
|
||
|
|
price: 1.5m, validFrom: Today, validTo: Today.AddDays(30), isActive: false);
|
||
|
|
|
||
|
|
entity.Id.Should().Be(42);
|
||
|
|
entity.MedioId.Should().Be(5);
|
||
|
|
entity.Symbol.Should().Be("$");
|
||
|
|
entity.Category.Should().Be("Currency");
|
||
|
|
entity.PricePerUnit.Should().Be(1.5m);
|
||
|
|
entity.ValidFrom.Should().Be(Today);
|
||
|
|
entity.ValidTo.Should().Be(Today.AddDays(30));
|
||
|
|
entity.IsActive.Should().BeFalse();
|
||
|
|
}
|
||
|
|
}
|