feat(application): guard avisos en MoveRubroCommandHandler (CAT-002)

This commit is contained in:
2026-04-19 08:24:07 -03:00
parent 216983623a
commit c03aad8c5a
2 changed files with 77 additions and 2 deletions

View File

@@ -17,6 +17,7 @@ public class MoveRubroCommandHandlerTests
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
private readonly IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
private readonly IAvisoQueryRepository _avisoQuery = Substitute.For<IAvisoQueryRepository>();
private readonly MoveRubroCommandHandler _handler;
private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true)
@@ -32,8 +33,11 @@ public class MoveRubroCommandHandlerTests
.Returns(0);
_repo.GetMaxOrdenAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(0);
// Default: no avisos
_avisoQuery.CountAvisosEnRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(0);
_handler = new MoveRubroCommandHandler(_repo, _audit, _timeProvider, _options);
_handler = new MoveRubroCommandHandler(_repo, _audit, _timeProvider, _options, _avisoQuery);
}
// ── Happy path: move to other parent ────────────────────────────────────
@@ -173,4 +177,66 @@ public class MoveRubroCommandHandlerTests
await act.Should().ThrowAsync<RubroMaxDepthExceededException>();
}
// ── CAT-002: Guard nuevo padre sin avisos ───────────────────────────────
[Fact]
public async Task Handle_NuevoParentTieneAvisos_Throws_RubroPadreEsHojaConAvisosException()
{
const int nuevoParentId = 20;
var rubro = MakeRubro(8, parentId: 2);
var newParent = MakeRubro(nuevoParentId);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(nuevoParentId, Arg.Any<CancellationToken>()).Returns(newParent);
_avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any<CancellationToken>()).Returns(2);
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: nuevoParentId, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroPadreEsHojaConAvisosException>()
.Where(ex => ex.ParentId == nuevoParentId && ex.CantidadAvisos == 2);
}
[Fact]
public async Task Handle_NuevoParentTieneCeroAvisos_DoesNotThrow()
{
const int nuevoParentId = 20;
var rubro = MakeRubro(8, parentId: 2);
var newParent = MakeRubro(nuevoParentId);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(nuevoParentId, Arg.Any<CancellationToken>()).Returns(newParent);
_avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any<CancellationToken>()).Returns(0);
var result = await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: nuevoParentId, NuevoOrden: 0));
result.Id.Should().Be(8);
}
[Fact]
public async Task Handle_NuevoParentEsNull_SkipsAvisosGuard()
{
// Move to root — no parent to check avisos for
var rubro = MakeRubro(8, parentId: 2);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: null, NuevoOrden: 0));
await _avisoQuery.DidNotReceive().CountAvisosEnRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_CycleCheck_Wins_OverAvisosGuard()
{
// Cycle check fires before avisos guard
const int nuevoParentId = 10;
var rubro = MakeRubro(5, parentId: null);
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(rubro);
// nuevoParentId IS a descendant (cycle)
_repo.GetDescendantsAsync(5, Arg.Any<CancellationToken>())
.Returns(new[] { MakeRubro(nuevoParentId, parentId: 5) });
_avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any<CancellationToken>()).Returns(3);
var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: nuevoParentId, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroCycleDetectedException>();
}
}