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("%",

View File

@@ -16,9 +16,8 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// 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. C# method/property renames (InsertWithCloseAsync
/// medioId: param, GetActiveForMedioAsync, entity.MedioId) are deferred to Agent 2 (Backend refactor).
/// This class will FAIL COMPILATION after Agent 2 renames the domain layer — expected.
/// 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
@@ -33,12 +32,16 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// 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 _medioId;
private int _productTypeId;
public ChargeableCharConfigRepositoryIntegrationTests(SqlTestFixture db)
{
@@ -49,14 +52,15 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
{
await _db.ResetAndSeedAsync();
// Create a dedicated Medio for per-medio tests
// 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();
_medioId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
_productTypeId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id
VALUES ('REPO_TEST', 'Medio RepoTest', 1, 1)
VALUES ('RepoIntegration PT1', 'RI_PT1', 1)
""");
}
@@ -69,16 +73,16 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
[Fact]
public async Task InsertWithCloseAsync_FirstInsertForSymbol_CreatesRowAndReturnsId()
{
// NEW symbol not in canonical seed — use per-medio so it doesn't conflict
// 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(
medioId: (long?)_medioId,
symbol: symbol,
category: "Other",
price: 2.5000m,
validFrom: new DateOnly(2026, 1, 1));
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");
@@ -108,20 +112,20 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// 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));
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(
medioId: (long?)_medioId,
symbol: symbol,
category: "Other",
price: 2.0000m,
validFrom: secondValidFrom);
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");
@@ -157,19 +161,19 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// 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));
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(
medioId: (long?)_medioId,
symbol: symbol,
category: "Currency",
price: 1.2000m,
validFrom: new DateOnly(2026, 3, 1));
productTypeId: (long?)_productTypeId,
symbol: symbol,
category: "Currency",
price: 1.2000m,
validFrom: new DateOnly(2026, 3, 1));
await act.Should()
.ThrowAsync<ChargeableCharConfigForwardOnlyException>(
@@ -188,19 +192,19 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// 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));
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(
medioId: (long?)_medioId,
symbol: symbol,
category: "Currency",
price: 4.0000m,
validFrom: new DateOnly(2026, 7, 1));
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();
@@ -214,64 +218,61 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
}
// ─────────────────────────────────────────────────────────────────────────
// T4.5 — GetActiveForMedioAsync: medio has override → returns both medio and global rows
// Note: SP returns ALL rows (global + per-medio); service does priority resolution.
// 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 GetActiveForMedioAsync_MedioHasOverride_ReturnsBothMedioAndGlobalRows()
public async Task GetActiveForProductTypeAsync_PTHasOverride_ReturnsBothPTAndGlobalRows()
{
var repo = BuildRepository();
var asOf = new DateOnly(2026, 6, 1);
// Add a per-medio override for symbol '$'
// Add a per-PT 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));
productTypeId: (long?)_productTypeId,
symbol: "$",
category: "Currency",
price: 5.0000m,
validFrom: new DateOnly(2026, 1, 1));
var rows = await repo.GetActiveForMedioAsync((long)_medioId, asOf);
var rows = await repo.GetActiveForProductTypeAsync((long)_productTypeId, 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.
// 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!.MedioId.Should().Be(_medioId,
"per-medio row takes priority over global in the SP's ROW_NUMBER ordering");
dollarRow!.ProductTypeId.Should().Be(_productTypeId,
"per-PT row takes priority over global in the SP's ROW_NUMBER ordering");
}
// ─────────────────────────────────────────────────────────────────────────
// T4.6 — GetActiveForMedioAsync: no medio override → returns only global rows
// T4.6 — GetActiveForProductTypeAsync: no PT override → returns only global rows
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task GetActiveForMedioAsync_NoMedioOverride_ReturnsOnlyGlobalRows()
public async Task GetActiveForProductTypeAsync_NoPTOverride_ReturnsOnlyGlobalRows()
{
var repo = BuildRepository();
var asOf = new DateOnly(2026, 6, 1);
// Use a DIFFERENT medioId that has no per-medio rows
// Use a DIFFERENT productTypeId that has no per-PT rows
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var otherMedioId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
var otherPTId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id
VALUES ('REPO_NO_OVRD', 'Medio sin override', 1, 1)
VALUES ('RepoIntegration PT2 NoOverride', 'RI_PT2', 1)
""");
var rows = await repo.GetActiveForMedioAsync((long)otherMedioId, asOf);
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.MedioId.Should().BeNull("all returned rows must be global (MedioId = NULL)"));
r.ProductTypeId.Should().BeNull("all returned rows must be global (ProductTypeId = NULL)"));
}
// ─────────────────────────────────────────────────────────────────────────
@@ -284,8 +285,8 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
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);
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");
@@ -299,7 +300,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
public async Task ListAsync_PageBeyondTotal_ReturnsEmpty()
{
var repo = BuildRepository();
var result = await repo.ListAsync(medioId: null, activeOnly: false, skip: 1000, take: 10);
var result = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 1000, take: 10);
result.Should().BeEmpty("skip far beyond available data must return empty");
}
@@ -313,8 +314,8 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
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);
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)");
@@ -331,18 +332,18 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// 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));
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(medioId: (long?)_medioId, activeOnly: true);
var beforeDeactivate = await repo.CountAsync(productTypeId: (long?)_productTypeId, activeOnly: true);
await repo.DeactivateAsync(id, today);
var afterDeactivate = await repo.CountAsync(medioId: (long?)_medioId, activeOnly: true);
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");
@@ -371,17 +372,17 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var expectedValidFrom = new DateOnly(2026, 2, 1);
var id = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId,
symbol: "^",
category: "Other",
price: 7.5000m,
validFrom: expectedValidFrom);
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.MedioId.Should().Be(_medioId);
entity.ProductTypeId.Should().Be(_productTypeId);
entity.Symbol.Should().Be("^");
entity.Category.Should().Be("Other");
entity.PricePerUnit.Should().Be(7.5000m);
@@ -400,11 +401,11 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var repo = BuildRepository();
var id = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId,
symbol: "&",
category: "Other",
price: 1.0000m,
validFrom: new DateOnly(2026, 1, 1));
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);
@@ -426,11 +427,11 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var repo = BuildRepository();
var id = await repo.InsertWithCloseAsync(
medioId: (long?)_medioId,
symbol: "*",
category: "Other",
price: 1.0000m,
validFrom: new DateOnly(2026, 1, 1));
productTypeId: (long?)_productTypeId,
symbol: "*",
category: "Other",
price: 1.0000m,
validFrom: new DateOnly(2026, 1, 1));
var today = new DateOnly(2026, 4, 20);
@@ -447,6 +448,101 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
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()