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:
@@ -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("%",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user