feat(application): Rubros commands/queries + RubroTreeBuilder + audit (CAT-001)

This commit is contained in:
2026-04-18 19:25:35 -03:00
parent dcb2e5ada6
commit d9fc9a2867
26 changed files with 1330 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Rubros;
using SIGCM2.Application.Rubros.Move;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Rubros.Move;
public class MoveRubroCommandHandlerTests
{
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
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 MoveRubroCommandHandler _handler;
private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true)
=> new(id, parentId, $"Rubro{id}", 0, activo, null, DateTime.UtcNow, null);
public MoveRubroCommandHandlerTests()
{
_repo.GetDescendantsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<Rubro>());
_repo.ExistsByNombreUnderParentAsync(Arg.Any<int?>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(false);
_repo.GetDepthAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(0);
_repo.GetMaxOrdenAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(0);
_handler = new MoveRubroCommandHandler(_repo, _audit, _timeProvider, _options);
}
// ── Happy path: move to other parent ────────────────────────────────────
[Fact]
public async Task Handle_HappyPath_Move_ReturnsMovedDto()
{
var rubro = MakeRubro(8, parentId: 2);
var newParent = MakeRubro(20, parentId: 1);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(newParent);
var result = await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
result.Id.Should().Be(8);
result.ParentId.Should().Be(20);
}
[Fact]
public async Task Handle_HappyPath_Move_CallsAuditLogWithParentTransition()
{
var rubro = MakeRubro(8, parentId: 2);
var newParent = MakeRubro(20, parentId: 1);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(newParent);
await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
await _audit.Received(1).LogAsync(
action: "rubro.moved",
targetType: "Rubro",
targetId: "8",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
// ── Move to root (nuevoParentId null) ─────────────────────────────────
[Fact]
public async Task Handle_MoveToRoot_SetsParentIdNull()
{
var rubro = MakeRubro(8, parentId: 3);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
var result = await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: null, NuevoOrden: 5));
result.ParentId.Should().BeNull();
result.Orden.Should().Be(5);
}
// ── Rubro not found → RubroNotFoundException ─────────────────────────
[Fact]
public async Task Handle_RubroNotFound_ThrowsRubroNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
var act = () => _handler.Handle(new MoveRubroCommand(Id: 99, NuevoParentId: 1, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroNotFoundException>();
}
// ── Cycle detection ───────────────────────────────────────────────────
[Fact]
public async Task Handle_DirectChildAsNewParent_ThrowsRubroCycleDetectedException()
{
var rubro = MakeRubro(5, parentId: null);
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(rubro);
// Descendant id=10 would be the new parent
_repo.GetDescendantsAsync(5, Arg.Any<CancellationToken>())
.Returns(new[] { MakeRubro(10, parentId: 5) });
var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: 10, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroCycleDetectedException>()
.Where(ex => ex.RubroId == 5 && ex.NuevoParentId == 10);
}
[Fact]
public async Task Handle_DeepDescendantAsNewParent_ThrowsRubroCycleDetectedException()
{
var rubro = MakeRubro(5, parentId: null);
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetDescendantsAsync(5, Arg.Any<CancellationToken>())
.Returns(new[] { MakeRubro(10, 5), MakeRubro(15, 10) });
var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: 15, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroCycleDetectedException>();
}
// ── New parent inactive → RubroPadreInactivoException ─────────────────
[Fact]
public async Task Handle_NewParentInactive_ThrowsRubroPadreInactivoException()
{
var rubro = MakeRubro(8, parentId: 2);
var inactiveParent = MakeRubro(20, activo: false);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(inactiveParent);
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroPadreInactivoException>();
}
// ── Duplicate name under new parent ────────────────────────────────────
[Fact]
public async Task Handle_DuplicateNameUnderNewParent_ThrowsRubroNombreDuplicadoEnPadreException()
{
var rubro = MakeRubro(8, parentId: 2);
var newParent = MakeRubro(20);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(newParent);
_repo.ExistsByNombreUnderParentAsync((int?)20, rubro.Nombre, 8, Arg.Any<CancellationToken>())
.Returns(true);
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroNombreDuplicadoEnPadreException>();
}
// ── Depth exceeded ─────────────────────────────────────────────────────
[Fact]
public async Task Handle_DepthExceeded_ThrowsRubroMaxDepthExceededException()
{
var rubro = MakeRubro(8, parentId: 2);
var newParent = MakeRubro(20);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(newParent);
_repo.GetDepthAsync((int?)20, Arg.Any<CancellationToken>()).Returns(10); // at MaxDepth
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroMaxDepthExceededException>();
}
}