From 6f999b8fcd6e5f30d202fd7279172770363c7261 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 12:50:24 -0300 Subject: [PATCH] feat(api): UDT-004 controller de roles + refactor validator UDT-003 a lookup dinamico MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RolesController /api/v1/roles CRUD admin-only: GET list, GET {codigo}, POST, PUT, DELETE (soft-delete con guard 409) - ExceptionFilter: mapea RolNotFound (404), RolAlreadyExists (409), RolInUse (409) - DI: registra 5 handlers de Roles (Application) y IRolRepository/RolRepository (Infrastructure) - CreateUsuarioCommandValidator: reemplaza whitelist hardcoded por IRolRepository.ExistsActiveByCodigoAsync via MustAsync; constructor recibe (AuthOptions, IRolRepository) - Tests: 202 verdes (173 application + 29 api). Nuevas: RolesEndpointTests (13 integration), CreateUsuarioCommandValidatorTests reescrito con NSubstitute mock, CreateUsuario_WithInactiveRol_Returns400 en Api.Tests - Fix: ApiIntegration pasa de IClassFixture (N factories) a ICollectionFixture (1 factory shared) — evitaba ObjectDisposedException sobre RSABCrypt al compartir coleccion con multiples test classes - tests/tests.runsettings: MaxCpuCount=1 para evitar race entre assemblies sobre SIGCM2_Test --- .../SIGCM2.Api/Controllers/RolesController.cs | 127 +++++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 36 ++ .../SIGCM2.Application/DependencyInjection.cs | 13 + .../Create/CreateUsuarioCommandValidator.cs | 20 +- .../DependencyInjection.cs | 1 + .../ApiIntegrationCollection.cs | 20 + .../Auth/AuthControllerTests.cs | 2 +- .../Roles/RolesEndpointTests.cs | 353 ++++++++++++++++++ .../Usuarios/CreateUsuarioEndpointTests.cs | 48 ++- .../CreateUsuarioCommandValidatorTests.cs | 166 ++++---- tests/tests.runsettings | 16 + 11 files changed, 722 insertions(+), 80 deletions(-) create mode 100644 src/api/SIGCM2.Api/Controllers/RolesController.cs create mode 100644 tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs create mode 100644 tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs create mode 100644 tests/tests.runsettings diff --git a/src/api/SIGCM2.Api/Controllers/RolesController.cs b/src/api/SIGCM2.Api/Controllers/RolesController.cs new file mode 100644 index 0000000..6329828 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/RolesController.cs @@ -0,0 +1,127 @@ +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Roles.Create; +using SIGCM2.Application.Roles.Deactivate; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Application.Roles.Get; +using SIGCM2.Application.Roles.List; +using SIGCM2.Application.Roles.Update; + +namespace SIGCM2.Api.Controllers; + +[ApiController] +[Route("api/v1/roles")] +[Authorize(Roles = "admin")] +public sealed class RolesController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public RolesController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + /// Lists all roles (including inactive). Requires admin role. + [HttpGet] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task List() + { + var result = await _dispatcher.Send>(new ListRolesQuery()); + return Ok(result); + } + + /// Gets a role by its code. Requires admin role. + [HttpGet("{codigo}")] + [ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetByCodigo(string codigo) + { + var result = await _dispatcher.Send(new GetRolByCodigoQuery(codigo)); + return Ok(result); + } + + /// Creates a new role. Requires admin role. + [HttpPost] + [ProducesResponseType(typeof(RolCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Create([FromBody] CreateRolRequest request) + { + var command = new CreateRolCommand( + Codigo: request.Codigo ?? string.Empty, + Nombre: request.Nombre ?? string.Empty, + Descripcion: request.Descripcion); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetByCodigo), new { codigo = result.Codigo }, result); + } + + /// Updates a role (codigo is immutable; route wins over body). Requires admin role. + [HttpPut("{codigo}")] + [ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update(string codigo, [FromBody] UpdateRolRequest request) + { + // Codigo comes from the route — body.codigo (if present) is ignored by design. + var command = new UpdateRolCommand( + Codigo: codigo, + Nombre: request.Nombre ?? string.Empty, + Descripcion: request.Descripcion, + Activo: request.Activo); + + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Soft-deletes (deactivates) a role. 409 if active usuarios reference it. Requires admin role. + [HttpDelete("{codigo}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Deactivate(string codigo) + { + await _dispatcher.Send(new DeactivateRolCommand(codigo)); + return NoContent(); + } +} + +public sealed record CreateRolRequest(string? Codigo, string? Nombre, string? Descripcion); +public sealed record UpdateRolRequest(string? Nombre, string? Descripcion, bool Activo); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index c61a26d..13b4b5a 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -71,6 +71,42 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + case RolNotFoundException rolNotFoundEx: + context.Result = new ObjectResult(new + { + error = "rol_not_found", + message = rolNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + case RolAlreadyExistsException rolExistsEx: + context.Result = new ObjectResult(new + { + error = "rol_already_exists", + message = rolExistsEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case RolInUseException rolInUseEx: + context.Result = new ObjectResult(new + { + error = "rol_in_use", + message = rolInUseEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + case ValidationException validationEx: var errors = validationEx.Errors .GroupBy(e => e.PropertyName) diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 068903f..cba09e3 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -4,6 +4,12 @@ using SIGCM2.Application.Abstractions; using SIGCM2.Application.Auth.Login; using SIGCM2.Application.Auth.Logout; using SIGCM2.Application.Auth.Refresh; +using SIGCM2.Application.Roles.Create; +using SIGCM2.Application.Roles.Deactivate; +using SIGCM2.Application.Roles.Dtos; +using SIGCM2.Application.Roles.Get; +using SIGCM2.Application.Roles.List; +using SIGCM2.Application.Roles.Update; using SIGCM2.Application.Usuarios.Create; namespace SIGCM2.Application; @@ -18,6 +24,13 @@ public static class DependencyInjection services.AddScoped, LogoutCommandHandler>(); services.AddScoped, CreateUsuarioCommandHandler>(); + // Roles (UDT-004) + services.AddScoped>, ListRolesQueryHandler>(); + services.AddScoped, GetRolByCodigoQueryHandler>(); + services.AddScoped, CreateRolCommandHandler>(); + services.AddScoped, UpdateRolCommandHandler>(); + services.AddScoped, DeactivateRolCommandHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs b/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs index 2ede281..b2fbbf7 100644 --- a/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs +++ b/src/api/SIGCM2.Application/Usuarios/Create/CreateUsuarioCommandValidator.cs @@ -1,27 +1,18 @@ using FluentValidation; +using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Auth; namespace SIGCM2.Application.Usuarios.Create; public sealed class CreateUsuarioCommandValidator : AbstractValidator { - // Whitelist aligned with canonical seeds in dbo.Rol (migration V003). - // Phase 5 of UDT-004 will replace this array with an async lookup against IRolRepository. - private static readonly string[] ValidRoles = - [ - "admin", "cajero", "operador_ctacte", "picadora", - "jefe_publicidad", "productor", "diagramacion", "reportes" - ]; - private const int UsernameMinLength = 3; private const int UsernameMaxLength = 50; private const int NombreMaxLength = 100; private const int ApellidoMaxLength = 100; private const int EmailMaxLength = 150; - public CreateUsuarioCommandValidator() : this(new AuthOptions()) { } - - public CreateUsuarioCommandValidator(AuthOptions authOptions) + public CreateUsuarioCommandValidator(AuthOptions authOptions, IRolRepository rolRepository) { RuleFor(x => x.Username) .NotEmpty().WithMessage("El nombre de usuario es requerido.") @@ -52,10 +43,13 @@ public sealed class CreateUsuarioCommandValidator : AbstractValidator x.Email is not null); + // Rol: lookup dinámico contra dbo.Rol (UDT-004). + // MustAsync requiere ValidateAsync en el call site — controllers ya usan ValidateAsync. RuleFor(x => x.Rol) .NotEmpty().WithMessage("El rol es requerido.") - .Must(r => ValidRoles.Contains(r)) - .WithMessage($"El rol debe ser uno de: {string.Join(", ", ValidRoles)}."); + .MustAsync(async (codigo, ct) => + !string.IsNullOrEmpty(codigo) && await rolRepository.ExistsActiveByCodigoAsync(codigo, ct)) + .WithMessage("El rol debe existir en el sistema y estar activo."); } private static bool ContainsLetter(string value) => diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 0f91ae0..f1e4a02 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -28,6 +28,7 @@ public static class DependencyInjection services.AddSingleton(new SqlConnectionFactory(connectionString)); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs b/tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs new file mode 100644 index 0000000..6de18d4 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs @@ -0,0 +1,20 @@ +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Api.Tests; + +/// +/// Shared collection for all Api integration tests. +/// Uses ICollectionFixture so a SINGLE TestWebAppFactory (and its RSA key singleton) +/// is shared across all test classes in the "ApiIntegration" collection. +/// +/// Previously each class used IClassFixture which spawned one factory per class; +/// that created N factories sequentially in the same process, and the RSA key +/// singleton from an earlier factory could leak into a later factory's DI graph +/// (producing ObjectDisposedException "RSABCrypt" on first signing). +/// +[CollectionDefinition("ApiIntegration")] +public sealed class ApiIntegrationCollection : ICollectionFixture +{ + // Intentionally empty: this class only exists to declare the collection/fixture binding. +} diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs index e3b56ab..c218178 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -6,7 +6,7 @@ using SIGCM2.TestSupport; namespace SIGCM2.Api.Tests.Auth; [Collection("ApiIntegration")] -public class AuthControllerTests : IClassFixture +public class AuthControllerTests { private readonly HttpClient _client; diff --git a/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs b/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs new file mode 100644 index 0000000..f32011d --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs @@ -0,0 +1,353 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Roles; + +/// +/// Integration tests for /api/v1/roles (UDT-004). +/// +[Collection("ApiIntegration")] +public sealed class RolesEndpointTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private const string Endpoint = "/api/v1/roles"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public RolesEndpointTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task DisposeAsync() => Task.CompletedTask; + + private async Task GetBearerTokenAsync(string username, string password) + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new { username, password }); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException($"Login failed ({(int)response.StatusCode}): {body}"); + } + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null) + { + var request = new HttpRequestMessage(method, url); + if (bearerToken is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + if (body is not null) + request.Content = JsonContent.Create(body); + return request; + } + + private static async Task DeleteRolIfExistsAsync(string codigo) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo }); + } + + private static async Task DeleteUsuarioIfExistsAsync(string username) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + DELETE rt FROM dbo.RefreshToken rt + INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId + WHERE u.Username = @Username + """, new { Username = username }); + await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username }); + } + + // ── 401 / 403 guards ──────────────────────────────────────────────────── + + [Fact] + public async Task List_WithoutAuth_Returns401() + { + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, Endpoint)); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task Create_WithNonAdmin_Returns403() + { + var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword); + const string nonAdminUser = "rolestest_nonadmin"; + + // Create a non-admin user via endpoint (admin can still create users). + using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new + { + username = nonAdminUser, + password = "Secure1234!", + nombre = "Non", + apellido = "Admin", + email = (string?)null, + rol = "cajero" + }, adminToken); + var mkUserResp = await _client.SendAsync(mkUser); + if (mkUserResp.StatusCode != HttpStatusCode.Created && mkUserResp.StatusCode != HttpStatusCode.Conflict) + Assert.Fail($"Seed non-admin user failed: {mkUserResp.StatusCode}"); + + try + { + var cajeroToken = await GetBearerTokenAsync(nonAdminUser, "Secure1234!"); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo = "nuevo_test", + nombre = "Test", + descripcion = (string?)null + }, cajeroToken); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + } + finally + { + await DeleteUsuarioIfExistsAsync(nonAdminUser); + } + } + + // ── List ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task List_WithAdmin_Returns200WithCanonicalSeeds() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + using var req = BuildRequest(HttpMethod.Get, Endpoint, bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var list = await resp.Content.ReadFromJsonAsync(); + var codes = list.EnumerateArray().Select(r => r.GetProperty("codigo").GetString()).ToHashSet(); + foreach (var c in new[] { "admin", "cajero", "operador_ctacte", "picadora", "jefe_publicidad", "productor", "diagramacion", "reportes" }) + Assert.Contains(c, codes); + } + + // ── Get ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetByCodigo_Existing_Returns200() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, $"{Endpoint}/cajero", bearerToken: token)); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("cajero", body.GetProperty("codigo").GetString()); + Assert.Equal("Cajero", body.GetProperty("nombre").GetString()); + Assert.True(body.GetProperty("activo").GetBoolean()); + } + + [Fact] + public async Task GetByCodigo_NonExistent_Returns404() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, $"{Endpoint}/no_existe_xyz", bearerToken: token)); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("rol_not_found", body.GetProperty("error").GetString()); + } + + // ── Create ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Create_NewRol_Returns201() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + const string codigo = "endpoint_new_rol"; + + try + { + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Endpoint New", + descripcion = "Creado por integration test" + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(codigo, body.GetProperty("codigo").GetString()); + Assert.True(body.GetProperty("id").GetInt32() > 0); + Assert.True(body.GetProperty("activo").GetBoolean()); + } + finally + { + await DeleteRolIfExistsAsync(codigo); + } + } + + [Fact] + public async Task Create_CodigoDuplicado_Returns409() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo = "cajero", + nombre = "Duplicate", + descripcion = (string?)null + }, token); + + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("rol_already_exists", body.GetProperty("error").GetString()); + } + + [Fact] + public async Task Create_InvalidCodigoFormat_Returns400() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo = "Cajero Senior", // uppercase + space — invalid + nombre = "Bad", + descripcion = (string?)null + }, token); + + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + // ── Update ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Update_Existing_Returns200WithUpdatedNombre() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + const string codigo = "endpoint_upd_rol"; + + // Seed a rol + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync( + "INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion, Activo) VALUES (@Codigo, N'Viejo', N'Desc vieja', 1);", + new { Codigo = codigo }); + + try + { + using var req = BuildRequest(HttpMethod.Put, $"{Endpoint}/{codigo}", new + { + nombre = "Nuevo Nombre", + descripcion = "Desc nueva", + activo = true + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("Nuevo Nombre", body.GetProperty("nombre").GetString()); + Assert.Equal("Desc nueva", body.GetProperty("descripcion").GetString()); + Assert.Equal(codigo, body.GetProperty("codigo").GetString()); + } + finally + { + await DeleteRolIfExistsAsync(codigo); + } + } + + [Fact] + public async Task Update_NonExistent_Returns404() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + using var req = BuildRequest(HttpMethod.Put, $"{Endpoint}/inexistente_abc", new + { + nombre = "X", + descripcion = (string?)null, + activo = true + }, token); + + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + + // ── Delete (soft) ─────────────────────────────────────────────────────── + + [Fact] + public async Task Delete_WithoutActiveUsuarios_Returns204() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + const string codigo = "endpoint_del_rol"; + + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync( + "INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'Temp', 1);", + new { Codigo = codigo }); + + try + { + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/{codigo}", bearerToken: token)); + Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode); + + var activo = await conn.ExecuteScalarAsync( + "SELECT Activo FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo }); + Assert.False(activo); + } + finally + { + await DeleteRolIfExistsAsync(codigo); + } + } + + [Fact] + public async Task Delete_WithActiveUsuarios_Returns409() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + const string codigo = "endpoint_del_inuse"; + const string testUser = "endpoint_del_user"; + + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync( + "INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'InUse', 1);", + new { Codigo = codigo }); + await conn.ExecuteAsync( + "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " + + "VALUES (@Username, '$2a$12$hash', 'Test', 'User', @Codigo, '[]', 1);", + new { Username = testUser, Codigo = codigo }); + + try + { + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/{codigo}", bearerToken: token)); + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("rol_in_use", body.GetProperty("error").GetString()); + + var activo = await conn.ExecuteScalarAsync( + "SELECT Activo FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo }); + Assert.True(activo); + } + finally + { + await DeleteUsuarioIfExistsAsync(testUser); + await DeleteRolIfExistsAsync(codigo); + } + } + + [Fact] + public async Task Delete_NonExistent_Returns404() + { + var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); + var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/no_existe_del", bearerToken: token)); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + } + +} diff --git a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs index a84669e..0864609 100644 --- a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs @@ -11,11 +11,11 @@ namespace SIGCM2.Api.Tests.Usuarios; /// /// Integration tests for POST api/v1/users (UDT-003). /// These tests run against SIGCM2_Test database via TestWebAppFactory. -/// Each test class instance gets the full WebApp factory (shared via IClassFixture). -/// DB reset happens once per test run (SqlTestFixture.InitializeAsync → ResetAndSeedAsync). +/// TestWebAppFactory is shared across the whole "ApiIntegration" collection +/// (see ApiIntegrationCollection) — one factory, one RSA singleton, one DB state. /// [Collection("ApiIntegration")] -public sealed class CreateUsuarioEndpointTests : IClassFixture, IAsyncLifetime +public sealed class CreateUsuarioEndpointTests : IAsyncLifetime { private const string TestConnectionString = "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; @@ -387,4 +387,46 @@ public sealed class CreateUsuarioEndpointTests : IClassFixture(); + Assert.True(json.TryGetProperty("errors", out var errors), "Response must contain 'errors'"); + // Validation error should be on the Rol field + Assert.True(errors.EnumerateObject().Any(p => p.Name.Equals("Rol", StringComparison.OrdinalIgnoreCase))); + } + finally + { + await DeleteUsuarioAsync(testUser); + await conn.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo }); + } + } } diff --git a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs index f1c5ced..aba9480 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandValidatorTests.cs @@ -1,4 +1,6 @@ using FluentValidation.TestHelper; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Auth; using SIGCM2.Application.Usuarios.Create; @@ -6,8 +8,19 @@ namespace SIGCM2.Application.Tests.Usuarios.Create; public class CreateUsuarioCommandValidatorTests { - private static CreateUsuarioCommandValidator BuildValidator(AuthOptions? opts = null) => - new(opts ?? new AuthOptions()); + private readonly IRolRepository _roles = Substitute.For(); + + public CreateUsuarioCommandValidatorTests() + { + // Default mock behavior: canonical seeds are active; unknown codes are not. + var canonical = new[] { "admin", "cajero", "operador_ctacte", "picadora", + "jefe_publicidad", "productor", "diagramacion", "reportes" }; + foreach (var code in canonical) + _roles.ExistsActiveByCodigoAsync(code, Arg.Any()).Returns(true); + } + + private CreateUsuarioCommandValidator BuildValidator(AuthOptions? opts = null) => + new(opts ?? new AuthOptions(), _roles); private static CreateUsuarioCommand ValidCommand() => new( Username: "operador1", @@ -20,136 +33,138 @@ public class CreateUsuarioCommandValidatorTests // ── Happy paths ────────────────────────────────────────────────────────── [Fact] - public void Validate_ValidCommand_NoErrors() + public async Task Validate_ValidCommand_NoErrors() { - var result = BuildValidator().TestValidate(ValidCommand()); + var result = await BuildValidator().TestValidateAsync(ValidCommand()); result.ShouldNotHaveAnyValidationErrors(); } [Fact] - public void Validate_NullEmail_IsValid() + public async Task Validate_NullEmail_IsValid() { var cmd = ValidCommand() with { Email = null }; - BuildValidator().TestValidate(cmd).ShouldNotHaveAnyValidationErrors(); + var result = await BuildValidator().TestValidateAsync(cmd); + result.ShouldNotHaveAnyValidationErrors(); } [Fact] - public void Validate_ValidEmailPresent_NoErrors() + public async Task Validate_ValidEmailPresent_NoErrors() { var cmd = ValidCommand() with { Email = "juan@example.com" }; - BuildValidator().TestValidate(cmd).ShouldNotHaveAnyValidationErrors(); + var result = await BuildValidator().TestValidateAsync(cmd); + result.ShouldNotHaveAnyValidationErrors(); } // ── Username ───────────────────────────────────────────────────────────── [Fact] - public void Validate_EmptyUsername_HasError() + public async Task Validate_EmptyUsername_HasError() { var cmd = ValidCommand() with { Username = "" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username); } [Fact] - public void Validate_UsernameTooShort_HasError() + public async Task Validate_UsernameTooShort_HasError() { var cmd = ValidCommand() with { Username = "ab" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username); } [Fact] - public void Validate_UsernameTooLong_HasError() + public async Task Validate_UsernameTooLong_HasError() { var cmd = ValidCommand() with { Username = new string('a', 51) }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username); } [Theory] - [InlineData("abc")] // 3 chars — boundary valid - [InlineData("user.name")] // dot allowed - [InlineData("user-name")] // dash allowed - [InlineData("user_name")] // underscore allowed - [InlineData("user123")] // alphanumeric - public void Validate_UsernameValidFormats_NoError(string username) + [InlineData("abc")] + [InlineData("user.name")] + [InlineData("user-name")] + [InlineData("user_name")] + [InlineData("user123")] + public async Task Validate_UsernameValidFormats_NoError(string username) { var cmd = ValidCommand() with { Username = username }; - BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Username); + (await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Username); } [Theory] - [InlineData("user name")] // space not allowed - [InlineData("user@name")] // @ not allowed - [InlineData("user#1")] // # not allowed - public void Validate_UsernameInvalidChars_HasError(string username) + [InlineData("user name")] + [InlineData("user@name")] + [InlineData("user#1")] + public async Task Validate_UsernameInvalidChars_HasError(string username) { var cmd = ValidCommand() with { Username = username }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Username); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Username); } // ── Password ───────────────────────────────────────────────────────────── [Fact] - public void Validate_EmptyPassword_HasError() + public async Task Validate_EmptyPassword_HasError() { var cmd = ValidCommand() with { Password = "" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password); } [Fact] - public void Validate_PasswordTooShort_HasError() + public async Task Validate_PasswordTooShort_HasError() { - var cmd = ValidCommand() with { Password = "Ab1cd5" }; // 6 chars < 8 - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password); + var cmd = ValidCommand() with { Password = "Ab1cd5" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password); } [Fact] - public void Validate_PasswordNoLetter_HasError() + public async Task Validate_PasswordNoLetter_HasError() { - var cmd = ValidCommand() with { Password = "12345678" }; // digits only - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password); + var cmd = ValidCommand() with { Password = "12345678" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password); } [Fact] - public void Validate_PasswordNoDigit_HasError() + public async Task Validate_PasswordNoDigit_HasError() { - var cmd = ValidCommand() with { Password = "abcdefgh" }; // letters only - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Password); + var cmd = ValidCommand() with { Password = "abcdefgh" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Password); } [Fact] - public void Validate_PasswordExactMinLength_NoError() + public async Task Validate_PasswordExactMinLength_NoError() { - var cmd = ValidCommand() with { Password = "Secre123" }; // exactly 8, letter + digit - BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Password); + var cmd = ValidCommand() with { Password = "Secre123" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Password); } // ── Nombre / Apellido ──────────────────────────────────────────────────── [Fact] - public void Validate_EmptyNombre_HasError() + public async Task Validate_EmptyNombre_HasError() { var cmd = ValidCommand() with { Nombre = "" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Nombre); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Nombre); } [Fact] - public void Validate_EmptyApellido_HasError() + public async Task Validate_EmptyApellido_HasError() { var cmd = ValidCommand() with { Apellido = "" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Apellido); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Apellido); } [Fact] - public void Validate_NombreTooLong_HasError() + public async Task Validate_NombreTooLong_HasError() { var cmd = ValidCommand() with { Nombre = new string('a', 101) }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Nombre); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Nombre); } [Fact] - public void Validate_ApellidoTooLong_HasError() + public async Task Validate_ApellidoTooLong_HasError() { var cmd = ValidCommand() with { Apellido = new string('a', 101) }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Apellido); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Apellido); } // ── Rol ────────────────────────────────────────────────────────────────── @@ -163,36 +178,61 @@ public class CreateUsuarioCommandValidatorTests [InlineData("productor")] [InlineData("diagramacion")] [InlineData("reportes")] - public void Validate_ValidRoles_NoError(string rol) + public async Task Validate_CanonicalActiveRoles_NoError(string rol) { var cmd = ValidCommand() with { Rol = rol }; - BuildValidator().TestValidate(cmd).ShouldNotHaveValidationErrorFor(c => c.Rol); + (await BuildValidator().TestValidateAsync(cmd)).ShouldNotHaveValidationErrorFor(c => c.Rol); } - [Theory] - [InlineData("superuser")] - [InlineData("ADMIN")] // case-sensitive - [InlineData("root")] - [InlineData("")] - public void Validate_InvalidRol_HasError(string rol) + [Fact] + public async Task Validate_RolInexistente_HasError() { - var cmd = ValidCommand() with { Rol = rol }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Rol); + _roles.ExistsActiveByCodigoAsync("superuser", Arg.Any()).Returns(false); + + var cmd = ValidCommand() with { Rol = "superuser" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol); + } + + [Fact] + public async Task Validate_RolInactivo_HasError() + { + // The repository reports NOT active (soft-deleted rol) → validator rejects. + _roles.ExistsActiveByCodigoAsync("picadora", Arg.Any()).Returns(false); + + var cmd = ValidCommand() with { Rol = "picadora" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol); + } + + [Fact] + public async Task Validate_RolEmptyString_HasError() + { + var cmd = ValidCommand() with { Rol = "" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol); + } + + [Fact] + public async Task Validate_RolCaseSensitive_HasError() + { + // 'ADMIN' uppercase is not a canonical code; mock returns false by default. + _roles.ExistsActiveByCodigoAsync("ADMIN", Arg.Any()).Returns(false); + + var cmd = ValidCommand() with { Rol = "ADMIN" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol); } // ── Email ──────────────────────────────────────────────────────────────── [Fact] - public void Validate_InvalidEmail_HasError() + public async Task Validate_InvalidEmail_HasError() { var cmd = ValidCommand() with { Email = "not-an-email" }; - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Email); + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Email); } [Fact] - public void Validate_EmailTooLong_HasError() + public async Task Validate_EmailTooLong_HasError() { - var cmd = ValidCommand() with { Email = new string('a', 145) + "@b.com" }; // >150 - BuildValidator().TestValidate(cmd).ShouldHaveValidationErrorFor(c => c.Email); + var cmd = ValidCommand() with { Email = new string('a', 145) + "@b.com" }; + (await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Email); } } diff --git a/tests/tests.runsettings b/tests/tests.runsettings new file mode 100644 index 0000000..781e11c --- /dev/null +++ b/tests/tests.runsettings @@ -0,0 +1,16 @@ + + + + + 1 + +