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 — 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-ProductType + global fallback resolution via GetActiveForProductTypeAsync: /// - ProductType1 override for '$' → per-PT row returned at priority /// - ProductType2 (no override) → global fallback returned /// /// V023 scope delta: MedioId → ProductTypeId. Seeds use dbo.ProductType rows. /// /// 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; // V023 scope delta: renamed from _eldiaId/_elplataId to ProductType-based IDs. // These are ProductType IDs (FK to dbo.ProductType), not Medio IDs. private int _productType1Id; // has per-PT override (was ELDIA) private int _productType2Id; // no override, falls back to global (was ELPLATA) 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 ProductTypes for override/fallback resolution tests. // V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id). _productType1Id = await conn.ExecuteScalarAsync(""" INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) OUTPUT INSERTED.Id VALUES ('Hardening PT1 (override)', 'H_PT1', 1) """); _productType2Id = await conn.ExecuteScalarAsync(""" INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) OUTPUT INSERTED.Id VALUES ('Hardening PT2 (fallback)', 'H_PT2', 1) """); } public Task DisposeAsync() => Task.CompletedTask; // ───────────────────────────────────────────────────────────────────────── // T7.1 — Concurrency: only one winner survives the race // // Three parallel connections try to InsertWithClose for the same (ProductTypeId=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 (ProductTypeId=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("@ProductTypeId", 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 (ProductTypeId=NULL, '¢') // V023: MedioId → ProductTypeId; global fallback = ProductTypeId IS NULL await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb); await verifyConn.OpenAsync(); var vigente = await verifyConn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.ChargeableCharConfig WHERE ProductTypeId 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 (ProductTypeId, 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 ProductTypeId 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-ProductType + global fallback resolution via GetActiveForProductTypeAsync // // V023 scope delta: MedioId → ProductTypeId in table + SP. // // Scenario: // - Global '$' at price 0.00 (seed row from ResetAndSeedAsync / canonical V022+V024 seed) // - ProductType1-specific '$' at price 5.00 effective from 2026-01-01 (per-PT override) // - ProductType2 has no override for '$' // // GetActiveConfigForProductTypeAsync(PT1, today) → '$' = 5.00 (per-PT override wins) // GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback) // // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task GetActiveConfigForProductType_Override_WinsOverGlobal() { var asOf = new DateOnly(2026, 6, 1); // Seed per-PT override for ProductType1: '$' at 5.00 effective from 2026-01-01 await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); await seedConn.OpenAsync(); await ExecInsertWithCloseAsync(seedConn, _productType1Id, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1)); // Build the repository + service (C# method will be renamed in Agent 2) var repo = BuildRepository(); var rows = await repo.GetActiveForProductTypeAsync((long)_productType1Id, asOf); // The per-PT '$' must be returned var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); dollarRow.Should().NotBeNull("ProductType1 has a per-PT '$' override — SP must return it"); dollarRow!.ProductTypeId.Should().Be(_productType1Id, "the per-PT row (ProductTypeId = PT1) must take priority over the global row"); dollarRow.PricePerUnit.Should().Be(5.0000m, "ProductType1 override has price 5.00, not the global 0.00"); } [Fact] public async Task GetActiveConfigForProductType_NoOverride_FallsBackToGlobal() { var asOf = new DateOnly(2026, 6, 1); // ProductType2 has no per-PT rows — the canonical global seed from ResetAndSeedAsync // provides '$' at global price (0.0000 after V024). var repo = BuildRepository(); var rows = await repo.GetActiveForProductTypeAsync((long)_productType2Id, 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 ProductType2 (no override exists)"); dollarRow!.ProductTypeId.Should().BeNull( "ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)"); } [Fact] public async Task GetActiveConfigForProductType_ServiceLayer_AppliesPriorityCorrectly() { // End-to-end: IChargeableCharConfigService resolves the final dictionary. // Seed ProductType1 override for '%' (percentage) at 3.00; global '%' at 0.00 (from V024 seed). var asOf = new DateOnly(2026, 6, 1); await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); await seedConn.OpenAsync(); await ExecInsertWithCloseAsync(seedConn, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1)); // Build the service (wraps repo with priority resolution) var service = BuildService(); var pt1Config = await service.GetActiveConfigForProductTypeAsync((long)_productType1Id, asOf); var pt2Config = await service.GetActiveConfigForProductTypeAsync((long)_productType2Id, asOf); // ProductType1: '%' must come from per-PT override at 3.00 pt1Config.Should().ContainKey("%", "ProductType1 has a per-PT override for '%'"); pt1Config["%"].PricePerUnit.Should().Be(3.0000m, "per-PT '%' at 3.00 must override the global 0.00 for ProductType1"); // ProductType2: '%' must come from global fallback // Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId/ProductTypeId). // We verify via price: global '%' is seeded at 0.0000 by V024 (opt-in billing, was 1.0000 in V022). pt2Config.Should().ContainKey("%", "global '%' from canonical seed must appear in ProductType2's resolved config"); // V024 resets global '%' to 0.0000 (opt-in billing — V022 placeholder 1.0000 replaced) pt2Config["%"].PricePerUnit.Should().Be(0.0000m, "ProductType2 falls back to the global '%' at 0.00 (V024 seed opt-in billing price)"); } // ── Helpers ─────────────────────────────────────────────────────────────── /// /// Helper: calls usp_ChargeableCharConfig_InsertWithClose directly via SQL. /// V023 scope delta: parameter renamed from @MedioId to @ProductTypeId. /// private static async Task ExecInsertWithCloseAsync( SqlConnection conn, int? productTypeId, string symbol, string category, decimal pricePerUnit, DateTime validFrom) { var p = new DynamicParameters(); p.Add("@ProductTypeId", productTypeId, System.Data.DbType.Int32); // V023: was @MedioId 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()); }