diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs new file mode 100644 index 0000000..2e7cde7 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs @@ -0,0 +1,386 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using SIGCM2.Infrastructure.Persistence; +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Application.Tests.Infrastructure.Pricing; + +/// +/// PRC-001 Batch 7 — Integration hardening tests for ChargeableCharConfig. +/// Covers cross-cutting concerns not addressed by individual layer batches: +/// +/// T7.1 Concurrency: SemaphoreSlim barrier forces genuine parallel race on +/// usp_ChargeableCharConfig_InsertWithClose — only 1 winner, SERIALIZABLE guard holds. +/// +/// T7.2 SYSTEM_VERSIONING: exact row count before/after close (0 → 1). +/// +/// T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot query at T0 returns pre-close state. +/// +/// T7.4 Per-medio + global fallback resolution via GetActiveForMedioAsync: +/// - ELDIA override for '$' → per-medio row returned at priority +/// - ELPLATA (no override) → global fallback returned +/// +/// All tests run against SIGCM2_Test_App (Database collection + SqlTestFixture). +/// Each test seeds its own unique symbols to avoid cross-test interference. +/// +[Collection("Database")] +public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime +{ + private readonly SqlTestFixture _db; + private int _eldiaId; + private int _elplataId; + + public ChargeableCharConfigHardeningTests(SqlTestFixture db) + { + _db = db; + } + + public async Task InitializeAsync() + { + await _db.ResetAndSeedAsync(); + + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + // Seed two dedicated medios: ELDIA (has per-medio override) and ELPLATA (no override). + _eldiaId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + OUTPUT INSERTED.Id + VALUES ('HARD_ELDIA', 'ELDIA Hardening', 1, 1) + """); + + _elplataId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + OUTPUT INSERTED.Id + VALUES ('HARD_ELPLA', 'ELPLATA Hardening', 1, 1) + """); + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ───────────────────────────────────────────────────────────────────────── + // T7.1 — Concurrency: only one winner survives the race + // + // Three parallel connections try to InsertWithClose for the same (MedioId=null, Symbol). + // The SP uses SERIALIZABLE + UPDLOCK + HOLDLOCK, so only one can commit. + // The other two must receive SqlException (50409, 2601, 2627, or deadlock 1205). + // + // After resolution: exactly 1 vigente row exists for (MedioId=NULL, Symbol). + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Concurrency_ThreeParallelInserts_ExactlyOneWins() + { + // Unique symbol so this test doesn't conflict with other tests or seed data. + const string symbol = "¢"; + const string category = "Currency"; + + // Barrier: all tasks must wait until released simultaneously to maximize the race. + var barrier = new SemaphoreSlim(0, 3); + + async Task TryInsert(decimal price) + { + await barrier.WaitAsync(); + try + { + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + var p = new DynamicParameters(); + p.Add("@MedioId", null, System.Data.DbType.Int32); + p.Add("@Symbol", symbol, System.Data.DbType.String); + p.Add("@Category", category, System.Data.DbType.String); + p.Add("@PricePerUnit", price, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", new DateTime(2027, 9, 1), System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + + await conn.ExecuteAsync( + "dbo.usp_ChargeableCharConfig_InsertWithClose", + p, + commandType: System.Data.CommandType.StoredProcedure); + + return null; // winner + } + catch (SqlException ex) + { + // Expected losers: 50409 (forward-only), 2601/2627 (unique index), 1205 (deadlock) + return ex; + } + } + + var t1 = Task.Run(() => TryInsert(1.00m)); + var t2 = Task.Run(() => TryInsert(2.00m)); + var t3 = Task.Run(() => TryInsert(3.00m)); + + // Release all three simultaneously to create a genuine race. + barrier.Release(3); + + var results = await Task.WhenAll(t1, t2, t3); + + var successes = results.Count(r => r is null); + var failures = results.Count(r => r is not null); + + successes.Should().Be(1, "exactly one concurrent InsertWithClose must succeed"); + failures.Should().Be(2, "the other two concurrent inserts must fail with SqlException"); + + // Verify post-race state: exactly 1 vigente row for (NULL, '¢') + await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await verifyConn.OpenAsync(); + + var vigente = await verifyConn.ExecuteScalarAsync(""" + SELECT COUNT(1) + FROM dbo.ChargeableCharConfig + WHERE MedioId IS NULL + AND Symbol = @Symbol + AND ValidTo IS NULL + AND IsActive = 1 + """, new { Symbol = symbol }); + + vigente.Should().Be(1, + "filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (MedioId, Symbol)"); + + // No duplicates: total row count must also be 1 (only the winner was inserted) + var total = await verifyConn.ExecuteScalarAsync(""" + SELECT COUNT(1) + FROM dbo.ChargeableCharConfig + WHERE MedioId IS NULL + AND Symbol = @Symbol + """, new { Symbol = symbol }); + + total.Should().Be(1, "no duplicate rows must exist — SERIALIZABLE guard ensures only one insert commits"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.2 — SYSTEM_VERSIONING: exact history row count before and after close + // + // Before any UPDATE: history table has 0 rows for the new Id. + // After InsertWithClose closes the previous row (UPDATE): exactly 1 history row. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task SystemVersioning_HistoryCount_IsZeroBeforeClose_AndOneAfter() + { + const string symbol = "₽"; + + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + // Insert first row (becomes vigente) + var firstId = await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 1.0m, new DateTime(2026, 1, 1)); + + // Before any UPDATE: history table must have 0 rows for firstId + var histBefore = await conn.ExecuteScalarAsync(""" + SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id + """, new { Id = firstId }); + + histBefore.Should().Be(0, + "SYSTEM_VERSIONING only creates history rows on UPDATE/DELETE — INSERT produces no history row"); + + // Insert second row which closes (UPDATEs) the first + await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 2.0m, new DateTime(2026, 7, 1)); + + // After the UPDATE (close): exactly 1 history row for firstId + var histAfter = await conn.ExecuteScalarAsync(""" + SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id + """, new { Id = firstId }); + + histAfter.Should().Be(1, + "SYSTEM_VERSIONING must produce exactly one history row when the vigente row is closed via UPDATE"); + + // Verify: the history row captured the pre-close state (ValidTo was NULL before the UPDATE) + var histRow = await conn.QuerySingleOrDefaultAsync(""" + SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig_History WHERE Id = @Id + """, new { Id = firstId }); + + ((object?)histRow).Should().NotBeNull("history row must exist after close"); + + // The history captures the state as it was BEFORE the UPDATE: + // before close, the row had ValidTo = NULL and IsActive = 1. + ((object?)histRow!.ValidTo).Should().BeNull( + "the history row captures the state before the close UPDATE — ValidTo was NULL at that point"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.3 — FOR SYSTEM_TIME AS OF: temporal snapshot returns pre-close state + // + // Create row at T0 → query FOR SYSTEM_TIME AS OF T0 → returns row with ValidTo = NULL. + // After close → current query → row has ValidTo != NULL. + // This validates that SYSTEM_VERSIONING preserves immutable history. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ForSystemTimeAsOf_ReturnsSnapshotAtT0_BeforeClose() + { + const string symbol = "₿"; + + await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + // Insert first row and capture the UTC timestamp immediately after + var firstId = await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 5.0m, new DateTime(2026, 3, 1)); + + // Capture T0: the SYSUTCDATETIME() right after INSERT (row is still active) + var t0 = await conn.ExecuteScalarAsync("SELECT SYSUTCDATETIME()"); + + // Wait 200ms so the SYSTEM_VERSIONING SysEndTime is strictly after T0 + // (SQL Server DATETIME2 has ~100ns precision — 200ms is more than sufficient) + await Task.Delay(200); + + // Insert second row — this closes (UPDATEs) the first, sending it to history + await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 6.0m, new DateTime(2026, 8, 1)); + + // FOR SYSTEM_TIME AS OF T0: must return the first row in its pre-close state + var snapshotRow = await conn.QuerySingleOrDefaultAsync(""" + SELECT Id, PricePerUnit, ValidTo + FROM dbo.ChargeableCharConfig + FOR SYSTEM_TIME AS OF @T0 + WHERE Id = @Id + """, new { T0 = t0, Id = firstId }); + + ((object?)snapshotRow).Should().NotBeNull( + "FOR SYSTEM_TIME AS OF T0 must return the row as it existed at T0 (before the close UPDATE)"); + + ((decimal)snapshotRow!.PricePerUnit).Should().Be(5.0m, + "snapshot must reflect the original price before the close"); + + ((object?)snapshotRow.ValidTo).Should().BeNull( + "at T0 the row was vigente (ValidTo IS NULL) — snapshot must preserve this"); + + // Current state: first row must now have ValidTo != NULL (it was closed) + var currentRow = await conn.QuerySingleOrDefaultAsync(""" + SELECT ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id + """, new { Id = firstId }); + + ((object?)currentRow).Should().NotBeNull("the closed row still exists in the current table"); + ((object?)currentRow!.ValidTo).Should().NotBeNull( + "after the close, the current row has ValidTo set — it is no longer vigente"); + } + + // ───────────────────────────────────────────────────────────────────────── + // T7.4 — Per-medio + global fallback resolution via GetActiveForMedioAsync + // + // Scenario: + // - Global '$' at price 1.00 (seed row from ResetAndSeedAsync / canonical V022 seed) + // - ELDIA-specific '$' at price 5.00 effective from today (per-medio override) + // - ELPLATA has no override for '$' + // + // GetActiveConfigForMedioAsync(ELDIA, today) → '$' = 5.00 (per-medio override wins) + // GetActiveConfigForMedioAsync(ELPLATA, today) → '$' = 1.00 (global fallback) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetActiveConfigForMedio_EldiaOverride_WinsOverGlobal() + { + var asOf = new DateOnly(2026, 6, 1); + + // Seed per-medio override for ELDIA: '$' at 5.00 effective from 2026-01-01 + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + await ExecInsertWithCloseAsync(seedConn, _eldiaId, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1)); + + // Build the repository + service (same as application layer usage) + var repo = BuildRepository(); + var rows = await repo.GetActiveForMedioAsync((long)_eldiaId, asOf); + + // The per-medio '$' must be returned + var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); + dollarRow.Should().NotBeNull("ELDIA has a per-medio '$' override — SP must return it"); + + dollarRow!.MedioId.Should().Be(_eldiaId, + "the per-medio row (MedioId = ELDIA) must take priority over the global row"); + + dollarRow.PricePerUnit.Should().Be(5.0000m, + "ELDIA override has price 5.00, not the global 1.00"); + } + + [Fact] + public async Task GetActiveConfigForMedio_ElplataNoOverride_FallsBackToGlobal() + { + var asOf = new DateOnly(2026, 6, 1); + + // ELPLATA has no per-medio rows — the canonical global seed from ResetAndSeedAsync + // provides '$' at global price. + var repo = BuildRepository(); + var rows = await repo.GetActiveForMedioAsync((long)_elplataId, asOf); + + // Must have at least the global '$' from seed + rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01"); + + var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); + dollarRow.Should().NotBeNull("global '$' must be returned for ELPLATA (no override exists)"); + + dollarRow!.MedioId.Should().BeNull( + "ELPLATA has no override — the returned row must be the global row (MedioId = NULL)"); + } + + [Fact] + public async Task GetActiveConfigForMedio_ServiceLayer_AppliesPriorityCorrectly() + { + // End-to-end: IChargeableCharConfigService resolves the final dictionary. + // Seed ELDIA override for '%' (percentage) at 3.00; global '%' at 2.00 (from V022 seed). + var asOf = new DateOnly(2026, 6, 1); + + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + await ExecInsertWithCloseAsync(seedConn, _eldiaId, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1)); + + // Build the service (wraps repo with priority resolution) + var service = BuildService(); + + var eldiaConfig = await service.GetActiveConfigForMedioAsync((long)_eldiaId, asOf); + var elplataConfig = await service.GetActiveConfigForMedioAsync((long)_elplataId, asOf); + + // ELDIA: '%' must come from per-medio override at 3.00 + eldiaConfig.Should().ContainKey("%", + "ELDIA has a per-medio override for '%'"); + eldiaConfig["%"].PricePerUnit.Should().Be(3.0000m, + "per-medio '%' at 3.00 must override the global 2.00 for ELDIA"); + + // ELPLATA: '%' must come from global fallback + // Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId). + // We verify via price: global '%' is seeded at 2.00 by V022. + elplataConfig.Should().ContainKey("%", + "global '%' from canonical seed must appear in ELPLATA's resolved config"); + // V022 seeds global '%' at 1.0000 (placeholder — see V022__seed_chargeable_char_config.sql) + elplataConfig["%"].PricePerUnit.Should().Be(1.0000m, + "ELPLATA falls back to the global '%' at 1.00 (V022 seed placeholder price)"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static async Task ExecInsertWithCloseAsync( + SqlConnection conn, + int? medioId, + string symbol, + string category, + decimal pricePerUnit, + DateTime validFrom) + { + var p = new DynamicParameters(); + p.Add("@MedioId", medioId, System.Data.DbType.Int32); + p.Add("@Symbol", symbol, System.Data.DbType.String); + p.Add("@Category", category, System.Data.DbType.String); + p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", validFrom, System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + + await conn.ExecuteAsync( + "dbo.usp_ChargeableCharConfig_InsertWithClose", + p, + commandType: System.Data.CommandType.StoredProcedure); + + return p.Get("@NewId"); + } + + private static ChargeableCharConfigRepository BuildRepository() + => new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb)); + + private static SIGCM2.Application.Pricing.ChargeableChars.ChargeableCharConfigService BuildService() + => new(BuildRepository()); +} diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs new file mode 100644 index 0000000..055f317 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs @@ -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; + +/// +/// 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. +/// +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. + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Builds a resolved config dictionary keyed by Symbol (simulates + /// IChargeableCharConfigService.GetActiveConfigForMedioAsync result). + /// + private static IReadOnlyDictionary BuildResolvedConfig( + IEnumerable<(string Symbol, string Category, decimal PricePerUnit)> entries) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var (symbol, category, price) in entries) + dict[symbol] = new ChargeableCharSnapshot(category, price); + return dict; + } + + /// + /// 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. + /// + private static decimal ComputeSpecialCharCharge( + WordCountResult countResult, + IReadOnlyDictionary 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(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; + } + + /// + /// 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. + /// + private static decimal ComputeSpecialCharChargeByCategory( + WordCountResult countResult, + IReadOnlyDictionary resolvedConfig) + => ComputeSpecialCharCharge(countResult, resolvedConfig); +}