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:
20
tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs
Normal file
20
tests/SIGCM2.Api.Tests/ApiIntegrationCollection.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using SIGCM2.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace SIGCM2.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared collection for all Api integration tests.
|
||||
/// Uses ICollectionFixture so a SINGLE TestWebAppFactory (and its RSA key singleton)
|
||||
/// is shared across all test classes in the "ApiIntegration" collection.
|
||||
///
|
||||
/// Previously each class used IClassFixture which spawned one factory per class;
|
||||
/// that created N factories sequentially in the same process, and the RSA key
|
||||
/// singleton from an earlier factory could leak into a later factory's DI graph
|
||||
/// (producing ObjectDisposedException "RSABCrypt" on first signing).
|
||||
/// </summary>
|
||||
[CollectionDefinition("ApiIntegration")]
|
||||
public sealed class ApiIntegrationCollection : ICollectionFixture<TestWebAppFactory>
|
||||
{
|
||||
// Intentionally empty: this class only exists to declare the collection/fixture binding.
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using SIGCM2.TestSupport;
|
||||
namespace SIGCM2.Api.Tests.Auth;
|
||||
|
||||
[Collection("ApiIntegration")]
|
||||
public class AuthControllerTests : IClassFixture<TestWebAppFactory>
|
||||
public class AuthControllerTests
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
|
||||
353
tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs
Normal file
353
tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Roles;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for /api/v1/roles (UDT-004).
|
||||
/// </summary>
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class RolesEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
|
||||
private const string Endpoint = "/api/v1/roles";
|
||||
private const string AdminUsername = "admin";
|
||||
private const string AdminPassword = "@Diego550@";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public RolesEndpointTests(TestWebAppFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task<string> GetBearerTokenAsync(string username, string password)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new { username, password });
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException($"Login failed ({(int)response.StatusCode}): {body}");
|
||||
}
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
if (bearerToken is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
if (body is not null)
|
||||
request.Content = JsonContent.Create(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static async Task DeleteRolIfExistsAsync(string codigo)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo });
|
||||
}
|
||||
|
||||
private static async Task DeleteUsuarioIfExistsAsync(string username)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync("""
|
||||
DELETE rt FROM dbo.RefreshToken rt
|
||||
INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId
|
||||
WHERE u.Username = @Username
|
||||
""", new { Username = username });
|
||||
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username });
|
||||
}
|
||||
|
||||
// ── 401 / 403 guards ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_WithoutAuth_Returns401()
|
||||
{
|
||||
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, Endpoint));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithNonAdmin_Returns403()
|
||||
{
|
||||
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
const string nonAdminUser = "rolestest_nonadmin";
|
||||
|
||||
// Create a non-admin user via endpoint (admin can still create users).
|
||||
using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new
|
||||
{
|
||||
username = nonAdminUser,
|
||||
password = "Secure1234!",
|
||||
nombre = "Non",
|
||||
apellido = "Admin",
|
||||
email = (string?)null,
|
||||
rol = "cajero"
|
||||
}, adminToken);
|
||||
var mkUserResp = await _client.SendAsync(mkUser);
|
||||
if (mkUserResp.StatusCode != HttpStatusCode.Created && mkUserResp.StatusCode != HttpStatusCode.Conflict)
|
||||
Assert.Fail($"Seed non-admin user failed: {mkUserResp.StatusCode}");
|
||||
|
||||
try
|
||||
{
|
||||
var cajeroToken = await GetBearerTokenAsync(nonAdminUser, "Secure1234!");
|
||||
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
|
||||
{
|
||||
codigo = "nuevo_test",
|
||||
nombre = "Test",
|
||||
descripcion = (string?)null
|
||||
}, cajeroToken);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteUsuarioIfExistsAsync(nonAdminUser);
|
||||
}
|
||||
}
|
||||
|
||||
// ── List ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_WithAdmin_Returns200WithCanonicalSeeds()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, Endpoint, bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var codes = list.EnumerateArray().Select(r => r.GetProperty("codigo").GetString()).ToHashSet();
|
||||
foreach (var c in new[] { "admin", "cajero", "operador_ctacte", "picadora", "jefe_publicidad", "productor", "diagramacion", "reportes" })
|
||||
Assert.Contains(c, codes);
|
||||
}
|
||||
|
||||
// ── Get ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCodigo_Existing_Returns200()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, $"{Endpoint}/cajero", bearerToken: token));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("cajero", body.GetProperty("codigo").GetString());
|
||||
Assert.Equal("Cajero", body.GetProperty("nombre").GetString());
|
||||
Assert.True(body.GetProperty("activo").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCodigo_NonExistent_Returns404()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, $"{Endpoint}/no_existe_xyz", bearerToken: token));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("rol_not_found", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NewRol_Returns201()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
const string codigo = "endpoint_new_rol";
|
||||
|
||||
try
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
|
||||
{
|
||||
codigo,
|
||||
nombre = "Endpoint New",
|
||||
descripcion = "Creado por integration test"
|
||||
}, token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(codigo, body.GetProperty("codigo").GetString());
|
||||
Assert.True(body.GetProperty("id").GetInt32() > 0);
|
||||
Assert.True(body.GetProperty("activo").GetBoolean());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRolIfExistsAsync(codigo);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_CodigoDuplicado_Returns409()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
|
||||
{
|
||||
codigo = "cajero",
|
||||
nombre = "Duplicate",
|
||||
descripcion = (string?)null
|
||||
}, token);
|
||||
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("rol_already_exists", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_InvalidCodigoFormat_Returns400()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Post, Endpoint, new
|
||||
{
|
||||
codigo = "Cajero Senior", // uppercase + space — invalid
|
||||
nombre = "Bad",
|
||||
descripcion = (string?)null
|
||||
}, token);
|
||||
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Update_Existing_Returns200WithUpdatedNombre()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
const string codigo = "endpoint_upd_rol";
|
||||
|
||||
// Seed a rol
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync(
|
||||
"INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion, Activo) VALUES (@Codigo, N'Viejo', N'Desc vieja', 1);",
|
||||
new { Codigo = codigo });
|
||||
|
||||
try
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Put, $"{Endpoint}/{codigo}", new
|
||||
{
|
||||
nombre = "Nuevo Nombre",
|
||||
descripcion = "Desc nueva",
|
||||
activo = true
|
||||
}, token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("Nuevo Nombre", body.GetProperty("nombre").GetString());
|
||||
Assert.Equal("Desc nueva", body.GetProperty("descripcion").GetString());
|
||||
Assert.Equal(codigo, body.GetProperty("codigo").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRolIfExistsAsync(codigo);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_NonExistent_Returns404()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Put, $"{Endpoint}/inexistente_abc", new
|
||||
{
|
||||
nombre = "X",
|
||||
descripcion = (string?)null,
|
||||
activo = true
|
||||
}, token);
|
||||
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── Delete (soft) ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_WithoutActiveUsuarios_Returns204()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
const string codigo = "endpoint_del_rol";
|
||||
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync(
|
||||
"INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'Temp', 1);",
|
||||
new { Codigo = codigo });
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/{codigo}", bearerToken: token));
|
||||
Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode);
|
||||
|
||||
var activo = await conn.ExecuteScalarAsync<bool>(
|
||||
"SELECT Activo FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo });
|
||||
Assert.False(activo);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteRolIfExistsAsync(codigo);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_WithActiveUsuarios_Returns409()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
const string codigo = "endpoint_del_inuse";
|
||||
const string testUser = "endpoint_del_user";
|
||||
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync(
|
||||
"INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES (@Codigo, N'InUse', 1);",
|
||||
new { Codigo = codigo });
|
||||
await conn.ExecuteAsync(
|
||||
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " +
|
||||
"VALUES (@Username, '$2a$12$hash', 'Test', 'User', @Codigo, '[]', 1);",
|
||||
new { Username = testUser, Codigo = codigo });
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/{codigo}", bearerToken: token));
|
||||
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
|
||||
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("rol_in_use", body.GetProperty("error").GetString());
|
||||
|
||||
var activo = await conn.ExecuteScalarAsync<bool>(
|
||||
"SELECT Activo FROM dbo.Rol WHERE Codigo = @Codigo", new { Codigo = codigo });
|
||||
Assert.True(activo);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteUsuarioIfExistsAsync(testUser);
|
||||
await DeleteRolIfExistsAsync(codigo);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_NonExistent_Returns404()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Delete, $"{Endpoint}/no_existe_del", bearerToken: token));
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,11 +11,11 @@ namespace SIGCM2.Api.Tests.Usuarios;
|
||||
/// <summary>
|
||||
/// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
16
tests/tests.runsettings
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RunSettings>
|
||||
<!--
|
||||
Fuerza ejecución secuencial de las diferentes test assemblies.
|
||||
Justificación: Application.Tests (integration) y Api.Tests (WebApplicationFactory)
|
||||
comparten la BD SIGCM2_Test. Ejecutarlas en paralelo produce race conditions
|
||||
sobre Respawn.Reset + SeedRolCanonical + SeedAdmin.
|
||||
|
||||
Cuando se corre proyecto a proyecto (`dotnet test <csproj>`) no hay paralelismo
|
||||
cross-assembly y no se necesita este settings. Este archivo es para el caso
|
||||
`dotnet test` en la raíz del repo.
|
||||
-->
|
||||
<RunConfiguration>
|
||||
<MaxCpuCount>1</MaxCpuCount>
|
||||
</RunConfiguration>
|
||||
</RunSettings>
|
||||
Reference in New Issue
Block a user