feat(api): List + GetById usuarios — handlers, repo, endpoints [UDT-008]

This commit is contained in:
2026-04-15 17:46:23 -03:00
parent 9dcd63543e
commit 2925336783
29 changed files with 1210 additions and 6 deletions

View File

@@ -3,26 +3,42 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization; using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.Create; using SIGCM2.Application.Usuarios.Create;
using SIGCM2.Application.Usuarios.Deactivate;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Application.Usuarios.List;
using SIGCM2.Application.Usuarios.Reactivate;
using SIGCM2.Application.Usuarios.Update;
using SIGCM2.Application.Usuarios.ChangeMyPassword;
using SIGCM2.Application.Usuarios.ResetPassword;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace SIGCM2.Api.Controllers; namespace SIGCM2.Api.Controllers;
/// <summary>
/// UDT-001/UDT-008: Usuario management endpoints.
/// RequirePermission moved to method level to allow /me/password with [Authorize] only.
/// </summary>
[ApiController] [ApiController]
[Route("api/v1/users")] [Route("api/v1/users")]
[RequirePermission("administracion:usuarios:gestionar")]
public sealed class UsuariosController : ControllerBase public sealed class UsuariosController : ControllerBase
{ {
private readonly IDispatcher _dispatcher; private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateUsuarioCommand> _validator; private readonly IValidator<CreateUsuarioCommand> _createValidator;
public UsuariosController(IDispatcher dispatcher, IValidator<CreateUsuarioCommand> validator) public UsuariosController(
IDispatcher dispatcher,
IValidator<CreateUsuarioCommand> createValidator)
{ {
_dispatcher = dispatcher; _dispatcher = dispatcher;
_validator = validator; _createValidator = createValidator;
} }
/// <summary>Creates a new user. Requires admin role.</summary> /// <summary>Creates a new user. Requires administracion:usuarios:gestionar.</summary>
[HttpPost] [HttpPost]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioCreatedDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(UsuarioCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
@@ -38,7 +54,7 @@ public sealed class UsuariosController : ControllerBase
Email: request.Email, Email: request.Email,
Rol: request.Rol ?? string.Empty); Rol: request.Rol ?? string.Empty);
var validation = await _validator.ValidateAsync(command); var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid) if (!validation.IsValid)
{ {
var errors = validation.Errors var errors = validation.Errors
@@ -51,8 +67,144 @@ public sealed class UsuariosController : ControllerBase
return CreatedAtAction(nameof(CreateUsuario), new { id = result.Id }, result); return CreatedAtAction(nameof(CreateUsuario), new { id = result.Id }, result);
} }
/// <summary>Lists usuarios with optional filters and pagination.</summary>
[HttpGet]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(PagedResult<UsuarioListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListUsuarios(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? rol = null,
[FromQuery] bool? activo = null,
[FromQuery] string? search = null)
{
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
var query = new ListUsuariosQuery(page, pageSize, rol, activo, search);
var result = await _dispatcher.Send<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>(query);
return Ok(result);
} }
/// <summary>Gets a single usuario by id.</summary>
[HttpGet("{id:int}")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetUsuarioById([FromRoute] int id)
{
var query = new GetUsuarioByIdQuery(id);
var result = await _dispatcher.Send<GetUsuarioByIdQuery, UsuarioDetailDto>(query);
return Ok(result);
}
/// <summary>Updates a usuario's editable fields.</summary>
[HttpPut("{id:int}")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateUsuario([FromRoute] int id, [FromBody] UpdateUsuarioRequest request)
{
var command = new UpdateUsuarioCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
Apellido: request.Apellido ?? string.Empty,
Email: request.Email,
Rol: request.Rol ?? string.Empty,
Activo: request.Activo ?? true);
var result = await _dispatcher.Send<UpdateUsuarioCommand, UsuarioDetailDto>(command);
return Ok(result);
}
/// <summary>Deactivates a usuario (idempotent).</summary>
[HttpPatch("{id:int}/deactivate")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateUsuario([FromRoute] int id)
{
var command = new DeactivateUsuarioCommand(id);
var result = await _dispatcher.Send<DeactivateUsuarioCommand, UsuarioDetailDto>(command);
return Ok(result);
}
/// <summary>Reactivates a usuario (idempotent).</summary>
[HttpPatch("{id:int}/reactivate")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReactivateUsuario([FromRoute] int id)
{
var command = new ReactivateUsuarioCommand(id);
var result = await _dispatcher.Send<ReactivateUsuarioCommand, UsuarioDetailDto>(command);
return Ok(result);
}
/// <summary>
/// Changes the authenticated user's own password.
/// Declared BEFORE /{id:int} route to avoid routing ambiguity (though :int constraint handles it).
/// Requires only authentication (no specific permission).
/// </summary>
[HttpPut("me/password")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ChangeMyPassword([FromBody] ChangeMyPasswordRequest request)
{
var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedAccessException();
var command = new ChangeMyPasswordCommand(
UsuarioId: int.Parse(sub),
OldPassword: request.OldPassword ?? string.Empty,
NewPassword: request.NewPassword ?? string.Empty);
await _dispatcher.Send<ChangeMyPasswordCommand, Unit>(command);
return NoContent();
}
/// <summary>Resets a usuario's password (admin only). Returns a one-time temp password.</summary>
[HttpPost("{id:int}/password/reset")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(ResetUsuarioPasswordResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ResetUsuarioPassword([FromRoute] int id)
{
var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedAccessException();
var command = new ResetUsuarioPasswordCommand(
TargetId: id,
CallerId: int.Parse(sub));
var result = await _dispatcher.Send<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>(command);
return Ok(result);
}
}
// ── request body records ──────────────────────────────────────────────────────
/// <summary>Create user request body — nullable to catch missing field scenarios.</summary> /// <summary>Create user request body — nullable to catch missing field scenarios.</summary>
public sealed record CreateUsuarioRequest( public sealed record CreateUsuarioRequest(
string? Username, string? Username,
@@ -61,3 +213,14 @@ public sealed record CreateUsuarioRequest(
string? Apellido, string? Apellido,
string? Email, string? Email,
string? Rol); string? Rol);
public sealed record UpdateUsuarioRequest(
string? Nombre,
string? Apellido,
string? Email,
string? Rol,
bool? Activo);
public sealed record ChangeMyPasswordRequest(
string? OldPassword,
string? NewPassword);

View File

@@ -19,6 +19,56 @@ public sealed class ExceptionFilter : IExceptionFilter
{ {
switch (context.Exception) switch (context.Exception)
{ {
case UsuarioNotFoundException usuarioNotFoundEx:
context.Result = new ObjectResult(new
{
error = "usuario_not_found",
message = usuarioNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case LastAdminLockoutException:
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
{
Type = "about:blank",
Title = "last-admin-lockout",
Status = 400,
Detail = "No se puede desactivar o cambiar el rol del último administrador activo."
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case CannotSelfResetException:
context.Result = new ObjectResult(new
{
error = "cannot-self-reset",
message = "Un administrador no puede resetear su propia contraseña. Use el endpoint de cambio de contraseña propio."
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case InvalidOldPasswordException:
context.Result = new ObjectResult(new
{
error = "invalid-old-password",
message = "La contraseña actual es incorrecta."
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case UsernameAlreadyExistsException usernameEx: case UsernameAlreadyExistsException usernameEx:
context.Result = new ObjectResult(new context.Result = new ObjectResult(new
{ {

View File

@@ -0,0 +1,43 @@
using System.Security.Cryptography;
using System.Text;
namespace SIGCM2.Application.Common;
/// <summary>
/// Generates cryptographically secure temporary passwords.
/// Excludes visually ambiguous characters (I, O, l, o, 0, 1).
/// </summary>
public static class TempPasswordGenerator
{
private const string UpperChars = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O
private const string LowerChars = "abcdefghijkmnpqrstuvwxyz"; // no l, o
private const string DigitChars = "23456789"; // no 0, 1
private const string SymbolChars = "!@#$%&*+-=?"; // copy-paste safe
private const string Charset = UpperChars + LowerChars + DigitChars + SymbolChars;
public static string Generate(int length = 12)
{
if (length < 8)
throw new ArgumentOutOfRangeException(nameof(length), "Password length must be at least 8.");
// SECURITY: NEVER log the result of this method
Span<byte> bytes = stackalloc byte[length];
RandomNumberGenerator.Fill(bytes);
var sb = new StringBuilder(length);
for (int i = 0; i < length; i++)
sb.Append(Charset[bytes[i] % Charset.Length]);
var result = sb.ToString();
// Guarantee diversity: at least 1 upper, 1 lower, 1 digit, 1 symbol
return HasDiversity(result) ? result : Generate(length);
}
private static bool HasDiversity(string pwd)
=> pwd.Any(c => UpperChars.Contains(c))
&& pwd.Any(c => LowerChars.Contains(c))
&& pwd.Any(c => DigitChars.Contains(c))
&& pwd.Any(c => SymbolChars.Contains(c));
}

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Common;
/// <summary>Represents the absence of a meaningful return value.</summary>
public readonly struct Unit
{
public static readonly Unit Value = default;
}

View File

@@ -4,6 +4,7 @@ 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.Common;
using SIGCM2.Application.Permisos.Assign; using SIGCM2.Application.Permisos.Assign;
using SIGCM2.Application.Permisos.Dtos; using SIGCM2.Application.Permisos.Dtos;
using SIGCM2.Application.Permisos.GetByRol; using SIGCM2.Application.Permisos.GetByRol;
@@ -14,7 +15,14 @@ using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Application.Roles.Get; using SIGCM2.Application.Roles.Get;
using SIGCM2.Application.Roles.List; using SIGCM2.Application.Roles.List;
using SIGCM2.Application.Roles.Update; using SIGCM2.Application.Roles.Update;
using SIGCM2.Application.Usuarios.ChangeMyPassword;
using SIGCM2.Application.Usuarios.Create; using SIGCM2.Application.Usuarios.Create;
using SIGCM2.Application.Usuarios.Deactivate;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Application.Usuarios.List;
using SIGCM2.Application.Usuarios.Reactivate;
using SIGCM2.Application.Usuarios.ResetPassword;
using SIGCM2.Application.Usuarios.Update;
namespace SIGCM2.Application; namespace SIGCM2.Application;
@@ -40,6 +48,15 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>, GetRolPermisosQueryHandler>(); services.AddScoped<ICommandHandler<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>, GetRolPermisosQueryHandler>();
services.AddScoped<ICommandHandler<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>, AssignPermisosToRolCommandHandler>(); services.AddScoped<ICommandHandler<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>, AssignPermisosToRolCommandHandler>();
// Usuarios (UDT-008)
services.AddScoped<ICommandHandler<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>, ListUsuariosQueryHandler>();
services.AddScoped<ICommandHandler<GetUsuarioByIdQuery, UsuarioDetailDto>, GetUsuarioByIdQueryHandler>();
services.AddScoped<ICommandHandler<UpdateUsuarioCommand, UsuarioDetailDto>, UpdateUsuarioCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateUsuarioCommand, UsuarioDetailDto>, DeactivateUsuarioCommandHandler>();
services.AddScoped<ICommandHandler<ReactivateUsuarioCommand, UsuarioDetailDto>, ReactivateUsuarioCommandHandler>();
services.AddScoped<ICommandHandler<ChangeMyPasswordCommand, Unit>, ChangeMyPasswordCommandHandler>();
services.AddScoped<ICommandHandler<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>, ResetUsuarioPasswordCommandHandler>();
// FluentValidation validators (scans entire Application assembly) // FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>(); services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Usuarios.ChangeMyPassword;
public sealed record ChangeMyPasswordCommand(
int UsuarioId,
string OldPassword,
string NewPassword
);

View File

@@ -0,0 +1,37 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Usuarios.ChangeMyPassword;
public sealed class ChangeMyPasswordCommandHandler : ICommandHandler<ChangeMyPasswordCommand, Unit>
{
private readonly IUsuarioRepository _repository;
private readonly IPasswordHasher _hasher;
public ChangeMyPasswordCommandHandler(
IUsuarioRepository repository,
IPasswordHasher hasher)
{
_repository = repository;
_hasher = hasher;
}
public async Task<Unit> Handle(ChangeMyPasswordCommand cmd)
{
var user = await _repository.GetByIdAsync(cmd.UsuarioId)
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
if (!_hasher.Verify(cmd.OldPassword, user.PasswordHash))
throw new InvalidOldPasswordException();
var newHash = _hasher.Hash(cmd.NewPassword);
await _repository.UpdatePasswordAsync(cmd.UsuarioId, newHash, mustChangePassword: false);
// TODO: audit — defer to ADM-004
// NOTE: intentionally does NOT revoke own refresh tokens (spec REQ-BCP-05)
return Unit.Value;
}
}

View File

@@ -0,0 +1,34 @@
using FluentValidation;
using SIGCM2.Application.Auth;
namespace SIGCM2.Application.Usuarios.ChangeMyPassword;
public sealed class ChangeMyPasswordCommandValidator : AbstractValidator<ChangeMyPasswordCommand>
{
public ChangeMyPasswordCommandValidator(AuthOptions authOptions)
{
RuleFor(x => x.OldPassword)
.NotEmpty()
.WithMessage("La contraseña actual es requerida.");
RuleFor(x => x.NewPassword)
.NotEmpty()
.WithMessage("La nueva contraseña es requerida.")
.MinimumLength(authOptions.PasswordMinLength)
.WithMessage($"La contraseña debe tener al menos {authOptions.PasswordMinLength} caracteres.");
if (authOptions.PasswordRequireLetter)
{
RuleFor(x => x.NewPassword)
.Matches(@"[a-zA-Z]")
.WithMessage("La contraseña debe contener al menos una letra.");
}
if (authOptions.PasswordRequireDigit)
{
RuleFor(x => x.NewPassword)
.Matches(@"\d")
.WithMessage("La contraseña debe contener al menos un dígito.");
}
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.Deactivate;
public sealed record DeactivateUsuarioCommand(int UsuarioId);

View File

@@ -0,0 +1,54 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Usuarios.Deactivate;
public sealed class DeactivateUsuarioCommandHandler : ICommandHandler<DeactivateUsuarioCommand, UsuarioDetailDto>
{
private readonly IUsuarioRepository _repository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
public DeactivateUsuarioCommandHandler(
IUsuarioRepository repository,
IRefreshTokenRepository refreshTokenRepository)
{
_repository = repository;
_refreshTokenRepository = refreshTokenRepository;
}
public async Task<UsuarioDetailDto> Handle(DeactivateUsuarioCommand cmd)
{
var target = await _repository.GetByIdAsync(cmd.UsuarioId)
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
// Idempotent: already inactive → return as-is without touching FechaModificacion
if (!target.Activo)
{
return new UsuarioDetailDto(
target.Id, target.Username, target.Nombre, target.Apellido,
target.Email, target.Rol, target.Activo, target.MustChangePassword,
target.UltimoLogin, target.FechaModificacion);
}
// Guard: anti-lockout
if (target.Rol == "admin" && await _repository.CountActiveAdminsAsync() <= 1)
throw new LastAdminLockoutException();
var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, false);
var now = DateTime.UtcNow;
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.UsuarioId, now);
// TODO: audit — defer to ADM-004
var updated = await _repository.GetDetailAsync(cmd.UsuarioId)
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
return new UsuarioDetailDto(
updated.Id, updated.Username, updated.Nombre, updated.Apellido,
updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword,
updated.UltimoLogin, updated.FechaModificacion);
}
}

View File

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

View File

@@ -0,0 +1,34 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Usuarios.GetById;
public sealed class GetUsuarioByIdQueryHandler : ICommandHandler<GetUsuarioByIdQuery, UsuarioDetailDto>
{
private readonly IUsuarioRepository _repository;
public GetUsuarioByIdQueryHandler(IUsuarioRepository repository)
{
_repository = repository;
}
public async Task<UsuarioDetailDto> Handle(GetUsuarioByIdQuery query)
{
var usuario = await _repository.GetDetailAsync(query.Id)
?? throw new UsuarioNotFoundException(query.Id);
return new UsuarioDetailDto(
Id: usuario.Id,
Username: usuario.Username,
Nombre: usuario.Nombre,
Apellido: usuario.Apellido,
Email: usuario.Email,
Rol: usuario.Rol,
Activo: usuario.Activo,
MustChangePassword: usuario.MustChangePassword,
UltimoLogin: usuario.UltimoLogin,
FechaModificacion: usuario.FechaModificacion
);
}
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Application.Usuarios.GetById;
/// <summary>
/// Full detail projection — excludes PasswordHash and PermisosJson (security).
/// </summary>
public sealed record UsuarioDetailDto(
int Id,
string Username,
string Nombre,
string Apellido,
string? Email,
string Rol,
bool Activo,
bool MustChangePassword,
DateTime? UltimoLogin,
DateTime? FechaModificacion
);

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Usuarios.List;
public sealed record ListUsuariosQuery(
int Page,
int PageSize,
string? Rol,
bool? Activo,
string? Search
);

View File

@@ -0,0 +1,31 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Usuarios.List;
public sealed class ListUsuariosQueryHandler : ICommandHandler<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>
{
private readonly IUsuarioRepository _repository;
public ListUsuariosQueryHandler(IUsuarioRepository repository)
{
_repository = repository;
}
public async Task<PagedResult<UsuarioListItemDto>> Handle(ListUsuariosQuery query)
{
// Clamp paging params
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new UsuariosQuery(page, pageSize, query.Rol, query.Activo, query.Search);
var paged = await _repository.GetPagedAsync(repoQuery);
var items = paged.Items
.Select(u => new UsuarioListItemDto(u.Id, u.Username, u.Nombre, u.Apellido, u.Email, u.Rol, u.Activo, u.UltimoLogin))
.ToList();
return new PagedResult<UsuarioListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Application.Usuarios.List;
public sealed record UsuarioListItemDto(
int Id,
string Username,
string Nombre,
string Apellido,
string? Email,
string Rol,
bool Activo,
DateTime? UltimoLogin
);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.Reactivate;
public sealed record ReactivateUsuarioCommand(int UsuarioId);

View File

@@ -0,0 +1,45 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Usuarios.Reactivate;
public sealed class ReactivateUsuarioCommandHandler : ICommandHandler<ReactivateUsuarioCommand, UsuarioDetailDto>
{
private readonly IUsuarioRepository _repository;
public ReactivateUsuarioCommandHandler(IUsuarioRepository repository)
{
_repository = repository;
}
public async Task<UsuarioDetailDto> Handle(ReactivateUsuarioCommand cmd)
{
var target = await _repository.GetByIdAsync(cmd.UsuarioId)
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
// Idempotent: already active → return as-is without touching FechaModificacion
if (target.Activo)
{
return new UsuarioDetailDto(
target.Id, target.Username, target.Nombre, target.Apellido,
target.Email, target.Rol, target.Activo, target.MustChangePassword,
target.UltimoLogin, target.FechaModificacion);
}
var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, true);
var now = DateTime.UtcNow;
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
// TODO: audit — defer to ADM-004
var updated = await _repository.GetDetailAsync(cmd.UsuarioId)
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
return new UsuarioDetailDto(
updated.Id, updated.Username, updated.Nombre, updated.Apellido,
updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword,
updated.UltimoLogin, updated.FechaModificacion);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Usuarios.ResetPassword;
public sealed record ResetUsuarioPasswordCommand(int TargetId, int CallerId);

View File

@@ -0,0 +1,44 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Usuarios.ResetPassword;
public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>
{
private readonly IUsuarioRepository _repository;
private readonly IPasswordHasher _hasher;
private readonly IRefreshTokenRepository _refreshTokenRepository;
public ResetUsuarioPasswordCommandHandler(
IUsuarioRepository repository,
IPasswordHasher hasher,
IRefreshTokenRepository refreshTokenRepository)
{
_repository = repository;
_hasher = hasher;
_refreshTokenRepository = refreshTokenRepository;
}
public async Task<ResetUsuarioPasswordResponse> Handle(ResetUsuarioPasswordCommand cmd)
{
// Cannot self-reset: admin must use /me/password
if (cmd.CallerId == cmd.TargetId)
throw new CannotSelfResetException();
var target = await _repository.GetByIdAsync(cmd.TargetId)
?? throw new UsuarioNotFoundException(cmd.TargetId);
var temp = TempPasswordGenerator.Generate(12);
// SECURITY: NEVER log tempPassword
var hash = _hasher.Hash(temp);
await _repository.UpdatePasswordAsync(cmd.TargetId, hash, mustChangePassword: true);
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.TargetId, DateTime.UtcNow);
// TODO: audit — defer to ADM-004
return new ResetUsuarioPasswordResponse(temp, MustChangeOnLogin: true);
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Usuarios.ResetPassword;
public sealed record ResetUsuarioPasswordResponse(
string TempPassword,
bool MustChangeOnLogin
);

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Usuarios.Update;
public sealed record UpdateUsuarioCommand(
int Id,
string Nombre,
string Apellido,
string? Email,
string Rol,
bool Activo
);

View File

@@ -0,0 +1,70 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Usuarios.Update;
public sealed class UpdateUsuarioCommandHandler : ICommandHandler<UpdateUsuarioCommand, UsuarioDetailDto>
{
private readonly IUsuarioRepository _repository;
private readonly IRolRepository _rolRepository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
public UpdateUsuarioCommandHandler(
IUsuarioRepository repository,
IRolRepository rolRepository,
IRefreshTokenRepository refreshTokenRepository)
{
_repository = repository;
_rolRepository = rolRepository;
_refreshTokenRepository = refreshTokenRepository;
}
public async Task<UsuarioDetailDto> Handle(UpdateUsuarioCommand cmd)
{
var target = await _repository.GetByIdAsync(cmd.Id)
?? throw new UsuarioNotFoundException(cmd.Id);
// Guard: validate rol exists and is active
var rolExists = await _rolRepository.ExistsActiveByCodigoAsync(cmd.Rol);
if (!rolExists)
throw new FluentValidation.ValidationException(
[new FluentValidation.Results.ValidationFailure("Rol", $"El rol '{cmd.Rol}' no existe o está inactivo.")]);
// Guard: anti-lockout — cannot remove last active admin
if (target.Rol == "admin" && target.Activo)
{
var isChangingRol = !string.Equals(cmd.Rol, "admin", StringComparison.Ordinal);
var isDeactivating = !cmd.Activo;
if ((isChangingRol || isDeactivating)
&& await _repository.CountActiveAdminsAsync() <= 1)
{
throw new LastAdminLockoutException();
}
}
var fields = new UpdateUsuarioFields(cmd.Nombre, cmd.Apellido, cmd.Email, cmd.Rol, cmd.Activo);
var now = DateTime.UtcNow;
await _repository.UpdateAsync(cmd.Id, fields, now);
// Revoke refresh tokens if rol changed or user deactivated
var rolChanged = !string.Equals(target.Rol, cmd.Rol, StringComparison.Ordinal);
var justDeactivated = target.Activo && !cmd.Activo;
if (rolChanged || justDeactivated)
{
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.Id, now);
}
// TODO: audit — defer to ADM-004
var updated = await _repository.GetDetailAsync(cmd.Id)
?? throw new UsuarioNotFoundException(cmd.Id);
return new UsuarioDetailDto(
updated.Id, updated.Username, updated.Nombre, updated.Apellido,
updated.Email, updated.Rol, updated.Activo, updated.MustChangePassword,
updated.UltimoLogin, updated.FechaModificacion);
}
}

View File

@@ -0,0 +1,29 @@
using FluentValidation;
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.Usuarios.Update;
public sealed class UpdateUsuarioCommandValidator : AbstractValidator<UpdateUsuarioCommand>
{
public UpdateUsuarioCommandValidator(IRolRepository rolRepository)
{
RuleFor(x => x.Email)
.EmailAddress()
.When(x => x.Email is not null)
.WithMessage("El formato del email es inválido.");
RuleFor(x => x.Nombre)
.NotEmpty()
.WithMessage("El nombre es requerido.");
RuleFor(x => x.Apellido)
.NotEmpty()
.WithMessage("El apellido es requerido.");
RuleFor(x => x.Rol)
.NotEmpty()
.WithMessage("El rol es requerido.")
.MustAsync(async (rol, ct) => await rolRepository.ExistsActiveByCodigoAsync(rol, ct))
.WithMessage(x => $"El rol '{x.Rol}' no existe o está inactivo.");
}
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a user provides an incorrect current password during password change.
/// </summary>
public sealed class InvalidOldPasswordException : DomainException
{
public InvalidOldPasswordException()
: base("La contraseña actual es incorrecta.") { }
}

View File

@@ -0,0 +1,131 @@
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.Usuarios;
/// <summary>
/// Integration tests for GET /api/v1/users/{id} (UDT-008 B3).
/// </summary>
[Collection("ApiIntegration")]
public sealed class GetUsuarioByIdEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private readonly HttpClient _client;
private readonly SqlTestFixture _db;
public GetUsuarioByIdEndpointTests(TestWebAppFactory factory)
{
_client = factory.CreateClient();
_db = new SqlTestFixture(TestConnectionString);
}
public async Task InitializeAsync() => await _db.InitializeAsync();
public async Task DisposeAsync() => await _db.DisposeAsync();
private async Task<string> GetAdminTokenAsync()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login",
new { username = "admin", password = "@Diego550@" });
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
private async Task<int> GetAdminIdAsync()
{
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
return await conn.ExecuteScalarAsync<int>("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
}
private async Task<string> GetCajeroTokenAsync()
{
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync("""
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'cajero_getbyid')
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('cajero_getbyid', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Cajero', 'Test', 'cajero', '[]', 1, 0)
""");
var response = await _client.PostAsJsonAsync("/api/v1/auth/login",
new { username = "cajero_getbyid", password = "@Diego550@" });
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
[Fact]
public async Task GET_Users_Id_200_Returns_Detail_Shape()
{
var token = await GetAdminTokenAsync();
var adminId = await GetAdminIdAsync();
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{adminId}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(adminId, json.GetProperty("id").GetInt32());
Assert.Equal("admin", json.GetProperty("username").GetString());
Assert.True(json.TryGetProperty("nombre", out _));
Assert.True(json.TryGetProperty("rol", out _));
Assert.True(json.TryGetProperty("activo", out _));
Assert.True(json.TryGetProperty("mustChangePassword", out _));
}
[Fact]
public async Task GET_Users_Id_DoesNotContain_PasswordHash_In_Response()
{
var token = await GetAdminTokenAsync();
var adminId = await GetAdminIdAsync();
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{adminId}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var rawJson = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("passwordHash", rawJson, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("permisosJson", rawJson, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task GET_Users_Id_9999_Returns_404()
{
var token = await GetAdminTokenAsync();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users/9999");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GET_Users_Id_No_Auth_Returns_401()
{
var response = await _client.GetAsync("/api/v1/users/1");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GET_Users_Id_No_Permission_Returns_403()
{
var token = await GetCajeroTokenAsync();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users/1");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View File

@@ -0,0 +1,152 @@
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.Usuarios;
/// <summary>
/// Integration tests for GET /api/v1/users (UDT-008 B3).
/// </summary>
[Collection("ApiIntegration")]
public sealed class ListUsuariosEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private readonly HttpClient _client;
private readonly SqlTestFixture _db;
public ListUsuariosEndpointTests(TestWebAppFactory factory)
{
_client = factory.CreateClient();
_db = new SqlTestFixture(TestConnectionString);
}
public async Task InitializeAsync() => await _db.InitializeAsync();
public async Task DisposeAsync() => await _db.DisposeAsync();
private async Task<string> GetAdminTokenAsync()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login",
new { username = "admin", password = "@Diego550@" });
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
private async Task<string> GetCajeroTokenAsync()
{
// Seed a cajero user
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync("""
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'cajero_test')
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
VALUES ('cajero_test', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Cajero', 'Test', 'cajero', '[]', 1, 0)
""");
var response = await _client.PostAsJsonAsync("/api/v1/auth/login",
new { username = "cajero_test", password = "@Diego550@" });
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
// ── happy path ────────────────────────────────────────────────────────────
[Fact]
public async Task GET_Users_200_Returns_Paged_Shape()
{
var token = await GetAdminTokenAsync();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("items", out _));
Assert.True(json.TryGetProperty("page", out _));
Assert.True(json.TryGetProperty("pageSize", out _));
Assert.True(json.TryGetProperty("total", out _));
}
[Fact]
public async Task GET_Users_Default_PageSize_Is_20()
{
var token = await GetAdminTokenAsync();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(20, json.GetProperty("pageSize").GetInt32());
Assert.Equal(1, json.GetProperty("page").GetInt32());
}
[Fact]
public async Task GET_Users_Filter_Rol_Admin_Returns_Only_Admins()
{
var token = await GetAdminTokenAsync();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users?rol=admin");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
var items = json.GetProperty("items").EnumerateArray().ToList();
Assert.All(items, item => Assert.Equal("admin", item.GetProperty("rol").GetString()));
}
[Fact]
public async Task GET_Users_PageSize_0_Returns_400()
{
var token = await GetAdminTokenAsync();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users?pageSize=0");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GET_Users_Page_0_Returns_400()
{
var token = await GetAdminTokenAsync();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users?page=0");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
// ── auth ──────────────────────────────────────────────────────────────────
[Fact]
public async Task GET_Users_No_Auth_Returns_401()
{
var response = await _client.GetAsync("/api/v1/users");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GET_Users_No_Permission_Returns_403()
{
var token = await GetCajeroTokenAsync();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View File

@@ -0,0 +1,61 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Usuarios;
public class GetUsuarioByIdQueryHandlerTests
{
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
private readonly GetUsuarioByIdQueryHandler _handler;
public GetUsuarioByIdQueryHandlerTests()
{
_handler = new GetUsuarioByIdQueryHandler(_repo);
}
[Fact]
public async Task Handle_Returns_UsuarioDetailDto_When_Found()
{
var usuario = new Usuario(5, "jperez", "$2a$12$hash", "Juan", "Pérez", "j@x.com", "cajero", "[]", true,
fechaModificacion: null, ultimoLogin: null, mustChangePassword: false);
_repo.GetDetailAsync(5, Arg.Any<CancellationToken>()).Returns(usuario);
var result = await _handler.Handle(new GetUsuarioByIdQuery(5));
Assert.Equal(5, result.Id);
Assert.Equal("jperez", result.Username);
Assert.Equal("Juan", result.Nombre);
Assert.Equal("Pérez", result.Apellido);
Assert.Equal("j@x.com", result.Email);
Assert.Equal("cajero", result.Rol);
Assert.True(result.Activo);
Assert.False(result.MustChangePassword);
}
[Fact]
public async Task Handle_DoesNotReturn_PasswordHash_In_Dto()
{
var usuario = new Usuario(5, "jperez", "$2a$12$SECRETHASH", "Juan", "Pérez", null, "cajero", "[]", true);
_repo.GetDetailAsync(5, Arg.Any<CancellationToken>()).Returns(usuario);
var result = await _handler.Handle(new GetUsuarioByIdQuery(5));
// UsuarioDetailDto must not expose PasswordHash
var props = typeof(UsuarioDetailDto).GetProperties().Select(p => p.Name);
Assert.DoesNotContain("PasswordHash", props);
Assert.DoesNotContain("PermisosJson", props);
}
[Fact]
public async Task Handle_Throws_UsuarioNotFoundException_When_Not_Found()
{
_repo.GetDetailAsync(9999, Arg.Any<CancellationToken>()).Returns((Usuario?)null);
await Assert.ThrowsAsync<UsuarioNotFoundException>(
() => _handler.Handle(new GetUsuarioByIdQuery(9999)));
}
}

View File

@@ -0,0 +1,119 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.List;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Usuarios;
public class ListUsuariosQueryHandlerTests
{
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
private readonly ListUsuariosQueryHandler _handler;
public ListUsuariosQueryHandlerTests()
{
_handler = new ListUsuariosQueryHandler(_repo);
}
[Fact]
public async Task Handle_Returns_PagedResult_With_Items()
{
var items = new List<UsuarioListItem>
{
new(1, "admin", "Admin", "Sys", null, "admin", true, null, null)
};
var paged = new PagedResult<UsuarioListItem>(items, 1, 20, 1);
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
.Returns(paged);
var query = new ListUsuariosQuery(1, 20, null, null, null);
var result = await _handler.Handle(query);
Assert.Equal(1, result.Total);
Assert.Single(result.Items);
}
[Fact]
public async Task Handle_Clamps_PageSize_Above_100_To_100()
{
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<UsuarioListItem>([], 1, 100, 0));
var query = new ListUsuariosQuery(1, 200, null, null, null);
await _handler.Handle(query);
await _repo.Received(1).GetPagedAsync(
Arg.Is<UsuariosQuery>(q => q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Clamps_Page_Below_1_To_1()
{
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<UsuarioListItem>([], 1, 20, 0));
var query = new ListUsuariosQuery(0, 20, null, null, null);
await _handler.Handle(query);
await _repo.Received(1).GetPagedAsync(
Arg.Is<UsuariosQuery>(q => q.Page == 1),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Passes_Rol_Filter()
{
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<UsuarioListItem>([], 1, 20, 0));
var query = new ListUsuariosQuery(1, 20, "admin", null, null);
await _handler.Handle(query);
await _repo.Received(1).GetPagedAsync(
Arg.Is<UsuariosQuery>(q => q.Rol == "admin"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Passes_Activo_Filter()
{
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<UsuarioListItem>([], 1, 20, 0));
var query = new ListUsuariosQuery(1, 20, null, false, null);
await _handler.Handle(query);
await _repo.Received(1).GetPagedAsync(
Arg.Is<UsuariosQuery>(q => q.Activo == false),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Passes_Search_Filter()
{
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<UsuarioListItem>([], 1, 20, 0));
var query = new ListUsuariosQuery(1, 20, null, null, "juan");
await _handler.Handle(query);
await _repo.Received(1).GetPagedAsync(
Arg.Is<UsuariosQuery>(q => q.Search == "juan"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Returns_Empty_When_No_Items()
{
_repo.GetPagedAsync(Arg.Any<UsuariosQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<UsuarioListItem>([], 1, 20, 0));
var result = await _handler.Handle(new ListUsuariosQuery(1, 20, null, null, null));
Assert.Equal(0, result.Total);
Assert.Empty(result.Items);
}
}