feat(api): UDT-003 registro de usuarios — backend completo (Phases 1-6)
- Domain: Usuario.ForCreation factory, UsernameAlreadyExistsException, IUsuarioRepository extendido - Application: CreateUsuarioCommand/Validator/Handler, UsuarioCreatedDto, AuthOptions password policy - Infrastructure: UsuarioRepository.ExistsByUsernameAsync + AddAsync (INSERT OUTPUT INSERTED.Id), RoleClaimType="rol" en TokenValidationParameters - Api: UsuariosController POST api/v1/users [Authorize(Roles="admin")], ExceptionFilter mapea UsernameAlreadyExistsException + SqlException 2627 → 409 - Tests (unit): 43 tests — 33 validator + 10 handler (107 total, green) - Tests (integration): 7 tests CreateUsuarioEndpoint — 401/403/400/201/409/race/e2e (green) - Fix: TestWebAppFactory.ConfigureTestServices reemplaza SqlConnectionFactory singleton con CS de test correcto
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Usuarios.Create;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Usuarios.Create;
|
||||
|
||||
public class CreateUsuarioCommandHandlerTests
|
||||
{
|
||||
private readonly IUsuarioRepository _repository = Substitute.For<IUsuarioRepository>();
|
||||
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
||||
private readonly CreateUsuarioCommandHandler _handler;
|
||||
|
||||
private static CreateUsuarioCommand ValidCommand() => new(
|
||||
Username: "operador1",
|
||||
Password: "Secreto123",
|
||||
Nombre: "Juan",
|
||||
Apellido: "Pérez",
|
||||
Email: null,
|
||||
Rol: "vendedor");
|
||||
|
||||
public CreateUsuarioCommandHandlerTests()
|
||||
{
|
||||
_handler = new CreateUsuarioCommandHandler(_repository, _hasher);
|
||||
}
|
||||
|
||||
// ── exists → throws ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UsernameAlreadyExists_ThrowsUsernameAlreadyExistsException()
|
||||
{
|
||||
_repository.ExistsByUsernameAsync("operador1", Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
await Assert.ThrowsAsync<UsernameAlreadyExistsException>(
|
||||
() => _handler.Handle(ValidCommand()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UsernameAlreadyExists_DoesNotCallAddAsync()
|
||||
{
|
||||
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
try { await _handler.Handle(ValidCommand()); } catch (UsernameAlreadyExistsException) { }
|
||||
|
||||
await _repository.DidNotReceive().AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UsernameAlreadyExists_ExceptionContainsUsername()
|
||||
{
|
||||
_repository.ExistsByUsernameAsync("operador1", Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<UsernameAlreadyExistsException>(
|
||||
() => _handler.Handle(ValidCommand()));
|
||||
|
||||
Assert.Equal("operador1", ex.Username);
|
||||
}
|
||||
|
||||
// ── happy path ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_HashesPasswordBeforePersisting()
|
||||
{
|
||||
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
_hasher.Hash("Secreto123").Returns("$2a$12$hashed");
|
||||
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(42);
|
||||
|
||||
await _handler.Handle(ValidCommand());
|
||||
|
||||
// AddAsync must be called with the hashed value, not the plain password
|
||||
await _repository.Received(1).AddAsync(
|
||||
Arg.Is<Usuario>(u => u.PasswordHash == "$2a$12$hashed"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_NeverPersistsPlainPassword()
|
||||
{
|
||||
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$hashed");
|
||||
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(1);
|
||||
|
||||
await _handler.Handle(ValidCommand());
|
||||
|
||||
await _repository.Received(1).AddAsync(
|
||||
Arg.Is<Usuario>(u => u.PasswordHash != "Secreto123"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_CallsAddAsyncOnce()
|
||||
{
|
||||
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$hashed");
|
||||
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(7);
|
||||
|
||||
await _handler.Handle(ValidCommand());
|
||||
|
||||
await _repository.Received(1).AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository()
|
||||
{
|
||||
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$hashed");
|
||||
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(42);
|
||||
|
||||
var result = await _handler.Handle(ValidCommand());
|
||||
|
||||
Assert.Equal(42, result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_DtoContainsCorrectFields()
|
||||
{
|
||||
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$hashed");
|
||||
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(10);
|
||||
|
||||
var cmd = new CreateUsuarioCommand("user1", "Pass1234", "Ana", "García", "ana@example.com", "admin");
|
||||
var result = await _handler.Handle(cmd);
|
||||
|
||||
Assert.Equal("user1", result.Username);
|
||||
Assert.Equal("Ana", result.Nombre);
|
||||
Assert.Equal("García", result.Apellido);
|
||||
Assert.Equal("ana@example.com", result.Email);
|
||||
Assert.Equal("admin", result.Rol);
|
||||
Assert.True(result.Activo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_DtoDoesNotContainPasswordHash()
|
||||
{
|
||||
// UsuarioCreatedDto must not expose PasswordHash — compile-time check via reflection
|
||||
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$secret");
|
||||
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(1);
|
||||
|
||||
var result = await _handler.Handle(ValidCommand());
|
||||
|
||||
var props = result.GetType().GetProperties().Select(p => p.Name);
|
||||
Assert.DoesNotContain("PasswordHash", props);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HappyPath_NewUserIsActive()
|
||||
{
|
||||
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$hashed");
|
||||
_repository.AddAsync(
|
||||
Arg.Is<Usuario>(u => u.Activo && u.PermisosJson == "[]"),
|
||||
Arg.Any<CancellationToken>()).Returns(5);
|
||||
|
||||
var result = await _handler.Handle(ValidCommand());
|
||||
|
||||
Assert.True(result.Activo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using SIGCM2.Application.Auth;
|
||||
using SIGCM2.Application.Usuarios.Create;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Usuarios.Create;
|
||||
|
||||
public class CreateUsuarioCommandValidatorTests
|
||||
{
|
||||
private static CreateUsuarioCommandValidator BuildValidator(AuthOptions? opts = null) =>
|
||||
new(opts ?? new AuthOptions());
|
||||
|
||||
private static CreateUsuarioCommand ValidCommand() => new(
|
||||
Username: "operador1",
|
||||
Password: "Secreto123",
|
||||
Nombre: "Juan",
|
||||
Apellido: "Pérez",
|
||||
Email: null,
|
||||
Rol: "vendedor");
|
||||
|
||||
// ── Happy paths ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidCommand_NoErrors()
|
||||
{
|
||||
var result = BuildValidator().TestValidate(ValidCommand());
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullEmail_IsValid()
|
||||
{
|
||||
var cmd = ValidCommand() with { Email = null };
|
||||
BuildValidator().TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidEmailPresent_NoErrors()
|
||||
{
|
||||
var cmd = ValidCommand() with { Email = "juan@example.com" };
|
||||
BuildValidator().TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
// ── Username ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyUsername_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Username = "" };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UsernameTooShort_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Username = "ab" };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UsernameTooLong_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Username = new string('a', 51) };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abc")] // 3 chars — boundary valid
|
||||
[InlineData("user.name")] // dot allowed
|
||||
[InlineData("user-name")] // dash allowed
|
||||
[InlineData("user_name")] // underscore allowed
|
||||
[InlineData("user123")] // alphanumeric
|
||||
public void Validate_UsernameValidFormats_NoError(string username)
|
||||
{
|
||||
var cmd = ValidCommand() with { Username = username };
|
||||
BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Username);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("user name")] // space not allowed
|
||||
[InlineData("user@name")] // @ not allowed
|
||||
[InlineData("user#1")] // # not allowed
|
||||
public void Validate_UsernameInvalidChars_HasError(string username)
|
||||
{
|
||||
var cmd = ValidCommand() with { Username = username };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username);
|
||||
}
|
||||
|
||||
// ── Password ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyPassword_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Password = "" };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PasswordTooShort_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Password = "Ab1cd5" }; // 6 chars < 8
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PasswordNoLetter_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Password = "12345678" }; // digits only
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PasswordNoDigit_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Password = "abcdefgh" }; // letters only
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PasswordExactMinLength_NoError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Password = "Secre123" }; // exactly 8, letter + digit
|
||||
BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Password);
|
||||
}
|
||||
|
||||
// ── Nombre / Apellido ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyNombre_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Nombre = "" };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Nombre);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyApellido_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Apellido = "" };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Apellido);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NombreTooLong_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Nombre = new string('a', 101) };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Nombre);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ApellidoTooLong_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Apellido = new string('a', 101) };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Apellido);
|
||||
}
|
||||
|
||||
// ── Rol ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("admin")]
|
||||
[InlineData("vendedor")]
|
||||
[InlineData("tasador")]
|
||||
[InlineData("consulta")]
|
||||
public void Validate_ValidRoles_NoError(string rol)
|
||||
{
|
||||
var cmd = ValidCommand() with { Rol = rol };
|
||||
BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Rol);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("superuser")]
|
||||
[InlineData("ADMIN")] // case-sensitive
|
||||
[InlineData("root")]
|
||||
[InlineData("")]
|
||||
public void Validate_InvalidRol_HasError(string rol)
|
||||
{
|
||||
var cmd = ValidCommand() with { Rol = rol };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Rol);
|
||||
}
|
||||
|
||||
// ── Email ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidEmail_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Email = "not-an-email" };
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmailTooLong_HasError()
|
||||
{
|
||||
var cmd = ValidCommand() with { Email = new string('a', 145) + "@b.com" }; // >150
|
||||
BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Email);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user