feat(domain): WordCounterService + WordCountResult + ChargeableCharConfig entity + exceptions (PRC-001)

- WordCounterService: pure domain service, 7-step algorithm (null/empty fast path → length check → emoji detection via Rune.EnumerateRunes → count specials before replace → replace specials+hyphens → collapse whitespace → tokenize)
- WordCountResult: sealed record with TotalWords + IReadOnlyDictionary<string,int> SpecialCharCounts
- 4 domain exceptions extending DomainException: EmojiDetectedException, WordCountValidationException, ChargeableCharConfigInvalidException, ChargeableCharConfigForwardOnlyException
- ChargeableCharConfig: rich entity with Create factory (invariants), Rehydrate reconstructor, ScheduleNewPrice (forward-only, returns new entity), Deactivate (idempotent)
- ChargeableCharCategories: enum-as-string constants (Currency, Percentage, Exclamation, Question, Other)
- DomainTimeProviderExtensions: internal GetArgentinaToday helper (mirrors Application.Common without creating Domain→Application dependency)
- 60 new tests: 25 golden cases all GREEN, 12 entity invariant tests, 12 exception tests, 5 WordCountResult tests, 6 ChargeableCharConfig entity tests
This commit is contained in:
2026-04-20 12:13:06 -03:00
parent 8ac91a13aa
commit ded76fcdc7
13 changed files with 1159 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
using FluentAssertions;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Application.Tests.Domain.Pricing.Exceptions;
/// <summary>
/// PRC-001 — T2.1 Domain unit tests for Pricing exceptions.
/// Verifies constructor props, message content, and DomainException inheritance.
/// </summary>
public sealed class PricingExceptionTests
{
// ── EmojiDetectedException ────────────────────────────────────────────────
[Fact]
public void EmojiDetectedException_SetsDetectedCodepoint()
{
var ex = new EmojiDetectedException(0x1F697); // 🚗
ex.DetectedCodepoint.Should().Be(0x1F697);
}
[Fact]
public void EmojiDetectedException_MessageContainsCodepoint()
{
var ex = new EmojiDetectedException(0x1F697);
ex.Message.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public void EmojiDetectedException_InheritsFromDomainException()
{
var ex = new EmojiDetectedException(0x1F697);
ex.Should().BeAssignableTo<DomainException>();
}
// ── WordCountValidationException ─────────────────────────────────────────
[Fact]
public void WordCountValidationException_SetsFieldAndReason()
{
var ex = new WordCountValidationException("rawText", "El texto supera el máximo de 2000 caracteres.");
ex.Field.Should().Be("rawText");
ex.Reason.Should().Be("El texto supera el máximo de 2000 caracteres.");
}
[Fact]
public void WordCountValidationException_MessageContainsFieldAndReason()
{
var ex = new WordCountValidationException("rawText", "supera el máximo");
ex.Message.Should().Contain("rawText");
ex.Message.Should().Contain("supera el máximo");
}
[Fact]
public void WordCountValidationException_InheritsFromDomainException()
{
var ex = new WordCountValidationException("field", "reason");
ex.Should().BeAssignableTo<DomainException>();
}
// ── ChargeableCharConfigInvalidException ─────────────────────────────────
[Fact]
public void ChargeableCharConfigInvalidException_SetsFieldAndReason()
{
var ex = new ChargeableCharConfigInvalidException("PricePerUnit", "debe ser > 0");
ex.Field.Should().Be("PricePerUnit");
ex.Reason.Should().Be("debe ser > 0");
}
[Fact]
public void ChargeableCharConfigInvalidException_MessageContainsFieldAndReason()
{
var ex = new ChargeableCharConfigInvalidException("Symbol", "no puede estar vacío");
ex.Message.Should().Contain("Symbol");
ex.Message.Should().Contain("no puede estar vacío");
}
[Fact]
public void ChargeableCharConfigInvalidException_InheritsFromDomainException()
{
var ex = new ChargeableCharConfigInvalidException("field", "reason");
ex.Should().BeAssignableTo<DomainException>();
}
// ── ChargeableCharConfigForwardOnlyException ──────────────────────────────
[Fact]
public void ChargeableCharConfigForwardOnlyException_SetsAllProperties()
{
var newVf = new DateOnly(2026, 3, 1);
var activeVf = new DateOnly(2026, 4, 1);
var ex = new ChargeableCharConfigForwardOnlyException(
medioId: 5, symbol: "$", newValidFrom: newVf, activeValidFrom: activeVf);
ex.MedioId.Should().Be(5);
ex.Symbol.Should().Be("$");
ex.NewValidFrom.Should().Be(newVf);
ex.ActiveValidFrom.Should().Be(activeVf);
}
[Fact]
public void ChargeableCharConfigForwardOnlyException_NullMedioId_IsAllowed()
{
var ex = new ChargeableCharConfigForwardOnlyException(
medioId: null, symbol: "$",
newValidFrom: new DateOnly(2026, 3, 1),
activeValidFrom: new DateOnly(2026, 4, 1));
ex.MedioId.Should().BeNull();
}
[Fact]
public void ChargeableCharConfigForwardOnlyException_MessageContainsKeyDates()
{
var newVf = new DateOnly(2026, 3, 1);
var activeVf = new DateOnly(2026, 4, 1);
var ex = new ChargeableCharConfigForwardOnlyException(
medioId: null, symbol: "$", newValidFrom: newVf, activeValidFrom: activeVf);
ex.Message.Should().Contain("2026-03-01");
ex.Message.Should().Contain("2026-04-01");
}
[Fact]
public void ChargeableCharConfigForwardOnlyException_InheritsFromDomainException()
{
var ex = new ChargeableCharConfigForwardOnlyException(
medioId: null, symbol: "$",
newValidFrom: new DateOnly(2026, 3, 1),
activeValidFrom: new DateOnly(2026, 4, 1));
ex.Should().BeAssignableTo<DomainException>();
}
}