diff --git a/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs b/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs index 8bd32f5..8adeda7 100644 --- a/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs +++ b/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs @@ -13,17 +13,20 @@ public sealed class MoveRubroCommandHandler : ICommandHandler options) + IOptions options, + IAvisoQueryRepository avisoQuery) { _repo = repo; _audit = audit; _timeProvider = timeProvider; _options = options.Value; + _avisoQuery = avisoQuery; } public async Task Handle(MoveRubroCommand command) @@ -47,6 +50,12 @@ public sealed class MoveRubroCommandHandler : ICommandHandler 0) + throw new RubroPadreEsHojaConAvisosException(command.NuevoParentId.Value, avisosCount); + // Depth check var parentDepth = await _repo.GetDepthAsync(command.NuevoParentId); var newDepth = parentDepth + 1; diff --git a/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs index 51bea63..9f4046c 100644 --- a/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs @@ -17,6 +17,7 @@ public class MoveRubroCommandHandlerTests private readonly IAuditLogger _audit = Substitute.For(); private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero)); private readonly IOptions _options = Options.Create(new RubrosOptions { MaxDepth = 10 }); + private readonly IAvisoQueryRepository _avisoQuery = Substitute.For(); 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(), Arg.Any()) .Returns(0); + // Default: no avisos + _avisoQuery.CountAvisosEnRubroAsync(Arg.Any(), Arg.Any()) + .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(); } + + // ── 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()).Returns(rubro); + _repo.GetByIdAsync(nuevoParentId, Arg.Any()).Returns(newParent); + _avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any()).Returns(2); + + var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: nuevoParentId, NuevoOrden: 0)); + + await act.Should().ThrowAsync() + .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()).Returns(rubro); + _repo.GetByIdAsync(nuevoParentId, Arg.Any()).Returns(newParent); + _avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any()).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()).Returns(rubro); + + await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: null, NuevoOrden: 0)); + + await _avisoQuery.DidNotReceive().CountAvisosEnRubroAsync(Arg.Any(), Arg.Any()); + } + + [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()).Returns(rubro); + // nuevoParentId IS a descendant (cycle) + _repo.GetDescendantsAsync(5, Arg.Any()) + .Returns(new[] { MakeRubro(nuevoParentId, parentId: 5) }); + _avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any()).Returns(3); + + var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: nuevoParentId, NuevoOrden: 0)); + + await act.Should().ThrowAsync(); + } }