Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs
dmolinari f7fb76219a refactor+feat(backend): ChargeableCharConfig por ProductType + Reactivate + Delete endpoints (PRC-001)
Part A — MedioId → ProductTypeId rename across all C# layers:
  Domain, Application, Infrastructure, API, all test projects.
  Solution was non-compilable after BD refactor (5c1675e); now compiles clean (0 errors).

Part B — PATCH /api/v1/admin/chargeable-chars/{id}/reactivate:
  ReactivateChargeableCharConfigCommand/Handler, SP guard maps 50410/50411/50412
  → ChargeableCharConfigReactivationNotAllowedException(Reason) → HTTP 409.

Part C — DELETE /api/v1/admin/chargeable-chars/{id}:
  DeleteChargeableCharConfigCommand/Handler, physical DELETE on SYSTEM_VERSIONED table.
  KeyNotFoundException → 404 via ExceptionFilter.

Tests: +30 unit tests (TDD RED→GREEN). All 1266 unit tests pass.
2026-04-21 10:54:47 -03:00

551 lines
27 KiB
C#

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;
/// <summary>
/// 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
/// </summary>
[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<int>("""
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<dynamic>(
"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<dynamic>(
"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<dynamic>(
"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<ChargeableCharConfigForwardOnlyException>(
"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<int>(
"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<int>("""
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<ChargeableCharConfigReactivationNotAllowedException>()
.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<KeyNotFoundException>(
"non-existent Id must throw KeyNotFoundException");
}
// ── Helper ───────────────────────────────────────────────────────────────
private static ChargeableCharConfigRepository BuildRepository()
=> new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb));
}