Merge pull request 'UDT-004: Gestión de Roles (tabla maestra + CRUD admin + validator dinámico + UI)' (#8) from feature/UDT-004 into main
This commit was merged in pull request #8.
This commit is contained in:
58
database/migrations/V003__create_rol.sql
Normal file
58
database/migrations/V003__create_rol.sql
Normal file
@@ -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
|
||||||
44
database/migrations/V004__alter_usuario_rol_fk.sql
Normal file
44
database/migrations/V004__alter_usuario_rol_fk.sql
Normal file
@@ -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
|
||||||
127
src/api/SIGCM2.Api/Controllers/RolesController.cs
Normal file
127
src/api/SIGCM2.Api/Controllers/RolesController.cs
Normal file
@@ -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<CreateRolCommand> _createValidator;
|
||||||
|
private readonly IValidator<UpdateRolCommand> _updateValidator;
|
||||||
|
|
||||||
|
public RolesController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateRolCommand> createValidator,
|
||||||
|
IValidator<UpdateRolCommand> updateValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createValidator = createValidator;
|
||||||
|
_updateValidator = updateValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists all roles (including inactive). Requires admin role.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<RolDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> List()
|
||||||
|
{
|
||||||
|
var result = await _dispatcher.Send<ListRolesQuery, IReadOnlyList<RolDto>>(new ListRolesQuery());
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a role by its code. Requires admin role.</summary>
|
||||||
|
[HttpGet("{codigo}")]
|
||||||
|
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetByCodigo(string codigo)
|
||||||
|
{
|
||||||
|
var result = await _dispatcher.Send<GetRolByCodigoQuery, RolDto>(new GetRolByCodigoQuery(codigo));
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new role. Requires admin role.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(RolCreatedDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> 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<CreateRolCommand, RolCreatedDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetByCodigo), new { codigo = result.Codigo }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a role (codigo is immutable; route wins over body). Requires admin role.</summary>
|
||||||
|
[HttpPut("{codigo}")]
|
||||||
|
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> 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<UpdateRolCommand, RolDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Soft-deletes (deactivates) a role. 409 if active usuarios reference it. Requires admin role.</summary>
|
||||||
|
[HttpDelete("{codigo}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Deactivate(string codigo)
|
||||||
|
{
|
||||||
|
await _dispatcher.Send<DeactivateRolCommand, RolDto>(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);
|
||||||
@@ -71,6 +71,42 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
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:
|
case ValidationException validationEx:
|
||||||
var errors = validationEx.Errors
|
var errors = validationEx.Errors
|
||||||
.GroupBy(e => e.PropertyName)
|
.GroupBy(e => e.PropertyName)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
public interface IRolRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Rol>> ListAsync(CancellationToken ct = default);
|
||||||
|
Task<Rol?> GetByCodigoAsync(string codigo, CancellationToken ct = default);
|
||||||
|
Task<bool> ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default);
|
||||||
|
Task<int> AddAsync(Rol rol, CancellationToken ct = default);
|
||||||
|
Task<bool> UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default);
|
||||||
|
Task<bool> HasActiveUsuariosAsync(string codigo, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ using SIGCM2.Application.Abstractions;
|
|||||||
using SIGCM2.Application.Auth.Login;
|
using SIGCM2.Application.Auth.Login;
|
||||||
using SIGCM2.Application.Auth.Logout;
|
using SIGCM2.Application.Auth.Logout;
|
||||||
using SIGCM2.Application.Auth.Refresh;
|
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;
|
using SIGCM2.Application.Usuarios.Create;
|
||||||
|
|
||||||
namespace SIGCM2.Application;
|
namespace SIGCM2.Application;
|
||||||
@@ -18,6 +24,13 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<LogoutCommand, LogoutResponseDto>, LogoutCommandHandler>();
|
services.AddScoped<ICommandHandler<LogoutCommand, LogoutResponseDto>, LogoutCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<CreateUsuarioCommand, UsuarioCreatedDto>, CreateUsuarioCommandHandler>();
|
services.AddScoped<ICommandHandler<CreateUsuarioCommand, UsuarioCreatedDto>, CreateUsuarioCommandHandler>();
|
||||||
|
|
||||||
|
// Roles (UDT-004)
|
||||||
|
services.AddScoped<ICommandHandler<ListRolesQuery, IReadOnlyList<RolDto>>, ListRolesQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetRolByCodigoQuery, RolDto>, GetRolByCodigoQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<CreateRolCommand, RolCreatedDto>, CreateRolCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdateRolCommand, RolDto>, UpdateRolCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateRolCommand, RolDto>, DeactivateRolCommandHandler>();
|
||||||
|
|
||||||
// FluentValidation validators (scans entire Application assembly)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.Roles.Create;
|
||||||
|
|
||||||
|
public sealed record CreateRolCommand(
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
string? Descripcion);
|
||||||
@@ -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<CreateRolCommand, RolCreatedDto>
|
||||||
|
{
|
||||||
|
private readonly IRolRepository _repository;
|
||||||
|
|
||||||
|
public CreateRolCommandHandler(IRolRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RolCreatedDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Roles.Create;
|
||||||
|
|
||||||
|
public sealed class CreateRolCommandValidator : AbstractValidator<CreateRolCommand>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Roles.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateRolCommand(string Codigo);
|
||||||
@@ -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<DeactivateRolCommand, RolDto>
|
||||||
|
{
|
||||||
|
private readonly IRolRepository _repository;
|
||||||
|
|
||||||
|
public DeactivateRolCommandHandler(IRolRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RolDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs
Normal file
8
src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SIGCM2.Application.Roles.Dtos;
|
||||||
|
|
||||||
|
public sealed record RolCreatedDto(
|
||||||
|
int Id,
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
string? Descripcion,
|
||||||
|
bool Activo);
|
||||||
10
src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs
Normal file
10
src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs
Normal file
@@ -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);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Roles.Get;
|
||||||
|
|
||||||
|
public sealed record GetRolByCodigoQuery(string Codigo);
|
||||||
@@ -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<GetRolByCodigoQuery, RolDto>
|
||||||
|
{
|
||||||
|
private readonly IRolRepository _repository;
|
||||||
|
|
||||||
|
public GetRolByCodigoQueryHandler(IRolRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RolDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs
Normal file
3
src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Roles.List;
|
||||||
|
|
||||||
|
public sealed record ListRolesQuery();
|
||||||
@@ -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<ListRolesQuery, IReadOnlyList<RolDto>>
|
||||||
|
{
|
||||||
|
private readonly IRolRepository _repository;
|
||||||
|
|
||||||
|
public ListRolesQueryHandler(IRolRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<RolDto>> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SIGCM2.Application.Roles.Update;
|
||||||
|
|
||||||
|
public sealed record UpdateRolCommand(
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
string? Descripcion,
|
||||||
|
bool Activo);
|
||||||
@@ -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<UpdateRolCommand, RolDto>
|
||||||
|
{
|
||||||
|
private readonly IRolRepository _repository;
|
||||||
|
|
||||||
|
public UpdateRolCommandHandler(IRolRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RolDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Roles.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateRolCommandValidator : AbstractValidator<UpdateRolCommand>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,18 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Auth;
|
using SIGCM2.Application.Auth;
|
||||||
|
|
||||||
namespace SIGCM2.Application.Usuarios.Create;
|
namespace SIGCM2.Application.Usuarios.Create;
|
||||||
|
|
||||||
public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsuarioCommand>
|
public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsuarioCommand>
|
||||||
{
|
{
|
||||||
private static readonly string[] ValidRoles = ["admin", "vendedor", "tasador", "consulta"];
|
|
||||||
|
|
||||||
private const int UsernameMinLength = 3;
|
private const int UsernameMinLength = 3;
|
||||||
private const int UsernameMaxLength = 50;
|
private const int UsernameMaxLength = 50;
|
||||||
private const int NombreMaxLength = 100;
|
private const int NombreMaxLength = 100;
|
||||||
private const int ApellidoMaxLength = 100;
|
private const int ApellidoMaxLength = 100;
|
||||||
private const int EmailMaxLength = 150;
|
private const int EmailMaxLength = 150;
|
||||||
|
|
||||||
public CreateUsuarioCommandValidator() : this(new AuthOptions()) { }
|
public CreateUsuarioCommandValidator(AuthOptions authOptions, IRolRepository rolRepository)
|
||||||
|
|
||||||
public CreateUsuarioCommandValidator(AuthOptions authOptions)
|
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Username)
|
RuleFor(x => x.Username)
|
||||||
.NotEmpty().WithMessage("El nombre de usuario es requerido.")
|
.NotEmpty().WithMessage("El nombre de usuario es requerido.")
|
||||||
@@ -46,10 +43,13 @@ public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsua
|
|||||||
.MaximumLength(EmailMaxLength).WithMessage($"El email no puede superar los {EmailMaxLength} caracteres.")
|
.MaximumLength(EmailMaxLength).WithMessage($"El email no puede superar los {EmailMaxLength} caracteres.")
|
||||||
.When(x => x.Email is not null);
|
.When(x => 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)
|
RuleFor(x => x.Rol)
|
||||||
.NotEmpty().WithMessage("El rol es requerido.")
|
.NotEmpty().WithMessage("El rol es requerido.")
|
||||||
.Must(r => ValidRoles.Contains(r))
|
.MustAsync(async (codigo, ct) =>
|
||||||
.WithMessage($"El rol debe ser uno de: {string.Join(", ", ValidRoles)}.");
|
!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) =>
|
private static bool ContainsLetter(string value) =>
|
||||||
|
|||||||
44
src/api/SIGCM2.Domain/Entities/Rol.cs
Normal file
44
src/api/SIGCM2.Domain/Entities/Rol.cs
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs
Normal file
12
src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs
Normal file
12
src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton(new SqlConnectionFactory(connectionString));
|
services.AddSingleton(new SqlConnectionFactory(connectionString));
|
||||||
services.AddScoped<IUsuarioRepository, UsuarioRepository>();
|
services.AddScoped<IUsuarioRepository, UsuarioRepository>();
|
||||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||||
|
services.AddScoped<IRolRepository, RolRepository>();
|
||||||
|
|
||||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||||
|
|||||||
129
src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs
Normal file
129
src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs
Normal file
@@ -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<IReadOnlyList<Rol>> 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<RolRow>(sql);
|
||||||
|
return rows.Select(MapRow).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Rol?> 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<RolRow>(sql, new { Codigo = codigo });
|
||||||
|
return row is null ? null : MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> 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<int>(sql, new { Codigo = codigo });
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> 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<int>(sql, new
|
||||||
|
{
|
||||||
|
rol.Codigo,
|
||||||
|
rol.Nombre,
|
||||||
|
rol.Descripcion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> 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<int>(sql, new { Codigo = codigo, Nombre = nombre, Descripcion = descripcion, Activo = activo });
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> 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<int>(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);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
Settings,
|
Settings,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
ShieldCheck,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -104,6 +105,18 @@ export function SidebarNav() {
|
|||||||
<UserPlus className="h-4 w-4 shrink-0" />
|
<UserPlus className="h-4 w-4 shrink-0" />
|
||||||
<span>Crear Usuario</span>
|
<span>Crear Usuario</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin/roles"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||||
|
pathname.startsWith('/admin/roles')
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-4 w-4 shrink-0" />
|
||||||
|
<span>Roles</span>
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
7
src/web/src/features/roles/api/createRole.ts
Normal file
7
src/web/src/features/roles/api/createRole.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '../../../api/axiosClient'
|
||||||
|
import type { CreateRolRequest, RolCreatedDto } from './types'
|
||||||
|
|
||||||
|
export async function createRole(payload: CreateRolRequest): Promise<RolCreatedDto> {
|
||||||
|
const response = await axiosClient.post<RolCreatedDto>('/api/v1/roles', payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
src/web/src/features/roles/api/deactivateRole.ts
Normal file
5
src/web/src/features/roles/api/deactivateRole.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '../../../api/axiosClient'
|
||||||
|
|
||||||
|
export async function deactivateRole(codigo: string): Promise<void> {
|
||||||
|
await axiosClient.delete(`/api/v1/roles/${encodeURIComponent(codigo)}`)
|
||||||
|
}
|
||||||
7
src/web/src/features/roles/api/getRol.ts
Normal file
7
src/web/src/features/roles/api/getRol.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '../../../api/axiosClient'
|
||||||
|
import type { RolDto } from './types'
|
||||||
|
|
||||||
|
export async function getRol(codigo: string): Promise<RolDto> {
|
||||||
|
const response = await axiosClient.get<RolDto>(`/api/v1/roles/${encodeURIComponent(codigo)}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
7
src/web/src/features/roles/api/listRoles.ts
Normal file
7
src/web/src/features/roles/api/listRoles.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '../../../api/axiosClient'
|
||||||
|
import type { RolDto } from './types'
|
||||||
|
|
||||||
|
export async function listRoles(): Promise<RolDto[]> {
|
||||||
|
const response = await axiosClient.get<RolDto[]>('/api/v1/roles')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
29
src/web/src/features/roles/api/types.ts
Normal file
29
src/web/src/features/roles/api/types.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
10
src/web/src/features/roles/api/updateRole.ts
Normal file
10
src/web/src/features/roles/api/updateRole.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { axiosClient } from '../../../api/axiosClient'
|
||||||
|
import type { RolDto, UpdateRolRequest } from './types'
|
||||||
|
|
||||||
|
export async function updateRole(codigo: string, payload: UpdateRolRequest): Promise<RolDto> {
|
||||||
|
const response = await axiosClient.put<RolDto>(
|
||||||
|
`/api/v1/roles/${encodeURIComponent(codigo)}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
245
src/web/src/features/roles/components/RolForm.tsx
Normal file
245
src/web/src/features/roles/components/RolForm.tsx
Normal file
@@ -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<typeof createSchema>
|
||||||
|
type UpdateFormValues = z.infer<typeof updateSchema>
|
||||||
|
|
||||||
|
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<CreateFormValues>({
|
||||||
|
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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendErr && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendErr}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="codigo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Código</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
placeholder="Ej: cajero_senior"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nombre"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nombre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} disabled={mutation.isPending} placeholder="Ej: Cajero Senior" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="descripcion"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Descripción (opcional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
placeholder="Descripción del rol"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={mutation.isPending} className="w-full">
|
||||||
|
{mutation.isPending ? 'Creando...' : 'Crear rol'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Form ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function EditRolForm({ initial, onSuccess }: { initial: RolDto; onSuccess?: () => void }) {
|
||||||
|
const mutation = useUpdateRole()
|
||||||
|
const form = useForm<UpdateFormValues>({
|
||||||
|
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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendErr && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendErr}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Código</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input value={initial.codigo} disabled readOnly />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nombre"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nombre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} disabled={mutation.isPending} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="descripcion"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Descripción (opcional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} disabled={mutation.isPending} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="activo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-3">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
aria-label="Activo"
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Activo</FormLabel>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={mutation.isPending} className="w-full">
|
||||||
|
{mutation.isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
src/web/src/features/roles/components/RolesList.tsx
Normal file
97
src/web/src/features/roles/components/RolesList.tsx
Normal file
@@ -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 <p className="text-sm text-muted-foreground">Cargando roles...</p>
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
const msg = isAxiosError(error) ? (error.message ?? 'Error al cargar roles') : 'Error al cargar roles'
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{msg}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roles || roles.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No hay roles registrados.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{deactivateErrMsg && (
|
||||||
|
<Alert variant="destructive" role="alert">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{deactivateErrMsg}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-4">Código</th>
|
||||||
|
<th className="py-2 pr-4">Nombre</th>
|
||||||
|
<th className="py-2 pr-4">Descripción</th>
|
||||||
|
<th className="py-2 pr-4">Estado</th>
|
||||||
|
<th className="py-2 pr-4 text-right">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<tr key={r.codigo} className="border-t border-border">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">{r.codigo}</td>
|
||||||
|
<td className="py-2 pr-4">{r.nombre}</td>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground">{r.descripcion ?? '—'}</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
{r.activo ? (
|
||||||
|
<Badge>Activo</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Inactivo</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right space-x-2">
|
||||||
|
<Link to={`/admin/roles/${encodeURIComponent(r.codigo)}/editar`}>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{r.activo && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deactivateMut.isPending}
|
||||||
|
onClick={() => handleDeactivate(r.codigo)}
|
||||||
|
>
|
||||||
|
Desactivar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
src/web/src/features/roles/hooks/useCreateRole.ts
Normal file
14
src/web/src/features/roles/hooks/useCreateRole.ts
Normal file
@@ -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 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/roles/hooks/useDeactivateRole.ts
Normal file
13
src/web/src/features/roles/hooks/useDeactivateRole.ts
Normal file
@@ -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 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
10
src/web/src/features/roles/hooks/useRol.ts
Normal file
10
src/web/src/features/roles/hooks/useRol.ts
Normal file
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/web/src/features/roles/hooks/useRoles.ts
Normal file
12
src/web/src/features/roles/hooks/useRoles.ts
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
20
src/web/src/features/roles/hooks/useUpdateRole.ts
Normal file
20
src/web/src/features/roles/hooks/useUpdateRole.ts
Normal file
@@ -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] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
46
src/web/src/features/roles/pages/EditRolPage.tsx
Normal file
46
src/web/src/features/roles/pages/EditRolPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-xl">Editar rol</CardTitle>
|
||||||
|
<CardDescription>Modificá nombre, descripción o estado del rol.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">Cargando...</p>}
|
||||||
|
{isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>No se pudo cargar el rol.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{rol && <EditRolForm initial={rol} onSuccess={() => navigate('/admin/roles')} />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/web/src/features/roles/pages/NewRolPage.tsx
Normal file
36
src/web/src/features/roles/pages/NewRolPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-xl">Nuevo rol</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Creá un nuevo rol del sistema. El código es inmutable una vez creado.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CreateRolForm onSuccess={() => navigate('/admin/roles')} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/web/src/features/roles/pages/RolesPage.tsx
Normal file
42
src/web/src/features/roles/pages/RolesPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Card className="w-full max-w-4xl">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-xl">Roles del sistema</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Gestión de roles canónicos. Los roles inactivos no pueden asignarse a nuevos usuarios.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link to="/admin/roles/nuevo">
|
||||||
|
<Button>Nuevo rol</Button>
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RolesList />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,10 +15,9 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { useCreateUser } from '../hooks/useCreateUser'
|
import { useCreateUser } from '../hooks/useCreateUser'
|
||||||
|
import { useRolesForSelect } from '../hooks/useRolesForSelect'
|
||||||
import type { CreatedUserDto } from '../api/createUser'
|
import type { CreatedUserDto } from '../api/createUser'
|
||||||
|
|
||||||
const ROL_OPTIONS = ['admin', 'vendedor', 'tasador', 'consulta'] as const
|
|
||||||
|
|
||||||
const userFormSchema = z.object({
|
const userFormSchema = z.object({
|
||||||
username: z
|
username: z
|
||||||
.string()
|
.string()
|
||||||
@@ -32,11 +31,7 @@ const userFormSchema = z.object({
|
|||||||
nombre: z.string().min(1, 'El nombre es requerido'),
|
nombre: z.string().min(1, 'El nombre es requerido'),
|
||||||
apellido: z.string().min(1, 'El apellido es requerido'),
|
apellido: z.string().min(1, 'El apellido es requerido'),
|
||||||
email: z.string().email('Email inválido').optional().or(z.literal('')),
|
email: z.string().email('Email inválido').optional().or(z.literal('')),
|
||||||
rol: z
|
rol: z.string().min(1, 'Seleccioná un rol válido'),
|
||||||
.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',
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type UserFormValues = z.infer<typeof userFormSchema>
|
type UserFormValues = z.infer<typeof userFormSchema>
|
||||||
@@ -59,6 +54,7 @@ function resolveBackendError(err: unknown): string | null {
|
|||||||
|
|
||||||
export function UserForm({ onSuccess }: UserFormProps) {
|
export function UserForm({ onSuccess }: UserFormProps) {
|
||||||
const { mutate, isPending, error } = useCreateUser()
|
const { mutate, isPending, error } = useCreateUser()
|
||||||
|
const { options: rolOptions, isLoading: rolesLoading, isError: rolesError } = useRolesForSelect()
|
||||||
|
|
||||||
const form = useForm<UserFormValues>({
|
const form = useForm<UserFormValues>({
|
||||||
resolver: zodResolver(userFormSchema),
|
resolver: zodResolver(userFormSchema),
|
||||||
@@ -91,6 +87,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const backendError = resolveBackendError(error)
|
const backendError = resolveBackendError(error)
|
||||||
|
const disabled = isPending || rolesLoading
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -102,6 +99,15 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rolesError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
No se pudieron cargar los roles. Intentá refrescar la página.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="username"
|
name="username"
|
||||||
@@ -113,7 +119,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
|||||||
{...field}
|
{...field}
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
disabled={isPending}
|
disabled={disabled}
|
||||||
placeholder="Nombre de usuario"
|
placeholder="Nombre de usuario"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -133,7 +139,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
|||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isPending}
|
disabled={disabled}
|
||||||
placeholder="Mínimo 8 chars, letra y dígito"
|
placeholder="Mínimo 8 chars, letra y dígito"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -149,7 +155,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Nombre</FormLabel>
|
<FormLabel>Nombre</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="text" disabled={isPending} placeholder="Nombre" />
|
<Input {...field} type="text" disabled={disabled} placeholder="Nombre" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -163,7 +169,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Apellido</FormLabel>
|
<FormLabel>Apellido</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="text" disabled={isPending} placeholder="Apellido" />
|
<Input {...field} type="text" disabled={disabled} placeholder="Apellido" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -181,7 +187,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
|||||||
{...field}
|
{...field}
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
disabled={isPending}
|
disabled={disabled}
|
||||||
placeholder="correo@ejemplo.com"
|
placeholder="correo@ejemplo.com"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -199,14 +205,16 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<select
|
<select
|
||||||
{...field}
|
{...field}
|
||||||
disabled={isPending}
|
disabled={disabled || rolesError}
|
||||||
aria-label="Rol"
|
aria-label="Rol"
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="">Seleccioná un rol</option>
|
<option value="">
|
||||||
{ROL_OPTIONS.map((r) => (
|
{rolesLoading ? 'Cargando roles...' : 'Seleccioná un rol'}
|
||||||
<option key={r} value={r}>
|
</option>
|
||||||
{r.charAt(0).toUpperCase() + r.slice(1)}
|
{rolOptions.map((r) => (
|
||||||
|
<option key={r.codigo} value={r.codigo}>
|
||||||
|
{r.nombre}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -216,7 +224,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending} className="w-full">
|
<Button type="submit" disabled={disabled} className="w-full">
|
||||||
{isPending ? 'Creando...' : 'Crear usuario'}
|
{isPending ? 'Creando...' : 'Crear usuario'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
22
src/web/src/features/users/hooks/useRolesForSelect.ts
Normal file
22
src/web/src/features/users/hooks/useRolesForSelect.ts
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import { useAuthStore } from './stores/authStore'
|
import { useAuthStore } from './stores/authStore'
|
||||||
import { LoginPage } from './features/auth/pages/LoginPage'
|
import { LoginPage } from './features/auth/pages/LoginPage'
|
||||||
import { CreateUserPage } from './features/users/pages/CreateUserPage'
|
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 { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||||
@@ -55,6 +58,36 @@ export function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/roles"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProtectedLayout>
|
||||||
|
<RolesPage />
|
||||||
|
</ProtectedLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/roles/nuevo"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProtectedLayout>
|
||||||
|
<NewRolPage />
|
||||||
|
</ProtectedLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/roles/:codigo/editar"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProtectedLayout>
|
||||||
|
<EditRolPage />
|
||||||
|
</ProtectedLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
167
src/web/src/tests/features/roles/RolForm.test.tsx
Normal file
167
src/web/src/tests/features/roles/RolForm.test.tsx
Normal file
@@ -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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<CreateRolForm onSuccess={onSuccess} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEdit(initial: RolDto, onSuccess = vi.fn()) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<EditRolForm initial={initial} onSuccess={onSuccess} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
})
|
||||||
88
src/web/src/tests/features/roles/RolesList.test.tsx
Normal file
88
src/web/src/tests/features/roles/RolesList.test.tsx
Normal file
@@ -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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<RolesList />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,10 +15,19 @@ const mockCreatedUser = {
|
|||||||
nombre: 'Juan',
|
nombre: 'Juan',
|
||||||
apellido: 'Doe',
|
apellido: 'Doe',
|
||||||
email: null,
|
email: null,
|
||||||
rol: 'vendedor',
|
rol: 'cajero',
|
||||||
activo: true,
|
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()
|
const server = setupServer()
|
||||||
|
|
||||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
@@ -27,7 +36,7 @@ afterAll(() => server.close())
|
|||||||
|
|
||||||
function renderForm(onSuccess = vi.fn()) {
|
function renderForm(onSuccess = vi.fn()) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { mutations: { retry: false } },
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
})
|
})
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
@@ -41,8 +50,11 @@ function renderForm(onSuccess = vi.fn()) {
|
|||||||
|
|
||||||
describe('UserForm — Zod validation', () => {
|
describe('UserForm — Zod validation', () => {
|
||||||
it('shows error when username is too short (< 3 chars)', async () => {
|
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()
|
const user = userEvent.setup()
|
||||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
|
||||||
renderForm()
|
renderForm()
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/usuario/i), 'ab')
|
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 () => {
|
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()
|
const user = userEvent.setup()
|
||||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
|
||||||
renderForm()
|
renderForm()
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Ab1')
|
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()
|
const user = userEvent.setup()
|
||||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
|
||||||
renderForm()
|
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(/usuario/i), 'jdoe123')
|
||||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
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()
|
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(
|
server.use(
|
||||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)),
|
||||||
return HttpResponse.json(mockCreatedUser, { status: 201 })
|
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 onSuccess = vi.fn()
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderForm(onSuccess)
|
renderForm(onSuccess)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('option', { name: 'Cajero' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/usuario/i), 'jdoe123')
|
await user.type(screen.getByLabelText(/usuario/i), 'jdoe123')
|
||||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
||||||
await user.type(screen.getByLabelText(/apellido/i), 'Doe')
|
await user.type(screen.getByLabelText(/apellido/i), 'Doe')
|
||||||
// Select rol via combobox
|
await user.selectOptions(screen.getByLabelText(/rol/i), 'cajero')
|
||||||
await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor')
|
|
||||||
// email left empty — valid
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||||
|
|
||||||
@@ -147,51 +148,43 @@ describe('UserForm — Zod validation', () => {
|
|||||||
expect(onSuccess).toHaveBeenCalledWith(mockCreatedUser)
|
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', () => {
|
describe('UserForm — 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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows backend 409 username_taken error in alert', async () => {
|
it('shows backend 409 username_taken error in alert', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)),
|
||||||
return HttpResponse.json(
|
http.post(`${API_URL}/api/v1/users`, async () =>
|
||||||
|
HttpResponse.json(
|
||||||
{ error: 'username_taken', message: 'El usuario ya existe' },
|
{ error: 'username_taken', message: 'El usuario ya existe' },
|
||||||
{ status: 409 },
|
{ status: 409 },
|
||||||
)
|
),
|
||||||
}),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderForm()
|
renderForm()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('option', { name: 'Cajero' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/usuario/i), 'existing')
|
await user.type(screen.getByLabelText(/usuario/i), 'existing')
|
||||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
||||||
await user.type(screen.getByLabelText(/apellido/i), 'Doe')
|
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 }))
|
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||||
|
|
||||||
|
|||||||
20
tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs
Normal file
20
tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using SIGCM2.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
[CollectionDefinition("ApiIntegration")]
|
||||||
|
public sealed class ApiIntegrationCollection : ICollectionFixture<TestWebAppFactory>
|
||||||
|
{
|
||||||
|
// Intentionally empty: this class only exists to declare the collection/fixture binding.
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using SIGCM2.TestSupport;
|
|||||||
namespace SIGCM2.Api.Tests.Auth;
|
namespace SIGCM2.Api.Tests.Auth;
|
||||||
|
|
||||||
[Collection("ApiIntegration")]
|
[Collection("ApiIntegration")]
|
||||||
public class AuthControllerTests : IClassFixture<TestWebAppFactory>
|
public class AuthControllerTests
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
|||||||
353
tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs
Normal file
353
tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for /api/v1/roles (UDT-004).
|
||||||
|
/// </summary>
|
||||||
|
[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<string> 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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<bool>(
|
||||||
|
"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<JsonElement>();
|
||||||
|
Assert.Equal("rol_in_use", body.GetProperty("error").GetString());
|
||||||
|
|
||||||
|
var activo = await conn.ExecuteScalarAsync<bool>(
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -11,11 +11,11 @@ namespace SIGCM2.Api.Tests.Usuarios;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Integration tests for POST api/v1/users (UDT-003).
|
/// Integration tests for POST api/v1/users (UDT-003).
|
||||||
/// These tests run against SIGCM2_Test database via TestWebAppFactory.
|
/// These tests run against SIGCM2_Test database via TestWebAppFactory.
|
||||||
/// Each test class instance gets the full WebApp factory (shared via IClassFixture).
|
/// TestWebAppFactory is shared across the whole "ApiIntegration" collection
|
||||||
/// DB reset happens once per test run (SqlTestFixture.InitializeAsync → ResetAndSeedAsync).
|
/// (see ApiIntegrationCollection) — one factory, one RSA singleton, one DB state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("ApiIntegration")]
|
[Collection("ApiIntegration")]
|
||||||
public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory>, IAsyncLifetime
|
public sealed class CreateUsuarioEndpointTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private const string TestConnectionString =
|
private const string TestConnectionString =
|
||||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
@@ -63,7 +63,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
nombre = "Test",
|
nombre = "Test",
|
||||||
apellido = "Usuario",
|
apellido = "Usuario",
|
||||||
email = (string?)null,
|
email = (string?)null,
|
||||||
rol = "vendedor"
|
rol = "cajero"
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -76,7 +76,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
await conn.ExecuteAsync("""
|
await conn.ExecuteAsync("""
|
||||||
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username)
|
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username)
|
||||||
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo)
|
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo)
|
||||||
VALUES (@Username, @Hash, 'Vendedor', 'Test', 'vendedor', '[]', 1)
|
VALUES (@Username, @Hash, 'Vendedor', 'Test', 'cajero', '[]', 1)
|
||||||
""",
|
""",
|
||||||
new { Username = username, Hash = passwordHash });
|
new { Username = username, Hash = passwordHash });
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
nombre = "Vendedor",
|
nombre = "Vendedor",
|
||||||
apellido = "Test",
|
apellido = "Test",
|
||||||
email = (string?)null,
|
email = (string?)null,
|
||||||
rol = "vendedor"
|
rol = "cajero"
|
||||||
}, adminToken);
|
}, adminToken);
|
||||||
|
|
||||||
var createResp = await _client.SendAsync(createRequest);
|
var createResp = await _client.SendAsync(createRequest);
|
||||||
@@ -190,7 +190,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
nombre = "Integration",
|
nombre = "Integration",
|
||||||
apellido = "Test",
|
apellido = "Test",
|
||||||
email = "integration@test.com",
|
email = "integration@test.com",
|
||||||
rol = "vendedor"
|
rol = "cajero"
|
||||||
}, adminToken);
|
}, adminToken);
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -211,7 +211,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
|
|
||||||
Assert.True(id.GetInt32() > 0, "'id' must be positive");
|
Assert.True(id.GetInt32() > 0, "'id' must be positive");
|
||||||
Assert.Equal(newUsername, username.GetString());
|
Assert.Equal(newUsername, username.GetString());
|
||||||
Assert.Equal("vendedor", rol.GetString());
|
Assert.Equal("cajero", rol.GetString());
|
||||||
|
|
||||||
// Must NOT contain passwordHash
|
// Must NOT contain passwordHash
|
||||||
Assert.False(json.TryGetProperty("passwordHash", out _), "Response must NOT leak 'passwordHash'");
|
Assert.False(json.TryGetProperty("passwordHash", out _), "Response must NOT leak 'passwordHash'");
|
||||||
@@ -253,7 +253,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
nombre = "First",
|
nombre = "First",
|
||||||
apellido = "User",
|
apellido = "User",
|
||||||
email = (string?)null,
|
email = (string?)null,
|
||||||
rol = "vendedor"
|
rol = "cajero"
|
||||||
}, adminToken);
|
}, adminToken);
|
||||||
var firstResp = await _client.SendAsync(first);
|
var firstResp = await _client.SendAsync(first);
|
||||||
Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode);
|
||||||
@@ -266,7 +266,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
nombre = "Second",
|
nombre = "Second",
|
||||||
apellido = "User",
|
apellido = "User",
|
||||||
email = (string?)null,
|
email = (string?)null,
|
||||||
rol = "consulta"
|
rol = "reportes"
|
||||||
}, adminToken);
|
}, adminToken);
|
||||||
var secondResp = await _client.SendAsync(second);
|
var secondResp = await _client.SendAsync(second);
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
await conn.ExecuteAsync("""
|
await conn.ExecuteAsync("""
|
||||||
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username)
|
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = @Username)
|
||||||
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo)
|
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo)
|
||||||
VALUES (@Username, '$2a$12$placeholder_hash_for_race_test', 'Race', 'User', 'vendedor', '[]', 1)
|
VALUES (@Username, '$2a$12$placeholder_hash_for_race_test', 'Race', 'User', 'cajero', '[]', 1)
|
||||||
""", new { Username = username });
|
""", new { Username = username });
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -314,7 +314,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
nombre = "Race",
|
nombre = "Race",
|
||||||
apellido = "User",
|
apellido = "User",
|
||||||
email = (string?)null,
|
email = (string?)null,
|
||||||
rol = "vendedor"
|
rol = "cajero"
|
||||||
}, adminToken);
|
}, adminToken);
|
||||||
|
|
||||||
var response = await _client.SendAsync(request);
|
var response = await _client.SendAsync(request);
|
||||||
@@ -357,7 +357,7 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
nombre = "E2E",
|
nombre = "E2E",
|
||||||
apellido = "Test",
|
apellido = "Test",
|
||||||
email = (string?)null,
|
email = (string?)null,
|
||||||
rol = "vendedor"
|
rol = "cajero"
|
||||||
}, adminToken);
|
}, adminToken);
|
||||||
|
|
||||||
var createResp = await _client.SendAsync(createReq);
|
var createResp = await _client.SendAsync(createReq);
|
||||||
@@ -380,11 +380,53 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory
|
|||||||
|
|
||||||
// Verify usuario in response
|
// Verify usuario in response
|
||||||
Assert.True(loginJson.TryGetProperty("usuario", out var usuario));
|
Assert.True(loginJson.TryGetProperty("usuario", out var usuario));
|
||||||
Assert.Equal("vendedor", usuario.GetProperty("rol").GetString());
|
Assert.Equal("cajero", usuario.GetProperty("rol").GetString());
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await DeleteUsuarioAsync(newUsername);
|
await DeleteUsuarioAsync(newUsername);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenario 7 (UDT-004 Phase 5.3): 400 — rol existe pero está inactivo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateUsuario_WithInactiveRol_Returns400()
|
||||||
|
{
|
||||||
|
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
|
const string codigo = "udt004_inactive_rol";
|
||||||
|
const string testUser = "udt004_inactive_rol_user";
|
||||||
|
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'Inactivo Test', 0);",
|
||||||
|
new { Codigo = codigo });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
|
||||||
|
{
|
||||||
|
username = testUser,
|
||||||
|
password = "Secure1234!",
|
||||||
|
nombre = "Test",
|
||||||
|
apellido = "Inactive",
|
||||||
|
email = (string?)null,
|
||||||
|
rol = codigo
|
||||||
|
}, adminToken);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.TryGetProperty("errors", out var errors), "Response must contain 'errors'");
|
||||||
|
// Validation error should be on the Rol field
|
||||||
|
Assert.Contains(errors.EnumerateObject(), p => p.Name.Equals("Rol", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteUsuarioAsync(testUser);
|
||||||
|
await conn.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
tests/SIGCM2.Application.Tests/Domain/RolTests.cs
Normal file
82
tests/SIGCM2.Application.Tests/Domain/RolTests.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Domain;
|
||||||
|
|
||||||
|
public class RolTests
|
||||||
|
{
|
||||||
|
// Happy path: full constructor sets all properties.
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_SetsAllProperties()
|
||||||
|
{
|
||||||
|
var created = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var modified = new DateTime(2026, 4, 15, 11, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var rol = new Rol(
|
||||||
|
id: 1,
|
||||||
|
codigo: "cajero",
|
||||||
|
nombre: "Cajero",
|
||||||
|
descripcion: "Atención de mostrador",
|
||||||
|
activo: true,
|
||||||
|
fechaCreacion: created,
|
||||||
|
fechaModificacion: modified
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(1, rol.Id);
|
||||||
|
Assert.Equal("cajero", rol.Codigo);
|
||||||
|
Assert.Equal("Cajero", rol.Nombre);
|
||||||
|
Assert.Equal("Atención de mostrador", rol.Descripcion);
|
||||||
|
Assert.True(rol.Activo);
|
||||||
|
Assert.Equal(created, rol.FechaCreacion);
|
||||||
|
Assert.Equal(modified, rol.FechaModificacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triangulation: descripcion is nullable, fechaModificacion is nullable.
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WithNullOptionals_SetsNulls()
|
||||||
|
{
|
||||||
|
var created = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var rol = new Rol(
|
||||||
|
id: 2,
|
||||||
|
codigo: "reportes",
|
||||||
|
nombre: "Reportes",
|
||||||
|
descripcion: null,
|
||||||
|
activo: false,
|
||||||
|
fechaCreacion: created,
|
||||||
|
fechaModificacion: null
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Null(rol.Descripcion);
|
||||||
|
Assert.Null(rol.FechaModificacion);
|
||||||
|
Assert.False(rol.Activo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForCreation: Id=0 (IDENTITY assigned by DB), Activo=true, FechaCreacion=SYSUTCDATETIME-ish (not set here), FechaModificacion=null.
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_ReturnsNewInstanceWithDefaults()
|
||||||
|
{
|
||||||
|
var rol = Rol.ForCreation(
|
||||||
|
codigo: "picadora",
|
||||||
|
nombre: "Picadora/Correctora",
|
||||||
|
descripcion: "Edición de textos"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(0, rol.Id);
|
||||||
|
Assert.Equal("picadora", rol.Codigo);
|
||||||
|
Assert.Equal("Picadora/Correctora", rol.Nombre);
|
||||||
|
Assert.Equal("Edición de textos", rol.Descripcion);
|
||||||
|
Assert.True(rol.Activo);
|
||||||
|
Assert.Null(rol.FechaModificacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triangulation: ForCreation accepts null descripcion.
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_WithNullDescripcion_AllowsNull()
|
||||||
|
{
|
||||||
|
var rol = Rol.ForCreation(codigo: "admin", nombre: "Administrador", descripcion: null);
|
||||||
|
|
||||||
|
Assert.Null(rol.Descripcion);
|
||||||
|
Assert.Equal("admin", rol.Codigo);
|
||||||
|
Assert.True(rol.Activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,10 +29,13 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
|
|||||||
|
|
||||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
_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 _respawner.ResetAsync(_connection);
|
await _respawner.ResetAsync(_connection);
|
||||||
|
await SeedRolCanonicalAsync();
|
||||||
await SeedTestUserAsync();
|
await SeedTestUserAsync();
|
||||||
|
|
||||||
_testUserId = await _connection.QuerySingleAsync<int>(
|
_testUserId = await _connection.QuerySingleAsync<int>(
|
||||||
@@ -49,6 +52,29 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
|
|||||||
await _connection.DisposeAsync();
|
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()
|
private async Task SeedTestUserAsync()
|
||||||
{
|
{
|
||||||
await _connection.ExecuteAsync("""
|
await _connection.ExecuteAsync("""
|
||||||
|
|||||||
240
tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs
Normal file
240
tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs
Normal file
@@ -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<string?>(
|
||||||
|
"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<int>(
|
||||||
|
"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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,11 +21,14 @@ public class UsuarioRepositoryTests : IAsyncLifetime
|
|||||||
|
|
||||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
_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 _respawner.ResetAsync(_connection);
|
||||||
|
await SeedRolCanonicalAsync();
|
||||||
await SeedAdminAsync();
|
await SeedAdminAsync();
|
||||||
|
|
||||||
var factory = new SqlConnectionFactory(ConnectionString);
|
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)
|
// Triangulation: case-sensitive username lookup (SQL Server UNIQUE constraint is case-insensitive by default)
|
||||||
[Fact]
|
[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(
|
await _connection.ExecuteAsync(
|
||||||
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) " +
|
"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 admin = await _repository.GetByUsernameAsync("admin");
|
||||||
var vendedor = await _repository.GetByUsernameAsync("vendedor1");
|
var cajero = await _repository.GetByUsernameAsync("cajero1");
|
||||||
|
|
||||||
Assert.NotNull(admin);
|
Assert.NotNull(admin);
|
||||||
Assert.NotNull(vendedor);
|
Assert.NotNull(cajero);
|
||||||
Assert.NotEqual(admin.Id, vendedor.Id);
|
Assert.NotEqual(admin.Id, cajero.Id);
|
||||||
Assert.Equal("admin", admin.Rol);
|
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()
|
private async Task SeedAdminAsync()
|
||||||
|
|||||||
@@ -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<IRolRepository>();
|
||||||
|
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<RolAlreadyExistsException>(
|
||||||
|
() => _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<string>())
|
||||||
|
.Returns(new Rol(1, "cajero_senior", "X", null, true, now, null));
|
||||||
|
|
||||||
|
try { await _handler.Handle(ValidCommand()); } catch (RolAlreadyExistsException) { }
|
||||||
|
|
||||||
|
await _repository.DidNotReceive().AddAsync(Arg.Any<Rol>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Happy_AddsAndReturnsDtoWithId()
|
||||||
|
{
|
||||||
|
_repository.GetByCodigoAsync(Arg.Any<string>()).Returns((Rol?)null);
|
||||||
|
_repository.AddAsync(Arg.Any<Rol>(), Arg.Any<CancellationToken>()).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<string>()).Returns((Rol?)null);
|
||||||
|
_repository.AddAsync(Arg.Any<Rol>(), Arg.Any<CancellationToken>()).Returns(5);
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repository.Received(1).AddAsync(
|
||||||
|
Arg.Is<Rol>(r => r.Codigo == "cajero_senior" && r.Nombre == "Cajero Senior" && r.Activo),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Happy_WithNullDescripcion_PassesNullToRepository()
|
||||||
|
{
|
||||||
|
_repository.GetByCodigoAsync(Arg.Any<string>()).Returns((Rol?)null);
|
||||||
|
_repository.AddAsync(Arg.Any<Rol>(), Arg.Any<CancellationToken>()).Returns(1);
|
||||||
|
|
||||||
|
await _handler.Handle(new CreateRolCommand("nuevo_rol", "Nuevo", null));
|
||||||
|
|
||||||
|
await _repository.Received(1).AddAsync(
|
||||||
|
Arg.Is<Rol>(r => r.Descripcion == null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IRolRepository>();
|
||||||
|
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<RolNotFoundException>(
|
||||||
|
() => _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<RolInUseException>(
|
||||||
|
() => _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<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<CancellationToken>())
|
||||||
|
.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<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
await _handler.Handle(new DeactivateRolCommand("reportes"));
|
||||||
|
|
||||||
|
await _repository.Received(1).UpdateAsync("reportes", "Nombre", "Desc", false, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IRolRepository>();
|
||||||
|
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<RolNotFoundException>(
|
||||||
|
() => _handler.Handle(new GetRolByCodigoQuery("missing")));
|
||||||
|
|
||||||
|
Assert.Equal("missing", ex.Codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IRolRepository>();
|
||||||
|
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<Rol>
|
||||||
|
{
|
||||||
|
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<Rol>
|
||||||
|
{
|
||||||
|
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<Rol>());
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new ListRolesQuery());
|
||||||
|
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IRolRepository>();
|
||||||
|
private readonly UpdateRolCommandHandler _handler;
|
||||||
|
|
||||||
|
public UpdateRolCommandHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new UpdateRolCommandHandler(_repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NonExistentCodigo_ThrowsRolNotFoundException()
|
||||||
|
{
|
||||||
|
_repository.UpdateAsync("missing", Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<RolNotFoundException>(
|
||||||
|
() => _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<CancellationToken>())
|
||||||
|
.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<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||||
|
.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<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using FluentValidation.TestHelper;
|
using FluentValidation.TestHelper;
|
||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Auth;
|
using SIGCM2.Application.Auth;
|
||||||
using SIGCM2.Application.Usuarios.Create;
|
using SIGCM2.Application.Usuarios.Create;
|
||||||
|
|
||||||
@@ -6,8 +8,19 @@ namespace SIGCM2.Application.Tests.Usuarios.Create;
|
|||||||
|
|
||||||
public class CreateUsuarioCommandValidatorTests
|
public class CreateUsuarioCommandValidatorTests
|
||||||
{
|
{
|
||||||
private static CreateUsuarioCommandValidator BuildValidator(AuthOptions? opts = null) =>
|
private readonly IRolRepository _roles = Substitute.For<IRolRepository>();
|
||||||
new(opts ?? new AuthOptions());
|
|
||||||
|
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<CancellationToken>()).Returns(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CreateUsuarioCommandValidator BuildValidator(AuthOptions? opts = null) =>
|
||||||
|
new(opts ?? new AuthOptions(), _roles);
|
||||||
|
|
||||||
private static CreateUsuarioCommand ValidCommand() => new(
|
private static CreateUsuarioCommand ValidCommand() => new(
|
||||||
Username: "operador1",
|
Username: "operador1",
|
||||||
@@ -15,180 +28,211 @@ public class CreateUsuarioCommandValidatorTests
|
|||||||
Nombre: "Juan",
|
Nombre: "Juan",
|
||||||
Apellido: "Pérez",
|
Apellido: "Pérez",
|
||||||
Email: null,
|
Email: null,
|
||||||
Rol: "vendedor");
|
Rol: "cajero");
|
||||||
|
|
||||||
// ── Happy paths ──────────────────────────────────────────────────────────
|
// ── Happy paths ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[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();
|
result.ShouldNotHaveAnyValidationErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_NullEmail_IsValid()
|
public async Task Validate_NullEmail_IsValid()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Email = null };
|
var cmd = ValidCommand() with { Email = null };
|
||||||
BuildValidator().TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
|
var result = await BuildValidator().TestValidateAsync(cmd);
|
||||||
|
result.ShouldNotHaveAnyValidationErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_ValidEmailPresent_NoErrors()
|
public async Task Validate_ValidEmailPresent_NoErrors()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Email = "juan@example.com" };
|
var cmd = ValidCommand() with { Email = "juan@example.com" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
|
var result = await BuildValidator().TestValidateAsync(cmd);
|
||||||
|
result.ShouldNotHaveAnyValidationErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Username ─────────────────────────────────────────────────────────────
|
// ── Username ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_EmptyUsername_HasError()
|
public async Task Validate_EmptyUsername_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Username = "" };
|
var cmd = ValidCommand() with { Username = "" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_UsernameTooShort_HasError()
|
public async Task Validate_UsernameTooShort_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Username = "ab" };
|
var cmd = ValidCommand() with { Username = "ab" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_UsernameTooLong_HasError()
|
public async Task Validate_UsernameTooLong_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Username = new string('a', 51) };
|
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]
|
[Theory]
|
||||||
[InlineData("abc")] // 3 chars — boundary valid
|
[InlineData("abc")]
|
||||||
[InlineData("user.name")] // dot allowed
|
[InlineData("user.name")]
|
||||||
[InlineData("user-name")] // dash allowed
|
[InlineData("user-name")]
|
||||||
[InlineData("user_name")] // underscore allowed
|
[InlineData("user_name")]
|
||||||
[InlineData("user123")] // alphanumeric
|
[InlineData("user123")]
|
||||||
public void Validate_UsernameValidFormats_NoError(string username)
|
public async Task Validate_UsernameValidFormats_NoError(string username)
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Username = username };
|
var cmd = ValidCommand() with { Username = username };
|
||||||
BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Username);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("user name")] // space not allowed
|
[InlineData("user name")]
|
||||||
[InlineData("user@name")] // @ not allowed
|
[InlineData("user@name")]
|
||||||
[InlineData("user#1")] // # not allowed
|
[InlineData("user#1")]
|
||||||
public void Validate_UsernameInvalidChars_HasError(string username)
|
public async Task Validate_UsernameInvalidChars_HasError(string username)
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Username = username };
|
var cmd = ValidCommand() with { Username = username };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Password ─────────────────────────────────────────────────────────────
|
// ── Password ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_EmptyPassword_HasError()
|
public async Task Validate_EmptyPassword_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Password = "" };
|
var cmd = ValidCommand() with { Password = "" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_PasswordTooShort_HasError()
|
public async Task Validate_PasswordTooShort_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Password = "Ab1cd5" }; // 6 chars < 8
|
var cmd = ValidCommand() with { Password = "Ab1cd5" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_PasswordNoLetter_HasError()
|
public async Task Validate_PasswordNoLetter_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Password = "12345678" }; // digits only
|
var cmd = ValidCommand() with { Password = "12345678" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_PasswordNoDigit_HasError()
|
public async Task Validate_PasswordNoDigit_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Password = "abcdefgh" }; // letters only
|
var cmd = ValidCommand() with { Password = "abcdefgh" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_PasswordExactMinLength_NoError()
|
public async Task Validate_PasswordExactMinLength_NoError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Password = "Secre123" }; // exactly 8, letter + digit
|
var cmd = ValidCommand() with { Password = "Secre123" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Password);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Nombre / Apellido ────────────────────────────────────────────────────
|
// ── Nombre / Apellido ────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_EmptyNombre_HasError()
|
public async Task Validate_EmptyNombre_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Nombre = "" };
|
var cmd = ValidCommand() with { Nombre = "" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Nombre);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Nombre);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_EmptyApellido_HasError()
|
public async Task Validate_EmptyApellido_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Apellido = "" };
|
var cmd = ValidCommand() with { Apellido = "" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Apellido);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Apellido);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_NombreTooLong_HasError()
|
public async Task Validate_NombreTooLong_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Nombre = new string('a', 101) };
|
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]
|
[Fact]
|
||||||
public void Validate_ApellidoTooLong_HasError()
|
public async Task Validate_ApellidoTooLong_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Apellido = new string('a', 101) };
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Rol ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("admin")]
|
[InlineData("admin")]
|
||||||
[InlineData("vendedor")]
|
[InlineData("cajero")]
|
||||||
[InlineData("tasador")]
|
[InlineData("operador_ctacte")]
|
||||||
[InlineData("consulta")]
|
[InlineData("picadora")]
|
||||||
public void Validate_ValidRoles_NoError(string rol)
|
[InlineData("jefe_publicidad")]
|
||||||
|
[InlineData("productor")]
|
||||||
|
[InlineData("diagramacion")]
|
||||||
|
[InlineData("reportes")]
|
||||||
|
public async Task Validate_CanonicalActiveRoles_NoError(string rol)
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Rol = rol };
|
var cmd = ValidCommand() with { Rol = rol };
|
||||||
BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Rol);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Rol);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData("superuser")]
|
public async Task Validate_RolInexistente_HasError()
|
||||||
[InlineData("ADMIN")] // case-sensitive
|
|
||||||
[InlineData("root")]
|
|
||||||
[InlineData("")]
|
|
||||||
public void Validate_InvalidRol_HasError(string rol)
|
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Rol = rol };
|
_roles.ExistsActiveByCodigoAsync("superuser", Arg.Any<CancellationToken>()).Returns(false);
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Rol);
|
|
||||||
|
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<CancellationToken>()).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<CancellationToken>()).Returns(false);
|
||||||
|
|
||||||
|
var cmd = ValidCommand() with { Rol = "ADMIN" };
|
||||||
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Email ────────────────────────────────────────────────────────────────
|
// ── Email ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_InvalidEmail_HasError()
|
public async Task Validate_InvalidEmail_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Email = "not-an-email" };
|
var cmd = ValidCommand() with { Email = "not-an-email" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Email);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Email);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_EmailTooLong_HasError()
|
public async Task Validate_EmailTooLong_HasError()
|
||||||
{
|
{
|
||||||
var cmd = ValidCommand() with { Email = new string('a', 145) + "@b.com" }; // >150
|
var cmd = ValidCommand() with { Email = new string('a', 145) + "@b.com" };
|
||||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Email);
|
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Email);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
|
|
||||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
_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();
|
await ResetAndSeedAsync();
|
||||||
@@ -37,9 +39,33 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
public async Task ResetAndSeedAsync()
|
public async Task ResetAndSeedAsync()
|
||||||
{
|
{
|
||||||
await _respawner.ResetAsync(_connection);
|
await _respawner.ResetAsync(_connection);
|
||||||
|
await SeedRolCanonicalAsync();
|
||||||
await SeedAdminAsync();
|
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()
|
public async Task DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_connection is not null)
|
if (_connection is not null)
|
||||||
|
|||||||
16
tests/tests.runsettings
Normal file
16
tests/tests.runsettings
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RunSettings>
|
||||||
|
<!--
|
||||||
|
Fuerza ejecución secuencial de las diferentes test assemblies.
|
||||||
|
Justificación: Application.Tests (integration) y Api.Tests (WebApplicationFactory)
|
||||||
|
comparten la BD SIGCM2_Test. Ejecutarlas en paralelo produce race conditions
|
||||||
|
sobre Respawn.Reset + SeedRolCanonical + SeedAdmin.
|
||||||
|
|
||||||
|
Cuando se corre proyecto a proyecto (`dotnet test <csproj>`) no hay paralelismo
|
||||||
|
cross-assembly y no se necesita este settings. Este archivo es para el caso
|
||||||
|
`dotnet test` en la raíz del repo.
|
||||||
|
-->
|
||||||
|
<RunConfiguration>
|
||||||
|
<MaxCpuCount>1</MaxCpuCount>
|
||||||
|
</RunConfiguration>
|
||||||
|
</RunSettings>
|
||||||
Reference in New Issue
Block a user