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:
127
src/api/SIGCM2.Api/Controllers/RolesController.cs
Normal file
127
src/api/SIGCM2.Api/Controllers/RolesController.cs
Normal file
@@ -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<CreateRolCommand> _createValidator;
|
||||
private readonly IValidator<UpdateRolCommand> _updateValidator;
|
||||
|
||||
public RolesController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateRolCommand> createValidator,
|
||||
IValidator<UpdateRolCommand> updateValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
/// <summary>Lists all roles (including inactive). Requires admin role.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<RolDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var result = await _dispatcher.Send<ListRolesQuery, IReadOnlyList<RolDto>>(new ListRolesQuery());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets a role by its code. Requires admin role.</summary>
|
||||
[HttpGet("{codigo}")]
|
||||
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetByCodigo(string codigo)
|
||||
{
|
||||
var result = await _dispatcher.Send<GetRolByCodigoQuery, RolDto>(new GetRolByCodigoQuery(codigo));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new role. Requires admin role.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(RolCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> 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<CreateRolCommand, RolCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetByCodigo), new { codigo = result.Codigo }, result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a role (codigo is immutable; route wins over body). Requires admin role.</summary>
|
||||
[HttpPut("{codigo}")]
|
||||
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> 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<UpdateRolCommand, RolDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes (deactivates) a role. 409 if active usuarios reference it. Requires admin role.</summary>
|
||||
[HttpDelete("{codigo}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Deactivate(string codigo)
|
||||
{
|
||||
await _dispatcher.Send<DeactivateRolCommand, RolDto>(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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ICommandHandler<LogoutCommand, LogoutResponseDto>, LogoutCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<CreateUsuarioCommand, UsuarioCreatedDto>, CreateUsuarioCommandHandler>();
|
||||
|
||||
// Roles (UDT-004)
|
||||
services.AddScoped<ICommandHandler<ListRolesQuery, IReadOnlyList<RolDto>>, ListRolesQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetRolByCodigoQuery, RolDto>, GetRolByCodigoQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<CreateRolCommand, RolCreatedDto>, CreateRolCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateRolCommand, RolDto>, UpdateRolCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateRolCommand, RolDto>, DeactivateRolCommandHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -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<CreateUsuarioCommand>
|
||||
{
|
||||
// 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<CreateUsua
|
||||
.MaximumLength(EmailMaxLength).WithMessage($"El email no puede superar los {EmailMaxLength} caracteres.")
|
||||
.When(x => 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) =>
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class DependencyInjection
|
||||
services.AddSingleton(new SqlConnectionFactory(connectionString));
|
||||
services.AddScoped<IUsuarioRepository, UsuarioRepository>();
|
||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||
services.AddScoped<IRolRepository, RolRepository>();
|
||||
|
||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||
|
||||
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