Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Admin/V014MigrationTests.cs
dmolinari e5b6c06f64 refactor(tests): Api.Tests apunta a SIGCM2_Test_Api via TestConnectionStrings
Todos los archivos de Api.Tests reemplazan la connection string hardcodeada
por TestConnectionStrings.ApiTestDb. Cada proyecto de tests ahora tiene su
propia base de datos aislada, eliminando la contención entre Application.Tests
y Api.Tests que causaba flakiness.
2026-04-18 21:44:40 -03:00

392 lines
17 KiB
C#

using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Xunit;
namespace SIGCM2.Api.Tests.Admin;
/// <summary>
/// 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.
/// </summary>
[Collection("ApiIntegration")]
public sealed class V014MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
{
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<int>("""
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<int>("""
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<int>("""
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<int>("""
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<int>("""
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<string>("""
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<int>("""
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<int>("""
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<string>("""
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<int>("""
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<int>("""
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<int>("""
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<int>("""
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<int>("""
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<int>("""
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<int>("""
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");
}
}