From 3b1edfd696be896056ee54d50aa9fddc7a389006 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:32:17 -0300 Subject: [PATCH] feat(infrastructure): ChargeableCharConfigRepository Dapper + SP invocation (PRC-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChargeableCharConfigRepository implements IChargeableCharConfigRepository via Dapper - InsertWithCloseAsync calls usp_ChargeableCharConfig_InsertWithClose with OUTPUT params; maps SqlException 50409 → ChargeableCharConfigForwardOnlyException, 50404 → ChargeableCharConfigInvalidException - GetActiveForMedioAsync calls usp_ChargeableCharConfig_GetActiveForMedio; returns all rows (global + per-medio) — Application service handles priority resolution - ListAsync / CountAsync use parameterized SQL with OFFSET/FETCH and NULL-aware MedioId filter - GetByIdAsync / DeactivateAsync cover single-entity read and idempotent deactivation - DateOnly mapping: DateTime → DateOnly.FromDateTime() pattern, same as ProductPriceRepository - Registered IChargeableCharConfigRepository → ChargeableCharConfigRepository in DI - 14 integration tests against SIGCM2_Test_App (all GREEN); 1571/1571 total tests pass --- .../DependencyInjection.cs | 2 + .../ChargeableCharConfigRepository.cs | 248 ++++++++++ ...bleCharConfigRepositoryIntegrationTests.cs | 450 ++++++++++++++++++ 3 files changed, 700 insertions(+) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 20e5090..24e06a3 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -45,6 +45,8 @@ public static class DependencyInjection services.AddScoped(); // PRD-003: ProductPrices históricos services.AddScoped(); + // PRC-001: ChargeableCharConfig — caracteres especiales tasables + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs new file mode 100644 index 0000000..1440cd4 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs @@ -0,0 +1,248 @@ +using System.Data; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Pricing.ChargeableChars; +using SIGCM2.Domain.Pricing.Exceptions; + +namespace SIGCM2.Infrastructure.Persistence; + +/// +/// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig. +/// +/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps: +/// - SqlException 50404 → ChargeableCharConfigInvalidException (Medio not found) +/// - SqlException 50409 → ChargeableCharConfigForwardOnlyException +/// +/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio. +/// Returns all rows (global + per-medio) — the Application service applies priority. +/// +/// DateOnly mapping: SQL DATE columns are received as DateTime by Dapper; converted via +/// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository. +/// +/// MedioId: the SP accepts INT NULL; int? cast from long? is performed in this layer. +/// +public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository +{ + private readonly SqlConnectionFactory _factory; + + public ChargeableCharConfigRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + /// + public async Task InsertWithCloseAsync( + long? medioId, + string symbol, + string category, + decimal price, + DateOnly validFrom, + CancellationToken ct = default) + { + var p = new DynamicParameters(); + // SP parameter is INT NULL — cast long? → int? here; DB uses INT for MedioId (V021) + p.Add("@MedioId", medioId.HasValue ? (int?)checked((int)medioId.Value) : null, DbType.Int32); + p.Add("@Symbol", symbol, DbType.String, size: 4); + p.Add("@Category", category, DbType.String, size: 32); + p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date); + p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output); + p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output); + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + try + { + await connection.ExecuteAsync( + new CommandDefinition( + "dbo.usp_ChargeableCharConfig_InsertWithClose", + p, + commandType: CommandType.StoredProcedure, + cancellationToken: ct)); + } + catch (SqlException ex) when (ex.Number == 50404) + { + // Medio not found (SP validates MedioId when not null) + throw new ChargeableCharConfigInvalidException( + nameof(medioId), + $"Medio with Id={medioId} not found."); + } + catch (SqlException ex) when (ex.Number == 50409) + { + // Forward-only violation: new ValidFrom <= active.ValidFrom + throw new ChargeableCharConfigForwardOnlyException( + medioId.HasValue ? (int?)checked((int)medioId.Value) : null, + symbol, + validFrom, + DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder + } + + return p.Get("@NewId"); + } + + /// + public async Task> GetActiveForMedioAsync( + long medioId, + DateOnly asOfDate, + CancellationToken ct = default) + { + var p = new DynamicParameters(); + // SP @MedioId is INT + p.Add("@MedioId", checked((int)medioId), DbType.Int32); + p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date); + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync( + new CommandDefinition( + "dbo.usp_ChargeableCharConfig_GetActiveForMedio", + p, + commandType: CommandType.StoredProcedure, + cancellationToken: ct)); + + return rows.Select(MapRow).ToList(); + } + + /// + public async Task> ListAsync( + long? medioId, + bool activeOnly, + int skip, + int take, + CancellationToken ct = default) + { + // NULL-aware MedioId filter: + // - medioId provided → filter to that medio only + // - medioId null → return all rows regardless of medio + // activeOnly filters by IsActive = 1. + const string sql = """ + SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM dbo.ChargeableCharConfig + WHERE (@MedioId IS NULL OR MedioId = @MedioId) + AND (@ActiveOnly = 0 OR IsActive = 1) + ORDER BY ValidFrom DESC, Id DESC + OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync( + new CommandDefinition( + sql, + new + { + MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null, + ActiveOnly = activeOnly ? 1 : 0, + Skip = skip, + Take = take + }, + cancellationToken: ct)); + + return rows.Select(MapRow).ToList(); + } + + /// + public async Task CountAsync( + long? medioId, + bool activeOnly, + CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) + FROM dbo.ChargeableCharConfig + WHERE (@MedioId IS NULL OR MedioId = @MedioId) + AND (@ActiveOnly = 0 OR IsActive = 1) + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync( + new CommandDefinition( + sql, + new + { + MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null, + ActiveOnly = activeOnly ? 1 : 0 + }, + cancellationToken: ct)); + } + + /// + public async Task GetByIdAsync( + long id, + CancellationToken ct = default) + { + const string sql = """ + SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM dbo.ChargeableCharConfig + WHERE Id = @Id + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(sql, new { Id = id }, cancellationToken: ct)); + + return row is null ? null : MapRow(row); + } + + /// + public async Task DeactivateAsync( + long id, + DateOnly today, + CancellationToken ct = default) + { + // Idempotent: WHERE ... AND IsActive = 1 — no-op if already inactive. + const string sql = """ + UPDATE dbo.ChargeableCharConfig + SET IsActive = 0, + ValidTo = @Today + WHERE Id = @Id + AND IsActive = 1 + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + await connection.ExecuteAsync( + new CommandDefinition( + sql, + new + { + Id = id, + Today = today.ToDateTime(TimeOnly.MinValue) + }, + cancellationToken: ct)); + } + + // ── Row mapper ──────────────────────────────────────────────────────────── + // Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here. + // Same pattern as ProductPriceRepository. + + private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r) + => ChargeableCharConfig.Rehydrate( + id: r.Id, + medioId: r.MedioId, + symbol: r.Symbol, + category: r.Category, + price: r.PricePerUnit, + validFrom: DateOnly.FromDateTime(r.ValidFrom), + validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null, + isActive: r.IsActive); + + private sealed record ChargeableCharConfigRow( + long Id, + int? MedioId, + string Symbol, + string Category, + decimal PricePerUnit, + DateTime ValidFrom, + DateTime? ValidTo, + bool IsActive); +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs new file mode 100644 index 0000000..63a4a6d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs @@ -0,0 +1,450 @@ +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)); +}