refactor(bd): V023+V024 ChargeableCharConfig por ProductType + SP ReactivateWithGuard (PRC-001)

BREAKING: schema refactor pre-merge. Backend+frontend do not compile yet;
subsequent commits in this PR restore compilation. Acceptable only because
feature/PRC-001 is not yet merged to main.

- V023: drop MedioId + FK_Medio, add ProductTypeId + FK_ProductType, rename
  indexes, drop+create SPs InsertWithClose (now @ProductTypeId) and
  GetActiveForProductType (renamed from GetActiveForMedio). NEW SP
  ReactivateWithGuard (A+guard pattern for feature 3 of scope delta).
  Drop CK_Price_Positive, add CK_Price_NonNegative (>= 0 for opt-in billing).
- V024: reseed global rows with PricePerUnit = 0.0000 (opt-in billing).
- V023_ROLLBACK + V024_ROLLBACK scripts.
- SqlTestFixture: EnsureV023SchemaAsync, EnsureV024SeedAsync, renamed seed
  method signature (ProductTypeId=NULL + PricePerUnit=0), history table
  TablesToIgnore preserved. HardeningTests seeds dbo.ProductType (not Medio).
- MigrationTests: updated SP existence + column + FK + price assertions.
- RepositoryIntegrationTests + HardeningTests: SQL-level assertions updated;
  C# method/property renames deferred to Agent 2 (backend refactor).
This commit is contained in:
2026-04-21 10:35:38 -03:00
parent 5175cc1ece
commit 5c1675e59a
8 changed files with 1390 additions and 160 deletions

View File

@@ -8,7 +8,7 @@ using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// <summary>
/// PRC-001 Batch 7 — Integration hardening tests for ChargeableCharConfig.
/// PRC-001 — Integration hardening tests for ChargeableCharConfig.
/// Covers cross-cutting concerns not addressed by individual layer batches:
///
/// T7.1 Concurrency: SemaphoreSlim barrier forces genuine parallel race on
@@ -18,9 +18,11 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
///
/// T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot query at T0 returns pre-close state.
///
/// T7.4 Per-medio + global fallback resolution via GetActiveForMedioAsync:
/// - ELDIA override for '$' → per-medio row returned at priority
/// - ELPLATA (no override) → global fallback returned
/// T7.4 Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync:
/// - ProductType1 override for '$' → per-PT row returned at priority
/// - ProductType2 (no override) → global fallback returned
///
/// V023 scope delta: MedioId → ProductTypeId. Seeds use dbo.ProductType rows.
///
/// All tests run against SIGCM2_Test_App (Database collection + SqlTestFixture).
/// Each test seeds its own unique symbols to avoid cross-test interference.
@@ -29,8 +31,10 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
{
private readonly SqlTestFixture _db;
private int _eldiaId;
private int _elplataId;
// V023 scope delta: renamed from _eldiaId/_elplataId to ProductType-based IDs.
// These are ProductType IDs (FK to dbo.ProductType), not Medio IDs.
private int _productType1Id; // has per-PT override (was ELDIA)
private int _productType2Id; // no override, falls back to global (was ELPLATA)
public ChargeableCharConfigHardeningTests(SqlTestFixture db)
{
@@ -44,17 +48,18 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
// Seed two dedicated medios: ELDIA (has per-medio override) and ELPLATA (no override).
_eldiaId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
// 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)
OUTPUT INSERTED.Id
VALUES ('HARD_ELDIA', 'ELDIA Hardening', 1, 1)
VALUES ('Hardening PT1 (override)', 1)
""");
_elplataId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
_productType2Id = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, IsActive)
OUTPUT INSERTED.Id
VALUES ('HARD_ELPLA', 'ELPLATA Hardening', 1, 1)
VALUES ('Hardening PT2 (fallback)', 1)
""");
}
@@ -126,27 +131,28 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
successes.Should().Be(1, "exactly one concurrent InsertWithClose must succeed");
failures.Should().Be(2, "the other two concurrent inserts must fail with SqlException");
// Verify post-race state: exactly 1 vigente row for (NULL, '¢')
// Verify post-race state: exactly 1 vigente row for (ProductTypeId=NULL, '¢')
// V023: MedioId → ProductTypeId; global fallback = ProductTypeId IS NULL
await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await verifyConn.OpenAsync();
var vigente = await verifyConn.ExecuteScalarAsync<int>("""
SELECT COUNT(1)
FROM dbo.ChargeableCharConfig
WHERE MedioId IS NULL
WHERE ProductTypeId IS NULL
AND Symbol = @Symbol
AND ValidTo IS NULL
AND IsActive = 1
""", new { Symbol = symbol });
vigente.Should().Be(1,
"filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (MedioId, Symbol)");
"filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (ProductTypeId, Symbol)");
// No duplicates: total row count must also be 1 (only the winner was inserted)
var total = await verifyConn.ExecuteScalarAsync<int>("""
SELECT COUNT(1)
FROM dbo.ChargeableCharConfig
WHERE MedioId IS NULL
WHERE ProductTypeId IS NULL
AND Symbol = @Symbol
""", new { Symbol = symbol });
@@ -260,115 +266,127 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
}
// ─────────────────────────────────────────────────────────────────────────
// T7.4 — Per-medio + global fallback resolution via GetActiveForMedioAsync
// T7.4 — Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync
//
// V023 scope delta: MedioId → ProductTypeId in table + SP.
//
// Scenario:
// - Global '$' at price 1.00 (seed row from ResetAndSeedAsync / canonical V022 seed)
// - ELDIA-specific '$' at price 5.00 effective from today (per-medio override)
// - ELPLATA has no override for '$'
// - Global '$' at price 0.00 (seed row from ResetAndSeedAsync / canonical V022+V024 seed)
// - ProductType1-specific '$' at price 5.00 effective from 2026-01-01 (per-PT override)
// - ProductType2 has no override for '$'
//
// GetActiveConfigForMedioAsync(ELDIA, today) → '$' = 5.00 (per-medio override wins)
// GetActiveConfigForMedioAsync(ELPLATA, today) → '$' = 1.00 (global fallback)
// 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]
public async Task GetActiveConfigForMedio_EldiaOverride_WinsOverGlobal()
public async Task GetActiveConfigForProductType_Override_WinsOverGlobal()
{
var asOf = new DateOnly(2026, 6, 1);
// Seed per-medio override for ELDIA: '$' at 5.00 effective from 2026-01-01
// Seed per-PT override for ProductType1: '$' at 5.00 effective from 2026-01-01
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
await ExecInsertWithCloseAsync(seedConn, _eldiaId, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1));
await ExecInsertWithCloseAsync(seedConn, _productType1Id, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1));
// Build the repository + service (same as application layer usage)
// Build the repository + service (C# method will be renamed in Agent 2)
var repo = BuildRepository();
var rows = await repo.GetActiveForMedioAsync((long)_eldiaId, asOf);
var rows = await repo.GetActiveForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync
// The per-medio '$' must be returned
// The per-PT '$' must be returned
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("ELDIA has a per-medio '$' override — SP must return it");
dollarRow.Should().NotBeNull("ProductType1 has a per-PT '$' override — SP must return it");
dollarRow!.MedioId.Should().Be(_eldiaId,
"the per-medio row (MedioId = ELDIA) must take priority over the global row");
dollarRow!.MedioId.Should().Be(_productType1Id, // TODO Agent 2: rename to ProductTypeId
"the per-PT row (ProductTypeId = PT1) must take priority over the global row");
dollarRow.PricePerUnit.Should().Be(5.0000m,
"ELDIA override has price 5.00, not the global 1.00");
"ProductType1 override has price 5.00, not the global 0.00");
}
[Fact]
public async Task GetActiveConfigForMedio_ElplataNoOverride_FallsBackToGlobal()
public async Task GetActiveConfigForProductType_NoOverride_FallsBackToGlobal()
{
var asOf = new DateOnly(2026, 6, 1);
// ELPLATA has no per-medio rows — the canonical global seed from ResetAndSeedAsync
// provides '$' at global price.
// 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)_elplataId, asOf);
var rows = await repo.GetActiveForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync
// Must have at least the global '$' from seed
rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01");
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("global '$' must be returned for ELPLATA (no override exists)");
dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)");
dollarRow!.MedioId.Should().BeNull(
"ELPLATA has no override — the returned row must be the global row (MedioId = NULL)");
dollarRow!.MedioId.Should().BeNull( // TODO Agent 2: rename to ProductTypeId
"ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)");
}
[Fact]
public async Task GetActiveConfigForMedio_ServiceLayer_AppliesPriorityCorrectly()
public async Task GetActiveConfigForProductType_ServiceLayer_AppliesPriorityCorrectly()
{
// End-to-end: IChargeableCharConfigService resolves the final dictionary.
// Seed ELDIA override for '%' (percentage) at 3.00; global '%' at 2.00 (from V022 seed).
// Seed ProductType1 override for '%' (percentage) at 3.00; global '%' at 0.00 (from V024 seed).
var asOf = new DateOnly(2026, 6, 1);
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
await ExecInsertWithCloseAsync(seedConn, _eldiaId, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1));
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 eldiaConfig = await service.GetActiveConfigForMedioAsync((long)_eldiaId, asOf);
var elplataConfig = await service.GetActiveConfigForMedioAsync((long)_elplataId, asOf);
var pt1Config = await service.GetActiveConfigForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2
var pt2Config = await service.GetActiveConfigForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2
// ELDIA: '%' must come from per-medio override at 3.00
eldiaConfig.Should().ContainKey("%",
"ELDIA has a per-medio override for '%'");
eldiaConfig["%"].PricePerUnit.Should().Be(3.0000m,
"per-medio '%' at 3.00 must override the global 2.00 for ELDIA");
// ProductType1: '%' must come from per-PT override at 3.00
pt1Config.Should().ContainKey("%",
"ProductType1 has a per-PT override for '%'");
pt1Config["%"].PricePerUnit.Should().Be(3.0000m,
"per-PT '%' at 3.00 must override the global 0.00 for ProductType1");
// ELPLATA: '%' must come from global fallback
// Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId).
// We verify via price: global '%' is seeded at 2.00 by V022.
elplataConfig.Should().ContainKey("%",
"global '%' from canonical seed must appear in ELPLATA's resolved config");
// V022 seeds global '%' at 1.0000 (placeholder — see V022__seed_chargeable_char_config.sql)
elplataConfig["%"].PricePerUnit.Should().Be(1.0000m,
"ELPLATA falls back to the global '%' at 1.00 (V022 seed placeholder price)");
// ProductType2: '%' must come from global fallback
// Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId/ProductTypeId).
// We verify via price: global '%' is seeded at 0.0000 by V024 (opt-in billing, was 1.0000 in V022).
pt2Config.Should().ContainKey("%",
"global '%' from canonical seed must appear in ProductType2's resolved config");
// V024 resets global '%' to 0.0000 (opt-in billing — V022 placeholder 1.0000 replaced)
pt2Config["%"].PricePerUnit.Should().Be(0.0000m,
"ProductType2 falls back to the global '%' at 0.00 (V024 seed opt-in billing price)");
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Helper: calls usp_ChargeableCharConfig_InsertWithClose directly via SQL.
/// V023 scope delta: parameter renamed from @MedioId to @ProductTypeId.
/// </summary>
private static async Task<long> ExecInsertWithCloseAsync(
SqlConnection conn,
int? medioId,
int? productTypeId,
string symbol,
string category,
decimal pricePerUnit,
DateTime validFrom)
{
var p = new DynamicParameters();
p.Add("@MedioId", medioId, System.Data.DbType.Int32);
p.Add("@Symbol", symbol, System.Data.DbType.String);
p.Add("@Category", category, System.Data.DbType.String);
p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4);
p.Add("@ValidFrom", validFrom, System.Data.DbType.Date);
p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
p.Add("@ProductTypeId", productTypeId, System.Data.DbType.Int32); // V023: was @MedioId
p.Add("@Symbol", symbol, System.Data.DbType.String);
p.Add("@Category", category, System.Data.DbType.String);
p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4);
p.Add("@ValidFrom", validFrom, System.Data.DbType.Date);
p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
await conn.ExecuteAsync(
"dbo.usp_ChargeableCharConfig_InsertWithClose",