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:
2026-04-20 13:21:59 -03:00
parent c2a0612a70
commit 5175cc1ece
2 changed files with 666 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
OUTPUT INSERTED.Id
VALUES ('HARD_ELDIA', 'ELDIA Hardening', 1, 1)
""");
_elplataId = await conn.ExecuteScalarAsync<int>("""
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<Exception?> 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<int>("""
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<int>("""
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<int>("""
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<int>("""
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<dynamic>("""
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<DateTime>("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<dynamic>("""
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<dynamic>("""
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<long> 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<long>("@NewId");
}
private static ChargeableCharConfigRepository BuildRepository()
=> new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb));
private static SIGCM2.Application.Pricing.ChargeableChars.ChargeableCharConfigService BuildService()
=> new(BuildRepository());
}