Merge pull request 'feat: CAT-001 Árbol N-ario de Rubros' (#30) from feature/CAT-001 into main
This commit was merged in pull request #30.
This commit is contained in:
@@ -29,9 +29,10 @@ database/
|
||||
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
|
||||
| V011 | `V011__create_medio_seccion.sql` | ADM-001 | Medio + Seccion (temporal, retention 10y) + permiso `administracion:secciones:gestionar` |
|
||||
| V012 | `V012__seed_medios.sql` | ADM-001 | Seed idempotente de Medios ELDIA y ELPLATA |
|
||||
| V013 | `V013__create_punto_de_venta.sql` | ADM-008 | PuntoDeVenta (temporal) + permiso `administracion:puntosdeventa:gestionar` |
|
||||
| V014 | `V014__create_tipo_de_iva_ingresos_brutos.sql` | ADM-009 | TipoDeIva + IngresosBrutos (temporales con vigencias) + permiso fiscal |
|
||||
| V015 | `V015__create_audit_views.sql` | UDT-011 | Vistas `v_AuditEvent_Local` + `v_SecurityEvent_Local` con timezone local |
|
||||
| V013 | `V013__create_puntos_de_venta.sql` | ADM-008 | PuntosDeVenta (temporal, retention 10y) + permiso `administracion:puntos_de_venta:gestionar` |
|
||||
| V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales |
|
||||
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
||||
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
||||
|
||||
## Convenciones
|
||||
|
||||
|
||||
82
database/migrations/V016_ROLLBACK.sql
Normal file
82
database/migrations/V016_ROLLBACK.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- V016_ROLLBACK.sql
|
||||
-- Reversa de V016__create_rubro.sql.
|
||||
--
|
||||
-- ⚠️ ADVERTENCIA: ejecutar ELIMINA dbo.Rubro, dbo.Rubro_History,
|
||||
-- el permiso 'catalogo:rubros:gestionar' y sus asignaciones.
|
||||
--
|
||||
-- Uso intended: ROLLBACK en entornos NO-productivos.
|
||||
-- Prerequisito: no deben existir FKs vivas apuntando a Rubro (p.ej., Producto, Tarifario).
|
||||
-- Si CAT-002..006 o PRC-001 ya están aplicados, agregar TarifarioBaseId FK,
|
||||
-- este rollback fallará — usar backup.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Rubro
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = OFF);
|
||||
PRINT 'Rubro: SYSTEM_VERSIONING OFF.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Rubro'))
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro DROP PERIOD FOR SYSTEM_TIME;
|
||||
PRINT 'Rubro: PERIOD FOR SYSTEM_TIME dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro DROP CONSTRAINT IF EXISTS DF_Rubro_ValidFrom;
|
||||
ALTER TABLE dbo.Rubro DROP CONSTRAINT IF EXISTS DF_Rubro_ValidTo;
|
||||
ALTER TABLE dbo.Rubro DROP COLUMN ValidFrom, ValidTo;
|
||||
PRINT 'Rubro: ValidFrom/ValidTo dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.Rubro_History', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.Rubro_History;
|
||||
PRINT 'Rubro_History dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. Drop índices + tabla Rubro
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Self-FK must be dropped before dropping the table (SQL Server handles it
|
||||
-- automatically when the table is dropped, but explicit is safer).
|
||||
|
||||
IF OBJECT_ID(N'dbo.Rubro', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.Rubro;
|
||||
PRINT 'Table dbo.Rubro dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. Remover permiso 'catalogo:rubros:gestionar' + RolPermiso
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
DELETE rp
|
||||
FROM dbo.RolPermiso rp
|
||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||
WHERE p.Codigo = 'catalogo:rubros:gestionar';
|
||||
GO
|
||||
|
||||
DELETE FROM dbo.Permiso
|
||||
WHERE Codigo = 'catalogo:rubros:gestionar';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V016 rolled back. dbo.Rubro and dbo.Rubro_History removed.';
|
||||
PRINT 'catalogo:rubros:gestionar permission and role assignment removed.';
|
||||
GO
|
||||
152
database/migrations/V016__create_rubro.sql
Normal file
152
database/migrations/V016__create_rubro.sql
Normal file
@@ -0,0 +1,152 @@
|
||||
-- V016__create_rubro.sql
|
||||
-- CAT-001: Árbol N-ario de Rubros — tabla fundacional del catálogo comercial.
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. dbo.Rubro (adjacency list, self-FK, soft-delete, SYSTEM_VERSIONING ON, retention 10 años).
|
||||
-- 2. Índice filtrado unique UQ_Rubro_ParentId_Nombre_Activo (unicidad CI por padre en activos).
|
||||
-- 3. Índice cubriente IX_Rubro_ParentId_Activo (child lookups ordenados).
|
||||
-- 4. Permiso 'catalogo:rubros:gestionar' + asignación a rol 'admin'.
|
||||
--
|
||||
-- Patrón: V011 (dbo.Medio con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Reversa: V016_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||
--
|
||||
-- Notas:
|
||||
-- - TarifarioBaseId es INT NULL SIN FK — la FK se agrega en PRC-001.
|
||||
-- - UQ_Rubro_ParentId_Nombre_Activo cubre solo ParentId IS NOT NULL;
|
||||
-- para roots (ParentId IS NULL) la unicidad CI la garantiza Application
|
||||
-- via ExistsByNombreUnderParentAsync(null, ...) — SQL Server trata NULLs
|
||||
-- como distintos en índices únicos. Ver Design §9 Risk 1.
|
||||
-- - FechaCreacion / FechaModificacion: DATETIME2(3) alineado con Medio/Seccion.
|
||||
-- - ValidFrom / ValidTo: DATETIME2(3) GENERATED ALWAYS HIDDEN (idéntico a V011).
|
||||
--
|
||||
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
|
||||
-- SDD Design: engram sdd/cat-001-arbol-nario-rubros/design
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. dbo.Rubro
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID(N'dbo.Rubro', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Rubro (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rubro PRIMARY KEY,
|
||||
ParentId INT NULL,
|
||||
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||
Orden INT NOT NULL CONSTRAINT DF_Rubro_Orden DEFAULT(0),
|
||||
Activo BIT NOT NULL CONSTRAINT DF_Rubro_Activo DEFAULT(1),
|
||||
TarifarioBaseId INT NULL, -- FK reservada para PRC-001 (sin constraint por ahora)
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rubro_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
FechaModificacion DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Rubro_Parent FOREIGN KEY (ParentId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION
|
||||
);
|
||||
PRINT 'Table dbo.Rubro created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.Rubro already exists — skip.';
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. SYSTEM_VERSIONING — Rubro
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Rubro_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Rubro_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
PRINT 'Rubro: PERIOD FOR SYSTEM_TIME added.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.Rubro_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'Rubro: SYSTEM_VERSIONING = ON (history: dbo.Rubro_History, retention: 10 years).';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Rubro: SYSTEM_VERSIONING already ON — skip.';
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Rubro_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys.partitions p
|
||||
JOIN sys.tables t ON t.object_id = p.object_id
|
||||
WHERE t.name = 'Rubro_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'Rubro_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. Índices
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Unicidad CI por nombre bajo el mismo padre (solo filas activas + ParentId NOT NULL).
|
||||
-- Para roots (ParentId IS NULL) la unicidad la garantiza Application layer.
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Rubro_ParentId_Nombre_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UQ_Rubro_ParentId_Nombre_Activo
|
||||
ON dbo.Rubro(ParentId, Nombre)
|
||||
WHERE Activo = 1 AND ParentId IS NOT NULL;
|
||||
PRINT 'Index UQ_Rubro_ParentId_Nombre_Activo created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Cubriente para child lookups ordenados por Orden.
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Rubro_ParentId_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Rubro_ParentId_Activo
|
||||
ON dbo.Rubro(ParentId, Activo)
|
||||
INCLUDE (Nombre, Orden);
|
||||
PRINT 'Index IX_Rubro_ParentId_Activo created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 4. Permiso: catalogo:rubros:gestionar + asignación a rol 'admin'
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||
GO
|
||||
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES
|
||||
('admin', 'catalogo:rubros:gestionar')
|
||||
) AS x (RolCodigo, PermisoCodigo)
|
||||
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V016 applied successfully — dbo.Rubro (temporal, retention 10y) + permiso catalogo:rubros:gestionar.';
|
||||
PRINT 'Next: V017 (future — TBD by next UDT).';
|
||||
GO
|
||||
151
src/api/SIGCM2.Api/Controllers/RubrosController.cs
Normal file
151
src/api/SIGCM2.Api/Controllers/RubrosController.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Rubros.Create;
|
||||
using SIGCM2.Application.Rubros.Deactivate;
|
||||
using SIGCM2.Application.Rubros.Dtos;
|
||||
using SIGCM2.Application.Rubros.GetById;
|
||||
using SIGCM2.Application.Rubros.GetTree;
|
||||
using SIGCM2.Application.Rubros.Move;
|
||||
using SIGCM2.Application.Rubros.Update;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// CAT-001: Rubro N-ary tree management.
|
||||
/// Read endpoints at /api/v1/rubros — require authentication (any role).
|
||||
/// Write endpoints at /api/v1/admin/rubros — require 'catalogo:rubros:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public sealed class RubrosController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
|
||||
public RubrosController(IDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
// ── READ endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Returns the full Rubro tree. Requires authentication.</summary>
|
||||
[HttpGet("api/v1/rubros/tree")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<RubroTreeNodeDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> GetRubroTree([FromQuery] bool incluirInactivos = false)
|
||||
{
|
||||
var query = new GetRubroTreeQuery(incluirInactivos);
|
||||
var result = await _dispatcher.Send<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Returns a single Rubro by id. Requires authentication.</summary>
|
||||
[HttpGet("api/v1/rubros/{id:int}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(RubroDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetRubroById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetRubroByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetRubroByIdQuery, RubroDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── WRITE endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Creates a new Rubro. Requires catalogo:rubros:gestionar.</summary>
|
||||
[HttpPost("api/v1/admin/rubros")]
|
||||
[RequirePermission("catalogo:rubros:gestionar")]
|
||||
[ProducesResponseType(typeof(RubroCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> CreateRubro([FromBody] CreateRubroRequest request)
|
||||
{
|
||||
var command = new CreateRubroCommand(
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
ParentId: request.ParentId,
|
||||
TarifarioBaseId: request.TarifarioBaseId);
|
||||
|
||||
var result = await _dispatcher.Send<CreateRubroCommand, RubroCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetRubroById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a Rubro's nombre. Requires catalogo:rubros:gestionar.</summary>
|
||||
[HttpPut("api/v1/admin/rubros/{id:int}")]
|
||||
[RequirePermission("catalogo:rubros:gestionar")]
|
||||
[ProducesResponseType(typeof(RubroUpdatedDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> UpdateRubro([FromRoute] int id, [FromBody] UpdateRubroRequest request)
|
||||
{
|
||||
var command = new UpdateRubroCommand(
|
||||
Id: id,
|
||||
Nombre: request.Nombre ?? string.Empty);
|
||||
|
||||
var result = await _dispatcher.Send<UpdateRubroCommand, RubroUpdatedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes (deactivates) a Rubro. Requires catalogo:rubros:gestionar.</summary>
|
||||
[HttpDelete("api/v1/admin/rubros/{id:int}")]
|
||||
[RequirePermission("catalogo:rubros:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> DeactivateRubro([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateRubroCommand(id);
|
||||
await _dispatcher.Send<DeactivateRubroCommand, RubroStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Moves a Rubro to a new parent. Requires catalogo:rubros:gestionar.</summary>
|
||||
[HttpPatch("api/v1/admin/rubros/{id:int}/mover")]
|
||||
[RequirePermission("catalogo:rubros:gestionar")]
|
||||
[ProducesResponseType(typeof(RubroMovedDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> MoveRubro([FromRoute] int id, [FromBody] MoveRubroRequest request)
|
||||
{
|
||||
var command = new MoveRubroCommand(
|
||||
Id: id,
|
||||
NuevoParentId: request.NuevoParentId,
|
||||
NuevoOrden: request.NuevoOrden);
|
||||
|
||||
var result = await _dispatcher.Send<MoveRubroCommand, RubroMovedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>CAT-001: Create rubro request body.</summary>
|
||||
public sealed record CreateRubroRequest(
|
||||
string? Nombre,
|
||||
int? ParentId,
|
||||
int? TarifarioBaseId);
|
||||
|
||||
/// <summary>CAT-001: Update rubro request body.</summary>
|
||||
public sealed record UpdateRubroRequest(
|
||||
string? Nombre);
|
||||
|
||||
/// <summary>CAT-001: Move rubro request body.</summary>
|
||||
public sealed record MoveRubroRequest(
|
||||
int? NuevoParentId,
|
||||
int NuevoOrden);
|
||||
@@ -169,6 +169,79 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// CAT-001: Rubro exceptions
|
||||
case RubroNotFoundException rubroNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_not_found",
|
||||
message = rubroNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroNombreDuplicadoEnPadreException rubroDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_nombre_duplicado",
|
||||
message = rubroDupEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroTieneHijosActivosException rubroHijosEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_tiene_hijos_activos",
|
||||
message = rubroHijosEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroPadreInactivoException rubroPadreEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_padre_inactivo",
|
||||
message = rubroPadreEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroMaxDepthExceededException rubroDepthEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_max_depth_exceeded",
|
||||
message = rubroDepthEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status422UnprocessableEntity
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroCycleDetectedException rubroCycleEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_cycle_detected",
|
||||
message = rubroCycleEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// ADM-001: Medio exceptions
|
||||
case MedioCodigoDuplicadoException medioCodDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
|
||||
@@ -32,5 +32,8 @@
|
||||
],
|
||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
||||
},
|
||||
"Rubros": {
|
||||
"MaxDepth": 10
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IRubroRepository
|
||||
{
|
||||
Task<int> AddAsync(Rubro rubro, CancellationToken ct = default);
|
||||
Task<Rubro?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Rubro>> GetAllAsync(bool incluirInactivos, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all descendants of rootId via recursive CTE (used only by MoveRubro for cycle detection).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Rubro>> GetDescendantsAsync(int rootId, CancellationToken ct = default);
|
||||
|
||||
Task UpdateAsync(Rubro rubro, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of active children for the given parentId.
|
||||
/// Used by soft-delete to guard against deleting non-leaf rubros.
|
||||
/// </summary>
|
||||
Task<int> CountActiveChildrenAsync(int id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns MAX(Orden)+1 among siblings of the given parentId (0 if no siblings).
|
||||
/// Used for append-on-create ordering.
|
||||
/// </summary>
|
||||
Task<int> GetMaxOrdenAsync(int? parentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
Task<bool> ExistsByNombreUnderParentAsync(int? parentId, string nombre, int? excludeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the depth of the given parentId (0 if parentId is null = root level).
|
||||
/// Uses a recursive CTE going upward through ancestors.
|
||||
/// </summary>
|
||||
Task<int> GetDepthAsync(int? parentId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -60,6 +60,13 @@ using SIGCM2.Application.Usuarios.Reactivate;
|
||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||
using SIGCM2.Application.Usuarios.Permisos;
|
||||
using SIGCM2.Application.Usuarios.Update;
|
||||
using SIGCM2.Application.Rubros.Create;
|
||||
using SIGCM2.Application.Rubros.Update;
|
||||
using SIGCM2.Application.Rubros.Deactivate;
|
||||
using SIGCM2.Application.Rubros.Move;
|
||||
using SIGCM2.Application.Rubros.GetTree;
|
||||
using SIGCM2.Application.Rubros.GetById;
|
||||
using SIGCM2.Application.Rubros.Dtos;
|
||||
|
||||
namespace SIGCM2.Application;
|
||||
|
||||
@@ -145,6 +152,14 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>, GetHistorialIngresosBrutosQueryHandler>();
|
||||
|
||||
// Rubros (CAT-001)
|
||||
services.AddScoped<ICommandHandler<CreateRubroCommand, RubroCreatedDto>, CreateRubroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<MoveRubroCommand, RubroMovedDto>, MoveRubroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
47
src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs
Normal file
47
src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using SIGCM2.Application.Rubros.Dtos;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Rubros.Common;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class RubroTreeBuilder
|
||||
{
|
||||
public static IReadOnlyList<RubroTreeNodeDto> Build(
|
||||
IEnumerable<Rubro> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Rubros.Create;
|
||||
|
||||
public sealed record CreateRubroCommand(
|
||||
string Nombre,
|
||||
int? ParentId,
|
||||
int? TarifarioBaseId);
|
||||
@@ -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<CreateRubroCommand, RubroCreatedDto>
|
||||
{
|
||||
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<RubrosOptions> options)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<RubroCreatedDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Rubros.Deactivate;
|
||||
|
||||
public sealed record DeactivateRubroCommand(int Id);
|
||||
@@ -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<DeactivateRubroCommand, RubroStatusDto>
|
||||
{
|
||||
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<RubroStatusDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Rubros.Deactivate;
|
||||
|
||||
public sealed record RubroStatusDto(int Id, bool Activo);
|
||||
13
src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs
Normal file
13
src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM2.Application.Rubros.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single node in the N-ary Rubro tree returned by GetRubroTreeQuery.
|
||||
/// </summary>
|
||||
public sealed record RubroTreeNodeDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
int Orden,
|
||||
bool Activo,
|
||||
int? ParentId,
|
||||
int? TarifarioBaseId,
|
||||
IReadOnlyList<RubroTreeNodeDto> Hijos);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Rubros.GetById;
|
||||
|
||||
public sealed record GetRubroByIdQuery(int Id);
|
||||
@@ -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<GetRubroByIdQuery, RubroDetailDto>
|
||||
{
|
||||
private readonly IRubroRepository _repo;
|
||||
|
||||
public GetRubroByIdQueryHandler(IRubroRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<RubroDetailDto> 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);
|
||||
}
|
||||
}
|
||||
11
src/api/SIGCM2.Application/Rubros/GetById/RubroDetailDto.cs
Normal file
11
src/api/SIGCM2.Application/Rubros/GetById/RubroDetailDto.cs
Normal file
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Rubros.GetTree;
|
||||
|
||||
public sealed record GetRubroTreeQuery(bool IncluirInactivos);
|
||||
@@ -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<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>
|
||||
{
|
||||
private readonly IRubroRepository _repo;
|
||||
|
||||
public GetRubroTreeQueryHandler(IRubroRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RubroTreeNodeDto>> Handle(GetRubroTreeQuery query)
|
||||
{
|
||||
var all = await _repo.GetAllAsync(query.IncluirInactivos);
|
||||
return RubroTreeBuilder.Build(all, query.IncluirInactivos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Rubros.Move;
|
||||
|
||||
public sealed record MoveRubroCommand(int Id, int? NuevoParentId, int NuevoOrden);
|
||||
@@ -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<MoveRubroCommand, RubroMovedDto>
|
||||
{
|
||||
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<RubrosOptions> options)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<RubroMovedDto> 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);
|
||||
}
|
||||
}
|
||||
8
src/api/SIGCM2.Application/Rubros/Move/RubroMovedDto.cs
Normal file
8
src/api/SIGCM2.Application/Rubros/Move/RubroMovedDto.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SIGCM2.Application.Rubros.Move;
|
||||
|
||||
public sealed record RubroMovedDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
int? ParentId,
|
||||
int Orden,
|
||||
bool Activo);
|
||||
13
src/api/SIGCM2.Application/Rubros/RubrosOptions.cs
Normal file
13
src/api/SIGCM2.Application/Rubros/RubrosOptions.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM2.Application.Rubros;
|
||||
|
||||
/// Bound from appsettings section "Rubros".
|
||||
/// Controls the maximum allowed depth of the N-ary rubro tree.
|
||||
/// Resolvable via IOptions<RubrosOptions> in any handler that enforces the depth rule.
|
||||
public sealed class RubrosOptions
|
||||
{
|
||||
public const string SectionName = "Rubros";
|
||||
|
||||
/// Maximum tree depth (0 = root level). Default: 10.
|
||||
/// Depth-10 means a root + 9 levels of children.
|
||||
public int MaxDepth { get; set; } = 10;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Rubros.Update;
|
||||
|
||||
public sealed record UpdateRubroCommand(int Id, string Nombre);
|
||||
@@ -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<UpdateRubroCommand, RubroUpdatedDto>
|
||||
{
|
||||
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<RubroUpdatedDto> 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);
|
||||
}
|
||||
}
|
||||
139
src/api/SIGCM2.Domain/Entities/Rubro.cs
Normal file
139
src/api/SIGCM2.Domain/Entities/Rubro.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
namespace SIGCM2.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable N-ary tree node for the commercial catalog taxonomy.
|
||||
/// Follows the same sealed-class + factory + with-methods pattern as Medio.cs.
|
||||
/// </summary>
|
||||
public sealed class Rubro
|
||||
{
|
||||
private const int NombreMaxLength = 200;
|
||||
|
||||
public int Id { get; }
|
||||
public int? ParentId { get; }
|
||||
public string Nombre { get; }
|
||||
public int Orden { get; }
|
||||
public bool Activo { get; }
|
||||
public int? TarifarioBaseId { get; }
|
||||
public DateTime FechaCreacion { get; }
|
||||
public DateTime? FechaModificacion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Full hydration constructor — used by the repository to reconstruct from DB rows.
|
||||
/// </summary>
|
||||
public Rubro(
|
||||
int id,
|
||||
int? parentId,
|
||||
string nombre,
|
||||
int orden,
|
||||
bool activo,
|
||||
int? tarifarioBaseId,
|
||||
DateTime fechaCreacion,
|
||||
DateTime? fechaModificacion)
|
||||
{
|
||||
Id = id;
|
||||
ParentId = parentId;
|
||||
Nombre = nombre;
|
||||
Orden = orden;
|
||||
Activo = activo;
|
||||
TarifarioBaseId = tarifarioBaseId;
|
||||
FechaCreacion = fechaCreacion;
|
||||
FechaModificacion = fechaModificacion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating a new Rubro.
|
||||
/// Id=0 — DB assigns via IDENTITY.
|
||||
/// Activo=true, FechaModificacion=null by default.
|
||||
/// FechaCreacion is set from TimeProvider so it is testable.
|
||||
/// </summary>
|
||||
public static Rubro ForCreation(
|
||||
string nombre,
|
||||
int? parentId,
|
||||
int orden,
|
||||
int? tarifarioBaseId,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
ValidateNombre(nombre);
|
||||
|
||||
if (parentId.HasValue && parentId.Value <= 0)
|
||||
throw new ArgumentException("parentId debe ser un entero positivo cuando no es nulo.", nameof(parentId));
|
||||
|
||||
if (tarifarioBaseId.HasValue && tarifarioBaseId.Value < 0)
|
||||
throw new ArgumentException("tarifarioBaseId no puede ser negativo.", nameof(tarifarioBaseId));
|
||||
|
||||
return new Rubro(
|
||||
id: 0,
|
||||
parentId: parentId,
|
||||
nombre: nombre,
|
||||
orden: orden,
|
||||
activo: true,
|
||||
tarifarioBaseId: tarifarioBaseId,
|
||||
fechaCreacion: timeProvider.GetUtcNow().UtcDateTime,
|
||||
fechaModificacion: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new Rubro instance with an updated Nombre and FechaModificacion.
|
||||
/// Does NOT mutate the current instance.
|
||||
/// </summary>
|
||||
public Rubro WithRenamed(string nuevoNombre, TimeProvider timeProvider)
|
||||
{
|
||||
ValidateNombre(nuevoNombre);
|
||||
|
||||
return new Rubro(
|
||||
id: Id,
|
||||
parentId: ParentId,
|
||||
nombre: nuevoNombre,
|
||||
orden: Orden,
|
||||
activo: Activo,
|
||||
tarifarioBaseId: TarifarioBaseId,
|
||||
fechaCreacion: FechaCreacion,
|
||||
fechaModificacion: timeProvider.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new Rubro instance with updated ParentId and Orden.
|
||||
/// Does NOT mutate the current instance.
|
||||
/// </summary>
|
||||
public Rubro WithMoved(int? nuevoParentId, int nuevoOrden, TimeProvider timeProvider)
|
||||
{
|
||||
return new Rubro(
|
||||
id: Id,
|
||||
parentId: nuevoParentId,
|
||||
nombre: Nombre,
|
||||
orden: nuevoOrden,
|
||||
activo: Activo,
|
||||
tarifarioBaseId: TarifarioBaseId,
|
||||
fechaCreacion: FechaCreacion,
|
||||
fechaModificacion: timeProvider.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new Rubro instance with updated Activo flag.
|
||||
/// Use Deactivate (false) or Reactivate (true).
|
||||
/// Does NOT mutate the current instance.
|
||||
/// </summary>
|
||||
public Rubro WithActivo(bool activo, TimeProvider timeProvider)
|
||||
{
|
||||
return new Rubro(
|
||||
id: Id,
|
||||
parentId: ParentId,
|
||||
nombre: Nombre,
|
||||
orden: Orden,
|
||||
activo: activo,
|
||||
tarifarioBaseId: TarifarioBaseId,
|
||||
fechaCreacion: FechaCreacion,
|
||||
fechaModificacion: timeProvider.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
private static void ValidateNombre(string nombre)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nombre))
|
||||
throw new ArgumentException("El nombre del rubro no puede estar vacío o ser solo espacios.", nameof(nombre));
|
||||
|
||||
if (nombre.Length > NombreMaxLength)
|
||||
throw new ArgumentException(
|
||||
$"El nombre del rubro no puede superar los {NombreMaxLength} caracteres.",
|
||||
nameof(nombre));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when moving a Rubro to one of its own descendants would create a cycle. → HTTP 400
|
||||
/// </summary>
|
||||
public sealed class RubroCycleDetectedException : DomainException
|
||||
{
|
||||
public int RubroId { get; }
|
||||
public int NuevoParentId { get; }
|
||||
|
||||
public RubroCycleDetectedException(int rubroId, int nuevoParentId)
|
||||
: base($"Mover el rubro '{rubroId}' al padre '{nuevoParentId}' crearía un ciclo en el árbol.")
|
||||
{
|
||||
RubroId = rubroId;
|
||||
NuevoParentId = nuevoParentId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when creating or moving a Rubro would exceed the configured maximum tree depth. → HTTP 422
|
||||
/// </summary>
|
||||
public sealed class RubroMaxDepthExceededException : DomainException
|
||||
{
|
||||
public int Intentada { get; }
|
||||
public int Max { get; }
|
||||
|
||||
public RubroMaxDepthExceededException(int intentada, int max)
|
||||
: base($"La profundidad intentada ({intentada}) excede el máximo permitido ({max}).")
|
||||
{
|
||||
Intentada = intentada;
|
||||
Max = max;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a Rubro with the same Nombre (CI) already exists under the same parent. → HTTP 409
|
||||
/// </summary>
|
||||
public sealed class RubroNombreDuplicadoEnPadreException : DomainException
|
||||
{
|
||||
public string Nombre { get; }
|
||||
public int? ParentId { get; }
|
||||
|
||||
public RubroNombreDuplicadoEnPadreException(string nombre, int? parentId)
|
||||
: base(parentId.HasValue
|
||||
? $"Ya existe un rubro con el nombre '{nombre}' bajo el padre con id '{parentId}'."
|
||||
: $"Ya existe un rubro raíz con el nombre '{nombre}'.")
|
||||
{
|
||||
Nombre = nombre;
|
||||
ParentId = parentId;
|
||||
}
|
||||
}
|
||||
15
src/api/SIGCM2.Domain/Exceptions/RubroNotFoundException.cs
Normal file
15
src/api/SIGCM2.Domain/Exceptions/RubroNotFoundException.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a requested Rubro does not exist in the system. → HTTP 404
|
||||
/// </summary>
|
||||
public sealed class RubroNotFoundException : DomainException
|
||||
{
|
||||
public int Id { get; }
|
||||
|
||||
public RubroNotFoundException(int id)
|
||||
: base($"El rubro con id '{id}' no existe.")
|
||||
{
|
||||
Id = id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when attempting to create or move a Rubro under an inactive parent. → HTTP 400
|
||||
/// </summary>
|
||||
public sealed class RubroPadreInactivoException : DomainException
|
||||
{
|
||||
public int ParentId { get; }
|
||||
|
||||
public RubroPadreInactivoException(int parentId)
|
||||
: base($"El padre con id '{parentId}' está inactivo y no puede tener hijos.")
|
||||
{
|
||||
ParentId = parentId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when attempting to soft-delete a Rubro that still has active children. → HTTP 409
|
||||
/// </summary>
|
||||
public sealed class RubroTieneHijosActivosException : DomainException
|
||||
{
|
||||
public int Id { get; }
|
||||
public int Count { get; }
|
||||
|
||||
public RubroTieneHijosActivosException(int id, int count)
|
||||
: base($"El rubro con id '{id}' tiene {count} subrubros activos.")
|
||||
{
|
||||
Id = id;
|
||||
Count = count;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Auth;
|
||||
using SIGCM2.Application.Rubros;
|
||||
using SIGCM2.Infrastructure.Http;
|
||||
using SIGCM2.Infrastructure.Messaging;
|
||||
using SIGCM2.Infrastructure.Persistence;
|
||||
@@ -37,6 +38,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
|
||||
services.AddScoped<ITipoDeIvaRepository, TipoDeIvaRepository>();
|
||||
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
||||
services.AddScoped<IRubroRepository, RubroRepository>();
|
||||
|
||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||
@@ -77,6 +79,9 @@ public static class DependencyInjection
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<IClientContext, ClientContext>();
|
||||
|
||||
// CAT-001: Rubros options (MaxDepth) — overridable via appsettings "Rubros".
|
||||
services.Configure<RubrosOptions>(configuration.GetSection(RubrosOptions.SectionName));
|
||||
|
||||
// UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit".
|
||||
services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName));
|
||||
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
|
||||
|
||||
236
src/api/SIGCM2.Infrastructure/Persistence/RubroRepository.cs
Normal file
236
src/api/SIGCM2.Infrastructure/Persistence/RubroRepository.cs
Normal file
@@ -0,0 +1,236 @@
|
||||
using Dapper;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
public sealed class RubroRepository : IRubroRepository
|
||||
{
|
||||
private readonly SqlConnectionFactory _factory;
|
||||
|
||||
public RubroRepository(SqlConnectionFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public async Task<int> AddAsync(Rubro rubro, CancellationToken ct = default)
|
||||
{
|
||||
// DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()), Orden (0 default — overridden if provided).
|
||||
const string sql = """
|
||||
INSERT INTO dbo.Rubro (ParentId, Nombre, Orden, Activo, TarifarioBaseId)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES (@ParentId, @Nombre, @Orden, @Activo, @TarifarioBaseId)
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
return await connection.ExecuteScalarAsync<int>(sql, new
|
||||
{
|
||||
rubro.ParentId,
|
||||
rubro.Nombre,
|
||||
rubro.Orden,
|
||||
Activo = rubro.Activo ? 1 : 0,
|
||||
rubro.TarifarioBaseId,
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Rubro?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion
|
||||
FROM dbo.Rubro
|
||||
WHERE Id = @Id
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var row = await connection.QuerySingleOrDefaultAsync<RubroRow>(sql, new { Id = id });
|
||||
return row is null ? null : MapRow(row);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Rubro>> GetAllAsync(bool incluirInactivos, CancellationToken ct = default)
|
||||
{
|
||||
var sql = incluirInactivos
|
||||
? """
|
||||
SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion
|
||||
FROM dbo.Rubro
|
||||
ORDER BY ParentId, Orden
|
||||
"""
|
||||
: """
|
||||
SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion
|
||||
FROM dbo.Rubro
|
||||
WHERE Activo = 1
|
||||
ORDER BY ParentId, Orden
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var rows = await connection.QueryAsync<RubroRow>(sql);
|
||||
return rows.Select(MapRow).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Rubro>> GetDescendantsAsync(int rootId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
WITH Descendants AS (
|
||||
SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion
|
||||
FROM dbo.Rubro
|
||||
WHERE ParentId = @RootId
|
||||
UNION ALL
|
||||
SELECT r.Id, r.ParentId, r.Nombre, r.Orden, r.Activo, r.TarifarioBaseId, r.FechaCreacion, r.FechaModificacion
|
||||
FROM dbo.Rubro r
|
||||
INNER JOIN Descendants d ON r.ParentId = d.Id
|
||||
)
|
||||
SELECT * FROM Descendants
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var rows = await connection.QueryAsync<RubroRow>(sql, new { RootId = rootId });
|
||||
return rows.Select(MapRow).ToList();
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Rubro rubro, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE dbo.Rubro
|
||||
SET Nombre = @Nombre,
|
||||
ParentId = @ParentId,
|
||||
Orden = @Orden,
|
||||
Activo = @Activo,
|
||||
TarifarioBaseId = @TarifarioBaseId,
|
||||
FechaModificacion = @FechaModificacion
|
||||
WHERE Id = @Id
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
await connection.ExecuteAsync(sql, new
|
||||
{
|
||||
rubro.Nombre,
|
||||
rubro.ParentId,
|
||||
rubro.Orden,
|
||||
Activo = rubro.Activo ? 1 : 0,
|
||||
rubro.TarifarioBaseId,
|
||||
rubro.FechaModificacion,
|
||||
rubro.Id,
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<int> CountActiveChildrenAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(1) FROM dbo.Rubro
|
||||
WHERE ParentId = @Id AND Activo = 1
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
return await connection.ExecuteScalarAsync<int>(sql, new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<int> GetMaxOrdenAsync(int? parentId, CancellationToken ct = default)
|
||||
{
|
||||
// Returns MAX(Orden) + 1 among siblings, or 0 if no siblings exist.
|
||||
// Handler uses return value directly as the orden for the new Rubro.
|
||||
const string sql = """
|
||||
SELECT ISNULL(MAX(Orden) + 1, 0)
|
||||
FROM dbo.Rubro
|
||||
WHERE (@ParentId IS NULL AND ParentId IS NULL)
|
||||
OR ParentId = @ParentId
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
return await connection.ExecuteScalarAsync<int>(sql, new { ParentId = parentId });
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsByNombreUnderParentAsync(
|
||||
int? parentId,
|
||||
string nombre,
|
||||
int? excludeId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Use UPPER() for explicit case-insensitive comparison.
|
||||
// DB collation is SQL_Latin1_General_CP1_CI_AI on Nombre column (already CI),
|
||||
// but UPPER() makes intent explicit and works regardless of collation.
|
||||
// The WHERE clause handles both root (NULL parent) and non-root cases.
|
||||
const string sql = """
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.Rubro
|
||||
WHERE ((@ParentId IS NULL AND ParentId IS NULL) OR ParentId = @ParentId)
|
||||
AND UPPER(Nombre) = UPPER(@Nombre)
|
||||
AND Activo = 1
|
||||
AND (@ExcludeId IS NULL OR Id <> @ExcludeId)
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var count = await connection.ExecuteScalarAsync<int>(sql, new
|
||||
{
|
||||
ParentId = parentId,
|
||||
Nombre = nombre,
|
||||
ExcludeId = excludeId,
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<int> GetDepthAsync(int? parentId, CancellationToken ct = default)
|
||||
{
|
||||
// If parentId is null, depth is 0 (creating a root node).
|
||||
if (!parentId.HasValue)
|
||||
return 0;
|
||||
|
||||
// CTE walks up the ancestor chain from parentId to root, counting levels.
|
||||
// Each UNION ALL step goes one level up, so the count of rows = depth of parentId.
|
||||
const string sql = """
|
||||
WITH Ancestors AS (
|
||||
SELECT Id, ParentId, 1 AS Depth
|
||||
FROM dbo.Rubro
|
||||
WHERE Id = @ParentId
|
||||
UNION ALL
|
||||
SELECT r.Id, r.ParentId, a.Depth + 1
|
||||
FROM dbo.Rubro r
|
||||
INNER JOIN Ancestors a ON r.Id = a.ParentId
|
||||
)
|
||||
SELECT ISNULL(MAX(Depth), 0) FROM Ancestors
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
return await connection.ExecuteScalarAsync<int>(sql, new { ParentId = parentId.Value });
|
||||
}
|
||||
|
||||
// ── mapping ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static Rubro MapRow(RubroRow r)
|
||||
=> new(
|
||||
id: r.Id,
|
||||
parentId: r.ParentId,
|
||||
nombre: r.Nombre,
|
||||
orden: r.Orden,
|
||||
activo: r.Activo,
|
||||
tarifarioBaseId: r.TarifarioBaseId,
|
||||
fechaCreacion: r.FechaCreacion,
|
||||
fechaModificacion: r.FechaModificacion);
|
||||
|
||||
private sealed record RubroRow(
|
||||
int Id,
|
||||
int? ParentId,
|
||||
string Nombre,
|
||||
int Orden,
|
||||
bool Activo,
|
||||
int? TarifarioBaseId,
|
||||
DateTime FechaCreacion,
|
||||
DateTime? FechaModificacion);
|
||||
}
|
||||
173
src/web/package-lock.json
generated
173
src/web/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -21,6 +22,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
@@ -1723,6 +1725,92 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
@@ -2925,6 +3013,91 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -26,6 +27,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Newspaper,
|
||||
Columns3,
|
||||
Store,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -68,6 +69,12 @@ const adminItems: NavItem[] = [
|
||||
icon: Store,
|
||||
requiredPermission: 'administracion:puntos_de_venta:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Rubros',
|
||||
href: '/admin/rubros',
|
||||
icon: Tag,
|
||||
requiredPermission: 'catalogo:rubros:gestionar',
|
||||
},
|
||||
]
|
||||
|
||||
interface SidebarNavProps {
|
||||
|
||||
9
src/web/src/components/ui/collapsible.tsx
Normal file
9
src/web/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
29
src/web/src/components/ui/switch.tsx
Normal file
29
src/web/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
7
src/web/src/features/rubros/api/createRubro.ts
Normal file
7
src/web/src/features/rubros/api/createRubro.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { CreateRubroRequest, Rubro } from '../types'
|
||||
|
||||
export async function createRubro(payload: CreateRubroRequest): Promise<Rubro> {
|
||||
const response = await axiosClient.post<Rubro>('/api/v1/admin/rubros', payload)
|
||||
return response.data
|
||||
}
|
||||
5
src/web/src/features/rubros/api/deleteRubro.ts
Normal file
5
src/web/src/features/rubros/api/deleteRubro.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
|
||||
export async function deleteRubro(id: number): Promise<void> {
|
||||
await axiosClient.delete(`/api/v1/admin/rubros/${id}`)
|
||||
}
|
||||
7
src/web/src/features/rubros/api/getRubroById.ts
Normal file
7
src/web/src/features/rubros/api/getRubroById.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { Rubro } from '../types'
|
||||
|
||||
export async function getRubroById(id: number): Promise<Rubro> {
|
||||
const response = await axiosClient.get<Rubro>(`/api/v1/rubros/${id}`)
|
||||
return response.data
|
||||
}
|
||||
8
src/web/src/features/rubros/api/getRubroTree.ts
Normal file
8
src/web/src/features/rubros/api/getRubroTree.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { RubroTreeNode } from '../types'
|
||||
|
||||
export async function getRubroTree(incluirInactivos?: boolean): Promise<RubroTreeNode[]> {
|
||||
const params = incluirInactivos ? { incluirInactivos: 'true' } : {}
|
||||
const response = await axiosClient.get<RubroTreeNode[]>('/api/v1/rubros/tree', { params })
|
||||
return response.data
|
||||
}
|
||||
6
src/web/src/features/rubros/api/moveRubro.ts
Normal file
6
src/web/src/features/rubros/api/moveRubro.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { MoveRubroRequest } from '../types'
|
||||
|
||||
export async function moveRubro(id: number, payload: MoveRubroRequest): Promise<void> {
|
||||
await axiosClient.patch(`/api/v1/admin/rubros/${id}/mover`, payload)
|
||||
}
|
||||
7
src/web/src/features/rubros/api/updateRubro.ts
Normal file
7
src/web/src/features/rubros/api/updateRubro.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { UpdateRubroRequest, Rubro } from '../types'
|
||||
|
||||
export async function updateRubro(id: number, payload: UpdateRubroRequest): Promise<Rubro> {
|
||||
const response = await axiosClient.put<Rubro>(`/api/v1/admin/rubros/${id}`, payload)
|
||||
return response.data
|
||||
}
|
||||
45
src/web/src/features/rubros/components/CategoryTree.tsx
Normal file
45
src/web/src/features/rubros/components/CategoryTree.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { CategoryTreeNode } from './CategoryTreeNode'
|
||||
import type { RubroTreeNode, Rubro } from '../types'
|
||||
|
||||
export interface CategoryTreeProps {
|
||||
nodes: RubroTreeNode[]
|
||||
onEdit: (rubro: Rubro) => void
|
||||
onDelete: (rubro: Rubro) => void
|
||||
onAddChild: (parentId: number) => void
|
||||
onMove: (rubro: Rubro) => void
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
export function CategoryTree({
|
||||
nodes,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
onMove,
|
||||
canEdit,
|
||||
}: CategoryTreeProps) {
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
No hay rubros cargados
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{nodes.map((node) => (
|
||||
<CategoryTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
onMove={onMove}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
src/web/src/features/rubros/components/CategoryTreeNode.tsx
Normal file
168
src/web/src/features/rubros/components/CategoryTreeNode.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight, ChevronDown, Pencil, Trash2, Plus, MoveVertical, AlertTriangle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import type { RubroTreeNode, Rubro } from '../types'
|
||||
|
||||
const MAX_DEPTH = 10
|
||||
|
||||
export interface CategoryTreeNodeProps {
|
||||
node: RubroTreeNode
|
||||
depth: number
|
||||
onEdit: (rubro: Rubro) => void
|
||||
onDelete: (rubro: Rubro) => void
|
||||
onAddChild: (parentId: number) => void
|
||||
onMove: (rubro: Rubro) => void
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
export function CategoryTreeNode({
|
||||
node,
|
||||
depth,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
onMove,
|
||||
canEdit,
|
||||
}: CategoryTreeNodeProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Depth guard: prevents infinite recursion on malformed data
|
||||
if (depth > MAX_DEPTH) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1 px-2 text-xs text-muted-foreground" role="alert">
|
||||
<AlertTriangle className="h-3 w-3 text-warning shrink-0" />
|
||||
<span>Profundidad máxima alcanzada ({MAX_DEPTH} niveles)</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasChildren = node.hijos.length > 0
|
||||
|
||||
// Coerce to Rubro for callbacks (tree node has compatible shape minus fechas)
|
||||
const asRubro: Rubro = {
|
||||
id: node.id,
|
||||
nombre: node.nombre,
|
||||
orden: node.orden,
|
||||
activo: node.activo,
|
||||
parentId: node.parentId,
|
||||
tarifarioBaseId: node.tarifarioBaseId,
|
||||
fechaCreacion: '',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const indentStyle = { paddingLeft: `${depth * 16}px` }
|
||||
|
||||
const nodeContent = (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent',
|
||||
!node.activo && 'opacity-60',
|
||||
)}
|
||||
style={indentStyle}
|
||||
>
|
||||
{/* Expand/collapse toggle */}
|
||||
{hasChildren ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={open ? `Colapsar ${node.nombre}` : `Expandir ${node.nombre}`}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
) : (
|
||||
<span className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{/* Node name */}
|
||||
<span className={cn('flex-1 truncate font-medium', !node.activo && 'text-muted-foreground')}>
|
||||
{node.nombre}
|
||||
</span>
|
||||
|
||||
{/* Inactive badge */}
|
||||
{!node.activo && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0">
|
||||
inactivo
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Action buttons — only if canEdit */}
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
aria-label={`Agregar subrubro en ${node.nombre}`}
|
||||
onClick={() => onAddChild(node.id)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
aria-label={`Editar ${node.nombre}`}
|
||||
onClick={() => onEdit(asRubro)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
aria-label={`Mover ${node.nombre}`}
|
||||
onClick={() => onMove(asRubro)}
|
||||
>
|
||||
<MoveVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
aria-label={`Eliminar ${node.nombre}`}
|
||||
onClick={() => onDelete(asRubro)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!hasChildren) {
|
||||
return <div>{nodeContent}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
{nodeContent}
|
||||
<CollapsibleContent>
|
||||
{node.hijos.map((child) => (
|
||||
<CategoryTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
onMove={onMove}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
87
src/web/src/features/rubros/components/DeleteRubroDialog.tsx
Normal file
87
src/web/src/features/rubros/components/DeleteRubroDialog.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import type { Rubro } from '../types'
|
||||
|
||||
interface DeleteRubroDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
rubro: Rubro
|
||||
onConfirm: (id: number) => Promise<void> | void
|
||||
}
|
||||
|
||||
function resolveDeleteError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
if (isAxiosError(err) && err.response?.data) {
|
||||
const data = err.response.data as { error?: string; message?: string }
|
||||
return data.message ?? data.error ?? 'Error al desactivar el rubro'
|
||||
}
|
||||
// Also handle raw rejection objects (from tests)
|
||||
const errObj = err as { response?: { status?: number; data?: { message?: string } } }
|
||||
if (errObj?.response?.data?.message) {
|
||||
return errObj.response.data.message
|
||||
}
|
||||
return 'Error al desactivar el rubro'
|
||||
}
|
||||
|
||||
export function DeleteRubroDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
rubro,
|
||||
onConfirm,
|
||||
}: DeleteRubroDialogProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
async function handleConfirm() {
|
||||
setError(null)
|
||||
setIsPending(true)
|
||||
try {
|
||||
await onConfirm(rubro.id)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(resolveDeleteError(err))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Desactivar rubro</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
¿Desactivar rubro “{rubro.nombre}”? Los avisos asociados conservan la
|
||||
referencia pero el rubro no aparecerá en listados activos.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||
{isPending ? 'Procesando...' : 'Desactivar'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
268
src/web/src/features/rubros/components/MoveRubroDialog.tsx
Normal file
268
src/web/src/features/rubros/components/MoveRubroDialog.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useMoveRubro } from '../hooks/useMoveRubro'
|
||||
import type { Rubro, RubroTreeNode } from '../types'
|
||||
|
||||
// ─── Helper: flatten tree excluding a subtree rooted at excludedId ────────────
|
||||
|
||||
export interface FlatNode {
|
||||
id: number
|
||||
nombre: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
export function flattenExcludingSubtree(
|
||||
tree: RubroTreeNode[],
|
||||
excludedId: number,
|
||||
): FlatNode[] {
|
||||
const result: FlatNode[] = []
|
||||
|
||||
function walk(nodes: RubroTreeNode[], depth: number) {
|
||||
for (const node of nodes) {
|
||||
if (node.id === excludedId) {
|
||||
// Skip this entire subtree (node + all descendants)
|
||||
continue
|
||||
}
|
||||
result.push({ id: node.id, nombre: node.nombre, depth })
|
||||
if (node.hijos.length > 0) {
|
||||
walk(node.hijos, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const moveRubroSchema = z.object({
|
||||
nuevoParentId: z
|
||||
.string()
|
||||
.transform((val) => (val === 'root' ? null : Number(val)))
|
||||
.pipe(z.number().int().positive().nullable()),
|
||||
nuevoOrden: z
|
||||
.string()
|
||||
.transform((val) => (val === '' ? 0 : Number(val)))
|
||||
.pipe(z.number().int().min(0, 'El orden debe ser 0 o mayor')),
|
||||
})
|
||||
|
||||
// Raw form field types (what useForm sees — strings before zod transforms).
|
||||
type MoveRubroFormRaw = {
|
||||
nuevoParentId: string
|
||||
nuevoOrden: string
|
||||
}
|
||||
|
||||
// Output type after zod transforms run (what handleSubmit receives at runtime
|
||||
// thanks to zodResolver). We type-cast to reconcile with SubmitHandler<Raw>.
|
||||
type MoveRubroFormOutput = {
|
||||
nuevoParentId: number | null
|
||||
nuevoOrden: number
|
||||
}
|
||||
|
||||
// ─── Error resolver ───────────────────────────────────────────────────────────
|
||||
|
||||
function resolveMoveError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
if (isAxiosError(err) && err.response?.data) {
|
||||
const data = err.response.data as { error?: string; message?: string }
|
||||
return data.message ?? data.error ?? 'Error al mover el rubro'
|
||||
}
|
||||
// Handle raw rejection objects (from tests)
|
||||
const errObj = err as { response?: { status?: number; data?: { message?: string } } }
|
||||
if (errObj?.response?.data?.message) {
|
||||
return errObj.response.data.message
|
||||
}
|
||||
return 'Error al mover el rubro'
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MoveRubroDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
rubro: Rubro | null
|
||||
tree: RubroTreeNode[]
|
||||
onConfirmed?: () => void
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function MoveRubroDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
rubro,
|
||||
tree,
|
||||
onConfirmed,
|
||||
}: MoveRubroDialogProps) {
|
||||
const [backendError, setBackendError] = useState<string | null>(null)
|
||||
const { mutateAsync, isPending } = useMoveRubro()
|
||||
|
||||
const availableParents = rubro ? flattenExcludingSubtree(tree, rubro.id) : []
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const form = useForm<MoveRubroFormRaw>({
|
||||
resolver: zodResolver(moveRubroSchema) as any,
|
||||
defaultValues: {
|
||||
nuevoParentId: rubro?.parentId != null ? String(rubro.parentId) : 'root',
|
||||
nuevoOrden: String(rubro?.orden ?? 0),
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open && rubro) {
|
||||
setBackendError(null)
|
||||
form.reset({
|
||||
nuevoParentId: rubro.parentId != null ? String(rubro.parentId) : 'root',
|
||||
nuevoOrden: String(rubro.orden ?? 0),
|
||||
})
|
||||
}
|
||||
}, [open, rubro, form])
|
||||
|
||||
async function handleSubmit(data: MoveRubroFormOutput) {
|
||||
if (!rubro) return
|
||||
setBackendError(null)
|
||||
|
||||
const { nuevoParentId, nuevoOrden } = data
|
||||
|
||||
try {
|
||||
await mutateAsync({ id: rubro.id, data: { nuevoParentId, nuevoOrden } })
|
||||
toast.success('Rubro movido')
|
||||
onOpenChange(false)
|
||||
onConfirmed?.()
|
||||
} catch (err) {
|
||||
const msg = resolveMoveError(err)
|
||||
setBackendError(msg)
|
||||
if (
|
||||
!isAxiosError(err) ||
|
||||
(err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400)
|
||||
) {
|
||||
toast.error('Error al mover el rubro')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mover rubro</DialogTitle>
|
||||
<DialogDescription>
|
||||
Seleccioná el nuevo padre y orden para “{rubro?.nombre ?? ''}”.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(
|
||||
handleSubmit as unknown as Parameters<typeof form.handleSubmit>[0],
|
||||
)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nuevoParentId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nuevo padre</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isPending}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Seleccioná el padre…" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="root">Raíz (sin padre)</SelectItem>
|
||||
{availableParents.map((node) => (
|
||||
<SelectItem key={node.id} value={String(node.id)}>
|
||||
{'—'.repeat(node.depth)} {node.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nuevoOrden"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Orden</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
disabled={isPending}
|
||||
placeholder="0"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Moviendo...' : 'Mover'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
180
src/web/src/features/rubros/components/RubroFormDialog.tsx
Normal file
180
src/web/src/features/rubros/components/RubroFormDialog.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import type { Rubro } from '../types'
|
||||
|
||||
const rubroFormSchema = z.object({
|
||||
nombre: z
|
||||
.string()
|
||||
.min(1, 'El nombre es requerido')
|
||||
.max(200, 'Máximo 200 caracteres'),
|
||||
tarifarioBaseId: z
|
||||
.string()
|
||||
.transform((val) => (val === '' ? null : Number(val)))
|
||||
.pipe(z.number().int().positive('Debe ser un número positivo').nullable())
|
||||
.optional()
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
export type RubroFormValues = {
|
||||
nombre: string
|
||||
tarifarioBaseId?: number | null
|
||||
}
|
||||
|
||||
interface RubroFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
rubro?: Rubro
|
||||
parentId?: number | null
|
||||
onSubmit: (values: RubroFormValues) => void
|
||||
isPending?: boolean
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function resolveBackendError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
if (isAxiosError(err) && err.response?.data) {
|
||||
const data = err.response.data as { error?: string; message?: string }
|
||||
if (data.error === 'rubro_nombre_duplicado') {
|
||||
return data.message ?? 'Ya existe un rubro con ese nombre bajo este padre'
|
||||
}
|
||||
if (data.error === 'rubro_max_depth_exceeded') {
|
||||
return data.message ?? 'Profundidad máxima 10 niveles alcanzada'
|
||||
}
|
||||
return data.message ?? data.error ?? 'Error al guardar el rubro'
|
||||
}
|
||||
return 'Error al guardar el rubro'
|
||||
}
|
||||
|
||||
export function RubroFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
rubro,
|
||||
onSubmit,
|
||||
isPending = false,
|
||||
error,
|
||||
}: RubroFormDialogProps) {
|
||||
const isEdit = !!rubro
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const form = useForm<RubroFormValues>({
|
||||
resolver: zodResolver(rubroFormSchema) as any,
|
||||
defaultValues: {
|
||||
nombre: rubro?.nombre ?? '',
|
||||
tarifarioBaseId: rubro?.tarifarioBaseId ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
nombre: rubro?.nombre ?? '',
|
||||
tarifarioBaseId: rubro?.tarifarioBaseId ?? null,
|
||||
})
|
||||
}
|
||||
}, [open, rubro, form])
|
||||
|
||||
const backendError = resolveBackendError(error)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Editar rubro' : 'Nuevo rubro'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? `Modificá los datos del rubro "${rubro?.nombre ?? ''}".`
|
||||
: 'Completá los datos para crear un nuevo rubro.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nombre"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
disabled={isPending}
|
||||
placeholder="Nombre del rubro"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tarifarioBaseId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tarifario Base ID (opcional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value != null ? String(field.value) : ''}
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending}
|
||||
placeholder="ID numérico (opcional)"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Crear'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
13
src/web/src/features/rubros/hooks/useCreateRubro.ts
Normal file
13
src/web/src/features/rubros/hooks/useCreateRubro.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { createRubro } from '../api/createRubro'
|
||||
import type { CreateRubroRequest } from '../types'
|
||||
|
||||
export function useCreateRubro() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateRubroRequest) => createRubro(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rubros'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
12
src/web/src/features/rubros/hooks/useDeleteRubro.ts
Normal file
12
src/web/src/features/rubros/hooks/useDeleteRubro.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { deleteRubro } from '../api/deleteRubro'
|
||||
|
||||
export function useDeleteRubro() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteRubro(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rubros'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
13
src/web/src/features/rubros/hooks/useMoveRubro.ts
Normal file
13
src/web/src/features/rubros/hooks/useMoveRubro.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { moveRubro } from '../api/moveRubro'
|
||||
import type { MoveRubroRequest } from '../types'
|
||||
|
||||
export function useMoveRubro() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: MoveRubroRequest }) => moveRubro(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rubros'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
13
src/web/src/features/rubros/hooks/useRubrosTree.ts
Normal file
13
src/web/src/features/rubros/hooks/useRubrosTree.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRubroTree } from '../api/getRubroTree'
|
||||
|
||||
export const rubrosTreeQueryKey = (incluirInactivos?: boolean) =>
|
||||
['rubros', 'tree', { incluirInactivos: !!incluirInactivos }] as const
|
||||
|
||||
export function useRubrosTree(incluirInactivos?: boolean) {
|
||||
return useQuery({
|
||||
queryKey: rubrosTreeQueryKey(incluirInactivos),
|
||||
queryFn: () => getRubroTree(incluirInactivos),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
13
src/web/src/features/rubros/hooks/useUpdateRubro.ts
Normal file
13
src/web/src/features/rubros/hooks/useUpdateRubro.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { updateRubro } from '../api/updateRubro'
|
||||
import type { UpdateRubroRequest } from '../types'
|
||||
|
||||
export function useUpdateRubro() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateRubroRequest }) => updateRubro(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rubros'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
12
src/web/src/features/rubros/index.ts
Normal file
12
src/web/src/features/rubros/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// CAT-001 — barrel export for rubros feature
|
||||
export { RubrosPage } from './pages/RubrosPage'
|
||||
export { CategoryTree } from './components/CategoryTree'
|
||||
export { CategoryTreeNode } from './components/CategoryTreeNode'
|
||||
export { RubroFormDialog } from './components/RubroFormDialog'
|
||||
export { DeleteRubroDialog } from './components/DeleteRubroDialog'
|
||||
export { useRubrosTree } from './hooks/useRubrosTree'
|
||||
export { useCreateRubro } from './hooks/useCreateRubro'
|
||||
export { useUpdateRubro } from './hooks/useUpdateRubro'
|
||||
export { useDeleteRubro } from './hooks/useDeleteRubro'
|
||||
export { useMoveRubro } from './hooks/useMoveRubro'
|
||||
export type { RubroTreeNode, Rubro, CreateRubroRequest, UpdateRubroRequest, MoveRubroRequest } from './types'
|
||||
195
src/web/src/features/rubros/pages/RubrosPage.tsx
Normal file
195
src/web/src/features/rubros/pages/RubrosPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, Plus } from 'lucide-react'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { CanPerform } from '@/components/auth/CanPerform'
|
||||
import { CategoryTree } from '../components/CategoryTree'
|
||||
import { RubroFormDialog } from '../components/RubroFormDialog'
|
||||
import { DeleteRubroDialog } from '../components/DeleteRubroDialog'
|
||||
import { MoveRubroDialog } from '../components/MoveRubroDialog'
|
||||
import { useRubrosTree } from '../hooks/useRubrosTree'
|
||||
import { useCreateRubro } from '../hooks/useCreateRubro'
|
||||
import { useUpdateRubro } from '../hooks/useUpdateRubro'
|
||||
import { useDeleteRubro } from '../hooks/useDeleteRubro'
|
||||
import type { Rubro } from '../types'
|
||||
import type { RubroFormValues } from '../components/RubroFormDialog'
|
||||
|
||||
export function RubrosPage() {
|
||||
const [incluirInactivos, setIncluirInactivos] = useState(false)
|
||||
|
||||
// Dialog states
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editingRubro, setEditingRubro] = useState<Rubro | undefined>(undefined)
|
||||
const [pendingParentId, setPendingParentId] = useState<number | null>(null)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deletingRubro, setDeletingRubro] = useState<Rubro | null>(null)
|
||||
const [formError, setFormError] = useState<unknown>(null)
|
||||
const [moveTarget, setMoveTarget] = useState<Rubro | null>(null)
|
||||
|
||||
const { data: tree, isLoading, isError } = useRubrosTree(incluirInactivos)
|
||||
const { mutateAsync: createRubro, isPending: creating } = useCreateRubro()
|
||||
const { mutateAsync: updateRubro, isPending: updating } = useUpdateRubro()
|
||||
const { mutateAsync: deleteRubro } = useDeleteRubro()
|
||||
|
||||
function handleNewRubro() {
|
||||
setEditingRubro(undefined)
|
||||
setPendingParentId(null)
|
||||
setFormError(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
function handleAddChild(parentId: number) {
|
||||
setEditingRubro(undefined)
|
||||
setPendingParentId(parentId)
|
||||
setFormError(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
function handleEdit(rubro: Rubro) {
|
||||
setEditingRubro(rubro)
|
||||
setPendingParentId(null)
|
||||
setFormError(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
function handleDelete(rubro: Rubro) {
|
||||
setDeletingRubro(rubro)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
async function handleFormSubmit(values: RubroFormValues) {
|
||||
setFormError(null)
|
||||
try {
|
||||
const tarifarioId = values.tarifarioBaseId ?? null
|
||||
|
||||
if (editingRubro) {
|
||||
await updateRubro({
|
||||
id: editingRubro.id,
|
||||
data: { nombre: values.nombre, tarifarioBaseId: tarifarioId },
|
||||
})
|
||||
toast.success('Rubro actualizado')
|
||||
} else {
|
||||
await createRubro({
|
||||
nombre: values.nombre,
|
||||
parentId: pendingParentId,
|
||||
tarifarioBaseId: tarifarioId,
|
||||
})
|
||||
toast.success('Rubro creado')
|
||||
}
|
||||
setFormOpen(false)
|
||||
} catch (err) {
|
||||
setFormError(err)
|
||||
if (!isAxiosError(err) || (err.response?.status !== 409 && err.response?.status !== 422)) {
|
||||
toast.error('Error al guardar el rubro')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm(id: number) {
|
||||
await deleteRubro(id)
|
||||
setDeleteOpen(false)
|
||||
toast.success('Rubro desactivado')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Rubros</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="incluir-inactivos"
|
||||
checked={incluirInactivos}
|
||||
onCheckedChange={setIncluirInactivos}
|
||||
/>
|
||||
<Label htmlFor="incluir-inactivos" className="text-sm text-muted-foreground cursor-pointer">
|
||||
Incluir inactivos
|
||||
</Label>
|
||||
</div>
|
||||
<CanPerform permission="catalogo:rubros:gestionar">
|
||||
<Button size="sm" onClick={handleNewRubro}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuevo rubro
|
||||
</Button>
|
||||
</CanPerform>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Error al cargar los rubros. Intentá de nuevo.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="surface rounded-lg p-2">
|
||||
<CanPerform
|
||||
permission="catalogo:rubros:gestionar"
|
||||
fallback={
|
||||
<CategoryTree
|
||||
nodes={tree ?? []}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onAddChild={() => {}}
|
||||
onMove={() => {}}
|
||||
canEdit={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CategoryTree
|
||||
nodes={tree ?? []}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onAddChild={handleAddChild}
|
||||
onMove={(rubro) => setMoveTarget(rubro)}
|
||||
canEdit={true}
|
||||
/>
|
||||
</CanPerform>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form dialog */}
|
||||
<RubroFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
rubro={editingRubro}
|
||||
parentId={pendingParentId}
|
||||
onSubmit={handleFormSubmit}
|
||||
isPending={creating || updating}
|
||||
error={formError}
|
||||
/>
|
||||
|
||||
{/* Delete dialog */}
|
||||
{deletingRubro && (
|
||||
<DeleteRubroDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
rubro={deletingRubro}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Move dialog */}
|
||||
<MoveRubroDialog
|
||||
open={!!moveTarget}
|
||||
onOpenChange={(o) => !o && setMoveTarget(null)}
|
||||
rubro={moveTarget}
|
||||
tree={tree ?? []}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/web/src/features/rubros/types.ts
Normal file
38
src/web/src/features/rubros/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// CAT-001 — shared types for rubros feature
|
||||
|
||||
export interface RubroTreeNode {
|
||||
id: number
|
||||
nombre: string
|
||||
orden: number
|
||||
activo: boolean
|
||||
parentId: number | null
|
||||
tarifarioBaseId: number | null
|
||||
hijos: RubroTreeNode[]
|
||||
}
|
||||
|
||||
export interface Rubro {
|
||||
id: number
|
||||
nombre: string
|
||||
orden: number
|
||||
activo: boolean
|
||||
parentId: number | null
|
||||
tarifarioBaseId: number | null
|
||||
fechaCreacion: string
|
||||
fechaModificacion: string | null
|
||||
}
|
||||
|
||||
export interface CreateRubroRequest {
|
||||
nombre: string
|
||||
parentId: number | null
|
||||
tarifarioBaseId: number | null
|
||||
}
|
||||
|
||||
export interface UpdateRubroRequest {
|
||||
nombre: string
|
||||
tarifarioBaseId: number | null
|
||||
}
|
||||
|
||||
export interface MoveRubroRequest {
|
||||
nuevoParentId: number | null
|
||||
nuevoOrden: number
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { PuntoDeVentaDetailPage } from './features/puntos-de-venta/pages/PuntoDe
|
||||
import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPuntoDeVentaPage'
|
||||
import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
|
||||
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -298,6 +299,16 @@ export function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Rubros routes — CAT-001 */}
|
||||
<Route
|
||||
path="/admin/rubros"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['catalogo:rubros:gestionar']}>
|
||||
<RubrosPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
139
src/web/src/tests/features/rubros/CategoryTree.test.tsx
Normal file
139
src/web/src/tests/features/rubros/CategoryTree.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { CategoryTree } from '../../../features/rubros/components/CategoryTree'
|
||||
import type { RubroTreeNode } from '../../../features/rubros/types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const makeTree = (): RubroTreeNode[] => [
|
||||
{
|
||||
id: 1,
|
||||
nombre: 'Autos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [
|
||||
{
|
||||
id: 2,
|
||||
nombre: 'Sedanes',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: 1,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [
|
||||
{
|
||||
id: 3,
|
||||
nombre: 'Sedanes chicos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: 2,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
nombre: 'Inmuebles',
|
||||
orden: 2,
|
||||
activo: false,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [],
|
||||
},
|
||||
]
|
||||
|
||||
const noop = vi.fn()
|
||||
|
||||
describe('CategoryTree', () => {
|
||||
it('renders empty state when no nodes', () => {
|
||||
render(
|
||||
<CategoryTree nodes={[]} onEdit={noop} onDelete={noop} onAddChild={noop} onMove={noop} canEdit={false} />,
|
||||
)
|
||||
expect(screen.getByText(/no hay rubros/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all root node names', () => {
|
||||
render(
|
||||
<CategoryTree nodes={makeTree()} onEdit={noop} onDelete={noop} onAddChild={noop} onMove={noop} canEdit={false} />,
|
||||
)
|
||||
expect(screen.getByText('Autos')).toBeInTheDocument()
|
||||
expect(screen.getByText('Inmuebles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows children after expanding a node', async () => {
|
||||
render(
|
||||
<CategoryTree nodes={makeTree()} onEdit={noop} onDelete={noop} onAddChild={noop} onMove={noop} canEdit={false} />,
|
||||
)
|
||||
// Sedanes should be hidden initially
|
||||
expect(screen.queryByText('Sedanes')).not.toBeInTheDocument()
|
||||
// Expand Autos (click the toggle)
|
||||
await userEvent.click(screen.getByRole('button', { name: /expandir autos/i }))
|
||||
expect(screen.getByText('Sedanes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides action buttons when canEdit is false', () => {
|
||||
render(
|
||||
<CategoryTree nodes={makeTree()} onEdit={noop} onDelete={noop} onAddChild={noop} onMove={noop} canEdit={false} />,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /editar/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /eliminar/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows action buttons when canEdit is true', () => {
|
||||
render(
|
||||
<CategoryTree nodes={makeTree()} onEdit={noop} onDelete={noop} onAddChild={noop} onMove={noop} canEdit={true} />,
|
||||
)
|
||||
const editBtns = screen.getAllByRole('button', { name: /editar/i })
|
||||
expect(editBtns.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows "inactivo" badge on inactive nodes', () => {
|
||||
render(
|
||||
<CategoryTree nodes={makeTree()} onEdit={noop} onDelete={noop} onAddChild={noop} onMove={noop} canEdit={false} />,
|
||||
)
|
||||
expect(screen.getByText('inactivo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders 3-level deep tree when expanded', async () => {
|
||||
render(
|
||||
<CategoryTree nodes={makeTree()} onEdit={noop} onDelete={noop} onAddChild={noop} onMove={noop} canEdit={false} />,
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: /expandir autos/i }))
|
||||
expect(screen.getByText('Sedanes')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: /expandir sedanes/i }))
|
||||
expect(screen.getByText('Sedanes chicos')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CategoryTreeNode depth guard', () => {
|
||||
it('renders depth warning when depth exceeds 10', () => {
|
||||
// Build a deeply nested node at depth 11
|
||||
const deepNode: RubroTreeNode = {
|
||||
id: 99,
|
||||
nombre: 'Deep',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [],
|
||||
}
|
||||
// Render with depth=11 via internal mechanism — we test via CategoryTree with a manually-crafted prop
|
||||
// CategoryTreeNode is not exported standalone; CategoryTree handles depth internally.
|
||||
// We verify no stack overflow with a highly-nested tree.
|
||||
let current: RubroTreeNode = { ...deepNode, id: 100, nombre: 'Level 11', hijos: [] }
|
||||
for (let i = 10; i >= 0; i--) {
|
||||
current = { ...deepNode, id: i, nombre: `Level ${i}`, hijos: [current] }
|
||||
}
|
||||
// Should render without crashing; depth guard warning visible for deepest
|
||||
render(
|
||||
<CategoryTree nodes={[current]} onEdit={noop} onDelete={noop} onAddChild={noop} onMove={noop} canEdit={false} />,
|
||||
)
|
||||
// Level 0 should always render
|
||||
expect(screen.getByText('Level 0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
374
src/web/src/tests/features/rubros/MoveRubroDialog.test.tsx
Normal file
374
src/web/src/tests/features/rubros/MoveRubroDialog.test.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { MoveRubroDialog } from '../../../features/rubros/components/MoveRubroDialog'
|
||||
import { flattenExcludingSubtree } from '../../../features/rubros/components/MoveRubroDialog'
|
||||
import type { Rubro, RubroTreeNode } from '../../../features/rubros/types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
// Mock useMoveRubro to avoid real network calls
|
||||
const mockMutateAsync = vi.fn()
|
||||
let mockIsPending = false
|
||||
|
||||
vi.mock('../../../features/rubros/hooks/useMoveRubro', () => ({
|
||||
useMoveRubro: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
get isPending() {
|
||||
return mockIsPending
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const rubroAutos: Rubro = {
|
||||
id: 1,
|
||||
nombre: 'Autos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
fechaCreacion: '2026-04-18T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const rubroUsados: Rubro = {
|
||||
id: 2,
|
||||
nombre: 'Usados',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: 1,
|
||||
tarifarioBaseId: null,
|
||||
fechaCreacion: '2026-04-18T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const rubroInmuebles: Rubro = {
|
||||
id: 3,
|
||||
nombre: 'Inmuebles',
|
||||
orden: 2,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
fechaCreacion: '2026-04-18T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
// Tree:
|
||||
// Autos (id=1)
|
||||
// Usados (id=2)
|
||||
// Compactos (id=4)
|
||||
// Inmuebles (id=3)
|
||||
const treeCompact: RubroTreeNode = {
|
||||
id: 4,
|
||||
nombre: 'Compactos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: 2,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [],
|
||||
}
|
||||
|
||||
const treeUsados: RubroTreeNode = {
|
||||
id: 2,
|
||||
nombre: 'Usados',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: 1,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [treeCompact],
|
||||
}
|
||||
|
||||
const treeAutos: RubroTreeNode = {
|
||||
id: 1,
|
||||
nombre: 'Autos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [treeUsados],
|
||||
}
|
||||
|
||||
const treeInmuebles: RubroTreeNode = {
|
||||
id: 3,
|
||||
nombre: 'Inmuebles',
|
||||
orden: 2,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [],
|
||||
}
|
||||
|
||||
const fullTree: RubroTreeNode[] = [treeAutos, treeInmuebles]
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function wrap(children: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── flattenExcludingSubtree unit tests ───────────────────────────────────────
|
||||
|
||||
describe('flattenExcludingSubtree', () => {
|
||||
it('returns all nodes when excludedId does not match anything', () => {
|
||||
const result = flattenExcludingSubtree(fullTree, 999)
|
||||
expect(result.map((n) => n.id)).toContain(1)
|
||||
expect(result.map((n) => n.id)).toContain(2)
|
||||
expect(result.map((n) => n.id)).toContain(3)
|
||||
expect(result.map((n) => n.id)).toContain(4)
|
||||
})
|
||||
|
||||
it('excludes the target node itself', () => {
|
||||
const result = flattenExcludingSubtree(fullTree, 1)
|
||||
expect(result.map((n) => n.id)).not.toContain(1)
|
||||
})
|
||||
|
||||
it('excludes all descendants of the target node', () => {
|
||||
const result = flattenExcludingSubtree(fullTree, 1)
|
||||
// Autos (id=1), Usados (id=2), Compactos (id=4) all excluded
|
||||
expect(result.map((n) => n.id)).not.toContain(1)
|
||||
expect(result.map((n) => n.id)).not.toContain(2)
|
||||
expect(result.map((n) => n.id)).not.toContain(4)
|
||||
// Inmuebles stays
|
||||
expect(result.map((n) => n.id)).toContain(3)
|
||||
})
|
||||
|
||||
it('excludes only the leaf node when a leaf is the target', () => {
|
||||
const result = flattenExcludingSubtree(fullTree, 4)
|
||||
expect(result.map((n) => n.id)).toContain(1)
|
||||
expect(result.map((n) => n.id)).toContain(2)
|
||||
expect(result.map((n) => n.id)).toContain(3)
|
||||
expect(result.map((n) => n.id)).not.toContain(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── MoveRubroDialog component tests ─────────────────────────────────────────
|
||||
|
||||
describe('MoveRubroDialog', () => {
|
||||
beforeEach(() => {
|
||||
mockMutateAsync.mockReset()
|
||||
mockIsPending = false
|
||||
})
|
||||
|
||||
it('renders with current parent selected when opened', async () => {
|
||||
// rubroUsados has parentId=1 (Autos)
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={rubroUsados}
|
||||
tree={fullTree}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/mover rubro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows dialog title with rubro name', async () => {
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={rubroAutos}
|
||||
tree={fullTree}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/mover rubro/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows flat list of available parents excluding the rubro being moved', async () => {
|
||||
// Moving Inmuebles (id=3) — Inmuebles should not appear as option, others should
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={rubroInmuebles}
|
||||
tree={fullTree}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
// The combobox/select area should contain Autos and Usados but not Inmuebles
|
||||
// We check via the select trigger content after opening
|
||||
const trigger = screen.getByRole('combobox')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows flat list of available parents excluding DESCENDANTS of the rubro being moved', async () => {
|
||||
// Moving Autos (id=1) — Usados (id=2) and Compactos (id=4) are descendants
|
||||
// The options should not include Autos, Usados, or Compactos
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={rubroAutos}
|
||||
tree={fullTree}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
// Open the select
|
||||
const trigger = screen.getByRole('combobox')
|
||||
await userEvent.click(trigger)
|
||||
await waitFor(() => {
|
||||
// Inmuebles should be available as an option
|
||||
expect(screen.getByRole('option', { name: /inmuebles/i })).toBeInTheDocument()
|
||||
})
|
||||
// Autos itself and its descendants should not appear
|
||||
expect(screen.queryByRole('option', { name: /^autos$/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: /usados/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: /compactos/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('allows selecting "raíz" (null parent)', async () => {
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={rubroUsados}
|
||||
tree={fullTree}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
const trigger = screen.getByRole('combobox')
|
||||
await userEvent.click(trigger)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: /raíz/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('submit calls mutateAsync with { nuevoParentId, nuevoOrden }', async () => {
|
||||
mockMutateAsync.mockResolvedValue(undefined)
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={rubroUsados}
|
||||
tree={fullTree}
|
||||
onConfirmed={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
// Select raíz
|
||||
const trigger = screen.getByRole('combobox')
|
||||
await userEvent.click(trigger)
|
||||
await waitFor(() => expect(screen.getByRole('option', { name: /raíz/i })).toBeInTheDocument())
|
||||
await userEvent.click(screen.getByRole('option', { name: /raíz/i }))
|
||||
|
||||
// Set orden
|
||||
const ordenInput = screen.getByLabelText(/orden/i)
|
||||
await userEvent.clear(ordenInput)
|
||||
await userEvent.type(ordenInput, '5')
|
||||
|
||||
// Submit
|
||||
await userEvent.click(screen.getByRole('button', { name: /mover/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
id: rubroUsados.id,
|
||||
data: { nuevoParentId: null, nuevoOrden: 5 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('displays backend error inline when 409 cycle/duplicate', async () => {
|
||||
mockMutateAsync.mockRejectedValue({
|
||||
response: {
|
||||
status: 409,
|
||||
data: { message: 'Ya existe un rubro con ese nombre en el destino' },
|
||||
},
|
||||
})
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={rubroUsados}
|
||||
tree={fullTree}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /mover/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/ya existe un rubro con ese nombre/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays backend error inline when 422 depth', async () => {
|
||||
mockMutateAsync.mockRejectedValue({
|
||||
response: {
|
||||
status: 422,
|
||||
data: { message: 'Profundidad máxima 10 niveles alcanzada' },
|
||||
},
|
||||
})
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={rubroUsados}
|
||||
tree={fullTree}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /mover/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/profundidad máxima/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('disables submit button while mutation is pending', async () => {
|
||||
// Set isPending to true before rendering — simulates pending state
|
||||
mockIsPending = true
|
||||
mockMutateAsync.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={rubroUsados}
|
||||
tree={fullTree}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
// When isPending=true, button shows "Moviendo..." and is disabled
|
||||
const submitBtn = screen.getByRole('button', { name: /moviendo/i })
|
||||
expect(submitBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('closes on cancel', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
wrap(
|
||||
<MoveRubroDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
rubro={rubroUsados}
|
||||
tree={fullTree}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
159
src/web/src/tests/features/rubros/RubrosPage.test.tsx
Normal file
159
src/web/src/tests/features/rubros/RubrosPage.test.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import { RubrosPage } from '../../../features/rubros/pages/RubrosPage'
|
||||
import { useAuthStore } from '../../../stores/authStore'
|
||||
import type { RubroTreeNode } from '../../../features/rubros/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const adminWithRubros = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nombre: 'Admin',
|
||||
rol: 'admin',
|
||||
permisos: ['catalogo:rubros:gestionar'],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
const userWithoutRubros = {
|
||||
id: 2,
|
||||
username: 'viewer',
|
||||
nombre: 'Viewer',
|
||||
rol: 'viewer',
|
||||
permisos: [],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
const mockTree: RubroTreeNode[] = [
|
||||
{
|
||||
id: 1,
|
||||
nombre: 'Autos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nombre: 'Inmuebles',
|
||||
orden: 2,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [],
|
||||
},
|
||||
]
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
useAuthStore.getState().clearAuth()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderPage(user = adminWithRubros) {
|
||||
useAuthStore.setState({ user })
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={['/admin/rubros']}>
|
||||
<Routes>
|
||||
<Route path="/admin/rubros" element={<RubrosPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('RubrosPage', () => {
|
||||
it('renders loading skeleton while fetching', () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, async () => {
|
||||
// Never resolves during this test
|
||||
await new Promise(() => {})
|
||||
return HttpResponse.json([])
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
// The skeleton elements should be present
|
||||
expect(document.querySelectorAll('[class*="skeleton"], .animate-pulse').length).toBeGreaterThanOrEqual(0)
|
||||
// Page title always renders
|
||||
expect(screen.getByText(/rubros/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders tree nodes when data loads', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, () => HttpResponse.json(mockTree)),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() => expect(screen.getByText('Autos')).toBeInTheDocument())
|
||||
expect(screen.getByText('Inmuebles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error state on fetch failure', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, () =>
|
||||
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||
),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/error al cargar/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows empty state when no rubros', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, () => HttpResponse.json([])),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/no hay rubros/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows "Nuevo rubro" button when user has permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, () => HttpResponse.json(mockTree)),
|
||||
)
|
||||
renderPage(adminWithRubros)
|
||||
await waitFor(() => expect(screen.getByText('Autos')).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: /nuevo rubro/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides "Nuevo rubro" button when user lacks permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, () => HttpResponse.json(mockTree)),
|
||||
)
|
||||
renderPage(userWithoutRubros)
|
||||
await waitFor(() => expect(screen.getByText('Autos')).toBeInTheDocument())
|
||||
expect(screen.queryByRole('button', { name: /nuevo rubro/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens create dialog when "Nuevo rubro" is clicked', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, () => HttpResponse.json(mockTree)),
|
||||
)
|
||||
renderPage(adminWithRubros)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /nuevo rubro/i })).toBeInTheDocument())
|
||||
await userEvent.click(screen.getByRole('button', { name: /nuevo rubro/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('heading', { name: /nuevo rubro/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
142
src/web/src/tests/features/rubros/api.test.ts
Normal file
142
src/web/src/tests/features/rubros/api.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { getRubroTree } from '../../../features/rubros/api/getRubroTree'
|
||||
import { getRubroById } from '../../../features/rubros/api/getRubroById'
|
||||
import { createRubro } from '../../../features/rubros/api/createRubro'
|
||||
import { updateRubro } from '../../../features/rubros/api/updateRubro'
|
||||
import { deleteRubro } from '../../../features/rubros/api/deleteRubro'
|
||||
import { moveRubro } from '../../../features/rubros/api/moveRubro'
|
||||
import type { RubroTreeNode, Rubro } from '../../../features/rubros/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockTree: RubroTreeNode[] = [
|
||||
{
|
||||
id: 1,
|
||||
nombre: 'Autos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [
|
||||
{
|
||||
id: 2,
|
||||
nombre: 'Sedanes',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: 1,
|
||||
tarifarioBaseId: null,
|
||||
hijos: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const mockRubro: Rubro = {
|
||||
id: 1,
|
||||
nombre: 'Autos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
fechaCreacion: '2026-04-18T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('getRubroTree', () => {
|
||||
it('calls GET /api/v1/rubros/tree and returns tree', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, () => HttpResponse.json(mockTree)),
|
||||
)
|
||||
const result = await getRubroTree()
|
||||
expect(result).toEqual(mockTree)
|
||||
})
|
||||
|
||||
it('passes incluirInactivos=true when requested', async () => {
|
||||
let capturedUrl = ''
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, ({ request }) => {
|
||||
capturedUrl = request.url
|
||||
return HttpResponse.json(mockTree)
|
||||
}),
|
||||
)
|
||||
await getRubroTree(true)
|
||||
expect(capturedUrl).toContain('incluirInactivos=true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRubroById', () => {
|
||||
it('calls GET /api/v1/rubros/:id and returns rubro', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/1`, () => HttpResponse.json(mockRubro)),
|
||||
)
|
||||
const result = await getRubroById(1)
|
||||
expect(result).toEqual(mockRubro)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createRubro', () => {
|
||||
it('calls POST /api/v1/admin/rubros with payload', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/rubros`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockRubro, { status: 201 })
|
||||
}),
|
||||
)
|
||||
const req = { nombre: 'Autos', parentId: null, tarifarioBaseId: null }
|
||||
await createRubro(req)
|
||||
expect(capturedBody).toEqual(req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateRubro', () => {
|
||||
it('calls PUT /api/v1/admin/rubros/:id with payload', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/admin/rubros/1`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockRubro)
|
||||
}),
|
||||
)
|
||||
const req = { nombre: 'Autos Actualizado', tarifarioBaseId: null }
|
||||
await updateRubro(1, req)
|
||||
expect(capturedBody).toEqual(req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteRubro', () => {
|
||||
it('calls DELETE /api/v1/admin/rubros/:id', async () => {
|
||||
let called = false
|
||||
server.use(
|
||||
http.delete(`${API_URL}/api/v1/admin/rubros/1`, () => {
|
||||
called = true
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
await deleteRubro(1)
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveRubro', () => {
|
||||
it('calls PATCH /api/v1/admin/rubros/:id/mover with payload', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.patch(`${API_URL}/api/v1/admin/rubros/1/mover`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return new HttpResponse(null, { status: 200 })
|
||||
}),
|
||||
)
|
||||
const req = { nuevoParentId: 2, nuevoOrden: 1 }
|
||||
await moveRubro(1, req)
|
||||
expect(capturedBody).toEqual(req)
|
||||
})
|
||||
})
|
||||
164
src/web/src/tests/features/rubros/dialogs.test.tsx
Normal file
164
src/web/src/tests/features/rubros/dialogs.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { RubroFormDialog } from '../../../features/rubros/components/RubroFormDialog'
|
||||
import { DeleteRubroDialog } from '../../../features/rubros/components/DeleteRubroDialog'
|
||||
import type { Rubro } from '../../../features/rubros/types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const sampleRubro: Rubro = {
|
||||
id: 1,
|
||||
nombre: 'Autos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
fechaCreacion: '2026-04-18T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
function wrap(children: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── RubroFormDialog — CREATE mode ──────────────────────────────────────────
|
||||
|
||||
describe('RubroFormDialog — create mode', () => {
|
||||
it('renders form in create mode when no rubro prop', () => {
|
||||
wrap(
|
||||
<RubroFormDialog open={true} onOpenChange={vi.fn()} onSubmit={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: /nuevo rubro/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSubmit with correct payload on valid submit', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
wrap(
|
||||
<RubroFormDialog open={true} onOpenChange={vi.fn()} onSubmit={onSubmit} />,
|
||||
)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Categoría Nueva')
|
||||
await userEvent.click(screen.getByRole('button', { name: /crear/i }))
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
const firstArg = onSubmit.mock.calls[0][0]
|
||||
expect(firstArg).toMatchObject({ nombre: 'Categoría Nueva' })
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call onSubmit when nombre is empty', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
wrap(
|
||||
<RubroFormDialog open={true} onOpenChange={vi.fn()} onSubmit={onSubmit} />,
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: /crear/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/nombre es requerido/i)).toBeInTheDocument()
|
||||
})
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── RubroFormDialog — EDIT mode ────────────────────────────────────────────
|
||||
|
||||
describe('RubroFormDialog — edit mode', () => {
|
||||
it('renders form in edit mode with pre-filled data', () => {
|
||||
wrap(
|
||||
<RubroFormDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={sampleRubro}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: /editar rubro/i })).toBeInTheDocument()
|
||||
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||
expect(input.value).toBe('Autos')
|
||||
})
|
||||
|
||||
it('calls onSubmit with correct payload on save', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
wrap(
|
||||
<RubroFormDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={sampleRubro}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, 'Autos Modificado')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar cambios/i }))
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
const firstArg = onSubmit.mock.calls[0][0]
|
||||
expect(firstArg).toMatchObject({ nombre: 'Autos Modificado' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DeleteRubroDialog ───────────────────────────────────────────────────────
|
||||
|
||||
describe('DeleteRubroDialog', () => {
|
||||
it('renders confirmation message with rubro name', () => {
|
||||
wrap(
|
||||
<DeleteRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={sampleRubro}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/autos/i)).toBeInTheDocument()
|
||||
// Title is present
|
||||
expect(screen.getByRole('heading', { name: /desactivar rubro/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm when user confirms deletion', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
wrap(
|
||||
<DeleteRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={sampleRubro}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
// Click the AlertDialogAction confirm button (not cancel)
|
||||
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
|
||||
const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
|
||||
await userEvent.click(confirmBtn)
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(sampleRubro.id))
|
||||
})
|
||||
|
||||
it('shows inline error when backend returns 409', async () => {
|
||||
const onConfirm = vi.fn(() =>
|
||||
Promise.reject({ response: { status: 409, data: { message: 'Tiene subrubros activos' } } }),
|
||||
)
|
||||
wrap(
|
||||
<DeleteRubroDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
rubro={sampleRubro}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
|
||||
const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
|
||||
await userEvent.click(confirmBtn)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/subrubros activos/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
172
src/web/src/tests/features/rubros/hooks.test.ts
Normal file
172
src/web/src/tests/features/rubros/hooks.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useRubrosTree } from '../../../features/rubros/hooks/useRubrosTree'
|
||||
import { useCreateRubro } from '../../../features/rubros/hooks/useCreateRubro'
|
||||
import { useUpdateRubro } from '../../../features/rubros/hooks/useUpdateRubro'
|
||||
import { useDeleteRubro } from '../../../features/rubros/hooks/useDeleteRubro'
|
||||
import { useMoveRubro } from '../../../features/rubros/hooks/useMoveRubro'
|
||||
import type { RubroTreeNode, Rubro } from '../../../features/rubros/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockTree: RubroTreeNode[] = [
|
||||
{ id: 1, nombre: 'Autos', orden: 1, activo: true, parentId: null, tarifarioBaseId: null, hijos: [] },
|
||||
]
|
||||
|
||||
const mockRubro: Rubro = {
|
||||
id: 1,
|
||||
nombre: 'Autos',
|
||||
orden: 1,
|
||||
activo: true,
|
||||
parentId: null,
|
||||
tarifarioBaseId: null,
|
||||
fechaCreacion: '2026-04-18T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function makeWrapper() {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
}
|
||||
|
||||
describe('useRubrosTree', () => {
|
||||
it('returns tree data on success', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, () => HttpResponse.json(mockTree)),
|
||||
)
|
||||
const { result } = renderHook(() => useRubrosTree(), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockTree)
|
||||
})
|
||||
|
||||
it('returns error state on failure', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, () =>
|
||||
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||
),
|
||||
)
|
||||
const { result } = renderHook(() => useRubrosTree(), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
})
|
||||
|
||||
it('passes incluirInactivos param when true', async () => {
|
||||
let capturedUrl = ''
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/rubros/tree`, ({ request }) => {
|
||||
capturedUrl = request.url
|
||||
return HttpResponse.json(mockTree)
|
||||
}),
|
||||
)
|
||||
const { result } = renderHook(() => useRubrosTree(true), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(capturedUrl).toContain('incluirInactivos=true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCreateRubro', () => {
|
||||
it('calls createRubro and invalidates rubros queries on success', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/rubros`, () =>
|
||||
HttpResponse.json(mockRubro, { status: 201 }),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useCreateRubro(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate({ nombre: 'Autos', parentId: null, tarifarioBaseId: null })
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['rubros'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpdateRubro', () => {
|
||||
it('calls updateRubro and invalidates rubros queries on success', async () => {
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/admin/rubros/1`, () =>
|
||||
HttpResponse.json(mockRubro),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useUpdateRubro(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate({ id: 1, data: { nombre: 'Autos Actualizado', tarifarioBaseId: null } })
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['rubros'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDeleteRubro', () => {
|
||||
it('calls deleteRubro and invalidates rubros queries on success', async () => {
|
||||
server.use(
|
||||
http.delete(`${API_URL}/api/v1/admin/rubros/1`, () =>
|
||||
new HttpResponse(null, { status: 204 }),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useDeleteRubro(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate(1)
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['rubros'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMoveRubro', () => {
|
||||
it('calls moveRubro and invalidates rubros queries on success', async () => {
|
||||
server.use(
|
||||
http.patch(`${API_URL}/api/v1/admin/rubros/1/mover`, () =>
|
||||
new HttpResponse(null, { status: 200 }),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useMoveRubro(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate({ id: 1, data: { nuevoParentId: null, nuevoOrden: 1 } })
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['rubros'] })
|
||||
})
|
||||
})
|
||||
@@ -49,8 +49,9 @@ public class AuthControllerTests
|
||||
Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
|
||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 total
|
||||
Assert.Equal(24, permisos.GetArrayLength());
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total
|
||||
Assert.Equal(25, permisos.GetArrayLength());
|
||||
}
|
||||
|
||||
// Scenario: invalid credentials return 401 with opaque error
|
||||
|
||||
@@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermisos_WithAdmin_Returns200With24Items()
|
||||
public async Task GetPermisos_WithAdmin_Returns200With25Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
||||
@@ -139,8 +139,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 total
|
||||
Assert.Equal(24, list.GetArrayLength());
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total
|
||||
Assert.Equal(25, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -183,7 +184,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With24Items()
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With25Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
||||
@@ -193,8 +194,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 total
|
||||
Assert.Equal(24, list.GetArrayLength());
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total
|
||||
Assert.Equal(25, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
669
tests/SIGCM2.Api.Tests/Rubros/RubrosControllerTests.cs
Normal file
669
tests/SIGCM2.Api.Tests/Rubros/RubrosControllerTests.cs
Normal file
@@ -0,0 +1,669 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Rubros;
|
||||
|
||||
/// <summary>
|
||||
/// CAT-001 — Integration tests for /api/v1/rubros and /api/v1/admin/rubros.
|
||||
/// Read endpoints require authentication (any role).
|
||||
/// Write endpoints require permission 'catalogo:rubros:gestionar'.
|
||||
/// Verifies audit events after each mutating operation.
|
||||
/// </summary>
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class RubrosControllerTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
private const string ReadEndpoint = "/api/v1/rubros";
|
||||
private const string AdminEndpoint = "/api/v1/admin/rubros";
|
||||
private const string AdminUsername = "admin";
|
||||
private const string AdminPassword = "@Diego550@";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public RubrosControllerTests(TestWebAppFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> GetAdminTokenAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||
{
|
||||
username = AdminUsername,
|
||||
password = AdminPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<string> GetCajeroTokenAsync(string username)
|
||||
{
|
||||
var adminToken = await GetAdminTokenAsync();
|
||||
|
||||
using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new
|
||||
{
|
||||
username,
|
||||
password = "Secure1234!",
|
||||
nombre = "Cajero",
|
||||
apellido = "Test",
|
||||
email = (string?)null,
|
||||
rol = "cajero"
|
||||
}, adminToken);
|
||||
var mkResp = await _client.SendAsync(mkUser);
|
||||
if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict)
|
||||
Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}");
|
||||
|
||||
var loginResp = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||
{
|
||||
username,
|
||||
password = "Secure1234!"
|
||||
});
|
||||
loginResp.EnsureSuccessStatusCode();
|
||||
var loginJson = await loginResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return loginJson.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<int> CountAuditEventsAsync(string action, string targetType, string targetId)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
return await conn.QuerySingleAsync<int>(
|
||||
"SELECT COUNT(*) FROM dbo.AuditEvent WHERE Action = @Action AND TargetType = @TargetType AND TargetId = @TargetId",
|
||||
new { Action = action, TargetType = targetType, TargetId = targetId });
|
||||
}
|
||||
|
||||
private static async Task DeleteRubroIfExistsAsync(int id)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// Need to disable system versioning to delete from history + main table
|
||||
await conn.ExecuteAsync("ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = OFF)");
|
||||
await conn.ExecuteAsync("DELETE FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id });
|
||||
// Delete children first (recursive), then the target
|
||||
await conn.ExecuteAsync("""
|
||||
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 conn.ExecuteAsync(
|
||||
"ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Rubro_History, HISTORY_RETENTION_PERIOD = 10 YEARS))");
|
||||
}
|
||||
|
||||
private static async Task DeleteUsuarioIfExistsAsync(string username)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync("""
|
||||
DELETE rt FROM dbo.RefreshToken rt
|
||||
INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId
|
||||
WHERE u.Username = @Username
|
||||
""", new { Username = username });
|
||||
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username });
|
||||
}
|
||||
|
||||
// ── 401 / 403 guards on READ endpoints ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetTree_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/tree");
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999");
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── 401 / 403 guards on WRITE endpoints ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRubro_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Test", parentId = (int?)null });
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRubro_WithCajeroRole_Returns403()
|
||||
{
|
||||
const string username = "cat001_rubro_cajero_403";
|
||||
try
|
||||
{
|
||||
var token = await GetCajeroTokenAsync(username);
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Test403", parentId = (int?)null }, token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteUsuarioIfExistsAsync(username);
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /api/v1/rubros/tree ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetTree_WithAdmin_Returns200WithTree()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
// Create a root rubro for the tree
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = "TreeRoot_GetTree",
|
||||
parentId = (int?)null,
|
||||
tarifarioBaseId = (int?)null
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
var created = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
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<JsonElement>();
|
||||
Assert.Equal(JsonValueKind.Array, json.ValueKind);
|
||||
// Should contain our created root
|
||||
var nombres = json.EnumerateArray().Select(n => n.GetProperty("nombre").GetString()).ToList();
|
||||
Assert.Contains("TreeRoot_GetTree", nombres);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(rootId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTree_IncluirInactivosTrue_IncludesInactivos()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
// Create then deactivate a rubro
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = "RubroInactivo_GetTree",
|
||||
parentId = (int?)null,
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
var created = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var rubroId = created.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
// Deactivate it
|
||||
using var deleteReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{rubroId}", bearerToken: token);
|
||||
await _client.SendAsync(deleteReq);
|
||||
|
||||
// Without incluirInactivos → should not appear
|
||||
using var req1 = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/tree", bearerToken: token);
|
||||
var resp1 = await _client.SendAsync(req1);
|
||||
var json1 = await resp1.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var nombres1 = json1.EnumerateArray().Select(n => n.GetProperty("nombre").GetString()).ToList();
|
||||
Assert.DoesNotContain("RubroInactivo_GetTree", nombres1);
|
||||
|
||||
// With incluirInactivos=true → should appear
|
||||
using var req2 = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/tree?incluirInactivos=true", bearerToken: token);
|
||||
var resp2 = await _client.SendAsync(req2);
|
||||
var json2 = await resp2.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var nombres2 = json2.EnumerateArray().Select(n => n.GetProperty("nombre").GetString()).ToList();
|
||||
Assert.Contains("RubroInactivo_GetTree", nombres2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(rubroId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /api/v1/rubros/{id} ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_ExistingRubro_Returns200()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = "RubroGetById",
|
||||
parentId = (int?)null,
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
var created = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var rubroId = created.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/{rubroId}", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("RubroGetById", json.GetProperty("nombre").GetString());
|
||||
Assert.Equal(rubroId, json.GetProperty("id").GetInt32());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(rubroId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_NotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("rubro_not_found", json.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
// ── POST /api/v1/admin/rubros ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRubro_Root_Returns201WithAuditEvent()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = "RubroCreate201",
|
||||
parentId = (int?)null,
|
||||
tarifarioBaseId = (int?)null
|
||||
}, token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
Assert.NotNull(resp.Headers.Location);
|
||||
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var id = json.GetProperty("id").GetInt32();
|
||||
Assert.True(id > 0);
|
||||
Assert.Equal("RubroCreate201", json.GetProperty("nombre").GetString());
|
||||
Assert.True(json.GetProperty("activo").GetBoolean());
|
||||
|
||||
try
|
||||
{
|
||||
var auditCount = await CountAuditEventsAsync("rubro.created", "Rubro", id.ToString());
|
||||
Assert.Equal(1, auditCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRubro_Child_Returns201()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var parentReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ParentCreate", parentId = (int?)null }, token);
|
||||
var parentResp = await _client.SendAsync(parentReq);
|
||||
Assert.Equal(HttpStatusCode.Created, parentResp.StatusCode);
|
||||
var parentJson = await parentResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var parentId = parentJson.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
using var childReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ChildCreate", parentId }, token);
|
||||
var childResp = await _client.SendAsync(childReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, childResp.StatusCode);
|
||||
var childJson = await childResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(parentId, childJson.GetProperty("parentId").GetInt32());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRubro_DuplicateNombreUnderParent_Returns409()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var parentReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ParentDup409", parentId = (int?)null }, token);
|
||||
var parentResp = await _client.SendAsync(parentReq);
|
||||
Assert.Equal(HttpStatusCode.Created, parentResp.StatusCode);
|
||||
var parentJson = await parentResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var parentId = parentJson.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
// First child
|
||||
using var child1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Duplicado", parentId }, token);
|
||||
var r1 = await _client.SendAsync(child1);
|
||||
Assert.Equal(HttpStatusCode.Created, r1.StatusCode);
|
||||
|
||||
// Second child with same nombre
|
||||
using var child2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Duplicado", parentId }, token);
|
||||
var r2 = await _client.SendAsync(child2);
|
||||
Assert.Equal(HttpStatusCode.Conflict, r2.StatusCode);
|
||||
var json = await r2.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("rubro_nombre_duplicado", json.GetProperty("error").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRubro_ParentNotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "OrphanChild", parentId = 999999 }, token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/admin/rubros/{id} ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRubro_Returns200WithAuditEvent()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "OriginalNombre", parentId = (int?)null }, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
var created = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var id = created.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
using var updateReq = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/{id}", new { nombre = "NombreActualizado" }, token);
|
||||
var updateResp = await _client.SendAsync(updateReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode);
|
||||
var updated = await updateResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("NombreActualizado", updated.GetProperty("nombre").GetString());
|
||||
|
||||
var auditCount = await CountAuditEventsAsync("rubro.updated", "Rubro", id.ToString());
|
||||
Assert.Equal(1, auditCount);
|
||||
|
||||
// Verify Rubro_History row
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
var histCount = await conn.QuerySingleAsync<int>(
|
||||
"SELECT COUNT(*) FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id });
|
||||
Assert.True(histCount >= 1, "Should have ≥1 row in Rubro_History after update");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRubro_NotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/999999", new { nombre = "Test" }, token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRubro_DuplicateNombreSibling_Returns409()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var parent = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ParentUpdate409", parentId = (int?)null }, token);
|
||||
var parentResp = await _client.SendAsync(parent);
|
||||
var parentJson = await parentResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var parentId = parentJson.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
using var c1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Sibling1", parentId }, token);
|
||||
var r1 = await _client.SendAsync(c1);
|
||||
var j1 = await r1.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var id1 = j1.GetProperty("id").GetInt32();
|
||||
|
||||
using var c2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Sibling2", parentId }, token);
|
||||
var r2 = await _client.SendAsync(c2);
|
||||
var j2 = await r2.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// Try to rename Sibling1 → Sibling2 (conflict)
|
||||
using var updateReq = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/{id1}", new { nombre = "Sibling2" }, token);
|
||||
var updateResp = await _client.SendAsync(updateReq);
|
||||
Assert.Equal(HttpStatusCode.Conflict, updateResp.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE /api/v1/admin/rubros/{id} ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRubro_LeafRubro_Returns204WithAuditEvent()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "RubroToDelete", parentId = (int?)null }, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
var created = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var id = created.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
using var deleteReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{id}", bearerToken: token);
|
||||
var deleteResp = await _client.SendAsync(deleteReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteResp.StatusCode);
|
||||
|
||||
// Verify audit event (handler uses "rubro.deleted")
|
||||
var auditCount = await CountAuditEventsAsync("rubro.deleted", "Rubro", id.ToString());
|
||||
Assert.Equal(1, auditCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRubro_WithActiveChildren_Returns409()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var parentReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ParentWithChildren", parentId = (int?)null }, token);
|
||||
var parentResp = await _client.SendAsync(parentReq);
|
||||
var parentJson = await parentResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var parentId = parentJson.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
// Add a child
|
||||
using var childReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ChildActive", parentId }, token);
|
||||
await _client.SendAsync(childReq);
|
||||
|
||||
// Try to delete parent (has active children → 409)
|
||||
using var deleteReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{parentId}", bearerToken: token);
|
||||
var deleteResp = await _client.SendAsync(deleteReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, deleteResp.StatusCode);
|
||||
var json = await deleteResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("rubro_tiene_hijos_activos", json.GetProperty("error").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRubro_NotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/999999", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── PATCH /api/v1/admin/rubros/{id}/mover ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MoveRubro_Returns200WithAuditEvent()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var p1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "MoveParent1", parentId = (int?)null }, token);
|
||||
var p1Resp = await _client.SendAsync(p1);
|
||||
var p1Json = await p1Resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var parent1Id = p1Json.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
using var p2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "MoveParent2", parentId = (int?)null }, token);
|
||||
var p2Resp = await _client.SendAsync(p2);
|
||||
var p2Json = await p2Resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var parent2Id = p2Json.GetProperty("id").GetInt32();
|
||||
|
||||
using var childReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "MoveChild", parentId = parent1Id }, token);
|
||||
var childResp = await _client.SendAsync(childReq);
|
||||
var childJson = await childResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var childId = childJson.GetProperty("id").GetInt32();
|
||||
|
||||
// Move child from parent1 to parent2
|
||||
using var moveReq = BuildRequest(HttpMethod.Patch, $"{AdminEndpoint}/{childId}/mover", new
|
||||
{
|
||||
nuevoParentId = parent2Id,
|
||||
nuevoOrden = 0
|
||||
}, token);
|
||||
var moveResp = await _client.SendAsync(moveReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, moveResp.StatusCode);
|
||||
var moved = await moveResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(parent2Id, moved.GetProperty("parentId").GetInt32());
|
||||
|
||||
var auditCount = await CountAuditEventsAsync("rubro.moved", "Rubro", childId.ToString());
|
||||
Assert.Equal(1, auditCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(parent1Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveRubro_CycleDetected_Returns400()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var rootReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "CycleRoot", parentId = (int?)null }, token);
|
||||
var rootResp = await _client.SendAsync(rootReq);
|
||||
var rootJson = await rootResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var rootId = rootJson.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
using var childReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "CycleChild", parentId = rootId }, token);
|
||||
var childResp = await _client.SendAsync(childReq);
|
||||
var childJson = await childResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var childId = childJson.GetProperty("id").GetInt32();
|
||||
|
||||
// Try to move root under its own child → cycle
|
||||
using var moveReq = BuildRequest(HttpMethod.Patch, $"{AdminEndpoint}/{rootId}/mover", new
|
||||
{
|
||||
nuevoParentId = childId,
|
||||
nuevoOrden = 0
|
||||
}, token);
|
||||
var moveResp = await _client.SendAsync(moveReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, moveResp.StatusCode);
|
||||
var json = await moveResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("rubro_cycle_detected", json.GetProperty("error").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(rootId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveRubro_DuplicateNombreUnderNewParent_Returns409()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var p1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "MoveDupParent1", parentId = (int?)null }, token);
|
||||
var p1Resp = await _client.SendAsync(p1);
|
||||
var p1Json = await p1Resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var parent1Id = p1Json.GetProperty("id").GetInt32();
|
||||
|
||||
try
|
||||
{
|
||||
using var p2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "MoveDupParent2", parentId = (int?)null }, token);
|
||||
var p2Resp = await _client.SendAsync(p2);
|
||||
var p2Json = await p2Resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var parent2Id = p2Json.GetProperty("id").GetInt32();
|
||||
|
||||
// Add "SameName" under parent1
|
||||
using var c1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "SameName", parentId = parent1Id }, token);
|
||||
var c1Resp = await _client.SendAsync(c1);
|
||||
var c1Json = await c1Resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var c1Id = c1Json.GetProperty("id").GetInt32();
|
||||
|
||||
// Add "SameName" under parent2 already
|
||||
using var c2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "SameName", parentId = parent2Id }, token);
|
||||
await _client.SendAsync(c2);
|
||||
|
||||
// Try to move c1 (SameName) under parent2 → duplicate
|
||||
using var moveReq = BuildRequest(HttpMethod.Patch, $"{AdminEndpoint}/{c1Id}/mover", new
|
||||
{
|
||||
nuevoParentId = parent2Id,
|
||||
nuevoOrden = 0
|
||||
}, token);
|
||||
var moveResp = await _client.SendAsync(moveReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, moveResp.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRubroIfExistsAsync(parent1Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using FluentAssertions;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Domain.Rubros;
|
||||
|
||||
public class RubroExceptionsTests
|
||||
{
|
||||
// ── RubroNotFoundException ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RubroNotFoundException_ContainsId()
|
||||
{
|
||||
var ex = new RubroNotFoundException(42);
|
||||
|
||||
ex.Id.Should().Be(42);
|
||||
ex.Message.Should().Contain("42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroNotFoundException_InheritsFromDomainException()
|
||||
{
|
||||
var ex = new RubroNotFoundException(1);
|
||||
|
||||
ex.Should().BeAssignableTo<DomainException>();
|
||||
}
|
||||
|
||||
// ── RubroNombreDuplicadoEnPadreException ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RubroNombreDuplicadoEnPadreException_ContainsNombreAndParentId()
|
||||
{
|
||||
var ex = new RubroNombreDuplicadoEnPadreException("Autos", parentId: 5);
|
||||
|
||||
ex.Nombre.Should().Be("Autos");
|
||||
ex.ParentId.Should().Be(5);
|
||||
ex.Message.Should().Contain("Autos");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroNombreDuplicadoEnPadreException_WithNullParent_IsValid()
|
||||
{
|
||||
var ex = new RubroNombreDuplicadoEnPadreException("Autos", parentId: null);
|
||||
|
||||
ex.Nombre.Should().Be("Autos");
|
||||
ex.ParentId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroNombreDuplicadoEnPadreException_InheritsFromDomainException()
|
||||
{
|
||||
var ex = new RubroNombreDuplicadoEnPadreException("x", null);
|
||||
|
||||
ex.Should().BeAssignableTo<DomainException>();
|
||||
}
|
||||
|
||||
// ── RubroMaxDepthExceededException ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RubroMaxDepthExceededException_ContainsDepthInfo()
|
||||
{
|
||||
var ex = new RubroMaxDepthExceededException(intentada: 11, max: 10);
|
||||
|
||||
ex.Intentada.Should().Be(11);
|
||||
ex.Max.Should().Be(10);
|
||||
ex.Message.Should().Contain("11");
|
||||
ex.Message.Should().Contain("10");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroMaxDepthExceededException_InheritsFromDomainException()
|
||||
{
|
||||
var ex = new RubroMaxDepthExceededException(11, 10);
|
||||
|
||||
ex.Should().BeAssignableTo<DomainException>();
|
||||
}
|
||||
|
||||
// ── RubroCycleDetectedException ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RubroCycleDetectedException_ContainsRubroIdAndIntendedParentId()
|
||||
{
|
||||
var ex = new RubroCycleDetectedException(rubroId: 5, nuevoParentId: 10);
|
||||
|
||||
ex.RubroId.Should().Be(5);
|
||||
ex.NuevoParentId.Should().Be(10);
|
||||
ex.Message.Should().Contain("5");
|
||||
ex.Message.Should().Contain("10");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroCycleDetectedException_InheritsFromDomainException()
|
||||
{
|
||||
var ex = new RubroCycleDetectedException(1, 2);
|
||||
|
||||
ex.Should().BeAssignableTo<DomainException>();
|
||||
}
|
||||
|
||||
// ── RubroTieneHijosActivosException ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RubroTieneHijosActivosException_ContainsIdAndCount()
|
||||
{
|
||||
var ex = new RubroTieneHijosActivosException(id: 7, count: 3);
|
||||
|
||||
ex.Id.Should().Be(7);
|
||||
ex.Count.Should().Be(3);
|
||||
ex.Message.Should().Contain("3");
|
||||
ex.Message.Should().Contain("subrubros");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroTieneHijosActivosException_InheritsFromDomainException()
|
||||
{
|
||||
var ex = new RubroTieneHijosActivosException(1, 2);
|
||||
|
||||
ex.Should().BeAssignableTo<DomainException>();
|
||||
}
|
||||
|
||||
// ── RubroPadreInactivoException ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RubroPadreInactivoException_ContainsParentId()
|
||||
{
|
||||
var ex = new RubroPadreInactivoException(parentId: 9);
|
||||
|
||||
ex.ParentId.Should().Be(9);
|
||||
ex.Message.Should().Contain("9");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroPadreInactivoException_InheritsFromDomainException()
|
||||
{
|
||||
var ex = new RubroPadreInactivoException(1);
|
||||
|
||||
ex.Should().BeAssignableTo<DomainException>();
|
||||
}
|
||||
}
|
||||
171
tests/SIGCM2.Application.Tests/Domain/Rubros/RubroTests.cs
Normal file
171
tests/SIGCM2.Application.Tests/Domain/Rubros/RubroTests.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Domain.Rubros;
|
||||
|
||||
public class RubroTests
|
||||
{
|
||||
private static readonly FakeTimeProvider FakeTime = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
// ── ForCreation: happy path ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_con_datos_validos_crea_rubro_activo_con_orden_cero_como_default()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
rubro.Nombre.Should().Be("Autos");
|
||||
rubro.ParentId.Should().BeNull();
|
||||
rubro.Orden.Should().Be(0);
|
||||
rubro.Activo.Should().BeTrue();
|
||||
rubro.TarifarioBaseId.Should().BeNull();
|
||||
rubro.Id.Should().Be(0);
|
||||
rubro.FechaCreacion.Should().Be(FakeTime.GetUtcNow().UtcDateTime);
|
||||
rubro.FechaModificacion.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_root_con_parentId_null_es_valido()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
rubro.ParentId.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── ForCreation: validations ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_con_nombre_vacio_lanza_ArgumentException()
|
||||
{
|
||||
var act = () => Rubro.ForCreation("", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_con_nombre_solo_whitespace_lanza_ArgumentException()
|
||||
{
|
||||
var act = () => Rubro.ForCreation(" ", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_con_nombre_excediendo_200_chars_lanza_ArgumentException()
|
||||
{
|
||||
var nombre = new string('A', 201);
|
||||
|
||||
var act = () => Rubro.ForCreation(nombre, parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_con_parentId_menor_o_igual_a_cero_lanza_ArgumentException()
|
||||
{
|
||||
var act = () => Rubro.ForCreation("Autos", parentId: 0, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_con_tarifarioBaseId_menor_a_cero_lanza_ArgumentException()
|
||||
{
|
||||
var act = () => Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: -1, FakeTime);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── WithRenamed ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Rename_con_nombre_valido_devuelve_nueva_instancia_con_FechaModificacion()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
var laterTime = new FakeTimeProvider(new DateTimeOffset(2026, 4, 18, 14, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var renamed = rubro.WithRenamed("Vehiculos", laterTime);
|
||||
|
||||
renamed.Nombre.Should().Be("Vehiculos");
|
||||
renamed.FechaModificacion.Should().Be(laterTime.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rename_con_nombre_invalido_lanza_ArgumentException()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
var act = () => rubro.WithRenamed("", FakeTime);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rename_no_muta_la_instancia_original()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
rubro.WithRenamed("Vehiculos", FakeTime);
|
||||
|
||||
rubro.Nombre.Should().Be("Autos");
|
||||
}
|
||||
|
||||
// ── WithMoved ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Move_a_nuevo_parent_devuelve_nueva_instancia_con_parentId_actualizado()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
var moved = rubro.WithMoved(nuevoParentId: 5, nuevoOrden: 2, FakeTime);
|
||||
|
||||
moved.ParentId.Should().Be(5);
|
||||
moved.Orden.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Move_a_root_permite_parentId_null()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Autos", parentId: 3, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
var moved = rubro.WithMoved(nuevoParentId: null, nuevoOrden: 0, FakeTime);
|
||||
|
||||
moved.ParentId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Move_no_muta_la_instancia_original()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
rubro.WithMoved(nuevoParentId: 5, nuevoOrden: 1, FakeTime);
|
||||
|
||||
rubro.ParentId.Should().BeNull();
|
||||
rubro.Orden.Should().Be(0);
|
||||
}
|
||||
|
||||
// ── WithActivo (Deactivate / Reactivate) ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Deactivate_flip_Activo_a_false()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
|
||||
var deactivated = rubro.WithActivo(false, FakeTime);
|
||||
|
||||
deactivated.Activo.Should().BeFalse();
|
||||
deactivated.FechaModificacion.Should().Be(FakeTime.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reactivate_flip_Activo_a_true()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
|
||||
var deactivated = rubro.WithActivo(false, FakeTime);
|
||||
|
||||
var reactivated = deactivated.WithActivo(true, FakeTime);
|
||||
|
||||
reactivated.Activo.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -73,15 +73,16 @@ public class PermisoRepositoryTests : IAsyncLifetime
|
||||
// ── ListAsync ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_Returns23CanonicalSeeds()
|
||||
public async Task ListAsync_Returns25CanonicalSeeds()
|
||||
{
|
||||
var list = await _repository.ListAsync();
|
||||
|
||||
// V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos
|
||||
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
|
||||
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar'
|
||||
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar' = 24 total
|
||||
Assert.Equal(24, list.Count);
|
||||
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
|
||||
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar' = 25 total
|
||||
Assert.Equal(25, list.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -173,15 +173,16 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
|
||||
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRolCodigoAsync_Admin_Returns23Permisos()
|
||||
public async Task GetByRolCodigoAsync_Admin_Returns25Permisos()
|
||||
{
|
||||
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
|
||||
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
|
||||
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar'
|
||||
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar' = 24 total
|
||||
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
|
||||
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar' = 25 total
|
||||
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
||||
|
||||
Assert.Equal(24, permisos.Count);
|
||||
Assert.Equal(25, permisos.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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<IRubroRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
|
||||
private readonly CreateRubroCommandHandler _handler;
|
||||
|
||||
public CreateRubroCommandHandlerTests()
|
||||
{
|
||||
_repo.ExistsByNombreUnderParentAsync(Arg.Any<int?>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
_repo.GetMaxOrdenAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(0);
|
||||
_repo.AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>())
|
||||
.Returns(1);
|
||||
_repo.GetDepthAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
.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<Rubro>(), Arg.Any<CancellationToken>()).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<Rubro>(r => r.Nombre == "Autos" && r.ParentId == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_Root_CallsAuditLog()
|
||||
{
|
||||
_repo.AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>()).Returns(7);
|
||||
|
||||
await _handler.Handle(RootCommand());
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "rubro.created",
|
||||
targetType: "Rubro",
|
||||
targetId: "7",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── 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<CancellationToken>()).Returns(3);
|
||||
var parent = new Rubro(5, null, "ParentRubro", 0, activo: true, tarifarioBaseId: null,
|
||||
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(parent);
|
||||
|
||||
await _handler.Handle(ChildCommand(parentId: 5));
|
||||
|
||||
await _repo.Received(1).AddAsync(
|
||||
Arg.Is<Rubro>(r => r.Orden == 3),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Parent not found → RubroNotFoundException ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ParentNotFound_ThrowsRubroNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
|
||||
|
||||
var act = () => _handler.Handle(ChildCommand(parentId: 999));
|
||||
|
||||
await act.Should().ThrowAsync<RubroNotFoundException>()
|
||||
.Where(ex => ex.Id == 999);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ParentNotFound_DoesNotCallAddAsync()
|
||||
{
|
||||
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
|
||||
|
||||
try { await _handler.Handle(ChildCommand(parentId: 999)); } catch { }
|
||||
|
||||
await _repo.DidNotReceive().AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── 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<CancellationToken>()).Returns(inactiveParent);
|
||||
|
||||
var act = () => _handler.Handle(ChildCommand(parentId: 7));
|
||||
|
||||
await act.Should().ThrowAsync<RubroPadreInactivoException>()
|
||||
.Where(ex => ex.ParentId == 7);
|
||||
}
|
||||
|
||||
// ── Duplicate name (CI) → RubroNombreDuplicadoEnPadreException ──────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DuplicateName_ThrowsRubroNombreDuplicadoEnPadreException()
|
||||
{
|
||||
_repo.ExistsByNombreUnderParentAsync(null, "Autos", null, Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
var act = () => _handler.Handle(RootCommand());
|
||||
|
||||
await act.Should().ThrowAsync<RubroNombreDuplicadoEnPadreException>();
|
||||
}
|
||||
|
||||
// ── 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<CancellationToken>()).Returns(10);
|
||||
var parent = new Rubro(5, null, "DeepParent", 0, activo: true, tarifarioBaseId: null,
|
||||
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(parent);
|
||||
|
||||
var act = () => _handler.Handle(ChildCommand(parentId: 5));
|
||||
|
||||
await act.Should().ThrowAsync<RubroMaxDepthExceededException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DepthAtMaxAllowed_Succeeds()
|
||||
{
|
||||
// MaxDepth=10, parent at depth 9 → child at depth 10 is allowed
|
||||
_repo.GetDepthAsync(5, Arg.Any<CancellationToken>()).Returns(9);
|
||||
var parent = new Rubro(5, null, "DeepParent", 0, activo: true, tarifarioBaseId: null,
|
||||
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
|
||||
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(parent);
|
||||
_repo.AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>()).Returns(99);
|
||||
|
||||
var result = await _handler.Handle(ChildCommand(parentId: 5));
|
||||
|
||||
result.Id.Should().Be(99);
|
||||
}
|
||||
}
|
||||
@@ -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<IRubroRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
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<int>(), Arg.Any<CancellationToken>()).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<CancellationToken>()).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<CancellationToken>()).Returns(LeafRubro());
|
||||
|
||||
await _handler.Handle(new DeactivateRubroCommand(Id: 10));
|
||||
|
||||
await _repo.Received(1).UpdateAsync(
|
||||
Arg.Is<Rubro>(r => r.Id == 10 && !r.Activo),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_LeafRubro_CallsAuditLog()
|
||||
{
|
||||
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(LeafRubro());
|
||||
|
||||
await _handler.Handle(new DeactivateRubroCommand(Id: 10));
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "rubro.deleted",
|
||||
targetType: "Rubro",
|
||||
targetId: "10",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Has active children → RubroTieneHijosActivosException ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HasActiveChildren_ThrowsRubroTieneHijosActivosException()
|
||||
{
|
||||
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(LeafRubro(5));
|
||||
_repo.CountActiveChildrenAsync(5, Arg.Any<CancellationToken>()).Returns(3);
|
||||
|
||||
var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 5));
|
||||
|
||||
await act.Should().ThrowAsync<RubroTieneHijosActivosException>()
|
||||
.Where(ex => ex.Id == 5 && ex.Count == 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HasActiveChildren_DoesNotCallAuditLog()
|
||||
{
|
||||
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(LeafRubro(5));
|
||||
_repo.CountActiveChildrenAsync(5, Arg.Any<CancellationToken>()).Returns(1);
|
||||
|
||||
try { await _handler.Handle(new DeactivateRubroCommand(Id: 5)); } catch { }
|
||||
|
||||
await _audit.DidNotReceive().LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Not found → RubroNotFoundException ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotFound_ThrowsRubroNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
|
||||
|
||||
var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 99));
|
||||
|
||||
await act.Should().ThrowAsync<RubroNotFoundException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Rubros.GetById;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Rubros.GetById;
|
||||
|
||||
public class GetRubroByIdQueryHandlerTests
|
||||
{
|
||||
private static readonly FakeTimeProvider FakeTime =
|
||||
new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
|
||||
|
||||
private static DateTime UtcNow => FakeTime.GetUtcNow().UtcDateTime;
|
||||
|
||||
private static Rubro MakeRubro(
|
||||
int id,
|
||||
int? parentId = null,
|
||||
bool activo = true,
|
||||
int? tarifarioBaseId = null,
|
||||
DateTime? fechaModificacion = null)
|
||||
=> new(id, parentId, $"Rubro{id}", orden: 1, activo, tarifarioBaseId,
|
||||
fechaCreacion: UtcNow, fechaModificacion);
|
||||
|
||||
// ── Handle_RubroExiste_ActivoPorDefecto_RetornaDto ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RubroExiste_ActivoPorDefecto_RetornaDto()
|
||||
{
|
||||
var rubro = MakeRubro(id: 5, parentId: 2, tarifarioBaseId: 10);
|
||||
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
|
||||
var handler = new GetRubroByIdQueryHandler(_repo);
|
||||
var result = await handler.Handle(new GetRubroByIdQuery(Id: 5));
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().Be(5);
|
||||
result.Nombre.Should().Be("Rubro5");
|
||||
result.ParentId.Should().Be(2);
|
||||
result.Orden.Should().Be(1);
|
||||
result.Activo.Should().BeTrue();
|
||||
result.TarifarioBaseId.Should().Be(10);
|
||||
result.FechaCreacion.Should().Be(UtcNow);
|
||||
result.FechaModificacion.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Handle_RubroNoExiste_LanzaRubroNotFoundException ────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RubroNoExiste_LanzaRubroNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
|
||||
|
||||
var handler = new GetRubroByIdQueryHandler(_repo);
|
||||
var act = () => handler.Handle(new GetRubroByIdQuery(Id: 99));
|
||||
|
||||
await act.Should().ThrowAsync<RubroNotFoundException>()
|
||||
.WithMessage("*99*");
|
||||
}
|
||||
|
||||
// ── Handle_RubroNotFoundException_ContienIdCorrecto ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RubroNotFoundException_ContieneIdCorrecto()
|
||||
{
|
||||
_repo.GetByIdAsync(42, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
|
||||
|
||||
var handler = new GetRubroByIdQueryHandler(_repo);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RubroNotFoundException>(
|
||||
() => handler.Handle(new GetRubroByIdQuery(Id: 42)));
|
||||
|
||||
ex.Id.Should().Be(42);
|
||||
}
|
||||
|
||||
// ── Handle_CancellationToken_SePropagaAlRepo ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_CancellationToken_SePropagaAlRepo()
|
||||
{
|
||||
// The handler signature accepts CancellationToken and forwards it to the repo.
|
||||
// We verify the repo is called exactly once with the matching id and any token.
|
||||
_repo.GetByIdAsync(7, Arg.Any<CancellationToken>()).Returns(MakeRubro(7));
|
||||
|
||||
var handler = new GetRubroByIdQueryHandler(_repo);
|
||||
await handler.Handle(new GetRubroByIdQuery(Id: 7));
|
||||
|
||||
await _repo.Received(1).GetByIdAsync(7, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Handle_FechaCreacion_SeSerializaComoInstantUTC ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FechaCreacion_SeSerializaComoInstantUTC()
|
||||
{
|
||||
var expectedUtc = new DateTime(2026, 1, 15, 8, 30, 0, DateTimeKind.Utc);
|
||||
var rubro = new Rubro(3, null, "RubroUTC", 0, true, null, expectedUtc, null);
|
||||
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
|
||||
var handler = new GetRubroByIdQueryHandler(_repo);
|
||||
var result = await handler.Handle(new GetRubroByIdQuery(Id: 3));
|
||||
|
||||
result.FechaCreacion.Should().Be(expectedUtc);
|
||||
result.FechaCreacion.Kind.Should().Be(DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
// ── Handle_FechaModificacion_Null_SeRetornaNull ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FechaModificacion_Null_SeRetornaNull()
|
||||
{
|
||||
var rubro = MakeRubro(id: 8, fechaModificacion: null);
|
||||
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
|
||||
var handler = new GetRubroByIdQueryHandler(_repo);
|
||||
var result = await handler.Handle(new GetRubroByIdQuery(Id: 8));
|
||||
|
||||
result.FechaModificacion.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Handle_FechaModificacion_ConValor_SeRetornaCorrecto ──────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FechaModificacion_ConValor_SeRetornaCorrecto()
|
||||
{
|
||||
var fechaMod = new DateTime(2026, 3, 10, 10, 0, 0, DateTimeKind.Utc);
|
||||
var rubro = MakeRubro(id: 9, fechaModificacion: fechaMod);
|
||||
_repo.GetByIdAsync(9, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
|
||||
var handler = new GetRubroByIdQueryHandler(_repo);
|
||||
var result = await handler.Handle(new GetRubroByIdQuery(Id: 9));
|
||||
|
||||
result.FechaModificacion.Should().Be(fechaMod);
|
||||
}
|
||||
|
||||
// ── Handle_RubroRaiz_SinParent_RetornaDtoConParentIdNull ─────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RubroRaiz_SinParent_RetornaDtoConParentIdNull()
|
||||
{
|
||||
var rubro = MakeRubro(id: 1, parentId: null);
|
||||
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
|
||||
var handler = new GetRubroByIdQueryHandler(_repo);
|
||||
var result = await handler.Handle(new GetRubroByIdQuery(Id: 1));
|
||||
|
||||
result.ParentId.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Handle_RubroConTarifarioBaseId_SeRetornaCorrecto ────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RubroConTarifarioBaseId_SeRetornaCorrecto()
|
||||
{
|
||||
var rubro = MakeRubro(id: 11, tarifarioBaseId: 42);
|
||||
_repo.GetByIdAsync(11, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
|
||||
var handler = new GetRubroByIdQueryHandler(_repo);
|
||||
var result = await handler.Handle(new GetRubroByIdQuery(Id: 11));
|
||||
|
||||
result.TarifarioBaseId.Should().Be(42);
|
||||
}
|
||||
}
|
||||
@@ -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<IRubroRepository>();
|
||||
|
||||
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<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.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<CancellationToken>());
|
||||
result.Should().HaveCount(2); // both roots
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTree_Empty_ReturnsEmptyList()
|
||||
{
|
||||
_repo.GetAllAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Rubro>());
|
||||
|
||||
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<CancellationToken>()).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<CancellationToken>()).Returns((Rubro?)null);
|
||||
|
||||
var handler = new GetRubroByIdQueryHandler(_repo);
|
||||
var act = () => handler.Handle(new GetRubroByIdQuery(Id: 99));
|
||||
|
||||
await act.Should().ThrowAsync<RubroNotFoundException>();
|
||||
}
|
||||
}
|
||||
@@ -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<IRubroRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly IOptions<RubrosOptions> _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<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Rubro>());
|
||||
_repo.ExistsByNombreUnderParentAsync(Arg.Any<int?>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
_repo.GetDepthAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(0);
|
||||
_repo.GetMaxOrdenAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>()).Returns(rubro);
|
||||
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).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<CancellationToken>()).Returns(rubro);
|
||||
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).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<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Move to root (nuevoParentId null) ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MoveToRoot_SetsParentIdNull()
|
||||
{
|
||||
var rubro = MakeRubro(8, parentId: 3);
|
||||
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).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<CancellationToken>()).Returns((Rubro?)null);
|
||||
|
||||
var act = () => _handler.Handle(new MoveRubroCommand(Id: 99, NuevoParentId: 1, NuevoOrden: 0));
|
||||
|
||||
await act.Should().ThrowAsync<RubroNotFoundException>();
|
||||
}
|
||||
|
||||
// ── Cycle detection ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DirectChildAsNewParent_ThrowsRubroCycleDetectedException()
|
||||
{
|
||||
var rubro = MakeRubro(5, parentId: null);
|
||||
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
// Descendant id=10 would be the new parent
|
||||
_repo.GetDescendantsAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { MakeRubro(10, parentId: 5) });
|
||||
|
||||
var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: 10, NuevoOrden: 0));
|
||||
|
||||
await act.Should().ThrowAsync<RubroCycleDetectedException>()
|
||||
.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<CancellationToken>()).Returns(rubro);
|
||||
_repo.GetDescendantsAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { MakeRubro(10, 5), MakeRubro(15, 10) });
|
||||
|
||||
var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: 15, NuevoOrden: 0));
|
||||
|
||||
await act.Should().ThrowAsync<RubroCycleDetectedException>();
|
||||
}
|
||||
|
||||
// ── 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<CancellationToken>()).Returns(rubro);
|
||||
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(inactiveParent);
|
||||
|
||||
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
|
||||
|
||||
await act.Should().ThrowAsync<RubroPadreInactivoException>();
|
||||
}
|
||||
|
||||
// ── 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<CancellationToken>()).Returns(rubro);
|
||||
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(newParent);
|
||||
_repo.ExistsByNombreUnderParentAsync((int?)20, rubro.Nombre, 8, Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
|
||||
|
||||
await act.Should().ThrowAsync<RubroNombreDuplicadoEnPadreException>();
|
||||
}
|
||||
|
||||
// ── Depth exceeded ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DepthExceeded_ThrowsRubroMaxDepthExceededException()
|
||||
{
|
||||
var rubro = MakeRubro(8, parentId: 2);
|
||||
var newParent = MakeRubro(20);
|
||||
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
|
||||
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(newParent);
|
||||
_repo.GetDepthAsync((int?)20, Arg.Any<CancellationToken>()).Returns(10); // at MaxDepth
|
||||
|
||||
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
|
||||
|
||||
await act.Should().ThrowAsync<RubroMaxDepthExceededException>();
|
||||
}
|
||||
}
|
||||
390
tests/SIGCM2.Application.Tests/Rubros/RubroRepositoryTests.cs
Normal file
390
tests/SIGCM2.Application.Tests/Rubros/RubroRepositoryTests.cs
Normal file
@@ -0,0 +1,390 @@
|
||||
using Dapper;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Infrastructure.Persistence;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Rubros;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for RubroRepository against SIGCM2_Test_App.
|
||||
/// Uses shared SqlTestFixture via [Collection("Database")] — fixture maneja Respawn + seeds.
|
||||
/// Temporal: after UpdateAsync, dbo.Rubro_History MUST have ≥1 row for that Id.
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class RubroRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly SqlTestFixture _db;
|
||||
private RubroRepository _repository = null!;
|
||||
private TimeProvider _timeProvider = null!;
|
||||
|
||||
public RubroRepositoryTests(SqlTestFixture db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _db.ResetAndSeedAsync();
|
||||
|
||||
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
|
||||
_repository = new RubroRepository(factory);
|
||||
_timeProvider = TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_AndGetById_ReturnsAllFields()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Automotores", parentId: null, orden: 0, tarifarioBaseId: null, _timeProvider);
|
||||
|
||||
var id = await _repository.AddAsync(rubro);
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(id, result!.Id);
|
||||
Assert.Equal("Automotores", result.Nombre);
|
||||
Assert.Null(result.ParentId);
|
||||
Assert.Equal(0, result.Orden);
|
||||
Assert.True(result.Activo);
|
||||
Assert.Null(result.TarifarioBaseId);
|
||||
Assert.True(result.FechaCreacion > DateTime.MinValue);
|
||||
Assert.Null(result.FechaModificacion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_WithTarifarioBaseId_PersistsValue()
|
||||
{
|
||||
var rubro = Rubro.ForCreation("Tecnología", parentId: null, orden: 0, tarifarioBaseId: 42, _timeProvider);
|
||||
|
||||
var id = await _repository.AddAsync(rubro);
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(42, result!.TarifarioBaseId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_NonExistent_ReturnsNull()
|
||||
{
|
||||
var result = await _repository.GetByIdAsync(999999);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ── GetAllAsync ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_IncluirInactivosFalse_OmitsInactive()
|
||||
{
|
||||
var activo = Rubro.ForCreation("Activo", null, 0, null, _timeProvider);
|
||||
var inactivo = Rubro.ForCreation("Inactivo", null, 1, null, _timeProvider);
|
||||
|
||||
await _repository.AddAsync(activo);
|
||||
var inactivoId = await _repository.AddAsync(inactivo);
|
||||
|
||||
// Deactivate the second one via UpdateAsync
|
||||
var inactivoEntity = await _repository.GetByIdAsync(inactivoId);
|
||||
await _repository.UpdateAsync(inactivoEntity!.WithActivo(false, _timeProvider));
|
||||
|
||||
var all = await _repository.GetAllAsync(incluirInactivos: false);
|
||||
|
||||
Assert.Contains(all, r => r.Nombre == "Activo");
|
||||
Assert.DoesNotContain(all, r => r.Nombre == "Inactivo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_IncluirInactivosTrue_ReturnsAll()
|
||||
{
|
||||
var activo = Rubro.ForCreation("ActivoAll", null, 0, null, _timeProvider);
|
||||
var inactivo = Rubro.ForCreation("InactivoAll", null, 1, null, _timeProvider);
|
||||
|
||||
await _repository.AddAsync(activo);
|
||||
var inactivoId = await _repository.AddAsync(inactivo);
|
||||
|
||||
var inactivoEntity = await _repository.GetByIdAsync(inactivoId);
|
||||
await _repository.UpdateAsync(inactivoEntity!.WithActivo(false, _timeProvider));
|
||||
|
||||
var all = await _repository.GetAllAsync(incluirInactivos: true);
|
||||
|
||||
Assert.Contains(all, r => r.Nombre == "ActivoAll");
|
||||
Assert.Contains(all, r => r.Nombre == "InactivoAll");
|
||||
}
|
||||
|
||||
// ── GetDescendantsAsync ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetDescendantsAsync_3LevelsDeep_ReturnsAllDescendants()
|
||||
{
|
||||
// Level 0: root
|
||||
var root = Rubro.ForCreation("Root", null, 0, null, _timeProvider);
|
||||
var rootId = await _repository.AddAsync(root);
|
||||
|
||||
// Level 1: child of root
|
||||
var child = Rubro.ForCreation("Child", rootId, 0, null, _timeProvider);
|
||||
var childId = await _repository.AddAsync(child);
|
||||
|
||||
// Level 2: grandchild of root
|
||||
var grandchild = Rubro.ForCreation("Grandchild", childId, 0, null, _timeProvider);
|
||||
var grandchildId = await _repository.AddAsync(grandchild);
|
||||
|
||||
var descendants = await _repository.GetDescendantsAsync(rootId);
|
||||
|
||||
Assert.Equal(2, descendants.Count);
|
||||
Assert.Contains(descendants, d => d.Id == childId);
|
||||
Assert.Contains(descendants, d => d.Id == grandchildId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDescendantsAsync_Leaf_ReturnsEmpty()
|
||||
{
|
||||
var leaf = Rubro.ForCreation("Leaf", null, 0, null, _timeProvider);
|
||||
var leafId = await _repository.AddAsync(leaf);
|
||||
|
||||
var descendants = await _repository.GetDescendantsAsync(leafId);
|
||||
|
||||
Assert.Empty(descendants);
|
||||
}
|
||||
|
||||
// ── CountActiveChildrenAsync ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CountActiveChildrenAsync_NoChildren_ReturnsZero()
|
||||
{
|
||||
var parent = Rubro.ForCreation("ParentNoHijos", null, 0, null, _timeProvider);
|
||||
var parentId = await _repository.AddAsync(parent);
|
||||
|
||||
var count = await _repository.CountActiveChildrenAsync(parentId);
|
||||
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CountActiveChildrenAsync_TwoActive_ReturnsTwo()
|
||||
{
|
||||
var parent = Rubro.ForCreation("ParentDosHijos", null, 0, null, _timeProvider);
|
||||
var parentId = await _repository.AddAsync(parent);
|
||||
|
||||
await _repository.AddAsync(Rubro.ForCreation("Hijo1", parentId, 0, null, _timeProvider));
|
||||
await _repository.AddAsync(Rubro.ForCreation("Hijo2", parentId, 1, null, _timeProvider));
|
||||
|
||||
var count = await _repository.CountActiveChildrenAsync(parentId);
|
||||
|
||||
Assert.Equal(2, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CountActiveChildrenAsync_ActiveAndInactive_CountsOnlyActive()
|
||||
{
|
||||
var parent = Rubro.ForCreation("ParentMixed", null, 0, null, _timeProvider);
|
||||
var parentId = await _repository.AddAsync(parent);
|
||||
|
||||
await _repository.AddAsync(Rubro.ForCreation("HijoActivo", parentId, 0, null, _timeProvider));
|
||||
var inactivoId = await _repository.AddAsync(Rubro.ForCreation("HijoInactivo", parentId, 1, null, _timeProvider));
|
||||
|
||||
var inactivo = await _repository.GetByIdAsync(inactivoId);
|
||||
await _repository.UpdateAsync(inactivo!.WithActivo(false, _timeProvider));
|
||||
|
||||
var count = await _repository.CountActiveChildrenAsync(parentId);
|
||||
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
// ── GetMaxOrdenAsync ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetMaxOrdenAsync_Empty_ReturnsZero()
|
||||
{
|
||||
// No siblings → first slot is 0
|
||||
var orden = await _repository.GetMaxOrdenAsync(parentId: null);
|
||||
|
||||
Assert.Equal(0, orden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMaxOrdenAsync_ThreeSiblings_ReturnsThree()
|
||||
{
|
||||
// Orden = [0, 1, 2] → next slot = 3 (MAX+1)
|
||||
var parent = Rubro.ForCreation("ParentOrden", null, 0, null, _timeProvider);
|
||||
var parentId = await _repository.AddAsync(parent);
|
||||
|
||||
await _repository.AddAsync(Rubro.ForCreation("S1", parentId, 0, null, _timeProvider));
|
||||
await _repository.AddAsync(Rubro.ForCreation("S2", parentId, 1, null, _timeProvider));
|
||||
await _repository.AddAsync(Rubro.ForCreation("S3", parentId, 2, null, _timeProvider));
|
||||
|
||||
var orden = await _repository.GetMaxOrdenAsync(parentId);
|
||||
|
||||
Assert.Equal(3, orden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMaxOrdenAsync_ParentIdNull_WorksForRoots()
|
||||
{
|
||||
// Insert one root with Orden=0
|
||||
await _repository.AddAsync(Rubro.ForCreation("RootOrden1", null, 0, null, _timeProvider));
|
||||
|
||||
var orden = await _repository.GetMaxOrdenAsync(parentId: null);
|
||||
|
||||
Assert.Equal(1, orden); // MAX(0) + 1 = 1
|
||||
}
|
||||
|
||||
// ── ExistsByNombreUnderParentAsync ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsByNombreUnderParentAsync_Exists_ReturnsTrue()
|
||||
{
|
||||
var parent = Rubro.ForCreation("ParentExists", null, 0, null, _timeProvider);
|
||||
var parentId = await _repository.AddAsync(parent);
|
||||
|
||||
await _repository.AddAsync(Rubro.ForCreation("Autos", parentId, 0, null, _timeProvider));
|
||||
|
||||
var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "Autos", excludeId: null);
|
||||
|
||||
Assert.True(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsByNombreUnderParentAsync_SameNameDifferentParent_ReturnsFalse()
|
||||
{
|
||||
var parent1 = Rubro.ForCreation("Parent1", null, 0, null, _timeProvider);
|
||||
var parent1Id = await _repository.AddAsync(parent1);
|
||||
|
||||
var parent2 = Rubro.ForCreation("Parent2", null, 1, null, _timeProvider);
|
||||
var parent2Id = await _repository.AddAsync(parent2);
|
||||
|
||||
// Add "Autos" under parent1
|
||||
await _repository.AddAsync(Rubro.ForCreation("Autos", parent1Id, 0, null, _timeProvider));
|
||||
|
||||
// Check under parent2 — should not exist
|
||||
var exists = await _repository.ExistsByNombreUnderParentAsync(parent2Id, "Autos", excludeId: null);
|
||||
|
||||
Assert.False(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsByNombreUnderParentAsync_ExcludeId_WhenProvidedSkipsSelf()
|
||||
{
|
||||
var parent = Rubro.ForCreation("ParentExclude", null, 0, null, _timeProvider);
|
||||
var parentId = await _repository.AddAsync(parent);
|
||||
|
||||
var id = await _repository.AddAsync(Rubro.ForCreation("AutosExclude", parentId, 0, null, _timeProvider));
|
||||
|
||||
// Excluding self → should return false (no other rubro with same name)
|
||||
var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "AutosExclude", excludeId: id);
|
||||
|
||||
Assert.False(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsByNombreUnderParentAsync_CaseInsensitive_InsensibleAMayusculas()
|
||||
{
|
||||
var parent = Rubro.ForCreation("ParentCI", null, 0, null, _timeProvider);
|
||||
var parentId = await _repository.AddAsync(parent);
|
||||
|
||||
await _repository.AddAsync(Rubro.ForCreation("autos", parentId, 0, null, _timeProvider));
|
||||
|
||||
// "AUTOS" should match "autos" (case-insensitive)
|
||||
var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "AUTOS", excludeId: null);
|
||||
|
||||
Assert.True(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsByNombreUnderParentAsync_ForRoot_ParentIdNull_WorksWithApplicationDefense()
|
||||
{
|
||||
// The DB filtered index only covers non-root rubros (WHERE ParentId IS NOT NULL AND Activo = 1).
|
||||
// Application must check roots via full scan (no unique index guarantee at DB level).
|
||||
await _repository.AddAsync(Rubro.ForCreation("RootCI", null, 0, null, _timeProvider));
|
||||
|
||||
var exists = await _repository.ExistsByNombreUnderParentAsync(null, "RootCI", excludeId: null);
|
||||
|
||||
Assert.True(exists);
|
||||
}
|
||||
|
||||
// ── GetDepthAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetDepthAsync_RootParent_ReturnsZero()
|
||||
{
|
||||
// parentId = null means we're creating a root → depth = 0
|
||||
var depth = await _repository.GetDepthAsync(parentId: null);
|
||||
|
||||
Assert.Equal(0, depth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDepthAsync_3LevelsDeep_ReturnsThree()
|
||||
{
|
||||
// root (depth 0) → child (depth 1) → grandchild (depth 2) → great-grandchild (depth 3)
|
||||
var rootId = await _repository.AddAsync(Rubro.ForCreation("RootDepth", null, 0, null, _timeProvider));
|
||||
var childId = await _repository.AddAsync(Rubro.ForCreation("ChildDepth", rootId, 0, null, _timeProvider));
|
||||
var grandchildId = await _repository.AddAsync(Rubro.ForCreation("GrandchildDepth", childId, 0, null, _timeProvider));
|
||||
|
||||
// Depth of the grandchild's own id as parentId = 3 levels deep (root=1, child=2, grandchild=3)
|
||||
var depth = await _repository.GetDepthAsync(grandchildId);
|
||||
|
||||
Assert.Equal(3, depth);
|
||||
}
|
||||
|
||||
// ── UpdateAsync + Temporal ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ModificaCamposYProduceHistoryRow()
|
||||
{
|
||||
var id = await _repository.AddAsync(Rubro.ForCreation("Original", null, 0, null, _timeProvider));
|
||||
var original = await _repository.GetByIdAsync(id);
|
||||
|
||||
var renamed = original!.WithRenamed("Actualizado", _timeProvider);
|
||||
await _repository.UpdateAsync(renamed);
|
||||
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Actualizado", result!.Nombre);
|
||||
Assert.NotNull(result.FechaModificacion);
|
||||
|
||||
var historyCount = await _db.Connection.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id });
|
||||
|
||||
Assert.True(historyCount >= 1, $"Expected ≥1 history row for Rubro Id={id}, got {historyCount}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SoftDeleteAsync_FlipActivoYActualizaFechaModificacion()
|
||||
{
|
||||
// Deactivate via UpdateAsync (with WithActivo(false)) — repository has no separate SoftDelete
|
||||
var id = await _repository.AddAsync(Rubro.ForCreation("ToDeactivate", null, 0, null, _timeProvider));
|
||||
var rubro = await _repository.GetByIdAsync(id);
|
||||
|
||||
var deactivated = rubro!.WithActivo(false, _timeProvider);
|
||||
await _repository.UpdateAsync(deactivated);
|
||||
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result!.Activo);
|
||||
Assert.NotNull(result.FechaModificacion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveAsync_CambiaParentIdOrdenYFechaModificacion()
|
||||
{
|
||||
var parent1Id = await _repository.AddAsync(Rubro.ForCreation("MoveParent1", null, 0, null, _timeProvider));
|
||||
var parent2Id = await _repository.AddAsync(Rubro.ForCreation("MoveParent2", null, 1, null, _timeProvider));
|
||||
var childId = await _repository.AddAsync(Rubro.ForCreation("MoveChild", parent1Id, 0, null, _timeProvider));
|
||||
|
||||
var child = await _repository.GetByIdAsync(childId);
|
||||
var moved = child!.WithMoved(parent2Id, 5, _timeProvider);
|
||||
await _repository.UpdateAsync(moved);
|
||||
|
||||
var result = await _repository.GetByIdAsync(childId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(parent2Id, result!.ParentId);
|
||||
Assert.Equal(5, result.Orden);
|
||||
Assert.NotNull(result.FechaModificacion);
|
||||
}
|
||||
|
||||
}
|
||||
158
tests/SIGCM2.Application.Tests/Rubros/RubroTreeBuilderTests.cs
Normal file
158
tests/SIGCM2.Application.Tests/Rubros/RubroTreeBuilderTests.cs
Normal file
@@ -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<Rubro>();
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -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<IRubroRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
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<int?>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
|
||||
_handler = new UpdateRubroCommandHandler(_repo, _audit, _timeProvider);
|
||||
}
|
||||
|
||||
// ── Happy path: rename ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_Rename_ReturnsUpdatedDto()
|
||||
{
|
||||
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).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<CancellationToken>()).Returns(ExistingRubro());
|
||||
|
||||
await _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Vehiculos"));
|
||||
|
||||
await _repo.Received(1).UpdateAsync(
|
||||
Arg.Is<Rubro>(r => r.Nombre == "Vehiculos"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_Rename_CallsAuditLog()
|
||||
{
|
||||
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).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<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Not found → RubroNotFoundException ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotFound_ThrowsRubroNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
|
||||
|
||||
var act = () => _handler.Handle(new UpdateRubroCommand(Id: 99, Nombre: "Cualquiera"));
|
||||
|
||||
await act.Should().ThrowAsync<RubroNotFoundException>()
|
||||
.Where(ex => ex.Id == 99);
|
||||
}
|
||||
|
||||
// ── Duplicate name CI under same parent → RubroNombreDuplicadoEnPadreException
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DuplicateNameUnderParent_ThrowsRubroNombreDuplicadoEnPadreException()
|
||||
{
|
||||
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(ExistingRubro());
|
||||
_repo.ExistsByNombreUnderParentAsync(null, "Motos", 3, Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
var act = () => _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Motos"));
|
||||
|
||||
await act.Should().ThrowAsync<RubroNombreDuplicadoEnPadreException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DuplicateName_DoesNotCallAuditLog()
|
||||
{
|
||||
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(ExistingRubro());
|
||||
_repo.ExistsByNombreUnderParentAsync(Arg.Any<int?>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
try { await _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Motos")); } catch { }
|
||||
|
||||
await _audit.DidNotReceive().LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
// V015 (UDT-011): ensure dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local views exist.
|
||||
await EnsureV015SchemaAsync();
|
||||
|
||||
// V016 (CAT-001): ensure dbo.Rubro + temporal + permiso 'catalogo:rubros:gestionar'.
|
||||
await EnsureV016SchemaAsync();
|
||||
|
||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
@@ -81,6 +84,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
// Seed de TipoDeIva e IngresosBrutos son datos de referencia — no limpiar con Respawn.
|
||||
new Respawn.Graph.Table("dbo", "TipoDeIva"),
|
||||
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
|
||||
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
|
||||
new Respawn.Graph.Table("dbo", "Rubro_History"),
|
||||
]
|
||||
});
|
||||
|
||||
@@ -201,7 +206,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
-- V013 (ADM-008): permiso para CRUD de Puntos de Venta
|
||||
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta AFIP','administracion'),
|
||||
-- V014 (ADM-009): permiso para tablas fiscales
|
||||
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion')
|
||||
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'),
|
||||
-- V016 (CAT-001): permiso para gestionar árbol de rubros
|
||||
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
@@ -247,6 +254,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
('admin', 'administracion:puntos_de_venta:gestionar'),
|
||||
-- V014 (ADM-009)
|
||||
('admin', 'administracion:fiscal:gestionar'),
|
||||
-- V016 (CAT-001)
|
||||
('admin', 'catalogo:rubros:gestionar'),
|
||||
('cajero', 'ventas:contado:crear'),
|
||||
('cajero', 'ventas:contado:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
@@ -849,4 +858,79 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
await _connection.ExecuteAsync(createAuditEventLocal);
|
||||
await _connection.ExecuteAsync(createSecurityEventLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CAT-001 (V016): applies dbo.Rubro schema + temporal + filtered unique index + covering index
|
||||
/// idempotentemente. Mirrors V016__create_rubro.sql.
|
||||
/// Nota: COLLATE debe ir ANTES de NOT NULL — parser de SQL Server 2019 es estricto con ese orden.
|
||||
/// Permiso 'catalogo:rubros:gestionar' y asignación a admin se siembran
|
||||
/// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
|
||||
/// </summary>
|
||||
private async Task EnsureV016SchemaAsync()
|
||||
{
|
||||
const string createRubro = """
|
||||
IF OBJECT_ID(N'dbo.Rubro', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Rubro (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rubro PRIMARY KEY,
|
||||
ParentId INT NULL,
|
||||
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||
Orden INT NOT NULL CONSTRAINT DF_Rubro_Orden DEFAULT(0),
|
||||
Activo BIT NOT NULL CONSTRAINT DF_Rubro_Activo DEFAULT(1),
|
||||
TarifarioBaseId INT NULL,
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rubro_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
FechaModificacion DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Rubro_Parent FOREIGN KEY (ParentId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION
|
||||
);
|
||||
END
|
||||
""";
|
||||
|
||||
const string addRubroPeriod = """
|
||||
IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Rubro_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Rubro_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
END
|
||||
""";
|
||||
|
||||
const string setRubroVersioning = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.Rubro_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
END
|
||||
""";
|
||||
|
||||
const string createUqIndex = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Rubro_ParentId_Nombre_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UQ_Rubro_ParentId_Nombre_Activo
|
||||
ON dbo.Rubro(ParentId, Nombre)
|
||||
WHERE Activo = 1 AND ParentId IS NOT NULL;
|
||||
END
|
||||
""";
|
||||
|
||||
const string createCoveringIndex = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Rubro_ParentId_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Rubro_ParentId_Activo
|
||||
ON dbo.Rubro(ParentId, Activo)
|
||||
INCLUDE (Nombre, Orden);
|
||||
END
|
||||
""";
|
||||
|
||||
await _connection.ExecuteAsync(createRubro);
|
||||
await _connection.ExecuteAsync(addRubroPeriod);
|
||||
await _connection.ExecuteAsync(setRubroVersioning);
|
||||
await _connection.ExecuteAsync(createUqIndex);
|
||||
await _connection.ExecuteAsync(createCoveringIndex);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user