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:
@@ -0,0 +1,32 @@
|
||||
using FluentAssertions;
|
||||
using SIGCM2.Domain.Pricing.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Domain.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Unit tests for ChargeableCharConfigReactivationNotAllowedException.
|
||||
/// </summary>
|
||||
public sealed class ChargeableCharConfigReactivationNotAllowedExceptionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ALREADY_ACTIVE")]
|
||||
[InlineData("VIGENTE_EXISTS")]
|
||||
[InlineData("POSTERIOR_ROWS_EXIST")]
|
||||
public void Constructor_SetsIdAndReason(string reason)
|
||||
{
|
||||
var ex = new ChargeableCharConfigReactivationNotAllowedException(42L, reason);
|
||||
|
||||
ex.Id.Should().Be(42L);
|
||||
ex.Reason.Should().Be(reason);
|
||||
ex.Message.Should().Contain("42");
|
||||
ex.Message.Should().Contain(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exception_IsDomainException()
|
||||
{
|
||||
var ex = new ChargeableCharConfigReactivationNotAllowedException(1L, "ALREADY_ACTIVE");
|
||||
|
||||
ex.Should().BeAssignableTo<SIGCM2.Domain.Exceptions.DomainException>();
|
||||
}
|
||||
}
|
||||
@@ -95,15 +95,15 @@ public sealed class ChargeableCharConfigTests
|
||||
entity.Category.Should().Be("Currency");
|
||||
entity.PricePerUnit.Should().Be(1.5m);
|
||||
entity.ValidFrom.Should().Be(Today);
|
||||
entity.MedioId.Should().BeNull();
|
||||
entity.ProductTypeId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithMedioId_SetsCorrectly()
|
||||
public void Create_WithProductTypeId_SetsCorrectly()
|
||||
{
|
||||
var entity = ChargeableCharConfig.Create(5, "$", "Currency", 2.0m, Today);
|
||||
|
||||
entity.MedioId.Should().Be(5);
|
||||
entity.ProductTypeId.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -218,11 +218,11 @@ public sealed class ChargeableCharConfigTests
|
||||
{
|
||||
// Rehydrate can create entities that would fail Create (e.g., IsActive=false)
|
||||
var entity = ChargeableCharConfig.Rehydrate(
|
||||
id: 42, medioId: 5, symbol: "$", category: "Currency",
|
||||
id: 42, productTypeId: 5, symbol: "$", category: "Currency",
|
||||
price: 1.5m, validFrom: Today, validTo: Today.AddDays(30), isActive: false);
|
||||
|
||||
entity.Id.Should().Be(42);
|
||||
entity.MedioId.Should().Be(5);
|
||||
entity.ProductTypeId.Should().Be(5);
|
||||
entity.Symbol.Should().Be("$");
|
||||
entity.Category.Should().Be("Currency");
|
||||
entity.PricePerUnit.Should().Be(1.5m);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -26,57 +26,57 @@ public class ChargeableCharConfigServiceTests
|
||||
private static ChargeableCharConfig GlobalConfig(string symbol, decimal price) =>
|
||||
ChargeableCharConfig.Rehydrate(10L, null, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true);
|
||||
|
||||
private static ChargeableCharConfig MedioConfig(long id, int medioId, string symbol, decimal price) =>
|
||||
ChargeableCharConfig.Rehydrate(id, medioId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true);
|
||||
private static ChargeableCharConfig ProductTypeConfig(long id, int productTypeId, string symbol, decimal price) =>
|
||||
ChargeableCharConfig.Rehydrate(id, productTypeId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true);
|
||||
|
||||
// ── Global fallback ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveConfig_NoPerMedio_ReturnsGlobalConfigs()
|
||||
public async Task GetActiveConfig_NoPerProductType_ReturnsGlobalConfigs()
|
||||
{
|
||||
_repo.GetActiveForMedioAsync(1, AsOf, Arg.Any<CancellationToken>())
|
||||
_repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>
|
||||
{
|
||||
GlobalConfig("$", 1.0m),
|
||||
GlobalConfig("%", 0.5m),
|
||||
});
|
||||
|
||||
var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
|
||||
var result = await _service.GetActiveConfigForProductTypeAsync(1, AsOf, CancellationToken.None);
|
||||
|
||||
result.Should().ContainKey("$");
|
||||
result["$"].PricePerUnit.Should().Be(1.0m);
|
||||
result.Should().ContainKey("%");
|
||||
}
|
||||
|
||||
// ── Per-medio wins over global ───────────────────────────────────────────────
|
||||
// ── Per-ProductType wins over global ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveConfig_PerMedioExists_OverridesGlobalForSameSymbol()
|
||||
public async Task GetActiveConfig_PerProductTypeExists_OverridesGlobalForSameSymbol()
|
||||
{
|
||||
_repo.GetActiveForMedioAsync(5, AsOf, Arg.Any<CancellationToken>())
|
||||
_repo.GetActiveForProductTypeAsync(5, AsOf, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>
|
||||
{
|
||||
GlobalConfig("$", 1.0m), // global price = 1.0
|
||||
MedioConfig(20L, 5, "$", 3.0m), // per-medio price = 3.0 → wins
|
||||
GlobalConfig("%", 0.5m), // global only
|
||||
GlobalConfig("$", 1.0m), // global price = 1.0
|
||||
ProductTypeConfig(20L, 5, "$", 3.0m), // per-PT price = 3.0 → wins
|
||||
GlobalConfig("%", 0.5m), // global only
|
||||
});
|
||||
|
||||
var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None);
|
||||
var result = await _service.GetActiveConfigForProductTypeAsync(5, AsOf, CancellationToken.None);
|
||||
|
||||
result["$"].PricePerUnit.Should().Be(3.0m); // per-medio wins
|
||||
result["$"].PricePerUnit.Should().Be(3.0m); // per-PT wins
|
||||
result["%"].PricePerUnit.Should().Be(0.5m); // global only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveConfig_PerMedioExists_IncludesCorrectCategory()
|
||||
public async Task GetActiveConfig_PerProductTypeExists_IncludesCorrectCategory()
|
||||
{
|
||||
_repo.GetActiveForMedioAsync(5, AsOf, Arg.Any<CancellationToken>())
|
||||
_repo.GetActiveForProductTypeAsync(5, AsOf, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>
|
||||
{
|
||||
MedioConfig(20L, 5, "$", 3.0m),
|
||||
ProductTypeConfig(20L, 5, "$", 3.0m),
|
||||
});
|
||||
|
||||
var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None);
|
||||
var result = await _service.GetActiveConfigForProductTypeAsync(5, AsOf, CancellationToken.None);
|
||||
|
||||
result["$"].Category.Should().Be(ChargeableCharCategories.Currency);
|
||||
}
|
||||
@@ -86,10 +86,10 @@ public class ChargeableCharConfigServiceTests
|
||||
[Fact]
|
||||
public async Task GetActiveConfig_NoConfigAtAll_ReturnsEmptyDictionary()
|
||||
{
|
||||
_repo.GetActiveForMedioAsync(1, AsOf, Arg.Any<CancellationToken>())
|
||||
_repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>());
|
||||
|
||||
var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
|
||||
var result = await _service.GetActiveConfigForProductTypeAsync(1, AsOf, CancellationToken.None);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
@@ -99,13 +99,13 @@ public class ChargeableCharConfigServiceTests
|
||||
[Fact]
|
||||
public async Task GetActiveConfig_KeyIsSymbol()
|
||||
{
|
||||
_repo.GetActiveForMedioAsync(1, AsOf, Arg.Any<CancellationToken>())
|
||||
_repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>
|
||||
{
|
||||
GlobalConfig("!", 2.0m),
|
||||
});
|
||||
|
||||
var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
|
||||
var result = await _service.GetActiveConfigForProductTypeAsync(1, AsOf, CancellationToken.None);
|
||||
|
||||
result.Should().ContainKey("!");
|
||||
result["!"].PricePerUnit.Should().Be(2.0m);
|
||||
|
||||
@@ -24,7 +24,7 @@ public class CreateChargeableCharConfigCommandValidatorTests
|
||||
}
|
||||
|
||||
private static CreateChargeableCharConfigCommand ValidCmd() => new(
|
||||
MedioId: null,
|
||||
ProductTypeId: null,
|
||||
Symbol: "$",
|
||||
Category: ChargeableCharCategories.Currency,
|
||||
PricePerUnit: 1.0m,
|
||||
|
||||
@@ -37,7 +37,7 @@ public class CreateChargeableCharConfigHandlerTests
|
||||
}
|
||||
|
||||
private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new(
|
||||
MedioId: null,
|
||||
ProductTypeId: null,
|
||||
Symbol: "$",
|
||||
Category: ChargeableCharCategories.Currency,
|
||||
PricePerUnit: 1.5m,
|
||||
@@ -80,9 +80,9 @@ public class CreateChargeableCharConfigHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithMedioId_PassesMedioIdToRepo()
|
||||
public async Task Handle_WithProductTypeId_PassesProductTypeIdToRepo()
|
||||
{
|
||||
var cmd = ValidCmd() with { MedioId = 7 };
|
||||
var cmd = ValidCmd() with { ProductTypeId = 7 };
|
||||
|
||||
await _handler.Handle(cmd);
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — DeleteChargeableCharConfigCommandHandler tests.
|
||||
/// Strict TDD — RED written before implementation.
|
||||
/// Covers: happy path, not-found, audit emission, audit fail-closed.
|
||||
/// </summary>
|
||||
public class DeleteChargeableCharConfigHandlerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly DeleteChargeableCharConfigCommandHandler _handler;
|
||||
|
||||
private static ChargeableCharConfig SomeConfig() =>
|
||||
ChargeableCharConfig.Rehydrate(
|
||||
id: 1L, productTypeId: null, symbol: "$", category: ChargeableCharCategories.Currency,
|
||||
price: 1.5m, validFrom: new DateOnly(2026, 1, 1), validTo: null, isActive: true);
|
||||
|
||||
public DeleteChargeableCharConfigHandlerTests()
|
||||
{
|
||||
_repo.GetByIdAsync(1L, Arg.Any<CancellationToken>())
|
||||
.Returns(SomeConfig());
|
||||
|
||||
_handler = new DeleteChargeableCharConfigCommandHandler(_repo, _audit, _time);
|
||||
}
|
||||
|
||||
private static DeleteChargeableCharConfigCommand ValidCmd() => new(Id: 1L);
|
||||
|
||||
// ── Happy path ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_ReturnsResponse()
|
||||
{
|
||||
var result = await _handler.Handle(ValidCmd());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().Be(1L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_CallsDeleteAsync()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _repo.Received(1).DeleteAsync(1L, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_EmitsAuditDelete()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "tasacion.chargeable_char.delete",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: "1",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Not found ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ConfigNotFound_ThrowsKeyNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(99L, Arg.Any<CancellationToken>())
|
||||
.Returns((ChargeableCharConfig?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(new DeleteChargeableCharConfigCommand(Id: 99L));
|
||||
|
||||
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
// ── Audit fail → rollback (fail-closed) ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuditThrows_ExceptionPropagates()
|
||||
{
|
||||
_audit.LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Audit down"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("Audit down");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuditThrows_DeleteWasCalled_TransactionNotCompleted()
|
||||
{
|
||||
_audit.LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Audit down"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
|
||||
await _repo.Received(1).DeleteAsync(1L, Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public class ListChargeableCharConfigHandlerTests
|
||||
_repo.CountAsync(null, true, Arg.Any<CancellationToken>())
|
||||
.Returns(2);
|
||||
|
||||
var query = new ListChargeableCharConfigQuery(MedioId: null, ActiveOnly: true, Page: 1, PageSize: 20);
|
||||
var query = new ListChargeableCharConfigQuery(ProductTypeId: null, ActiveOnly: true, Page: 1, PageSize: 20);
|
||||
var result = await _handler.Handle(query);
|
||||
|
||||
result.Items.Should().HaveCount(2);
|
||||
@@ -104,7 +104,7 @@ public class ListChargeableCharConfigHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FiltersByMedioId_WhenProvided()
|
||||
public async Task Handle_FiltersByProductTypeId_WhenProvided()
|
||||
{
|
||||
_repo.ListAsync(7L, true, 0, 20, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ChargeableCharConfig>());
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
using SIGCM2.Domain.Pricing.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — ReactivateChargeableCharConfigCommandHandler tests.
|
||||
/// Strict TDD — RED written before implementation.
|
||||
/// Covers: happy path, audit emission, audit fail-closed, repo exception propagation.
|
||||
/// </summary>
|
||||
public class ReactivateChargeableCharConfigHandlerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly IChargeableCharConfigRepository _repo = Substitute.For<IChargeableCharConfigRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly ReactivateChargeableCharConfigCommandHandler _handler;
|
||||
|
||||
private static readonly DateOnly Today = new(2026, 4, 20);
|
||||
|
||||
private static ChargeableCharConfig ClosedConfig() =>
|
||||
ChargeableCharConfig.Rehydrate(
|
||||
id: 1L, productTypeId: null, symbol: "$", category: ChargeableCharCategories.Currency,
|
||||
price: 1.5m, validFrom: new DateOnly(2026, 1, 1), validTo: new DateOnly(2026, 4, 19), isActive: false);
|
||||
|
||||
private static ChargeableCharConfig ActiveConfig() =>
|
||||
ChargeableCharConfig.Rehydrate(
|
||||
id: 1L, productTypeId: null, symbol: "$", category: ChargeableCharCategories.Currency,
|
||||
price: 1.5m, validFrom: new DateOnly(2026, 1, 1), validTo: null, isActive: true);
|
||||
|
||||
public ReactivateChargeableCharConfigHandlerTests()
|
||||
{
|
||||
_repo.ReactivateAsync(1L, Arg.Any<CancellationToken>())
|
||||
.Returns(ActiveConfig());
|
||||
|
||||
_handler = new ReactivateChargeableCharConfigCommandHandler(_repo, _audit, _time);
|
||||
}
|
||||
|
||||
private static ReactivateChargeableCharConfigCommand ValidCmd() => new(Id: 1L);
|
||||
|
||||
// ── Happy path ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_ReturnsResponse()
|
||||
{
|
||||
var result = await _handler.Handle(ValidCmd());
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().Be(1L);
|
||||
result.Symbol.Should().Be("$");
|
||||
result.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_CallsReactivateAsync()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _repo.Received(1).ReactivateAsync(1L, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_EmitsAuditReactivate()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "tasacion.chargeable_char.reactivate",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: "1",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Guard failures propagate ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlreadyActive_ThrowsReactivationNotAllowed()
|
||||
{
|
||||
_repo.ReactivateAsync(2L, Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new ChargeableCharConfigReactivationNotAllowedException(2L, "ALREADY_ACTIVE"));
|
||||
|
||||
var act = async () => await _handler.Handle(new ReactivateChargeableCharConfigCommand(Id: 2L));
|
||||
|
||||
await act.Should().ThrowAsync<ChargeableCharConfigReactivationNotAllowedException>()
|
||||
.Where(e => e.Reason == "ALREADY_ACTIVE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_VigenteExists_ThrowsReactivationNotAllowed()
|
||||
{
|
||||
_repo.ReactivateAsync(3L, Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new ChargeableCharConfigReactivationNotAllowedException(3L, "VIGENTE_EXISTS"));
|
||||
|
||||
var act = async () => await _handler.Handle(new ReactivateChargeableCharConfigCommand(Id: 3L));
|
||||
|
||||
await act.Should().ThrowAsync<ChargeableCharConfigReactivationNotAllowedException>()
|
||||
.Where(e => e.Reason == "VIGENTE_EXISTS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PosteriorRowsExist_ThrowsReactivationNotAllowed()
|
||||
{
|
||||
_repo.ReactivateAsync(4L, Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new ChargeableCharConfigReactivationNotAllowedException(4L, "POSTERIOR_ROWS_EXIST"));
|
||||
|
||||
var act = async () => await _handler.Handle(new ReactivateChargeableCharConfigCommand(Id: 4L));
|
||||
|
||||
await act.Should().ThrowAsync<ChargeableCharConfigReactivationNotAllowedException>()
|
||||
.Where(e => e.Reason == "POSTERIOR_ROWS_EXIST");
|
||||
}
|
||||
|
||||
// ── Audit fail → rollback (fail-closed) ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuditThrows_ExceptionPropagates()
|
||||
{
|
||||
_audit.LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Audit down"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("Audit down");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AuditThrows_ReactivateWasCalled_TransactionNotCompleted()
|
||||
{
|
||||
_audit.LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("Audit down"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
|
||||
await _repo.Received(1).ReactivateAsync(1L, Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user