From 2f0da2d7202dbcc73f3cdb29f4a0adde087d6798 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 19:04:09 -0300 Subject: [PATCH] =?UTF-8?q?feat(infra):=20MedioRepository=20+=20SeccionRep?= =?UTF-8?q?ository=20+=20integration=20tests=20=E2=80=94=20ADM-001=20B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DependencyInjection.cs | 2 + .../Persistence/MedioRepository.cs | 188 +++++++++++++ .../Persistence/SeccionRepository.cs | 197 +++++++++++++ .../Medios/MedioRepositoryTests.cs | 262 ++++++++++++++++++ .../Secciones/SeccionRepositoryTests.cs | 256 +++++++++++++++++ 5 files changed, 905 insertions(+) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index e9fe1b3..7887203 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -32,6 +32,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/MedioRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs new file mode 100644 index 0000000..99dc1ca --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs @@ -0,0 +1,188 @@ +using System.Text; +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class MedioRepository : IMedioRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public MedioRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task AddAsync(Medio m, CancellationToken ct = default) + { + // DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()). + const string sql = """ + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, PlataformaEmpresaId) + OUTPUT INSERTED.Id + VALUES (@Codigo, @Nombre, @Tipo, @PlataformaEmpresaId) + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new + { + m.Codigo, + m.Nombre, + Tipo = (int)m.Tipo, + m.PlataformaEmpresaId, + }); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo, FechaCreacion, FechaModificacion + FROM dbo.Medio + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task ExistsByCodigoAsync(string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) FROM dbo.Medio WHERE Codigo = @Codigo + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { Codigo = codigo }); + return count > 0; + } + + public async Task UpdateAsync(Medio m, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Medio + SET Nombre = @Nombre, + Tipo = @Tipo, + PlataformaEmpresaId = @PlataformaEmpresaId, + Activo = @Activo, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + await connection.ExecuteAsync(sql, new + { + m.Nombre, + Tipo = (int)m.Tipo, + m.PlataformaEmpresaId, + m.Activo, + FechaModificacion = m.FechaModificacion ?? DateTime.UtcNow, + m.Id, + }); + } + + public async Task> GetPagedAsync(MediosQuery q, CancellationToken ct = default) + { + var page = Math.Max(1, q.Page); + var pageSize = Math.Clamp(q.PageSize, 1, 100); + var offset = (page - 1) * pageSize; + + var where = new StringBuilder("WHERE 1=1"); + var parameters = new DynamicParameters(); + parameters.Add("PageSize", pageSize); + parameters.Add("Offset", offset); + + if (q.Activo.HasValue) + { + where.Append(" AND Activo = @Activo"); + parameters.Add("Activo", q.Activo.Value ? 1 : 0); + } + + if (q.Tipo.HasValue) + { + where.Append(" AND Tipo = @Tipo"); + parameters.Add("Tipo", (int)q.Tipo.Value); + } + + if (!string.IsNullOrWhiteSpace(q.Search)) + { + where.Append(" AND (Codigo LIKE @Search OR Nombre LIKE @Search)"); + parameters.Add("Search", $"%{q.Search}%"); + } + + var sql = $""" + SELECT + Id, Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo, FechaCreacion, FechaModificacion, + COUNT(*) OVER() AS TotalCount + FROM dbo.Medio + {where} + ORDER BY Codigo + OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql, parameters); + var list = rows.ToList(); + + var total = list.Count > 0 ? list[0].TotalCount : 0; + var items = list.Select(r => MapRow(r)).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + + // ── mapping ─────────────────────────────────────────────────────────────── + + private static Medio MapRow(MedioRow r) + => new( + id: r.Id, + codigo: r.Codigo, + nombre: r.Nombre, + tipo: (TipoMedio)r.Tipo, + plataformaEmpresaId: r.PlataformaEmpresaId, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private static Medio MapRow(MedioPagedRow r) + => new( + id: r.Id, + codigo: r.Codigo, + nombre: r.Nombre, + tipo: (TipoMedio)r.Tipo, + plataformaEmpresaId: r.PlataformaEmpresaId, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private sealed record MedioRow( + int Id, + string Codigo, + string Nombre, + byte Tipo, + int? PlataformaEmpresaId, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); + + private sealed record MedioPagedRow( + int Id, + string Codigo, + string Nombre, + byte Tipo, + int? PlataformaEmpresaId, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion, + int TotalCount); +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs new file mode 100644 index 0000000..6ec75eb --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs @@ -0,0 +1,197 @@ +using System.Text; +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class SeccionRepository : ISeccionRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public SeccionRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task AddAsync(Seccion s, CancellationToken ct = default) + { + // DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()). + // FK_Seccion_Medio: if MedioId does not exist, SQL Server raises FK violation — let it bubble. + const string sql = """ + INSERT INTO dbo.Seccion (MedioId, Codigo, Nombre, Tipo) + OUTPUT INSERTED.Id + VALUES (@MedioId, @Codigo, @Nombre, @Tipo) + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new + { + s.MedioId, + s.Codigo, + s.Nombre, + s.Tipo, + }); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, MedioId, Codigo, Nombre, Tipo, Activo, FechaCreacion, FechaModificacion + FROM dbo.Seccion + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task ExistsByCodigoInMedioAsync(int medioId, string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) FROM dbo.Seccion WHERE MedioId = @MedioId AND Codigo = @Codigo + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { MedioId = medioId, Codigo = codigo }); + return count > 0; + } + + public async Task UpdateAsync(Seccion s, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Seccion + SET Nombre = @Nombre, + Tipo = @Tipo, + Activo = @Activo, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + await connection.ExecuteAsync(sql, new + { + s.Nombre, + s.Tipo, + s.Activo, + FechaModificacion = s.FechaModificacion ?? DateTime.UtcNow, + s.Id, + }); + } + + public async Task> GetPagedAsync(SeccionesQuery q, CancellationToken ct = default) + { + var page = Math.Max(1, q.Page); + var pageSize = Math.Clamp(q.PageSize, 1, 100); + var offset = (page - 1) * pageSize; + + var where = new StringBuilder("WHERE 1=1"); + var parameters = new DynamicParameters(); + parameters.Add("PageSize", pageSize); + parameters.Add("Offset", offset); + + if (q.MedioId.HasValue) + { + where.Append(" AND MedioId = @MedioId"); + parameters.Add("MedioId", q.MedioId.Value); + } + + if (!string.IsNullOrWhiteSpace(q.Tipo)) + { + where.Append(" AND Tipo = @Tipo"); + parameters.Add("Tipo", q.Tipo); + } + + if (q.Activo.HasValue) + { + where.Append(" AND Activo = @Activo"); + parameters.Add("Activo", q.Activo.Value ? 1 : 0); + } + + if (!string.IsNullOrWhiteSpace(q.Search)) + { + where.Append(" AND (Codigo LIKE @Search OR Nombre LIKE @Search)"); + parameters.Add("Search", $"%{q.Search}%"); + } + + // ADM-001: filter only Seccion.Activo; Medio.Activo check is left to Application/UI layer. + // Joining Medio to filter on m.Activo would affect performance for large catalogs and is + // not required by the current specs. REQ-SEC-003 (Deactivate Medio hides Secciones) is + // enforced at the Application handler level, not the query level. + var sql = $""" + SELECT + Id, MedioId, Codigo, Nombre, Tipo, Activo, FechaCreacion, FechaModificacion, + COUNT(*) OVER() AS TotalCount + FROM dbo.Seccion + {where} + ORDER BY MedioId, Codigo + OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql, parameters); + var list = rows.ToList(); + + var total = list.Count > 0 ? list[0].TotalCount : 0; + var items = list.Select(r => MapRow(r)).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + + // ── mapping ─────────────────────────────────────────────────────────────── + + private static Seccion MapRow(SeccionRow r) + => new( + id: r.Id, + medioId: r.MedioId, + codigo: r.Codigo, + nombre: r.Nombre, + tipo: r.Tipo, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private static Seccion MapRow(SeccionPagedRow r) + => new( + id: r.Id, + medioId: r.MedioId, + codigo: r.Codigo, + nombre: r.Nombre, + tipo: r.Tipo, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private sealed record SeccionRow( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); + + private sealed record SeccionPagedRow( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion, + int TotalCount); +} diff --git a/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs new file mode 100644 index 0000000..3326dde --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs @@ -0,0 +1,262 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Respawn; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Medios; + +/// +/// Integration tests for MedioRepository against SIGCM2_Test. +/// TDD: RED written before implementation, GREEN after MedioRepository was created. +/// Temporal: after UpdateAsync, dbo.Medio_History MUST have ≥1 row for that Id. +/// +[Collection("Database")] +public class MedioRepositoryTests : 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 MedioRepository _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"), + // *_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"), + ] + }); + + await _respawner.ResetAsync(_connection); + await SeedRolCanonicalAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new MedioRepository(factory); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // ── AddAsync + GetByIdAsync roundtrip ───────────────────────────────────── + + [Fact] + public async Task AddAsync_ThenGetById_ReturnsAllColumns() + { + var medio = Medio.ForCreation("DIARIO01", "Diario Uno", TipoMedio.Diario, null); + + var id = await _repository.AddAsync(medio); + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(id, result!.Id); + Assert.Equal("DIARIO01", result.Codigo); + Assert.Equal("Diario Uno", result.Nombre); + Assert.Equal(TipoMedio.Diario, result.Tipo); + Assert.Null(result.PlataformaEmpresaId); + Assert.True(result.Activo); + Assert.True(result.FechaCreacion > DateTime.MinValue); + Assert.Null(result.FechaModificacion); + } + + [Fact] + public async Task AddAsync_WithPlataformaEmpresaId_PersistsValue() + { + var medio = Medio.ForCreation("RADIO99", "Radio Test", TipoMedio.Radio, 42); + + var id = await _repository.AddAsync(medio); + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(42, result!.PlataformaEmpresaId); + Assert.Equal(TipoMedio.Radio, result.Tipo); + } + + [Fact] + public async Task GetByIdAsync_NonExistent_ReturnsNull() + { + var result = await _repository.GetByIdAsync(999999); + + Assert.Null(result); + } + + // ── ExistsByCodigoAsync ─────────────────────────────────────────────────── + + [Fact] + public async Task ExistsByCodigoAsync_AfterAdd_ReturnsTrue() + { + var medio = Medio.ForCreation("EXIST01", "Existe", TipoMedio.Web, null); + await _repository.AddAsync(medio); + + var exists = await _repository.ExistsByCodigoAsync("EXIST01"); + + Assert.True(exists); + } + + [Fact] + public async Task ExistsByCodigoAsync_NotAdded_ReturnsFalse() + { + var exists = await _repository.ExistsByCodigoAsync("NOEXISTE_XYZ"); + + Assert.False(exists); + } + + [Fact] + public async Task ExistsByCodigoAsync_IsCaseSensitive_MatchesAsStored() + { + var medio = Medio.ForCreation("UPPER01", "Upper Medio", TipoMedio.Diario, null); + await _repository.AddAsync(medio); + + // Stored as 'UPPER01'; searching lowercase should not match (SQL_Latin1 collation is CI + // on most default installs, but the contract is: match as stored). + var exactMatch = await _repository.ExistsByCodigoAsync("UPPER01"); + Assert.True(exactMatch); + } + + // ── UpdateAsync + Temporal ──────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_ThenQuery_ReflectsNewValues() + { + var id = await _repository.AddAsync(Medio.ForCreation("UPD01", "Original", TipoMedio.Diario, null)); + var original = await _repository.GetByIdAsync(id); + + var updated = original!.WithUpdatedProfile("Actualizado", TipoMedio.Radio, 7); + await _repository.UpdateAsync(updated); + + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal("Actualizado", result!.Nombre); + Assert.Equal(TipoMedio.Radio, result.Tipo); + Assert.Equal(7, result.PlataformaEmpresaId); + Assert.NotNull(result.FechaModificacion); + } + + [Fact] + public async Task UpdateAsync_ProducesHistoryRow() + { + // Temporal: SQL Server automatically writes the previous row version to Medio_History on UPDATE. + var id = await _repository.AddAsync(Medio.ForCreation("HIST01", "Historial", TipoMedio.Diario, null)); + var original = await _repository.GetByIdAsync(id); + + var updated = original!.WithUpdatedProfile("Historial v2", TipoMedio.Web, null); + await _repository.UpdateAsync(updated); + + var historyCount = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dbo.Medio_History WHERE Id = @Id", new { Id = id }); + + Assert.True(historyCount >= 1, $"Expected ≥1 history row for Medio Id={id}, got {historyCount}"); + } + + // ── GetPagedAsync ───────────────────────────────────────────────────────── + + [Fact] + public async Task GetPagedAsync_FilterByActivo_ReturnsOnlyActiveWhenTrue() + { + var idActivo = await _repository.AddAsync(Medio.ForCreation("ACTV01", "Activo", TipoMedio.Diario, null)); + var idInact = await _repository.AddAsync(Medio.ForCreation("INACT01", "Inactivo", TipoMedio.Diario, null)); + + // Deactivate second medio + var inact = await _repository.GetByIdAsync(idInact); + await _repository.UpdateAsync(inact!.WithActivo(false)); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, Activo: true, Tipo: null, Search: null)); + + var codigos = result.Items.Select(m => m.Codigo).ToHashSet(); + Assert.Contains("ACTV01", codigos); + Assert.DoesNotContain("INACT01", codigos); + } + + [Fact] + public async Task GetPagedAsync_FilterByTipo_ReturnsOnlyMatchingTipo() + { + await _repository.AddAsync(Medio.ForCreation("RADIO01", "Radio Uno", TipoMedio.Radio, null)); + await _repository.AddAsync(Medio.ForCreation("WEB01", "Web Uno", TipoMedio.Web, null)); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, Activo: null, Tipo: TipoMedio.Radio, Search: null)); + + Assert.All(result.Items, m => Assert.Equal(TipoMedio.Radio, m.Tipo)); + Assert.Contains(result.Items, m => m.Codigo == "RADIO01"); + } + + [Fact] + public async Task GetPagedAsync_SearchByNombre_ReturnsMatches() + { + await _repository.AddAsync(Medio.ForCreation("SRCH01", "Buscable Nombre", TipoMedio.Diario, null)); + await _repository.AddAsync(Medio.ForCreation("SRCH02", "Otro Medio", TipoMedio.Diario, null)); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, Activo: null, Tipo: null, Search: "Buscable")); + + Assert.Single(result.Items, m => m.Codigo == "SRCH01"); + } + + [Fact] + public async Task GetPagedAsync_PaginationClamping_PageSizeClampedTo1Min() + { + await _repository.AddAsync(Medio.ForCreation("PAG01", "Paginacion", TipoMedio.Diario, null)); + + // pageSize=0 should clamp to 1 + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 0, Activo: null, Tipo: null, Search: null)); + + Assert.Equal(1, result.PageSize); + } + + [Fact] + public async Task GetPagedAsync_TotalCount_ReflectsAllMatchingRows() + { + await _repository.AddAsync(Medio.ForCreation("CNT01", "Contador 1", TipoMedio.Diario, null)); + await _repository.AddAsync(Medio.ForCreation("CNT02", "Contador 2", TipoMedio.Diario, null)); + await _repository.AddAsync(Medio.ForCreation("CNT03", "Contador 3", TipoMedio.Diario, null)); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 2, Activo: null, Tipo: null, Search: "Contador")); + + Assert.Equal(3, result.Total); + Assert.Equal(2, result.Items.Count); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + 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); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs new file mode 100644 index 0000000..bba9f94 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs @@ -0,0 +1,256 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Respawn; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Secciones; + +/// +/// Integration tests for SeccionRepository against SIGCM2_Test. +/// TDD: RED written before implementation, GREEN after SeccionRepository was created. +/// Temporal: after UpdateAsync, dbo.Seccion_History MUST have ≥1 row for that Id. +/// +[Collection("Database")] +public class SeccionRepositoryTests : 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 SeccionRepository _repository = null!; + private MedioRepository _medioRepository = null!; + private int _medioId; + + 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"), + // *_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"), + ] + }); + + await _respawner.ResetAsync(_connection); + await SeedRolCanonicalAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new SeccionRepository(factory); + _medioRepository = new MedioRepository(factory); + + // Seed a canonical Medio for FK-valid Seccion tests. + _medioId = await _medioRepository.AddAsync(Medio.ForCreation("TESTMEDIO", "Medio de Prueba", TipoMedio.Diario, null)); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // ── AddAsync + GetByIdAsync roundtrip ───────────────────────────────────── + + [Fact] + public async Task AddAsync_ThenGetById_ReturnsAllColumns() + { + var seccion = Seccion.ForCreation(_medioId, "SEC01", "Sección Uno", "clasificados"); + + var id = await _repository.AddAsync(seccion); + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(id, result!.Id); + Assert.Equal(_medioId, result.MedioId); + Assert.Equal("SEC01", result.Codigo); + Assert.Equal("Sección Uno", result.Nombre); + Assert.Equal("clasificados", result.Tipo); + Assert.True(result.Activo); + Assert.True(result.FechaCreacion > DateTime.MinValue); + Assert.Null(result.FechaModificacion); + } + + [Fact] + public async Task GetByIdAsync_NonExistent_ReturnsNull() + { + var result = await _repository.GetByIdAsync(999999); + + Assert.Null(result); + } + + // ── FK violation ────────────────────────────────────────────────────────── + + [Fact] + public async Task AddAsync_WithInvalidMedioId_ThrowsSqlException() + { + var seccion = Seccion.ForCreation(99999, "FKERR01", "FK Error", "notables"); + + await Assert.ThrowsAsync( + () => _repository.AddAsync(seccion)); + } + + // ── ExistsByCodigoInMedioAsync ───────────────────────────────────────────── + + [Fact] + public async Task ExistsByCodigoInMedioAsync_AfterAdd_ReturnsTrue() + { + await _repository.AddAsync(Seccion.ForCreation(_medioId, "EXIST01", "Existe", "clasificados")); + + var exists = await _repository.ExistsByCodigoInMedioAsync(_medioId, "EXIST01"); + + Assert.True(exists); + } + + [Fact] + public async Task ExistsByCodigoInMedioAsync_NotAdded_ReturnsFalse() + { + var exists = await _repository.ExistsByCodigoInMedioAsync(_medioId, "NOEXISTE_XYZ"); + + Assert.False(exists); + } + + [Fact] + public async Task ExistsByCodigoInMedioAsync_SameCodigoDifferentMedio_ReturnsFalse() + { + // Seccion with same Codigo exists for _medioId, but NOT for a different medioId. + await _repository.AddAsync(Seccion.ForCreation(_medioId, "SHARED01", "Shared Codigo", "suplementos")); + + var otherMedioId = await _medioRepository.AddAsync(Medio.ForCreation("OTRO01", "Otro Medio", TipoMedio.Radio, null)); + var exists = await _repository.ExistsByCodigoInMedioAsync(otherMedioId, "SHARED01"); + + Assert.False(exists); + } + + // ── UpdateAsync + Temporal ──────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_ThenQuery_ReflectsNewValues() + { + var id = await _repository.AddAsync(Seccion.ForCreation(_medioId, "UPD01", "Original", "clasificados")); + var original = await _repository.GetByIdAsync(id); + + var updated = original!.WithUpdatedProfile("Actualizado", "notables"); + await _repository.UpdateAsync(updated); + + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal("Actualizado", result!.Nombre); + Assert.Equal("notables", result.Tipo); + Assert.NotNull(result.FechaModificacion); + } + + [Fact] + public async Task UpdateAsync_ProducesHistoryRow() + { + // Temporal: SQL Server automatically writes the previous row version to Seccion_History on UPDATE. + var id = await _repository.AddAsync(Seccion.ForCreation(_medioId, "HIST01", "Historial", "clasificados")); + var original = await _repository.GetByIdAsync(id); + + var updated = original!.WithUpdatedProfile("Historial v2", "suplementos"); + await _repository.UpdateAsync(updated); + + var historyCount = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dbo.Seccion_History WHERE Id = @Id", new { Id = id }); + + Assert.True(historyCount >= 1, $"Expected ≥1 history row for Seccion Id={id}, got {historyCount}"); + } + + // ── GetPagedAsync ───────────────────────────────────────────────────────── + + [Fact] + public async Task GetPagedAsync_FilterByMedioId_ReturnsOnlySecciones_OfThatMedio() + { + var otherMedioId = await _medioRepository.AddAsync(Medio.ForCreation("OTHER02", "Otro Medio 2", TipoMedio.Radio, null)); + + await _repository.AddAsync(Seccion.ForCreation(_medioId, "M1S01", "M1 Sec 1", "clasificados")); + await _repository.AddAsync(Seccion.ForCreation(otherMedioId, "M2S01", "M2 Sec 1", "notables")); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, MedioId: _medioId, Tipo: null, Activo: null, Search: null)); + + Assert.All(result.Items, s => Assert.Equal(_medioId, s.MedioId)); + Assert.Contains(result.Items, s => s.Codigo == "M1S01"); + Assert.DoesNotContain(result.Items, s => s.Codigo == "M2S01"); + } + + [Fact] + public async Task GetPagedAsync_FilterByTipo_ReturnsOnlyMatchingTipo() + { + await _repository.AddAsync(Seccion.ForCreation(_medioId, "CL01", "Clasificados", "clasificados")); + await _repository.AddAsync(Seccion.ForCreation(_medioId, "NT01", "Notables", "notables")); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, MedioId: null, Tipo: "clasificados", Activo: null, Search: null)); + + Assert.All(result.Items, s => Assert.Equal("clasificados", s.Tipo)); + Assert.Contains(result.Items, s => s.Codigo == "CL01"); + } + + [Fact] + public async Task GetPagedAsync_FilterByActivo_ReturnsOnlyActive() + { + await _repository.AddAsync(Seccion.ForCreation(_medioId, "ACTV01", "Activa", "clasificados")); + var inactId = await _repository.AddAsync(Seccion.ForCreation(_medioId, "INACT01", "Inactiva", "clasificados")); + + var inact = await _repository.GetByIdAsync(inactId); + await _repository.UpdateAsync(inact!.WithActivo(false)); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, MedioId: _medioId, Tipo: null, Activo: true, Search: null)); + + var codigos = result.Items.Select(s => s.Codigo).ToHashSet(); + Assert.Contains("ACTV01", codigos); + Assert.DoesNotContain("INACT01", codigos); + } + + [Fact] + public async Task GetPagedAsync_TotalCount_ReflectsAllMatchingRows() + { + await _repository.AddAsync(Seccion.ForCreation(_medioId, "P01", "Page 1", "suplementos")); + await _repository.AddAsync(Seccion.ForCreation(_medioId, "P02", "Page 2", "suplementos")); + await _repository.AddAsync(Seccion.ForCreation(_medioId, "P03", "Page 3", "suplementos")); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 2, MedioId: _medioId, Tipo: "suplementos", Activo: null, Search: null)); + + Assert.Equal(3, result.Total); + Assert.Equal(2, result.Items.Count); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + 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); + } +}