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

@@ -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()