ADM-001: Medios y Secciones (fundacional) #15

Merged
dmolinari merged 9 commits from feature/ADM-001 into main 2026-04-17 14:37:15 +00:00
5 changed files with 905 additions and 0 deletions
Showing only changes of commit 2f0da2d720 - Show all commits

View File

@@ -32,6 +32,8 @@ public static class DependencyInjection
services.AddScoped<IRolRepository, RolRepository>();
services.AddScoped<IPermisoRepository, PermisoRepository>();
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
services.AddScoped<IMedioRepository, MedioRepository>();
services.AddScoped<ISeccionRepository, SeccionRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -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<int> 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<int>(sql, new
{
m.Codigo,
m.Nombre,
Tipo = (int)m.Tipo,
m.PlataformaEmpresaId,
});
}
public async Task<Medio?> 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<MedioRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<bool> 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<int>(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<PagedResult<Medio>> 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<MedioPagedRow>(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<Medio>(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);
}

View File

@@ -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<int> 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<int>(sql, new
{
s.MedioId,
s.Codigo,
s.Nombre,
s.Tipo,
});
}
public async Task<Seccion?> 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<SeccionRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<bool> 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<int>(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<PagedResult<Seccion>> 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<SeccionPagedRow>(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<Seccion>(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);
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<int>(
"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);
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<Microsoft.Data.SqlClient.SqlException>(
() => _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<int>(
"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);
}
}