using FluentAssertions; using Microsoft.Extensions.Time.Testing; using SIGCM2.Domain.Pricing.ChargeableChars; using SIGCM2.Domain.Pricing.Exceptions; namespace SIGCM2.Application.Tests.Domain.Pricing.ChargeableChars; /// /// PRC-001 — T2.3 Domain unit tests for ChargeableCharConfig entity. /// Covers: factory invariants, ScheduleNewPrice forward-only, Deactivate idempotency. /// 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() .Which.Field.Should().Be("Symbol"); } [Fact] public void Create_ThrowsInvalid_WhenSymbolIsWhitespace() { var act = () => ChargeableCharConfig.Create(null, " ", "Currency", 1.0m, Today); act.Should().Throw() .Which.Field.Should().Be("Symbol"); } [Fact] public void Create_ThrowsInvalid_WhenSymbolExceedsFourChars() { var act = () => ChargeableCharConfig.Create(null, "$$$$$", "Currency", 1.0m, Today); act.Should().Throw() .Which.Field.Should().Be("Symbol"); } [Fact] public void Create_ThrowsInvalid_WhenPricePerUnitIsZero() { var act = () => ChargeableCharConfig.Create(null, "$", "Currency", 0m, Today); act.Should().Throw() .Which.Field.Should().Be("PricePerUnit"); } [Fact] public void Create_ThrowsInvalid_WhenPricePerUnitIsNegative() { var act = () => ChargeableCharConfig.Create(null, "$", "Currency", -1.5m, Today); act.Should().Throw() .Which.Field.Should().Be("PricePerUnit"); } [Fact] public void Create_ThrowsInvalid_WhenCategoryIsUnknown() { var act = () => ChargeableCharConfig.Create(null, "$", "INVALID_CAT", 1.0m, Today); act.Should().Throw() .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.ProductTypeId.Should().BeNull(); } [Fact] public void Create_WithProductTypeId_SetsCorrectly() { var entity = ChargeableCharConfig.Create(5, "$", "Currency", 2.0m, Today); entity.ProductTypeId.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(); } [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(); } [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() .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(); } // ── 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, productTypeId: 5, symbol: "$", category: "Currency", price: 1.5m, validFrom: Today, validTo: Today.AddDays(30), isActive: false); entity.Id.Should().Be(42); entity.ProductTypeId.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(); } }