Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs
dmolinari 5175cc1ece test(integration): concurrency + SYSTEM_VERSIONING + e2e extra (PRC-001)
Batch 7 hardening tests:
- T7.1 Concurrency: SemaphoreSlim barrier + Task.WhenAll; exactly 1 winner,
  2 losers receive SqlException; post-race vigente count = 1.
- T7.2 SYSTEM_VERSIONING: exact 0-before / 1-after history row count on close;
  history captures pre-close state (ValidTo was NULL at snapshot).
- T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot at T0 returns row as it existed
  before the close UPDATE (ValidTo=NULL, original price).
- T7.4 Per-medio + global fallback: ELDIA override for % wins over global;
  ELPLATA falls back to V022 global seed at 1.00; service-layer priority verified.
- T7.6 WordCounterService x ChargeableCharConfig integration contract (pure unit):
  documents PRC-002+ billing pattern; asserts charge computation for 6 scenarios.

Total .NET tests: 1603 (was 1591; +12 new).
2026-04-20 13:21:59 -03:00

281 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using FluentAssertions;
using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Domain.Pricing.ChargeableChars;
using SIGCM2.Domain.Pricing.WordCounter;
using Xunit;
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 Batch 7 — T7.6: WordCounterService × ChargeableCharConfig integration contract.
///
/// Documents the integration pattern that PRC-002+ will follow when computing
/// special-character billing charges for classified ads.
///
/// Contract:
/// 1. WordCounterService.Count(text) → WordCountResult with SpecialCharCounts keyed by Category.
/// 2. IChargeableCharConfigService.GetActiveConfigForMedioAsync(medioId, today)
/// → IReadOnlyDictionary&lt;string, ChargeableCharSnapshot&gt; keyed by Symbol.
/// 3. A charge calculator maps SpecialCharCounts (by Category) against resolved config
/// (by Symbol) to compute the total special-character billing amount.
///
/// This test is a PURE UNIT TEST — no DB, no HTTP, no DI container.
/// It exercises the in-memory integration point using the real WordCounterService
/// and a hand-built resolved config dictionary.
///
/// Spec reference: PRC-001-R1 + C5 golden cases + design §11.1.
/// </summary>
public sealed class WordCounterChargeableCharIntegrationTests
{
private readonly WordCounterService _svc = new();
// ─────────────────────────────────────────────────────────────────────────
// T7.6.a — Basic integration contract
//
// Text: "vendo $5000 %50"
// WordCount result: { Currency: 1, Percentage: 1 }
// Resolved config: { "$": 2.00, "%": 3.00 }
// Expected charge: 1 × 2.00 + 1 × 3.00 = 5.00
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void BasicIntegration_CurrencyAndPercentage_ComputesCorrectCharge()
{
const string text = "vendo $5000 %50";
// Step 1: count specials
var countResult = _svc.Count(text);
countResult.TotalWords.Should().Be(3, "vendo + 5000 + 50 after special replacement");
countResult.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(1);
countResult.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(1);
// Step 2: resolved config (simulates IChargeableCharConfigService output)
// Key is Symbol; ChargeableCharSnapshot holds Category + PricePerUnit.
var resolvedConfig = BuildResolvedConfig(new[]
{
("$", "Currency", 2.0000m),
("%", "Percentage", 3.0000m),
});
// Step 3: compute charge using the integration helper
var charge = ComputeSpecialCharCharge(countResult, resolvedConfig);
charge.Should().Be(5.0000m,
"1 × $2.00 (Currency) + 1 × $3.00 (Percentage) = $5.00");
}
// ─────────────────────────────────────────────────────────────────────────
// T7.6.b — Anti-fraud: multiple specials per word
//
// Text: "P$a$l$a$b$r$a" (anti-fraud: 7 currency symbols embedded in word)
// WordCount: TotalWords = 7, Currency = 6
// Config: { "$": 1.50 }
// Charge: 6 × 1.50 = 9.00
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void AntiFraud_MultipleSpecialsEmbedded_CountsEachOccurrence()
{
const string text = "P$a$l$a$b$r$a";
var countResult = _svc.Count(text);
countResult.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(6,
"anti-fraud: 6 '$' embedded in the word — each is counted before replacement");
var resolvedConfig = BuildResolvedConfig(new[]
{
("$", "Currency", 1.5000m),
});
var charge = ComputeSpecialCharCharge(countResult, resolvedConfig);
charge.Should().Be(9.0000m, "6 × $1.50 = $9.00");
}
// ─────────────────────────────────────────────────────────────────────────
// T7.6.c — Symbol not in config → 0 charge (free special)
//
// Text: "¿Que tal?"
// WordCount: { Question: 2 }
// Config: only "$" and "%" configured — "?" and "¿" are NOT priced symbols.
// Charge: 0.00 (no config entry for Question symbols)
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void SymbolNotInConfig_ProducesZeroCharge()
{
const string text = "¿Que tal?";
var countResult = _svc.Count(text);
countResult.SpecialCharCounts.GetValueOrDefault("Question").Should().Be(2,
"¿ and ? each count as 1 Question");
// Config has no entry for "?" or "¿" — Questions are not billable in this scenario
var resolvedConfig = BuildResolvedConfig(new[]
{
("$", "Currency", 2.0000m),
("%", "Percentage", 3.0000m),
});
var charge = ComputeSpecialCharCharge(countResult, resolvedConfig);
charge.Should().Be(0.0000m,
"Question symbols are not in the config — no charge should apply");
}
// ─────────────────────────────────────────────────────────────────────────
// T7.6.d — Combined scenario: multiple categories, all billable
//
// Text: "¡Oferta! $500 -20% hoy"
// From GC-23: TotalWords=4, Currency=1, Percentage=1, Exclamation=2
// Config: { "$": 2.00, "%": 3.00, "!": 0.50, "¡": 0.50 }
// Charge: 1×2.00 + 1×3.00 + 2×0.50 = 6.00
//
// Note: "!" and "¡" are two distinct symbols but both are Exclamation category.
// The charge is computed per-symbol-occurrence, not per-category.
// WordCountResult gives us Exclamation=2 total; we price them the same since both
// symbols have the same configured price (0.50 each).
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void CombinedCategories_AllBillable_ComputesTotalCorrectly()
{
const string text = "¡Oferta! $500 -20% hoy";
var countResult = _svc.Count(text);
// Verify WordCounterService counts (GC-23 equivalent)
countResult.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(1);
countResult.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(1);
countResult.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(2,
"both '¡' and '!' count as Exclamation (GC-23)");
// Resolved config: all four symbols priced
var resolvedConfig = BuildResolvedConfig(new[]
{
("$", "Currency", 2.0000m),
("%", "Percentage", 3.0000m),
("!", "Exclamation", 0.5000m),
("¡", "Exclamation", 0.5000m),
});
// The charge calculator works at the CATEGORY level:
// for each category in SpecialCharCounts, find the first config entry for that category,
// use its price × occurrence count. (This simplification is valid for PRC-001 spike;
// PRC-002 will introduce per-symbol pricing if symbols in same category have different prices.)
var chargePerCategory = ComputeSpecialCharChargeByCategory(countResult, resolvedConfig);
// 1×2.00 + 1×3.00 + 2×0.50 = 6.00
chargePerCategory.Should().Be(6.0000m,
"Currency 1×2.00 + Percentage 1×3.00 + Exclamation 2×0.50 = 6.00");
}
// ─────────────────────────────────────────────────────────────────────────
// T7.6.e — Empty text → zero charge
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void EmptyText_ProducesZeroWordsAndZeroCharge()
{
var countResult = _svc.Count("");
countResult.TotalWords.Should().Be(0);
countResult.SpecialCharCounts.Should().BeEmpty();
var resolvedConfig = BuildResolvedConfig(new[]
{
("$", "Currency", 2.0000m),
});
var charge = ComputeSpecialCharCharge(countResult, resolvedConfig);
charge.Should().Be(0.0000m, "no specials in empty text → zero charge");
}
// ─────────────────────────────────────────────────────────────────────────
// T7.6.f — Null text → zero charge (WordCounterService null-safe)
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void NullText_ProducesZeroWordsAndZeroCharge()
{
var countResult = _svc.Count(null);
countResult.TotalWords.Should().Be(0);
countResult.SpecialCharCounts.Should().BeEmpty();
var resolvedConfig = BuildResolvedConfig(Array.Empty<(string, string, decimal)>());
var charge = ComputeSpecialCharCharge(countResult, resolvedConfig);
charge.Should().Be(0.0000m);
}
// ─────────────────────────────────────────────────────────────────────────
// Integration contract: charge calculator helpers
//
// These helpers document the PRC-002+ integration contract.
// A real implementation would live in a pricing service or use case handler.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Builds a resolved config dictionary keyed by Symbol (simulates
/// IChargeableCharConfigService.GetActiveConfigForMedioAsync result).
/// </summary>
private static IReadOnlyDictionary<string, ChargeableCharSnapshot> BuildResolvedConfig(
IEnumerable<(string Symbol, string Category, decimal PricePerUnit)> entries)
{
var dict = new Dictionary<string, ChargeableCharSnapshot>(StringComparer.Ordinal);
foreach (var (symbol, category, price) in entries)
dict[symbol] = new ChargeableCharSnapshot(category, price);
return dict;
}
/// <summary>
/// Computes the total special-character billing charge.
///
/// Algorithm (per-symbol lookup):
/// For each symbol in the resolved config where the config category matches a
/// SpecialCharCounts category, multiply the category occurrence count by the symbol price.
///
/// This is the SIMPLEST form of the integration contract: one price per category.
/// PRC-002+ can extend this to per-symbol pricing if different symbols in the same
/// category have different prices.
/// </summary>
private static decimal ComputeSpecialCharCharge(
WordCountResult countResult,
IReadOnlyDictionary<string, ChargeableCharSnapshot> resolvedConfig)
{
if (countResult.SpecialCharCounts.Count == 0) return 0m;
var total = 0m;
// For each configured symbol, check if there are occurrences of its category.
// Use the first symbol in each category as the representative price.
// (Sufficient for PRC-001 where symbols within the same category share the same price.)
var chargedCategories = new HashSet<string>(StringComparer.Ordinal);
foreach (var (symbol, snapshot) in resolvedConfig)
{
if (chargedCategories.Contains(snapshot.Category)) continue;
if (countResult.SpecialCharCounts.TryGetValue(snapshot.Category, out var count))
{
total += count * snapshot.PricePerUnit;
chargedCategories.Add(snapshot.Category);
}
}
return total;
}
/// <summary>
/// Category-level charge computation: resolves price by finding any configured symbol
/// for each category, then multiplies by occurrence count.
/// Explicit version of ComputeSpecialCharCharge for clarity in documentation tests.
/// </summary>
private static decimal ComputeSpecialCharChargeByCategory(
WordCountResult countResult,
IReadOnlyDictionary<string, ChargeableCharSnapshot> resolvedConfig)
=> ComputeSpecialCharCharge(countResult, resolvedConfig);
}