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 IAvisoQueryRepository _avisoQuery = Substitute.For(); 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); // Default: no avisos (stub behavior) _avisoQuery.CountAvisosEnRubroAsync(Arg.Any(), Arg.Any()) .Returns(0); _handler = new CreateRubroCommandHandler(_repo, _audit, _timeProvider, _options, _avisoQuery); } 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); } // ── 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()).Returns(parent); _avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any()).Returns(3); var act = () => _handler.Handle(ChildCommand(parentId: parentId)); await act.Should().ThrowAsync() .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()).Returns(parent); _avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any()).Returns(0); _repo.AddAsync(Arg.Any(), Arg.Any()).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(), Arg.Any()); } [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()).Returns(inactiveParent); _avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any()).Returns(3); var act = () => _handler.Handle(ChildCommand(parentId: parentId)); await act.Should().ThrowAsync(); } [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()).Returns(parent); _avisoQuery.CountAvisosEnRubroAsync(parentId, Arg.Any()).Returns(2); _repo.GetDepthAsync(parentId, Arg.Any()).Returns(10); // at MaxDepth var act = () => _handler.Handle(ChildCommand(parentId: parentId)); await act.Should().ThrowAsync(); } }