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

@@ -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);

View File

@@ -24,7 +24,7 @@ public class CreateChargeableCharConfigCommandValidatorTests
}
private static CreateChargeableCharConfigCommand ValidCmd() => new(
MedioId: null,
ProductTypeId: null,
Symbol: "$",
Category: ChargeableCharCategories.Currency,
PricePerUnit: 1.0m,

View File

@@ -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);

View File

@@ -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>());
}
}

View File

@@ -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>());

View File

@@ -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>());
}
}