From d4c05cc364e292f2552d0a0765a163b492a87078 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sat, 18 Apr 2026 19:25:35 -0300 Subject: [PATCH] feat(application): Rubros commands/queries + RubroTreeBuilder + audit (CAT-001) --- .../Persistence/IRubroRepository.cs | 41 ++++ .../Rubros/Common/RubroTreeBuilder.cs | 47 +++++ .../Rubros/Create/CreateRubroCommand.cs | 6 + .../Create/CreateRubroCommandHandler.cs | 91 +++++++++ .../Rubros/Create/RubroCreatedDto.cs | 9 + .../Deactivate/DeactivateRubroCommand.cs | 3 + .../DeactivateRubroCommandHandler.cs | 58 ++++++ .../Rubros/Deactivate/RubroStatusDto.cs | 3 + .../Rubros/Dtos/RubroTreeNodeDto.cs | 13 ++ .../Rubros/GetById/GetRubroByIdQuery.cs | 3 + .../GetById/GetRubroByIdQueryHandler.cs | 31 +++ .../Rubros/GetById/RubroDetailDto.cs | 11 ++ .../Rubros/GetTree/GetRubroTreeQuery.cs | 3 + .../GetTree/GetRubroTreeQueryHandler.cs | 22 +++ .../Rubros/Move/MoveRubroCommand.cs | 3 + .../Rubros/Move/MoveRubroCommandHandler.cs | 92 +++++++++ .../Rubros/Move/RubroMovedDto.cs | 8 + .../Rubros/Update/RubroUpdatedDto.cs | 9 + .../Rubros/Update/UpdateRubroCommand.cs | 3 + .../Update/UpdateRubroCommandHandler.cs | 65 +++++++ .../Create/CreateRubroCommandHandlerTests.cs | 176 ++++++++++++++++++ .../DeactivateRubroCommandHandlerTests.cs | 106 +++++++++++ .../GetTree/GetRubroTreeQueryHandlerTests.cs | 83 +++++++++ .../Move/MoveRubroCommandHandlerTests.cs | 176 ++++++++++++++++++ .../Rubros/RubroTreeBuilderTests.cs | 158 ++++++++++++++++ .../Update/UpdateRubroCommandHandlerTests.cs | 110 +++++++++++ 26 files changed, 1330 insertions(+) create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IRubroRepository.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommand.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Create/RubroCreatedDto.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommand.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Deactivate/RubroStatusDto.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs create mode 100644 src/api/SIGCM2.Application/Rubros/GetById/GetRubroByIdQuery.cs create mode 100644 src/api/SIGCM2.Application/Rubros/GetById/GetRubroByIdQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Rubros/GetById/RubroDetailDto.cs create mode 100644 src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQuery.cs create mode 100644 src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommand.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Move/RubroMovedDto.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Update/RubroUpdatedDto.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Update/UpdateRubroCommand.cs create mode 100644 src/api/SIGCM2.Application/Rubros/Update/UpdateRubroCommandHandler.cs create mode 100644 tests/SIGCM2.Application.Tests/Rubros/Create/CreateRubroCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Rubros/Deactivate/DeactivateRubroCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Rubros/GetTree/GetRubroTreeQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Rubros/RubroTreeBuilderTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Rubros/Update/UpdateRubroCommandHandlerTests.cs diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IRubroRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IRubroRepository.cs new file mode 100644 index 0000000..8456881 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IRubroRepository.cs @@ -0,0 +1,41 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IRubroRepository +{ + Task AddAsync(Rubro rubro, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task> GetAllAsync(bool incluirInactivos, CancellationToken ct = default); + + /// + /// Returns all descendants of rootId via recursive CTE (used only by MoveRubro for cycle detection). + /// + Task> GetDescendantsAsync(int rootId, CancellationToken ct = default); + + Task UpdateAsync(Rubro rubro, CancellationToken ct = default); + + /// + /// Returns the count of active children for the given parentId. + /// Used by soft-delete to guard against deleting non-leaf rubros. + /// + Task CountActiveChildrenAsync(int id, CancellationToken ct = default); + + /// + /// Returns MAX(Orden)+1 among siblings of the given parentId (0 if no siblings). + /// Used for append-on-create ordering. + /// + Task GetMaxOrdenAsync(int? parentId, CancellationToken ct = default); + + /// + /// Returns true if an active Rubro with the same Nombre (CI) exists under the same parentId, + /// optionally excluding the Rubro with the given id (for rename operations). + /// + Task ExistsByNombreUnderParentAsync(int? parentId, string nombre, int? excludeId, CancellationToken ct = default); + + /// + /// Returns the depth of the given parentId (0 if parentId is null = root level). + /// Uses a recursive CTE going upward through ancestors. + /// + Task GetDepthAsync(int? parentId, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs b/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs new file mode 100644 index 0000000..3e92ddf --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs @@ -0,0 +1,47 @@ +using SIGCM2.Application.Rubros.Dtos; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Rubros.Common; + +/// +/// Builds an N-ary tree from a flat list of Rubro entities in O(n) time. +/// Algorithm: (1) optionally filter inactivos, (2) group by ParentId into a dictionary, +/// (3) recursively assemble roots (ParentId==null) attaching children sorted by Orden ASC. +/// +public static class RubroTreeBuilder +{ + public static IReadOnlyList Build( + IEnumerable flat, + bool incluirInactivos) + { + var filtered = incluirInactivos + ? flat.ToList() + : flat.Where(r => r.Activo).ToList(); + + // Group by ParentId → each bucket sorted by Orden ASC + // Use ToLookup (handles the int? key safely) instead of ToDictionary + var byParent = filtered.ToLookup(r => r.ParentId); + + RubroTreeNodeDto Map(Rubro r) + { + var children = byParent[(int?)r.Id] + .OrderBy(x => x.Orden) + .Select(Map) + .ToList(); + + return new RubroTreeNodeDto( + Id: r.Id, + Nombre: r.Nombre, + Orden: r.Orden, + Activo: r.Activo, + ParentId: r.ParentId, + TarifarioBaseId: r.TarifarioBaseId, + Hijos: children); + } + + return byParent[null] + .OrderBy(r => r.Orden) + .Select(Map) + .ToList(); + } +} diff --git a/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommand.cs b/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommand.cs new file mode 100644 index 0000000..46010f2 --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommand.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Rubros.Create; + +public sealed record CreateRubroCommand( + string Nombre, + int? ParentId, + int? TarifarioBaseId); diff --git a/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs b/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs new file mode 100644 index 0000000..7332524 --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs @@ -0,0 +1,91 @@ +using System.Transactions; +using Microsoft.Extensions.Options; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Rubros.Create; + +public sealed class CreateRubroCommandHandler : ICommandHandler +{ + private readonly IRubroRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + private readonly RubrosOptions _options; + + public CreateRubroCommandHandler( + IRubroRepository repo, + IAuditLogger audit, + TimeProvider timeProvider, + IOptions options) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + _options = options.Value; + } + + public async Task Handle(CreateRubroCommand command) + { + // Validate parent exists and is active (if provided) + if (command.ParentId.HasValue) + { + var parent = await _repo.GetByIdAsync(command.ParentId.Value); + if (parent is null) + throw new RubroNotFoundException(command.ParentId.Value); + if (!parent.Activo) + throw new RubroPadreInactivoException(command.ParentId.Value); + + // Depth check: parent's depth + 1 must not exceed MaxDepth + var parentDepth = await _repo.GetDepthAsync(command.ParentId); + var newDepth = parentDepth + 1; + if (newDepth > _options.MaxDepth) + throw new RubroMaxDepthExceededException(newDepth, _options.MaxDepth); + } + + // Duplicate name check (CI) under same parent + var exists = await _repo.ExistsByNombreUnderParentAsync(command.ParentId, command.Nombre, excludeId: null); + if (exists) + throw new RubroNombreDuplicadoEnPadreException(command.Nombre, command.ParentId); + + // Determine Orden = MAX+1 among siblings + var orden = await _repo.GetMaxOrdenAsync(command.ParentId); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var rubro = Rubro.ForCreation(command.Nombre, command.ParentId, orden, command.TarifarioBaseId, _timeProvider); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + var newId = await _repo.AddAsync(rubro); + + await _audit.LogAsync( + action: "rubro.created", + targetType: "Rubro", + targetId: newId.ToString(), + metadata: new + { + after = new + { + rubro.Nombre, + rubro.ParentId, + rubro.Orden, + rubro.TarifarioBaseId, + }, + }); + + tx.Complete(); + + return new RubroCreatedDto( + Id: newId, + Nombre: rubro.Nombre, + ParentId: rubro.ParentId, + Orden: rubro.Orden, + Activo: rubro.Activo, + TarifarioBaseId: rubro.TarifarioBaseId); + } +} diff --git a/src/api/SIGCM2.Application/Rubros/Create/RubroCreatedDto.cs b/src/api/SIGCM2.Application/Rubros/Create/RubroCreatedDto.cs new file mode 100644 index 0000000..4aa8b6e --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Create/RubroCreatedDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Rubros.Create; + +public sealed record RubroCreatedDto( + int Id, + string Nombre, + int? ParentId, + int Orden, + bool Activo, + int? TarifarioBaseId); diff --git a/src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommand.cs b/src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommand.cs new file mode 100644 index 0000000..8afe27d --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Rubros.Deactivate; + +public sealed record DeactivateRubroCommand(int Id); diff --git a/src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommandHandler.cs b/src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommandHandler.cs new file mode 100644 index 0000000..0099b13 --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Deactivate/DeactivateRubroCommandHandler.cs @@ -0,0 +1,58 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Rubros.Deactivate; + +public sealed class DeactivateRubroCommandHandler : ICommandHandler +{ + private readonly IRubroRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public DeactivateRubroCommandHandler( + IRubroRepository repo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(DeactivateRubroCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new RubroNotFoundException(command.Id); + + var activeChildren = await _repo.CountActiveChildrenAsync(command.Id); + if (activeChildren > 0) + throw new RubroTieneHijosActivosException(command.Id, activeChildren); + + var deactivated = target.WithActivo(false, _timeProvider); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(deactivated); + + await _audit.LogAsync( + action: "rubro.deleted", + targetType: "Rubro", + targetId: command.Id.ToString(), + metadata: new + { + rubroId = command.Id, + nombre = target.Nombre, + activeChildrenCount = 0, + }); + + tx.Complete(); + + return new RubroStatusDto(Id: deactivated.Id, Activo: deactivated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Rubros/Deactivate/RubroStatusDto.cs b/src/api/SIGCM2.Application/Rubros/Deactivate/RubroStatusDto.cs new file mode 100644 index 0000000..e0cc5cf --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Deactivate/RubroStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Rubros.Deactivate; + +public sealed record RubroStatusDto(int Id, bool Activo); diff --git a/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs b/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs new file mode 100644 index 0000000..1bf5468 --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs @@ -0,0 +1,13 @@ +namespace SIGCM2.Application.Rubros.Dtos; + +/// +/// Represents a single node in the N-ary Rubro tree returned by GetRubroTreeQuery. +/// +public sealed record RubroTreeNodeDto( + int Id, + string Nombre, + int Orden, + bool Activo, + int? ParentId, + int? TarifarioBaseId, + IReadOnlyList Hijos); diff --git a/src/api/SIGCM2.Application/Rubros/GetById/GetRubroByIdQuery.cs b/src/api/SIGCM2.Application/Rubros/GetById/GetRubroByIdQuery.cs new file mode 100644 index 0000000..11d70f9 --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/GetById/GetRubroByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Rubros.GetById; + +public sealed record GetRubroByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/Rubros/GetById/GetRubroByIdQueryHandler.cs b/src/api/SIGCM2.Application/Rubros/GetById/GetRubroByIdQueryHandler.cs new file mode 100644 index 0000000..d1a9120 --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/GetById/GetRubroByIdQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Rubros.GetById; + +public sealed class GetRubroByIdQueryHandler : ICommandHandler +{ + private readonly IRubroRepository _repo; + + public GetRubroByIdQueryHandler(IRubroRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetRubroByIdQuery query) + { + var rubro = await _repo.GetByIdAsync(query.Id) + ?? throw new RubroNotFoundException(query.Id); + + return new RubroDetailDto( + Id: rubro.Id, + Nombre: rubro.Nombre, + ParentId: rubro.ParentId, + Orden: rubro.Orden, + Activo: rubro.Activo, + TarifarioBaseId: rubro.TarifarioBaseId, + FechaCreacion: rubro.FechaCreacion, + FechaModificacion: rubro.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Rubros/GetById/RubroDetailDto.cs b/src/api/SIGCM2.Application/Rubros/GetById/RubroDetailDto.cs new file mode 100644 index 0000000..9a3f8f9 --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/GetById/RubroDetailDto.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.Rubros.GetById; + +public sealed record RubroDetailDto( + int Id, + string Nombre, + int? ParentId, + int Orden, + bool Activo, + int? TarifarioBaseId, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQuery.cs b/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQuery.cs new file mode 100644 index 0000000..f7c6eb4 --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Rubros.GetTree; + +public sealed record GetRubroTreeQuery(bool IncluirInactivos); diff --git a/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs b/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs new file mode 100644 index 0000000..6fcefcc --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs @@ -0,0 +1,22 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Rubros.Common; +using SIGCM2.Application.Rubros.Dtos; + +namespace SIGCM2.Application.Rubros.GetTree; + +public sealed class GetRubroTreeQueryHandler : ICommandHandler> +{ + private readonly IRubroRepository _repo; + + public GetRubroTreeQueryHandler(IRubroRepository repo) + { + _repo = repo; + } + + public async Task> Handle(GetRubroTreeQuery query) + { + var all = await _repo.GetAllAsync(query.IncluirInactivos); + return RubroTreeBuilder.Build(all, query.IncluirInactivos); + } +} diff --git a/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommand.cs b/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommand.cs new file mode 100644 index 0000000..d5348bd --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Rubros.Move; + +public sealed record MoveRubroCommand(int Id, int? NuevoParentId, int NuevoOrden); diff --git a/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs b/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs new file mode 100644 index 0000000..8bd32f5 --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs @@ -0,0 +1,92 @@ +using System.Transactions; +using Microsoft.Extensions.Options; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Rubros.Move; + +public sealed class MoveRubroCommandHandler : ICommandHandler +{ + private readonly IRubroRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + private readonly RubrosOptions _options; + + public MoveRubroCommandHandler( + IRubroRepository repo, + IAuditLogger audit, + TimeProvider timeProvider, + IOptions options) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + _options = options.Value; + } + + public async Task Handle(MoveRubroCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new RubroNotFoundException(command.Id); + + var anteriorParentId = target.ParentId; + + // Cycle check: nuevoParentId must not be in descendants of target + if (command.NuevoParentId.HasValue) + { + var descendants = await _repo.GetDescendantsAsync(command.Id); + if (descendants.Any(d => d.Id == command.NuevoParentId.Value)) + throw new RubroCycleDetectedException(command.Id, command.NuevoParentId.Value); + + // New parent must exist and be active + var newParent = await _repo.GetByIdAsync(command.NuevoParentId.Value) + ?? throw new RubroNotFoundException(command.NuevoParentId.Value); + + if (!newParent.Activo) + throw new RubroPadreInactivoException(command.NuevoParentId.Value); + + // Depth check + var parentDepth = await _repo.GetDepthAsync(command.NuevoParentId); + var newDepth = parentDepth + 1; + if (newDepth > _options.MaxDepth) + throw new RubroMaxDepthExceededException(newDepth, _options.MaxDepth); + } + + // Duplicate name check under new parent (excluding self) + var exists = await _repo.ExistsByNombreUnderParentAsync(command.NuevoParentId, target.Nombre, excludeId: command.Id); + if (exists) + throw new RubroNombreDuplicadoEnPadreException(target.Nombre, command.NuevoParentId); + + var moved = target.WithMoved(command.NuevoParentId, command.NuevoOrden, _timeProvider); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(moved); + + await _audit.LogAsync( + action: "rubro.moved", + targetType: "Rubro", + targetId: command.Id.ToString(), + metadata: new + { + anteriorParentId, + nuevoParentId = command.NuevoParentId, + anteriorOrden = target.Orden, + nuevoOrden = command.NuevoOrden, + }); + + tx.Complete(); + + return new RubroMovedDto( + Id: moved.Id, + Nombre: moved.Nombre, + ParentId: moved.ParentId, + Orden: moved.Orden, + Activo: moved.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Rubros/Move/RubroMovedDto.cs b/src/api/SIGCM2.Application/Rubros/Move/RubroMovedDto.cs new file mode 100644 index 0000000..f922ff5 --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Move/RubroMovedDto.cs @@ -0,0 +1,8 @@ +namespace SIGCM2.Application.Rubros.Move; + +public sealed record RubroMovedDto( + int Id, + string Nombre, + int? ParentId, + int Orden, + bool Activo); diff --git a/src/api/SIGCM2.Application/Rubros/Update/RubroUpdatedDto.cs b/src/api/SIGCM2.Application/Rubros/Update/RubroUpdatedDto.cs new file mode 100644 index 0000000..b462dfa --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Update/RubroUpdatedDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Rubros.Update; + +public sealed record RubroUpdatedDto( + int Id, + string Nombre, + int? ParentId, + int Orden, + bool Activo, + int? TarifarioBaseId); diff --git a/src/api/SIGCM2.Application/Rubros/Update/UpdateRubroCommand.cs b/src/api/SIGCM2.Application/Rubros/Update/UpdateRubroCommand.cs new file mode 100644 index 0000000..4d5ee9c --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Update/UpdateRubroCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Rubros.Update; + +public sealed record UpdateRubroCommand(int Id, string Nombre); diff --git a/src/api/SIGCM2.Application/Rubros/Update/UpdateRubroCommandHandler.cs b/src/api/SIGCM2.Application/Rubros/Update/UpdateRubroCommandHandler.cs new file mode 100644 index 0000000..b9d91bd --- /dev/null +++ b/src/api/SIGCM2.Application/Rubros/Update/UpdateRubroCommandHandler.cs @@ -0,0 +1,65 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Rubros.Update; + +public sealed class UpdateRubroCommandHandler : ICommandHandler +{ + private readonly IRubroRepository _repo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public UpdateRubroCommandHandler( + IRubroRepository repo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _repo = repo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(UpdateRubroCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new RubroNotFoundException(command.Id); + + // Duplicate name check (CI, excluding self) + var exists = await _repo.ExistsByNombreUnderParentAsync(target.ParentId, command.Nombre, excludeId: command.Id); + if (exists) + throw new RubroNombreDuplicadoEnPadreException(command.Nombre, target.ParentId); + + var updated = target.WithRenamed(command.Nombre, _timeProvider); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "rubro.updated", + targetType: "Rubro", + targetId: command.Id.ToString(), + metadata: new + { + before = new { target.Nombre }, + after = new { updated.Nombre }, + }); + + tx.Complete(); + + return new RubroUpdatedDto( + Id: updated.Id, + Nombre: updated.Nombre, + ParentId: updated.ParentId, + Orden: updated.Orden, + Activo: updated.Activo, + TarifarioBaseId: updated.TarifarioBaseId); + } +} diff --git a/tests/SIGCM2.Application.Tests/Rubros/Create/CreateRubroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Rubros/Create/CreateRubroCommandHandlerTests.cs new file mode 100644 index 0000000..f4b4798 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Rubros/Create/CreateRubroCommandHandlerTests.cs @@ -0,0 +1,176 @@ +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); + } +} diff --git a/tests/SIGCM2.Application.Tests/Rubros/Deactivate/DeactivateRubroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Rubros/Deactivate/DeactivateRubroCommandHandlerTests.cs new file mode 100644 index 0000000..68b5ddd --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Rubros/Deactivate/DeactivateRubroCommandHandlerTests.cs @@ -0,0 +1,106 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Rubros.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Rubros.Deactivate; + +public class DeactivateRubroCommandHandlerTests +{ + 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 DeactivateRubroCommandHandler _handler; + + private static Rubro LeafRubro(int id = 10) => new(id, null, "Autos", 0, activo: true, + tarifarioBaseId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null); + + public DeactivateRubroCommandHandlerTests() + { + _repo.CountActiveChildrenAsync(Arg.Any(), Arg.Any()).Returns(0); + _handler = new DeactivateRubroCommandHandler(_repo, _audit, _timeProvider); + } + + // ── Happy path: leaf soft-delete ───────────────────────────────────────── + + [Fact] + public async Task Handle_LeafRubro_SoftDeletes() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(LeafRubro()); + + var result = await _handler.Handle(new DeactivateRubroCommand(Id: 10)); + + result.Id.Should().Be(10); + result.Activo.Should().BeFalse(); + } + + [Fact] + public async Task Handle_LeafRubro_CallsUpdateAsync() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(LeafRubro()); + + await _handler.Handle(new DeactivateRubroCommand(Id: 10)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(r => r.Id == 10 && !r.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_LeafRubro_CallsAuditLog() + { + _repo.GetByIdAsync(10, Arg.Any()).Returns(LeafRubro()); + + await _handler.Handle(new DeactivateRubroCommand(Id: 10)); + + await _audit.Received(1).LogAsync( + action: "rubro.deleted", + targetType: "Rubro", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── Has active children → RubroTieneHijosActivosException ─────────────── + + [Fact] + public async Task Handle_HasActiveChildren_ThrowsRubroTieneHijosActivosException() + { + _repo.GetByIdAsync(5, Arg.Any()).Returns(LeafRubro(5)); + _repo.CountActiveChildrenAsync(5, Arg.Any()).Returns(3); + + var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 5)); + + await act.Should().ThrowAsync() + .Where(ex => ex.Id == 5 && ex.Count == 3); + } + + [Fact] + public async Task Handle_HasActiveChildren_DoesNotCallAuditLog() + { + _repo.GetByIdAsync(5, Arg.Any()).Returns(LeafRubro(5)); + _repo.CountActiveChildrenAsync(5, Arg.Any()).Returns(1); + + try { await _handler.Handle(new DeactivateRubroCommand(Id: 5)); } catch { } + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + // ── Not found → RubroNotFoundException ────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsRubroNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((Rubro?)null); + + var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 99)); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Rubros/GetTree/GetRubroTreeQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Rubros/GetTree/GetRubroTreeQueryHandlerTests.cs new file mode 100644 index 0000000..e79edbb --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Rubros/GetTree/GetRubroTreeQueryHandlerTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Rubros.GetById; +using SIGCM2.Application.Rubros.GetTree; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Rubros.GetTree; + +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 static Rubro MakeRubro(int id, int? parentId = null, bool activo = true) + => new(id, parentId, $"Rubro{id}", 0, activo, null, FakeTime.GetUtcNow().UtcDateTime, null); + + // ── GetRubroTreeQueryHandler ───────────────────────────────────────────── + + [Fact] + public async Task GetTree_OnlyActivos_ByDefault_ReturnsActiveTree() + { + _repo.GetAllAsync(false, Arg.Any()) + .Returns(new[] { MakeRubro(1), MakeRubro(2) }); + + var handler = new GetRubroTreeQueryHandler(_repo); + var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false)); + + result.Should().HaveCount(2); + } + + [Fact] + public async Task GetTree_IncluirInactivos_CallsRepoWithTrue() + { + _repo.GetAllAsync(true, Arg.Any()) + .Returns(new[] { MakeRubro(1), MakeRubro(2, activo: false) }); + + var handler = new GetRubroTreeQueryHandler(_repo); + var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: true)); + + await _repo.Received(1).GetAllAsync(true, Arg.Any()); + result.Should().HaveCount(2); // both roots + } + + [Fact] + public async Task GetTree_Empty_ReturnsEmptyList() + { + _repo.GetAllAsync(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + + var handler = new GetRubroTreeQueryHandler(_repo); + var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false)); + + result.Should().BeEmpty(); + } + + // ── GetRubroByIdQueryHandler ───────────────────────────────────────────── + + [Fact] + public async Task GetById_Found_Active_ReturnsDto() + { + _repo.GetByIdAsync(5, Arg.Any()).Returns(MakeRubro(5)); + + var handler = new GetRubroByIdQueryHandler(_repo); + var result = await handler.Handle(new GetRubroByIdQuery(Id: 5)); + + result.Should().NotBeNull(); + result!.Id.Should().Be(5); + } + + [Fact] + public async Task GetById_NotFound_ThrowsRubroNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((Rubro?)null); + + var handler = new GetRubroByIdQueryHandler(_repo); + var act = () => handler.Handle(new GetRubroByIdQuery(Id: 99)); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs new file mode 100644 index 0000000..51bea63 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Rubros/Move/MoveRubroCommandHandlerTests.cs @@ -0,0 +1,176 @@ +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(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Rubros/RubroTreeBuilderTests.cs b/tests/SIGCM2.Application.Tests/Rubros/RubroTreeBuilderTests.cs new file mode 100644 index 0000000..14e8338 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Rubros/RubroTreeBuilderTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SIGCM2.Application.Rubros.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Rubros; + +public class RubroTreeBuilderTests +{ + private static readonly FakeTimeProvider FakeTime = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero)); + + private static Rubro MakeRubro(int id, int? parentId, string nombre, int orden, bool activo = true) + => new(id, parentId, nombre, orden, activo, tarifarioBaseId: null, + fechaCreacion: FakeTime.GetUtcNow().UtcDateTime, fechaModificacion: null); + + // ── empty ───────────────────────────────────────────────────────────────── + + [Fact] + public void Build_empty_returns_empty_list() + { + var result = RubroTreeBuilder.Build([], incluirInactivos: false); + + result.Should().BeEmpty(); + } + + // ── single root ─────────────────────────────────────────────────────────── + + [Fact] + public void Build_single_root_returns_one_node_no_children() + { + var rubros = new[] { MakeRubro(1, null, "Autos", 0) }; + + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + + result.Should().HaveCount(1); + result[0].Id.Should().Be(1); + result[0].Nombre.Should().Be("Autos"); + result[0].Hijos.Should().BeEmpty(); + } + + // ── multiple roots sorted by Orden ─────────────────────────────────────── + + [Fact] + public void Build_flat_list_with_multiple_roots_sorted_by_orden() + { + var rubros = new[] + { + MakeRubro(3, null, "Motos", 2), + MakeRubro(1, null, "Autos", 0), + MakeRubro(2, null, "Camiones", 1) + }; + + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + + result.Should().HaveCount(3); + result[0].Id.Should().Be(1); // Orden=0 + result[1].Id.Should().Be(2); // Orden=1 + result[2].Id.Should().Be(3); // Orden=2 + } + + // ── tree 3 levels deep ──────────────────────────────────────────────────── + + [Fact] + public void Build_tree_3_levels_deep_correctly_nests() + { + var rubros = new[] + { + MakeRubro(1, null, "Autos", 0), + MakeRubro(2, 1, "Sedanes", 0), + MakeRubro(3, 2, "Compactos", 0), + }; + + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + + result.Should().HaveCount(1); + result[0].Id.Should().Be(1); + result[0].Hijos.Should().HaveCount(1); + result[0].Hijos[0].Id.Should().Be(2); + result[0].Hijos[0].Hijos.Should().HaveCount(1); + result[0].Hijos[0].Hijos[0].Id.Should().Be(3); + } + + // ── filter inactivos ────────────────────────────────────────────────────── + + [Fact] + public void Build_filters_inactivos_by_default() + { + var rubros = new[] + { + MakeRubro(1, null, "Autos", 0, activo: true), + MakeRubro(2, null, "Motos", 1, activo: false), + }; + + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + + result.Should().HaveCount(1); + result[0].Id.Should().Be(1); + } + + [Fact] + public void Build_includes_inactivos_when_incluirInactivos_true() + { + var rubros = new[] + { + MakeRubro(1, null, "Autos", 0, activo: true), + MakeRubro(2, null, "Motos", 1, activo: false), + }; + + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: true); + + result.Should().HaveCount(2); + } + + // ── siblings sorted by Orden ────────────────────────────────────────────── + + [Fact] + public void Build_orders_siblings_by_orden() + { + var rubros = new[] + { + MakeRubro(1, null, "Root", 0), + MakeRubro(4, 1, "D", 3), + MakeRubro(2, 1, "B", 1), + MakeRubro(3, 1, "C", 2), + MakeRubro(5, 1, "A", 0), + }; + + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + + var hijos = result[0].Hijos; + hijos.Should().HaveCount(4); + hijos[0].Nombre.Should().Be("A"); // Orden=0 + hijos[1].Nombre.Should().Be("B"); // Orden=1 + hijos[2].Nombre.Should().Be("C"); // Orden=2 + hijos[3].Nombre.Should().Be("D"); // Orden=3 + } + + // ── O(n) perf smoke test ────────────────────────────────────────────────── + + [Fact] + public void Build_is_Olinear_perf_smoke_test_1000_nodes_under_100ms() + { + var rubros = new List(); + // root + rubros.Add(MakeRubro(1, null, "Root", 0)); + // 999 children of root + for (int i = 2; i <= 1000; i++) + rubros.Add(MakeRubro(i, 1, $"Child{i}", i - 2)); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false); + sw.Stop(); + + result.Should().HaveCount(1); + result[0].Hijos.Should().HaveCount(999); + sw.ElapsedMilliseconds.Should().BeLessThan(100); + } +} diff --git a/tests/SIGCM2.Application.Tests/Rubros/Update/UpdateRubroCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Rubros/Update/UpdateRubroCommandHandlerTests.cs new file mode 100644 index 0000000..fbd8029 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Rubros/Update/UpdateRubroCommandHandlerTests.cs @@ -0,0 +1,110 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Rubros.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Rubros.Update; + +public class UpdateRubroCommandHandlerTests +{ + 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 UpdateRubroCommandHandler _handler; + + private static Rubro ExistingRubro(int id = 3) => new(id, null, "Autos", 0, activo: true, + tarifarioBaseId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null); + + public UpdateRubroCommandHandlerTests() + { + _repo.ExistsByNombreUnderParentAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + + _handler = new UpdateRubroCommandHandler(_repo, _audit, _timeProvider); + } + + // ── Happy path: rename ─────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_Rename_ReturnsUpdatedDto() + { + _repo.GetByIdAsync(3, Arg.Any()).Returns(ExistingRubro()); + + var result = await _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Vehiculos")); + + result.Nombre.Should().Be("Vehiculos"); + result.Id.Should().Be(3); + } + + [Fact] + public async Task Handle_HappyPath_Rename_CallsUpdateAsync() + { + _repo.GetByIdAsync(3, Arg.Any()).Returns(ExistingRubro()); + + await _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Vehiculos")); + + await _repo.Received(1).UpdateAsync( + Arg.Is(r => r.Nombre == "Vehiculos"), + Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_Rename_CallsAuditLog() + { + _repo.GetByIdAsync(3, Arg.Any()).Returns(ExistingRubro()); + + await _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Vehiculos")); + + await _audit.Received(1).LogAsync( + action: "rubro.updated", + targetType: "Rubro", + targetId: "3", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── Not found → RubroNotFoundException ────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsRubroNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((Rubro?)null); + + var act = () => _handler.Handle(new UpdateRubroCommand(Id: 99, Nombre: "Cualquiera")); + + await act.Should().ThrowAsync() + .Where(ex => ex.Id == 99); + } + + // ── Duplicate name CI under same parent → RubroNombreDuplicadoEnPadreException + + [Fact] + public async Task Handle_DuplicateNameUnderParent_ThrowsRubroNombreDuplicadoEnPadreException() + { + _repo.GetByIdAsync(3, Arg.Any()).Returns(ExistingRubro()); + _repo.ExistsByNombreUnderParentAsync(null, "Motos", 3, Arg.Any()) + .Returns(true); + + var act = () => _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Motos")); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_DuplicateName_DoesNotCallAuditLog() + { + _repo.GetByIdAsync(3, Arg.Any()).Returns(ExistingRubro()); + _repo.ExistsByNombreUnderParentAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + try { await _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Motos")); } catch { } + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +}