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.
This commit is contained in:
2026-04-21 10:54:47 -03:00
parent 5c1675e59a
commit f7fb76219a
35 changed files with 1273 additions and 273 deletions

View File

@@ -51,15 +51,15 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// Seed two dedicated ProductTypes for override/fallback resolution tests.
// V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id).
_productType1Id = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, IsActive)
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id
VALUES ('Hardening PT1 (override)', 1)
VALUES ('Hardening PT1 (override)', 'H_PT1', 1)
""");
_productType2Id = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, IsActive)
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id
VALUES ('Hardening PT2 (fallback)', 1)
VALUES ('Hardening PT2 (fallback)', 'H_PT2', 1)
""");
}
@@ -68,11 +68,11 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// ─────────────────────────────────────────────────────────────────────────
// T7.1 — Concurrency: only one winner survives the race
//
// Three parallel connections try to InsertWithClose for the same (MedioId=null, Symbol).
// 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 (MedioId=NULL, Symbol).
// After resolution: exactly 1 vigente row exists for (ProductTypeId=NULL, Symbol).
// ─────────────────────────────────────────────────────────────────────────
[Fact]
@@ -94,7 +94,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
await conn.OpenAsync();
var p = new DynamicParameters();
p.Add("@MedioId", null, System.Data.DbType.Int32);
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);
@@ -278,10 +278,6 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// GetActiveConfigForProductTypeAsync(PT1, today) → '$' = 5.00 (per-PT override wins)
// GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback)
//
// NOTE: C# method calls (GetActiveForMedioAsync, GetActiveConfigForMedioAsync) will be
// renamed in Agent 2 (Backend refactor). These tests will FAIL COMPILATION until Agent 2.
// SQL-level assertions in this test (the ExecInsertWithCloseAsync helper) are already
// updated for V023 (@ProductTypeId param). The C# repo/service method calls are left as-is.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
@@ -297,13 +293,13 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// Build the repository + service (C# method will be renamed in Agent 2)
var repo = BuildRepository();
var rows = await repo.GetActiveForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync
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!.MedioId.Should().Be(_productType1Id, // TODO Agent 2: rename to ProductTypeId
dollarRow!.ProductTypeId.Should().Be(_productType1Id,
"the per-PT row (ProductTypeId = PT1) must take priority over the global row");
dollarRow.PricePerUnit.Should().Be(5.0000m,
@@ -318,7 +314,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// 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.GetActiveForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync
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");
@@ -326,7 +322,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)");
dollarRow!.MedioId.Should().BeNull( // TODO Agent 2: rename to ProductTypeId
dollarRow!.ProductTypeId.Should().BeNull(
"ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)");
}
@@ -343,11 +339,10 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
await ExecInsertWithCloseAsync(seedConn, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1));
// Build the service (wraps repo with priority resolution)
// TODO Agent 2: rename GetActiveConfigForMedioAsync → GetActiveConfigForProductTypeAsync
var service = BuildService();
var pt1Config = await service.GetActiveConfigForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2
var pt2Config = await service.GetActiveConfigForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2
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("%",