feat(application): guard avisos en CreateRubroCommandHandler (CAT-002)
This commit is contained in:
@@ -14,17 +14,20 @@ public sealed class CreateRubroCommandHandler : ICommandHandler<CreateRubroComma
|
|||||||
private readonly IAuditLogger _audit;
|
private readonly IAuditLogger _audit;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly RubrosOptions _options;
|
private readonly RubrosOptions _options;
|
||||||
|
private readonly IAvisoQueryRepository _avisoQuery;
|
||||||
|
|
||||||
public CreateRubroCommandHandler(
|
public CreateRubroCommandHandler(
|
||||||
IRubroRepository repo,
|
IRubroRepository repo,
|
||||||
IAuditLogger audit,
|
IAuditLogger audit,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
IOptions<RubrosOptions> options)
|
IOptions<RubrosOptions> options,
|
||||||
|
IAvisoQueryRepository avisoQuery)
|
||||||
{
|
{
|
||||||
_repo = repo;
|
_repo = repo;
|
||||||
_audit = audit;
|
_audit = audit;
|
||||||
_timeProvider = timeProvider;
|
_timeProvider = timeProvider;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
_avisoQuery = avisoQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RubroCreatedDto> Handle(CreateRubroCommand command)
|
public async Task<RubroCreatedDto> Handle(CreateRubroCommand command)
|
||||||
@@ -38,6 +41,12 @@ public sealed class CreateRubroCommandHandler : ICommandHandler<CreateRubroComma
|
|||||||
if (!parent.Activo)
|
if (!parent.Activo)
|
||||||
throw new RubroPadreInactivoException(command.ParentId.Value);
|
throw new RubroPadreInactivoException(command.ParentId.Value);
|
||||||
|
|
||||||
|
// CAT-002: Regla de Oro — 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.ParentId.Value);
|
||||||
|
if (avisosCount > 0)
|
||||||
|
throw new RubroPadreEsHojaConAvisosException(command.ParentId.Value, avisosCount);
|
||||||
|
|
||||||
// Depth check: parent's depth + 1 must not exceed MaxDepth
|
// Depth check: parent's depth + 1 must not exceed MaxDepth
|
||||||
var parentDepth = await _repo.GetDepthAsync(command.ParentId);
|
var parentDepth = await _repo.GetDepthAsync(command.ParentId);
|
||||||
var newDepth = parentDepth + 1;
|
var newDepth = parentDepth + 1;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class CreateRubroCommandHandlerTests
|
|||||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
|
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 IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
|
||||||
|
private readonly IAvisoQueryRepository _avisoQuery = Substitute.For<IAvisoQueryRepository>();
|
||||||
private readonly CreateRubroCommandHandler _handler;
|
private readonly CreateRubroCommandHandler _handler;
|
||||||
|
|
||||||
public CreateRubroCommandHandlerTests()
|
public CreateRubroCommandHandlerTests()
|
||||||
@@ -30,8 +31,11 @@ public class CreateRubroCommandHandlerTests
|
|||||||
.Returns(1);
|
.Returns(1);
|
||||||
_repo.GetDepthAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
_repo.GetDepthAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(0);
|
.Returns(0);
|
||||||
|
// Default: no avisos (stub behavior)
|
||||||
|
_avisoQuery.CountAvisosEnRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(0);
|
||||||
|
|
||||||
_handler = new CreateRubroCommandHandler(_repo, _audit, _timeProvider, _options);
|
_handler = new CreateRubroCommandHandler(_repo, _audit, _timeProvider, _options, _avisoQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CreateRubroCommand RootCommand() => new("Autos", ParentId: null, TarifarioBaseId: null);
|
private static CreateRubroCommand RootCommand() => new("Autos", ParentId: null, TarifarioBaseId: null);
|
||||||
@@ -173,4 +177,76 @@ public class CreateRubroCommandHandlerTests
|
|||||||
|
|
||||||
result.Id.Should().Be(99);
|
result.Id.Should().Be(99);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CAT-002: Guard padre sin avisos ──────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ParentTieneAvisos_Throws_RubroPadreEsHojaConAvisosException()
|
||||||
|
{
|
||||||
|
const int parentId = 5;
|
||||||
|
var parent = new Rubro(parentId, null, "ParentConAvisos", 0, activo: true, tarifarioBaseId: null,
|
||||||
|
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||||
|
_repo.GetByIdAsync(parentId, Arg.Any<CancellationToken>()).Returns(parent);
|
||||||
|
_avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any<CancellationToken>()).Returns(3);
|
||||||
|
|
||||||
|
var act = () => _handler.Handle(ChildCommand(parentId: parentId));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<RubroPadreEsHojaConAvisosException>()
|
||||||
|
.Where(ex => ex.ParentId == parentId && ex.CantidadAvisos == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ParentTieneCeroAvisos_DoesNotThrow()
|
||||||
|
{
|
||||||
|
const int parentId = 5;
|
||||||
|
var parent = new Rubro(parentId, null, "ParentSinAvisos", 0, activo: true, tarifarioBaseId: null,
|
||||||
|
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||||
|
_repo.GetByIdAsync(parentId, Arg.Any<CancellationToken>()).Returns(parent);
|
||||||
|
_avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any<CancellationToken>()).Returns(0);
|
||||||
|
_repo.AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>()).Returns(10);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(ChildCommand(parentId: parentId));
|
||||||
|
|
||||||
|
result.Id.Should().Be(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ParentNull_SkipsAvisosGuard()
|
||||||
|
{
|
||||||
|
// Root creation — no parent → CountAvisosEnRubroAsync should NOT be called
|
||||||
|
await _handler.Handle(RootCommand());
|
||||||
|
|
||||||
|
await _avisoQuery.DidNotReceive().CountAvisosEnRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_GuardOrder_ParentInactivo_Wins_OverAvisos()
|
||||||
|
{
|
||||||
|
// Inactive parent with avisos → RubroPadreInactivoException (not avisos exception)
|
||||||
|
const int parentId = 7;
|
||||||
|
var inactiveParent = new Rubro(parentId, null, "InactivoConAvisos", 0, activo: false, tarifarioBaseId: null,
|
||||||
|
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||||
|
_repo.GetByIdAsync(parentId, Arg.Any<CancellationToken>()).Returns(inactiveParent);
|
||||||
|
_avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any<CancellationToken>()).Returns(3);
|
||||||
|
|
||||||
|
var act = () => _handler.Handle(ChildCommand(parentId: parentId));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<RubroPadreInactivoException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_GuardOrder_Avisos_Wins_OverDepth()
|
||||||
|
{
|
||||||
|
// Parent at MAX_DEPTH AND has avisos → RubroPadreEsHojaConAvisosException (avisos guard fires first)
|
||||||
|
const int parentId = 5;
|
||||||
|
var parent = new Rubro(parentId, null, "ParentAtMaxDepth", 0, activo: true, tarifarioBaseId: null,
|
||||||
|
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||||
|
_repo.GetByIdAsync(parentId, Arg.Any<CancellationToken>()).Returns(parent);
|
||||||
|
_avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any<CancellationToken>()).Returns(2);
|
||||||
|
_repo.GetDepthAsync(parentId, Arg.Any<CancellationToken>()).Returns(10); // at MaxDepth
|
||||||
|
|
||||||
|
var act = () => _handler.Handle(ChildCommand(parentId: parentId));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<RubroPadreEsHojaConAvisosException>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user