diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs index bd1d554..31d10ae 100644 --- a/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IUsuarioRepository.cs @@ -17,4 +17,7 @@ public interface IUsuarioRepository Task UpdateAsync(int id, UpdateUsuarioFields fields, DateTime fechaModificacion, CancellationToken ct = default); Task UpdatePasswordAsync(int id, string passwordHash, bool mustChangePassword, CancellationToken ct = default); Task CountActiveAdminsAsync(CancellationToken ct = default); + + // UDT-009 + Task UpdatePermisosJsonAsync(int id, string permisosJson, DateTime fechaModificacion, CancellationToken ct = default); } diff --git a/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs index 263aa20..503b2e6 100644 --- a/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs +++ b/src/api/SIGCM2.Infrastructure/Persistence/UsuarioRepository.cs @@ -226,6 +226,27 @@ public sealed class UsuarioRepository : IUsuarioRepository return await connection.ExecuteScalarAsync(sql); } + // UDT-009 ───────────────────────────────────────────────────────────────── + + public async Task UpdatePermisosJsonAsync(int id, string permisosJson, DateTime fechaModificacion, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Usuario + SET PermisosJson = @PermisosJson, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + await connection.ExecuteAsync(sql, new + { + PermisosJson = permisosJson, + FechaModificacion = fechaModificacion, + Id = id + }); + } + // ── mapping ─────────────────────────────────────────────────────────────── private static Usuario MapRow(UsuarioRow row) diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs new file mode 100644 index 0000000..6ffe69a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs @@ -0,0 +1,131 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Respawn; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Integration; + +/// +/// Integration tests for IUsuarioRepository.UpdatePermisosJsonAsync (UDT-009). +/// Uses SIGCM2_Test database directly. +/// +[Collection("Database")] +public sealed class UsuarioRepository_PermisosTests : 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, + TablesToIgnore = + [ + new Respawn.Graph.Table("dbo", "Rol"), + new Respawn.Graph.Table("dbo", "Permiso"), + new Respawn.Graph.Table("dbo", "RolPermiso"), + ] + }); + + await _respawner.ResetAsync(_connection); + await SeedRolCanonicalAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new UsuarioRepository(factory); + + // Seed a test user + await _connection.ExecuteAsync(""" + INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) + VALUES ('testuser', '$2a$12$hash', 'Test', 'User', 'cajero', '{"grant":[],"deny":[]}', 1, 0) + """); + } + + public async Task DisposeAsync() + { + if (_connection is not null) + { + await _respawner.ResetAsync(_connection); + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + } + + // UPJ-01: UpdatePermisosJsonAsync persists PermisosJson and FechaModificacion + [Fact] + public async Task UpdatePermisosJsonAsync_PersistsJsonAndFechaModificacion() + { + // Arrange + var userId = await _connection.QuerySingleAsync( + "SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'"); + var newJson = """{"grant":["textos:editar"],"deny":[]}"""; + var fechaMod = DateTime.UtcNow; + + // Act + await _repository.UpdatePermisosJsonAsync(userId, newJson, fechaMod); + + // Assert + var row = await _connection.QuerySingleAsync<(string PermisosJson, DateTime? FechaModificacion)>( + "SELECT PermisosJson, FechaModificacion FROM dbo.Usuario WHERE Id = @Id", + new { Id = userId }); + + Assert.Equal(newJson, row.PermisosJson); + Assert.NotNull(row.FechaModificacion); + // Allow 2-second tolerance for DB round-trip + Assert.True( + Math.Abs((row.FechaModificacion!.Value - fechaMod).TotalSeconds) < 2, + $"FechaModificacion {row.FechaModificacion} is too far from {fechaMod}"); + } + + // UPJ-02: UpdatePermisosJsonAsync with non-existent id → no throw (UPDATE affects 0 rows) + [Fact] + public async Task UpdatePermisosJsonAsync_NonExistentId_NoThrow() + { + // Should not throw — UPDATE with 0 rows affected is a no-op + await _repository.UpdatePermisosJsonAsync(99999, """{"grant":[],"deny":[]}""", DateTime.UtcNow); + } + + // UPJ-03: GetByIdAsync after update reflects new PermisosJson + [Fact] + public async Task UpdatePermisosJsonAsync_GetByIdReflectsChange() + { + // Arrange + var userId = await _connection.QuerySingleAsync( + "SELECT Id FROM dbo.Usuario WHERE Username = 'testuser'"); + var newJson = """{"grant":["pauta:azanu:ver"],"deny":["ventas:contado:cobrar"]}"""; + + // Act + await _repository.UpdatePermisosJsonAsync(userId, newJson, DateTime.UtcNow); + + // Assert — read back through the repo + var usuario = await _repository.GetByIdAsync(userId); + + Assert.NotNull(usuario); + Assert.Equal(newJson, usuario!.PermisosJson); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private async Task SeedRolCanonicalAsync() + { + await _connection.ExecuteAsync(""" + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Rol AS t + USING (VALUES + ('admin', N'Administrador', N'Supervisor total'), + ('cajero', N'Cajero', N'Mostrador contado') + ) 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); + """); + } +}