feat(api): UDT-004 controller de roles + refactor validator UDT-003 a lookup dinamico

- 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
This commit is contained in:
2026-04-15 12:50:24 -03:00
parent 34b714750a
commit 6f999b8fcd
11 changed files with 722 additions and 80 deletions

View File

@@ -0,0 +1,20 @@
using SIGCM2.TestSupport;
using Xunit;
namespace SIGCM2.Api.Tests;
/// <summary>
/// Shared collection for all Api integration tests.
/// Uses ICollectionFixture so a SINGLE TestWebAppFactory (and its RSA key singleton)
/// is shared across all test classes in the "ApiIntegration" collection.
///
/// Previously each class used IClassFixture which spawned one factory per class;
/// that created N factories sequentially in the same process, and the RSA key
/// singleton from an earlier factory could leak into a later factory's DI graph
/// (producing ObjectDisposedException "RSABCrypt" on first signing).
/// </summary>
[CollectionDefinition("ApiIntegration")]
public sealed class ApiIntegrationCollection : ICollectionFixture<TestWebAppFactory>
{
// Intentionally empty: this class only exists to declare the collection/fixture binding.
}

View File

@@ -6,7 +6,7 @@ using SIGCM2.TestSupport;
namespace SIGCM2.Api.Tests.Auth;
[Collection("ApiIntegration")]
public class AuthControllerTests : IClassFixture<TestWebAppFactory>
public class AuthControllerTests
{
private readonly HttpClient _client;

View File

@@ -0,0 +1,353 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SIGCM2.TestSupport;
namespace SIGCM2.Api.Tests.Roles;
/// <summary>
/// Integration tests for /api/v1/roles (UDT-004).
/// </summary>
[Collection("ApiIntegration")]
public sealed class RolesEndpointTests : IAsyncLifetime
{
private const string TestConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private const string Endpoint = "/api/v1/roles";
private const string AdminUsername = "admin";
private const string AdminPassword = "@Diego550@";
private readonly HttpClient _client;
public RolesEndpointTests(TestWebAppFactory factory)
{
_client = factory.CreateClient();
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
private async Task<string> GetBearerTokenAsync(string username, string password)
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new { username, password });
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Login failed ({(int)response.StatusCode}): {body}");
}
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
{
var request = new HttpRequestMessage(method, url);
if (bearerToken is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
if (body is not null)
request.Content = JsonContent.Create(body);
return request;
}
private static async Task DeleteRolIfExistsAsync(string codigo)
{
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo });
}
private static async Task DeleteUsuarioIfExistsAsync(string username)
{
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync("""
DELETE rt FROM dbo.RefreshToken rt
INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId
WHERE u.Username = @Username
""", new { Username = username });
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username });
}
// ── 401 / 403 guards ────────────────────────────────────────────────────
[Fact]
public async Task List_WithoutAuth_Returns401()
{
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, Endpoint));
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task Create_WithNonAdmin_Returns403()
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
const string nonAdminUser = "rolestest_nonadmin";
// Create a non-admin user via endpoint (admin can still create users).
using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new
{
username = nonAdminUser,
password = "Secure1234!",
nombre = "Non",
apellido = "Admin",
email = (string?)null,
rol = "cajero"
}, adminToken);
var mkUserResp = await _client.SendAsync(mkUser);
if (mkUserResp.StatusCode != HttpStatusCode.Created && mkUserResp.StatusCode != HttpStatusCode.Conflict)
Assert.Fail($"Seed non-admin user failed: {mkUserResp.StatusCode}");
try
{
var cajeroToken = await GetBearerTokenAsync(nonAdminUser, "Secure1234!");
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
codigo = "nuevo_test",
nombre = "Test",
descripcion = (string?)null
}, cajeroToken);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
}
finally
{
await DeleteUsuarioIfExistsAsync(nonAdminUser);
}
}
// ── List ────────────────────────────────────────────────────────────────
[Fact]
public async Task List_WithAdmin_Returns200WithCanonicalSeeds()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, Endpoint, bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
var codes = list.EnumerateArray().Select(r => r.GetProperty("codigo").GetString()).ToHashSet();
foreach (var c in new[] { "admin", "cajero", "operador_ctacte", "picadora", "jefe_publicidad", "productor", "diagramacion", "reportes" })
Assert.Contains(c, codes);
}
// ── Get ─────────────────────────────────────────────────────────────────
[Fact]
public async Task GetByCodigo_Existing_Returns200()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, $"{Endpoint}/cajero", bearerToken: token));
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("cajero", body.GetProperty("codigo").GetString());
Assert.Equal("Cajero", body.GetProperty("nombre").GetString());
Assert.True(body.GetProperty("activo").GetBoolean());
}
[Fact]
public async Task GetByCodigo_NonExistent_Returns404()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, $"{Endpoint}/no_existe_xyz", bearerToken: token));
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("rol_not_found", body.GetProperty("error").GetString());
}
// ── Create ──────────────────────────────────────────────────────────────
[Fact]
public async Task Create_NewRol_Returns201()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
const string codigo = "endpoint_new_rol";
try
{
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
codigo,
nombre = "Endpoint New",
descripcion = "Creado por integration test"
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(codigo, body.GetProperty("codigo").GetString());
Assert.True(body.GetProperty("id").GetInt32() > 0);
Assert.True(body.GetProperty("activo").GetBoolean());
}
finally
{
await DeleteRolIfExistsAsync(codigo);
}
}
[Fact]
public async Task Create_CodigoDuplicado_Returns409()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
codigo = "cajero",
nombre = "Duplicate",
descripcion = (string?)null
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("rol_already_exists", body.GetProperty("error").GetString());
}
[Fact]
public async Task Create_InvalidCodigoFormat_Returns400()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
codigo = "Cajero Senior", // uppercase + space — invalid
nombre = "Bad",
descripcion = (string?)null
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
// ── Update ──────────────────────────────────────────────────────────────
[Fact]
public async Task Update_Existing_Returns200WithUpdatedNombre()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
const string codigo = "endpoint_upd_rol";
// Seed a rol
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync(
"INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion, Activo) VALUES (@Codigo, N'Viejo', N'Desc vieja', 1);",
new { Codigo = codigo });
try
{
using var req = BuildRequest(HttpMethod.Put, $"{Endpoint}/{codigo}", new
{
nombre = "Nuevo Nombre",
descripcion = "Desc nueva",
activo = true
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Nuevo Nombre", body.GetProperty("nombre").GetString());
Assert.Equal("Desc nueva", body.GetProperty("descripcion").GetString());
Assert.Equal(codigo, body.GetProperty("codigo").GetString());
}
finally
{
await DeleteRolIfExistsAsync(codigo);
}
}
[Fact]
public async Task Update_NonExistent_Returns404()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Put, $"{Endpoint}/inexistente_abc", new
{
nombre = "X",
descripcion = (string?)null,
activo = true
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
// ── Delete (soft) ───────────────────────────────────────────────────────
[Fact]
public async Task Delete_WithoutActiveUsuarios_Returns204()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
const string codigo = "endpoint_del_rol";
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync(
"INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'Temp', 1);",
new { Codigo = codigo });
try
{
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/{codigo}", bearerToken: token));
Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode);
var activo = await conn.ExecuteScalarAsync<bool>(
"SELECT Activo FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo });
Assert.False(activo);
}
finally
{
await DeleteRolIfExistsAsync(codigo);
}
}
[Fact]
public async Task Delete_WithActiveUsuarios_Returns409()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
const string codigo = "endpoint_del_inuse";
const string testUser = "endpoint_del_user";
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync(
"INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'InUse', 1);",
new { Codigo = codigo });
await conn.ExecuteAsync(
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " +
"VALUES (@Username, '$2a$12$hash', 'Test', 'User', @Codigo, '[]', 1);",
new { Username = testUser, Codigo = codigo });
try
{
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/{codigo}", bearerToken: token));
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("rol_in_use", body.GetProperty("error").GetString());
var activo = await conn.ExecuteScalarAsync<bool>(
"SELECT Activo FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo });
Assert.True(activo);
}
finally
{
await DeleteUsuarioIfExistsAsync(testUser);
await DeleteRolIfExistsAsync(codigo);
}
}
[Fact]
public async Task Delete_NonExistent_Returns404()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/no_existe_del", bearerToken: token));
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
}

View File

@@ -11,11 +11,11 @@ namespace SIGCM2.Api.Tests.Usuarios;
/// <summary>
/// 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.
/// </summary>
[Collection("ApiIntegration")]
public sealed class CreateUsuarioEndpointTests : IClassFixture<TestWebAppFactory>, 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<TestWebAppFactory
await DeleteUsuarioAsync(newUsername);
}
}
// ---------------------------------------------------------------------------
// Scenario 7 (UDT-004 Phase 5.3): 400 — rol existe pero está inactivo
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_WithInactiveRol_Returns400()
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
const string codigo = "udt004_inactive_rol";
const string testUser = "udt004_inactive_rol_user";
await using var conn = new SqlConnection(TestConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync(
"INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'Inactivo Test', 0);",
new { Codigo = codigo });
try
{
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
{
username = testUser,
password = "Secure1234!",
nombre = "Test",
apellido = "Inactive",
email = (string?)null,
rol = codigo
}, adminToken);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("errors", out var errors), "Response must contain 'errors'");
// Validation error should be on the Rol field
Assert.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 });
}
}
}

View File

@@ -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<IRolRepository>();
public CreateUsuarioCommandValidatorTests()
{
// Default mock behavior: canonical seeds are active; unknown codes are not.
var canonical = new[] { "admin", "cajero", "operador_ctacte", "picadora",
"jefe_publicidad", "productor", "diagramacion", "reportes" };
foreach (var code in canonical)
_roles.ExistsActiveByCodigoAsync(code, Arg.Any<CancellationToken>()).Returns(true);
}
private CreateUsuarioCommandValidator BuildValidator(AuthOptions? opts = null) =>
new(opts ?? new AuthOptions(), _roles);
private static CreateUsuarioCommand ValidCommand() => new(
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<CancellationToken>()).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<CancellationToken>()).Returns(false);
var cmd = ValidCommand() with { Rol = "picadora" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol);
}
[Fact]
public async Task Validate_RolEmptyString_HasError()
{
var cmd = ValidCommand() with { Rol = "" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol);
}
[Fact]
public async Task Validate_RolCaseSensitive_HasError()
{
// 'ADMIN' uppercase is not a canonical code; mock returns false by default.
_roles.ExistsActiveByCodigoAsync("ADMIN", Arg.Any<CancellationToken>()).Returns(false);
var cmd = ValidCommand() with { Rol = "ADMIN" };
(await BuildValidator().TestValidateAsync(cmd)).ShouldHaveValidationErrorFor(c => c.Rol);
}
// ── Email ────────────────────────────────────────────────────────────────
[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);
}
}

16
tests/tests.runsettings Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<!--
Fuerza ejecución secuencial de las diferentes test assemblies.
Justificación: Application.Tests (integration) y Api.Tests (WebApplicationFactory)
comparten la BD SIGCM2_Test. Ejecutarlas en paralelo produce race conditions
sobre Respawn.Reset + SeedRolCanonical + SeedAdmin.
Cuando se corre proyecto a proyecto (`dotnet test <csproj>`) no hay paralelismo
cross-assembly y no se necesita este settings. Este archivo es para el caso
`dotnet test` en la raíz del repo.
-->
<RunConfiguration>
<MaxCpuCount>1</MaxCpuCount>
</RunConfiguration>
</RunSettings>