feat(domain): RubroConProductosActivosException + guard en DeactivateRubro (closes #41) #44

Merged
dmolinari merged 4 commits from fix/issue-41-rubro-deactivation-guard into main 2026-04-19 20:09:38 +00:00
3 changed files with 55 additions and 1 deletions
Showing only changes of commit 900fd5e975 - Show all commits

View File

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

View File

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

View File

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