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 (MedioId, Symbol) pairs clean their own state before mutating. /// /// 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 GetActiveForMedioAsync — medio has override → returns both medio and global rows /// T4.6 GetActiveForMedioAsync — no medio 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) /// [Collection("Database")] public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime { private readonly SqlTestFixture _db; private int _medioId; public ChargeableCharConfigRepositoryIntegrationTests(SqlTestFixture db) { _db = db; } public async Task InitializeAsync() { await _db.ResetAndSeedAsync(); // Create a dedicated Medio for per-medio tests await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); _medioId = await conn.ExecuteScalarAsync(""" INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) OUTPUT INSERTED.Id VALUES ('REPO_TEST', 'Medio RepoTest', 1, 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-medio so it doesn't conflict const string symbol = "@"; var repo = BuildRepository(); var newId = await repo.InsertWithCloseAsync( medioId: (long?)_medioId, 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( medioId: (long?)_medioId, 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( medioId: (long?)_medioId, 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( medioId: (long?)_medioId, 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( medioId: (long?)_medioId, 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( medioId: (long?)_medioId, 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( medioId: (long?)_medioId, 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 — GetActiveForMedioAsync: medio has override → returns both medio and global rows // Note: SP returns ALL rows (global + per-medio); service does priority resolution. // This test verifies the REPOSITORY returns both, not just one. // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task GetActiveForMedioAsync_MedioHasOverride_ReturnsBothMedioAndGlobalRows() { var repo = BuildRepository(); var asOf = new DateOnly(2026, 6, 1); // Add a per-medio override for symbol '$' // Canonical seed already has global '$' from ResetAndSeedAsync await repo.InsertWithCloseAsync( medioId: (long?)_medioId, symbol: "$", category: "Currency", price: 5.0000m, validFrom: new DateOnly(2026, 1, 1)); var rows = await repo.GetActiveForMedioAsync((long)_medioId, asOf); // The SP returns both the per-medio '$' AND global rows for other symbols // (at minimum: global '$' was replaced by per-medio; other globals still present) // SP uses ROW_NUMBER to pick 1 row per Symbol, preferring per-medio. // So we should get exactly one row per symbol that is active as of asOf. 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!.MedioId.Should().Be(_medioId, "per-medio row takes priority over global in the SP's ROW_NUMBER ordering"); } // ───────────────────────────────────────────────────────────────────────── // T4.6 — GetActiveForMedioAsync: no medio override → returns only global rows // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task GetActiveForMedioAsync_NoMedioOverride_ReturnsOnlyGlobalRows() { var repo = BuildRepository(); var asOf = new DateOnly(2026, 6, 1); // Use a DIFFERENT medioId that has no per-medio rows await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); var otherMedioId = await conn.ExecuteScalarAsync(""" INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) OUTPUT INSERTED.Id VALUES ('REPO_NO_OVRD', 'Medio sin override', 1, 1) """); var rows = await repo.GetActiveForMedioAsync((long)otherMedioId, asOf); rows.Should().NotBeEmpty("canonical seed has 4 global rows active since 2026-01-01"); rows.Should().AllSatisfy(r => r.MedioId.Should().BeNull("all returned rows must be global (MedioId = 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(medioId: null, activeOnly: false, skip: 0, take: 2); var page2 = await repo.ListAsync(medioId: 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(medioId: 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(medioId: null, activeOnly: false); var countActive = await repo.CountAsync(medioId: 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( medioId: (long?)_medioId, symbol: "~", category: "Other", price: 1.0000m, validFrom: new DateOnly(2026, 1, 1)); var today = new DateOnly(2026, 4, 20); var beforeDeactivate = await repo.CountAsync(medioId: (long?)_medioId, activeOnly: true); await repo.DeactivateAsync(id, today); var afterDeactivate = await repo.CountAsync(medioId: (long?)_medioId, 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( medioId: (long?)_medioId, symbol: "^", category: "Other", price: 7.5000m, validFrom: expectedValidFrom); var entity = await repo.GetByIdAsync(id); entity.Should().NotBeNull(); entity!.Id.Should().Be(id); entity.MedioId.Should().Be(_medioId); 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( medioId: (long?)_medioId, 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( medioId: (long?)_medioId, 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); } // ── Helper ─────────────────────────────────────────────────────────────── private static ChargeableCharConfigRepository BuildRepository() => new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb)); }