using Dapper; using FluentAssertions; using Microsoft.Data.SqlClient; using Xunit; namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// /// PRC-001 Batch 1 (RED) — Integration tests for V020/V021/V022 migrations. /// These tests verify the BD schema applied against SIGCM2_Test_App: /// - V020: permission 'tasacion:caracteres_especiales:gestionar' exists. /// - V021: dbo.ChargeableCharConfig table + SYSTEM_VERSIONING + filtered UX + SPs. /// - V022: 4 global seed rows ($, %, !, ¡) exist and are active. /// /// Tests are tagged [RED] until V020+V021+V022 are applied (Batch 1 GREEN step). /// After GREEN, all tests in this class should pass. /// [Collection("Database")] public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime { private SqlConnection _connection = null!; public async Task InitializeAsync() { _connection = new SqlConnection(TestConnectionStrings.AppTestDb); await _connection.OpenAsync(); } public async Task DisposeAsync() { await _connection.CloseAsync(); await _connection.DisposeAsync(); } // ── V021: Table existence ───────────────────────────────────────────── [Fact] public async Task V021_Table_ChargeableCharConfig_Exists() { var exists = await _connection.ExecuteScalarAsync( "SELECT COUNT(*) FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); exists.Should().Be(1, "V021 debe crear dbo.ChargeableCharConfig"); } [Fact] public async Task V021_Table_ChargeableCharConfig_HasSystemVersioning() { var temporalType = await _connection.ExecuteScalarAsync(@" SELECT temporal_type FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); temporalType.Should().Be(2, "SYSTEM_VERSIONING = ON requiere temporal_type = 2"); } [Fact] public async Task V021_HistoryTable_ChargeableCharConfigHistory_Exists() { var exists = await _connection.ExecuteScalarAsync( "SELECT COUNT(*) FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')"); exists.Should().Be(1, "SYSTEM_VERSIONING debe crear dbo.ChargeableCharConfig_History"); } [Fact] public async Task V021_FilteredUniqueIndex_UX_ChargeableCharConfig_Vigente_Exists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); exists.Should().Be(1, "UX_ChargeableCharConfig_Vigente filtered index debe existir"); } [Fact] public async Task V021_CoverIndex_IX_ChargeableCharConfig_Query_Exists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); exists.Should().Be(1, "IX_ChargeableCharConfig_Query covering index debe existir"); } // ── V021: SP — usp_ChargeableCharConfig_InsertWithClose ────────────── [Fact] public async Task V021_SP_InsertWithClose_Exists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.objects WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose') AND type = 'P'"); exists.Should().Be(1, "usp_ChargeableCharConfig_InsertWithClose debe existir"); } [Fact] public async Task usp_InsertWithClose_FirstPrice_ClosedIsNull() { // Cleanup await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'@'"); var p = new DynamicParameters(); p.Add("@MedioId", null, System.Data.DbType.Int32); p.Add("@Symbol", "@", System.Data.DbType.String); p.Add("@Category", "Other",System.Data.DbType.String); p.Add("@PricePerUnit", 1.5m, System.Data.DbType.Decimal, precision: 18, scale: 4); p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync( "dbo.usp_ChargeableCharConfig_InsertWithClose", p, commandType: System.Data.CommandType.StoredProcedure); var newId = p.Get("@NewId"); var closedId = p.Get("@ClosedId"); newId.Should().BeGreaterThan(0, "primer insert debe devolver NewId > 0"); closedId.Should().BeNull("primer insert no cierra nada — ClosedId debe ser NULL"); // Cleanup await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'@' AND MedioId IS NULL"); } [Fact] public async Task usp_InsertWithClose_HappyPath_ClosesAndCreates() { // Seed primer activo await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'€'"); var p1 = new DynamicParameters(); p1.Add("@MedioId", null, System.Data.DbType.Int32); p1.Add("@Symbol", "€", System.Data.DbType.String); p1.Add("@Category", "Currency", System.Data.DbType.String); p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); p1.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, commandType: System.Data.CommandType.StoredProcedure); var firstId = p1.Get("@NewId")!.Value; // Insertar segundo (debe cerrar el primero) var p2 = new DynamicParameters(); p2.Add("@MedioId", null, System.Data.DbType.Int32); p2.Add("@Symbol", "€", System.Data.DbType.String); p2.Add("@Category", "Currency", System.Data.DbType.String); p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); p2.Add("@ValidFrom", new DateTime(2026, 2, 1), System.Data.DbType.Date); p2.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2, commandType: System.Data.CommandType.StoredProcedure); var newId = p2.Get("@NewId")!.Value; var closedId = p2.Get("@ClosedId"); newId.Should().BeGreaterThan(firstId); closedId.Should().Be(firstId, "el primer activo debe ser cerrado"); // Verify el activo cerrado tiene ValidTo = 2026-01-31 var closedValidTo = await _connection.ExecuteScalarAsync( "SELECT ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id", new { Id = firstId }); closedValidTo.Should().Be(new DateTime(2026, 1, 31), "ValidTo del cerrado = ValidFrom(nuevo) - 1 día"); // Cleanup await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'€' AND MedioId IS NULL"); } [Fact] public async Task usp_InsertWithClose_ForwardOnlyViolation_Throws50409() { await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'£'"); var p1 = new DynamicParameters(); p1.Add("@MedioId", null, System.Data.DbType.Int32); p1.Add("@Symbol", "£", System.Data.DbType.String); p1.Add("@Category", "Currency", System.Data.DbType.String); p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); p1.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); p1.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, commandType: System.Data.CommandType.StoredProcedure); // Intento con ValidFrom <= activo.ValidFrom → debe lanzar 50409 var p2 = new DynamicParameters(); p2.Add("@MedioId", null, System.Data.DbType.Int32); p2.Add("@Symbol", "£", System.Data.DbType.String); p2.Add("@Category", "Currency", System.Data.DbType.String); p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); p2.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); // igual → viola forward-only p2.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); var act = async () => await _connection.ExecuteAsync( "dbo.usp_ChargeableCharConfig_InsertWithClose", p2, commandType: System.Data.CommandType.StoredProcedure); await act.Should().ThrowAsync() .Where(ex => ex.Number == 50409, "ValidFrom igual al activo debe generar THROW 50409 (forward-only)"); // Cleanup await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'£' AND MedioId IS NULL"); } [Fact] public async Task usp_InsertWithClose_MedioNull_GlobalFallback_Works() { await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'¥'"); var p = new DynamicParameters(); p.Add("@MedioId", null, System.Data.DbType.Int32); p.Add("@Symbol", "¥", System.Data.DbType.String); p.Add("@Category", "Currency", System.Data.DbType.String); p.Add("@PricePerUnit", 1.25m, System.Data.DbType.Decimal, precision: 18, scale: 4); p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p, commandType: System.Data.CommandType.StoredProcedure); var newId = p.Get("@NewId"); newId.Should().BeGreaterThan(0, "global insert (MedioId NULL) debe funcionar"); // Cleanup await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND MedioId IS NULL"); } [Fact] public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow() { await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'#'"); var p1 = new DynamicParameters(); p1.Add("@MedioId", null, System.Data.DbType.Int32); p1.Add("@Symbol", "#", System.Data.DbType.String); p1.Add("@Category", "Other", System.Data.DbType.String); p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); p1.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, commandType: System.Data.CommandType.StoredProcedure); var firstId = p1.Get("@NewId")!.Value; // Close it by inserting a newer version var p2 = new DynamicParameters(); p2.Add("@MedioId", null, System.Data.DbType.Int32); p2.Add("@Symbol", "#", System.Data.DbType.String); p2.Add("@Category", "Other", System.Data.DbType.String); p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); p2.Add("@ValidFrom", new DateTime(2026, 6, 1), System.Data.DbType.Date); p2.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2, commandType: System.Data.CommandType.StoredProcedure); // The UPDATE on the closed row should produce a history row var historyCount = await _connection.ExecuteScalarAsync( "SELECT COUNT(*) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id", new { Id = firstId }); historyCount.Should().BeGreaterThanOrEqualTo(1, "UPDATE en SYSTEM_VERSIONED table debe producir al menos 1 fila en history"); // Cleanup await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'#' AND MedioId IS NULL"); } // ── V021: SP — usp_ChargeableCharConfig_GetActiveForMedio ──────────── [Fact] public async Task V021_SP_GetActiveForMedio_Exists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.objects WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio') AND type = 'P'"); exists.Should().Be(1, "usp_ChargeableCharConfig_GetActiveForMedio debe existir"); } // ── V020: Permission existence ──────────────────────────────────────── [Fact] public async Task V020_Permission_tasacion_caracteres_especiales_gestionar_Exists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM dbo.Permiso WHERE Codigo = 'tasacion:caracteres_especiales:gestionar'"); exists.Should().Be(1, "V020 debe insertar el permiso 'tasacion:caracteres_especiales:gestionar'"); } // ── V022: Seed rows ─────────────────────────────────────────────────── [Fact] public async Task V022_Seeds_AtLeastFourGlobalRows() { var count = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') AND ValidTo IS NULL AND IsActive = 1"); count.Should().Be(4, "V022 debe sembrar exactamente 4 filas globales: $, %, !, ¡"); } [Fact] public async Task V022_AllSeedRowsHaveIsActive_True() { var inactiveCount = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') AND IsActive = 0"); inactiveCount.Should().Be(0, "todas las filas de seed deben tener IsActive = 1"); } }