using Microsoft.Data.SqlClient; using Respawn; using SIGCM2.Infrastructure.Persistence; namespace SIGCM2.Application.Tests.Integration; [Collection("Database")] public class UsuarioRepositoryTests : IAsyncLifetime { private const string ConnectionString = "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; private SqlConnection _connection = null!; private Respawner _respawner = null!; private UsuarioRepository _repository = null!; public async Task InitializeAsync() { _connection = new SqlConnection(ConnectionString); await _connection.OpenAsync(); _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { 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"), new Respawn.Graph.Table("dbo", "Permiso"), new Respawn.Graph.Table("dbo", "RolPermiso"), // UDT-010: *_History tables are system-versioned — engine rejects direct DELETE. new Respawn.Graph.Table("dbo", "Usuario_History"), new Respawn.Graph.Table("dbo", "Rol_History"), new Respawn.Graph.Table("dbo", "Permiso_History"), new Respawn.Graph.Table("dbo", "RolPermiso_History"), // ADM-001 (V011): Medio + Seccion are temporal — history tables cannot be directly deleted. new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Seccion_History"), // ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado). new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"), // ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales. new Respawn.Graph.Table("dbo", "TipoDeIva_History"), new Respawn.Graph.Table("dbo", "IngresosBrutos_History"), new Respawn.Graph.Table("dbo", "TipoDeIva"), new Respawn.Graph.Table("dbo", "IngresosBrutos"), // CAT-001 (V016): Rubro es temporal — history no puede deletearse directo. new Respawn.Graph.Table("dbo", "Rubro_History"), ] }); // 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); _repository = new UsuarioRepository(factory); } public async Task DisposeAsync() { await _respawner.ResetAsync(_connection); await _connection.CloseAsync(); await _connection.DisposeAsync(); } // Scenario: GetByUsername returns correct entity when user exists [Fact] public async Task GetByUsernameAsync_ExistingUser_ReturnsUsuario() { var usuario = await _repository.GetByUsernameAsync("admin"); Assert.NotNull(usuario); Assert.Equal("admin", usuario.Username); Assert.Equal("admin", usuario.Rol); Assert.True(usuario.Activo); Assert.False(string.IsNullOrWhiteSpace(usuario.PasswordHash)); } // Triangulation: GetByUsername returns null when user does not exist [Fact] public async Task GetByUsernameAsync_NonExistentUser_ReturnsNull() { var usuario = await _repository.GetByUsernameAsync("noexiste"); Assert.Null(usuario); } // Triangulation: case-sensitive username lookup (SQL Server UNIQUE constraint is case-insensitive by default) [Fact] public async Task GetByUsernameAsync_DifferentUser_ReturnsCorrectUser_Cajero() { // 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 ('cajero1', '$2a$12$hash2', 'Juan', 'Pérez', 'cajero', '[]')"); var admin = await _repository.GetByUsernameAsync("admin"); var cajero = await _repository.GetByUsernameAsync("cajero1"); Assert.NotNull(admin); Assert.NotNull(cajero); Assert.NotEqual(admin.Id, cajero.Id); Assert.Equal("admin", admin.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() { await _connection.ExecuteAsync( "SET QUOTED_IDENTIFIER ON; " + "IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin') " + "INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo) " + "VALUES ('admin', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', " + "'Administrador', 'Sistema', 'admin', '[\"*\"]', 1)"); } } // Dapper extension helper for IDbConnection file static class DapperHelper { public static async Task ExecuteAsync(this SqlConnection conn, string sql) { using var cmd = conn.CreateCommand(); cmd.CommandText = sql; await cmd.ExecuteNonQueryAsync(); } }