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:
@@ -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