feat: CAT-002 Regla de Oro Rama vs Hoja + validaciones #35

Merged
dmolinari merged 9 commits from feature/CAT-002 into main 2026-04-19 11:56:32 +00:00
2 changed files with 77 additions and 2 deletions
Showing only changes of commit c03aad8c5a - Show all commits

View File

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

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