From 93664612d52d259c7af6356a8970027fd69fb47f Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 17:32:02 -0300 Subject: [PATCH] test(adm-009): V014 migration integration tests (Red) --- .../Admin/V014MigrationTests.cs | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Admin/V014MigrationTests.cs diff --git a/tests/SIGCM2.Api.Tests/Admin/V014MigrationTests.cs b/tests/SIGCM2.Api.Tests/Admin/V014MigrationTests.cs new file mode 100644 index 0000000..95b7ab1 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Admin/V014MigrationTests.cs @@ -0,0 +1,345 @@ +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 = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + 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 ────────────────────────────────────────────────────────── + + [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"); + + 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 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 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 Activo = 0 + OR PredecesorId IS NOT NULL + OR VigenciaHasta IS NOT NULL + """); + + invalidRows.Should().Be(0, + "Todas las filas seed deben tener Activo=1, PredecesorId=NULL, VigenciaHasta=NULL"); + } + + // ── REQ-SEED-002 ────────────────────────────────────────────────────────── + + [Fact] + public async Task IngresosBrutos_Seed_HasExactly24Rows() + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + + var count = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.IngresosBrutos"); + + // Design canónico: 23 provincias INDEC + CABA = 24 jurisdicciones. + // La lista del design incluye CABA como elemento propio junto a BUENOS_AIRES (provincia). + // 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. + 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 ORDER BY Provincia")).ToList(); + + // Lista canónica del design ADM-009: 23 provincias argentinas INDEC + CABA = 24 + var expectedCanonical = new[] + { + "BUENOS_AIRES", "CABA", "CATAMARCA", "CHACO", "CHUBUT", + "CORDOBA", "CORRIENTES", "ENTRE_RIOS", "FORMOSA", "JUJUY", + "LA_PAMPA", "LA_RIOJA", "MENDOZA", "MISIONES", "NEUQUEN", + "RIO_NEGRO", "SALTA", "SAN_JUAN", "SAN_LUIS", "SANTA_CRUZ", + "SANTA_FE", "SANTIAGO_DEL_ESTERO", "TIERRA_DEL_FUEGO", "TUCUMAN" + }; + + provincias.Should().Contain("CABA", "CABA debe estar entre las provincias"); + provincias.Should().Contain("BUENOS_AIRES", "Buenos Aires (provincia) debe estar como BUENOS_AIRES"); + foreach (var prov in expectedCanonical) + provincias.Should().Contain(prov, $"Provincia {prov} debe estar en el seed"); + } + + [Fact] + public async Task IngresosBrutos_Seed_AlicuotaZero_AllRows() + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + + var nonZero = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.IngresosBrutos WHERE Alicuota <> 0"); + + nonZero.Should().Be(0, "Todas las 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 Activo = 0 + OR PredecesorId IS NOT NULL + OR VigenciaHasta IS NOT NULL + """); + + invalidRows.Should().Be(0, + "Todas las 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); + """); + + var count = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.TipoDeIva"); + + 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) + // Las 25 provincias canónicas: 24 INDEC + CABA (CABA es la #25) + await conn.ExecuteAsync(""" + MERGE dbo.IngresosBrutos AS t + USING (VALUES + ('BUENOS_AIRES'),('CABA'),('CATAMARCA'),('CHACO'),('CHUBUT'), + ('CORDOBA'),('CORRIENTES'),('ENTRE_RIOS'),('FORMOSA'),('JUJUY'), + ('LA_PAMPA'),('LA_RIOJA'),('MENDOZA'),('MISIONES'),('NEUQUEN'), + ('RIO_NEGRO'),('SALTA'),('SAN_JUAN'),('SAN_LUIS'),('SANTA_CRUZ'), + ('SANTA_FE'),('SANTIAGO_DEL_ESTERO'),('TIERRA_DEL_FUEGO'),('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); + """); + + var count = await conn.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.IngresosBrutos"); + + 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"); + } +}