feat: CAT-002 Regla de Oro Rama vs Hoja + validaciones #35
@@ -13,17 +13,20 @@ public sealed class MoveRubroCommandHandler : ICommandHandler<MoveRubroCommand,
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RubrosOptions _options;
|
||||
private readonly IAvisoQueryRepository _avisoQuery;
|
||||
|
||||
public MoveRubroCommandHandler(
|
||||
IRubroRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<RubrosOptions> options)
|
||||
IOptions<RubrosOptions> options,
|
||||
IAvisoQueryRepository avisoQuery)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
_avisoQuery = avisoQuery;
|
||||
}
|
||||
|
||||
public async Task<RubroMovedDto> Handle(MoveRubroCommand command)
|
||||
@@ -47,6 +50,12 @@ public sealed class MoveRubroCommandHandler : ICommandHandler<MoveRubroCommand,
|
||||
if (!newParent.Activo)
|
||||
throw new RubroPadreInactivoException(command.NuevoParentId.Value);
|
||||
|
||||
// CAT-002: Regla de Oro — nuevo padre no puede ser hoja con avisos
|
||||
// CAT-002/PRD-002 TOCTOU — evaluar upgrade a RepeatableRead o constraint DB cuando exista dbo.Aviso
|
||||
var avisosCount = await _avisoQuery.CountAvisosEnRubroAsync(command.NuevoParentId.Value);
|
||||
if (avisosCount > 0)
|
||||
throw new RubroPadreEsHojaConAvisosException(command.NuevoParentId.Value, avisosCount);
|
||||
|
||||
// Depth check
|
||||
var parentDepth = await _repo.GetDepthAsync(command.NuevoParentId);
|
||||
var newDepth = parentDepth + 1;
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user