From cc3108dfdbe72f204248b98d7f26a8d8ad133074 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sat, 18 Apr 2026 20:00:51 -0300 Subject: [PATCH] feat(infrastructure): RubroRepository Dapper + DI + integration tests (CAT-001) --- .../DependencyInjection.cs | 1 + .../Persistence/RubroRepository.cs | 236 +++++++++ .../Rubros/RubroRepositoryTests.cs | 450 ++++++++++++++++++ 3 files changed, 687 insertions(+) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/RubroRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Rubros/RubroRepositoryTests.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index f940494..40ee1ab 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -38,6 +38,7 @@ public static class DependencyInjection 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/RubroRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/RubroRepository.cs new file mode 100644 index 0000000..c7803dd --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/RubroRepository.cs @@ -0,0 +1,236 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class RubroRepository : IRubroRepository +{ + private readonly SqlConnectionFactory _factory; + + public RubroRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + public async Task AddAsync(Rubro rubro, CancellationToken ct = default) + { + // DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()), Orden (0 default — overridden if provided). + const string sql = """ + INSERT INTO dbo.Rubro (ParentId, Nombre, Orden, Activo, TarifarioBaseId) + OUTPUT INSERTED.Id + VALUES (@ParentId, @Nombre, @Orden, @Activo, @TarifarioBaseId) + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new + { + rubro.ParentId, + rubro.Nombre, + rubro.Orden, + Activo = rubro.Activo ? 1 : 0, + rubro.TarifarioBaseId, + }); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion + FROM dbo.Rubro + WHERE Id = @Id + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task> GetAllAsync(bool incluirInactivos, CancellationToken ct = default) + { + var sql = incluirInactivos + ? """ + SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion + FROM dbo.Rubro + ORDER BY ParentId, Orden + """ + : """ + SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion + FROM dbo.Rubro + WHERE Activo = 1 + ORDER BY ParentId, Orden + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql); + return rows.Select(MapRow).ToList(); + } + + public async Task> GetDescendantsAsync(int rootId, CancellationToken ct = default) + { + const string sql = """ + WITH Descendants AS ( + SELECT Id, ParentId, Nombre, Orden, Activo, TarifarioBaseId, FechaCreacion, FechaModificacion + FROM dbo.Rubro + WHERE ParentId = @RootId + UNION ALL + SELECT r.Id, r.ParentId, r.Nombre, r.Orden, r.Activo, r.TarifarioBaseId, r.FechaCreacion, r.FechaModificacion + FROM dbo.Rubro r + INNER JOIN Descendants d ON r.ParentId = d.Id + ) + SELECT * FROM Descendants + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql, new { RootId = rootId }); + return rows.Select(MapRow).ToList(); + } + + public async Task UpdateAsync(Rubro rubro, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Rubro + SET Nombre = @Nombre, + ParentId = @ParentId, + Orden = @Orden, + Activo = @Activo, + TarifarioBaseId = @TarifarioBaseId, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + await connection.ExecuteAsync(sql, new + { + rubro.Nombre, + rubro.ParentId, + rubro.Orden, + Activo = rubro.Activo ? 1 : 0, + rubro.TarifarioBaseId, + rubro.FechaModificacion, + rubro.Id, + }); + } + + public async Task CountActiveChildrenAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) FROM dbo.Rubro + WHERE ParentId = @Id AND Activo = 1 + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new { Id = id }); + } + + public async Task GetMaxOrdenAsync(int? parentId, CancellationToken ct = default) + { + // Returns MAX(Orden) + 1 among siblings, or 0 if no siblings exist. + // Handler uses return value directly as the orden for the new Rubro. + const string sql = """ + SELECT ISNULL(MAX(Orden) + 1, 0) + FROM dbo.Rubro + WHERE (@ParentId IS NULL AND ParentId IS NULL) + OR ParentId = @ParentId + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new { ParentId = parentId }); + } + + public async Task ExistsByNombreUnderParentAsync( + int? parentId, + string nombre, + int? excludeId, + CancellationToken ct = default) + { + // Use UPPER() for explicit case-insensitive comparison. + // DB collation is SQL_Latin1_General_CP1_CI_AI on Nombre column (already CI), + // but UPPER() makes intent explicit and works regardless of collation. + // The WHERE clause handles both root (NULL parent) and non-root cases. + const string sql = """ + SELECT COUNT(1) + FROM dbo.Rubro + WHERE ((@ParentId IS NULL AND ParentId IS NULL) OR ParentId = @ParentId) + AND UPPER(Nombre) = UPPER(@Nombre) + AND Activo = 1 + AND (@ExcludeId IS NULL OR Id <> @ExcludeId) + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new + { + ParentId = parentId, + Nombre = nombre, + ExcludeId = excludeId, + }); + + return count > 0; + } + + public async Task GetDepthAsync(int? parentId, CancellationToken ct = default) + { + // If parentId is null, depth is 0 (creating a root node). + if (!parentId.HasValue) + return 0; + + // CTE walks up the ancestor chain from parentId to root, counting levels. + // Each UNION ALL step goes one level up, so the count of rows = depth of parentId. + const string sql = """ + WITH Ancestors AS ( + SELECT Id, ParentId, 1 AS Depth + FROM dbo.Rubro + WHERE Id = @ParentId + UNION ALL + SELECT r.Id, r.ParentId, a.Depth + 1 + FROM dbo.Rubro r + INNER JOIN Ancestors a ON r.Id = a.ParentId + ) + SELECT ISNULL(MAX(Depth), 0) FROM Ancestors + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new { ParentId = parentId.Value }); + } + + // ── mapping ─────────────────────────────────────────────────────────────── + + private static Rubro MapRow(RubroRow r) + => new( + id: r.Id, + parentId: r.ParentId, + nombre: r.Nombre, + orden: r.Orden, + activo: r.Activo, + tarifarioBaseId: r.TarifarioBaseId, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private sealed record RubroRow( + int Id, + int? ParentId, + string Nombre, + int Orden, + bool Activo, + int? TarifarioBaseId, + DateTime FechaCreacion, + DateTime? FechaModificacion); +} diff --git a/tests/SIGCM2.Application.Tests/Rubros/RubroRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Rubros/RubroRepositoryTests.cs new file mode 100644 index 0000000..33902b3 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Rubros/RubroRepositoryTests.cs @@ -0,0 +1,450 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Respawn; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Rubros; + +/// +/// Integration tests for RubroRepository against SIGCM2_Test. +/// TDD: RED written before implementation, GREEN after RubroRepository was created. +/// Temporal: after UpdateAsync, dbo.Rubro_History MUST have ≥1 row for that Id. +/// +[Collection("Database")] +public class RubroRepositoryTests : 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 RubroRepository _repository = null!; + private TimeProvider _timeProvider = 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"), + // ADM-008 (V013): PuntoDeVenta is temporal. + new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"), + // ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales. + new Respawn.Graph.Table("dbo", "TipoDeIva_History"), + new Respawn.Graph.Table("dbo", "IngresosBrutos_History"), + new Respawn.Graph.Table("dbo", "TipoDeIva"), + new Respawn.Graph.Table("dbo", "IngresosBrutos"), + // CAT-001 (V016): Rubro es temporal — history no puede deletearse directo. + new Respawn.Graph.Table("dbo", "Rubro_History"), + ] + }); + + await _respawner.ResetAsync(_connection); + await SeedRolCanonicalAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new RubroRepository(factory); + _timeProvider = TimeProvider.System; + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // ── AddAsync + GetByIdAsync roundtrip ───────────────────────────────────── + + [Fact] + public async Task AddAsync_AndGetById_ReturnsAllFields() + { + var rubro = Rubro.ForCreation("Automotores", parentId: null, orden: 0, tarifarioBaseId: null, _timeProvider); + + var id = await _repository.AddAsync(rubro); + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(id, result!.Id); + Assert.Equal("Automotores", result.Nombre); + Assert.Null(result.ParentId); + Assert.Equal(0, result.Orden); + Assert.True(result.Activo); + Assert.Null(result.TarifarioBaseId); + Assert.True(result.FechaCreacion > DateTime.MinValue); + Assert.Null(result.FechaModificacion); + } + + [Fact] + public async Task AddAsync_WithTarifarioBaseId_PersistsValue() + { + var rubro = Rubro.ForCreation("Tecnología", parentId: null, orden: 0, tarifarioBaseId: 42, _timeProvider); + + var id = await _repository.AddAsync(rubro); + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(42, result!.TarifarioBaseId); + } + + [Fact] + public async Task GetByIdAsync_NonExistent_ReturnsNull() + { + var result = await _repository.GetByIdAsync(999999); + + Assert.Null(result); + } + + // ── GetAllAsync ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GetAllAsync_IncluirInactivosFalse_OmitsInactive() + { + var activo = Rubro.ForCreation("Activo", null, 0, null, _timeProvider); + var inactivo = Rubro.ForCreation("Inactivo", null, 1, null, _timeProvider); + + await _repository.AddAsync(activo); + var inactivoId = await _repository.AddAsync(inactivo); + + // Deactivate the second one via UpdateAsync + var inactivoEntity = await _repository.GetByIdAsync(inactivoId); + await _repository.UpdateAsync(inactivoEntity!.WithActivo(false, _timeProvider)); + + var all = await _repository.GetAllAsync(incluirInactivos: false); + + Assert.Contains(all, r => r.Nombre == "Activo"); + Assert.DoesNotContain(all, r => r.Nombre == "Inactivo"); + } + + [Fact] + public async Task GetAllAsync_IncluirInactivosTrue_ReturnsAll() + { + var activo = Rubro.ForCreation("ActivoAll", null, 0, null, _timeProvider); + var inactivo = Rubro.ForCreation("InactivoAll", null, 1, null, _timeProvider); + + await _repository.AddAsync(activo); + var inactivoId = await _repository.AddAsync(inactivo); + + var inactivoEntity = await _repository.GetByIdAsync(inactivoId); + await _repository.UpdateAsync(inactivoEntity!.WithActivo(false, _timeProvider)); + + var all = await _repository.GetAllAsync(incluirInactivos: true); + + Assert.Contains(all, r => r.Nombre == "ActivoAll"); + Assert.Contains(all, r => r.Nombre == "InactivoAll"); + } + + // ── GetDescendantsAsync ──────────────────────────────────────────────────── + + [Fact] + public async Task GetDescendantsAsync_3LevelsDeep_ReturnsAllDescendants() + { + // Level 0: root + var root = Rubro.ForCreation("Root", null, 0, null, _timeProvider); + var rootId = await _repository.AddAsync(root); + + // Level 1: child of root + var child = Rubro.ForCreation("Child", rootId, 0, null, _timeProvider); + var childId = await _repository.AddAsync(child); + + // Level 2: grandchild of root + var grandchild = Rubro.ForCreation("Grandchild", childId, 0, null, _timeProvider); + var grandchildId = await _repository.AddAsync(grandchild); + + var descendants = await _repository.GetDescendantsAsync(rootId); + + Assert.Equal(2, descendants.Count); + Assert.Contains(descendants, d => d.Id == childId); + Assert.Contains(descendants, d => d.Id == grandchildId); + } + + [Fact] + public async Task GetDescendantsAsync_Leaf_ReturnsEmpty() + { + var leaf = Rubro.ForCreation("Leaf", null, 0, null, _timeProvider); + var leafId = await _repository.AddAsync(leaf); + + var descendants = await _repository.GetDescendantsAsync(leafId); + + Assert.Empty(descendants); + } + + // ── CountActiveChildrenAsync ─────────────────────────────────────────────── + + [Fact] + public async Task CountActiveChildrenAsync_NoChildren_ReturnsZero() + { + var parent = Rubro.ForCreation("ParentNoHijos", null, 0, null, _timeProvider); + var parentId = await _repository.AddAsync(parent); + + var count = await _repository.CountActiveChildrenAsync(parentId); + + Assert.Equal(0, count); + } + + [Fact] + public async Task CountActiveChildrenAsync_TwoActive_ReturnsTwo() + { + var parent = Rubro.ForCreation("ParentDosHijos", null, 0, null, _timeProvider); + var parentId = await _repository.AddAsync(parent); + + await _repository.AddAsync(Rubro.ForCreation("Hijo1", parentId, 0, null, _timeProvider)); + await _repository.AddAsync(Rubro.ForCreation("Hijo2", parentId, 1, null, _timeProvider)); + + var count = await _repository.CountActiveChildrenAsync(parentId); + + Assert.Equal(2, count); + } + + [Fact] + public async Task CountActiveChildrenAsync_ActiveAndInactive_CountsOnlyActive() + { + var parent = Rubro.ForCreation("ParentMixed", null, 0, null, _timeProvider); + var parentId = await _repository.AddAsync(parent); + + await _repository.AddAsync(Rubro.ForCreation("HijoActivo", parentId, 0, null, _timeProvider)); + var inactivoId = await _repository.AddAsync(Rubro.ForCreation("HijoInactivo", parentId, 1, null, _timeProvider)); + + var inactivo = await _repository.GetByIdAsync(inactivoId); + await _repository.UpdateAsync(inactivo!.WithActivo(false, _timeProvider)); + + var count = await _repository.CountActiveChildrenAsync(parentId); + + Assert.Equal(1, count); + } + + // ── GetMaxOrdenAsync ─────────────────────────────────────────────────────── + + [Fact] + public async Task GetMaxOrdenAsync_Empty_ReturnsZero() + { + // No siblings → first slot is 0 + var orden = await _repository.GetMaxOrdenAsync(parentId: null); + + Assert.Equal(0, orden); + } + + [Fact] + public async Task GetMaxOrdenAsync_ThreeSiblings_ReturnsThree() + { + // Orden = [0, 1, 2] → next slot = 3 (MAX+1) + var parent = Rubro.ForCreation("ParentOrden", null, 0, null, _timeProvider); + var parentId = await _repository.AddAsync(parent); + + await _repository.AddAsync(Rubro.ForCreation("S1", parentId, 0, null, _timeProvider)); + await _repository.AddAsync(Rubro.ForCreation("S2", parentId, 1, null, _timeProvider)); + await _repository.AddAsync(Rubro.ForCreation("S3", parentId, 2, null, _timeProvider)); + + var orden = await _repository.GetMaxOrdenAsync(parentId); + + Assert.Equal(3, orden); + } + + [Fact] + public async Task GetMaxOrdenAsync_ParentIdNull_WorksForRoots() + { + // Insert one root with Orden=0 + await _repository.AddAsync(Rubro.ForCreation("RootOrden1", null, 0, null, _timeProvider)); + + var orden = await _repository.GetMaxOrdenAsync(parentId: null); + + Assert.Equal(1, orden); // MAX(0) + 1 = 1 + } + + // ── ExistsByNombreUnderParentAsync ───────────────────────────────────────── + + [Fact] + public async Task ExistsByNombreUnderParentAsync_Exists_ReturnsTrue() + { + var parent = Rubro.ForCreation("ParentExists", null, 0, null, _timeProvider); + var parentId = await _repository.AddAsync(parent); + + await _repository.AddAsync(Rubro.ForCreation("Autos", parentId, 0, null, _timeProvider)); + + var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "Autos", excludeId: null); + + Assert.True(exists); + } + + [Fact] + public async Task ExistsByNombreUnderParentAsync_SameNameDifferentParent_ReturnsFalse() + { + var parent1 = Rubro.ForCreation("Parent1", null, 0, null, _timeProvider); + var parent1Id = await _repository.AddAsync(parent1); + + var parent2 = Rubro.ForCreation("Parent2", null, 1, null, _timeProvider); + var parent2Id = await _repository.AddAsync(parent2); + + // Add "Autos" under parent1 + await _repository.AddAsync(Rubro.ForCreation("Autos", parent1Id, 0, null, _timeProvider)); + + // Check under parent2 — should not exist + var exists = await _repository.ExistsByNombreUnderParentAsync(parent2Id, "Autos", excludeId: null); + + Assert.False(exists); + } + + [Fact] + public async Task ExistsByNombreUnderParentAsync_ExcludeId_WhenProvidedSkipsSelf() + { + var parent = Rubro.ForCreation("ParentExclude", null, 0, null, _timeProvider); + var parentId = await _repository.AddAsync(parent); + + var id = await _repository.AddAsync(Rubro.ForCreation("AutosExclude", parentId, 0, null, _timeProvider)); + + // Excluding self → should return false (no other rubro with same name) + var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "AutosExclude", excludeId: id); + + Assert.False(exists); + } + + [Fact] + public async Task ExistsByNombreUnderParentAsync_CaseInsensitive_InsensibleAMayusculas() + { + var parent = Rubro.ForCreation("ParentCI", null, 0, null, _timeProvider); + var parentId = await _repository.AddAsync(parent); + + await _repository.AddAsync(Rubro.ForCreation("autos", parentId, 0, null, _timeProvider)); + + // "AUTOS" should match "autos" (case-insensitive) + var exists = await _repository.ExistsByNombreUnderParentAsync(parentId, "AUTOS", excludeId: null); + + Assert.True(exists); + } + + [Fact] + public async Task ExistsByNombreUnderParentAsync_ForRoot_ParentIdNull_WorksWithApplicationDefense() + { + // The DB filtered index only covers non-root rubros (WHERE ParentId IS NOT NULL AND Activo = 1). + // Application must check roots via full scan (no unique index guarantee at DB level). + await _repository.AddAsync(Rubro.ForCreation("RootCI", null, 0, null, _timeProvider)); + + var exists = await _repository.ExistsByNombreUnderParentAsync(null, "RootCI", excludeId: null); + + Assert.True(exists); + } + + // ── GetDepthAsync ────────────────────────────────────────────────────────── + + [Fact] + public async Task GetDepthAsync_RootParent_ReturnsZero() + { + // parentId = null means we're creating a root → depth = 0 + var depth = await _repository.GetDepthAsync(parentId: null); + + Assert.Equal(0, depth); + } + + [Fact] + public async Task GetDepthAsync_3LevelsDeep_ReturnsThree() + { + // root (depth 0) → child (depth 1) → grandchild (depth 2) → great-grandchild (depth 3) + var rootId = await _repository.AddAsync(Rubro.ForCreation("RootDepth", null, 0, null, _timeProvider)); + var childId = await _repository.AddAsync(Rubro.ForCreation("ChildDepth", rootId, 0, null, _timeProvider)); + var grandchildId = await _repository.AddAsync(Rubro.ForCreation("GrandchildDepth", childId, 0, null, _timeProvider)); + + // Depth of the grandchild's own id as parentId = 3 levels deep (root=1, child=2, grandchild=3) + var depth = await _repository.GetDepthAsync(grandchildId); + + Assert.Equal(3, depth); + } + + // ── UpdateAsync + Temporal ──────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_ModificaCamposYProduceHistoryRow() + { + var id = await _repository.AddAsync(Rubro.ForCreation("Original", null, 0, null, _timeProvider)); + var original = await _repository.GetByIdAsync(id); + + var renamed = original!.WithRenamed("Actualizado", _timeProvider); + await _repository.UpdateAsync(renamed); + + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal("Actualizado", result!.Nombre); + Assert.NotNull(result.FechaModificacion); + + var historyCount = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id }); + + Assert.True(historyCount >= 1, $"Expected ≥1 history row for Rubro Id={id}, got {historyCount}"); + } + + [Fact] + public async Task SoftDeleteAsync_FlipActivoYActualizaFechaModificacion() + { + // Deactivate via UpdateAsync (with WithActivo(false)) — repository has no separate SoftDelete + var id = await _repository.AddAsync(Rubro.ForCreation("ToDeactivate", null, 0, null, _timeProvider)); + var rubro = await _repository.GetByIdAsync(id); + + var deactivated = rubro!.WithActivo(false, _timeProvider); + await _repository.UpdateAsync(deactivated); + + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.False(result!.Activo); + Assert.NotNull(result.FechaModificacion); + } + + [Fact] + public async Task MoveAsync_CambiaParentIdOrdenYFechaModificacion() + { + var parent1Id = await _repository.AddAsync(Rubro.ForCreation("MoveParent1", null, 0, null, _timeProvider)); + var parent2Id = await _repository.AddAsync(Rubro.ForCreation("MoveParent2", null, 1, null, _timeProvider)); + var childId = await _repository.AddAsync(Rubro.ForCreation("MoveChild", parent1Id, 0, null, _timeProvider)); + + var child = await _repository.GetByIdAsync(childId); + var moved = child!.WithMoved(parent2Id, 5, _timeProvider); + await _repository.UpdateAsync(moved); + + var result = await _repository.GetByIdAsync(childId); + + Assert.NotNull(result); + Assert.Equal(parent2Id, result!.ParentId); + Assert.Equal(5, result.Orden); + Assert.NotNull(result.FechaModificacion); + } + + // ── 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); + } +}