feat(api): UDT-004 dominio + repositorio + application roles (tdd)

- Migraciones V003 (tabla Rol + 8 seeds canonicos) y V004 (drop CK + FK Usuario.Rol)
- Dominio: Rol entity + 3 excepciones (RolNotFound/AlreadyExists/InUse)
- Infraestructura: RolRepository (Dapper) con List/Get/ExistsActive/Add/Update/HasActiveUsuarios
- Application: CRUD queries y commands (List, Get, Create, Update, Deactivate) + validators (codigo regex ^[a-z][a-z0-9_]*$)
- Validator UDT-003: whitelist alineada a codigos canonicos (full IRolRepository lookup diferido a Phase 5.1)
- Tests: 169 application + 15 api (todos verdes). Respawn configurado para re-seedear Rol canonical post-reset.
- Estricto TDD: RED/GREEN/TRIANGULATE en todos los handlers nuevos.
This commit is contained in:
2026-04-15 12:31:29 -03:00
parent e0e9ec3b88
commit 34b714750a
37 changed files with 1510 additions and 27 deletions

View File

@@ -0,0 +1,240 @@
using Dapper;
using Microsoft.Data.SqlClient;
using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Application.Tests.Integration;
[Collection("Database")]
public class RolRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private RolRepository _repository = null!;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
// Clean Usuario first (FK), then custom Rol codes created by tests.
await _connection.ExecuteAsync("DELETE FROM dbo.Usuario;");
await _connection.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo NOT IN ('admin','cajero','operador_ctacte','picadora','jefe_publicidad','productor','diagramacion','reportes');");
// Ensure canonical Rol seeds exist (idempotent — previous test classes may have wiped them via Respawn).
await SeedRolCanonicalAsync();
// Reset any mutations applied to canonical seeds during prior tests.
await _connection.ExecuteAsync("UPDATE dbo.Rol SET Activo = 1, FechaModificacion = NULL WHERE Codigo IN ('admin','cajero','operador_ctacte','picadora','jefe_publicidad','productor','diagramacion','reportes');");
// Seed admin usuario (needed by HasActiveUsuariosAsync test expecting admin active).
await _connection.ExecuteAsync(
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " +
"VALUES ('admin', '$2a$12$hash', 'Administrador', 'Sistema', 'admin', '[\"*\"]', 1);");
var factory = new SqlConnectionFactory(ConnectionString);
_repository = new RolRepository(factory);
}
public async Task DisposeAsync()
{
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
private async Task SeedRolCanonicalAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Rol AS t
USING (VALUES
('admin', N'Administrador', N'Supervisor total'),
('cajero', N'Cajero', N'Mostrador contado'),
('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'),
('picadora', N'Picadora/Correctora', N'Edición de textos'),
('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'),
('productor', N'Productor', N'Carga restringida'),
('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'),
('reportes', N'Reportes', N'Solo lectura reportes')
) AS s (Codigo, Nombre, Descripcion)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Activo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
""";
await _connection.ExecuteAsync(sql);
}
// ── ListAsync ────────────────────────────────────────────────────────────
[Fact]
public async Task ListAsync_ReturnsAllCanonicalSeeds()
{
var list = await _repository.ListAsync();
var codes = list.Select(r => r.Codigo).ToHashSet();
Assert.Contains("admin", codes);
Assert.Contains("cajero", codes);
Assert.Contains("operador_ctacte", codes);
Assert.Contains("picadora", codes);
Assert.Contains("jefe_publicidad", codes);
Assert.Contains("productor", codes);
Assert.Contains("diagramacion", codes);
Assert.Contains("reportes", codes);
Assert.True(list.Count >= 8);
}
[Fact]
public async Task ListAsync_IncludesInactiveRoles()
{
// Triangulation: list must include deactivated rows too.
await _connection.ExecuteAsync("INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES ('listtest_inactive', N'Inactivo de test', 0);");
var list = await _repository.ListAsync();
var inactive = list.Single(r => r.Codigo == "listtest_inactive");
Assert.False(inactive.Activo);
}
// ── GetByCodigoAsync ────────────────────────────────────────────────────
[Fact]
public async Task GetByCodigoAsync_ExistingCodigo_ReturnsRol()
{
var rol = await _repository.GetByCodigoAsync("cajero");
Assert.NotNull(rol);
Assert.Equal("cajero", rol!.Codigo);
Assert.Equal("Cajero", rol.Nombre);
Assert.True(rol.Activo);
}
[Fact]
public async Task GetByCodigoAsync_NonExistentCodigo_ReturnsNull()
{
var rol = await _repository.GetByCodigoAsync("no_existe");
Assert.Null(rol);
}
// ── ExistsActiveByCodigoAsync ───────────────────────────────────────────
[Fact]
public async Task ExistsActiveByCodigoAsync_ActiveCodigo_ReturnsTrue()
{
Assert.True(await _repository.ExistsActiveByCodigoAsync("admin"));
}
[Fact]
public async Task ExistsActiveByCodigoAsync_InactiveCodigo_ReturnsFalse()
{
await _connection.ExecuteAsync("INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES ('exists_inactive', N'Test inactivo', 0);");
Assert.False(await _repository.ExistsActiveByCodigoAsync("exists_inactive"));
}
[Fact]
public async Task ExistsActiveByCodigoAsync_MissingCodigo_ReturnsFalse()
{
Assert.False(await _repository.ExistsActiveByCodigoAsync("missing_codigo_xyz"));
}
// ── AddAsync ────────────────────────────────────────────────────────────
[Fact]
public async Task AddAsync_NewRol_PersistsAndReturnsId()
{
var rol = Rol.ForCreation("addtest_new", "Add Test", "Rol de prueba add");
var newId = await _repository.AddAsync(rol);
Assert.True(newId > 0);
var persisted = await _connection.QuerySingleAsync<(string Codigo, string Nombre, string? Descripcion, bool Activo)>(
"SELECT Codigo, Nombre, Descripcion, Activo FROM dbo.Rol WHERE Id = @Id",
new { Id = newId });
Assert.Equal("addtest_new", persisted.Codigo);
Assert.Equal("Add Test", persisted.Nombre);
Assert.Equal("Rol de prueba add", persisted.Descripcion);
Assert.True(persisted.Activo);
}
[Fact]
public async Task AddAsync_WithNullDescripcion_PersistsNull()
{
var rol = Rol.ForCreation("addtest_nulldesc", "Null Desc", null);
var newId = await _repository.AddAsync(rol);
var desc = await _connection.ExecuteScalarAsync<string?>(
"SELECT Descripcion FROM dbo.Rol WHERE Id = @Id", new { Id = newId });
Assert.Null(desc);
}
// ── UpdateAsync ─────────────────────────────────────────────────────────
[Fact]
public async Task UpdateAsync_ExistingCodigo_UpdatesMutableFieldsAndSetsFechaModificacion()
{
await _connection.ExecuteAsync(
"INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion, Activo) VALUES ('updtest_one', N'Nombre Viejo', N'Desc vieja', 1);");
var updated = await _repository.UpdateAsync("updtest_one", "Nombre Nuevo", "Desc nueva", activo: true);
Assert.True(updated);
var row = await _connection.QuerySingleAsync<(string Nombre, string? Descripcion, bool Activo, DateTime? FechaModificacion)>(
"SELECT Nombre, Descripcion, Activo, FechaModificacion FROM dbo.Rol WHERE Codigo = 'updtest_one'");
Assert.Equal("Nombre Nuevo", row.Nombre);
Assert.Equal("Desc nueva", row.Descripcion);
Assert.True(row.Activo);
Assert.NotNull(row.FechaModificacion);
}
[Fact]
public async Task UpdateAsync_NonExistentCodigo_ReturnsFalse()
{
var updated = await _repository.UpdateAsync("updtest_missing", "X", null, true);
Assert.False(updated);
}
[Fact]
public async Task UpdateAsync_DoesNotChangeCodigo()
{
await _connection.ExecuteAsync(
"INSERT INTO dbo.Rol (Codigo, Nombre, Activo) VALUES ('updtest_codigo', N'Test Codigo', 1);");
await _repository.UpdateAsync("updtest_codigo", "Nombre Cambiado", null, true);
var stillExists = await _connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM dbo.Rol WHERE Codigo = 'updtest_codigo';");
Assert.Equal(1, stillExists);
}
// ── HasActiveUsuariosAsync ──────────────────────────────────────────────
[Fact]
public async Task HasActiveUsuariosAsync_WithActiveUsuario_ReturnsTrue()
{
// 'admin' Usuario is seeded active and references Rol.admin.
Assert.True(await _repository.HasActiveUsuariosAsync("admin"));
}
[Fact]
public async Task HasActiveUsuariosAsync_NoUsuariosReferencing_ReturnsFalse()
{
// 'reportes' seed has no Usuario referencing it in a clean test DB.
Assert.False(await _repository.HasActiveUsuariosAsync("reportes"));
}
[Fact]
public async Task HasActiveUsuariosAsync_OnlyInactiveUsuarioReferencing_ReturnsFalse()
{
// Insert an INACTIVE usuario referencing 'cajero'.
await _connection.ExecuteAsync(
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " +
"VALUES ('inactivo1', '$2a$12$hash', 'Test', 'Inactivo', 'cajero', '[]', 0);");
Assert.False(await _repository.HasActiveUsuariosAsync("cajero"));
}
}

View File

@@ -21,11 +21,14 @@ public class UsuarioRepositoryTests : IAsyncLifetime
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer
DbAdapter = DbAdapter.SqlServer,
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")]
});
// Reset DB and seed admin user for each test class run
// Reset DB, re-seed Rol canonical table (lookup) and admin user for each test class run.
await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
await SeedAdminAsync();
var factory = new SqlConnectionFactory(ConnectionString);
@@ -62,21 +65,44 @@ public class UsuarioRepositoryTests : IAsyncLifetime
// Triangulation: case-sensitive username lookup (SQL Server UNIQUE constraint is case-insensitive by default)
[Fact]
public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser()
public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser_Cajero()
{
// Insert a second user
// Insert a second user with canonical rol 'cajero' (post-UDT-004 FK requires Rol.Codigo to exist).
await _connection.ExecuteAsync(
"INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) " +
"VALUES ('vendedor1', '$2a$12$hash2', 'Juan', 'Pérez', 'vendedor', '[]')");
"VALUES ('cajero1', '$2a$12$hash2', 'Juan', 'Pérez', 'cajero', '[]')");
var admin = await _repository.GetByUsernameAsync("admin");
var vendedor = await _repository.GetByUsernameAsync("vendedor1");
var cajero = await _repository.GetByUsernameAsync("cajero1");
Assert.NotNull(admin);
Assert.NotNull(vendedor);
Assert.NotEqual(admin.Id, vendedor.Id);
Assert.NotNull(cajero);
Assert.NotEqual(admin.Id, cajero.Id);
Assert.Equal("admin", admin.Rol);
Assert.Equal("vendedor", vendedor.Rol);
Assert.Equal("cajero", cajero.Rol);
}
private async Task SeedRolCanonicalAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Rol AS t
USING (VALUES
('admin', N'Administrador', N'Supervisor total'),
('cajero', N'Cajero', N'Mostrador contado'),
('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'),
('picadora', N'Picadora/Correctora', N'Edición de textos'),
('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta'),
('productor', N'Productor', N'Carga restringida'),
('diagramacion', N'Diagramación/Taller', N'Solo lectura pauta'),
('reportes', N'Reportes', N'Solo lectura reportes')
) AS s (Codigo, Nombre, Descripcion)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Activo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
""";
await _connection.ExecuteAsync(sql);
}
private async Task SeedAdminAsync()