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(); private readonly IAuditLogger _audit = Substitute.For(); private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero)); private readonly IOptions _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(), Arg.Any()) .Returns(Array.Empty()); _repo.ExistsByNombreUnderParentAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(false); _repo.GetDepthAsync(Arg.Any(), Arg.Any()) .Returns(0); _repo.GetMaxOrdenAsync(Arg.Any(), Arg.Any()) .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()).Returns(rubro); _repo.GetByIdAsync(20, Arg.Any()).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()).Returns(rubro); _repo.GetByIdAsync(20, Arg.Any()).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(), ct: Arg.Any()); } // ── Move to root (nuevoParentId null) ───────────────────────────────── [Fact] public async Task Handle_MoveToRoot_SetsParentIdNull() { var rubro = MakeRubro(8, parentId: 3); _repo.GetByIdAsync(8, Arg.Any()).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()).Returns((Rubro?)null); var act = () => _handler.Handle(new MoveRubroCommand(Id: 99, NuevoParentId: 1, NuevoOrden: 0)); await act.Should().ThrowAsync(); } // ── Cycle detection ─────────────────────────────────────────────────── [Fact] public async Task Handle_DirectChildAsNewParent_ThrowsRubroCycleDetectedException() { var rubro = MakeRubro(5, parentId: null); _repo.GetByIdAsync(5, Arg.Any()).Returns(rubro); // Descendant id=10 would be the new parent _repo.GetDescendantsAsync(5, Arg.Any()) .Returns(new[] { MakeRubro(10, parentId: 5) }); var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: 10, NuevoOrden: 0)); await act.Should().ThrowAsync() .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()).Returns(rubro); _repo.GetDescendantsAsync(5, Arg.Any()) .Returns(new[] { MakeRubro(10, 5), MakeRubro(15, 10) }); var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: 15, NuevoOrden: 0)); await act.Should().ThrowAsync(); } // ── 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()).Returns(rubro); _repo.GetByIdAsync(20, Arg.Any()).Returns(inactiveParent); var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0)); await act.Should().ThrowAsync(); } // ── 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()).Returns(rubro); _repo.GetByIdAsync(20, Arg.Any()).Returns(newParent); _repo.ExistsByNombreUnderParentAsync((int?)20, rubro.Nombre, 8, Arg.Any()) .Returns(true); var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0)); await act.Should().ThrowAsync(); } // ── Depth exceeded ───────────────────────────────────────────────────── [Fact] public async Task Handle_DepthExceeded_ThrowsRubroMaxDepthExceededException() { var rubro = MakeRubro(8, parentId: 2); var newParent = MakeRubro(20); _repo.GetByIdAsync(8, Arg.Any()).Returns(rubro); _repo.GetByIdAsync(20, Arg.Any()).Returns(newParent); _repo.GetDepthAsync((int?)20, Arg.Any()).Returns(10); // at MaxDepth var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0)); await act.Should().ThrowAsync(); } }