using Dapper; using FluentAssertions; using Microsoft.Data.SqlClient; using Xunit; namespace SIGCM2.Api.Tests.Admin; /// /// ADM-009 Batch 1 — V014 migration integration tests. /// Validates: /// REQ-SEED-001 : TipoDeIva seed genera exactamente 4 filas canónicas (EXENTO, NO_GRAVADO, IVA_105, IVA_21). /// REQ-SEED-002 : IngresosBrutos seed genera exactamente 25 filas (24 provincias INDEC + CABA). /// REQ-SEED-003 : Re-ejecución de V014 NO duplica filas (idempotencia MERGE). /// REQ-TEMPORAL-001 : SYSTEM_VERSIONING ON en TipoDeIva e IngresosBrutos (temporal_type = 2). /// REQ-FISCAL-AUTH-002 : Permiso 'administracion:fiscal:gestionar' existe y está asignado al rol admin. /// /// NOTA: Esta suite opera directamente sobre SIGCM2_Test con Dapper. /// NO usa WebApplicationFactory (es test de migración pura, no API). /// La migración debe haberse aplicado previamente a SIGCM2_Test via sqlcmd o SqlTestFixture. /// [Collection("ApiIntegration")] public sealed class V014MigrationTests : IClassFixture { private const string ConnectionString = TestConnectionStrings.ApiTestDb; public V014MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _) { // Depend on the factory so SqlTestFixture.InitializeAsync runs // (ensures V014 schema is present via EnsureV014SchemaAsync). } // ── REQ-TEMPORAL-001 ────────────────────────────────────────────────────── [Fact] public async Task TipoDeIva_SystemVersioning_IsActive() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var temporalType = await conn.ExecuteScalarAsync(""" SELECT temporal_type FROM sys.tables WHERE object_id = OBJECT_ID('dbo.TipoDeIva') """); temporalType.Should().Be(2, "TipoDeIva debe tener SYSTEM_VERSIONING = ON (temporal_type = 2)"); } [Fact] public async Task TipoDeIva_History_Exists() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var exists = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM sys.tables WHERE name = 'TipoDeIva_History' AND schema_id = SCHEMA_ID('dbo') """); exists.Should().Be(1, "dbo.TipoDeIva_History debe existir como tabla de historial temporal"); } [Fact] public async Task IngresosBrutos_SystemVersioning_IsActive() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var temporalType = await conn.ExecuteScalarAsync(""" SELECT temporal_type FROM sys.tables WHERE object_id = OBJECT_ID('dbo.IngresosBrutos') """); temporalType.Should().Be(2, "IngresosBrutos debe tener SYSTEM_VERSIONING = ON (temporal_type = 2)"); } [Fact] public async Task IngresosBrutos_History_Exists() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var exists = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM sys.tables WHERE name = 'IngresosBrutos_History' AND schema_id = SCHEMA_ID('dbo') """); exists.Should().Be(1, "dbo.IngresosBrutos_History debe existir como tabla de historial temporal"); } // ── REQ-SEED-001 ────────────────────────────────────────────────────────── // NOTE: Filters use Codigo IN (...) + PredecesorId IS NULL + VigenciaDesde='2020-01-01' // to isolate ONLY the 4 canonical seed rows, ignoring rows inserted by repo integration tests. [Fact] public async Task TipoDeIva_Seed_HasExactly4Rows() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var count = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.TipoDeIva WHERE Codigo IN ('EXENTO', 'NO_GRAVADO', 'IVA_105', 'IVA_21') AND PredecesorId IS NULL AND VigenciaDesde = CAST('2020-01-01' AS DATE) """); count.Should().Be(4, "El seed de V014 debe generar exactamente 4 TipoDeIva canónicos"); } [Fact] public async Task TipoDeIva_Seed_HasCorrectCodigos() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var codigos = (await conn.QueryAsync(""" SELECT Codigo FROM dbo.TipoDeIva WHERE Codigo IN ('EXENTO', 'NO_GRAVADO', 'IVA_105', 'IVA_21') AND PredecesorId IS NULL AND VigenciaDesde = CAST('2020-01-01' AS DATE) ORDER BY Codigo """)).ToList(); codigos.Should().BeEquivalentTo( new[] { "EXENTO", "IVA_105", "IVA_21", "NO_GRAVADO" }, "Los 4 códigos canónicos deben existir en TipoDeIva"); } [Fact] public async Task TipoDeIva_Seed_Porcentajes_AreCorrect() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var rows = (await conn.QueryAsync<(string Codigo, decimal Porcentaje)>(""" SELECT Codigo, Porcentaje FROM dbo.TipoDeIva WHERE Codigo IN ('EXENTO', 'NO_GRAVADO', 'IVA_105', 'IVA_21') AND PredecesorId IS NULL AND VigenciaDesde = CAST('2020-01-01' AS DATE) ORDER BY Codigo """)).ToList(); rows.Should().ContainSingle(r => r.Codigo == "EXENTO" && r.Porcentaje == 0m); rows.Should().ContainSingle(r => r.Codigo == "NO_GRAVADO" && r.Porcentaje == 0m); rows.Should().ContainSingle(r => r.Codigo == "IVA_105" && r.Porcentaje == 10.5m); rows.Should().ContainSingle(r => r.Codigo == "IVA_21" && r.Porcentaje == 21m); } [Fact] public async Task TipoDeIva_Seed_AllRowsActive_PredecesorNull_VigenciaHastaNull() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var invalidRows = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.TipoDeIva WHERE Codigo IN ('EXENTO', 'NO_GRAVADO', 'IVA_105', 'IVA_21') AND VigenciaDesde = CAST('2020-01-01' AS DATE) AND PredecesorId IS NULL AND (Activo = 0 OR VigenciaHasta IS NOT NULL) """); invalidRows.Should().Be(0, "Las 4 filas seed canónicas deben tener Activo=1, PredecesorId=NULL, VigenciaHasta=NULL"); } // ── REQ-SEED-002 ────────────────────────────────────────────────────────── // NOTE: Filters use Alicuota=0 + PredecesorId IS NULL + VigenciaDesde='2020-01-01' // to isolate ONLY the 24 canonical seed rows, ignoring rows inserted by repo integration tests. // Seed provinces are stored as PascalCase matching enum ProvinciaArgentina.ToString() (T700 cleanup). [Fact] public async Task IngresosBrutos_Seed_HasExactly24Rows() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); // Design canónico: 23 provincias INDEC + CABA = 24 jurisdicciones. // La lista del design incluye CABA (CiudadAutonomaDeBuenosAires) como elemento propio. // REQ-SEED-002 especifica "25" pero la lista canónica del design tiene 24 entradas únicas. // DISCOVERY: posible discrepancia spec vs. design — anotado en apply-progress. // Implementamos lo que la lista del design establece explícitamente: 24 filas. var count = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.IngresosBrutos WHERE Alicuota = 0 AND PredecesorId IS NULL AND VigenciaDesde = CAST('2020-01-01' AS DATE) """); count.Should().Be(24, "El seed de V014 debe generar 24 IngresosBrutos (23 provincias INDEC + CABA)"); } [Fact] public async Task IngresosBrutos_Seed_HasAllProvincias() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var provincias = (await conn.QueryAsync(""" SELECT Provincia FROM dbo.IngresosBrutos WHERE Alicuota = 0 AND PredecesorId IS NULL AND VigenciaDesde = CAST('2020-01-01' AS DATE) ORDER BY Provincia """)).ToList(); // Lista canónica del design ADM-009: 23 provincias argentinas INDEC + CABA = 24 // Stored as PascalCase matching ProvinciaArgentina enum values (T700 cleanup). var expectedCanonical = new[] { "BuenosAires", "CiudadAutonomaDeBuenosAires", "Catamarca", "Chaco", "Chubut", "Cordoba", "Corrientes", "EntreRios", "Formosa", "Jujuy", "LaPampa", "LaRioja", "Mendoza", "Misiones", "Neuquen", "RioNegro", "Salta", "SanJuan", "SanLuis", "SantaCruz", "SantaFe", "SantiagoDelEstero", "TierraDelFuego", "Tucuman" }; provincias.Should().Contain("CiudadAutonomaDeBuenosAires", "CABA debe estar almacenada como CiudadAutonomaDeBuenosAires (PascalCase enum)"); provincias.Should().Contain("BuenosAires", "Buenos Aires (provincia) debe estar como BuenosAires (PascalCase enum)"); foreach (var prov in expectedCanonical) provincias.Should().Contain(prov, $"Provincia {prov} debe estar en el seed (PascalCase)"); } [Fact] public async Task IngresosBrutos_Seed_AlicuotaZero_AllRows() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); // Verify all 24 seed rows (VigenciaDesde='2020-01-01', PredecesorId IS NULL) have Alicuota=0. var nonZero = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.IngresosBrutos WHERE PredecesorId IS NULL AND VigenciaDesde = CAST('2020-01-01' AS DATE) AND Alicuota <> 0 """); nonZero.Should().Be(0, "Las 24 filas seed de IngresosBrutos deben tener Alicuota=0 (placeholder)"); } [Fact] public async Task IngresosBrutos_Seed_AllRowsActive_PredecesorNull_VigenciaHastaNull() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var invalidRows = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.IngresosBrutos WHERE VigenciaDesde = CAST('2020-01-01' AS DATE) AND PredecesorId IS NULL AND Alicuota = 0 AND (Activo = 0 OR VigenciaHasta IS NOT NULL) """); invalidRows.Should().Be(0, "Las 24 filas seed deben tener Activo=1, PredecesorId=NULL, VigenciaHasta=NULL"); } // ── REQ-FISCAL-AUTH-002 ─────────────────────────────────────────────────── [Fact] public async Task Permiso_AdministracionFiscalGestionar_Exists() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var count = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.Permiso WHERE Codigo = 'administracion:fiscal:gestionar' """); count.Should().Be(1, "El permiso 'administracion:fiscal:gestionar' debe existir en dbo.Permiso"); } [Fact] public async Task Permiso_AdministracionFiscalGestionar_AsignadoARolAdmin() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var count = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.RolPermiso rp JOIN dbo.Rol r ON r.Id = rp.RolId JOIN dbo.Permiso p ON p.Id = rp.PermisoId WHERE r.Codigo = 'admin' AND p.Codigo = 'administracion:fiscal:gestionar' """); count.Should().Be(1, "El permiso 'administracion:fiscal:gestionar' debe estar asignado al rol 'admin'"); } // ── REQ-SEED-003 — Idempotencia ─────────────────────────────────────────── [Fact] public async Task V014_Idempotencia_TipoDeIva_NoSeDuplica() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); // Aplicar el MERGE seed manualmente una segunda vez (simula re-ejecución de V014) await conn.ExecuteAsync(""" MERGE dbo.TipoDeIva AS t USING (VALUES ('EXENTO', N'Exento de IVA', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), '2020-01-01'), ('NO_GRAVADO', N'No gravado', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), '2020-01-01'), ('IVA_105', N'IVA 10.5%', CAST(10.5 AS DECIMAL(5,2)), CAST(1 AS BIT), '2020-01-01'), ('IVA_21', N'IVA 21%', CAST(21 AS DECIMAL(5,2)), CAST(1 AS BIT), '2020-01-01') ) AS s (Codigo, Descripcion, Porcentaje, AplicaIVA, VigenciaDesde) ON t.Codigo = s.Codigo AND t.PredecesorId IS NULL WHEN NOT MATCHED BY TARGET THEN INSERT (Codigo, Descripcion, Porcentaje, AplicaIVA, Activo, VigenciaDesde, VigenciaHasta, PredecesorId) VALUES (s.Codigo, s.Descripcion, s.Porcentaje, s.AplicaIVA, 1, s.VigenciaDesde, NULL, NULL); """); // Count only the 4 canonical seed rows — not test-inserted rows. var count = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.TipoDeIva WHERE Codigo IN ('EXENTO', 'NO_GRAVADO', 'IVA_105', 'IVA_21') AND PredecesorId IS NULL AND VigenciaDesde = CAST('2020-01-01' AS DATE) """); count.Should().Be(4, "Re-ejecutar el seed MERGE no debe duplicar filas en TipoDeIva"); } [Fact] public async Task V014_Idempotencia_IngresosBrutos_NoSeDuplica() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); // Re-aplicar el MERGE de provincias (simula re-ejecución de V014) — ahora con PascalCase. await conn.ExecuteAsync(""" MERGE dbo.IngresosBrutos AS t USING (VALUES ('BuenosAires'),('CiudadAutonomaDeBuenosAires'),('Catamarca'),('Chaco'),('Chubut'), ('Cordoba'),('Corrientes'),('EntreRios'),('Formosa'),('Jujuy'), ('LaPampa'),('LaRioja'),('Mendoza'),('Misiones'),('Neuquen'), ('RioNegro'),('Salta'),('SanJuan'),('SanLuis'),('SantaCruz'), ('SantaFe'),('SantiagoDelEstero'),('TierraDelFuego'),('Tucuman') ) AS s (Provincia) ON t.Provincia = s.Provincia AND t.PredecesorId IS NULL WHEN NOT MATCHED BY TARGET THEN INSERT (Provincia, Descripcion, Alicuota, Activo, VigenciaDesde, VigenciaHasta, PredecesorId) VALUES (s.Provincia, N'Ingresos Brutos ' + s.Provincia, CAST(0 AS DECIMAL(5,2)), 1, '2020-01-01', NULL, NULL); """); // Count only the 24 canonical seed rows — not test-inserted rows. var count = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.IngresosBrutos WHERE Alicuota = 0 AND PredecesorId IS NULL AND VigenciaDesde = CAST('2020-01-01' AS DATE) """); count.Should().Be(24, "Re-ejecutar el seed MERGE no debe duplicar filas en IngresosBrutos"); } [Fact] public async Task V014_Idempotencia_Permiso_NoSeDuplica() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); // Re-aplicar MERGE permiso (simula re-ejecución de V014) await conn.ExecuteAsync(""" MERGE dbo.Permiso AS t USING (VALUES ('administracion:fiscal:gestionar', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion')) AS s (Codigo, Descripcion, Modulo) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN INSERT (Codigo, Nombre, Descripcion, Modulo) VALUES (s.Codigo, N'Gestionar tablas fiscales', s.Descripcion, s.Modulo); """); var count = await conn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.Permiso WHERE Codigo = 'administracion:fiscal:gestionar' """); count.Should().Be(1, "Re-ejecutar el MERGE de Permiso no debe crear duplicados"); } }