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).
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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<string, ChargeableCharSnapshot> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user