Compare commits

...

9 Commits

Author SHA1 Message Date
f8d861a25a fix(frontend): corregir tipos zodResolver en RubroFormDialog (CAT-001)
- Reemplaza z.union([z.coerce.number(), z.literal('')]) por z.string().transform+pipe para evitar inferencia unknown en zodResolver
- Simplifica RubroFormValues a {nombre: string, tarifarioBaseId?: number | null}
- Actualiza RubrosPage: tarifarioId ya llega como number|null del schema transform
2026-04-18 20:36:12 -03:00
f6733acfbb feat(frontend): rubros feature + CategoryTree + CRUD dialogs (CAT-001)
Co-Authored-By: none
2026-04-18 20:21:11 -03:00
ff7c28789e feat(api): RubrosController + integration tests e2e + audit verification (CAT-001) 2026-04-18 20:05:20 -03:00
cc3108dfdb feat(infrastructure): RubroRepository Dapper + DI + integration tests (CAT-001) 2026-04-18 20:00:51 -03:00
b1be4a5573 fix(tests): propagar Rubro_History + permisos 25 a integration tests (CAT-001)
- SqlTestFixture: agrega EnsureV016SchemaAsync + seed del permiso catalogo:rubros:gestionar + Rubro_History al TablesToIgnore
- 6 test files con Respawner propio: agrega Rubro_History al TablesToIgnore
- 2 tests con count hardcoded (Permiso/RolPermiso): 24 -> 25 + rename
- 3 Api tests con count hardcoded (Auth/Permisos): 24 -> 25 + rename
2026-04-18 19:48:33 -03:00
d4c05cc364 feat(application): Rubros commands/queries + RubroTreeBuilder + audit (CAT-001) 2026-04-18 19:25:35 -03:00
4c9b7eabaf feat(domain): Rubro entity + domain exceptions (CAT-001) 2026-04-18 19:17:33 -03:00
4a88cb4319 fix(bd): V016 COLLATE order — SQL Server requiere COLLATE antes de NOT NULL (CAT-001) 2026-04-18 19:10:03 -03:00
d3ed8300f0 feat(bd): V016 create Rubro table con SYSTEM_VERSIONING (CAT-001)
- dbo.Rubro: adjacency list, self-FK, soft-delete, temporal retention 10y
- Filtered unique index UQ_Rubro_ParentId_Nombre_Activo + covering IX_Rubro_ParentId_Activo
- Permission catalogo:rubros:gestionar seeded + assigned to admin role
- V016_ROLLBACK.sql: full reversal script
- RubrosOptions class (MaxDepth=10) + appsettings.json Rubros section
- services.Configure<RubrosOptions> registered in Infrastructure DI
- database/README.md updated with V013-V016 entries
2026-04-18 19:04:24 -03:00
87 changed files with 5668 additions and 15 deletions

View File

@@ -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

View 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

View 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

View 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);

View File

@@ -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

View File

@@ -32,5 +32,8 @@
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
},
"Rubros": {
"MaxDepth": 10
},
"AllowedHosts": "*"
}

View File

@@ -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);
}

View File

@@ -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>();

View 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();
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Rubros.Create;
public sealed record CreateRubroCommand(
string Nombre,
int? ParentId,
int? TarifarioBaseId);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Rubros.Deactivate;
public sealed record DeactivateRubroCommand(int Id);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Rubros.Deactivate;
public sealed record RubroStatusDto(int Id, bool Activo);

View 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);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Rubros.GetById;
public sealed record GetRubroByIdQuery(int Id);

View File

@@ -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);
}
}

View 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);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Rubros.GetTree;
public sealed record GetRubroTreeQuery(bool IncluirInactivos);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Rubros.Move;
public sealed record MoveRubroCommand(int Id, int? NuevoParentId, int NuevoOrden);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,8 @@
namespace SIGCM2.Application.Rubros.Move;
public sealed record RubroMovedDto(
int Id,
string Nombre,
int? ParentId,
int Orden,
bool Activo);

View 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;
}

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Rubros.Update;
public sealed record UpdateRubroCommand(int Id, string Nombre);

View File

@@ -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);
}
}

View 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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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>();

View 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);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 {

View 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 }

View 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 }

View 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
}

View 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}`)
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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 &ldquo;{rubro.nombre}&rdquo;? 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>
)
}

View File

@@ -0,0 +1,174 @@
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,
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>
</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>
)
}

View 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'] })
},
})
}

View 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'] })
},
})
}

View 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'] })
},
})
}

View 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,
})
}

View 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'] })
},
})
}

View 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'

View File

@@ -0,0 +1,185 @@
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 { 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 { 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={() => {}}
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}
/>
)}
</div>
)
}

View 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
}

View File

@@ -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>
)

View 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()
})
})

View 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(),
)
})
})

View 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)
})
})

View 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()
})
})
})

View 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'] })
})
})

View File

@@ -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

View File

@@ -130,7 +130,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 +140,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 +185,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 +195,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]

View File

@@ -0,0 +1,670 @@
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 =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
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);
}
}
}

View File

@@ -0,0 +1,137 @@
using FluentAssertions;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Domain.Rubros;
public class RubroExceptionsTests
{
// ── RubroNotFoundException ───────────────────────────────────────────────
[Fact]
public void RubroNotFoundException_ContainsId()
{
var ex = new RubroNotFoundException(42);
ex.Id.Should().Be(42);
ex.Message.Should().Contain("42");
}
[Fact]
public void RubroNotFoundException_InheritsFromDomainException()
{
var ex = new RubroNotFoundException(1);
ex.Should().BeAssignableTo<DomainException>();
}
// ── RubroNombreDuplicadoEnPadreException ─────────────────────────────────
[Fact]
public void RubroNombreDuplicadoEnPadreException_ContainsNombreAndParentId()
{
var ex = new RubroNombreDuplicadoEnPadreException("Autos", parentId: 5);
ex.Nombre.Should().Be("Autos");
ex.ParentId.Should().Be(5);
ex.Message.Should().Contain("Autos");
}
[Fact]
public void RubroNombreDuplicadoEnPadreException_WithNullParent_IsValid()
{
var ex = new RubroNombreDuplicadoEnPadreException("Autos", parentId: null);
ex.Nombre.Should().Be("Autos");
ex.ParentId.Should().BeNull();
}
[Fact]
public void RubroNombreDuplicadoEnPadreException_InheritsFromDomainException()
{
var ex = new RubroNombreDuplicadoEnPadreException("x", null);
ex.Should().BeAssignableTo<DomainException>();
}
// ── RubroMaxDepthExceededException ───────────────────────────────────────
[Fact]
public void RubroMaxDepthExceededException_ContainsDepthInfo()
{
var ex = new RubroMaxDepthExceededException(intentada: 11, max: 10);
ex.Intentada.Should().Be(11);
ex.Max.Should().Be(10);
ex.Message.Should().Contain("11");
ex.Message.Should().Contain("10");
}
[Fact]
public void RubroMaxDepthExceededException_InheritsFromDomainException()
{
var ex = new RubroMaxDepthExceededException(11, 10);
ex.Should().BeAssignableTo<DomainException>();
}
// ── RubroCycleDetectedException ──────────────────────────────────────────
[Fact]
public void RubroCycleDetectedException_ContainsRubroIdAndIntendedParentId()
{
var ex = new RubroCycleDetectedException(rubroId: 5, nuevoParentId: 10);
ex.RubroId.Should().Be(5);
ex.NuevoParentId.Should().Be(10);
ex.Message.Should().Contain("5");
ex.Message.Should().Contain("10");
}
[Fact]
public void RubroCycleDetectedException_InheritsFromDomainException()
{
var ex = new RubroCycleDetectedException(1, 2);
ex.Should().BeAssignableTo<DomainException>();
}
// ── RubroTieneHijosActivosException ─────────────────────────────────────
[Fact]
public void RubroTieneHijosActivosException_ContainsIdAndCount()
{
var ex = new RubroTieneHijosActivosException(id: 7, count: 3);
ex.Id.Should().Be(7);
ex.Count.Should().Be(3);
ex.Message.Should().Contain("3");
ex.Message.Should().Contain("subrubros");
}
[Fact]
public void RubroTieneHijosActivosException_InheritsFromDomainException()
{
var ex = new RubroTieneHijosActivosException(1, 2);
ex.Should().BeAssignableTo<DomainException>();
}
// ── RubroPadreInactivoException ──────────────────────────────────────────
[Fact]
public void RubroPadreInactivoException_ContainsParentId()
{
var ex = new RubroPadreInactivoException(parentId: 9);
ex.ParentId.Should().Be(9);
ex.Message.Should().Contain("9");
}
[Fact]
public void RubroPadreInactivoException_InheritsFromDomainException()
{
var ex = new RubroPadreInactivoException(1);
ex.Should().BeAssignableTo<DomainException>();
}
}

View File

@@ -0,0 +1,171 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Domain.Rubros;
public class RubroTests
{
private static readonly FakeTimeProvider FakeTime = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
// ── ForCreation: happy path ──────────────────────────────────────────────
[Fact]
public void Create_con_datos_validos_crea_rubro_activo_con_orden_cero_como_default()
{
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
rubro.Nombre.Should().Be("Autos");
rubro.ParentId.Should().BeNull();
rubro.Orden.Should().Be(0);
rubro.Activo.Should().BeTrue();
rubro.TarifarioBaseId.Should().BeNull();
rubro.Id.Should().Be(0);
rubro.FechaCreacion.Should().Be(FakeTime.GetUtcNow().UtcDateTime);
rubro.FechaModificacion.Should().BeNull();
}
[Fact]
public void Create_root_con_parentId_null_es_valido()
{
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
rubro.ParentId.Should().BeNull();
}
// ── ForCreation: validations ─────────────────────────────────────────────
[Fact]
public void Create_con_nombre_vacio_lanza_ArgumentException()
{
var act = () => Rubro.ForCreation("", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Create_con_nombre_solo_whitespace_lanza_ArgumentException()
{
var act = () => Rubro.ForCreation(" ", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Create_con_nombre_excediendo_200_chars_lanza_ArgumentException()
{
var nombre = new string('A', 201);
var act = () => Rubro.ForCreation(nombre, parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Create_con_parentId_menor_o_igual_a_cero_lanza_ArgumentException()
{
var act = () => Rubro.ForCreation("Autos", parentId: 0, orden: 0, tarifarioBaseId: null, FakeTime);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Create_con_tarifarioBaseId_menor_a_cero_lanza_ArgumentException()
{
var act = () => Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: -1, FakeTime);
act.Should().Throw<ArgumentException>();
}
// ── WithRenamed ──────────────────────────────────────────────────────────
[Fact]
public void Rename_con_nombre_valido_devuelve_nueva_instancia_con_FechaModificacion()
{
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
var laterTime = new FakeTimeProvider(new DateTimeOffset(2026, 4, 18, 14, 0, 0, TimeSpan.Zero));
var renamed = rubro.WithRenamed("Vehiculos", laterTime);
renamed.Nombre.Should().Be("Vehiculos");
renamed.FechaModificacion.Should().Be(laterTime.GetUtcNow().UtcDateTime);
}
[Fact]
public void Rename_con_nombre_invalido_lanza_ArgumentException()
{
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
var act = () => rubro.WithRenamed("", FakeTime);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Rename_no_muta_la_instancia_original()
{
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
rubro.WithRenamed("Vehiculos", FakeTime);
rubro.Nombre.Should().Be("Autos");
}
// ── WithMoved ────────────────────────────────────────────────────────────
[Fact]
public void Move_a_nuevo_parent_devuelve_nueva_instancia_con_parentId_actualizado()
{
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
var moved = rubro.WithMoved(nuevoParentId: 5, nuevoOrden: 2, FakeTime);
moved.ParentId.Should().Be(5);
moved.Orden.Should().Be(2);
}
[Fact]
public void Move_a_root_permite_parentId_null()
{
var rubro = Rubro.ForCreation("Autos", parentId: 3, orden: 0, tarifarioBaseId: null, FakeTime);
var moved = rubro.WithMoved(nuevoParentId: null, nuevoOrden: 0, FakeTime);
moved.ParentId.Should().BeNull();
}
[Fact]
public void Move_no_muta_la_instancia_original()
{
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
rubro.WithMoved(nuevoParentId: 5, nuevoOrden: 1, FakeTime);
rubro.ParentId.Should().BeNull();
rubro.Orden.Should().Be(0);
}
// ── WithActivo (Deactivate / Reactivate) ────────────────────────────────
[Fact]
public void Deactivate_flip_Activo_a_false()
{
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
var deactivated = rubro.WithActivo(false, FakeTime);
deactivated.Activo.Should().BeFalse();
deactivated.FechaModificacion.Should().Be(FakeTime.GetUtcNow().UtcDateTime);
}
[Fact]
public void Reactivate_flip_Activo_a_true()
{
var rubro = Rubro.ForCreation("Autos", parentId: null, orden: 0, tarifarioBaseId: null, FakeTime);
var deactivated = rubro.WithActivo(false, FakeTime);
var reactivated = deactivated.WithActivo(true, FakeTime);
reactivated.Activo.Should().BeTrue();
}
}

View File

@@ -51,6 +51,8 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});

View File

@@ -74,15 +74,16 @@ public class PermisoRepositoryTests : IAsyncLifetime
// ── ListAsync ────────────────────────────────────────────────────────────
[Fact]
public async Task ListAsync_Returns23CanonicalSeeds()
public async Task ListAsync_Returns25CanonicalSeeds()
{
var list = await _repository.ListAsync();
// V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar'
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar' = 24 total
Assert.Equal(24, list.Count);
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar' = 25 total
Assert.Equal(25, list.Count);
}
[Fact]

View File

@@ -174,15 +174,16 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
[Fact]
public async Task GetByRolCodigoAsync_Admin_Returns23Permisos()
public async Task GetByRolCodigoAsync_Admin_Returns25Permisos()
{
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar'
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar' = 24 total
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar' = 25 total
var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(24, permisos.Count);
Assert.Equal(25, permisos.Count);
}
[Fact]

View File

@@ -43,6 +43,8 @@ public class UsuarioRepositoryTests : IAsyncLifetime
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});

View File

@@ -47,6 +47,8 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});

View File

@@ -46,6 +46,8 @@ public sealed class V009MigrationTests : IAsyncLifetime
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});

View File

@@ -49,6 +49,8 @@ public class MedioRepositoryTests : IAsyncLifetime
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});

View File

@@ -0,0 +1,176 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Rubros;
using SIGCM2.Application.Rubros.Create;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Rubros.Create;
public class CreateRubroCommandHandlerTests
{
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
private readonly IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
private readonly CreateRubroCommandHandler _handler;
public CreateRubroCommandHandlerTests()
{
_repo.ExistsByNombreUnderParentAsync(Arg.Any<int?>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(false);
_repo.GetMaxOrdenAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(0);
_repo.AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>())
.Returns(1);
_repo.GetDepthAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(0);
_handler = new CreateRubroCommandHandler(_repo, _audit, _timeProvider, _options);
}
private static CreateRubroCommand RootCommand() => new("Autos", ParentId: null, TarifarioBaseId: null);
private static CreateRubroCommand ChildCommand(int parentId) => new("Sedanes", ParentId: parentId, TarifarioBaseId: null);
// ── Happy path: root ─────────────────────────────────────────────────────
[Fact]
public async Task Handle_HappyPath_Root_ReturnsIdFromRepository()
{
_repo.AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>()).Returns(42);
var result = await _handler.Handle(RootCommand());
result.Id.Should().Be(42);
}
[Fact]
public async Task Handle_HappyPath_Root_CallsAddAsync()
{
await _handler.Handle(RootCommand());
await _repo.Received(1).AddAsync(
Arg.Is<Rubro>(r => r.Nombre == "Autos" && r.ParentId == null),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_HappyPath_Root_CallsAuditLog()
{
_repo.AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>()).Returns(7);
await _handler.Handle(RootCommand());
await _audit.Received(1).LogAsync(
action: "rubro.created",
targetType: "Rubro",
targetId: "7",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
// ── Happy path: child — uses GetMaxOrdenAsync ────────────────────────────
[Fact]
public async Task Handle_HappyPath_Child_UsesMaxOrdenForOrden()
{
// GetMaxOrdenAsync returns the next available slot (MAX+1 semantics in the repo)
_repo.GetMaxOrdenAsync((int?)5, Arg.Any<CancellationToken>()).Returns(3);
var parent = new Rubro(5, null, "ParentRubro", 0, activo: true, tarifarioBaseId: null,
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(parent);
await _handler.Handle(ChildCommand(parentId: 5));
await _repo.Received(1).AddAsync(
Arg.Is<Rubro>(r => r.Orden == 3),
Arg.Any<CancellationToken>());
}
// ── Parent not found → RubroNotFoundException ────────────────────────────
[Fact]
public async Task Handle_ParentNotFound_ThrowsRubroNotFoundException()
{
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
var act = () => _handler.Handle(ChildCommand(parentId: 999));
await act.Should().ThrowAsync<RubroNotFoundException>()
.Where(ex => ex.Id == 999);
}
[Fact]
public async Task Handle_ParentNotFound_DoesNotCallAddAsync()
{
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
try { await _handler.Handle(ChildCommand(parentId: 999)); } catch { }
await _repo.DidNotReceive().AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>());
}
// ── Parent inactive → RubroPadreInactivoException ───────────────────────
[Fact]
public async Task Handle_ParentInactive_ThrowsRubroPadreInactivoException()
{
var inactiveParent = new Rubro(7, null, "InactivoParent", 0, activo: false, tarifarioBaseId: null,
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
_repo.GetByIdAsync(7, Arg.Any<CancellationToken>()).Returns(inactiveParent);
var act = () => _handler.Handle(ChildCommand(parentId: 7));
await act.Should().ThrowAsync<RubroPadreInactivoException>()
.Where(ex => ex.ParentId == 7);
}
// ── Duplicate name (CI) → RubroNombreDuplicadoEnPadreException ──────────
[Fact]
public async Task Handle_DuplicateName_ThrowsRubroNombreDuplicadoEnPadreException()
{
_repo.ExistsByNombreUnderParentAsync(null, "Autos", null, Arg.Any<CancellationToken>())
.Returns(true);
var act = () => _handler.Handle(RootCommand());
await act.Should().ThrowAsync<RubroNombreDuplicadoEnPadreException>();
}
// ── Depth exceeded → RubroMaxDepthExceededException ─────────────────────
[Fact]
public async Task Handle_DepthExceeded_ThrowsRubroMaxDepthExceededException()
{
// MaxDepth=10, parent is at depth 10 → creating child would be depth 11
_repo.GetDepthAsync(5, Arg.Any<CancellationToken>()).Returns(10);
var parent = new Rubro(5, null, "DeepParent", 0, activo: true, tarifarioBaseId: null,
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(parent);
var act = () => _handler.Handle(ChildCommand(parentId: 5));
await act.Should().ThrowAsync<RubroMaxDepthExceededException>();
}
[Fact]
public async Task Handle_DepthAtMaxAllowed_Succeeds()
{
// MaxDepth=10, parent at depth 9 → child at depth 10 is allowed
_repo.GetDepthAsync(5, Arg.Any<CancellationToken>()).Returns(9);
var parent = new Rubro(5, null, "DeepParent", 0, activo: true, tarifarioBaseId: null,
fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(parent);
_repo.AddAsync(Arg.Any<Rubro>(), Arg.Any<CancellationToken>()).Returns(99);
var result = await _handler.Handle(ChildCommand(parentId: 5));
result.Id.Should().Be(99);
}
}

View File

@@ -0,0 +1,106 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Rubros.Deactivate;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Rubros.Deactivate;
public class DeactivateRubroCommandHandlerTests
{
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
private readonly DeactivateRubroCommandHandler _handler;
private static Rubro LeafRubro(int id = 10) => new(id, null, "Autos", 0, activo: true,
tarifarioBaseId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
public DeactivateRubroCommandHandlerTests()
{
_repo.CountActiveChildrenAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(0);
_handler = new DeactivateRubroCommandHandler(_repo, _audit, _timeProvider);
}
// ── Happy path: leaf soft-delete ─────────────────────────────────────────
[Fact]
public async Task Handle_LeafRubro_SoftDeletes()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(LeafRubro());
var result = await _handler.Handle(new DeactivateRubroCommand(Id: 10));
result.Id.Should().Be(10);
result.Activo.Should().BeFalse();
}
[Fact]
public async Task Handle_LeafRubro_CallsUpdateAsync()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(LeafRubro());
await _handler.Handle(new DeactivateRubroCommand(Id: 10));
await _repo.Received(1).UpdateAsync(
Arg.Is<Rubro>(r => r.Id == 10 && !r.Activo),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_LeafRubro_CallsAuditLog()
{
_repo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(LeafRubro());
await _handler.Handle(new DeactivateRubroCommand(Id: 10));
await _audit.Received(1).LogAsync(
action: "rubro.deleted",
targetType: "Rubro",
targetId: "10",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
// ── Has active children → RubroTieneHijosActivosException ───────────────
[Fact]
public async Task Handle_HasActiveChildren_ThrowsRubroTieneHijosActivosException()
{
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(LeafRubro(5));
_repo.CountActiveChildrenAsync(5, Arg.Any<CancellationToken>()).Returns(3);
var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 5));
await act.Should().ThrowAsync<RubroTieneHijosActivosException>()
.Where(ex => ex.Id == 5 && ex.Count == 3);
}
[Fact]
public async Task Handle_HasActiveChildren_DoesNotCallAuditLog()
{
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(LeafRubro(5));
_repo.CountActiveChildrenAsync(5, Arg.Any<CancellationToken>()).Returns(1);
try { await _handler.Handle(new DeactivateRubroCommand(Id: 5)); } catch { }
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
// ── Not found → RubroNotFoundException ──────────────────────────────────
[Fact]
public async Task Handle_NotFound_ThrowsRubroNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
var act = () => _handler.Handle(new DeactivateRubroCommand(Id: 99));
await act.Should().ThrowAsync<RubroNotFoundException>();
}
}

View File

@@ -0,0 +1,83 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Rubros.GetById;
using SIGCM2.Application.Rubros.GetTree;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Rubros.GetTree;
public class GetRubroTreeQueryHandlerTests
{
private static readonly FakeTimeProvider FakeTime = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true)
=> new(id, parentId, $"Rubro{id}", 0, activo, null, FakeTime.GetUtcNow().UtcDateTime, null);
// ── GetRubroTreeQueryHandler ─────────────────────────────────────────────
[Fact]
public async Task GetTree_OnlyActivos_ByDefault_ReturnsActiveTree()
{
_repo.GetAllAsync(false, Arg.Any<CancellationToken>())
.Returns(new[] { MakeRubro(1), MakeRubro(2) });
var handler = new GetRubroTreeQueryHandler(_repo);
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false));
result.Should().HaveCount(2);
}
[Fact]
public async Task GetTree_IncluirInactivos_CallsRepoWithTrue()
{
_repo.GetAllAsync(true, Arg.Any<CancellationToken>())
.Returns(new[] { MakeRubro(1), MakeRubro(2, activo: false) });
var handler = new GetRubroTreeQueryHandler(_repo);
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: true));
await _repo.Received(1).GetAllAsync(true, Arg.Any<CancellationToken>());
result.Should().HaveCount(2); // both roots
}
[Fact]
public async Task GetTree_Empty_ReturnsEmptyList()
{
_repo.GetAllAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<Rubro>());
var handler = new GetRubroTreeQueryHandler(_repo);
var result = await handler.Handle(new GetRubroTreeQuery(IncluirInactivos: false));
result.Should().BeEmpty();
}
// ── GetRubroByIdQueryHandler ─────────────────────────────────────────────
[Fact]
public async Task GetById_Found_Active_ReturnsDto()
{
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(MakeRubro(5));
var handler = new GetRubroByIdQueryHandler(_repo);
var result = await handler.Handle(new GetRubroByIdQuery(Id: 5));
result.Should().NotBeNull();
result!.Id.Should().Be(5);
}
[Fact]
public async Task GetById_NotFound_ThrowsRubroNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
var handler = new GetRubroByIdQueryHandler(_repo);
var act = () => handler.Handle(new GetRubroByIdQuery(Id: 99));
await act.Should().ThrowAsync<RubroNotFoundException>();
}
}

View File

@@ -0,0 +1,176 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Rubros;
using SIGCM2.Application.Rubros.Move;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Rubros.Move;
public class MoveRubroCommandHandlerTests
{
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
private readonly IOptions<RubrosOptions> _options = Options.Create(new RubrosOptions { MaxDepth = 10 });
private readonly MoveRubroCommandHandler _handler;
private static Rubro MakeRubro(int id, int? parentId = null, bool activo = true)
=> new(id, parentId, $"Rubro{id}", 0, activo, null, DateTime.UtcNow, null);
public MoveRubroCommandHandlerTests()
{
_repo.GetDescendantsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<Rubro>());
_repo.ExistsByNombreUnderParentAsync(Arg.Any<int?>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(false);
_repo.GetDepthAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(0);
_repo.GetMaxOrdenAsync(Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(0);
_handler = new MoveRubroCommandHandler(_repo, _audit, _timeProvider, _options);
}
// ── Happy path: move to other parent ────────────────────────────────────
[Fact]
public async Task Handle_HappyPath_Move_ReturnsMovedDto()
{
var rubro = MakeRubro(8, parentId: 2);
var newParent = MakeRubro(20, parentId: 1);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(newParent);
var result = await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
result.Id.Should().Be(8);
result.ParentId.Should().Be(20);
}
[Fact]
public async Task Handle_HappyPath_Move_CallsAuditLogWithParentTransition()
{
var rubro = MakeRubro(8, parentId: 2);
var newParent = MakeRubro(20, parentId: 1);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(newParent);
await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
await _audit.Received(1).LogAsync(
action: "rubro.moved",
targetType: "Rubro",
targetId: "8",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
// ── Move to root (nuevoParentId null) ─────────────────────────────────
[Fact]
public async Task Handle_MoveToRoot_SetsParentIdNull()
{
var rubro = MakeRubro(8, parentId: 3);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
var result = await _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: null, NuevoOrden: 5));
result.ParentId.Should().BeNull();
result.Orden.Should().Be(5);
}
// ── Rubro not found → RubroNotFoundException ─────────────────────────
[Fact]
public async Task Handle_RubroNotFound_ThrowsRubroNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
var act = () => _handler.Handle(new MoveRubroCommand(Id: 99, NuevoParentId: 1, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroNotFoundException>();
}
// ── Cycle detection ───────────────────────────────────────────────────
[Fact]
public async Task Handle_DirectChildAsNewParent_ThrowsRubroCycleDetectedException()
{
var rubro = MakeRubro(5, parentId: null);
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(rubro);
// Descendant id=10 would be the new parent
_repo.GetDescendantsAsync(5, Arg.Any<CancellationToken>())
.Returns(new[] { MakeRubro(10, parentId: 5) });
var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: 10, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroCycleDetectedException>()
.Where(ex => ex.RubroId == 5 && ex.NuevoParentId == 10);
}
[Fact]
public async Task Handle_DeepDescendantAsNewParent_ThrowsRubroCycleDetectedException()
{
var rubro = MakeRubro(5, parentId: null);
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetDescendantsAsync(5, Arg.Any<CancellationToken>())
.Returns(new[] { MakeRubro(10, 5), MakeRubro(15, 10) });
var act = () => _handler.Handle(new MoveRubroCommand(Id: 5, NuevoParentId: 15, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroCycleDetectedException>();
}
// ── New parent inactive → RubroPadreInactivoException ─────────────────
[Fact]
public async Task Handle_NewParentInactive_ThrowsRubroPadreInactivoException()
{
var rubro = MakeRubro(8, parentId: 2);
var inactiveParent = MakeRubro(20, activo: false);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(inactiveParent);
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroPadreInactivoException>();
}
// ── Duplicate name under new parent ────────────────────────────────────
[Fact]
public async Task Handle_DuplicateNameUnderNewParent_ThrowsRubroNombreDuplicadoEnPadreException()
{
var rubro = MakeRubro(8, parentId: 2);
var newParent = MakeRubro(20);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(newParent);
_repo.ExistsByNombreUnderParentAsync((int?)20, rubro.Nombre, 8, Arg.Any<CancellationToken>())
.Returns(true);
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroNombreDuplicadoEnPadreException>();
}
// ── Depth exceeded ─────────────────────────────────────────────────────
[Fact]
public async Task Handle_DepthExceeded_ThrowsRubroMaxDepthExceededException()
{
var rubro = MakeRubro(8, parentId: 2);
var newParent = MakeRubro(20);
_repo.GetByIdAsync(8, Arg.Any<CancellationToken>()).Returns(rubro);
_repo.GetByIdAsync(20, Arg.Any<CancellationToken>()).Returns(newParent);
_repo.GetDepthAsync((int?)20, Arg.Any<CancellationToken>()).Returns(10); // at MaxDepth
var act = () => _handler.Handle(new MoveRubroCommand(Id: 8, NuevoParentId: 20, NuevoOrden: 0));
await act.Should().ThrowAsync<RubroMaxDepthExceededException>();
}
}

View File

@@ -0,0 +1,450 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Application.Tests.Rubros;
/// <summary>
/// Integration tests for RubroRepository against SIGCM2_Test.
/// TDD: RED written before implementation, GREEN after RubroRepository was created.
/// Temporal: after UpdateAsync, dbo.Rubro_History MUST have ≥1 row for that Id.
/// </summary>
[Collection("Database")]
public class RubroRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private RubroRepository _repository = null!;
private TimeProvider _timeProvider = null!;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
// ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted.
new Respawn.Graph.Table("dbo", "Medio_History"),
new Respawn.Graph.Table("dbo", "Seccion_History"),
// ADM-008 (V013): PuntoDeVenta is temporal.
new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"),
// ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales.
new Respawn.Graph.Table("dbo", "TipoDeIva_History"),
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});
await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
var factory = new SqlConnectionFactory(ConnectionString);
_repository = new RubroRepository(factory);
_timeProvider = TimeProvider.System;
}
public async Task DisposeAsync()
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
// ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
[Fact]
public async Task AddAsync_AndGetById_ReturnsAllFields()
{
var rubro = Rubro.ForCreation("Automotores", parentId: null, orden: 0, tarifarioBaseId: null, _timeProvider);
var id = await _repository.AddAsync(rubro);
var result = await _repository.GetByIdAsync(id);
Assert.NotNull(result);
Assert.Equal(id, result!.Id);
Assert.Equal("Automotores", result.Nombre);
Assert.Null(result.ParentId);
Assert.Equal(0, result.Orden);
Assert.True(result.Activo);
Assert.Null(result.TarifarioBaseId);
Assert.True(result.FechaCreacion > DateTime.MinValue);
Assert.Null(result.FechaModificacion);
}
[Fact]
public async Task AddAsync_WithTarifarioBaseId_PersistsValue()
{
var rubro = Rubro.ForCreation("Tecnología", parentId: null, orden: 0, tarifarioBaseId: 42, _timeProvider);
var id = await _repository.AddAsync(rubro);
var result = await _repository.GetByIdAsync(id);
Assert.NotNull(result);
Assert.Equal(42, result!.TarifarioBaseId);
}
[Fact]
public async Task GetByIdAsync_NonExistent_ReturnsNull()
{
var result = await _repository.GetByIdAsync(999999);
Assert.Null(result);
}
// ── GetAllAsync ────────────────────────────────────────────────────────────
[Fact]
public async Task GetAllAsync_IncluirInactivosFalse_OmitsInactive()
{
var activo = Rubro.ForCreation("Activo", null, 0, null, _timeProvider);
var inactivo = Rubro.ForCreation("Inactivo", null, 1, null, _timeProvider);
await _repository.AddAsync(activo);
var inactivoId = await _repository.AddAsync(inactivo);
// Deactivate the second one via UpdateAsync
var inactivoEntity = await _repository.GetByIdAsync(inactivoId);
await _repository.UpdateAsync(inactivoEntity!.WithActivo(false, _timeProvider));
var all = await _repository.GetAllAsync(incluirInactivos: false);
Assert.Contains(all, r => r.Nombre == "Activo");
Assert.DoesNotContain(all, r => r.Nombre == "Inactivo");
}
[Fact]
public async Task GetAllAsync_IncluirInactivosTrue_ReturnsAll()
{
var activo = Rubro.ForCreation("ActivoAll", null, 0, null, _timeProvider);
var inactivo = Rubro.ForCreation("InactivoAll", null, 1, null, _timeProvider);
await _repository.AddAsync(activo);
var inactivoId = await _repository.AddAsync(inactivo);
var inactivoEntity = await _repository.GetByIdAsync(inactivoId);
await _repository.UpdateAsync(inactivoEntity!.WithActivo(false, _timeProvider));
var all = await _repository.GetAllAsync(incluirInactivos: true);
Assert.Contains(all, r => r.Nombre == "ActivoAll");
Assert.Contains(all, r => r.Nombre == "InactivoAll");
}
// ── GetDescendantsAsync ────────────────────────────────────────────────────
[Fact]
public async Task GetDescendantsAsync_3LevelsDeep_ReturnsAllDescendants()
{
// Level 0: root
var root = Rubro.ForCreation("Root", null, 0, null, _timeProvider);
var rootId = await _repository.AddAsync(root);
// Level 1: child of root
var child = Rubro.ForCreation("Child", rootId, 0, null, _timeProvider);
var childId = await _repository.AddAsync(child);
// Level 2: grandchild of root
var grandchild = Rubro.ForCreation("Grandchild", childId, 0, null, _timeProvider);
var grandchildId = await _repository.AddAsync(grandchild);
var descendants = await _repository.GetDescendantsAsync(rootId);
Assert.Equal(2, descendants.Count);
Assert.Contains(descendants, d => d.Id == childId);
Assert.Contains(descendants, d => d.Id == grandchildId);
}
[Fact]
public async Task GetDescendantsAsync_Leaf_ReturnsEmpty()
{
var leaf = Rubro.ForCreation("Leaf", null, 0, null, _timeProvider);
var leafId = await _repository.AddAsync(leaf);
var descendants = await _repository.GetDescendantsAsync(leafId);
Assert.Empty(descendants);
}
// ── CountActiveChildrenAsync ───────────────────────────────────────────────
[Fact]
public async Task CountActiveChildrenAsync_NoChildren_ReturnsZero()
{
var parent = Rubro.ForCreation("ParentNoHijos", null, 0, null, _timeProvider);
var parentId = await _repository.AddAsync(parent);
var count = await _repository.CountActiveChildrenAsync(parentId);
Assert.Equal(0, count);
}
[Fact]
public async Task CountActiveChildrenAsync_TwoActive_ReturnsTwo()
{
var parent = Rubro.ForCreation("ParentDosHijos", null, 0, null, _timeProvider);
var parentId = await _repository.AddAsync(parent);
await _repository.AddAsync(Rubro.ForCreation("Hijo1", parentId, 0, null, _timeProvider));
await _repository.AddAsync(Rubro.ForCreation("Hijo2", parentId, 1, null, _timeProvider));
var count = await _repository.CountActiveChildrenAsync(parentId);
Assert.Equal(2, count);
}
[Fact]
public async Task CountActiveChildrenAsync_ActiveAndInactive_CountsOnlyActive()
{
var parent = Rubro.ForCreation("ParentMixed", null, 0, null, _timeProvider);
var parentId = await _repository.AddAsync(parent);
await _repository.AddAsync(Rubro.ForCreation("HijoActivo", parentId, 0, null, _timeProvider));
var inactivoId = await _repository.AddAsync(Rubro.ForCreation("HijoInactivo", parentId, 1, null, _timeProvider));
var inactivo = await _repository.GetByIdAsync(inactivoId);
await _repository.UpdateAsync(inactivo!.WithActivo(false, _timeProvider));
var count = await _repository.CountActiveChildrenAsync(parentId);
Assert.Equal(1, count);
}
// ── GetMaxOrdenAsync ───────────────────────────────────────────────────────
[Fact]
public async Task GetMaxOrdenAsync_Empty_ReturnsZero()
{
// No siblings → first slot is 0
var orden = await _repository.GetMaxOrdenAsync(parentId: null);
Assert.Equal(0, orden);
}
[Fact]
public async Task GetMaxOrdenAsync_ThreeSiblings_ReturnsThree()
{
// Orden = [0, 1, 2] → next slot = 3 (MAX+1)
var parent = Rubro.ForCreation("ParentOrden", null, 0, null, _timeProvider);
var parentId = await _repository.AddAsync(parent);
await _repository.AddAsync(Rubro.ForCreation("S1", parentId, 0, null, _timeProvider));
await _repository.AddAsync(Rubro.ForCreation("S2", parentId, 1, null, _timeProvider));
await _repository.AddAsync(Rubro.ForCreation("S3", parentId, 2, null, _timeProvider));
var orden = await _repository.GetMaxOrdenAsync(parentId);
Assert.Equal(3, orden);
}
[Fact]
public async Task GetMaxOrdenAsync_ParentIdNull_WorksForRoots()
{
// Insert one root with Orden=0
await _repository.AddAsync(Rubro.ForCreation("RootOrden1", null, 0, null, _timeProvider));
var orden = await _repository.GetMaxOrdenAsync(parentId: null);
Assert.Equal(1, orden); // MAX(0) + 1 = 1
}
// ── ExistsByNombreUnderParentAsync ─────────────────────────────────────────
[Fact]
public async Task ExistsByNombreUnderParentAsync_Exists_ReturnsTrue()
{
var parent = Rubro.ForCreation("ParentExists", null, 0, null, _timeProvider);
var parentId = await _repository.AddAsync(parent);
await _repository.AddAsync(Rubro.ForCreation("Autos", parentId, 0, null, _timeProvider));
var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "Autos", excludeId: null);
Assert.True(exists);
}
[Fact]
public async Task ExistsByNombreUnderParentAsync_SameNameDifferentParent_ReturnsFalse()
{
var parent1 = Rubro.ForCreation("Parent1", null, 0, null, _timeProvider);
var parent1Id = await _repository.AddAsync(parent1);
var parent2 = Rubro.ForCreation("Parent2", null, 1, null, _timeProvider);
var parent2Id = await _repository.AddAsync(parent2);
// Add "Autos" under parent1
await _repository.AddAsync(Rubro.ForCreation("Autos", parent1Id, 0, null, _timeProvider));
// Check under parent2 — should not exist
var exists = await _repository.ExistsByNombreUnderParentAsync(parent2Id, "Autos", excludeId: null);
Assert.False(exists);
}
[Fact]
public async Task ExistsByNombreUnderParentAsync_ExcludeId_WhenProvidedSkipsSelf()
{
var parent = Rubro.ForCreation("ParentExclude", null, 0, null, _timeProvider);
var parentId = await _repository.AddAsync(parent);
var id = await _repository.AddAsync(Rubro.ForCreation("AutosExclude", parentId, 0, null, _timeProvider));
// Excluding self → should return false (no other rubro with same name)
var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "AutosExclude", excludeId: id);
Assert.False(exists);
}
[Fact]
public async Task ExistsByNombreUnderParentAsync_CaseInsensitive_InsensibleAMayusculas()
{
var parent = Rubro.ForCreation("ParentCI", null, 0, null, _timeProvider);
var parentId = await _repository.AddAsync(parent);
await _repository.AddAsync(Rubro.ForCreation("autos", parentId, 0, null, _timeProvider));
// "AUTOS" should match "autos" (case-insensitive)
var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "AUTOS", excludeId: null);
Assert.True(exists);
}
[Fact]
public async Task ExistsByNombreUnderParentAsync_ForRoot_ParentIdNull_WorksWithApplicationDefense()
{
// The DB filtered index only covers non-root rubros (WHERE ParentId IS NOT NULL AND Activo = 1).
// Application must check roots via full scan (no unique index guarantee at DB level).
await _repository.AddAsync(Rubro.ForCreation("RootCI", null, 0, null, _timeProvider));
var exists = await _repository.ExistsByNombreUnderParentAsync(null, "RootCI", excludeId: null);
Assert.True(exists);
}
// ── GetDepthAsync ──────────────────────────────────────────────────────────
[Fact]
public async Task GetDepthAsync_RootParent_ReturnsZero()
{
// parentId = null means we're creating a root → depth = 0
var depth = await _repository.GetDepthAsync(parentId: null);
Assert.Equal(0, depth);
}
[Fact]
public async Task GetDepthAsync_3LevelsDeep_ReturnsThree()
{
// root (depth 0) → child (depth 1) → grandchild (depth 2) → great-grandchild (depth 3)
var rootId = await _repository.AddAsync(Rubro.ForCreation("RootDepth", null, 0, null, _timeProvider));
var childId = await _repository.AddAsync(Rubro.ForCreation("ChildDepth", rootId, 0, null, _timeProvider));
var grandchildId = await _repository.AddAsync(Rubro.ForCreation("GrandchildDepth", childId, 0, null, _timeProvider));
// Depth of the grandchild's own id as parentId = 3 levels deep (root=1, child=2, grandchild=3)
var depth = await _repository.GetDepthAsync(grandchildId);
Assert.Equal(3, depth);
}
// ── UpdateAsync + Temporal ────────────────────────────────────────────────
[Fact]
public async Task UpdateAsync_ModificaCamposYProduceHistoryRow()
{
var id = await _repository.AddAsync(Rubro.ForCreation("Original", null, 0, null, _timeProvider));
var original = await _repository.GetByIdAsync(id);
var renamed = original!.WithRenamed("Actualizado", _timeProvider);
await _repository.UpdateAsync(renamed);
var result = await _repository.GetByIdAsync(id);
Assert.NotNull(result);
Assert.Equal("Actualizado", result!.Nombre);
Assert.NotNull(result.FechaModificacion);
var historyCount = await _connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id });
Assert.True(historyCount >= 1, $"Expected ≥1 history row for Rubro Id={id}, got {historyCount}");
}
[Fact]
public async Task SoftDeleteAsync_FlipActivoYActualizaFechaModificacion()
{
// Deactivate via UpdateAsync (with WithActivo(false)) — repository has no separate SoftDelete
var id = await _repository.AddAsync(Rubro.ForCreation("ToDeactivate", null, 0, null, _timeProvider));
var rubro = await _repository.GetByIdAsync(id);
var deactivated = rubro!.WithActivo(false, _timeProvider);
await _repository.UpdateAsync(deactivated);
var result = await _repository.GetByIdAsync(id);
Assert.NotNull(result);
Assert.False(result!.Activo);
Assert.NotNull(result.FechaModificacion);
}
[Fact]
public async Task MoveAsync_CambiaParentIdOrdenYFechaModificacion()
{
var parent1Id = await _repository.AddAsync(Rubro.ForCreation("MoveParent1", null, 0, null, _timeProvider));
var parent2Id = await _repository.AddAsync(Rubro.ForCreation("MoveParent2", null, 1, null, _timeProvider));
var childId = await _repository.AddAsync(Rubro.ForCreation("MoveChild", parent1Id, 0, null, _timeProvider));
var child = await _repository.GetByIdAsync(childId);
var moved = child!.WithMoved(parent2Id, 5, _timeProvider);
await _repository.UpdateAsync(moved);
var result = await _repository.GetByIdAsync(childId);
Assert.NotNull(result);
Assert.Equal(parent2Id, result!.ParentId);
Assert.Equal(5, result.Orden);
Assert.NotNull(result.FechaModificacion);
}
// ── helpers ───────────────────────────────────────────────────────────────
private async Task SeedRolCanonicalAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Rol AS t
USING (VALUES
('admin', N'Administrador', N'Supervisor total'),
('cajero', N'Cajero', N'Mostrador contado'),
('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'),
('picadora', N'Picadora/Correctora', N'Edición de textos'),
('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'),
('productor', N'Productor', N'Carga restringida'),
('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'),
('reportes', N'Reportes', N'Solo lectura reportes')
) AS s (Codigo, Nombre, Descripcion)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Activo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
""";
await _connection.ExecuteAsync(sql);
}
}

View File

@@ -0,0 +1,158 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using SIGCM2.Application.Rubros.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Rubros;
public class RubroTreeBuilderTests
{
private static readonly FakeTimeProvider FakeTime = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
private static Rubro MakeRubro(int id, int? parentId, string nombre, int orden, bool activo = true)
=> new(id, parentId, nombre, orden, activo, tarifarioBaseId: null,
fechaCreacion: FakeTime.GetUtcNow().UtcDateTime, fechaModificacion: null);
// ── empty ─────────────────────────────────────────────────────────────────
[Fact]
public void Build_empty_returns_empty_list()
{
var result = RubroTreeBuilder.Build([], incluirInactivos: false);
result.Should().BeEmpty();
}
// ── single root ───────────────────────────────────────────────────────────
[Fact]
public void Build_single_root_returns_one_node_no_children()
{
var rubros = new[] { MakeRubro(1, null, "Autos", 0) };
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
result.Should().HaveCount(1);
result[0].Id.Should().Be(1);
result[0].Nombre.Should().Be("Autos");
result[0].Hijos.Should().BeEmpty();
}
// ── multiple roots sorted by Orden ───────────────────────────────────────
[Fact]
public void Build_flat_list_with_multiple_roots_sorted_by_orden()
{
var rubros = new[]
{
MakeRubro(3, null, "Motos", 2),
MakeRubro(1, null, "Autos", 0),
MakeRubro(2, null, "Camiones", 1)
};
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
result.Should().HaveCount(3);
result[0].Id.Should().Be(1); // Orden=0
result[1].Id.Should().Be(2); // Orden=1
result[2].Id.Should().Be(3); // Orden=2
}
// ── tree 3 levels deep ────────────────────────────────────────────────────
[Fact]
public void Build_tree_3_levels_deep_correctly_nests()
{
var rubros = new[]
{
MakeRubro(1, null, "Autos", 0),
MakeRubro(2, 1, "Sedanes", 0),
MakeRubro(3, 2, "Compactos", 0),
};
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
result.Should().HaveCount(1);
result[0].Id.Should().Be(1);
result[0].Hijos.Should().HaveCount(1);
result[0].Hijos[0].Id.Should().Be(2);
result[0].Hijos[0].Hijos.Should().HaveCount(1);
result[0].Hijos[0].Hijos[0].Id.Should().Be(3);
}
// ── filter inactivos ──────────────────────────────────────────────────────
[Fact]
public void Build_filters_inactivos_by_default()
{
var rubros = new[]
{
MakeRubro(1, null, "Autos", 0, activo: true),
MakeRubro(2, null, "Motos", 1, activo: false),
};
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
result.Should().HaveCount(1);
result[0].Id.Should().Be(1);
}
[Fact]
public void Build_includes_inactivos_when_incluirInactivos_true()
{
var rubros = new[]
{
MakeRubro(1, null, "Autos", 0, activo: true),
MakeRubro(2, null, "Motos", 1, activo: false),
};
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: true);
result.Should().HaveCount(2);
}
// ── siblings sorted by Orden ──────────────────────────────────────────────
[Fact]
public void Build_orders_siblings_by_orden()
{
var rubros = new[]
{
MakeRubro(1, null, "Root", 0),
MakeRubro(4, 1, "D", 3),
MakeRubro(2, 1, "B", 1),
MakeRubro(3, 1, "C", 2),
MakeRubro(5, 1, "A", 0),
};
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
var hijos = result[0].Hijos;
hijos.Should().HaveCount(4);
hijos[0].Nombre.Should().Be("A"); // Orden=0
hijos[1].Nombre.Should().Be("B"); // Orden=1
hijos[2].Nombre.Should().Be("C"); // Orden=2
hijos[3].Nombre.Should().Be("D"); // Orden=3
}
// ── O(n) perf smoke test ──────────────────────────────────────────────────
[Fact]
public void Build_is_Olinear_perf_smoke_test_1000_nodes_under_100ms()
{
var rubros = new List<Rubro>();
// root
rubros.Add(MakeRubro(1, null, "Root", 0));
// 999 children of root
for (int i = 2; i <= 1000; i++)
rubros.Add(MakeRubro(i, 1, $"Child{i}", i - 2));
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = RubroTreeBuilder.Build(rubros, incluirInactivos: false);
sw.Stop();
result.Should().HaveCount(1);
result[0].Hijos.Should().HaveCount(999);
sw.ElapsedMilliseconds.Should().BeLessThan(100);
}
}

View File

@@ -0,0 +1,110 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Rubros.Update;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Rubros.Update;
public class UpdateRubroCommandHandlerTests
{
private readonly IRubroRepository _repo = Substitute.For<IRubroRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 18, 12, 0, 0, TimeSpan.Zero));
private readonly UpdateRubroCommandHandler _handler;
private static Rubro ExistingRubro(int id = 3) => new(id, null, "Autos", 0, activo: true,
tarifarioBaseId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null);
public UpdateRubroCommandHandlerTests()
{
_repo.ExistsByNombreUnderParentAsync(Arg.Any<int?>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(false);
_handler = new UpdateRubroCommandHandler(_repo, _audit, _timeProvider);
}
// ── Happy path: rename ───────────────────────────────────────────────────
[Fact]
public async Task Handle_HappyPath_Rename_ReturnsUpdatedDto()
{
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(ExistingRubro());
var result = await _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Vehiculos"));
result.Nombre.Should().Be("Vehiculos");
result.Id.Should().Be(3);
}
[Fact]
public async Task Handle_HappyPath_Rename_CallsUpdateAsync()
{
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(ExistingRubro());
await _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Vehiculos"));
await _repo.Received(1).UpdateAsync(
Arg.Is<Rubro>(r => r.Nombre == "Vehiculos"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_HappyPath_Rename_CallsAuditLog()
{
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(ExistingRubro());
await _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Vehiculos"));
await _audit.Received(1).LogAsync(
action: "rubro.updated",
targetType: "Rubro",
targetId: "3",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
// ── Not found → RubroNotFoundException ──────────────────────────────────
[Fact]
public async Task Handle_NotFound_ThrowsRubroNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Rubro?)null);
var act = () => _handler.Handle(new UpdateRubroCommand(Id: 99, Nombre: "Cualquiera"));
await act.Should().ThrowAsync<RubroNotFoundException>()
.Where(ex => ex.Id == 99);
}
// ── Duplicate name CI under same parent → RubroNombreDuplicadoEnPadreException
[Fact]
public async Task Handle_DuplicateNameUnderParent_ThrowsRubroNombreDuplicadoEnPadreException()
{
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(ExistingRubro());
_repo.ExistsByNombreUnderParentAsync(null, "Motos", 3, Arg.Any<CancellationToken>())
.Returns(true);
var act = () => _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Motos"));
await act.Should().ThrowAsync<RubroNombreDuplicadoEnPadreException>();
}
[Fact]
public async Task Handle_DuplicateName_DoesNotCallAuditLog()
{
_repo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(ExistingRubro());
_repo.ExistsByNombreUnderParentAsync(Arg.Any<int?>(), Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(true);
try { await _handler.Handle(new UpdateRubroCommand(Id: 3, Nombre: "Motos")); } catch { }
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -50,6 +50,8 @@ public class SeccionRepositoryTests : IAsyncLifetime
new Respawn.Graph.Table("dbo", "IngresosBrutos_History"),
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});

View File

@@ -50,6 +50,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V015 (UDT-011): ensure dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local views exist.
await EnsureV015SchemaAsync();
// V016 (CAT-001): ensure dbo.Rubro + temporal + permiso 'catalogo:rubros:gestionar'.
await EnsureV016SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
@@ -74,6 +77,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
// Seed de TipoDeIva e IngresosBrutos son datos de referencia — no limpiar con Respawn.
new Respawn.Graph.Table("dbo", "TipoDeIva"),
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
// CAT-001 (V016): Rubro es system-versioned — Respawn no puede DELETE su history.
new Respawn.Graph.Table("dbo", "Rubro_History"),
]
});
@@ -187,7 +192,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
-- V013 (ADM-008): permiso para CRUD de Puntos de Venta
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta AFIP','administracion'),
-- V014 (ADM-009): permiso para tablas fiscales
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion')
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'),
-- V016 (CAT-001): permiso para gestionar árbol de rubros
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
@@ -233,6 +240,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
('admin', 'administracion:puntos_de_venta:gestionar'),
-- V014 (ADM-009)
('admin', 'administracion:fiscal:gestionar'),
-- V016 (CAT-001)
('admin', 'catalogo:rubros:gestionar'),
('cajero', 'ventas:contado:crear'),
('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'),
@@ -778,6 +787,82 @@ public sealed class SqlTestFixture : IAsyncLifetime
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
}
/// <summary>
/// CAT-001 (V016): applies dbo.Rubro schema + temporal + filtered unique index + covering index
/// + permiso 'catalogo:rubros:gestionar' idempotentemente. Mirrors V016__create_rubro.sql.
/// Nota: COLLATE debe ir ANTES de NOT NULL — parser de SQL Server 2019 es estricto con ese orden.
/// Permiso y asignación a admin se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync.
/// </summary>
private async Task EnsureV016SchemaAsync()
{
const string createRubro = """
IF OBJECT_ID(N'dbo.Rubro', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Rubro (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rubro PRIMARY KEY,
ParentId INT NULL,
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
Orden INT NOT NULL CONSTRAINT DF_Rubro_Orden DEFAULT(0),
Activo BIT NOT NULL CONSTRAINT DF_Rubro_Activo DEFAULT(1),
TarifarioBaseId INT NULL,
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rubro_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_Rubro_Parent FOREIGN KEY (ParentId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION
);
END
""";
const string addRubroPeriod = """
IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Rubro
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Rubro_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Rubro_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
END
""";
const string setRubroVersioning = """
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Rubro
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Rubro_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
END
""";
const string createUqIndex = """
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Rubro_ParentId_Nombre_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
BEGIN
CREATE UNIQUE INDEX UQ_Rubro_ParentId_Nombre_Activo
ON dbo.Rubro(ParentId, Nombre)
WHERE Activo = 1 AND ParentId IS NOT NULL;
END
""";
const string createCoveringIndex = """
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Rubro_ParentId_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
BEGIN
CREATE INDEX IX_Rubro_ParentId_Activo
ON dbo.Rubro(ParentId, Activo)
INCLUDE (Nombre, Orden);
END
""";
await _connection.ExecuteAsync(createRubro);
await _connection.ExecuteAsync(addRubroPeriod);
await _connection.ExecuteAsync(setRubroVersioning);
await _connection.ExecuteAsync(createUqIndex);
await _connection.ExecuteAsync(createCoveringIndex);
// Permiso 'catalogo:rubros:gestionar' y asignación a admin se siembran
// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
}
/// <summary>
/// UDT-011 (V015): applies dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local views
/// idempotently to the test database. Mirrors V015__create_local_timezone_views.sql.