feat(application): DeactivateRubroCommandHandler guard contra Products activos

Extiende IProductQueryRepository con CountActiveByRubroAsync, inyecta
el repositorio en el handler e intercala el chequeo después del guard
de hijos activos. Tests de unidad cubren: throw, success con 0 productos,
y estabilidad del orden de guardas (hijos primero).
This commit is contained in:
2026-04-19 17:08:30 -03:00
parent e9d1e3237d
commit 900fd5e975
3 changed files with 55 additions and 1 deletions

View File

@@ -12,4 +12,10 @@ public interface IProductQueryRepository
/// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products. /// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
/// </summary> /// </summary>
Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default); Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default);
/// <summary>
/// Returns the count of active Products where RubroId = rubroId.
/// Used by DeactivateRubroCommandHandler to guard against orphaning active products. (issue #41)
/// </summary>
Task<int> CountActiveByRubroAsync(int rubroId, CancellationToken ct = default);
} }

View File

@@ -10,15 +10,18 @@ public sealed class DeactivateRubroCommandHandler : ICommandHandler<DeactivateRu
{ {
private readonly IRubroRepository _repo; private readonly IRubroRepository _repo;
private readonly IAuditLogger _audit; private readonly IAuditLogger _audit;
private readonly IProductQueryRepository _productQuery;
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
public DeactivateRubroCommandHandler( public DeactivateRubroCommandHandler(
IRubroRepository repo, IRubroRepository repo,
IAuditLogger audit, IAuditLogger audit,
IProductQueryRepository productQuery,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
_repo = repo; _repo = repo;
_audit = audit; _audit = audit;
_productQuery = productQuery;
_timeProvider = timeProvider; _timeProvider = timeProvider;
} }
@@ -31,6 +34,10 @@ public sealed class DeactivateRubroCommandHandler : ICommandHandler<DeactivateRu
if (activeChildren > 0) if (activeChildren > 0)
throw new RubroTieneHijosActivosException(command.Id, activeChildren); throw new RubroTieneHijosActivosException(command.Id, activeChildren);
var productosActivos = await _productQuery.CountActiveByRubroAsync(command.Id);
if (productosActivos > 0)
throw new RubroConProductosActivosException(command.Id, productosActivos);
var deactivated = target.WithActivo(false, _timeProvider); var deactivated = target.WithActivo(false, _timeProvider);
using var tx = new TransactionScope( using var tx = new TransactionScope(

View File

@@ -13,6 +13,7 @@ public class DeactivateRubroCommandHandlerTests
{ {
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>(); private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>(); private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly IProductQueryRepository _productQuery = Substitute.For<IProductQueryRepository>();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero)); private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
private readonly DeactivateRubroCommandHandler _handler; private readonly DeactivateRubroCommandHandler _handler;
@@ -22,7 +23,8 @@ public class DeactivateRubroCommandHandlerTests
public DeactivateRubroCommandHandlerTests() public DeactivateRubroCommandHandlerTests()
{ {
_repo.CountActiveChildrenAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(0); _repo.CountActiveChildrenAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(0);
_handler = new DeactivateRubroCommandHandler(_repo, _audit, _timeProvider); _productQuery.CountActiveByRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(0);
_handler = new DeactivateRubroCommandHandler(_repo, _audit, _productQuery, _timeProvider);
} }
// ── Happy path: leaf soft-delete ───────────────────────────────────────── // ── Happy path: leaf soft-delete ─────────────────────────────────────────
@@ -103,4 +105,43 @@ public class DeactivateRubroCommandHandlerTests
await act.Should().ThrowAsync<RubroNotFoundException>(); await act.Should().ThrowAsync<RubroNotFoundException>();
} }
// ── Active Products guard (issue #41) ────────────────────────────────────
[Fact]
public async Task Deactivate_WithActiveProducts_ThrowsRubroConProductosActivosException()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(LeafRubro(10));
_productQuery.CountActiveByRubroAsync(10, Arg.Any<CancellationToken>()).Returns(3);
var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 10));
await act.Should().ThrowAsync<RubroConProductosActivosException>()
.Where(ex => ex.RubroId == 10 && ex.ProductosActivosCount == 3);
}
[Fact]
public async Task Deactivate_WithZeroActiveProducts_Succeeds()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(LeafRubro(10));
_productQuery.CountActiveByRubroAsync(10, Arg.Any<CancellationToken>()).Returns(0);
var result = await _handler.Handle(new DeactivateRubroCommand(Id: 10));
result.Activo.Should().BeFalse();
}
[Fact]
public async Task Deactivate_WithActiveChildrenAndProducts_ThrowsChildrenFirst()
{
// Children guard fires BEFORE products guard — order of checks must be stable.
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(LeafRubro(5));
_repo.CountActiveChildrenAsync(5, Arg.Any<CancellationToken>()).Returns(2);
_productQuery.CountActiveByRubroAsync(5, Arg.Any<CancellationToken>()).Returns(3);
var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 5));
// Children exception must win — products guard is never reached
await act.Should().ThrowAsync<RubroTieneHijosActivosException>();
}
} }