using Dapper; using FluentAssertions; using Microsoft.Data.SqlClient; using SIGCM2.Domain.Pricing.ChargeableChars; using SIGCM2.Domain.Pricing.Exceptions; using SIGCM2.Infrastructure.Persistence; using SIGCM2.TestSupport; using Xunit; namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// /// PRC-001 — Integration tests for ChargeableCharConfigRepository (Dapper) against SIGCM2_Test_App. /// /// All tests run against the real DB via SqlTestFixture (Database collection). /// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync(). /// Tests that mutate specific (ProductTypeId, Symbol) pairs clean their own state before mutating. /// /// V023 scope delta: MedioId → ProductTypeId. Uses dbo.ProductType for per-PT override tests. /// Uses unique name "RepoIntegration PT1" to avoid uniqueness conflicts with HardeningTests. /// /// Spec coverage: /// T4.1 InsertWithCloseAsync — first insert for symbol → new row, returns Id /// T4.2 InsertWithCloseAsync — with existing vigente → closes previous, inserts new /// T4.3 InsertWithCloseAsync — backdate attempt → ThrowsForwardOnlyException /// T4.4 InsertWithCloseAsync — system versioning captures history row after mutation /// T4.5 GetActiveForProductTypeAsync — PT has override → returns both PT and global rows /// T4.6 GetActiveForProductTypeAsync — no PT override → returns only global rows /// T4.7 ListAsync — paginates (skip/take) /// T4.8 CountAsync — filters by activeOnly /// T4.9 GetByIdAsync — missing → returns null /// T4.10 GetByIdAsync — exists → returns entity /// T4.11 DeactivateAsync — sets IsActive = false and ValidTo = today /// T4.12 DeactivateAsync — already inactive → idempotent (no-op) /// T4.13 ReactivateAsync — last closed row → returns reactivated entity /// T4.14 ReactivateAsync — already active → throws ALREADY_ACTIVE /// T4.15 DeleteAsync — row exists → deleted (0 rows after) /// T4.16 DeleteAsync — row not found → throws KeyNotFoundException /// [Collection("Database")] public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime { private readonly SqlTestFixture _db; private int _productTypeId; public ChargeableCharConfigRepositoryIntegrationTests(SqlTestFixture db) { _db = db; } public async Task InitializeAsync() { await _db.ResetAndSeedAsync(); // Create a dedicated ProductType for per-PT tests. // Unique name to avoid conflicts with HardeningTests ("RepoIntegration PT1"). await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); _productTypeId = await conn.ExecuteScalarAsync(""" INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) OUTPUT INSERTED.Id VALUES ('RepoIntegration PT1', 'RI_PT1', 1) """); } public Task DisposeAsync() => Task.CompletedTask; // ───────────────────────────────────────────────────────────────────────── // T4.1 — InsertWithCloseAsync: first insert for new symbol → row created, returns Id > 0 // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task InsertWithCloseAsync_FirstInsertForSymbol_CreatesRowAndReturnsId() { // NEW symbol not in canonical seed — use per-PT so it doesn't conflict const string symbol = "@"; var repo = BuildRepository(); var newId = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: symbol, category: "Other", price: 2.5000m, validFrom: new DateOnly(2026, 1, 1)); newId.Should().BeGreaterThan(0, "first insert must return the new row's Id"); // Verify the row exists in DB await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); var row = await conn.QuerySingleOrDefaultAsync( "SELECT Id, Symbol, IsActive, ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id", new { Id = newId }); ((object?)row).Should().NotBeNull(); ((string)row!.Symbol).Should().Be(symbol); ((bool)row.IsActive).Should().BeTrue(); ((object?)row.ValidTo).Should().BeNull("first insert has no ValidTo — still active"); } // ───────────────────────────────────────────────────────────────────────── // T4.2 — InsertWithCloseAsync: with existing vigente → closes previous, inserts new // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task InsertWithCloseAsync_WithExistingVigente_ClosesPreviousAndInsertsNew() { const string symbol = "#"; var repo = BuildRepository(); // First insert — becomes the vigente var firstId = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: symbol, category: "Other", price: 1.0000m, validFrom: new DateOnly(2026, 3, 1)); // Second insert (forward) — must close the first var secondValidFrom = new DateOnly(2026, 6, 1); var secondId = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: symbol, category: "Other", price: 2.0000m, validFrom: secondValidFrom); secondId.Should().BeGreaterThan(firstId, "second row must be a new insert with higher Id"); await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); // First row must now have ValidTo = secondValidFrom - 1 day = 2026-05-31 var firstRow = await conn.QuerySingleAsync( "SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig WHERE Id = @Id", new { Id = firstId }); ((DateTime)firstRow.ValidTo).Date.Should().Be(new DateTime(2026, 5, 31), "SP closes the vigente with ValidTo = new ValidFrom - 1 day"); // Second row must be the new vigente (ValidTo IS NULL) var secondRow = await conn.QuerySingleAsync( "SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig WHERE Id = @Id", new { Id = secondId }); ((object?)secondRow.ValidTo).Should().BeNull("new vigente has ValidTo = NULL"); ((bool)secondRow.IsActive).Should().BeTrue(); } // ───────────────────────────────────────────────────────────────────────── // T4.3 — InsertWithCloseAsync: backdate attempt → ThrowsForwardOnlyException // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task InsertWithCloseAsync_BackdateAttempt_ThrowsChargeableCharConfigForwardOnlyException() { const string symbol = "€"; var repo = BuildRepository(); // Establish a vigente at 2026-04-01 await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: symbol, category: "Currency", price: 1.5000m, validFrom: new DateOnly(2026, 4, 1)); // Try to insert retroactively — SP will THROW 50409 var act = async () => await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: symbol, category: "Currency", price: 1.2000m, validFrom: new DateOnly(2026, 3, 1)); await act.Should() .ThrowAsync( "SQL THROW 50409 must be mapped to ChargeableCharConfigForwardOnlyException"); } // ───────────────────────────────────────────────────────────────────────── // T4.4 — InsertWithCloseAsync: SYSTEM_VERSIONING captures history row after close // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task InsertWithCloseAsync_SystemVersioningCaptures_HistoryHasRowAfterClose() { const string symbol = "£"; var repo = BuildRepository(); // Insert the first row var firstId = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: symbol, category: "Currency", price: 3.0000m, validFrom: new DateOnly(2026, 1, 1)); // Insert a second row — this triggers an UPDATE on the first row → history await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: symbol, category: "Currency", price: 4.0000m, validFrom: new DateOnly(2026, 7, 1)); await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); var histCount = await conn.ExecuteScalarAsync( "SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id", new { Id = firstId }); histCount.Should().BeGreaterThanOrEqualTo(1, "SYSTEM_VERSIONING must create a history row when the vigente row is closed via UPDATE"); } // ───────────────────────────────────────────────────────────────────────── // T4.5 — GetActiveForProductTypeAsync: PT has override → returns both PT and global rows // Note: SP returns ALL rows (global + per-PT); service does priority resolution. // This test verifies the REPOSITORY returns both, not just one. // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task GetActiveForProductTypeAsync_PTHasOverride_ReturnsBothPTAndGlobalRows() { var repo = BuildRepository(); var asOf = new DateOnly(2026, 6, 1); // Add a per-PT override for symbol '$' // Canonical seed already has global '$' from ResetAndSeedAsync await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: "$", category: "Currency", price: 5.0000m, validFrom: new DateOnly(2026, 1, 1)); var rows = await repo.GetActiveForProductTypeAsync((long)_productTypeId, asOf); // The SP returns both the per-PT '$' AND global rows for other symbols rows.Should().NotBeEmpty("there are active global rows seeded by canonical seed"); var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); dollarRow.Should().NotBeNull("the SP must return a row for '$'"); dollarRow!.ProductTypeId.Should().Be(_productTypeId, "per-PT row takes priority over global in the SP's ROW_NUMBER ordering"); } // ───────────────────────────────────────────────────────────────────────── // T4.6 — GetActiveForProductTypeAsync: no PT override → returns only global rows // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task GetActiveForProductTypeAsync_NoPTOverride_ReturnsOnlyGlobalRows() { var repo = BuildRepository(); var asOf = new DateOnly(2026, 6, 1); // Use a DIFFERENT productTypeId that has no per-PT rows await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); var otherPTId = await conn.ExecuteScalarAsync(""" INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) OUTPUT INSERTED.Id VALUES ('RepoIntegration PT2 NoOverride', 'RI_PT2', 1) """); var rows = await repo.GetActiveForProductTypeAsync((long)otherPTId, asOf); rows.Should().NotBeEmpty("canonical seed has 4 global rows active since 2026-01-01"); rows.Should().AllSatisfy(r => r.ProductTypeId.Should().BeNull("all returned rows must be global (ProductTypeId = NULL)")); } // ───────────────────────────────────────────────────────────────────────── // T4.7 — ListAsync: paginates via skip/take // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task ListAsync_Paginates_ReturnsCorrectSubset() { var repo = BuildRepository(); // Canonical seed has 4 global rows. Request page 1 (skip=0, take=2) and page 2 (skip=2, take=2). var page1 = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 0, take: 2); var page2 = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 2, take: 2); page1.Should().HaveCount(2, "take=2 with at least 4 rows"); page2.Should().HaveCount(2, "second page of 4 rows"); // No overlap page1.Select(r => r.Id).Intersect(page2.Select(r => r.Id)) .Should().BeEmpty("two non-overlapping pages must not share any row"); } [Fact] public async Task ListAsync_PageBeyondTotal_ReturnsEmpty() { var repo = BuildRepository(); var result = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 1000, take: 10); result.Should().BeEmpty("skip far beyond available data must return empty"); } // ───────────────────────────────────────────────────────────────────────── // T4.8 — CountAsync: filters by activeOnly // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task CountAsync_FiltersByActiveOnly_CountsOnlyActiveRows() { var repo = BuildRepository(); // Canonical seed: 4 active global rows var countAll = await repo.CountAsync(productTypeId: null, activeOnly: false); var countActive = await repo.CountAsync(productTypeId: null, activeOnly: true); countAll.Should().BeGreaterThanOrEqualTo(4, "canonical seed provides at least 4 rows (may have more if other tests ran)"); countActive.Should().BeGreaterThanOrEqualTo(4, "all canonical rows are active"); countActive.Should().BeLessThanOrEqualTo(countAll, "active-only count must be <= total count"); } [Fact] public async Task CountAsync_AfterDeactivation_ActiveCountDecreases() { var repo = BuildRepository(); // Insert a row, then deactivate it — active count should decrease by 1 var id = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: "~", category: "Other", price: 1.0000m, validFrom: new DateOnly(2026, 1, 1)); var today = new DateOnly(2026, 4, 20); var beforeDeactivate = await repo.CountAsync(productTypeId: (long?)_productTypeId, activeOnly: true); await repo.DeactivateAsync(id, today); var afterDeactivate = await repo.CountAsync(productTypeId: (long?)_productTypeId, activeOnly: true); afterDeactivate.Should().Be(beforeDeactivate - 1, "deactivating one row must decrease the active count by 1"); } // ───────────────────────────────────────────────────────────────────────── // T4.9 — GetByIdAsync: missing → returns null // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task GetByIdAsync_Missing_ReturnsNull() { var repo = BuildRepository(); var result = await repo.GetByIdAsync(999_999_999L); result.Should().BeNull("non-existent Id must return null"); } // ───────────────────────────────────────────────────────────────────────── // T4.10 — GetByIdAsync: exists → returns entity with all fields correct // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task GetByIdAsync_Exists_ReturnsEntityWithCorrectFields() { var repo = BuildRepository(); var expectedValidFrom = new DateOnly(2026, 2, 1); var id = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: "^", category: "Other", price: 7.5000m, validFrom: expectedValidFrom); var entity = await repo.GetByIdAsync(id); entity.Should().NotBeNull(); entity!.Id.Should().Be(id); entity.ProductTypeId.Should().Be(_productTypeId); entity.Symbol.Should().Be("^"); entity.Category.Should().Be("Other"); entity.PricePerUnit.Should().Be(7.5000m); entity.ValidFrom.Should().Be(expectedValidFrom); entity.ValidTo.Should().BeNull("freshly inserted row has no ValidTo"); entity.IsActive.Should().BeTrue(); } // ───────────────────────────────────────────────────────────────────────── // T4.11 — DeactivateAsync: sets IsActive = false and ValidTo = today // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task DeactivateAsync_SetsIsActiveFalseAndValidToToday() { var repo = BuildRepository(); var id = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: "&", category: "Other", price: 1.0000m, validFrom: new DateOnly(2026, 1, 1)); var today = new DateOnly(2026, 4, 20); await repo.DeactivateAsync(id, today); var entity = await repo.GetByIdAsync(id); entity.Should().NotBeNull(); entity!.IsActive.Should().BeFalse("deactivated row must have IsActive = false"); entity.ValidTo.Should().Be(today, "ValidTo must be set to the provided today date"); } // ───────────────────────────────────────────────────────────────────────── // T4.12 — DeactivateAsync: already inactive → idempotent (no error, row unchanged) // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task DeactivateAsync_AlreadyInactive_IsIdempotent() { var repo = BuildRepository(); var id = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: "*", category: "Other", price: 1.0000m, validFrom: new DateOnly(2026, 1, 1)); var today = new DateOnly(2026, 4, 20); // Deactivate once await repo.DeactivateAsync(id, today); // Deactivate again — must be a no-op, no exception var act = async () => await repo.DeactivateAsync(id, today); await act.Should().NotThrowAsync("re-deactivating an already-inactive row must be idempotent"); // State must remain the same var entity = await repo.GetByIdAsync(id); entity!.IsActive.Should().BeFalse(); entity.ValidTo.Should().Be(today); } // ───────────────────────────────────────────────────────────────────────── // T4.13 — ReactivateAsync: last closed row → returns reactivated entity // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task ReactivateAsync_LastClosedRow_ReturnsReactivatedEntity() { var repo = BuildRepository(); // Insert a row, then deactivate it — it becomes "last closed" for this symbol const string symbol = "≈"; var id = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: symbol, category: "Other", price: 2.0000m, validFrom: new DateOnly(2026, 1, 1)); var today = new DateOnly(2026, 4, 20); await repo.DeactivateAsync(id, today); // Reactivate — no posterior rows, no vigente var reactivated = await repo.ReactivateAsync(id); reactivated.Should().NotBeNull(); reactivated.Id.Should().Be(id); reactivated.IsActive.Should().BeTrue("row must be active after reactivation"); reactivated.ValidTo.Should().BeNull("ValidTo must be NULL after reactivation"); } // ───────────────────────────────────────────────────────────────────────── // T4.14 — ReactivateAsync: already active → throws ALREADY_ACTIVE // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task ReactivateAsync_AlreadyActive_ThrowsAlreadyActive() { var repo = BuildRepository(); // Insert an active row — do NOT deactivate it const string symbol = "≠"; var id = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: symbol, category: "Other", price: 3.0000m, validFrom: new DateOnly(2026, 1, 1)); var act = async () => await repo.ReactivateAsync(id); await act.Should() .ThrowAsync() .Where(e => e.Reason == "ALREADY_ACTIVE", "SP 50410 → ALREADY_ACTIVE reason"); } // ───────────────────────────────────────────────────────────────────────── // T4.15 — DeleteAsync: row exists → deleted (0 rows after) // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task DeleteAsync_ExistingRow_RowIsGone() { var repo = BuildRepository(); const string symbol = "∞"; var id = await repo.InsertWithCloseAsync( productTypeId: (long?)_productTypeId, symbol: symbol, category: "Other", price: 1.0000m, validFrom: new DateOnly(2026, 1, 1)); await repo.DeleteAsync(id); // Row must be gone from current state var entity = await repo.GetByIdAsync(id); entity.Should().BeNull("deleted row must not appear in GetByIdAsync"); } // ───────────────────────────────────────────────────────────────────────── // T4.16 — DeleteAsync: row not found → throws KeyNotFoundException // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task DeleteAsync_NotFound_ThrowsKeyNotFoundException() { var repo = BuildRepository(); var act = async () => await repo.DeleteAsync(999_999_997L); await act.Should().ThrowAsync( "non-existent Id must throw KeyNotFoundException"); } // ── Helper ─────────────────────────────────────────────────────────────── private static ChargeableCharConfigRepository BuildRepository() => new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb)); }