From be2257a9bf4890f2ee17f4cb0ddedde9353a0ab4 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 15:39:25 -0300 Subject: [PATCH] feat(infra): BATCH 4 - Permiso/RolPermiso repos Dapper + tests integracion [UDT-005] --- .../DependencyInjection.cs | 2 + .../Persistence/PermisoRepository.cs | 85 +++++ .../Persistence/RolPermisoRepository.cs | 97 ++++++ .../RefreshTokenRepositoryTests.cs | 7 +- .../Integration/PermisoRepositoryTests.cs | 194 +++++++++++ .../Integration/RolPermisoRepositoryTests.cs | 315 ++++++++++++++++++ .../Integration/UsuarioRepositoryTests.cs | 7 +- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 89 +++++ 8 files changed, 794 insertions(+), 2 deletions(-) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/PermisoRepository.cs create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/RolPermisoRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index f1e4a02..baa417f 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -29,6 +29,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/PermisoRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/PermisoRepository.cs new file mode 100644 index 0000000..833cd0d --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/PermisoRepository.cs @@ -0,0 +1,85 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class PermisoRepository : IPermisoRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public PermisoRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task> ListAsync(CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Codigo, Nombre, Descripcion, Modulo, Activo, FechaCreacion + FROM dbo.Permiso + ORDER BY Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql); + return rows.Select(MapRow).ToList(); + } + + public async Task GetByCodigoAsync(string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Codigo, Nombre, Descripcion, Modulo, Activo, FechaCreacion + FROM dbo.Permiso + WHERE Codigo = @Codigo + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Codigo = codigo }); + return row is null ? null : MapRow(row); + } + + public async Task> GetByCodigosAsync( + IEnumerable codigos, + CancellationToken ct = default) + { + var codigosList = codigos.ToList(); + if (codigosList.Count == 0) + return Array.Empty(); + + const string sql = """ + SELECT Id, Codigo, Nombre, Descripcion, Modulo, Activo, FechaCreacion + FROM dbo.Permiso + WHERE Codigo IN @Codigos + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql, new { Codigos = codigosList }); + return rows.Select(MapRow).ToList(); + } + + private static Permiso MapRow(PermisoRow row) + => Permiso.ForRead( + id: row.Id, + codigo: row.Codigo, + nombre: row.Nombre, + descripcion: row.Descripcion, + modulo: row.Modulo, + activo: row.Activo, + fechaCreacion: row.FechaCreacion); + + private sealed record PermisoRow( + int Id, + string Codigo, + string Nombre, + string? Descripcion, + string Modulo, + bool Activo, + DateTime FechaCreacion); +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/RolPermisoRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/RolPermisoRepository.cs new file mode 100644 index 0000000..459643a --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/RolPermisoRepository.cs @@ -0,0 +1,97 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class RolPermisoRepository : IRolPermisoRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public RolPermisoRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task> GetByRolCodigoAsync( + string rolCodigo, + CancellationToken ct = default) + { + const string sql = """ + SELECT p.Id, p.Codigo, p.Nombre, p.Descripcion, p.Modulo, p.Activo, p.FechaCreacion + FROM dbo.RolPermiso rp + JOIN dbo.Rol r ON r.Id = rp.RolId + JOIN dbo.Permiso p ON p.Id = rp.PermisoId + WHERE r.Codigo = @RolCodigo + ORDER BY p.Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql, new { RolCodigo = rolCodigo }); + return rows.Select(MapRow).ToList(); + } + + public async Task ReplaceForRolAsync( + int rolId, + IEnumerable permisoIds, + CancellationToken ct = default) + { + var ids = permisoIds.ToList(); + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + await using var transaction = await connection.BeginTransactionAsync(ct); + try + { + // Step 1: Delete all existing permisos for this rol + await connection.ExecuteAsync( + "DELETE FROM dbo.RolPermiso WHERE RolId = @RolId", + new { RolId = rolId }, + transaction); + + // Step 2: Insert the new set (bulk via multi-row VALUES) + if (ids.Count > 0) + { + foreach (var permisoId in ids) + { + await connection.ExecuteAsync( + """ + INSERT INTO dbo.RolPermiso (RolId, PermisoId) + VALUES (@RolId, @PermisoId) + """, + new { RolId = rolId, PermisoId = permisoId }, + transaction); + } + } + + await transaction.CommitAsync(ct); + } + catch + { + await transaction.RollbackAsync(ct); + throw; + } + } + + private static Permiso MapRow(PermisoRow row) + => Permiso.ForRead( + id: row.Id, + codigo: row.Codigo, + nombre: row.Nombre, + descripcion: row.Descripcion, + modulo: row.Modulo, + activo: row.Activo, + fechaCreacion: row.FechaCreacion); + + private sealed record PermisoRow( + int Id, + string Codigo, + string Nombre, + string? Descripcion, + string Modulo, + bool Activo, + DateTime FechaCreacion); +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs index 13bb776..8758b8e 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs @@ -31,7 +31,12 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime { 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")] + TablesToIgnore = + [ + new Respawn.Graph.Table("dbo", "Rol"), + new Respawn.Graph.Table("dbo", "Permiso"), + new Respawn.Graph.Table("dbo", "RolPermiso"), + ] }); await _respawner.ResetAsync(_connection); diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs new file mode 100644 index 0000000..cb54042 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -0,0 +1,194 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Integration; + +/// +/// Integration tests for PermisoRepository against SIGCM2_Test. +/// RED: written before the repository implementation exists. +/// +[Collection("Database")] +public class PermisoRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private PermisoRepository _repository = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + + // Ensure the 18 canonical permisos are present — idempotent MERGE. + // Needed because other test classes (RefreshTokenRepositoryTests, UsuarioRepositoryTests) + // may call Respawn.ResetAsync before us, which would clear Permiso even if listed in + // TablesToIgnore of the central SqlTestFixture (each class configures its own Respawner). + await SeedPermisosCanonicalAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new PermisoRepository(factory); + } + + private async Task SeedPermisosCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Permiso AS t + USING (VALUES + ('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'), + ('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'), + ('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'), + ('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'), + ('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'), + ('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'), + ('textos:editar', N'Editar textos', NULL, 'textos'), + ('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'), + ('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'), + ('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'), + ('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'), + ('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'), + ('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'), + ('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'), + ('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'), + ('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'), + ('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'), + ('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion') + ) AS s (Codigo, Nombre, Descripcion, Modulo) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Modulo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo); + """; + await _connection.ExecuteAsync(sql); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // ── ListAsync ──────────────────────────────────────────────────────────── + + [Fact] + public async Task ListAsync_Returns18CanonicalSeeds() + { + var list = await _repository.ListAsync(); + + // V005 seeds exactly 18 canonical permisos + Assert.Equal(18, list.Count); + } + + [Fact] + public async Task ListAsync_ContainsExpectedCodigos() + { + var list = await _repository.ListAsync(); + var codigos = list.Select(p => p.Codigo).ToHashSet(); + + Assert.Contains("ventas:contado:crear", codigos); + Assert.Contains("ventas:contado:facturar", codigos); + Assert.Contains("administracion:usuarios:gestionar", codigos); + Assert.Contains("administracion:auditoria:ver", codigos); + } + + [Fact] + public async Task ListAsync_AllItemsHaveNonEmptyCodigoAndNombre() + { + var list = await _repository.ListAsync(); + + foreach (var p in list) + { + Assert.False(string.IsNullOrWhiteSpace(p.Codigo)); + Assert.False(string.IsNullOrWhiteSpace(p.Nombre)); + } + } + + // ── GetByCodigoAsync ───────────────────────────────────────────────────── + + [Fact] + public async Task GetByCodigoAsync_ExistingCodigo_ReturnsPermiso() + { + var permiso = await _repository.GetByCodigoAsync("ventas:contado:crear"); + + Assert.NotNull(permiso); + Assert.Equal("ventas:contado:crear", permiso!.Codigo); + Assert.False(string.IsNullOrWhiteSpace(permiso.Nombre)); + } + + [Fact] + public async Task GetByCodigoAsync_AnotherExistingCodigo_ReturnsCorrectPermiso() + { + var permiso = await _repository.GetByCodigoAsync("administracion:usuarios:gestionar"); + + Assert.NotNull(permiso); + Assert.Equal("administracion:usuarios:gestionar", permiso!.Codigo); + } + + [Fact] + public async Task GetByCodigoAsync_NonExistentCodigo_ReturnsNull() + { + var permiso = await _repository.GetByCodigoAsync("permiso:inexistente:xyz"); + + Assert.Null(permiso); + } + + // ── GetByCodigosAsync ──────────────────────────────────────────────────── + + [Fact] + public async Task GetByCodigosAsync_ThreeValidCodigos_ReturnsThreeEntities() + { + var codigos = new[] + { + "ventas:contado:crear", + "ventas:contado:facturar", + "textos:editar" + }; + + var result = await _repository.GetByCodigosAsync(codigos); + + Assert.Equal(3, result.Count); + var returnedCodigos = result.Select(p => p.Codigo).ToHashSet(); + foreach (var c in codigos) + Assert.Contains(c, returnedCodigos); + } + + [Fact] + public async Task GetByCodigosAsync_MixedExistingAndNonExisting_ReturnsOnlyExisting() + { + var codigos = new[] + { + "ventas:contado:crear", + "permiso:no:existe", + "textos:editar" + }; + + var result = await _repository.GetByCodigosAsync(codigos); + + // Only 2 of 3 exist + Assert.Equal(2, result.Count); + var returnedCodigos = result.Select(p => p.Codigo).ToHashSet(); + Assert.Contains("ventas:contado:crear", returnedCodigos); + Assert.Contains("textos:editar", returnedCodigos); + } + + [Fact] + public async Task GetByCodigosAsync_EmptyList_ReturnsEmpty() + { + var result = await _repository.GetByCodigosAsync(Array.Empty()); + + Assert.Empty(result); + } + + [Fact] + public async Task GetByCodigosAsync_AllNonExisting_ReturnsEmpty() + { + var codigos = new[] { "no:existe:uno", "no:existe:dos" }; + + var result = await _repository.GetByCodigosAsync(codigos); + + Assert.Empty(result); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs new file mode 100644 index 0000000..f38e405 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -0,0 +1,315 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Integration; + +/// +/// Integration tests for RolPermisoRepository against SIGCM2_Test. +/// RED: written before the repository implementation exists. +/// +[Collection("Database")] +public class RolPermisoRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private RolPermisoRepository _repository = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + + // Ensure the 18 canonical permisos exist — idempotent MERGE. + // Other test classes call Respawn.ResetAsync which may have cleared Permiso. + await SeedPermisosCanonicalAsync(); + + // Ensure canonical RolPermiso seeds are present. + await SeedRolPermisosCanonicalAsync(); + + // Restore RolPermiso seeds for 'cajero' (4 permisos) in case prior test modified them. + await RestoreCajeroPermisosAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new RolPermisoRepository(factory); + } + + private async Task SeedPermisosCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Permiso AS t + USING (VALUES + ('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'), + ('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'), + ('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'), + ('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'), + ('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'), + ('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'), + ('textos:editar', N'Editar textos', NULL, 'textos'), + ('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'), + ('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'), + ('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'), + ('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'), + ('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'), + ('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'), + ('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'), + ('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'), + ('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'), + ('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'), + ('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion') + ) AS s (Codigo, Nombre, Descripcion, Modulo) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Modulo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo); + """; + await _connection.ExecuteAsync(sql); + } + + private async Task SeedRolPermisosCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.RolPermiso AS t + USING ( + SELECT r.Id AS RolId, p.Id AS PermisoId + FROM (VALUES + ('admin', 'ventas:contado:crear'), + ('admin', 'ventas:contado:modificar'), + ('admin', 'ventas:contado:cobrar'), + ('admin', 'ventas:contado:facturar'), + ('admin', 'ventas:ctacte:crear'), + ('admin', 'ventas:ctacte:facturar'), + ('admin', 'textos:editar'), + ('admin', 'textos:reclamos:ver'), + ('admin', 'pauta:azanu:ver'), + ('admin', 'pauta:limpiar'), + ('admin', 'pauta:recursos:fueradehora'), + ('admin', 'productores:deuda:ver'), + ('admin', 'productores:pendientes:crear'), + ('admin', 'productores:deuda:bypass'), + ('admin', 'administracion:usuarios:gestionar'), + ('admin', 'administracion:tarifarios:gestionar'), + ('admin', 'administracion:medios:gestionar'), + ('admin', 'administracion:auditoria:ver'), + ('cajero', 'ventas:contado:crear'), + ('cajero', 'ventas:contado:modificar'), + ('cajero', 'ventas:contado:cobrar'), + ('cajero', 'ventas:contado:facturar'), + ('operador_ctacte', 'ventas:ctacte:crear'), + ('operador_ctacte', 'ventas:ctacte:facturar'), + ('picadora', 'textos:editar'), + ('picadora', 'textos:reclamos:ver'), + ('jefe_publicidad', 'textos:editar'), + ('jefe_publicidad', 'textos:reclamos:ver'), + ('jefe_publicidad', 'pauta:azanu:ver'), + ('jefe_publicidad', 'pauta:limpiar'), + ('jefe_publicidad', 'pauta:recursos:fueradehora'), + ('jefe_publicidad', 'productores:deuda:ver'), + ('jefe_publicidad', 'productores:deuda:bypass'), + ('productor', 'productores:deuda:ver'), + ('productor', 'productores:pendientes:crear'), + ('diagramacion', 'pauta:azanu:ver') + ) AS x (RolCodigo, PermisoCodigo) + JOIN dbo.Rol r ON r.Codigo = x.RolCodigo + JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo + ) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId + WHEN NOT MATCHED BY TARGET THEN + INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId); + """; + await _connection.ExecuteAsync(sql); + } + + public async Task DisposeAsync() + { + // Restore cajero permisos so TablesToIgnore still reflects clean seed state. + await RestoreCajeroPermisosAsync(); + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + /// + /// Restores the 4 canonical cajero permisos to match V006 seed state. + /// Uses MERGE so it's idempotent. + /// + private async Task RestoreCajeroPermisosAsync() + { + // Delete any extra permisos assigned to cajero by tests + await _connection.ExecuteAsync(""" + DELETE rp FROM dbo.RolPermiso rp + JOIN dbo.Rol r ON r.Id = rp.RolId + JOIN dbo.Permiso p ON p.Id = rp.PermisoId + WHERE r.Codigo = 'cajero' + AND p.Codigo NOT IN ( + 'ventas:contado:crear', + 'ventas:contado:modificar', + 'ventas:contado:cobrar', + 'ventas:contado:facturar' + ); + """); + + // Re-add the 4 canonical cajero permisos if missing + await _connection.ExecuteAsync(""" + SET QUOTED_IDENTIFIER ON; + MERGE dbo.RolPermiso AS t + USING ( + SELECT r.Id AS RolId, p.Id AS PermisoId + FROM (VALUES + ('cajero', 'ventas:contado:crear'), + ('cajero', 'ventas:contado:modificar'), + ('cajero', 'ventas:contado:cobrar'), + ('cajero', 'ventas:contado:facturar') + ) AS x (RolCodigo, PermisoCodigo) + JOIN dbo.Rol r ON r.Codigo = x.RolCodigo + JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo + ) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId + WHEN NOT MATCHED BY TARGET THEN + INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId); + """); + } + + // ── GetByRolCodigoAsync ────────────────────────────────────────────────── + + [Fact] + public async Task GetByRolCodigoAsync_Admin_Returns18Permisos() + { + // admin has all 18 permisos assigned in V006 seed + var permisos = await _repository.GetByRolCodigoAsync("admin"); + + Assert.Equal(18, permisos.Count); + } + + [Fact] + public async Task GetByRolCodigoAsync_Admin_ContainsAllModules() + { + var permisos = await _repository.GetByRolCodigoAsync("admin"); + var codigos = permisos.Select(p => p.Codigo).ToHashSet(); + + Assert.Contains("ventas:contado:crear", codigos); + Assert.Contains("administracion:auditoria:ver", codigos); + Assert.Contains("pauta:limpiar", codigos); + Assert.Contains("productores:deuda:bypass", codigos); + } + + [Fact] + public async Task GetByRolCodigoAsync_Cajero_Returns4Permisos() + { + // cajero: ventas:contado:crear, :modificar, :cobrar, :facturar + var permisos = await _repository.GetByRolCodigoAsync("cajero"); + + Assert.Equal(4, permisos.Count); + } + + [Fact] + public async Task GetByRolCodigoAsync_Cajero_OnlyVentasContadoPermisos() + { + var permisos = await _repository.GetByRolCodigoAsync("cajero"); + var codigos = permisos.Select(p => p.Codigo).ToHashSet(); + + Assert.Contains("ventas:contado:crear", codigos); + Assert.Contains("ventas:contado:modificar", codigos); + Assert.Contains("ventas:contado:cobrar", codigos); + Assert.Contains("ventas:contado:facturar", codigos); + } + + [Fact] + public async Task GetByRolCodigoAsync_Reportes_ReturnsEmpty() + { + // 'reportes' rol has 0 permisos in V006 seed + var permisos = await _repository.GetByRolCodigoAsync("reportes"); + + Assert.Empty(permisos); + } + + [Fact] + public async Task GetByRolCodigoAsync_NonExistentRol_ReturnsEmpty() + { + // Unknown rol código — returns empty list, not an exception + var permisos = await _repository.GetByRolCodigoAsync("rol_inexistente_xyz"); + + Assert.Empty(permisos); + } + + [Fact] + public async Task GetByRolCodigoAsync_ReturnsFullPermisoEntities() + { + var permisos = await _repository.GetByRolCodigoAsync("cajero"); + + var primero = permisos.First(); + // All entity fields must be populated + Assert.True(primero.Id > 0); + Assert.False(string.IsNullOrWhiteSpace(primero.Codigo)); + Assert.False(string.IsNullOrWhiteSpace(primero.Nombre)); + } + + // ── ReplaceForRolAsync ─────────────────────────────────────────────────── + + [Fact] + public async Task ReplaceForRolAsync_ReplacesExistingSetWithNewSet() + { + // Get cajero's rol ID and a different permiso ID + var cajeroId = await _connection.ExecuteScalarAsync( + "SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'"); + var textoPermisoId = await _connection.ExecuteScalarAsync( + "SELECT Id FROM dbo.Permiso WHERE Codigo = 'textos:editar'"); + + // Replace cajero's 4 permisos with just 1 + await _repository.ReplaceForRolAsync(cajeroId, new[] { textoPermisoId }); + + var permisos = await _repository.GetByRolCodigoAsync("cajero"); + Assert.Single(permisos); + Assert.Equal("textos:editar", permisos[0].Codigo); + } + + [Fact] + public async Task ReplaceForRolAsync_Idempotent_SameCallTwiceProducesSameResult() + { + var cajeroId = await _connection.ExecuteScalarAsync( + "SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'"); + var permisoId = await _connection.ExecuteScalarAsync( + "SELECT Id FROM dbo.Permiso WHERE Codigo = 'ventas:contado:crear'"); + + await _repository.ReplaceForRolAsync(cajeroId, new[] { permisoId }); + await _repository.ReplaceForRolAsync(cajeroId, new[] { permisoId }); + + var permisos = await _repository.GetByRolCodigoAsync("cajero"); + Assert.Single(permisos); + Assert.Equal("ventas:contado:crear", permisos[0].Codigo); + } + + [Fact] + public async Task ReplaceForRolAsync_WithEmptyList_DeletesAllPermisos() + { + var cajeroId = await _connection.ExecuteScalarAsync( + "SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'"); + + await _repository.ReplaceForRolAsync(cajeroId, Array.Empty()); + + var permisos = await _repository.GetByRolCodigoAsync("cajero"); + Assert.Empty(permisos); + } + + [Fact] + public async Task ReplaceForRolAsync_PostReplace_GetReflectsNewSet() + { + var cajeroId = await _connection.ExecuteScalarAsync( + "SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'"); + + // Get IDs of 2 specific permisos + var rows = await _connection.QueryAsync<(int Id, string Codigo)>( + "SELECT Id, Codigo FROM dbo.Permiso WHERE Codigo IN ('pauta:azanu:ver', 'pauta:limpiar')"); + var ids = rows.Select(r => r.Id).ToArray(); + + await _repository.ReplaceForRolAsync(cajeroId, ids); + + var permisos = await _repository.GetByRolCodigoAsync("cajero"); + var codigos = permisos.Select(p => p.Codigo).ToHashSet(); + + Assert.Equal(2, permisos.Count); + Assert.Contains("pauta:azanu:ver", codigos); + Assert.Contains("pauta:limpiar", codigos); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs index 33e55f7..27b34e9 100644 --- a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs @@ -23,7 +23,12 @@ public class UsuarioRepositoryTests : IAsyncLifetime { 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")] + TablesToIgnore = + [ + new Respawn.Graph.Table("dbo", "Rol"), + new Respawn.Graph.Table("dbo", "Permiso"), + new Respawn.Graph.Table("dbo", "RolPermiso"), + ] }); // Reset DB, re-seed Rol canonical table (lookup) and admin user for each test class run. diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 2eeddd2..dc8b062 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -46,6 +46,8 @@ public sealed class SqlTestFixture : IAsyncLifetime { await _respawner.ResetAsync(_connection); await SeedRolCanonicalAsync(); + await SeedPermisosCanonicalAsync(); + await SeedRolPermisosCanonicalAsync(); await SeedAdminAsync(); } @@ -81,6 +83,93 @@ public sealed class SqlTestFixture : IAsyncLifetime } } + private async Task SeedPermisosCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Permiso AS t + USING (VALUES + ('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'), + ('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'), + ('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'), + ('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'), + ('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'), + ('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'), + ('textos:editar', N'Editar textos', NULL, 'textos'), + ('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'), + ('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'), + ('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'), + ('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'), + ('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'), + ('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'), + ('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'), + ('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'), + ('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'), + ('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'), + ('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion') + ) AS s (Codigo, Nombre, Descripcion, Modulo) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Modulo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo); + """; + await _connection.ExecuteAsync(sql); + } + + private async Task SeedRolPermisosCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.RolPermiso AS t + USING ( + SELECT r.Id AS RolId, p.Id AS PermisoId + FROM (VALUES + ('admin', 'ventas:contado:crear'), + ('admin', 'ventas:contado:modificar'), + ('admin', 'ventas:contado:cobrar'), + ('admin', 'ventas:contado:facturar'), + ('admin', 'ventas:ctacte:crear'), + ('admin', 'ventas:ctacte:facturar'), + ('admin', 'textos:editar'), + ('admin', 'textos:reclamos:ver'), + ('admin', 'pauta:azanu:ver'), + ('admin', 'pauta:limpiar'), + ('admin', 'pauta:recursos:fueradehora'), + ('admin', 'productores:deuda:ver'), + ('admin', 'productores:pendientes:crear'), + ('admin', 'productores:deuda:bypass'), + ('admin', 'administracion:usuarios:gestionar'), + ('admin', 'administracion:tarifarios:gestionar'), + ('admin', 'administracion:medios:gestionar'), + ('admin', 'administracion:auditoria:ver'), + ('cajero', 'ventas:contado:crear'), + ('cajero', 'ventas:contado:modificar'), + ('cajero', 'ventas:contado:cobrar'), + ('cajero', 'ventas:contado:facturar'), + ('operador_ctacte', 'ventas:ctacte:crear'), + ('operador_ctacte', 'ventas:ctacte:facturar'), + ('picadora', 'textos:editar'), + ('picadora', 'textos:reclamos:ver'), + ('jefe_publicidad', 'textos:editar'), + ('jefe_publicidad', 'textos:reclamos:ver'), + ('jefe_publicidad', 'pauta:azanu:ver'), + ('jefe_publicidad', 'pauta:limpiar'), + ('jefe_publicidad', 'pauta:recursos:fueradehora'), + ('jefe_publicidad', 'productores:deuda:ver'), + ('jefe_publicidad', 'productores:deuda:bypass'), + ('productor', 'productores:deuda:ver'), + ('productor', 'productores:pendientes:crear'), + ('diagramacion', 'pauta:azanu:ver') + ) AS x (RolCodigo, PermisoCodigo) + JOIN dbo.Rol r ON r.Codigo = x.RolCodigo + JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo + ) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId + WHEN NOT MATCHED BY TARGET THEN + INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId); + """; + await _connection.ExecuteAsync(sql); + } + private async Task SeedAdminAsync() { const string sql = """