feat(domain): RubroConProductosActivosException + guard en DeactivateRubro (closes #41) #44
@@ -12,4 +12,10 @@ public interface IProductQueryRepository
|
||||
/// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -10,15 +10,18 @@ public sealed class DeactivateRubroCommandHandler : ICommandHandler<DeactivateRu
|
||||
{
|
||||
private readonly IRubroRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly IProductQueryRepository _productQuery;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeactivateRubroCommandHandler(
|
||||
IRubroRepository repo,
|
||||
IAuditLogger audit,
|
||||
IProductQueryRepository productQuery,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_productQuery = productQuery;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
@@ -31,6 +34,10 @@ public sealed class DeactivateRubroCommandHandler : ICommandHandler<DeactivateRu
|
||||
if (activeChildren > 0)
|
||||
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);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
|
||||
@@ -13,6 +13,7 @@ public class DeactivateRubroCommandHandlerTests
|
||||
{
|
||||
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
|
||||
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 DeactivateRubroCommandHandler _handler;
|
||||
|
||||
@@ -22,7 +23,8 @@ public class DeactivateRubroCommandHandlerTests
|
||||
public DeactivateRubroCommandHandlerTests()
|
||||
{
|
||||
_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 ─────────────────────────────────────────
|
||||
@@ -103,4 +105,43 @@ public class DeactivateRubroCommandHandlerTests
|
||||
|
||||
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>();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user