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.
400 lines
20 KiB
C#
400 lines
20 KiB
C#
using Dapper;
|
|
using FluentAssertions;
|
|
using Microsoft.Data.SqlClient;
|
|
using SIGCM2.Infrastructure.Persistence;
|
|
using SIGCM2.TestSupport;
|
|
using Xunit;
|
|
|
|
namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// usp_ChargeableCharConfig_InsertWithClose — only 1 winner, SERIALIZABLE guard holds.
|
|
///
|
|
/// T7.2 SYSTEM_VERSIONING: exact row count before/after close (0 → 1).
|
|
///
|
|
/// T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot query at T0 returns pre-close state.
|
|
///
|
|
/// 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.
|
|
/// </summary>
|
|
[Collection("Database")]
|
|
public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
|
|
{
|
|
private readonly SqlTestFixture _db;
|
|
// 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)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await _db.ResetAndSeedAsync();
|
|
|
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
|
await conn.OpenAsync();
|
|
|
|
// 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, Codigo, Activo)
|
|
OUTPUT INSERTED.Id
|
|
VALUES ('Hardening PT1 (override)', 'H_PT1', 1)
|
|
""");
|
|
|
|
_productType2Id = await conn.ExecuteScalarAsync<int>("""
|
|
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
|
|
OUTPUT INSERTED.Id
|
|
VALUES ('Hardening PT2 (fallback)', 'H_PT2', 1)
|
|
""");
|
|
}
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// T7.1 — Concurrency: only one winner survives the race
|
|
//
|
|
// 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 (ProductTypeId=NULL, Symbol).
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Concurrency_ThreeParallelInserts_ExactlyOneWins()
|
|
{
|
|
// Unique symbol so this test doesn't conflict with other tests or seed data.
|
|
const string symbol = "¢";
|
|
const string category = "Currency";
|
|
|
|
// Barrier: all tasks must wait until released simultaneously to maximize the race.
|
|
var barrier = new SemaphoreSlim(0, 3);
|
|
|
|
async Task<Exception?> TryInsert(decimal price)
|
|
{
|
|
await barrier.WaitAsync();
|
|
try
|
|
{
|
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
|
await conn.OpenAsync();
|
|
|
|
var p = new DynamicParameters();
|
|
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);
|
|
p.Add("@ValidFrom", new DateTime(2027, 9, 1), 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",
|
|
p,
|
|
commandType: System.Data.CommandType.StoredProcedure);
|
|
|
|
return null; // winner
|
|
}
|
|
catch (SqlException ex)
|
|
{
|
|
// Expected losers: 50409 (forward-only), 2601/2627 (unique index), 1205 (deadlock)
|
|
return ex;
|
|
}
|
|
}
|
|
|
|
var t1 = Task.Run(() => TryInsert(1.00m));
|
|
var t2 = Task.Run(() => TryInsert(2.00m));
|
|
var t3 = Task.Run(() => TryInsert(3.00m));
|
|
|
|
// Release all three simultaneously to create a genuine race.
|
|
barrier.Release(3);
|
|
|
|
var results = await Task.WhenAll(t1, t2, t3);
|
|
|
|
var successes = results.Count(r => r is null);
|
|
var failures = results.Count(r => r is not null);
|
|
|
|
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 (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 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 (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 ProductTypeId IS NULL
|
|
AND Symbol = @Symbol
|
|
""", new { Symbol = symbol });
|
|
|
|
total.Should().Be(1, "no duplicate rows must exist — SERIALIZABLE guard ensures only one insert commits");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// T7.2 — SYSTEM_VERSIONING: exact history row count before and after close
|
|
//
|
|
// Before any UPDATE: history table has 0 rows for the new Id.
|
|
// After InsertWithClose closes the previous row (UPDATE): exactly 1 history row.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task SystemVersioning_HistoryCount_IsZeroBeforeClose_AndOneAfter()
|
|
{
|
|
const string symbol = "₽";
|
|
|
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
|
await conn.OpenAsync();
|
|
|
|
// Insert first row (becomes vigente)
|
|
var firstId = await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 1.0m, new DateTime(2026, 1, 1));
|
|
|
|
// Before any UPDATE: history table must have 0 rows for firstId
|
|
var histBefore = await conn.ExecuteScalarAsync<int>("""
|
|
SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id
|
|
""", new { Id = firstId });
|
|
|
|
histBefore.Should().Be(0,
|
|
"SYSTEM_VERSIONING only creates history rows on UPDATE/DELETE — INSERT produces no history row");
|
|
|
|
// Insert second row which closes (UPDATEs) the first
|
|
await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 2.0m, new DateTime(2026, 7, 1));
|
|
|
|
// After the UPDATE (close): exactly 1 history row for firstId
|
|
var histAfter = await conn.ExecuteScalarAsync<int>("""
|
|
SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id
|
|
""", new { Id = firstId });
|
|
|
|
histAfter.Should().Be(1,
|
|
"SYSTEM_VERSIONING must produce exactly one history row when the vigente row is closed via UPDATE");
|
|
|
|
// Verify: the history row captured the pre-close state (ValidTo was NULL before the UPDATE)
|
|
var histRow = await conn.QuerySingleOrDefaultAsync<dynamic>("""
|
|
SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig_History WHERE Id = @Id
|
|
""", new { Id = firstId });
|
|
|
|
((object?)histRow).Should().NotBeNull("history row must exist after close");
|
|
|
|
// The history captures the state as it was BEFORE the UPDATE:
|
|
// before close, the row had ValidTo = NULL and IsActive = 1.
|
|
((object?)histRow!.ValidTo).Should().BeNull(
|
|
"the history row captures the state before the close UPDATE — ValidTo was NULL at that point");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// T7.3 — FOR SYSTEM_TIME AS OF: temporal snapshot returns pre-close state
|
|
//
|
|
// Create row at T0 → query FOR SYSTEM_TIME AS OF T0 → returns row with ValidTo = NULL.
|
|
// After close → current query → row has ValidTo != NULL.
|
|
// This validates that SYSTEM_VERSIONING preserves immutable history.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ForSystemTimeAsOf_ReturnsSnapshotAtT0_BeforeClose()
|
|
{
|
|
const string symbol = "₿";
|
|
|
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
|
await conn.OpenAsync();
|
|
|
|
// Insert first row and capture the UTC timestamp immediately after
|
|
var firstId = await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 5.0m, new DateTime(2026, 3, 1));
|
|
|
|
// Capture T0: the SYSUTCDATETIME() right after INSERT (row is still active)
|
|
var t0 = await conn.ExecuteScalarAsync<DateTime>("SELECT SYSUTCDATETIME()");
|
|
|
|
// Wait 200ms so the SYSTEM_VERSIONING SysEndTime is strictly after T0
|
|
// (SQL Server DATETIME2 has ~100ns precision — 200ms is more than sufficient)
|
|
await Task.Delay(200);
|
|
|
|
// Insert second row — this closes (UPDATEs) the first, sending it to history
|
|
await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 6.0m, new DateTime(2026, 8, 1));
|
|
|
|
// FOR SYSTEM_TIME AS OF T0: must return the first row in its pre-close state
|
|
var snapshotRow = await conn.QuerySingleOrDefaultAsync<dynamic>("""
|
|
SELECT Id, PricePerUnit, ValidTo
|
|
FROM dbo.ChargeableCharConfig
|
|
FOR SYSTEM_TIME AS OF @T0
|
|
WHERE Id = @Id
|
|
""", new { T0 = t0, Id = firstId });
|
|
|
|
((object?)snapshotRow).Should().NotBeNull(
|
|
"FOR SYSTEM_TIME AS OF T0 must return the row as it existed at T0 (before the close UPDATE)");
|
|
|
|
((decimal)snapshotRow!.PricePerUnit).Should().Be(5.0m,
|
|
"snapshot must reflect the original price before the close");
|
|
|
|
((object?)snapshotRow.ValidTo).Should().BeNull(
|
|
"at T0 the row was vigente (ValidTo IS NULL) — snapshot must preserve this");
|
|
|
|
// Current state: first row must now have ValidTo != NULL (it was closed)
|
|
var currentRow = await conn.QuerySingleOrDefaultAsync<dynamic>("""
|
|
SELECT ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id
|
|
""", new { Id = firstId });
|
|
|
|
((object?)currentRow).Should().NotBeNull("the closed row still exists in the current table");
|
|
((object?)currentRow!.ValidTo).Should().NotBeNull(
|
|
"after the close, the current row has ValidTo set — it is no longer vigente");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// T7.4 — Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync
|
|
//
|
|
// V023 scope delta: MedioId → ProductTypeId in table + SP.
|
|
//
|
|
// Scenario:
|
|
// - 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 '$'
|
|
//
|
|
// GetActiveConfigForProductTypeAsync(PT1, today) → '$' = 5.00 (per-PT override wins)
|
|
// GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback)
|
|
//
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetActiveConfigForProductType_Override_WinsOverGlobal()
|
|
{
|
|
var asOf = new DateOnly(2026, 6, 1);
|
|
|
|
// 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, _productType1Id, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1));
|
|
|
|
// Build the repository + service (C# method will be renamed in Agent 2)
|
|
var repo = BuildRepository();
|
|
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!.ProductTypeId.Should().Be(_productType1Id,
|
|
"the per-PT row (ProductTypeId = PT1) must take priority over the global row");
|
|
|
|
dollarRow.PricePerUnit.Should().Be(5.0000m,
|
|
"ProductType1 override has price 5.00, not the global 0.00");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetActiveConfigForProductType_NoOverride_FallsBackToGlobal()
|
|
{
|
|
var asOf = new DateOnly(2026, 6, 1);
|
|
|
|
// 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.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");
|
|
|
|
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
|
|
dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)");
|
|
|
|
dollarRow!.ProductTypeId.Should().BeNull(
|
|
"ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetActiveConfigForProductType_ServiceLayer_AppliesPriorityCorrectly()
|
|
{
|
|
// End-to-end: IChargeableCharConfigService resolves the final dictionary.
|
|
// 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, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1));
|
|
|
|
// Build the service (wraps repo with priority resolution)
|
|
var service = BuildService();
|
|
|
|
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("%",
|
|
"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");
|
|
|
|
// 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? productTypeId,
|
|
string symbol,
|
|
string category,
|
|
decimal pricePerUnit,
|
|
DateTime validFrom)
|
|
{
|
|
var p = new DynamicParameters();
|
|
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",
|
|
p,
|
|
commandType: System.Data.CommandType.StoredProcedure);
|
|
|
|
return p.Get<long>("@NewId");
|
|
}
|
|
|
|
private static ChargeableCharConfigRepository BuildRepository()
|
|
=> new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb));
|
|
|
|
private static SIGCM2.Application.Pricing.ChargeableChars.ChargeableCharConfigService BuildService()
|
|
=> new(BuildRepository());
|
|
}
|