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); } }