From ddd28ea4d55ca076e9241bf4d1b64dcc8f671630 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 08:17:45 -0300 Subject: [PATCH 1/9] feat(domain): excepciones regla de oro rama/hoja (CAT-002) --- .../RubroEsRamaConHijosActivosException.cs | 19 ++++++ .../RubroPadreEsHojaConAvisosException.cs | 18 ++++++ .../Rubros/RubroReglaDeOroExceptionsTests.cs | 61 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/api/SIGCM2.Domain/Exceptions/RubroEsRamaConHijosActivosException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/RubroPadreEsHojaConAvisosException.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/Rubros/RubroReglaDeOroExceptionsTests.cs diff --git a/src/api/SIGCM2.Domain/Exceptions/RubroEsRamaConHijosActivosException.cs b/src/api/SIGCM2.Domain/Exceptions/RubroEsRamaConHijosActivosException.cs new file mode 100644 index 0000000..84bfda6 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RubroEsRamaConHijosActivosException.cs @@ -0,0 +1,19 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to assign an aviso to a rubro that has active children. +/// Invariante: un nodo con hijos activos es RAMA — no puede recibir avisos directos. → HTTP 409 +/// NOTE: no handler launches this in CAT-002. Consumer: PRD-002 CreateAvisoCommandHandler. +/// +public sealed class RubroEsRamaConHijosActivosException : DomainException +{ + public int RubroId { get; } + public int CantidadHijos { get; } + + public RubroEsRamaConHijosActivosException(int rubroId, int cantidadHijos) + : base($"El destino tiene sub-rubros. No puede contener avisos directos.") + { + RubroId = rubroId; + CantidadHijos = cantidadHijos; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/RubroPadreEsHojaConAvisosException.cs b/src/api/SIGCM2.Domain/Exceptions/RubroPadreEsHojaConAvisosException.cs new file mode 100644 index 0000000..143e785 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RubroPadreEsHojaConAvisosException.cs @@ -0,0 +1,18 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to create or move a child into a rubro that already has avisos assigned. +/// Invariante: un nodo con avisos es HOJA — no puede tener hijos. → HTTP 409 +/// +public sealed class RubroPadreEsHojaConAvisosException : DomainException +{ + public int ParentId { get; } + public int CantidadAvisos { get; } + + public RubroPadreEsHojaConAvisosException(int parentId, int cantidadAvisos) + : base($"El rubro padre contiene {cantidadAvisos} avisos. Muévalos antes de crear sub-rubros.") + { + ParentId = parentId; + CantidadAvisos = cantidadAvisos; + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/Rubros/RubroReglaDeOroExceptionsTests.cs b/tests/SIGCM2.Application.Tests/Domain/Rubros/RubroReglaDeOroExceptionsTests.cs new file mode 100644 index 0000000..6327d5b --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/Rubros/RubroReglaDeOroExceptionsTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Domain.Rubros; + +public class RubroReglaDeOroExceptionsTests +{ + // ── RubroPadreEsHojaConAvisosException ────────────────────────────────── + + [Fact] + public void RubroPadreEsHojaConAvisosException_Constructor_SetsMessage() + { + var ex = new RubroPadreEsHojaConAvisosException(parentId: 42, cantidadAvisos: 3); + + ex.Message.Should().Be("El rubro padre contiene 3 avisos. Muévalos antes de crear sub-rubros."); + } + + [Fact] + public void RubroPadreEsHojaConAvisosException_Constructor_SetsParentId_Y_CantidadAvisos() + { + var ex = new RubroPadreEsHojaConAvisosException(parentId: 42, cantidadAvisos: 3); + + ex.ParentId.Should().Be(42); + ex.CantidadAvisos.Should().Be(3); + } + + [Fact] + public void RubroPadreEsHojaConAvisosException_IsA_DomainException() + { + var ex = new RubroPadreEsHojaConAvisosException(parentId: 1, cantidadAvisos: 1); + + ex.Should().BeAssignableTo(); + } + + // ── RubroEsRamaConHijosActivosException ───────────────────────────────── + + [Fact] + public void RubroEsRamaConHijosActivosException_Constructor_SetsMessage() + { + var ex = new RubroEsRamaConHijosActivosException(rubroId: 7, cantidadHijos: 2); + + ex.Message.Should().Be("El destino tiene sub-rubros. No puede contener avisos directos."); + } + + [Fact] + public void RubroEsRamaConHijosActivosException_Constructor_SetsRubroId_Y_CantidadHijos() + { + var ex = new RubroEsRamaConHijosActivosException(rubroId: 7, cantidadHijos: 2); + + ex.RubroId.Should().Be(7); + ex.CantidadHijos.Should().Be(2); + } + + [Fact] + public void RubroEsRamaConHijosActivosException_IsA_DomainException() + { + var ex = new RubroEsRamaConHijosActivosException(rubroId: 1, cantidadHijos: 1); + + ex.Should().BeAssignableTo(); + } +} -- 2.49.1 From 673194e2494404e67db2da28b633153cd676ac5c Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 08:18:56 -0300 Subject: [PATCH 2/9] feat(application): IAvisoQueryRepository + NullAvisoQueryRepository (CAT-002) --- .../Persistence/IAvisoQueryRepository.cs | 24 +++++++++++++ .../Avisos/NullAvisoQueryRepository.cs | 22 ++++++++++++ .../SIGCM2.Application/DependencyInjection.cs | 7 +++- .../Avisos/NullAvisoQueryRepositoryTests.cs | 35 +++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IAvisoQueryRepository.cs create mode 100644 src/api/SIGCM2.Application/Avisos/NullAvisoQueryRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Avisos/NullAvisoQueryRepositoryTests.cs diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IAvisoQueryRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IAvisoQueryRepository.cs new file mode 100644 index 0000000..a077780 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IAvisoQueryRepository.cs @@ -0,0 +1,24 @@ +namespace SIGCM2.Application.Abstractions.Persistence; + +/// +/// Query-only access to Aviso counts by Rubro. +/// CAT-002 introduces the contract. The real Dapper-based impl lands in PRD-002 +/// (when dbo.Aviso exists). Until then, NullAvisoQueryRepository is the binding. +/// +public interface IAvisoQueryRepository +{ + /// + /// Returns the count of avisos (active, non-archived) assigned to the given rubro. + /// + Task CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default); + + /// + /// Returns a dictionary of { rubroId → count } for the provided ids. + /// Used by GetRubroTreeQueryHandler to avoid N+1 when populating TieneAvisos per node. + /// The implementation MUST do a single query; the stub returns an empty dictionary + /// (every rubro gets 0 via dictionary.GetValueOrDefault). + /// + Task> CountAvisosBatchAsync( + IReadOnlyCollection rubroIds, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Avisos/NullAvisoQueryRepository.cs b/src/api/SIGCM2.Application/Avisos/NullAvisoQueryRepository.cs new file mode 100644 index 0000000..223c146 --- /dev/null +++ b/src/api/SIGCM2.Application/Avisos/NullAvisoQueryRepository.cs @@ -0,0 +1,22 @@ +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.Avisos; + +/// +/// STUB — PRD-002 reemplaza con AvisoQueryRepository contra dbo.Aviso. +/// Returns 0 / empty dictionary so every handler guard passes and every tree node shows TieneAvisos=false. +/// This is intentional for CAT-002: the mechanism is installed; the data feed arrives in PRD-002. +/// +public sealed class NullAvisoQueryRepository : IAvisoQueryRepository +{ + private static readonly IReadOnlyDictionary Empty = + new Dictionary(capacity: 0); + + public Task CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default) + => Task.FromResult(0); + + public Task> CountAvisosBatchAsync( + IReadOnlyCollection rubroIds, + CancellationToken ct = default) + => Task.FromResult(Empty); +} diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 7d7f85f..2b10fbb 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -67,6 +67,8 @@ using SIGCM2.Application.Rubros.Move; using SIGCM2.Application.Rubros.GetTree; using SIGCM2.Application.Rubros.GetById; using SIGCM2.Application.Rubros.Dtos; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Avisos; namespace SIGCM2.Application; @@ -152,7 +154,10 @@ public static class DependencyInjection services.AddScoped>, ListIngresosBrutosQueryHandler>(); services.AddScoped>, GetHistorialIngresosBrutosQueryHandler>(); - // Rubros (CAT-001) + // Rubros (CAT-001 + CAT-002) + // CAT-002: Regla de Oro Rama vs Hoja — stub binding until PRD-002 provides real impl + services.AddScoped(); + services.AddScoped, CreateRubroCommandHandler>(); services.AddScoped, UpdateRubroCommandHandler>(); services.AddScoped, DeactivateRubroCommandHandler>(); diff --git a/tests/SIGCM2.Application.Tests/Avisos/NullAvisoQueryRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Avisos/NullAvisoQueryRepositoryTests.cs new file mode 100644 index 0000000..4afa33d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Avisos/NullAvisoQueryRepositoryTests.cs @@ -0,0 +1,35 @@ +using FluentAssertions; +using SIGCM2.Application.Avisos; + +namespace SIGCM2.Application.Tests.Avisos; + +public class NullAvisoQueryRepositoryTests +{ + private readonly NullAvisoQueryRepository _repo = new(); + + [Fact] + public async Task CountAvisosEnRubroAsync_Always_ReturnsZero() + { + var result = await _repo.CountAvisosEnRubroAsync(rubroId: 99); + + result.Should().Be(0); + } + + [Fact] + public async Task CountAvisosBatchAsync_WithIds_ReturnsDictionaryAllZero() + { + var result = await _repo.CountAvisosBatchAsync([1, 2, 3]); + + result.GetValueOrDefault(1, 0).Should().Be(0); + result.GetValueOrDefault(2, 0).Should().Be(0); + result.GetValueOrDefault(3, 0).Should().Be(0); + } + + [Fact] + public async Task CountAvisosBatchAsync_EmptyIds_ReturnsEmptyDictionary() + { + var result = await _repo.CountAvisosBatchAsync([]); + + result.Should().HaveCount(0); + } +} -- 2.49.1 From 9e50a929ae5ec828ceb097a0d0b882931f706aee Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 08:20:36 -0300 Subject: [PATCH 3/9] feat(application): RubroTreeBuilder + GetRubroTree con tieneAvisos (CAT-002) --- .../Rubros/Common/RubroTreeBuilder.cs | 4 +- .../Rubros/Dtos/RubroTreeNodeDto.cs | 1 + .../GetTree/GetRubroTreeQueryHandler.cs | 3 +- .../Rubros/RubroTreeBuilderTests.cs | 57 ++++++++++++++++--- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs b/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs index 3e92ddf..04c3fc0 100644 --- a/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs +++ b/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs @@ -12,7 +12,8 @@ public static class RubroTreeBuilder { public static IReadOnlyList Build( IEnumerable flat, - bool incluirInactivos) + bool incluirInactivos, + IReadOnlyDictionary avisoCounts) { var filtered = incluirInactivos ? flat.ToList() @@ -36,6 +37,7 @@ public static class RubroTreeBuilder Activo: r.Activo, ParentId: r.ParentId, TarifarioBaseId: r.TarifarioBaseId, + TieneAvisos: avisoCounts.GetValueOrDefault(r.Id, 0) > 0, Hijos: children); } diff --git a/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs b/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs index 1bf5468..2f4d2c6 100644 --- a/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs +++ b/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs @@ -10,4 +10,5 @@ public sealed record RubroTreeNodeDto( bool Activo, int? ParentId, int? TarifarioBaseId, + bool TieneAvisos, IReadOnlyList Hijos); diff --git a/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs b/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs index 6fcefcc..884b869 100644 --- a/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs +++ b/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs @@ -17,6 +17,7 @@ public sealed class GetRubroTreeQueryHandler : ICommandHandler> Handle(GetRubroTreeQuery query) { var all = await _repo.GetAllAsync(query.IncluirInactivos); - return RubroTreeBuilder.Build(all, query.IncluirInactivos); + // CAT-002: avisoCounts injected via IAvisoQueryRepository (wired in Batch 6) + return RubroTreeBuilder.Build(all, query.IncluirInactivos, new Dictionary()); } } diff --git a/tests/SIGCM2.Application.Tests/Rubros/RubroTreeBuilderTests.cs b/tests/SIGCM2.Application.Tests/Rubros/RubroTreeBuilderTests.cs index 14e8338..c56aa9a 100644 --- a/tests/SIGCM2.Application.Tests/Rubros/RubroTreeBuilderTests.cs +++ b/tests/SIGCM2.Application.Tests/Rubros/RubroTreeBuilderTests.cs @@ -18,7 +18,7 @@ public class RubroTreeBuilderTests [Fact] public void Build_empty_returns_empty_list() { - var result = RubroTreeBuilder.Build([], incluirInactivos: false); + var result = RubroTreeBuilder.Build([], incluirInactivos: false, new Dictionary()); result.Should().BeEmpty(); } @@ -30,7 +30,7 @@ public class RubroTreeBuilderTests { var rubros = new[] { MakeRubro(1, null, "Autos", 0) }; - var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary()); result.Should().HaveCount(1); result[0].Id.Should().Be(1); @@ -50,7 +50,7 @@ public class RubroTreeBuilderTests MakeRubro(2, null, "Camiones", 1) }; - var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary()); result.Should().HaveCount(3); result[0].Id.Should().Be(1); // Orden=0 @@ -70,7 +70,7 @@ public class RubroTreeBuilderTests MakeRubro(3, 2, "Compactos", 0), }; - var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary()); result.Should().HaveCount(1); result[0].Id.Should().Be(1); @@ -91,7 +91,7 @@ public class RubroTreeBuilderTests MakeRubro(2, null, "Motos", 1, activo: false), }; - var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary()); result.Should().HaveCount(1); result[0].Id.Should().Be(1); @@ -106,7 +106,7 @@ public class RubroTreeBuilderTests MakeRubro(2, null, "Motos", 1, activo: false), }; - var result = RubroTreeBuilder.Build(rubros, incluirInactivos: true); + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: true, new Dictionary()); result.Should().HaveCount(2); } @@ -125,7 +125,7 @@ public class RubroTreeBuilderTests MakeRubro(5, 1, "A", 0), }; - var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary()); var hijos = result[0].Hijos; hijos.Should().HaveCount(4); @@ -135,6 +135,47 @@ public class RubroTreeBuilderTests hijos[3].Nombre.Should().Be("D"); // Orden=3 } + // ── TieneAvisos from avisoCounts dict ──────────────────────────────────── + + [Fact] + public void Build_SetsTieneAvisos_True_WhenCountGreaterThanZero() + { + var rubros = new[] + { + MakeRubro(1, null, "Autos", 0), + MakeRubro(2, null, "Motos", 1), + }; + var avisoCounts = new Dictionary { { 1, 2 }, { 2, 0 } }; + + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, avisoCounts); + + result.Single(n => n.Id == 1).TieneAvisos.Should().BeTrue(); + result.Single(n => n.Id == 2).TieneAvisos.Should().BeFalse(); + } + + [Fact] + public void Build_SetsTieneAvisos_False_WhenCountIsZero() + { + var rubros = new[] { MakeRubro(1, null, "Autos", 0) }; + var avisoCounts = new Dictionary { { 1, 0 } }; + + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, avisoCounts); + + result[0].TieneAvisos.Should().BeFalse(); + } + + [Fact] + public void Build_SetsTieneAvisos_False_WhenIdMissingFromDict() + { + // Stub semantics: missing key = 0 = false + var rubros = new[] { MakeRubro(1, null, "Autos", 0) }; + var avisoCounts = new Dictionary(); // empty dict + + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, avisoCounts); + + result[0].TieneAvisos.Should().BeFalse(); + } + // ── O(n) perf smoke test ────────────────────────────────────────────────── [Fact] @@ -148,7 +189,7 @@ public class RubroTreeBuilderTests rubros.Add(MakeRubro(i, 1, $"Child{i}", i - 2)); var sw = System.Diagnostics.Stopwatch.StartNew(); - var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false, new Dictionary()); sw.Stop(); result.Should().HaveCount(1); -- 2.49.1 From 216983623abde45bae09386994763344e5c0de9c Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 08:22:55 -0300 Subject: [PATCH 4/9] feat(application): guard avisos en CreateRubroCommandHandler (CAT-002) --- .../Create/CreateRubroCommandHandler.cs | 11 ++- .../Create/CreateRubroCommandHandlerTests.cs | 78 ++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs b/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs index 7332524..f142877 100644 --- a/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs +++ b/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs @@ -14,17 +14,20 @@ public sealed class CreateRubroCommandHandler : ICommandHandler options) + IOptions options, + IAvisoQueryRepository avisoQuery) { _repo = repo; _audit = audit; _timeProvider = timeProvider; _options = options.Value; + _avisoQuery = avisoQuery; } public async Task Handle(CreateRubroCommand command) @@ -38,6 +41,12 @@ public sealed class CreateRubroCommandHandler : ICommandHandler 0) + throw new RubroPadreEsHojaConAvisosException(command.ParentId.Value, avisosCount); + // Depth check: parent's depth + 1 must not exceed MaxDepth var parentDepth = await _repo.GetDepthAsync(command.ParentId); var newDepth = parentDepth + 1; diff --git a/tests/SIGCM2.Application.Tests/Rubros/Create/CreateRubroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Rubros/Create/CreateRubroCommandHandlerTests.cs index f4b4798..6181380 100644 --- a/tests/SIGCM2.Application.Tests/Rubros/Create/CreateRubroCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Rubros/Create/CreateRubroCommandHandlerTests.cs @@ -18,6 +18,7 @@ public class CreateRubroCommandHandlerTests 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() @@ -30,8 +31,11 @@ public class CreateRubroCommandHandlerTests .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); + _handler = new CreateRubroCommandHandler(_repo, _audit, _timeProvider, _options, _avisoQuery); } private static CreateRubroCommand RootCommand() => new("Autos", ParentId: null, TarifarioBaseId: null); @@ -173,4 +177,76 @@ public class CreateRubroCommandHandlerTests 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(); + } } -- 2.49.1 From c03aad8c5a7d0d6b32d934350d41ec3b09c75d90 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 08:24:07 -0300 Subject: [PATCH 5/9] feat(application): guard avisos en MoveRubroCommandHandler (CAT-002) --- .../Rubros/Move/MoveRubroCommandHandler.cs | 11 ++- .../Move/MoveRubroCommandHandlerTests.cs | 68 ++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs b/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs index 8bd32f5..8adeda7 100644 --- a/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs +++ b/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs @@ -13,17 +13,20 @@ public sealed class MoveRubroCommandHandler : ICommandHandler options) + IOptions options, + IAvisoQueryRepository avisoQuery) { _repo = repo; _audit = audit; _timeProvider = timeProvider; _options = options.Value; + _avisoQuery = avisoQuery; } public async Task Handle(MoveRubroCommand command) @@ -47,6 +50,12 @@ public sealed class MoveRubroCommandHandler : ICommandHandler 0) + throw new RubroPadreEsHojaConAvisosException(command.NuevoParentId.Value, avisosCount); + // Depth check var parentDepth = await _repo.GetDepthAsync(command.NuevoParentId); var newDepth = parentDepth + 1; diff --git a/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs index 51bea63..9f4046c 100644 --- a/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs @@ -17,6 +17,7 @@ public class MoveRubroCommandHandlerTests 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 MoveRubroCommandHandler _handler; private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true) @@ -32,8 +33,11 @@ public class MoveRubroCommandHandlerTests .Returns(0); _repo.GetMaxOrdenAsync(Arg.Any(), Arg.Any()) .Returns(0); + // Default: no avisos + _avisoQuery.CountAvisosEnRubroAsync(Arg.Any(), Arg.Any()) + .Returns(0); - _handler = new MoveRubroCommandHandler(_repo, _audit, _timeProvider, _options); + _handler = new MoveRubroCommandHandler(_repo, _audit, _timeProvider, _options, _avisoQuery); } // ── Happy path: move to other parent ──────────────────────────────────── @@ -173,4 +177,66 @@ public class MoveRubroCommandHandlerTests await act.Should().ThrowAsync(); } + + // ── 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()).Returns(rubro); + _repo.GetByIdAsync(nuevoParentId, Arg.Any()).Returns(newParent); + _avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any()).Returns(2); + + var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: nuevoParentId, NuevoOrden: 0)); + + await act.Should().ThrowAsync() + .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()).Returns(rubro); + _repo.GetByIdAsync(nuevoParentId, Arg.Any()).Returns(newParent); + _avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any()).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()).Returns(rubro); + + await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: null, NuevoOrden: 0)); + + await _avisoQuery.DidNotReceive().CountAvisosEnRubroAsync(Arg.Any(), Arg.Any()); + } + + [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()).Returns(rubro); + // nuevoParentId IS a descendant (cycle) + _repo.GetDescendantsAsync(5, Arg.Any()) + .Returns(new[] { MakeRubro(nuevoParentId, parentId: 5) }); + _avisoQuery.CountAvisosEnRubroAsync(nuevoParentId, Arg.Any()).Returns(3); + + var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: nuevoParentId, NuevoOrden: 0)); + + await act.Should().ThrowAsync(); + } } -- 2.49.1 From f861dfa826da8d5af84142823074c1d4d2477182 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 08:25:13 -0300 Subject: [PATCH 6/9] feat(application): RubroTreeBuilder + GetRubroTree con tieneAvisos (CAT-002) --- .../GetTree/GetRubroTreeQueryHandler.cs | 10 ++- .../GetTree/GetRubroTreeQueryHandlerTests.cs | 76 ++++++++++++++++++- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs b/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs index 884b869..26e0de1 100644 --- a/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs +++ b/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs @@ -8,16 +8,20 @@ namespace SIGCM2.Application.Rubros.GetTree; public sealed class GetRubroTreeQueryHandler : ICommandHandler> { private readonly IRubroRepository _repo; + private readonly IAvisoQueryRepository _avisoQuery; - public GetRubroTreeQueryHandler(IRubroRepository repo) + public GetRubroTreeQueryHandler(IRubroRepository repo, IAvisoQueryRepository avisoQuery) { _repo = repo; + _avisoQuery = avisoQuery; } public async Task> Handle(GetRubroTreeQuery query) { var all = await _repo.GetAllAsync(query.IncluirInactivos); - // CAT-002: avisoCounts injected via IAvisoQueryRepository (wired in Batch 6) - return RubroTreeBuilder.Build(all, query.IncluirInactivos, new Dictionary()); + var ids = all.Select(r => r.Id).ToList(); + // CAT-002: single batch call — avoids N+1 when PRD-002 activates the real implementation + var avisoCounts = await _avisoQuery.CountAvisosBatchAsync(ids); + return RubroTreeBuilder.Build(all, query.IncluirInactivos, avisoCounts); } } diff --git a/tests/SIGCM2.Application.Tests/Rubros/GetTree/GetRubroTreeQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Rubros/GetTree/GetRubroTreeQueryHandlerTests.cs index e79edbb..00e4150 100644 --- a/tests/SIGCM2.Application.Tests/Rubros/GetTree/GetRubroTreeQueryHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Rubros/GetTree/GetRubroTreeQueryHandlerTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using Microsoft.Extensions.Time.Testing; using NSubstitute; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Avisos; using SIGCM2.Application.Rubros.GetById; using SIGCM2.Application.Rubros.GetTree; using SIGCM2.Domain.Entities; @@ -13,6 +14,7 @@ public class GetRubroTreeQueryHandlerTests { private static readonly FakeTimeProvider FakeTime = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero)); private readonly IRubroRepository _repo = Substitute.For(); + private readonly IAvisoQueryRepository _avisoQuery = Substitute.For(); private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true) => new(id, parentId, $"Rubro{id}", 0, activo, null, FakeTime.GetUtcNow().UtcDateTime, null); @@ -24,8 +26,10 @@ public class GetRubroTreeQueryHandlerTests { _repo.GetAllAsync(false, Arg.Any()) .Returns(new[] { MakeRubro(1), MakeRubro(2) }); + _avisoQuery.CountAvisosBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); - var handler = new GetRubroTreeQueryHandler(_repo); + var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery); var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false)); result.Should().HaveCount(2); @@ -36,8 +40,10 @@ public class GetRubroTreeQueryHandlerTests { _repo.GetAllAsync(true, Arg.Any()) .Returns(new[] { MakeRubro(1), MakeRubro(2, activo: false) }); + _avisoQuery.CountAvisosBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); - var handler = new GetRubroTreeQueryHandler(_repo); + var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery); var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: true)); await _repo.Received(1).GetAllAsync(true, Arg.Any()); @@ -49,13 +55,77 @@ public class GetRubroTreeQueryHandlerTests { _repo.GetAllAsync(Arg.Any(), Arg.Any()) .Returns(Array.Empty()); + _avisoQuery.CountAvisosBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); - var handler = new GetRubroTreeQueryHandler(_repo); + var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery); var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false)); result.Should().BeEmpty(); } + // ── CAT-002: TieneAvisos populated via batch ───────────────────────────── + + [Fact] + public async Task Handle_PopulatesTieneAvisos_True_WhenBatchResultContainsCount() + { + _repo.GetAllAsync(false, Arg.Any()) + .Returns(new[] { MakeRubro(1), MakeRubro(2), MakeRubro(3) }); + _avisoQuery.CountAvisosBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary { { 1, 2 } }); + + var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery); + var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false)); + + result.Single(n => n.Id == 1).TieneAvisos.Should().BeTrue(); + result.Single(n => n.Id == 2).TieneAvisos.Should().BeFalse(); + result.Single(n => n.Id == 3).TieneAvisos.Should().BeFalse(); + } + + [Fact] + public async Task Handle_CallsBatchExactlyOnce_WithAllRubroIds() + { + _repo.GetAllAsync(false, Arg.Any()) + .Returns(new[] { MakeRubro(1), MakeRubro(2), MakeRubro(3) }); + _avisoQuery.CountAvisosBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + + var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery); + await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false)); + + await _avisoQuery.Received(1).CountAvisosBatchAsync( + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task Handle_EmptyTree_CallsBatchWithEmptyList() + { + _repo.GetAllAsync(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + _avisoQuery.CountAvisosBatchAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + + var handler = new GetRubroTreeQueryHandler(_repo, _avisoQuery); + await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false)); + + await _avisoQuery.Received(1).CountAvisosBatchAsync( + Arg.Is>(ids => ids.Count == 0), + Arg.Any()); + } + + [Fact] + public async Task Handle_StubBehavior_AllNodesTieneAvisosFalse() + { + _repo.GetAllAsync(false, Arg.Any()) + .Returns(new[] { MakeRubro(1), MakeRubro(2) }); + + var handler = new GetRubroTreeQueryHandler(_repo, new NullAvisoQueryRepository()); + var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false)); + + result.Should().AllSatisfy(n => n.TieneAvisos.Should().BeFalse()); + } + // ── GetRubroByIdQueryHandler ───────────────────────────────────────────── [Fact] -- 2.49.1 From bb5dde6e24c339bcca15f2a59f4f129a78521318 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 08:31:39 -0300 Subject: [PATCH 7/9] feat(api): ExceptionFilter 409 para regla de oro + DTO delta (CAT-002) --- src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 25 +++ .../Rubros/RubrosReglaDeOroTests.cs | 177 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index f43eac9..e936231 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -242,6 +242,31 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // CAT-002: Rubro Regla de Oro (rama vs hoja) + case RubroPadreEsHojaConAvisosException rubroPadreHojaEx: + context.Result = new ObjectResult(new + { + error = "rubro_padre_es_hoja_con_avisos", + message = rubroPadreHojaEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case RubroEsRamaConHijosActivosException rubroRamaHijosEx: + context.Result = new ObjectResult(new + { + error = "rubro_es_rama_con_hijos_activos", + message = rubroRamaHijosEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + // ADM-001: Medio exceptions case MedioCodigoDuplicadoException medioCodDupEx: context.Result = new ObjectResult(new diff --git a/tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs b/tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs new file mode 100644 index 0000000..97e788b --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs @@ -0,0 +1,177 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging.Abstractions; +using SIGCM2.Api.Filters; +using SIGCM2.Domain.Exceptions; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Rubros; + +/// +/// CAT-002 — Regla de Oro Rama vs Hoja. +/// +/// Unit tests: ExceptionFilter mapping for new 409 cases (no DB needed). +/// Integration: GET /arbol returns tieneAvisos field per node (stub = false). +/// +/// Design note: the 409 guard behavior is fully covered by unit tests in +/// SIGCM2.Application.Tests (CreateRubroCommandHandlerTests, MoveRubroCommandHandlerTests). +/// e2e 409 verification via a separate factory is skipped here because the shared +/// ApiIntegration singleton factory cannot be safely augmented with per-test DI overrides +/// (RSA key singleton issue documented in ApiIntegrationCollection.cs). +/// +[Collection("ApiIntegration")] +public sealed class RubrosReglaDeOroTests : IAsyncLifetime +{ + private const string AdminEndpoint = "/api/v1/admin/rubros"; + private const string ReadEndpoint = "/api/v1/rubros"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public RubrosReglaDeOroTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task DisposeAsync() => Task.CompletedTask; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null) + { + var request = new HttpRequestMessage(method, url); + if (bearerToken is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + if (body is not null) + request.Content = JsonContent.Create(body); + return request; + } + + private static async Task DeleteRubroIfExistsAsync(int id) + { + await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.ApiTestDb); + await conn.OpenAsync(); + await Dapper.SqlMapper.ExecuteAsync(conn, "ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = OFF)"); + await Dapper.SqlMapper.ExecuteAsync(conn, "DELETE FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id }); + await Dapper.SqlMapper.ExecuteAsync(conn, """ + WITH ToDelete AS ( + SELECT Id FROM dbo.Rubro WHERE Id = @Id + UNION ALL + SELECT r.Id FROM dbo.Rubro r INNER JOIN ToDelete t ON r.ParentId = t.Id + ) + DELETE r FROM dbo.Rubro r INNER JOIN ToDelete td ON r.Id = td.Id + """, new { Id = id }); + await Dapper.SqlMapper.ExecuteAsync(conn, + "ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Rubro_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + } + + // ── ExceptionFilter unit tests (no DB, no HTTP) ─────────────────────────── + + private static ExceptionContext MakeExceptionContext(Exception exception) + { + var httpContext = new DefaultHttpContext(); + var routeData = new Microsoft.AspNetCore.Routing.RouteData(); + var actionDescriptor = new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor(); + var modelState = new Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary(); + var actionContext = new ActionContext(httpContext, routeData, actionDescriptor, modelState); + return new ExceptionContext(actionContext, new List()) + { + Exception = exception + }; + } + + [Fact] + public void ExceptionFilter_MapsRubroPadreEsHojaConAvisos_To409() + { + var filter = new ExceptionFilter(NullLogger.Instance); + var ctx = MakeExceptionContext(new RubroPadreEsHojaConAvisosException(parentId: 1, cantidadAvisos: 3)); + + filter.OnException(ctx); + + var result = Assert.IsType(ctx.Result); + Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode); + var json = System.Text.Json.JsonSerializer.Serialize(result.Value); + Assert.Contains("rubro_padre_es_hoja_con_avisos", json); + } + + [Fact] + public void ExceptionFilter_MapsRubroEsRamaConHijosActivos_To409() + { + var filter = new ExceptionFilter(NullLogger.Instance); + var ctx = MakeExceptionContext(new RubroEsRamaConHijosActivosException(rubroId: 7, cantidadHijos: 2)); + + filter.OnException(ctx); + + var result = Assert.IsType(ctx.Result); + Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode); + var json = System.Text.Json.JsonSerializer.Serialize(result.Value); + Assert.Contains("rubro_es_rama_con_hijos_activos", json); + } + + // ── Integration: GET /arbol includes tieneAvisos field (stub = false) ───── + + [Fact] + public async Task GetTree_ResponseIncludesTieneAvisosField_FalseWithStub() + { + var token = await GetAdminTokenAsync(); + + // Create a root rubro to ensure tree is non-empty + using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = "TieneAvisosCheck_CAT002", + parentId = (int?)null, + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var rootId = created.GetProperty("id").GetInt32(); + + try + { + using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/tree", bearerToken: token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var json = await resp.Content.ReadFromJsonAsync(); + var ourNode = json.EnumerateArray() + .FirstOrDefault(n => n.GetProperty("id").GetInt32() == rootId); + + Assert.True(ourNode.ValueKind != JsonValueKind.Undefined, "Our rubro must appear in tree"); + Assert.True(ourNode.TryGetProperty("tieneAvisos", out var tieneAvisos), + "tieneAvisos must be present in every tree node (CAT-002 additive field)"); + Assert.False(tieneAvisos.GetBoolean(), + "Stub (NullAvisoQueryRepository) must always return false"); + } + finally + { + await DeleteRubroIfExistsAsync(rootId); + } + } + + // ── Integration: POST returns 409 message format (guard path) ───────────── + // NOTE: these tests rely on the unit-tested handler behavior. The 409 is proven by: + // - CreateRubroCommandHandlerTests.Handle_ParentTieneAvisos_Throws_RubroPadreEsHojaConAvisosException + // - ExceptionFilter_MapsRubroPadreEsHojaConAvisos_To409 (above) + // The combined e2e 409 test is omitted here because it requires per-factory DI override + // which conflicts with the shared ApiIntegration RSA singleton pattern. + // See: ApiIntegrationCollection.cs for the rationale. +} -- 2.49.1 From 4f25233babae160ec851ba03082870f2168f2fff Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 08:35:42 -0300 Subject: [PATCH 8/9] feat(frontend): tieneAvisos en RubroTreeNode + disable btn subrubro (CAT-002) --- .../rubros/components/CategoryTreeNode.tsx | 3 + src/web/src/features/rubros/types.ts | 2 + .../features/rubros/CategoryTree.test.tsx | 56 +++++++++++++++++++ .../features/rubros/MoveRubroDialog.test.tsx | 29 ++++++++++ 4 files changed, 90 insertions(+) diff --git a/src/web/src/features/rubros/components/CategoryTreeNode.tsx b/src/web/src/features/rubros/components/CategoryTreeNode.tsx index 5cc95e1..058a809 100644 --- a/src/web/src/features/rubros/components/CategoryTreeNode.tsx +++ b/src/web/src/features/rubros/components/CategoryTreeNode.tsx @@ -101,11 +101,14 @@ export function CategoryTreeNode({ {/* Action buttons — only if canEdit */} {canEdit && (
+ {/* CAT-002: disabled when leaf-with-avisos; PRD-002 activates the real data path */}