Compare commits
26 Commits
f5ed9c4b3c
...
389dda6e5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 389dda6e5e | |||
| bd2febf411 | |||
| 46ef3878de | |||
| 022a36a90c | |||
| f07802f769 | |||
| b22e9fe59a | |||
| 5e2323e0bc | |||
| f8e9d18379 | |||
| d9fc9a2867 | |||
| dcb2e5ada6 | |||
| 9f78425a93 | |||
| 0d50d4f3cc | |||
| 9886524645 | |||
| bcbba2c012 | |||
| 3cb89f80a3 | |||
| 18ce4f6841 | |||
| 8daadc8a77 | |||
| a0dcc7258b | |||
| e5b6c06f64 | |||
| e0b9cba948 | |||
| 03a695feb9 | |||
| e987228f14 | |||
| d4a2b3bc3e | |||
| 50a3c87b14 | |||
| 9957724c40 | |||
| 1cb69cbaf3 |
@@ -29,6 +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_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
|
||||
|
||||
@@ -36,23 +40,24 @@ database/
|
||||
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
|
||||
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
|
||||
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
|
||||
- **Se aplican a AMBAS bases**: `SIGCM2` (dev) y `SIGCM2_Test` (integration tests). El orden debe ser idéntico.
|
||||
- **Se aplican a TRES bases**: `SIGCM2` (dev), `SIGCM2_Test_App` (Application.Tests) y `SIGCM2_Test_Api` (Api.Tests). El orden debe ser idéntico en las tres.
|
||||
|
||||
## Cómo aplicar migraciones
|
||||
|
||||
### En dev (manual)
|
||||
|
||||
```bash
|
||||
# Con sqlcmd:
|
||||
# Con sqlcmd (aplicar a las tres bases en orden):
|
||||
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
sqlcmd -S TECNICA3 -d SIGCM2_Test -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
sqlcmd -S TECNICA3 -d SIGCM2_Test_App -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
sqlcmd -S TECNICA3 -d SIGCM2_Test_Api -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
```
|
||||
|
||||
O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
||||
|
||||
### En integration tests
|
||||
|
||||
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). **NO** hace falta correr el script manualmente.
|
||||
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test_App` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). `TestWebAppFactory` hace lo mismo contra `SIGCM2_Test_Api`. **NO** hace falta correr los scripts manualmente si el fixture ya lo cubre.
|
||||
|
||||
### En producción (roadmap futuro)
|
||||
|
||||
@@ -90,6 +95,22 @@ O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
||||
- `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003.
|
||||
- Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) → `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados.
|
||||
|
||||
## Bases de datos de integration tests
|
||||
|
||||
| Base | Propósito | Usada por |
|
||||
|---|---|---|
|
||||
| `SIGCM2_Test_App` | Tests de repositorios y Application layer | `SIGCM2.Application.Tests` vía `SqlTestFixture` (parameterless ctor) |
|
||||
| `SIGCM2_Test_Api` | Tests de endpoints HTTP / WebApplicationFactory | `SIGCM2.Api.Tests` vía `TestWebAppFactory` |
|
||||
|
||||
**Script de creación inicial** (idempotente): `database/init/create-test-api-db.sql`
|
||||
|
||||
Ambas bases deben tener **todas las migraciones V001–V015** aplicadas en orden. Al crear una base nueva o al agregar un desarrollador:
|
||||
1. Crear las bases con `create-test-api-db.sql`
|
||||
2. Aplicar V001–V015 en orden (ver tabla de arriba) contra cada base de test
|
||||
3. Las `EnsureV0XX` del fixture validan presencia; no aplican migraciones pesadas
|
||||
|
||||
Fuente única de connection strings: `tests/SIGCM2.TestSupport/TestConnectionStrings.cs`
|
||||
|
||||
## Recursos
|
||||
|
||||
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
|
||||
|
||||
30
database/init/create-test-api-db.sql
Normal file
30
database/init/create-test-api-db.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- create-test-api-db.sql
|
||||
-- Creates test databases for integration tests (idempotent).
|
||||
-- Run once per environment on TECNICA3 before executing integration tests.
|
||||
--
|
||||
-- SIGCM2_Test_App -> used by SIGCM2.Application.Tests
|
||||
-- SIGCM2_Test_Api -> used by SIGCM2.Api.Tests
|
||||
-- SIGCM2_Test -> legacy (kept for old branches e.g. pre-merge CAT-001)
|
||||
--
|
||||
-- After creating the DBs, apply V010 to both new DBs:
|
||||
-- See database/README.md > "Test DBs" section for the PowerShell runbook.
|
||||
|
||||
IF DB_ID(N'SIGCM2_Test_App') IS NULL
|
||||
BEGIN
|
||||
CREATE DATABASE [SIGCM2_Test_App]
|
||||
COLLATE Modern_Spanish_CI_AS;
|
||||
PRINT 'Database SIGCM2_Test_App created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Database SIGCM2_Test_App already exists -- skip.';
|
||||
GO
|
||||
|
||||
IF DB_ID(N'SIGCM2_Test_Api') IS NULL
|
||||
BEGIN
|
||||
CREATE DATABASE [SIGCM2_Test_Api]
|
||||
COLLATE Modern_Spanish_CI_AS;
|
||||
PRINT 'Database SIGCM2_Test_Api created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Database SIGCM2_Test_Api already exists -- skip.';
|
||||
GO
|
||||
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 {
|
||||
|
||||
@@ -33,4 +33,5 @@ function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui generated: badgeVariants is intentionally co-located with the component
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -53,4 +53,5 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui generated: buttonVariants is intentionally co-located with the component
|
||||
export { Button, buttonVariants }
|
||||
|
||||
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 }
|
||||
@@ -165,6 +165,7 @@ const FormMessage = React.forwardRef<
|
||||
FormMessage.displayName = 'FormMessage'
|
||||
|
||||
export {
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- shadcn/ui generated: useFormField hook is intentionally co-located with form components
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
|
||||
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 }
|
||||
@@ -14,6 +14,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
@@ -173,6 +174,11 @@ export function IngresosBrutosFormModal({
|
||||
<DialogTitle>
|
||||
{isEdit ? 'Editar Ingresos Brutos' : 'Crear Ingresos Brutos'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Modificá los datos de Ingresos Brutos. La alícuota no puede cambiarse aquí; usá "Nueva vigencia".'
|
||||
: 'Completá los datos para crear un nuevo registro de Ingresos Brutos con su alícuota inicial.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
@@ -139,6 +140,9 @@ export function NuevaVigenciaIibbModal({
|
||||
<TriangleAlert className="h-5 w-5 text-warning" />
|
||||
Nueva vigencia — {item?.provinciaDisplay}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Crea una nueva versión de Ingresos Brutos para esta provincia con una alícuota y fecha de vigencia nuevas. La versión actual quedará cerrada.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
@@ -140,6 +141,9 @@ export function NuevaVigenciaModal({
|
||||
<TriangleAlert className="h-5 w-5 text-warning" />
|
||||
Nueva vigencia — {item?.codigo}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Crea una nueva versión del tipo de IVA con un porcentaje y fecha de vigencia nuevos. La versión actual quedará cerrada.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Banner de advertencia — usa token --warning-bg */}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
@@ -180,6 +181,11 @@ export function TipoDeIvaFormModal({
|
||||
<DialogTitle>
|
||||
{isEdit ? 'Editar tipo de IVA' : 'Crear tipo de IVA'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Modificá los datos del tipo de IVA. El porcentaje no puede cambiarse aquí; usá "Nueva vigencia".'
|
||||
: 'Completá los datos para crear un nuevo tipo de IVA con su alícuota inicial.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
|
||||
@@ -33,6 +33,7 @@ export function RolPermisosEditor({ rolCodigo }: RolPermisosEditorProps) {
|
||||
// Prefill checkboxes cuando lleguen los permisos asignados al rol
|
||||
useEffect(() => {
|
||||
if (asignados) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sincroniza prop externa (asignados) con estado local; patrón válido de derived state
|
||||
setSelected(new Set(asignados.map((p) => p.codigo)))
|
||||
setSaved(false)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export function PermisosEditor({ userId }: PermisosEditorProps) {
|
||||
for (const c of permisoData.overrides.grant) map.set(c, 'concedido')
|
||||
// Apply deny overrides
|
||||
for (const c of permisoData.overrides.deny) map.set(c, 'denegado')
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sincroniza prop externa (permisoData) con mapa local de overrides; patrón válido de derived state
|
||||
setStates(map)
|
||||
setSaveError(null)
|
||||
}, [permisoData])
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CreatedUserDto } from '../api/createUser'
|
||||
export function CreateUserPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- el callback recibe CreatedUserDto por contrato de UserForm pero solo necesitamos navegar
|
||||
function handleSuccess(_created: CreatedUserDto) {
|
||||
void navigate('/')
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ interface FormatInstantOptions {
|
||||
*/
|
||||
export function formatInstant(
|
||||
iso: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- parámetro reservado para futura extensibilidad; el formato está hardcodeado por compatibilidad con entornos donde Intl.DateTimeFormat ignora dateStyle/timeStyle
|
||||
_opts: FormatInstantOptions = { dateStyle: 'short', timeStyle: 'medium' }
|
||||
): string {
|
||||
const parts = new Intl.DateTimeFormat('es-AR', {
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface AuditFiltersValue {
|
||||
to: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- constante de reset co-ubicada con el componente que la consume como valor inicial
|
||||
export const EMPTY_FILTERS: AuditFiltersValue = {
|
||||
actor: '',
|
||||
targetType: '',
|
||||
@@ -137,6 +138,7 @@ export function AuditFilters({
|
||||
* Los convertimos a ISO UTC vía `parseArgentinaDateTimeToUtc()` (fix BUG-FE-05).
|
||||
* - Strings vacíos → omitidos.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- función utilitaria de mapeo co-ubicada con el componente que la necesita; extraerla a otro archivo aumentaría la fragmentación innecesariamente
|
||||
export function toApiFilter(
|
||||
value: AuditFiltersValue,
|
||||
): import('@/api/audit').AuditEventsFilter {
|
||||
|
||||
@@ -67,6 +67,7 @@ export function AuditPage() {
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
if (cursor === undefined) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- acumula datos paginados de una query externa; reset en primera página es intencional
|
||||
setAccumulated(data.items)
|
||||
} else {
|
||||
setAccumulated((prev) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -131,11 +131,8 @@ describe('axiosClient', () => {
|
||||
setAuth('expired-access', 'valid-refresh')
|
||||
|
||||
let refreshCallCount = 0
|
||||
let requestCount = 0
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/protected`, ({ request }) => {
|
||||
requestCount++
|
||||
const auth = request.headers.get('Authorization')
|
||||
if (auth === 'Bearer new-access-from-refresh') {
|
||||
return HttpResponse.json({ data: 'ok' })
|
||||
|
||||
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'] })
|
||||
})
|
||||
})
|
||||
@@ -17,8 +17,7 @@ namespace SIGCM2.Api.Tests.Admin;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class FiscalControllerTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
private const string IvaEndpoint = "/api/v1/admin/fiscal/iva";
|
||||
private const string IibbEndpoint = "/api/v1/admin/fiscal/iibb";
|
||||
|
||||
@@ -15,8 +15,7 @@ namespace SIGCM2.Api.Tests.Admin;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class MediosControllerTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
private const string Endpoint = "/api/v1/admin/medios";
|
||||
private const string AdminUsername = "admin";
|
||||
|
||||
@@ -16,8 +16,7 @@ namespace SIGCM2.Api.Tests.Admin;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class PuntosDeVentaControllerTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
private const string Endpoint = "/api/v1/admin/puntos-de-venta";
|
||||
private const string MediosEndpoint = "/api/v1/admin/medios";
|
||||
|
||||
@@ -15,8 +15,7 @@ namespace SIGCM2.Api.Tests.Admin;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class SeccionesControllerTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
private const string Endpoint = "/api/v1/admin/secciones";
|
||||
private const string MediosEndpoint = "/api/v1/admin/medios";
|
||||
|
||||
@@ -21,8 +21,7 @@ namespace SIGCM2.Api.Tests.Admin;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class V014MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
|
||||
{
|
||||
private const string ConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
public V014MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
|
||||
{
|
||||
|
||||
@@ -21,8 +21,7 @@ namespace SIGCM2.Api.Tests.Admin;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class V015MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
|
||||
{
|
||||
private const string ConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
public V015MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
|
||||
{
|
||||
|
||||
@@ -18,8 +18,7 @@ namespace SIGCM2.Api.Tests.Audit;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class AuditControllerTests : IClassFixture<TestWebAppFactory>
|
||||
{
|
||||
private const string ConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
private readonly TestWebAppFactory _factory;
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ namespace SIGCM2.Api.Tests.Audit;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class TransactionScopeSpikeTests
|
||||
{
|
||||
private const string ConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
[Fact]
|
||||
public async Task TransactionScope_DoesNotEscalateToMSDTC_WithSingleConnectionString()
|
||||
|
||||
@@ -13,8 +13,7 @@ namespace SIGCM2.Api.Tests.Audit;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class V010MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
|
||||
{
|
||||
private const string ConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
public V010MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,8 +15,7 @@ namespace SIGCM2.Api.Tests.Permisos;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
private const string AdminUsername = "admin";
|
||||
private const string AdminPassword = "@Diego550@";
|
||||
@@ -130,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);
|
||||
@@ -140,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]
|
||||
@@ -184,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);
|
||||
@@ -194,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]
|
||||
|
||||
@@ -14,8 +14,7 @@ namespace SIGCM2.Api.Tests.Roles;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class RolesEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
private const string Endpoint = "/api/v1/roles";
|
||||
private const string AdminUsername = "admin";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="SIGCM2.TestSupport" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -14,8 +14,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class ChangeMyPasswordEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
// This hash corresponds to "@Diego550@"
|
||||
private const string DefaultHash = "$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW";
|
||||
|
||||
@@ -17,8 +17,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class CreateUsuarioEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
private const string Endpoint = "/api/v1/users";
|
||||
private const string AdminUsername = "admin";
|
||||
|
||||
@@ -14,8 +14,7 @@ namespace SIGCM2.Api.Tests.Usuarios;
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class DeactivateReactivateEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
|
||||
|
||||
private readonly HttpClient _client;
|
||||
private readonly SqlTestFixture _db;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user