2026-04-18 19:25:35 -03:00
|
|
|
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 });
|
2026-04-19 08:24:07 -03:00
|
|
|
private readonly IAvisoQueryRepository _avisoQuery = Substitute.For<IAvisoQueryRepository>();
|
2026-04-18 19:25:35 -03:00
|
|
|
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);
|
2026-04-19 08:24:07 -03:00
|
|
|
// Default: no avisos
|
|
|
|
|
_avisoQuery.CountAvisosEnRubroAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
|
|
|
.Returns(0);
|
2026-04-18 19:25:35 -03:00
|
|
|
|
2026-04-19 08:24:07 -03:00
|
|
|
_handler = new MoveRubroCommandHandler(_repo, _audit, _timeProvider, _options, _avisoQuery);
|
2026-04-18 19:25:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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>();
|
|
|
|
|
}
|
2026-04-19 08:24:07 -03:00
|
|
|
|
|
|
|
|
// ── 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>();
|
|
|
|
|
}
|
2026-04-18 19:25:35 -03:00
|
|
|
}
|