From 34b714750a2bf02ebf3c879f0be5018f99cd07e8 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 12:31:29 -0300 Subject: [PATCH 1/4] feat(api): UDT-004 dominio + repositorio + application roles (tdd) - Migraciones V003 (tabla Rol + 8 seeds canonicos) y V004 (drop CK + FK Usuario.Rol) - Dominio: Rol entity + 3 excepciones (RolNotFound/AlreadyExists/InUse) - Infraestructura: RolRepository (Dapper) con List/Get/ExistsActive/Add/Update/HasActiveUsuarios - Application: CRUD queries y commands (List, Get, Create, Update, Deactivate) + validators (codigo regex ^[a-z][a-z0-9_]*$) - Validator UDT-003: whitelist alineada a codigos canonicos (full IRolRepository lookup diferido a Phase 5.1) - Tests: 169 application + 15 api (todos verdes). Respawn configurado para re-seedear Rol canonical post-reset. - Estricto TDD: RED/GREEN/TRIANGULATE en todos los handlers nuevos. --- database/migrations/V003__create_rol.sql | 58 +++++ .../migrations/V004__alter_usuario_rol_fk.sql | 44 ++++ .../Persistence/IRolRepository.cs | 13 + .../Roles/Create/CreateRolCommand.cs | 6 + .../Roles/Create/CreateRolCommandHandler.cs | 36 +++ .../Roles/Create/CreateRolCommandValidator.cs | 31 +++ .../Roles/Deactivate/DeactivateRolCommand.cs | 3 + .../Deactivate/DeactivateRolCommandHandler.cs | 36 +++ .../Roles/Dtos/RolCreatedDto.cs | 8 + .../SIGCM2.Application/Roles/Dtos/RolDto.cs | 10 + .../Roles/Get/GetRolByCodigoQuery.cs | 3 + .../Roles/Get/GetRolByCodigoQueryHandler.cs | 25 ++ .../Roles/List/ListRolesQuery.cs | 3 + .../Roles/List/ListRolesQueryHandler.cs | 23 ++ .../Roles/Update/UpdateRolCommand.cs | 7 + .../Roles/Update/UpdateRolCommandHandler.cs | 30 +++ .../Roles/Update/UpdateRolCommandValidator.cs | 27 ++ .../Create/CreateUsuarioCommandValidator.cs | 8 +- src/api/SIGCM2.Domain/Entities/Rol.cs | 44 ++++ .../Exceptions/RolAlreadyExistsException.cs | 12 + .../Exceptions/RolInUseException.cs | 12 + .../Exceptions/RolNotFoundException.cs | 12 + .../Persistence/RolRepository.cs | 129 ++++++++++ .../Usuarios/CreateUsuarioEndpointTests.cs | 22 +- .../Domain/RolTests.cs | 82 ++++++ .../RefreshTokenRepositoryTests.cs | 28 +- .../Integration/RolRepositoryTests.cs | 240 ++++++++++++++++++ .../Integration/UsuarioRepositoryTests.cs | 44 +++- .../Create/CreateRolCommandHandlerTests.cs | 86 +++++++ .../Create/CreateRolCommandValidatorTests.cs | 97 +++++++ .../DeactivateRolCommandHandlerTests.cs | 98 +++++++ .../Get/GetRolByCodigoQueryHandlerTests.cs | 43 ++++ .../Roles/List/ListRolesQueryHandlerTests.cs | 62 +++++ .../Update/UpdateRolCommandHandlerTests.cs | 64 +++++ .../Update/UpdateRolCommandValidatorTests.cs | 51 ++++ .../CreateUsuarioCommandValidatorTests.cs | 12 +- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 28 +- 37 files changed, 1510 insertions(+), 27 deletions(-) create mode 100644 database/migrations/V003__create_rol.sql create mode 100644 database/migrations/V004__alter_usuario_rol_fk.sql create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IRolRepository.cs create mode 100644 src/api/SIGCM2.Application/Roles/Create/CreateRolCommand.cs create mode 100644 src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Roles/Create/CreateRolCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommand.cs create mode 100644 src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs create mode 100644 src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs create mode 100644 src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQuery.cs create mode 100644 src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs create mode 100644 src/api/SIGCM2.Application/Roles/List/ListRolesQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Roles/Update/UpdateRolCommand.cs create mode 100644 src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandValidator.cs create mode 100644 src/api/SIGCM2.Domain/Entities/Rol.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/RolAlreadyExistsException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/RolTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandValidatorTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Roles/Get/GetRolByCodigoQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Roles/List/ListRolesQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandValidatorTests.cs diff --git a/database/migrations/V003__create_rol.sql b/database/migrations/V003__create_rol.sql new file mode 100644 index 0000000..c2c4982 --- /dev/null +++ b/database/migrations/V003__create_rol.sql @@ -0,0 +1,58 @@ +-- V003__create_rol.sql +-- Creates dbo.Rol master table (referenced by Usuario.Rol via FK in V004) and seeds +-- the 8 canonical business roles (RBAC doc §2.4.2). +-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests) + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +IF OBJECT_ID(N'dbo.Rol', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Rol + ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rol PRIMARY KEY, + Codigo VARCHAR(30) NOT NULL, + Nombre NVARCHAR(60) NOT NULL, + Descripcion NVARCHAR(250) NULL, + Activo BIT NOT NULL CONSTRAINT DF_Rol_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rol_FC DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT UQ_Rol_Codigo UNIQUE (Codigo), + -- Codigo format: lowercase letter followed by lowercase letters, digits or underscore. + -- Using binary collation to enforce case-sensitivity (default DB collation is case-insensitive). + CONSTRAINT CK_Rol_Codigo_Format CHECK ( + PATINDEX('[a-z]%', Codigo COLLATE Latin1_General_BIN2) = 1 + AND PATINDEX('%[^a-z0-9_]%', Codigo COLLATE Latin1_General_BIN2) = 0 + ) + ); + + PRINT 'Table dbo.Rol created successfully.'; +END +ELSE +BEGIN + PRINT 'Table dbo.Rol already exists — skipping create.'; +END +GO + +-- Seed 8 canonical roles (idempotent). +MERGE dbo.Rol AS target +USING (VALUES + ('admin', N'Administrador', N'Supervisor total del sistema'), + ('cajero', N'Cajero', N'Atención de mostrador, contado'), + ('operador_ctacte', N'Operador Cta Cte', N'Gestión de cuenta corriente'), + ('picadora', N'Picadora/Correctora', N'Edición de textos y corrección'), + ('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta y recursos'), + ('productor', N'Productor', N'Consulta y carga restringida'), + ('diagramacion', N'Diagramación/Taller', N'Solo lectura de pauta'), + ('reportes', N'Reportes', N'Solo lectura de reportes y estadísticas') +) AS source (Codigo, Nombre, Descripcion) +ON target.Codigo = source.Codigo +WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Activo) + VALUES (source.Codigo, source.Nombre, source.Descripcion, 1); +GO + +PRINT 'Rol seeds applied (8 canonical roles).'; +GO diff --git a/database/migrations/V004__alter_usuario_rol_fk.sql b/database/migrations/V004__alter_usuario_rol_fk.sql new file mode 100644 index 0000000..de38ec8 --- /dev/null +++ b/database/migrations/V004__alter_usuario_rol_fk.sql @@ -0,0 +1,44 @@ +-- V004__alter_usuario_rol_fk.sql +-- Replaces the hardcoded CHECK constraint on Usuario.Rol with a FOREIGN KEY +-- against dbo.Rol(Codigo). Must run AFTER V003 (which creates dbo.Rol and seeds the +-- codes already in use, including 'admin'). +-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests) + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- 1) Drop the old hardcoded whitelist CHECK constraint (if still present). +IF EXISTS ( + SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_Usuario_Rol' + AND parent_object_id = OBJECT_ID(N'dbo.Usuario') +) +BEGIN + ALTER TABLE dbo.Usuario DROP CONSTRAINT CK_Usuario_Rol; + PRINT 'Dropped CK_Usuario_Rol (hardcoded whitelist).'; +END +ELSE +BEGIN + PRINT 'CK_Usuario_Rol not present — skipping drop.'; +END +GO + +-- 2) Add the FK Usuario.Rol -> Rol.Codigo (only if not already present). +IF NOT EXISTS ( + SELECT 1 FROM sys.foreign_keys + WHERE name = 'FK_Usuario_Rol' + AND parent_object_id = OBJECT_ID(N'dbo.Usuario') +) +BEGIN + ALTER TABLE dbo.Usuario + ADD CONSTRAINT FK_Usuario_Rol + FOREIGN KEY (Rol) REFERENCES dbo.Rol(Codigo); + PRINT 'Added FK_Usuario_Rol -> dbo.Rol(Codigo).'; +END +ELSE +BEGIN + PRINT 'FK_Usuario_Rol already present — skipping.'; +END +GO diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IRolRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IRolRepository.cs new file mode 100644 index 0000000..20f456d --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IRolRepository.cs @@ -0,0 +1,13 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IRolRepository +{ + Task> ListAsync(CancellationToken ct = default); + Task GetByCodigoAsync(string codigo, CancellationToken ct = default); + Task ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default); + Task AddAsync(Rol rol, CancellationToken ct = default); + Task UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default); + Task HasActiveUsuariosAsync(string codigo, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Roles/Create/CreateRolCommand.cs b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommand.cs new file mode 100644 index 0000000..998ca64 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommand.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Roles.Create; + +public sealed record CreateRolCommand( + string Codigo, + string Nombre, + string? Descripcion); diff --git a/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs new file mode 100644 index 0000000..072dab6 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs @@ -0,0 +1,36 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Roles.Create; + +public sealed class CreateRolCommandHandler : ICommandHandler +{ + private readonly IRolRepository _repository; + + public CreateRolCommandHandler(IRolRepository repository) + { + _repository = repository; + } + + public async Task Handle(CreateRolCommand command) + { + // Check-then-insert: explicit check produces a clear 409 message. + // SqlException 2627 (UQ violation) acts as race-condition fallback — caught in ExceptionFilter. + var existing = await _repository.GetByCodigoAsync(command.Codigo); + if (existing is not null) + throw new RolAlreadyExistsException(command.Codigo); + + var rol = Rol.ForCreation(command.Codigo, command.Nombre, command.Descripcion); + var newId = await _repository.AddAsync(rol); + + return new RolCreatedDto( + Id: newId, + Codigo: rol.Codigo, + Nombre: rol.Nombre, + Descripcion: rol.Descripcion, + Activo: rol.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandValidator.cs b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandValidator.cs new file mode 100644 index 0000000..2e7dfa8 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; + +namespace SIGCM2.Application.Roles.Create; + +public sealed class CreateRolCommandValidator : AbstractValidator +{ + private const int CodigoMinLength = 3; + private const int CodigoMaxLength = 30; + private const int NombreMaxLength = 60; + private const int DescripcionMaxLength = 250; + + public CreateRolCommandValidator() + { + RuleFor(x => x.Codigo) + .NotEmpty().WithMessage("El código es requerido.") + .Length(CodigoMinLength, CodigoMaxLength) + .WithMessage($"El código debe tener entre {CodigoMinLength} y {CodigoMaxLength} caracteres.") + .Matches(@"^[a-z][a-z0-9_]*$") + .WithMessage("El código debe empezar con una letra minúscula y contener solo minúsculas, dígitos o guion bajo."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength) + .WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Descripcion) + .MaximumLength(DescripcionMaxLength) + .WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.") + .When(x => x.Descripcion is not null); + } +} diff --git a/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommand.cs b/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommand.cs new file mode 100644 index 0000000..daf4f0d --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Roles.Deactivate; + +public sealed record DeactivateRolCommand(string Codigo); diff --git a/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommandHandler.cs b/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommandHandler.cs new file mode 100644 index 0000000..3aa630b --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Deactivate/DeactivateRolCommandHandler.cs @@ -0,0 +1,36 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Roles.Deactivate; + +public sealed class DeactivateRolCommandHandler : ICommandHandler +{ + private readonly IRolRepository _repository; + + public DeactivateRolCommandHandler(IRolRepository repository) + { + _repository = repository; + } + + public async Task Handle(DeactivateRolCommand command) + { + var existing = await _repository.GetByCodigoAsync(command.Codigo) + ?? throw new RolNotFoundException(command.Codigo); + + // Guard: block soft-delete when active usuarios reference this rol. + if (await _repository.HasActiveUsuariosAsync(command.Codigo)) + throw new RolInUseException(command.Codigo); + + var updated = await _repository.UpdateAsync( + existing.Codigo, existing.Nombre, existing.Descripcion, activo: false); + if (!updated) + throw new RolNotFoundException(command.Codigo); + + var rol = await _repository.GetByCodigoAsync(command.Codigo) + ?? throw new RolNotFoundException(command.Codigo); + + return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs b/src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs new file mode 100644 index 0000000..2be6060 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs @@ -0,0 +1,8 @@ +namespace SIGCM2.Application.Roles.Dtos; + +public sealed record RolCreatedDto( + int Id, + string Codigo, + string Nombre, + string? Descripcion, + bool Activo); diff --git a/src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs b/src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs new file mode 100644 index 0000000..0ca89a1 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Roles.Dtos; + +public sealed record RolDto( + int Id, + string Codigo, + string Nombre, + string? Descripcion, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQuery.cs b/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQuery.cs new file mode 100644 index 0000000..9ca0ec2 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Roles.Get; + +public sealed record GetRolByCodigoQuery(string Codigo); diff --git a/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQueryHandler.cs b/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQueryHandler.cs new file mode 100644 index 0000000..5cc65a1 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Get/GetRolByCodigoQueryHandler.cs @@ -0,0 +1,25 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Roles.Get; + +public sealed class GetRolByCodigoQueryHandler : ICommandHandler +{ + private readonly IRolRepository _repository; + + public GetRolByCodigoQueryHandler(IRolRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetRolByCodigoQuery query) + { + var rol = await _repository.GetByCodigoAsync(query.Codigo); + if (rol is null) + throw new RolNotFoundException(query.Codigo); + + return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs b/src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs new file mode 100644 index 0000000..44b41a2 --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Roles.List; + +public sealed record ListRolesQuery(); diff --git a/src/api/SIGCM2.Application/Roles/List/ListRolesQueryHandler.cs b/src/api/SIGCM2.Application/Roles/List/ListRolesQueryHandler.cs new file mode 100644 index 0000000..004a92f --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/List/ListRolesQueryHandler.cs @@ -0,0 +1,23 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Dtos; + +namespace SIGCM2.Application.Roles.List; + +public sealed class ListRolesQueryHandler : ICommandHandler> +{ + private readonly IRolRepository _repository; + + public ListRolesQueryHandler(IRolRepository repository) + { + _repository = repository; + } + + public async Task> Handle(ListRolesQuery query) + { + var roles = await _repository.ListAsync(); + return roles + .Select(r => new RolDto(r.Id, r.Codigo, r.Nombre, r.Descripcion, r.Activo, r.FechaCreacion, r.FechaModificacion)) + .ToList(); + } +} diff --git a/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommand.cs b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommand.cs new file mode 100644 index 0000000..3b6565c --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Roles.Update; + +public sealed record UpdateRolCommand( + string Codigo, + string Nombre, + string? Descripcion, + bool Activo); diff --git a/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandHandler.cs b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandHandler.cs new file mode 100644 index 0000000..eb14aba --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandHandler.cs @@ -0,0 +1,30 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Roles.Update; + +public sealed class UpdateRolCommandHandler : ICommandHandler +{ + private readonly IRolRepository _repository; + + public UpdateRolCommandHandler(IRolRepository repository) + { + _repository = repository; + } + + public async Task Handle(UpdateRolCommand command) + { + var updated = await _repository.UpdateAsync( + command.Codigo, command.Nombre, command.Descripcion, command.Activo); + + if (!updated) + throw new RolNotFoundException(command.Codigo); + + var rol = await _repository.GetByCodigoAsync(command.Codigo) + ?? throw new RolNotFoundException(command.Codigo); + + return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandValidator.cs b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandValidator.cs new file mode 100644 index 0000000..f3f711d --- /dev/null +++ b/src/api/SIGCM2.Application/Roles/Update/UpdateRolCommandValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; + +namespace SIGCM2.Application.Roles.Update; + +public sealed class UpdateRolCommandValidator : AbstractValidator +{ + private const int NombreMaxLength = 60; + private const int DescripcionMaxLength = 250; + + public UpdateRolCommandValidator() + { + // Codigo is taken from the URL route — we don't re-validate format here, + // but we require it to be non-empty so handler always has a target to match. + RuleFor(x => x.Codigo) + .NotEmpty().WithMessage("El código es requerido."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength) + .WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Descripcion) + .MaximumLength(DescripcionMaxLength) + .WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.") + .When(x => x.Descripcion is not null); + } +} diff --git a/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs b/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs index c200def..2ede281 100644 --- a/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs +++ b/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs @@ -5,7 +5,13 @@ namespace SIGCM2.Application.Usuarios.Create; public sealed class CreateUsuarioCommandValidator : AbstractValidator { - private static readonly string[] ValidRoles = ["admin", "vendedor", "tasador", "consulta"]; + // Whitelist aligned with canonical seeds in dbo.Rol (migration V003). + // Phase 5 of UDT-004 will replace this array with an async lookup against IRolRepository. + private static readonly string[] ValidRoles = + [ + "admin", "cajero", "operador_ctacte", "picadora", + "jefe_publicidad", "productor", "diagramacion", "reportes" + ]; private const int UsernameMinLength = 3; private const int UsernameMaxLength = 50; diff --git a/src/api/SIGCM2.Domain/Entities/Rol.cs b/src/api/SIGCM2.Domain/Entities/Rol.cs new file mode 100644 index 0000000..cadc65c --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/Rol.cs @@ -0,0 +1,44 @@ +namespace SIGCM2.Domain.Entities; + +public sealed class Rol +{ + public int Id { get; } + public string Codigo { get; } + public string Nombre { get; } + public string? Descripcion { get; } + public bool Activo { get; } + public DateTime FechaCreacion { get; } + public DateTime? FechaModificacion { get; } + + public Rol( + int id, + string codigo, + string nombre, + string? descripcion, + bool activo, + DateTime fechaCreacion, + DateTime? fechaModificacion) + { + Id = id; + Codigo = codigo; + Nombre = nombre; + Descripcion = descripcion; + Activo = activo; + FechaCreacion = fechaCreacion; + FechaModificacion = fechaModificacion; + } + + // Factory for creating a new Rol (Id=0 — DB assigns via IDENTITY; Activo=true; FechaCreacion set by DB default). + public static Rol ForCreation(string codigo, string nombre, string? descripcion) + { + return new Rol( + id: 0, + codigo: codigo, + nombre: nombre, + descripcion: descripcion, + activo: true, + fechaCreacion: default, + fechaModificacion: null + ); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/RolAlreadyExistsException.cs b/src/api/SIGCM2.Domain/Exceptions/RolAlreadyExistsException.cs new file mode 100644 index 0000000..a45324e --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RolAlreadyExistsException.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Domain.Exceptions; + +public sealed class RolAlreadyExistsException : Exception +{ + public string Codigo { get; } + + public RolAlreadyExistsException(string codigo) + : base($"El rol '{codigo}' ya existe.") + { + Codigo = codigo; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs b/src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs new file mode 100644 index 0000000..477368b --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Domain.Exceptions; + +public sealed class RolInUseException : Exception +{ + public string Codigo { get; } + + public RolInUseException(string codigo) + : base($"El rol '{codigo}' no puede desactivarse porque existen usuarios activos que lo referencian.") + { + Codigo = codigo; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs new file mode 100644 index 0000000..2546444 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Domain.Exceptions; + +public sealed class RolNotFoundException : Exception +{ + public string Codigo { get; } + + public RolNotFoundException(string codigo) + : base($"El rol '{codigo}' no existe.") + { + Codigo = codigo; + } +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs new file mode 100644 index 0000000..12f0ce4 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs @@ -0,0 +1,129 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class RolRepository : IRolRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public RolRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task> ListAsync(CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Codigo, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion + FROM dbo.Rol + ORDER BY Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql); + return rows.Select(MapRow).ToList(); + } + + public async Task GetByCodigoAsync(string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Codigo, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion + FROM dbo.Rol + WHERE Codigo = @Codigo + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Codigo = codigo }); + return row is null ? null : MapRow(row); + } + + public async Task ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) FROM dbo.Rol WHERE Codigo = @Codigo AND Activo = 1 + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { Codigo = codigo }); + return count > 0; + } + + public async Task AddAsync(Rol rol, CancellationToken ct = default) + { + // DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()). + const string sql = """ + INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion) + OUTPUT INSERTED.Id + VALUES (@Codigo, @Nombre, @Descripcion) + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new + { + rol.Codigo, + rol.Nombre, + rol.Descripcion, + }); + } + + public async Task UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Rol + SET Nombre = @Nombre, + Descripcion = @Descripcion, + Activo = @Activo, + FechaModificacion = SYSUTCDATETIME() + WHERE Codigo = @Codigo; + SELECT @@ROWCOUNT; + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.ExecuteScalarAsync(sql, new { Codigo = codigo, Nombre = nombre, Descripcion = descripcion, Activo = activo }); + return rows > 0; + } + + public async Task HasActiveUsuariosAsync(string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) FROM dbo.Usuario WHERE Rol = @Codigo AND Activo = 1 + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { Codigo = codigo }); + return count > 0; + } + + private static Rol MapRow(RolRow row) + => new( + id: row.Id, + codigo: row.Codigo, + nombre: row.Nombre, + descripcion: row.Descripcion, + activo: row.Activo, + fechaCreacion: row.FechaCreacion, + fechaModificacion: row.FechaModificacion); + + private sealed record RolRow( + int Id, + string Codigo, + string Nombre, + string? Descripcion, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); +} diff --git a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs index 3deba6f..a84669e 100644 --- a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs @@ -63,7 +63,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture 0, "'id' must be positive"); Assert.Equal(newUsername, username.GetString()); - Assert.Equal("vendedor", rol.GetString()); + Assert.Equal("cajero", rol.GetString()); // Must NOT contain passwordHash Assert.False(json.TryGetProperty("passwordHash", out _), "Response must NOT leak 'passwordHash'"); @@ -253,7 +253,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture( @@ -49,6 +52,29 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime await _connection.DisposeAsync(); } + 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); + } + private async Task SeedTestUserAsync() { await _connection.ExecuteAsync(""" diff --git a/tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs new file mode 100644 index 0000000..ee1cf60 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs @@ -0,0 +1,240 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Integration; + +[Collection("Database")] +public class RolRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private RolRepository _repository = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + + // Clean Usuario first (FK), then custom Rol codes created by tests. + await _connection.ExecuteAsync("DELETE FROM dbo.Usuario;"); + await _connection.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo NOT IN ('admin','cajero','operador_ctacte','picadora','jefe_publicidad','productor','diagramacion','reportes');"); + // Ensure canonical Rol seeds exist (idempotent — previous test classes may have wiped them via Respawn). + await SeedRolCanonicalAsync(); + // Reset any mutations applied to canonical seeds during prior tests. + await _connection.ExecuteAsync("UPDATE dbo.Rol SET Activo = 1, FechaModificacion = NULL WHERE Codigo IN ('admin','cajero','operador_ctacte','picadora','jefe_publicidad','productor','diagramacion','reportes');"); + // Seed admin usuario (needed by HasActiveUsuariosAsync test expecting admin active). + await _connection.ExecuteAsync( + "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " + + "VALUES ('admin', '$2a$12$hash', 'Administrador', 'Sistema', 'admin', '[\"*\"]', 1);"); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new RolRepository(factory); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + 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); + } + + // ── ListAsync ──────────────────────────────────────────────────────────── + + [Fact] + public async Task ListAsync_ReturnsAllCanonicalSeeds() + { + var list = await _repository.ListAsync(); + + var codes = list.Select(r => r.Codigo).ToHashSet(); + Assert.Contains("admin", codes); + Assert.Contains("cajero", codes); + Assert.Contains("operador_ctacte", codes); + Assert.Contains("picadora", codes); + Assert.Contains("jefe_publicidad", codes); + Assert.Contains("productor", codes); + Assert.Contains("diagramacion", codes); + Assert.Contains("reportes", codes); + Assert.True(list.Count >= 8); + } + + [Fact] + public async Task ListAsync_IncludesInactiveRoles() + { + // Triangulation: list must include deactivated rows too. + await _connection.ExecuteAsync("INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES ('listtest_inactive', N'Inactivo de test', 0);"); + + var list = await _repository.ListAsync(); + + var inactive = list.Single(r => r.Codigo == "listtest_inactive"); + Assert.False(inactive.Activo); + } + + // ── GetByCodigoAsync ──────────────────────────────────────────────────── + + [Fact] + public async Task GetByCodigoAsync_ExistingCodigo_ReturnsRol() + { + var rol = await _repository.GetByCodigoAsync("cajero"); + + Assert.NotNull(rol); + Assert.Equal("cajero", rol!.Codigo); + Assert.Equal("Cajero", rol.Nombre); + Assert.True(rol.Activo); + } + + [Fact] + public async Task GetByCodigoAsync_NonExistentCodigo_ReturnsNull() + { + var rol = await _repository.GetByCodigoAsync("no_existe"); + Assert.Null(rol); + } + + // ── ExistsActiveByCodigoAsync ─────────────────────────────────────────── + + [Fact] + public async Task ExistsActiveByCodigoAsync_ActiveCodigo_ReturnsTrue() + { + Assert.True(await _repository.ExistsActiveByCodigoAsync("admin")); + } + + [Fact] + public async Task ExistsActiveByCodigoAsync_InactiveCodigo_ReturnsFalse() + { + await _connection.ExecuteAsync("INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES ('exists_inactive', N'Test inactivo', 0);"); + + Assert.False(await _repository.ExistsActiveByCodigoAsync("exists_inactive")); + } + + [Fact] + public async Task ExistsActiveByCodigoAsync_MissingCodigo_ReturnsFalse() + { + Assert.False(await _repository.ExistsActiveByCodigoAsync("missing_codigo_xyz")); + } + + // ── AddAsync ──────────────────────────────────────────────────────────── + + [Fact] + public async Task AddAsync_NewRol_PersistsAndReturnsId() + { + var rol = Rol.ForCreation("addtest_new", "Add Test", "Rol de prueba add"); + + var newId = await _repository.AddAsync(rol); + + Assert.True(newId > 0); + + var persisted = await _connection.QuerySingleAsync<(string Codigo, string Nombre, string? Descripcion, bool Activo)>( + "SELECT Codigo, Nombre, Descripcion, Activo FROM dbo.Rol WHERE Id = @Id", + new { Id = newId }); + + Assert.Equal("addtest_new", persisted.Codigo); + Assert.Equal("Add Test", persisted.Nombre); + Assert.Equal("Rol de prueba add", persisted.Descripcion); + Assert.True(persisted.Activo); + } + + [Fact] + public async Task AddAsync_WithNullDescripcion_PersistsNull() + { + var rol = Rol.ForCreation("addtest_nulldesc", "Null Desc", null); + + var newId = await _repository.AddAsync(rol); + + var desc = await _connection.ExecuteScalarAsync( + "SELECT Descripcion FROM dbo.Rol WHERE Id = @Id", new { Id = newId }); + Assert.Null(desc); + } + + // ── UpdateAsync ───────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_ExistingCodigo_UpdatesMutableFieldsAndSetsFechaModificacion() + { + await _connection.ExecuteAsync( + "INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion, Activo) VALUES ('updtest_one', N'Nombre Viejo', N'Desc vieja', 1);"); + + var updated = await _repository.UpdateAsync("updtest_one", "Nombre Nuevo", "Desc nueva", activo: true); + + Assert.True(updated); + + var row = await _connection.QuerySingleAsync<(string Nombre, string? Descripcion, bool Activo, DateTime? FechaModificacion)>( + "SELECT Nombre, Descripcion, Activo, FechaModificacion FROM dbo.Rol WHERE Codigo = 'updtest_one'"); + + Assert.Equal("Nombre Nuevo", row.Nombre); + Assert.Equal("Desc nueva", row.Descripcion); + Assert.True(row.Activo); + Assert.NotNull(row.FechaModificacion); + } + + [Fact] + public async Task UpdateAsync_NonExistentCodigo_ReturnsFalse() + { + var updated = await _repository.UpdateAsync("updtest_missing", "X", null, true); + Assert.False(updated); + } + + [Fact] + public async Task UpdateAsync_DoesNotChangeCodigo() + { + await _connection.ExecuteAsync( + "INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES ('updtest_codigo', N'Test Codigo', 1);"); + + await _repository.UpdateAsync("updtest_codigo", "Nombre Cambiado", null, true); + + var stillExists = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dbo.Rol WHERE Codigo = 'updtest_codigo';"); + Assert.Equal(1, stillExists); + } + + // ── HasActiveUsuariosAsync ────────────────────────────────────────────── + + [Fact] + public async Task HasActiveUsuariosAsync_WithActiveUsuario_ReturnsTrue() + { + // 'admin' Usuario is seeded active and references Rol.admin. + Assert.True(await _repository.HasActiveUsuariosAsync("admin")); + } + + [Fact] + public async Task HasActiveUsuariosAsync_NoUsuariosReferencing_ReturnsFalse() + { + // 'reportes' seed has no Usuario referencing it in a clean test DB. + Assert.False(await _repository.HasActiveUsuariosAsync("reportes")); + } + + [Fact] + public async Task HasActiveUsuariosAsync_OnlyInactiveUsuarioReferencing_ReturnsFalse() + { + // Insert an INACTIVE usuario referencing 'cajero'. + await _connection.ExecuteAsync( + "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " + + "VALUES ('inactivo1', '$2a$12$hash', 'Test', 'Inactivo', 'cajero', '[]', 0);"); + + Assert.False(await _repository.HasActiveUsuariosAsync("cajero")); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs index 6e1e66d..33e55f7 100644 --- a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs @@ -21,11 +21,14 @@ public class UsuarioRepositoryTests : IAsyncLifetime _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { - DbAdapter = DbAdapter.SqlServer + DbAdapter = DbAdapter.SqlServer, + // Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks. + TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")] }); - // Reset DB and seed admin user for each test class run + // Reset DB, re-seed Rol canonical table (lookup) and admin user for each test class run. await _respawner.ResetAsync(_connection); + await SeedRolCanonicalAsync(); await SeedAdminAsync(); var factory = new SqlConnectionFactory(ConnectionString); @@ -62,21 +65,44 @@ public class UsuarioRepositoryTests : IAsyncLifetime // Triangulation: case-sensitive username lookup (SQL Server UNIQUE constraint is case-insensitive by default) [Fact] - public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser() + public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser_Cajero() { - // Insert a second user + // Insert a second user with canonical rol 'cajero' (post-UDT-004 FK requires Rol.Codigo to exist). await _connection.ExecuteAsync( "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) " + - "VALUES ('vendedor1', '$2a$12$hash2', 'Juan', 'Pérez', 'vendedor', '[]')"); + "VALUES ('cajero1', '$2a$12$hash2', 'Juan', 'Pérez', 'cajero', '[]')"); var admin = await _repository.GetByUsernameAsync("admin"); - var vendedor = await _repository.GetByUsernameAsync("vendedor1"); + var cajero = await _repository.GetByUsernameAsync("cajero1"); Assert.NotNull(admin); - Assert.NotNull(vendedor); - Assert.NotEqual(admin.Id, vendedor.Id); + Assert.NotNull(cajero); + Assert.NotEqual(admin.Id, cajero.Id); Assert.Equal("admin", admin.Rol); - Assert.Equal("vendedor", vendedor.Rol); + Assert.Equal("cajero", cajero.Rol); + } + + 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); } private async Task SeedAdminAsync() diff --git a/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs new file mode 100644 index 0000000..04b47f2 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs @@ -0,0 +1,86 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Create; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Roles.Create; + +public class CreateRolCommandHandlerTests +{ + private readonly IRolRepository _repository = Substitute.For(); + private readonly CreateRolCommandHandler _handler; + + private static CreateRolCommand ValidCommand() => new("cajero_senior", "Cajero Senior", "Con más permisos"); + + public CreateRolCommandHandlerTests() + { + _handler = new CreateRolCommandHandler(_repository); + } + + [Fact] + public async Task Handle_CodigoDuplicado_ThrowsRolAlreadyExistsException() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _repository.GetByCodigoAsync("cajero_senior") + .Returns(new Rol(99, "cajero_senior", "Cajero Senior", null, true, now, null)); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + + Assert.Equal("cajero_senior", ex.Codigo); + } + + [Fact] + public async Task Handle_CodigoDuplicado_DoesNotCallAddAsync() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _repository.GetByCodigoAsync(Arg.Any()) + .Returns(new Rol(1, "cajero_senior", "X", null, true, now, null)); + + try { await _handler.Handle(ValidCommand()); } catch (RolAlreadyExistsException) { } + + await _repository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Happy_AddsAndReturnsDtoWithId() + { + _repository.GetByCodigoAsync(Arg.Any()).Returns((Rol?)null); + _repository.AddAsync(Arg.Any(), Arg.Any()).Returns(42); + + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(42, result.Id); + Assert.Equal("cajero_senior", result.Codigo); + Assert.Equal("Cajero Senior", result.Nombre); + Assert.Equal("Con más permisos", result.Descripcion); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_Happy_CallsAddAsyncOnce() + { + _repository.GetByCodigoAsync(Arg.Any()).Returns((Rol?)null); + _repository.AddAsync(Arg.Any(), Arg.Any()).Returns(5); + + await _handler.Handle(ValidCommand()); + + await _repository.Received(1).AddAsync( + Arg.Is(r => r.Codigo == "cajero_senior" && r.Nombre == "Cajero Senior" && r.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_Happy_WithNullDescripcion_PassesNullToRepository() + { + _repository.GetByCodigoAsync(Arg.Any()).Returns((Rol?)null); + _repository.AddAsync(Arg.Any(), Arg.Any()).Returns(1); + + await _handler.Handle(new CreateRolCommand("nuevo_rol", "Nuevo", null)); + + await _repository.Received(1).AddAsync( + Arg.Is(r => r.Descripcion == null), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandValidatorTests.cs new file mode 100644 index 0000000..51476a8 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandValidatorTests.cs @@ -0,0 +1,97 @@ +using FluentValidation.TestHelper; +using SIGCM2.Application.Roles.Create; + +namespace SIGCM2.Application.Tests.Roles.Create; + +public class CreateRolCommandValidatorTests +{ + private static CreateRolCommandValidator BuildValidator() => new(); + private static CreateRolCommand Valid() => new("cajero_senior", "Cajero Senior", "Cajero con permisos extendidos"); + + // ── Happy path ───────────────────────────────────────────────────────── + + [Fact] + public void Validate_Valid_NoErrors() + { + BuildValidator().TestValidate(Valid()).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_NullDescripcion_IsValid() + { + BuildValidator().TestValidate(Valid() with { Descripcion = null }).ShouldNotHaveAnyValidationErrors(); + } + + // ── Codigo ───────────────────────────────────────────────────────────── + + [Fact] + public void Validate_EmptyCodigo_HasError() + { + BuildValidator().TestValidate(Valid() with { Codigo = "" }) + .ShouldHaveValidationErrorFor(c => c.Codigo); + } + + [Fact] + public void Validate_CodigoTooShort_HasError() + { + BuildValidator().TestValidate(Valid() with { Codigo = "ab" }) + .ShouldHaveValidationErrorFor(c => c.Codigo); + } + + [Fact] + public void Validate_CodigoTooLong_HasError() + { + BuildValidator().TestValidate(Valid() with { Codigo = new string('a', 31) }) + .ShouldHaveValidationErrorFor(c => c.Codigo); + } + + [Theory] + [InlineData("abc")] // boundary short + [InlineData("cajero")] + [InlineData("operador_ctacte")] + [InlineData("jefe_publicidad")] + [InlineData("a1b2")] + public void Validate_CodigoValidFormats_NoError(string codigo) + { + BuildValidator().TestValidate(Valid() with { Codigo = codigo }) + .ShouldNotHaveValidationErrorFor(c => c.Codigo); + } + + [Theory] + [InlineData("Cajero")] // uppercase + [InlineData("1cajero")] // starts with digit + [InlineData("_cajero")] // starts with underscore + [InlineData("cajero senior")] // space + [InlineData("cajero-senior")] // dash + [InlineData("cajero.senior")] // dot + public void Validate_CodigoInvalidFormats_HasError(string codigo) + { + BuildValidator().TestValidate(Valid() with { Codigo = codigo }) + .ShouldHaveValidationErrorFor(c => c.Codigo); + } + + // ── Nombre ───────────────────────────────────────────────────────────── + + [Fact] + public void Validate_EmptyNombre_HasError() + { + BuildValidator().TestValidate(Valid() with { Nombre = "" }) + .ShouldHaveValidationErrorFor(c => c.Nombre); + } + + [Fact] + public void Validate_NombreTooLong_HasError() + { + BuildValidator().TestValidate(Valid() with { Nombre = new string('a', 61) }) + .ShouldHaveValidationErrorFor(c => c.Nombre); + } + + // ── Descripcion ──────────────────────────────────────────────────────── + + [Fact] + public void Validate_DescripcionTooLong_HasError() + { + BuildValidator().TestValidate(Valid() with { Descripcion = new string('a', 251) }) + .ShouldHaveValidationErrorFor(c => c.Descripcion); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs new file mode 100644 index 0000000..71af502 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs @@ -0,0 +1,98 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Roles.Deactivate; + +public class DeactivateRolCommandHandlerTests +{ + private readonly IRolRepository _repository = Substitute.For(); + private readonly DeactivateRolCommandHandler _handler; + + private static Rol RolActive(string codigo, int id = 10) + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + return new Rol(id, codigo, "Nombre", "Desc", true, now, null); + } + + public DeactivateRolCommandHandlerTests() + { + _handler = new DeactivateRolCommandHandler(_repository); + } + + [Fact] + public async Task Handle_NonExistentCodigo_ThrowsRolNotFoundException() + { + _repository.GetByCodigoAsync("missing").Returns((Rol?)null); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateRolCommand("missing"))); + + Assert.Equal("missing", ex.Codigo); + } + + [Fact] + public async Task Handle_CodigoConUsuariosActivos_ThrowsRolInUseException() + { + _repository.GetByCodigoAsync("cajero").Returns(RolActive("cajero")); + _repository.HasActiveUsuariosAsync("cajero").Returns(true); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateRolCommand("cajero"))); + + Assert.Equal("cajero", ex.Codigo); + } + + [Fact] + public async Task Handle_RolInUse_DoesNotCallUpdateAsync() + { + _repository.GetByCodigoAsync("cajero").Returns(RolActive("cajero")); + _repository.HasActiveUsuariosAsync("cajero").Returns(true); + + try { await _handler.Handle(new DeactivateRolCommand("cajero")); } catch (RolInUseException) { } + + await _repository.DidNotReceive().UpdateAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_Happy_SetsActivoFalseAndReturnsDto() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + var afterDeactivation = new DateTime(2026, 4, 15, 13, 0, 0, DateTimeKind.Utc); + + _repository.GetByCodigoAsync("reportes") + .Returns( + RolActive("reportes", 20), + new Rol(20, "reportes", "Nombre", "Desc", false, now, afterDeactivation)); + _repository.HasActiveUsuariosAsync("reportes").Returns(false); + _repository.UpdateAsync("reportes", "Nombre", "Desc", false, Arg.Any()) + .Returns(true); + + var dto = await _handler.Handle(new DeactivateRolCommand("reportes")); + + Assert.Equal(20, dto.Id); + Assert.False(dto.Activo); + Assert.Equal(afterDeactivation, dto.FechaModificacion); + } + + [Fact] + public async Task Handle_Happy_CallsUpdateAsyncWithActivoFalse() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + + _repository.GetByCodigoAsync("reportes") + .Returns( + RolActive("reportes"), + new Rol(10, "reportes", "Nombre", "Desc", false, now, now)); + _repository.HasActiveUsuariosAsync("reportes").Returns(false); + _repository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + await _handler.Handle(new DeactivateRolCommand("reportes")); + + await _repository.Received(1).UpdateAsync("reportes", "Nombre", "Desc", false, Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/Get/GetRolByCodigoQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Get/GetRolByCodigoQueryHandlerTests.cs new file mode 100644 index 0000000..289e9e3 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Get/GetRolByCodigoQueryHandlerTests.cs @@ -0,0 +1,43 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Get; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Roles.Get; + +public class GetRolByCodigoQueryHandlerTests +{ + private readonly IRolRepository _repository = Substitute.For(); + private readonly GetRolByCodigoQueryHandler _handler; + + public GetRolByCodigoQueryHandlerTests() + { + _handler = new GetRolByCodigoQueryHandler(_repository); + } + + [Fact] + public async Task Handle_ExistingCodigo_ReturnsDto() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _repository.GetByCodigoAsync("cajero").Returns(new Rol(5, "cajero", "Cajero", "Desc", true, now, null)); + + var dto = await _handler.Handle(new GetRolByCodigoQuery("cajero")); + + Assert.Equal(5, dto.Id); + Assert.Equal("cajero", dto.Codigo); + Assert.Equal("Cajero", dto.Nombre); + Assert.True(dto.Activo); + } + + [Fact] + public async Task Handle_NonExistentCodigo_ThrowsRolNotFoundException() + { + _repository.GetByCodigoAsync("missing").Returns((Rol?)null); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new GetRolByCodigoQuery("missing"))); + + Assert.Equal("missing", ex.Codigo); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/List/ListRolesQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/List/ListRolesQueryHandlerTests.cs new file mode 100644 index 0000000..8c4510a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/List/ListRolesQueryHandlerTests.cs @@ -0,0 +1,62 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Roles.List; + +public class ListRolesQueryHandlerTests +{ + private readonly IRolRepository _repository = Substitute.For(); + private readonly ListRolesQueryHandler _handler; + + public ListRolesQueryHandlerTests() + { + _handler = new ListRolesQueryHandler(_repository); + } + + [Fact] + public async Task Handle_ReturnsAllRolesFromRepositoryAsDtos() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _repository.ListAsync().Returns(new List + { + new(1, "admin", "Administrador", "Desc admin", true, now, null), + new(2, "cajero", "Cajero", "Desc cajero", true, now, null), + }); + + var result = await _handler.Handle(new ListRolesQuery()); + + Assert.Equal(2, result.Count); + Assert.Equal("admin", result[0].Codigo); + Assert.Equal("Administrador", result[0].Nombre); + Assert.Equal("cajero", result[1].Codigo); + } + + [Fact] + public async Task Handle_IncludesInactiveRoles() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _repository.ListAsync().Returns(new List + { + new(1, "active_code", "Activo", null, true, now, null), + new(2, "inactive_code", "Inactivo", null, false, now, null), + }); + + var result = await _handler.Handle(new ListRolesQuery()); + + Assert.Equal(2, result.Count); + Assert.True(result[0].Activo); + Assert.False(result[1].Activo); + } + + [Fact] + public async Task Handle_EmptyRepository_ReturnsEmptyList() + { + _repository.ListAsync().Returns(new List()); + + var result = await _handler.Handle(new ListRolesQuery()); + + Assert.Empty(result); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs new file mode 100644 index 0000000..9edb511 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs @@ -0,0 +1,64 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Roles.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Roles.Update; + +public class UpdateRolCommandHandlerTests +{ + private readonly IRolRepository _repository = Substitute.For(); + private readonly UpdateRolCommandHandler _handler; + + public UpdateRolCommandHandlerTests() + { + _handler = new UpdateRolCommandHandler(_repository); + } + + [Fact] + public async Task Handle_NonExistentCodigo_ThrowsRolNotFoundException() + { + _repository.UpdateAsync("missing", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new UpdateRolCommand("missing", "X", null, true))); + + Assert.Equal("missing", ex.Codigo); + } + + [Fact] + public async Task Handle_Happy_ReturnsDtoWithUpdatedFields() + { + var fechaCreacion = new DateTime(2026, 4, 10, 9, 0, 0, DateTimeKind.Utc); + var fechaModificacion = new DateTime(2026, 4, 15, 12, 0, 0, DateTimeKind.Utc); + + _repository.UpdateAsync("cajero", "Cajero V2", "Desc V2", true, Arg.Any()) + .Returns(true); + _repository.GetByCodigoAsync("cajero") + .Returns(new Rol(10, "cajero", "Cajero V2", "Desc V2", true, fechaCreacion, fechaModificacion)); + + var dto = await _handler.Handle(new UpdateRolCommand("cajero", "Cajero V2", "Desc V2", true)); + + Assert.Equal(10, dto.Id); + Assert.Equal("Cajero V2", dto.Nombre); + Assert.Equal("Desc V2", dto.Descripcion); + Assert.True(dto.Activo); + Assert.Equal(fechaModificacion, dto.FechaModificacion); + } + + [Fact] + public async Task Handle_Happy_CallsUpdateAsyncWithExactFields() + { + var now = new DateTime(2026, 4, 15, 12, 0, 0, DateTimeKind.Utc); + _repository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + _repository.GetByCodigoAsync("cajero") + .Returns(new Rol(1, "cajero", "X", null, false, now, now)); + + await _handler.Handle(new UpdateRolCommand("cajero", "X", null, false)); + + await _repository.Received(1).UpdateAsync("cajero", "X", null, false, Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandValidatorTests.cs new file mode 100644 index 0000000..562c51a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandValidatorTests.cs @@ -0,0 +1,51 @@ +using FluentValidation.TestHelper; +using SIGCM2.Application.Roles.Update; + +namespace SIGCM2.Application.Tests.Roles.Update; + +public class UpdateRolCommandValidatorTests +{ + private static UpdateRolCommandValidator BuildValidator() => new(); + private static UpdateRolCommand Valid() => new("cajero", "Cajero Updated", "Desc updated", true); + + [Fact] + public void Validate_Valid_NoErrors() + { + BuildValidator().TestValidate(Valid()).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyCodigo_HasError() + { + BuildValidator().TestValidate(Valid() with { Codigo = "" }) + .ShouldHaveValidationErrorFor(c => c.Codigo); + } + + [Fact] + public void Validate_EmptyNombre_HasError() + { + BuildValidator().TestValidate(Valid() with { Nombre = "" }) + .ShouldHaveValidationErrorFor(c => c.Nombre); + } + + [Fact] + public void Validate_NombreTooLong_HasError() + { + BuildValidator().TestValidate(Valid() with { Nombre = new string('a', 61) }) + .ShouldHaveValidationErrorFor(c => c.Nombre); + } + + [Fact] + public void Validate_NullDescripcion_Allowed() + { + BuildValidator().TestValidate(Valid() with { Descripcion = null }) + .ShouldNotHaveValidationErrorFor(c => c.Descripcion); + } + + [Fact] + public void Validate_DescripcionTooLong_HasError() + { + BuildValidator().TestValidate(Valid() with { Descripcion = new string('a', 251) }) + .ShouldHaveValidationErrorFor(c => c.Descripcion); + } +} diff --git a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs index 53dacf8..f1c5ced 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs @@ -15,7 +15,7 @@ public class CreateUsuarioCommandValidatorTests Nombre: "Juan", Apellido: "Pérez", Email: null, - Rol: "vendedor"); + Rol: "cajero"); // ── Happy paths ────────────────────────────────────────────────────────── @@ -156,9 +156,13 @@ public class CreateUsuarioCommandValidatorTests [Theory] [InlineData("admin")] - [InlineData("vendedor")] - [InlineData("tasador")] - [InlineData("consulta")] + [InlineData("cajero")] + [InlineData("operador_ctacte")] + [InlineData("picadora")] + [InlineData("jefe_publicidad")] + [InlineData("productor")] + [InlineData("diagramacion")] + [InlineData("reportes")] public void Validate_ValidRoles_NoError(string rol) { var cmd = ValidCommand() with { Rol = rol }; diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index d4b1c63..ac9419d 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -28,7 +28,9 @@ public sealed class SqlTestFixture : IAsyncLifetime _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { - DbAdapter = DbAdapter.SqlServer + DbAdapter = DbAdapter.SqlServer, + // Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks. + TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")] }); await ResetAndSeedAsync(); @@ -37,9 +39,33 @@ public sealed class SqlTestFixture : IAsyncLifetime public async Task ResetAndSeedAsync() { await _respawner.ResetAsync(_connection); + await SeedRolCanonicalAsync(); await SeedAdminAsync(); } + 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); + } + public async Task DisposeAsync() { if (_connection is not null) -- 2.49.1 From 6f999b8fcd6e5f30d202fd7279172770363c7261 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 12:50:24 -0300 Subject: [PATCH 2/4] feat(api): UDT-004 controller de roles + refactor validator UDT-003 a lookup dinamico MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RolesController /api/v1/roles CRUD admin-only: GET list, GET {codigo}, POST, PUT, DELETE (soft-delete con guard 409) - ExceptionFilter: mapea RolNotFound (404), RolAlreadyExists (409), RolInUse (409) - DI: registra 5 handlers de Roles (Application) y IRolRepository/RolRepository (Infrastructure) - CreateUsuarioCommandValidator: reemplaza whitelist hardcoded por IRolRepository.ExistsActiveByCodigoAsync via MustAsync; constructor recibe (AuthOptions, IRolRepository) - Tests: 202 verdes (173 application + 29 api). Nuevas: RolesEndpointTests (13 integration), CreateUsuarioCommandValidatorTests reescrito con NSubstitute mock, CreateUsuario_WithInactiveRol_Returns400 en Api.Tests - Fix: ApiIntegration pasa de IClassFixture (N factories) a ICollectionFixture (1 factory shared) — evitaba ObjectDisposedException sobre RSABCrypt al compartir coleccion con multiples test classes - tests/tests.runsettings: MaxCpuCount=1 para evitar race entre assemblies sobre SIGCM2_Test --- .../SIGCM2.Api/Controllers/RolesController.cs | 127 +++++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 36 ++ .../SIGCM2.Application/DependencyInjection.cs | 13 + .../Create/CreateUsuarioCommandValidator.cs | 20 +- .../DependencyInjection.cs | 1 + .../ApiIntegrationCollection.cs | 20 + .../Auth/AuthControllerTests.cs | 2 +- .../Roles/RolesEndpointTests.cs | 353 ++++++++++++++++++ .../Usuarios/CreateUsuarioEndpointTests.cs | 48 ++- .../CreateUsuarioCommandValidatorTests.cs | 166 ++++---- tests/tests.runsettings | 16 + 11 files changed, 722 insertions(+), 80 deletions(-) create mode 100644 src/api/SIGCM2.Api/Controllers/RolesController.cs create mode 100644 tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs create mode 100644 tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs create mode 100644 tests/tests.runsettings diff --git a/src/api/SIGCM2.Api/Controllers/RolesController.cs b/src/api/SIGCM2.Api/Controllers/RolesController.cs new file mode 100644 index 0000000..6329828 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/RolesController.cs @@ -0,0 +1,127 @@ +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Roles.Create; +using SIGCM2.Application.Roles.Deactivate; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Application.Roles.Get; +using SIGCM2.Application.Roles.List; +using SIGCM2.Application.Roles.Update; + +namespace SIGCM2.Api.Controllers; + +[ApiController] +[Route("api/v1/roles")] +[Authorize(Roles = "admin")] +public sealed class RolesController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public RolesController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + /// Lists all roles (including inactive). Requires admin role. + [HttpGet] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task List() + { + var result = await _dispatcher.Send>(new ListRolesQuery()); + return Ok(result); + } + + /// Gets a role by its code. Requires admin role. + [HttpGet("{codigo}")] + [ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetByCodigo(string codigo) + { + var result = await _dispatcher.Send(new GetRolByCodigoQuery(codigo)); + return Ok(result); + } + + /// Creates a new role. Requires admin role. + [HttpPost] + [ProducesResponseType(typeof(RolCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Create([FromBody] CreateRolRequest request) + { + var command = new CreateRolCommand( + Codigo: request.Codigo ?? string.Empty, + Nombre: request.Nombre ?? string.Empty, + Descripcion: request.Descripcion); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetByCodigo), new { codigo = result.Codigo }, result); + } + + /// Updates a role (codigo is immutable; route wins over body). Requires admin role. + [HttpPut("{codigo}")] + [ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update(string codigo, [FromBody] UpdateRolRequest request) + { + // Codigo comes from the route — body.codigo (if present) is ignored by design. + var command = new UpdateRolCommand( + Codigo: codigo, + Nombre: request.Nombre ?? string.Empty, + Descripcion: request.Descripcion, + Activo: request.Activo); + + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Soft-deletes (deactivates) a role. 409 if active usuarios reference it. Requires admin role. + [HttpDelete("{codigo}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Deactivate(string codigo) + { + await _dispatcher.Send(new DeactivateRolCommand(codigo)); + return NoContent(); + } +} + +public sealed record CreateRolRequest(string? Codigo, string? Nombre, string? Descripcion); +public sealed record UpdateRolRequest(string? Nombre, string? Descripcion, bool Activo); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index c61a26d..13b4b5a 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -71,6 +71,42 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + case RolNotFoundException rolNotFoundEx: + context.Result = new ObjectResult(new + { + error = "rol_not_found", + message = rolNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + case RolAlreadyExistsException rolExistsEx: + context.Result = new ObjectResult(new + { + error = "rol_already_exists", + message = rolExistsEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case RolInUseException rolInUseEx: + context.Result = new ObjectResult(new + { + error = "rol_in_use", + message = rolInUseEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + case ValidationException validationEx: var errors = validationEx.Errors .GroupBy(e => e.PropertyName) diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 068903f..cba09e3 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -4,6 +4,12 @@ using SIGCM2.Application.Abstractions; using SIGCM2.Application.Auth.Login; using SIGCM2.Application.Auth.Logout; using SIGCM2.Application.Auth.Refresh; +using SIGCM2.Application.Roles.Create; +using SIGCM2.Application.Roles.Deactivate; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Application.Roles.Get; +using SIGCM2.Application.Roles.List; +using SIGCM2.Application.Roles.Update; using SIGCM2.Application.Usuarios.Create; namespace SIGCM2.Application; @@ -18,6 +24,13 @@ public static class DependencyInjection services.AddScoped, LogoutCommandHandler>(); services.AddScoped, CreateUsuarioCommandHandler>(); + // Roles (UDT-004) + services.AddScoped>, ListRolesQueryHandler>(); + services.AddScoped, GetRolByCodigoQueryHandler>(); + services.AddScoped, CreateRolCommandHandler>(); + services.AddScoped, UpdateRolCommandHandler>(); + services.AddScoped, DeactivateRolCommandHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs b/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs index 2ede281..b2fbbf7 100644 --- a/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs +++ b/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs @@ -1,27 +1,18 @@ using FluentValidation; +using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Auth; namespace SIGCM2.Application.Usuarios.Create; public sealed class CreateUsuarioCommandValidator : AbstractValidator { - // Whitelist aligned with canonical seeds in dbo.Rol (migration V003). - // Phase 5 of UDT-004 will replace this array with an async lookup against IRolRepository. - private static readonly string[] ValidRoles = - [ - "admin", "cajero", "operador_ctacte", "picadora", - "jefe_publicidad", "productor", "diagramacion", "reportes" - ]; - private const int UsernameMinLength = 3; private const int UsernameMaxLength = 50; private const int NombreMaxLength = 100; private const int ApellidoMaxLength = 100; private const int EmailMaxLength = 150; - public CreateUsuarioCommandValidator() : this(new AuthOptions()) { } - - public CreateUsuarioCommandValidator(AuthOptions authOptions) + public CreateUsuarioCommandValidator(AuthOptions authOptions, IRolRepository rolRepository) { RuleFor(x => x.Username) .NotEmpty().WithMessage("El nombre de usuario es requerido.") @@ -52,10 +43,13 @@ public sealed class CreateUsuarioCommandValidator : AbstractValidator x.Email is not null); + // Rol: lookup dinámico contra dbo.Rol (UDT-004). + // MustAsync requiere ValidateAsync en el call site — controllers ya usan ValidateAsync. RuleFor(x => x.Rol) .NotEmpty().WithMessage("El rol es requerido.") - .Must(r => ValidRoles.Contains(r)) - .WithMessage($"El rol debe ser uno de: {string.Join(", ", ValidRoles)}."); + .MustAsync(async (codigo, ct) => + !string.IsNullOrEmpty(codigo) && await rolRepository.ExistsActiveByCodigoAsync(codigo, ct)) + .WithMessage("El rol debe existir en el sistema y estar activo."); } private static bool ContainsLetter(string value) => diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 0f91ae0..f1e4a02 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -28,6 +28,7 @@ public static class DependencyInjection services.AddSingleton(new SqlConnectionFactory(connectionString)); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs b/tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs new file mode 100644 index 0000000..6de18d4 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs @@ -0,0 +1,20 @@ +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Api.Tests; + +/// +/// Shared collection for all Api integration tests. +/// Uses ICollectionFixture so a SINGLE TestWebAppFactory (and its RSA key singleton) +/// is shared across all test classes in the "ApiIntegration" collection. +/// +/// Previously each class used IClassFixture which spawned one factory per class; +/// that created N factories sequentially in the same process, and the RSA key +/// singleton from an earlier factory could leak into a later factory's DI graph +/// (producing ObjectDisposedException "RSABCrypt" on first signing). +/// +[CollectionDefinition("ApiIntegration")] +public sealed class ApiIntegrationCollection : ICollectionFixture +{ + // Intentionally empty: this class only exists to declare the collection/fixture binding. +} diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs index e3b56ab..c218178 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -6,7 +6,7 @@ using SIGCM2.TestSupport; namespace SIGCM2.Api.Tests.Auth; [Collection("ApiIntegration")] -public class AuthControllerTests : IClassFixture +public class AuthControllerTests { private readonly HttpClient _client; diff --git a/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs b/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs new file mode 100644 index 0000000..f32011d --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs @@ -0,0 +1,353 @@ +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.Roles; + +/// +/// Integration tests for /api/v1/roles (UDT-004). +/// +[Collection("ApiIntegration")] +public sealed class RolesEndpointTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private const string Endpoint = "/api/v1/roles"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public RolesEndpointTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task DisposeAsync() => Task.CompletedTask; + + private async Task GetBearerTokenAsync(string username, string password) + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new { username, password }); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException($"Login failed ({(int)response.StatusCode}): {body}"); + } + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null) + { + var request = new HttpRequestMessage(method, url); + if (bearerToken is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + if (body is not null) + request.Content = JsonContent.Create(body); + return request; + } + + private static async Task DeleteRolIfExistsAsync(string codigo) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo }); + } + + 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 ──────────────────────────────────────────────────── + + [Fact] + public async Task List_WithoutAuth_Returns401() + { + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, Endpoint)); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task Create_WithNonAdmin_Returns403() + { + var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword); + const string nonAdminUser = "rolestest_nonadmin"; + + // Create a non-admin user via endpoint (admin can still create users). + using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new + { + username = nonAdminUser, + password = "Secure1234!", + nombre = "Non", + apellido = "Admin", + email = (string?)null, + rol = "cajero" + }, adminToken); + var mkUserResp = await _client.SendAsync(mkUser); + if (mkUserResp.StatusCode != HttpStatusCode.Created && mkUserResp.StatusCode != HttpStatusCode.Conflict) + Assert.Fail($"Seed non-admin user failed: {mkUserResp.StatusCode}"); + + try + { + var cajeroToken = await GetBearerTokenAsync(nonAdminUser, "Secure1234!"); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo = "nuevo_test", + nombre = "Test", + descripcion = (string?)null + }, cajeroToken); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + } + finally + { + await DeleteUsuarioIfExistsAsync(nonAdminUser); + } + } + + // ── List ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task List_WithAdmin_Returns200WithCanonicalSeeds() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + using var req = BuildRequest(HttpMethod.Get, Endpoint, bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var list = await resp.Content.ReadFromJsonAsync(); + var codes = list.EnumerateArray().Select(r => r.GetProperty("codigo").GetString()).ToHashSet(); + foreach (var c in new[] { "admin", "cajero", "operador_ctacte", "picadora", "jefe_publicidad", "productor", "diagramacion", "reportes" }) + Assert.Contains(c, codes); + } + + // ── Get ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetByCodigo_Existing_Returns200() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, $"{Endpoint}/cajero", bearerToken: token)); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("cajero", body.GetProperty("codigo").GetString()); + Assert.Equal("Cajero", body.GetProperty("nombre").GetString()); + Assert.True(body.GetProperty("activo").GetBoolean()); + } + + [Fact] + public async Task GetByCodigo_NonExistent_Returns404() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, $"{Endpoint}/no_existe_xyz", bearerToken: token)); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("rol_not_found", body.GetProperty("error").GetString()); + } + + // ── Create ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Create_NewRol_Returns201() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + const string codigo = "endpoint_new_rol"; + + try + { + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Endpoint New", + descripcion = "Creado por integration test" + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(codigo, body.GetProperty("codigo").GetString()); + Assert.True(body.GetProperty("id").GetInt32() > 0); + Assert.True(body.GetProperty("activo").GetBoolean()); + } + finally + { + await DeleteRolIfExistsAsync(codigo); + } + } + + [Fact] + public async Task Create_CodigoDuplicado_Returns409() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo = "cajero", + nombre = "Duplicate", + descripcion = (string?)null + }, token); + + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("rol_already_exists", body.GetProperty("error").GetString()); + } + + [Fact] + public async Task Create_InvalidCodigoFormat_Returns400() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo = "Cajero Senior", // uppercase + space — invalid + nombre = "Bad", + descripcion = (string?)null + }, token); + + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + // ── Update ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Update_Existing_Returns200WithUpdatedNombre() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + const string codigo = "endpoint_upd_rol"; + + // Seed a rol + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync( + "INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion, Activo) VALUES (@Codigo, N'Viejo', N'Desc vieja', 1);", + new { Codigo = codigo }); + + try + { + using var req = BuildRequest(HttpMethod.Put, $"{Endpoint}/{codigo}", new + { + nombre = "Nuevo Nombre", + descripcion = "Desc nueva", + activo = true + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("Nuevo Nombre", body.GetProperty("nombre").GetString()); + Assert.Equal("Desc nueva", body.GetProperty("descripcion").GetString()); + Assert.Equal(codigo, body.GetProperty("codigo").GetString()); + } + finally + { + await DeleteRolIfExistsAsync(codigo); + } + } + + [Fact] + public async Task Update_NonExistent_Returns404() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + using var req = BuildRequest(HttpMethod.Put, $"{Endpoint}/inexistente_abc", new + { + nombre = "X", + descripcion = (string?)null, + activo = true + }, token); + + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + // ── Delete (soft) ─────────────────────────────────────────────────────── + + [Fact] + public async Task Delete_WithoutActiveUsuarios_Returns204() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + const string codigo = "endpoint_del_rol"; + + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync( + "INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'Temp', 1);", + new { Codigo = codigo }); + + try + { + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/{codigo}", bearerToken: token)); + Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode); + + var activo = await conn.ExecuteScalarAsync( + "SELECT Activo FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo }); + Assert.False(activo); + } + finally + { + await DeleteRolIfExistsAsync(codigo); + } + } + + [Fact] + public async Task Delete_WithActiveUsuarios_Returns409() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + const string codigo = "endpoint_del_inuse"; + const string testUser = "endpoint_del_user"; + + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync( + "INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'InUse', 1);", + new { Codigo = codigo }); + await conn.ExecuteAsync( + "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " + + "VALUES (@Username, '$2a$12$hash', 'Test', 'User', @Codigo, '[]', 1);", + new { Username = testUser, Codigo = codigo }); + + try + { + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/{codigo}", bearerToken: token)); + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("rol_in_use", body.GetProperty("error").GetString()); + + var activo = await conn.ExecuteScalarAsync( + "SELECT Activo FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo }); + Assert.True(activo); + } + finally + { + await DeleteUsuarioIfExistsAsync(testUser); + await DeleteRolIfExistsAsync(codigo); + } + } + + [Fact] + public async Task Delete_NonExistent_Returns404() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/no_existe_del", bearerToken: token)); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + +} diff --git a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs index a84669e..0864609 100644 --- a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs @@ -11,11 +11,11 @@ namespace SIGCM2.Api.Tests.Usuarios; /// /// Integration tests for POST api/v1/users (UDT-003). /// These tests run against SIGCM2_Test database via TestWebAppFactory. -/// Each test class instance gets the full WebApp factory (shared via IClassFixture). -/// DB reset happens once per test run (SqlTestFixture.InitializeAsync → ResetAndSeedAsync). +/// TestWebAppFactory is shared across the whole "ApiIntegration" collection +/// (see ApiIntegrationCollection) — one factory, one RSA singleton, one DB state. /// [Collection("ApiIntegration")] -public sealed class CreateUsuarioEndpointTests : IClassFixture, IAsyncLifetime +public sealed class CreateUsuarioEndpointTests : IAsyncLifetime { private const string TestConnectionString = "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; @@ -387,4 +387,46 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture(); + Assert.True(json.TryGetProperty("errors", out var errors), "Response must contain 'errors'"); + // Validation error should be on the Rol field + Assert.True(errors.EnumerateObject().Any(p => p.Name.Equals("Rol", StringComparison.OrdinalIgnoreCase))); + } + finally + { + await DeleteUsuarioAsync(testUser); + await conn.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo }); + } + } } diff --git a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs index f1c5ced..aba9480 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs @@ -1,4 +1,6 @@ using FluentValidation.TestHelper; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Auth; using SIGCM2.Application.Usuarios.Create; @@ -6,8 +8,19 @@ namespace SIGCM2.Application.Tests.Usuarios.Create; public class CreateUsuarioCommandValidatorTests { - private static CreateUsuarioCommandValidator BuildValidator(AuthOptions? opts = null) => - new(opts ?? new AuthOptions()); + private readonly IRolRepository _roles = Substitute.For(); + + public CreateUsuarioCommandValidatorTests() + { + // Default mock behavior: canonical seeds are active; unknown codes are not. + var canonical = new[] { "admin", "cajero", "operador_ctacte", "picadora", + "jefe_publicidad", "productor", "diagramacion", "reportes" }; + foreach (var code in canonical) + _roles.ExistsActiveByCodigoAsync(code, Arg.Any()).Returns(true); + } + + private CreateUsuarioCommandValidator BuildValidator(AuthOptions? opts = null) => + new(opts ?? new AuthOptions(), _roles); private static CreateUsuarioCommand ValidCommand() => new( Username: "operador1", @@ -20,136 +33,138 @@ public class CreateUsuarioCommandValidatorTests // ── Happy paths ────────────────────────────────────────────────────────── [Fact] - public void Validate_ValidCommand_NoErrors() + public async Task Validate_ValidCommand_NoErrors() { - var result = BuildValidator().TestValidate(ValidCommand()); + var result = await BuildValidator().TestValidateAsync(ValidCommand()); result.ShouldNotHaveAnyValidationErrors(); } [Fact] - public void Validate_NullEmail_IsValid() + public async Task Validate_NullEmail_IsValid() { var cmd = ValidCommand() with { Email = null }; - BuildValidator().TestValidate(cmd).ShouldNotHaveAnyValidationErrors(); + var result = await BuildValidator().TestValidateAsync(cmd); + result.ShouldNotHaveAnyValidationErrors(); } [Fact] - public void Validate_ValidEmailPresent_NoErrors() + public async Task Validate_ValidEmailPresent_NoErrors() { var cmd = ValidCommand() with { Email = "juan@example.com" }; - BuildValidator().TestValidate(cmd).ShouldNotHaveAnyValidationErrors(); + var result = await BuildValidator().TestValidateAsync(cmd); + result.ShouldNotHaveAnyValidationErrors(); } // ── Username ───────────────────────────────────────────────────────────── [Fact] - public void Validate_EmptyUsername_HasError() + public async Task Validate_EmptyUsername_HasError() { var cmd = ValidCommand() with { Username = "" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username); } [Fact] - public void Validate_UsernameTooShort_HasError() + public async Task Validate_UsernameTooShort_HasError() { var cmd = ValidCommand() with { Username = "ab" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username); } [Fact] - public void Validate_UsernameTooLong_HasError() + public async Task Validate_UsernameTooLong_HasError() { var cmd = ValidCommand() with { Username = new string('a', 51) }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username); } [Theory] - [InlineData("abc")] // 3 chars — boundary valid - [InlineData("user.name")] // dot allowed - [InlineData("user-name")] // dash allowed - [InlineData("user_name")] // underscore allowed - [InlineData("user123")] // alphanumeric - public void Validate_UsernameValidFormats_NoError(string username) + [InlineData("abc")] + [InlineData("user.name")] + [InlineData("user-name")] + [InlineData("user_name")] + [InlineData("user123")] + public async Task Validate_UsernameValidFormats_NoError(string username) { var cmd = ValidCommand() with { Username = username }; - BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Username); + (await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Username); } [Theory] - [InlineData("user name")] // space not allowed - [InlineData("user@name")] // @ not allowed - [InlineData("user#1")] // # not allowed - public void Validate_UsernameInvalidChars_HasError(string username) + [InlineData("user name")] + [InlineData("user@name")] + [InlineData("user#1")] + public async Task Validate_UsernameInvalidChars_HasError(string username) { var cmd = ValidCommand() with { Username = username }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username); } // ── Password ───────────────────────────────────────────────────────────── [Fact] - public void Validate_EmptyPassword_HasError() + public async Task Validate_EmptyPassword_HasError() { var cmd = ValidCommand() with { Password = "" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password); } [Fact] - public void Validate_PasswordTooShort_HasError() + public async Task Validate_PasswordTooShort_HasError() { - var cmd = ValidCommand() with { Password = "Ab1cd5" }; // 6 chars < 8 - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password); + var cmd = ValidCommand() with { Password = "Ab1cd5" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password); } [Fact] - public void Validate_PasswordNoLetter_HasError() + public async Task Validate_PasswordNoLetter_HasError() { - var cmd = ValidCommand() with { Password = "12345678" }; // digits only - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password); + var cmd = ValidCommand() with { Password = "12345678" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password); } [Fact] - public void Validate_PasswordNoDigit_HasError() + public async Task Validate_PasswordNoDigit_HasError() { - var cmd = ValidCommand() with { Password = "abcdefgh" }; // letters only - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password); + var cmd = ValidCommand() with { Password = "abcdefgh" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password); } [Fact] - public void Validate_PasswordExactMinLength_NoError() + public async Task Validate_PasswordExactMinLength_NoError() { - var cmd = ValidCommand() with { Password = "Secre123" }; // exactly 8, letter + digit - BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Password); + var cmd = ValidCommand() with { Password = "Secre123" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Password); } // ── Nombre / Apellido ──────────────────────────────────────────────────── [Fact] - public void Validate_EmptyNombre_HasError() + public async Task Validate_EmptyNombre_HasError() { var cmd = ValidCommand() with { Nombre = "" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Nombre); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Nombre); } [Fact] - public void Validate_EmptyApellido_HasError() + public async Task Validate_EmptyApellido_HasError() { var cmd = ValidCommand() with { Apellido = "" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Apellido); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Apellido); } [Fact] - public void Validate_NombreTooLong_HasError() + public async Task Validate_NombreTooLong_HasError() { var cmd = ValidCommand() with { Nombre = new string('a', 101) }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Nombre); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Nombre); } [Fact] - public void Validate_ApellidoTooLong_HasError() + public async Task Validate_ApellidoTooLong_HasError() { var cmd = ValidCommand() with { Apellido = new string('a', 101) }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Apellido); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Apellido); } // ── Rol ────────────────────────────────────────────────────────────────── @@ -163,36 +178,61 @@ public class CreateUsuarioCommandValidatorTests [InlineData("productor")] [InlineData("diagramacion")] [InlineData("reportes")] - public void Validate_ValidRoles_NoError(string rol) + public async Task Validate_CanonicalActiveRoles_NoError(string rol) { var cmd = ValidCommand() with { Rol = rol }; - BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Rol); + (await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Rol); } - [Theory] - [InlineData("superuser")] - [InlineData("ADMIN")] // case-sensitive - [InlineData("root")] - [InlineData("")] - public void Validate_InvalidRol_HasError(string rol) + [Fact] + public async Task Validate_RolInexistente_HasError() { - var cmd = ValidCommand() with { Rol = rol }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Rol); + _roles.ExistsActiveByCodigoAsync("superuser", Arg.Any()).Returns(false); + + var cmd = ValidCommand() with { Rol = "superuser" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol); + } + + [Fact] + public async Task Validate_RolInactivo_HasError() + { + // The repository reports NOT active (soft-deleted rol) → validator rejects. + _roles.ExistsActiveByCodigoAsync("picadora", Arg.Any()).Returns(false); + + var cmd = ValidCommand() with { Rol = "picadora" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol); + } + + [Fact] + public async Task Validate_RolEmptyString_HasError() + { + var cmd = ValidCommand() with { Rol = "" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol); + } + + [Fact] + public async Task Validate_RolCaseSensitive_HasError() + { + // 'ADMIN' uppercase is not a canonical code; mock returns false by default. + _roles.ExistsActiveByCodigoAsync("ADMIN", Arg.Any()).Returns(false); + + var cmd = ValidCommand() with { Rol = "ADMIN" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol); } // ── Email ──────────────────────────────────────────────────────────────── [Fact] - public void Validate_InvalidEmail_HasError() + public async Task Validate_InvalidEmail_HasError() { var cmd = ValidCommand() with { Email = "not-an-email" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Email); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Email); } [Fact] - public void Validate_EmailTooLong_HasError() + public async Task Validate_EmailTooLong_HasError() { - var cmd = ValidCommand() with { Email = new string('a', 145) + "@b.com" }; // >150 - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Email); + var cmd = ValidCommand() with { Email = new string('a', 145) + "@b.com" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Email); } } diff --git a/tests/tests.runsettings b/tests/tests.runsettings new file mode 100644 index 0000000..781e11c --- /dev/null +++ b/tests/tests.runsettings @@ -0,0 +1,16 @@ + + + + + 1 + + -- 2.49.1 From fae06fb8b80a4662774c95635f85f8aaae081f0b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 12:58:08 -0300 Subject: [PATCH 3/4] feat(web): UDT-004 gestion de roles + UserForm dinamico - features/roles: API clients (list/get/create/update/deactivate), TanStack Query hooks, RolForm (create + edit variants), RolesList con acciones y guard 409, paginas RolesPage/NewRolPage/EditRolPage - router.tsx: rutas /admin/roles, /admin/roles/nuevo, /admin/roles/:codigo/editar - AppSidebar: nav Roles (admin-only) - features/users: useRolesForSelect wrapper (filtra activo=true), UserForm fetchea roles async con loading/error states; elimina ROL_OPTIONS hardcoded - tests: 47 vitest verdes (10 authStore + 5 auth api + 7 axios + 3 useCreateUser + 3 RolesList + 5 LoginPage + 7 UserForm + 7 RolForm). Typecheck limpio --- src/web/src/components/layout/AppSidebar.tsx | 13 + src/web/src/features/roles/api/createRole.ts | 7 + .../src/features/roles/api/deactivateRole.ts | 5 + src/web/src/features/roles/api/getRol.ts | 7 + src/web/src/features/roles/api/listRoles.ts | 7 + src/web/src/features/roles/api/types.ts | 29 +++ src/web/src/features/roles/api/updateRole.ts | 10 + .../src/features/roles/components/RolForm.tsx | 245 ++++++++++++++++++ .../features/roles/components/RolesList.tsx | 97 +++++++ .../src/features/roles/hooks/useCreateRole.ts | 14 + .../features/roles/hooks/useDeactivateRole.ts | 13 + src/web/src/features/roles/hooks/useRol.ts | 10 + src/web/src/features/roles/hooks/useRoles.ts | 12 + .../src/features/roles/hooks/useUpdateRole.ts | 20 ++ .../src/features/roles/pages/EditRolPage.tsx | 46 ++++ .../src/features/roles/pages/NewRolPage.tsx | 36 +++ .../src/features/roles/pages/RolesPage.tsx | 42 +++ .../features/users/components/UserForm.tsx | 44 ++-- .../features/users/hooks/useRolesForSelect.ts | 22 ++ src/web/src/router.tsx | 33 +++ .../src/tests/features/roles/RolForm.test.tsx | 167 ++++++++++++ .../tests/features/roles/RolesList.test.tsx | 88 +++++++ .../tests/features/users/UserForm.test.tsx | 159 ++++++------ 23 files changed, 1025 insertions(+), 101 deletions(-) create mode 100644 src/web/src/features/roles/api/createRole.ts create mode 100644 src/web/src/features/roles/api/deactivateRole.ts create mode 100644 src/web/src/features/roles/api/getRol.ts create mode 100644 src/web/src/features/roles/api/listRoles.ts create mode 100644 src/web/src/features/roles/api/types.ts create mode 100644 src/web/src/features/roles/api/updateRole.ts create mode 100644 src/web/src/features/roles/components/RolForm.tsx create mode 100644 src/web/src/features/roles/components/RolesList.tsx create mode 100644 src/web/src/features/roles/hooks/useCreateRole.ts create mode 100644 src/web/src/features/roles/hooks/useDeactivateRole.ts create mode 100644 src/web/src/features/roles/hooks/useRol.ts create mode 100644 src/web/src/features/roles/hooks/useRoles.ts create mode 100644 src/web/src/features/roles/hooks/useUpdateRole.ts create mode 100644 src/web/src/features/roles/pages/EditRolPage.tsx create mode 100644 src/web/src/features/roles/pages/NewRolPage.tsx create mode 100644 src/web/src/features/roles/pages/RolesPage.tsx create mode 100644 src/web/src/features/users/hooks/useRolesForSelect.ts create mode 100644 src/web/src/tests/features/roles/RolForm.test.tsx create mode 100644 src/web/src/tests/features/roles/RolesList.test.tsx diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 31a153a..73252da 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -6,6 +6,7 @@ import { Zap, Settings, UserPlus, + ShieldCheck, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -104,6 +105,18 @@ export function SidebarNav() { Crear Usuario + + + Roles + )} diff --git a/src/web/src/features/roles/api/createRole.ts b/src/web/src/features/roles/api/createRole.ts new file mode 100644 index 0000000..6563e2b --- /dev/null +++ b/src/web/src/features/roles/api/createRole.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { CreateRolRequest, RolCreatedDto } from './types' + +export async function createRole(payload: CreateRolRequest): Promise { + const response = await axiosClient.post('/api/v1/roles', payload) + return response.data +} diff --git a/src/web/src/features/roles/api/deactivateRole.ts b/src/web/src/features/roles/api/deactivateRole.ts new file mode 100644 index 0000000..ce59ab5 --- /dev/null +++ b/src/web/src/features/roles/api/deactivateRole.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '../../../api/axiosClient' + +export async function deactivateRole(codigo: string): Promise { + await axiosClient.delete(`/api/v1/roles/${encodeURIComponent(codigo)}`) +} diff --git a/src/web/src/features/roles/api/getRol.ts b/src/web/src/features/roles/api/getRol.ts new file mode 100644 index 0000000..2e9bed5 --- /dev/null +++ b/src/web/src/features/roles/api/getRol.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { RolDto } from './types' + +export async function getRol(codigo: string): Promise { + const response = await axiosClient.get(`/api/v1/roles/${encodeURIComponent(codigo)}`) + return response.data +} diff --git a/src/web/src/features/roles/api/listRoles.ts b/src/web/src/features/roles/api/listRoles.ts new file mode 100644 index 0000000..6cb2ffa --- /dev/null +++ b/src/web/src/features/roles/api/listRoles.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { RolDto } from './types' + +export async function listRoles(): Promise { + const response = await axiosClient.get('/api/v1/roles') + return response.data +} diff --git a/src/web/src/features/roles/api/types.ts b/src/web/src/features/roles/api/types.ts new file mode 100644 index 0000000..cba3615 --- /dev/null +++ b/src/web/src/features/roles/api/types.ts @@ -0,0 +1,29 @@ +export interface RolDto { + id: number + codigo: string + nombre: string + descripcion: string | null + activo: boolean + fechaCreacion: string + fechaModificacion: string | null +} + +export interface RolCreatedDto { + id: number + codigo: string + nombre: string + descripcion: string | null + activo: boolean +} + +export interface CreateRolRequest { + codigo: string + nombre: string + descripcion?: string | null +} + +export interface UpdateRolRequest { + nombre: string + descripcion?: string | null + activo: boolean +} diff --git a/src/web/src/features/roles/api/updateRole.ts b/src/web/src/features/roles/api/updateRole.ts new file mode 100644 index 0000000..1a4e65c --- /dev/null +++ b/src/web/src/features/roles/api/updateRole.ts @@ -0,0 +1,10 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { RolDto, UpdateRolRequest } from './types' + +export async function updateRole(codigo: string, payload: UpdateRolRequest): Promise { + const response = await axiosClient.put( + `/api/v1/roles/${encodeURIComponent(codigo)}`, + payload, + ) + return response.data +} diff --git a/src/web/src/features/roles/components/RolForm.tsx b/src/web/src/features/roles/components/RolForm.tsx new file mode 100644 index 0000000..76075a3 --- /dev/null +++ b/src/web/src/features/roles/components/RolForm.tsx @@ -0,0 +1,245 @@ +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 { 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 { useCreateRole } from '../hooks/useCreateRole' +import { useUpdateRole } from '../hooks/useUpdateRole' +import type { RolDto } from '../api/types' + +const CODIGO_REGEX = /^[a-z][a-z0-9_]*$/ + +const createSchema = z.object({ + codigo: z + .string() + .min(3, 'Mínimo 3 caracteres') + .max(30, 'Máximo 30 caracteres') + .regex(CODIGO_REGEX, 'Solo minúsculas, dígitos y guion bajo; debe empezar con letra'), + nombre: z.string().min(1, 'El nombre es requerido').max(60, 'Máximo 60 caracteres'), + descripcion: z.string().max(250, 'Máximo 250 caracteres').optional().or(z.literal('')), +}) + +const updateSchema = z.object({ + nombre: z.string().min(1, 'El nombre es requerido').max(60, 'Máximo 60 caracteres'), + descripcion: z.string().max(250, 'Máximo 250 caracteres').optional().or(z.literal('')), + activo: z.boolean(), +}) + +type CreateFormValues = z.infer +type UpdateFormValues = z.infer + +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 === 'rol_already_exists') return data.message ?? 'El rol ya existe' + if (data.error === 'rol_not_found') return data.message ?? 'Rol no encontrado' + return data.message ?? data.error ?? 'Error al guardar el rol' + } + return 'Error al guardar el rol' +} + +// ── Create Form ──────────────────────────────────────────────────────────── + +export function CreateRolForm({ onSuccess }: { onSuccess?: () => void }) { + const mutation = useCreateRole() + const form = useForm({ + resolver: zodResolver(createSchema), + defaultValues: { codigo: '', nombre: '', descripcion: '' }, + }) + + function onSubmit(values: CreateFormValues) { + mutation.mutate( + { + codigo: values.codigo, + nombre: values.nombre, + descripcion: values.descripcion ? values.descripcion : null, + }, + { onSuccess: () => onSuccess?.() }, + ) + } + + const backendErr = resolveBackendError(mutation.error) + + return ( +
+ + {backendErr && ( + + + {backendErr} + + )} + + ( + + Código + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + ( + + Descripción (opcional) + + + + + + )} + /> + + + + + ) +} + +// ── Edit Form ────────────────────────────────────────────────────────────── + +export function EditRolForm({ initial, onSuccess }: { initial: RolDto; onSuccess?: () => void }) { + const mutation = useUpdateRole() + const form = useForm({ + resolver: zodResolver(updateSchema), + defaultValues: { + nombre: initial.nombre, + descripcion: initial.descripcion ?? '', + activo: initial.activo, + }, + }) + + function onSubmit(values: UpdateFormValues) { + mutation.mutate( + { + codigo: initial.codigo, + payload: { + nombre: values.nombre, + descripcion: values.descripcion ? values.descripcion : null, + activo: values.activo, + }, + }, + { onSuccess: () => onSuccess?.() }, + ) + } + + const backendErr = resolveBackendError(mutation.error) + + return ( +
+ + {backendErr && ( + + + {backendErr} + + )} + + + Código + + + + + + ( + + Nombre + + + + + + )} + /> + + ( + + Descripción (opcional) + + + + + + )} + /> + + ( + + + field.onChange(e.target.checked)} + disabled={mutation.isPending} + aria-label="Activo" + className="h-4 w-4" + /> + + Activo + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/roles/components/RolesList.tsx b/src/web/src/features/roles/components/RolesList.tsx new file mode 100644 index 0000000..638b456 --- /dev/null +++ b/src/web/src/features/roles/components/RolesList.tsx @@ -0,0 +1,97 @@ +import { Link } from 'react-router-dom' +import { isAxiosError } from 'axios' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { AlertCircle } from 'lucide-react' +import { useRoles } from '../hooks/useRoles' +import { useDeactivateRole } from '../hooks/useDeactivateRole' + +export function RolesList() { + const { data: roles, isLoading, isError, error } = useRoles() + const deactivateMut = useDeactivateRole() + + if (isLoading) return

Cargando roles...

+ + if (isError) { + const msg = isAxiosError(error) ? (error.message ?? 'Error al cargar roles') : 'Error al cargar roles' + return ( + + + {msg} + + ) + } + + if (!roles || roles.length === 0) { + return

No hay roles registrados.

+ } + + function handleDeactivate(codigo: string) { + deactivateMut.mutate(codigo) + } + + const deactivateErr = deactivateMut.error + const deactivateErrMsg = + deactivateErr && isAxiosError(deactivateErr) + ? (deactivateErr.response?.data as { message?: string } | undefined)?.message ?? + 'No se pudo desactivar el rol' + : deactivateErr + ? 'No se pudo desactivar el rol' + : null + + return ( +
+ {deactivateErrMsg && ( + + + {deactivateErrMsg} + + )} + + + + + + + + + + + + {roles.map((r) => ( + + + + + + + + ))} + +
CódigoNombreDescripciónEstadoAcciones
{r.codigo}{r.nombre}{r.descripcion ?? '—'} + {r.activo ? ( + Activo + ) : ( + Inactivo + )} + + + + + {r.activo && ( + + )} +
+
+ ) +} diff --git a/src/web/src/features/roles/hooks/useCreateRole.ts b/src/web/src/features/roles/hooks/useCreateRole.ts new file mode 100644 index 0000000..99713d1 --- /dev/null +++ b/src/web/src/features/roles/hooks/useCreateRole.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createRole } from '../api/createRole' +import type { CreateRolRequest } from '../api/types' +import { rolesQueryKey } from './useRoles' + +export function useCreateRole() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateRolRequest) => createRole(payload), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: rolesQueryKey }) + }, + }) +} diff --git a/src/web/src/features/roles/hooks/useDeactivateRole.ts b/src/web/src/features/roles/hooks/useDeactivateRole.ts new file mode 100644 index 0000000..755fb7e --- /dev/null +++ b/src/web/src/features/roles/hooks/useDeactivateRole.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateRole } from '../api/deactivateRole' +import { rolesQueryKey } from './useRoles' + +export function useDeactivateRole() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (codigo: string) => deactivateRole(codigo), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: rolesQueryKey }) + }, + }) +} diff --git a/src/web/src/features/roles/hooks/useRol.ts b/src/web/src/features/roles/hooks/useRol.ts new file mode 100644 index 0000000..87bb3f2 --- /dev/null +++ b/src/web/src/features/roles/hooks/useRol.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { getRol } from '../api/getRol' + +export function useRol(codigo: string | undefined) { + return useQuery({ + queryKey: ['roles', codigo], + queryFn: () => getRol(codigo!), + enabled: Boolean(codigo), + }) +} diff --git a/src/web/src/features/roles/hooks/useRoles.ts b/src/web/src/features/roles/hooks/useRoles.ts new file mode 100644 index 0000000..68c3caf --- /dev/null +++ b/src/web/src/features/roles/hooks/useRoles.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' +import { listRoles } from '../api/listRoles' + +export const rolesQueryKey = ['roles'] as const + +export function useRoles() { + return useQuery({ + queryKey: rolesQueryKey, + queryFn: listRoles, + staleTime: 30_000, + }) +} diff --git a/src/web/src/features/roles/hooks/useUpdateRole.ts b/src/web/src/features/roles/hooks/useUpdateRole.ts new file mode 100644 index 0000000..b9fc025 --- /dev/null +++ b/src/web/src/features/roles/hooks/useUpdateRole.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateRole } from '../api/updateRole' +import type { UpdateRolRequest } from '../api/types' +import { rolesQueryKey } from './useRoles' + +interface UpdateRoleVars { + codigo: string + payload: UpdateRolRequest +} + +export function useUpdateRole() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ codigo, payload }: UpdateRoleVars) => updateRole(codigo, payload), + onSuccess: (_data, vars) => { + void qc.invalidateQueries({ queryKey: rolesQueryKey }) + void qc.invalidateQueries({ queryKey: ['roles', vars.codigo] }) + }, + }) +} diff --git a/src/web/src/features/roles/pages/EditRolPage.tsx b/src/web/src/features/roles/pages/EditRolPage.tsx new file mode 100644 index 0000000..b5e7973 --- /dev/null +++ b/src/web/src/features/roles/pages/EditRolPage.tsx @@ -0,0 +1,46 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { useAuthStore } from '@/stores/authStore' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { AlertCircle } from 'lucide-react' +import { useRol } from '../hooks/useRol' +import { EditRolForm } from '../components/RolForm' + +export function EditRolPage() { + const navigate = useNavigate() + const { codigo } = useParams<{ codigo: string }>() + const user = useAuthStore((s) => s.user) + const { data: rol, isLoading, isError } = useRol(codigo) + + if (!user || user.rol !== 'admin') { + void navigate('/', { replace: true }) + return null + } + + return ( +
+ + + Editar rol + Modificá nombre, descripción o estado del rol. + + + {isLoading &&

Cargando...

} + {isError && ( + + + No se pudo cargar el rol. + + )} + {rol && navigate('/admin/roles')} />} +
+
+
+ ) +} diff --git a/src/web/src/features/roles/pages/NewRolPage.tsx b/src/web/src/features/roles/pages/NewRolPage.tsx new file mode 100644 index 0000000..3bcd68e --- /dev/null +++ b/src/web/src/features/roles/pages/NewRolPage.tsx @@ -0,0 +1,36 @@ +import { useNavigate } from 'react-router-dom' +import { useAuthStore } from '@/stores/authStore' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { CreateRolForm } from '../components/RolForm' + +export function NewRolPage() { + const navigate = useNavigate() + const user = useAuthStore((s) => s.user) + + if (!user || user.rol !== 'admin') { + void navigate('/', { replace: true }) + return null + } + + return ( +
+ + + Nuevo rol + + Creá un nuevo rol del sistema. El código es inmutable una vez creado. + + + + navigate('/admin/roles')} /> + + +
+ ) +} diff --git a/src/web/src/features/roles/pages/RolesPage.tsx b/src/web/src/features/roles/pages/RolesPage.tsx new file mode 100644 index 0000000..3741377 --- /dev/null +++ b/src/web/src/features/roles/pages/RolesPage.tsx @@ -0,0 +1,42 @@ +import { Link, useNavigate } from 'react-router-dom' +import { useAuthStore } from '@/stores/authStore' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { RolesList } from '../components/RolesList' + +export function RolesPage() { + const navigate = useNavigate() + const user = useAuthStore((s) => s.user) + + if (!user || user.rol !== 'admin') { + void navigate('/', { replace: true }) + return null + } + + return ( +
+ + +
+ Roles del sistema + + Gestión de roles canónicos. Los roles inactivos no pueden asignarse a nuevos usuarios. + +
+ + + +
+ + + +
+
+ ) +} diff --git a/src/web/src/features/users/components/UserForm.tsx b/src/web/src/features/users/components/UserForm.tsx index c5ed7ee..792adc3 100644 --- a/src/web/src/features/users/components/UserForm.tsx +++ b/src/web/src/features/users/components/UserForm.tsx @@ -15,10 +15,9 @@ import { FormMessage, } from '@/components/ui/form' import { useCreateUser } from '../hooks/useCreateUser' +import { useRolesForSelect } from '../hooks/useRolesForSelect' import type { CreatedUserDto } from '../api/createUser' -const ROL_OPTIONS = ['admin', 'vendedor', 'tasador', 'consulta'] as const - const userFormSchema = z.object({ username: z .string() @@ -32,11 +31,7 @@ const userFormSchema = z.object({ nombre: z.string().min(1, 'El nombre es requerido'), apellido: z.string().min(1, 'El apellido es requerido'), email: z.string().email('Email inválido').optional().or(z.literal('')), - rol: z - .string({ required_error: 'Seleccioná un rol válido' }) - .refine((v): v is (typeof ROL_OPTIONS)[number] => (ROL_OPTIONS as readonly string[]).includes(v), { - message: 'Seleccioná un rol válido', - }), + rol: z.string().min(1, 'Seleccioná un rol válido'), }) type UserFormValues = z.infer @@ -59,6 +54,7 @@ function resolveBackendError(err: unknown): string | null { export function UserForm({ onSuccess }: UserFormProps) { const { mutate, isPending, error } = useCreateUser() + const { options: rolOptions, isLoading: rolesLoading, isError: rolesError } = useRolesForSelect() const form = useForm({ resolver: zodResolver(userFormSchema), @@ -91,6 +87,7 @@ export function UserForm({ onSuccess }: UserFormProps) { } const backendError = resolveBackendError(error) + const disabled = isPending || rolesLoading return (
@@ -102,6 +99,15 @@ export function UserForm({ onSuccess }: UserFormProps) { )} + {rolesError && ( + + + + No se pudieron cargar los roles. Intentá refrescar la página. + + + )} + @@ -133,7 +139,7 @@ export function UserForm({ onSuccess }: UserFormProps) { {...field} type="password" autoComplete="new-password" - disabled={isPending} + disabled={disabled} placeholder="Mínimo 8 chars, letra y dígito" /> @@ -149,7 +155,7 @@ export function UserForm({ onSuccess }: UserFormProps) { Nombre - + @@ -163,7 +169,7 @@ export function UserForm({ onSuccess }: UserFormProps) { Apellido - + @@ -181,7 +187,7 @@ export function UserForm({ onSuccess }: UserFormProps) { {...field} type="email" autoComplete="off" - disabled={isPending} + disabled={disabled} placeholder="correo@ejemplo.com" /> @@ -199,14 +205,16 @@ export function UserForm({ onSuccess }: UserFormProps) { @@ -216,7 +224,7 @@ export function UserForm({ onSuccess }: UserFormProps) { )} /> - diff --git a/src/web/src/features/users/hooks/useRolesForSelect.ts b/src/web/src/features/users/hooks/useRolesForSelect.ts new file mode 100644 index 0000000..84a5b71 --- /dev/null +++ b/src/web/src/features/users/hooks/useRolesForSelect.ts @@ -0,0 +1,22 @@ +import { useRoles } from '../../roles/hooks/useRoles' +import type { RolDto } from '../../roles/api/types' + +export interface RolOption { + codigo: string + nombre: string +} + +interface UseRolesForSelectResult { + options: RolOption[] + isLoading: boolean + isError: boolean +} + +// Returns only ACTIVE roles, mapped to a select-friendly shape. +export function useRolesForSelect(): UseRolesForSelectResult { + const { data, isLoading, isError } = useRoles() + const options: RolOption[] = (data ?? []) + .filter((r: RolDto) => r.activo) + .map((r) => ({ codigo: r.codigo, nombre: r.nombre })) + return { options, isLoading, isError } +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 1ea7daa..d43145d 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -2,6 +2,9 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { useAuthStore } from './stores/authStore' import { LoginPage } from './features/auth/pages/LoginPage' import { CreateUserPage } from './features/users/pages/CreateUserPage' +import { RolesPage } from './features/roles/pages/RolesPage' +import { NewRolPage } from './features/roles/pages/NewRolPage' +import { EditRolPage } from './features/roles/pages/EditRolPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -55,6 +58,36 @@ export function AppRoutes() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> } /> ) diff --git a/src/web/src/tests/features/roles/RolForm.test.tsx b/src/web/src/tests/features/roles/RolForm.test.tsx new file mode 100644 index 0000000..c28b5d2 --- /dev/null +++ b/src/web/src/tests/features/roles/RolForm.test.tsx @@ -0,0 +1,167 @@ +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 } from 'react-router-dom' +import { CreateRolForm, EditRolForm } from '../../../features/roles/components/RolForm' +import type { RolDto } from '../../../features/roles/api/types' + +const API_URL = 'http://localhost:5000' + +const mockCreated = { + id: 10, + codigo: 'cajero_senior', + nombre: 'Cajero Senior', + descripcion: 'Con más permisos', + activo: true, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +function renderCreate(onSuccess = vi.fn()) { + const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + return render( + + + + + , + ) +} + +function renderEdit(initial: RolDto, onSuccess = vi.fn()) { + const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + return render( + + + + + , + ) +} + +describe('CreateRolForm — zod validation', () => { + it('shows error when codigo is too short (< 3 chars)', async () => { + const u = userEvent.setup() + renderCreate() + + await u.type(screen.getByLabelText(/código/i), 'ab') + await u.type(screen.getByLabelText(/nombre/i), 'Test') + await u.click(screen.getByRole('button', { name: /crear rol/i })) + + await waitFor(() => { + expect(screen.getByText(/mínimo 3 caracteres/i)).toBeInTheDocument() + }) + }) + + it('shows error for uppercase codigo', async () => { + const u = userEvent.setup() + renderCreate() + + await u.type(screen.getByLabelText(/código/i), 'Cajero') + await u.type(screen.getByLabelText(/nombre/i), 'Test') + await u.click(screen.getByRole('button', { name: /crear rol/i })) + + await waitFor(() => { + expect(screen.getByText(/solo minúsculas/i)).toBeInTheDocument() + }) + }) + + it('shows error when nombre is empty', async () => { + const u = userEvent.setup() + renderCreate() + + await u.type(screen.getByLabelText(/código/i), 'cajero_test') + await u.click(screen.getByRole('button', { name: /crear rol/i })) + + await waitFor(() => { + expect(screen.getByText(/el nombre es requerido/i)).toBeInTheDocument() + }) + }) +}) + +describe('CreateRolForm — submit', () => { + it('posts to /api/v1/roles and calls onSuccess on 201', async () => { + server.use( + http.post(`${API_URL}/api/v1/roles`, async () => HttpResponse.json(mockCreated, { status: 201 })), + ) + + const onSuccess = vi.fn() + const u = userEvent.setup() + renderCreate(onSuccess) + + await u.type(screen.getByLabelText(/código/i), 'cajero_senior') + await u.type(screen.getByLabelText(/nombre/i), 'Cajero Senior') + await u.type(screen.getByLabelText(/descripción/i), 'Con más permisos') + await u.click(screen.getByRole('button', { name: /crear rol/i })) + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()) + }) + + it('shows 409 rol_already_exists error', async () => { + server.use( + http.post(`${API_URL}/api/v1/roles`, async () => + HttpResponse.json( + { error: 'rol_already_exists', message: "El rol 'cajero' ya existe." }, + { status: 409 }, + ), + ), + ) + + const u = userEvent.setup() + renderCreate() + + await u.type(screen.getByLabelText(/código/i), 'cajero') + await u.type(screen.getByLabelText(/nombre/i), 'Duplicated') + await u.click(screen.getByRole('button', { name: /crear rol/i })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/ya existe/i) + }) + }) +}) + +describe('EditRolForm', () => { + const initial: RolDto = { + id: 5, + codigo: 'picadora', + nombre: 'Picadora', + descripcion: 'Edición de textos', + activo: true, + fechaCreacion: '2026-04-15T00:00:00Z', + fechaModificacion: null, + } + + it('shows initial values and renders codigo as read-only', () => { + renderEdit(initial) + + expect(screen.getByDisplayValue('picadora')).toHaveAttribute('readOnly') + expect(screen.getByDisplayValue('Picadora')).toBeInTheDocument() + expect(screen.getByDisplayValue('Edición de textos')).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: /activo/i })).toBeChecked() + }) + + it('submits PUT to /api/v1/roles/{codigo} with updated values', async () => { + const updated: RolDto = { ...initial, nombre: 'Picadora V2', fechaModificacion: '2026-04-15T10:00:00Z' } + server.use( + http.put(`${API_URL}/api/v1/roles/picadora`, async () => HttpResponse.json(updated, { status: 200 })), + ) + + const onSuccess = vi.fn() + const u = userEvent.setup() + renderEdit(initial, onSuccess) + + const nombreInput = screen.getByDisplayValue('Picadora') + await u.clear(nombreInput) + await u.type(nombreInput, 'Picadora V2') + await u.click(screen.getByRole('button', { name: /guardar cambios/i })) + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()) + }) +}) diff --git a/src/web/src/tests/features/roles/RolesList.test.tsx b/src/web/src/tests/features/roles/RolesList.test.tsx new file mode 100644 index 0000000..7d09221 --- /dev/null +++ b/src/web/src/tests/features/roles/RolesList.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } 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 } from 'react-router-dom' +import { RolesList } from '../../../features/roles/components/RolesList' + +const API_URL = 'http://localhost:5000' + +const canonical = [ + { id: 1, codigo: 'admin', nombre: 'Administrador', descripcion: 'Todo', activo: true, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null }, + { id: 2, codigo: 'cajero', nombre: 'Cajero', descripcion: 'Mostrador', activo: true, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null }, + { id: 3, codigo: 'reportes', nombre: 'Reportes', descripcion: null, activo: false, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null }, +] + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +function renderList() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + + + , + ) +} + +describe('RolesList', () => { + it('renders all roles including inactive with correct badge', async () => { + server.use(http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(canonical))) + + renderList() + + await waitFor(() => expect(screen.getByText('Administrador')).toBeInTheDocument()) + expect(screen.getByText('Cajero')).toBeInTheDocument() + expect(screen.getByText('Reportes')).toBeInTheDocument() + + // Active badge for cajero, inactive for reportes + const activeBadges = screen.getAllByText(/^Activo$/) + expect(activeBadges.length).toBeGreaterThanOrEqual(2) + expect(screen.getByText(/^Inactivo$/)).toBeInTheDocument() + }) + + it('hides Deactivate button for already-inactive roles', async () => { + server.use(http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(canonical))) + + renderList() + + await waitFor(() => expect(screen.getByText('Reportes')).toBeInTheDocument()) + + // There should be Deactivate buttons only for active roles (2: admin, cajero). + const deactivateButtons = screen.getAllByRole('button', { name: /desactivar/i }) + expect(deactivateButtons).toHaveLength(2) + }) + + it('shows 409 error alert when deactivate blocked by active usuarios', async () => { + server.use( + http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(canonical)), + http.delete(`${API_URL}/api/v1/roles/cajero`, () => + HttpResponse.json( + { error: 'rol_in_use', message: "El rol 'cajero' no puede desactivarse porque existen usuarios activos." }, + { status: 409 }, + ), + ), + ) + + const u = userEvent.setup() + renderList() + + await waitFor(() => expect(screen.getByText('Cajero')).toBeInTheDocument()) + + // Click Deactivate on cajero row (first active row with deactivate button after admin). + const buttons = screen.getAllByRole('button', { name: /desactivar/i }) + // Admin is listed first in canonical; cajero deactivate button is index 1. + await u.click(buttons[1]!) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/no puede desactivarse/i) + }) + }) +}) diff --git a/src/web/src/tests/features/users/UserForm.test.tsx b/src/web/src/tests/features/users/UserForm.test.tsx index 89ab027..cd9f92a 100644 --- a/src/web/src/tests/features/users/UserForm.test.tsx +++ b/src/web/src/tests/features/users/UserForm.test.tsx @@ -15,10 +15,19 @@ const mockCreatedUser = { nombre: 'Juan', apellido: 'Doe', email: null, - rol: 'vendedor', + rol: 'cajero', activo: true, } +// Mock the 8 canonical active roles served by /api/v1/roles. +// UserForm filters active=true via useRolesForSelect. +const mockRoles = [ + { id: 1, codigo: 'admin', nombre: 'Administrador', descripcion: null, activo: true, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null }, + { id: 2, codigo: 'cajero', nombre: 'Cajero', descripcion: null, activo: true, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null }, + { id: 3, codigo: 'picadora', nombre: 'Picadora/Correctora', descripcion: null, activo: true, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null }, + { id: 4, codigo: 'reportes', nombre: 'Reportes', descripcion: null, activo: false, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null }, +] + const server = setupServer() beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) @@ -27,7 +36,7 @@ afterAll(() => server.close()) function renderForm(onSuccess = vi.fn()) { const queryClient = new QueryClient({ - defaultOptions: { mutations: { retry: false } }, + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) return render( @@ -41,8 +50,11 @@ function renderForm(onSuccess = vi.fn()) { describe('UserForm — Zod validation', () => { it('shows error when username is too short (< 3 chars)', async () => { + server.use( + http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)), + http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })), + ) const user = userEvent.setup() - server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) renderForm() await user.type(screen.getByLabelText(/usuario/i), 'ab') @@ -53,22 +65,12 @@ describe('UserForm — Zod validation', () => { }) }) - it('shows error when username exceeds 50 chars', async () => { - const user = userEvent.setup() - server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) - renderForm() - - await user.type(screen.getByLabelText(/usuario/i), 'a'.repeat(51)) - await user.click(screen.getByRole('button', { name: /crear usuario/i })) - - await waitFor(() => { - expect(screen.getByText(/máximo 50 caracteres/i)).toBeInTheDocument() - }) - }) - it('shows error when password is too short (< 8 chars)', async () => { + server.use( + http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)), + http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })), + ) const user = userEvent.setup() - server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) renderForm() await user.type(screen.getByLabelText(/^contraseña$/i), 'Ab1') @@ -79,38 +81,14 @@ describe('UserForm — Zod validation', () => { }) }) - it('shows error when password has no letter', async () => { + it('shows error when rol is not selected', async () => { + server.use( + http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)), + http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })), + ) const user = userEvent.setup() - server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) renderForm() - await user.type(screen.getByLabelText(/^contraseña$/i), '12345678') - await user.click(screen.getByRole('button', { name: /crear usuario/i })) - - await waitFor(() => { - expect(screen.getByText(/debe contener al menos una letra/i)).toBeInTheDocument() - }) - }) - - it('shows error when password has no digit', async () => { - const user = userEvent.setup() - server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) - renderForm() - - await user.type(screen.getByLabelText(/^contraseña$/i), 'abcdefgh') - await user.click(screen.getByRole('button', { name: /crear usuario/i })) - - await waitFor(() => { - expect(screen.getByText(/debe contener al menos un dígito/i)).toBeInTheDocument() - }) - }) - - it('shows error when rol is not in whitelist', async () => { - const user = userEvent.setup() - server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 }))) - renderForm() - - // Fill valid fields, leave rol empty (default placeholder) await user.type(screen.getByLabelText(/usuario/i), 'jdoe123') await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') await user.type(screen.getByLabelText(/nombre/i), 'Juan') @@ -121,25 +99,48 @@ describe('UserForm — Zod validation', () => { expect(screen.getByText(/seleccioná un rol válido/i)).toBeInTheDocument() }) }) +}) - it('accepts optional empty email', async () => { +describe('UserForm — roles dropdown integration', () => { + it('renders only ACTIVE canonical roles fetched from /api/v1/roles', async () => { server.use( - http.post(`${API_URL}/api/v1/users`, async () => { - return HttpResponse.json(mockCreatedUser, { status: 201 }) - }), + http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)), + http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })), + ) + renderForm() + + // Wait for roles to load — active options should appear. + await waitFor(() => { + expect( + screen.getByRole('option', { name: 'Administrador' }), + ).toBeInTheDocument() + }) + + expect(screen.getByRole('option', { name: 'Cajero' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Picadora/Correctora' })).toBeInTheDocument() + // Inactive 'reportes' MUST NOT appear + expect(screen.queryByRole('option', { name: 'Reportes' })).not.toBeInTheDocument() + }) + + it('selects cajero and submits successfully', async () => { + server.use( + http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)), + http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })), ) const onSuccess = vi.fn() const user = userEvent.setup() renderForm(onSuccess) + await waitFor(() => { + expect(screen.getByRole('option', { name: 'Cajero' })).toBeInTheDocument() + }) + await user.type(screen.getByLabelText(/usuario/i), 'jdoe123') await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') await user.type(screen.getByLabelText(/nombre/i), 'Juan') await user.type(screen.getByLabelText(/apellido/i), 'Doe') - // Select rol via combobox - await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor') - // email left empty — valid + await user.selectOptions(screen.getByLabelText(/rol/i), 'cajero') await user.click(screen.getByRole('button', { name: /crear usuario/i })) @@ -147,51 +148,43 @@ describe('UserForm — Zod validation', () => { expect(onSuccess).toHaveBeenCalledWith(mockCreatedUser) }) }) + + it('shows error alert when roles fetch fails', async () => { + server.use( + http.get(`${API_URL}/api/v1/roles`, () => new HttpResponse(null, { status: 500 })), + ) + renderForm() + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/no se pudieron cargar los roles/i) + }) + }) }) -describe('UserForm — submit and backend error display', () => { - it('calls mutation on valid submit and invokes onSuccess callback', async () => { - server.use( - http.post(`${API_URL}/api/v1/users`, async () => { - return HttpResponse.json(mockCreatedUser, { status: 201 }) - }), - ) - - const onSuccess = vi.fn() - const user = userEvent.setup() - renderForm(onSuccess) - - await user.type(screen.getByLabelText(/usuario/i), 'jdoe123') - await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') - await user.type(screen.getByLabelText(/nombre/i), 'Juan') - await user.type(screen.getByLabelText(/apellido/i), 'Doe') - await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor') - - await user.click(screen.getByRole('button', { name: /crear usuario/i })) - - await waitFor(() => { - expect(onSuccess).toHaveBeenCalledWith(mockCreatedUser) - }) - }) - +describe('UserForm — backend error display', () => { it('shows backend 409 username_taken error in alert', async () => { server.use( - http.post(`${API_URL}/api/v1/users`, async () => { - return HttpResponse.json( + http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)), + http.post(`${API_URL}/api/v1/users`, async () => + HttpResponse.json( { error: 'username_taken', message: 'El usuario ya existe' }, { status: 409 }, - ) - }), + ), + ), ) const user = userEvent.setup() renderForm() + await waitFor(() => { + expect(screen.getByRole('option', { name: 'Cajero' })).toBeInTheDocument() + }) + await user.type(screen.getByLabelText(/usuario/i), 'existing') await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') await user.type(screen.getByLabelText(/nombre/i), 'Juan') await user.type(screen.getByLabelText(/apellido/i), 'Doe') - await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor') + await user.selectOptions(screen.getByLabelText(/rol/i), 'cajero') await user.click(screen.getByRole('button', { name: /crear usuario/i })) -- 2.49.1 From 57e4cdac01939c68b79b23f3e60f832b18003c57 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 13:03:18 -0300 Subject: [PATCH 4/4] chore(tests): limpia warning xUnit2012 en CreateUsuario_WithInactiveRol_Returns400 Reemplaza Assert.True(enumerable.Any(...)) por Assert.Contains idiomatico. --- tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs index 0864609..1fa0570 100644 --- a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs @@ -421,7 +421,7 @@ public sealed class CreateUsuarioEndpointTests : IAsyncLifetime var json = await resp.Content.ReadFromJsonAsync(); Assert.True(json.TryGetProperty("errors", out var errors), "Response must contain 'errors'"); // Validation error should be on the Rol field - Assert.True(errors.EnumerateObject().Any(p => p.Name.Equals("Rol", StringComparison.OrdinalIgnoreCase))); + Assert.Contains(errors.EnumerateObject(), p => p.Name.Equals("Rol", StringComparison.OrdinalIgnoreCase)); } finally { -- 2.49.1