using FluentAssertions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using NSubstitute; using NSubstitute.ExceptionExtensions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Audit; using SIGCM2.Application.Rubros; using SIGCM2.Application.Rubros.Create; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; namespace SIGCM2.Application.Tests.Rubros.Create; public class CreateRubroCommandHandlerTests { 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 CreateRubroCommandHandler _handler; public CreateRubroCommandHandlerTests() { _repo.ExistsByNombreUnderParentAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(false); _repo.GetMaxOrdenAsync(Arg.Any(), Arg.Any()) .Returns(0); _repo.AddAsync(Arg.Any(), Arg.Any()) .Returns(1); _repo.GetDepthAsync(Arg.Any(), Arg.Any()) .Returns(0); _handler = new CreateRubroCommandHandler(_repo, _audit, _timeProvider, _options); } private static CreateRubroCommand RootCommand() => new("Autos", ParentId: null, TarifarioBaseId: null); private static CreateRubroCommand ChildCommand(int parentId) => new("Sedanes", ParentId: parentId, TarifarioBaseId: null); // ── Happy path: root ───────────────────────────────────────────────────── [Fact] public async Task Handle_HappyPath_Root_ReturnsIdFromRepository() { _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(42); var result = await _handler.Handle(RootCommand()); result.Id.Should().Be(42); } [Fact] public async Task Handle_HappyPath_Root_CallsAddAsync() { await _handler.Handle(RootCommand()); await _repo.Received(1).AddAsync( Arg.Is(r => r.Nombre == "Autos" && r.ParentId == null), Arg.Any()); } [Fact] public async Task Handle_HappyPath_Root_CallsAuditLog() { _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(7); await _handler.Handle(RootCommand()); await _audit.Received(1).LogAsync( action: "rubro.created", targetType: "Rubro", targetId: "7", metadata: Arg.Any(), ct: Arg.Any()); } // ── Happy path: child — uses GetMaxOrdenAsync ──────────────────────────── [Fact] public async Task Handle_HappyPath_Child_UsesMaxOrdenForOrden() { // GetMaxOrdenAsync returns the next available slot (MAX+1 semantics in the repo) _repo.GetMaxOrdenAsync((int?)5, Arg.Any()).Returns(3); var parent = new Rubro(5, null, "ParentRubro", 0, activo: true, tarifarioBaseId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null); _repo.GetByIdAsync(5, Arg.Any()).Returns(parent); await _handler.Handle(ChildCommand(parentId: 5)); await _repo.Received(1).AddAsync( Arg.Is(r => r.Orden == 3), Arg.Any()); } // ── Parent not found → RubroNotFoundException ──────────────────────────── [Fact] public async Task Handle_ParentNotFound_ThrowsRubroNotFoundException() { _repo.GetByIdAsync(999, Arg.Any()).Returns((Rubro?)null); var act = () => _handler.Handle(ChildCommand(parentId: 999)); await act.Should().ThrowAsync() .Where(ex => ex.Id == 999); } [Fact] public async Task Handle_ParentNotFound_DoesNotCallAddAsync() { _repo.GetByIdAsync(999, Arg.Any()).Returns((Rubro?)null); try { await _handler.Handle(ChildCommand(parentId: 999)); } catch { } await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); } // ── Parent inactive → RubroPadreInactivoException ─────────────────────── [Fact] public async Task Handle_ParentInactive_ThrowsRubroPadreInactivoException() { var inactiveParent = new Rubro(7, null, "InactivoParent", 0, activo: false, tarifarioBaseId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null); _repo.GetByIdAsync(7, Arg.Any()).Returns(inactiveParent); var act = () => _handler.Handle(ChildCommand(parentId: 7)); await act.Should().ThrowAsync() .Where(ex => ex.ParentId == 7); } // ── Duplicate name (CI) → RubroNombreDuplicadoEnPadreException ────────── [Fact] public async Task Handle_DuplicateName_ThrowsRubroNombreDuplicadoEnPadreException() { _repo.ExistsByNombreUnderParentAsync(null, "Autos", null, Arg.Any()) .Returns(true); var act = () => _handler.Handle(RootCommand()); await act.Should().ThrowAsync(); } // ── Depth exceeded → RubroMaxDepthExceededException ───────────────────── [Fact] public async Task Handle_DepthExceeded_ThrowsRubroMaxDepthExceededException() { // MaxDepth=10, parent is at depth 10 → creating child would be depth 11 _repo.GetDepthAsync(5, Arg.Any()).Returns(10); var parent = new Rubro(5, null, "DeepParent", 0, activo: true, tarifarioBaseId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null); _repo.GetByIdAsync(5, Arg.Any()).Returns(parent); var act = () => _handler.Handle(ChildCommand(parentId: 5)); await act.Should().ThrowAsync(); } [Fact] public async Task Handle_DepthAtMaxAllowed_Succeeds() { // MaxDepth=10, parent at depth 9 → child at depth 10 is allowed _repo.GetDepthAsync(5, Arg.Any()).Returns(9); var parent = new Rubro(5, null, "DeepParent", 0, activo: true, tarifarioBaseId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null); _repo.GetByIdAsync(5, Arg.Any()).Returns(parent); _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(99); var result = await _handler.Handle(ChildCommand(parentId: 5)); result.Id.Should().Be(99); } }