using Dapper; using FluentAssertions; using Microsoft.Data.SqlClient; using Xunit; namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// /// PRC-001 — Integration tests for V020/V021/V022/V023/V024 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 + SPs (initial MedioId shape). /// - V022: 4 global seed rows ($, %, !, ¡) exist and are active. /// - V023 (scope delta): MedioId → ProductTypeId refactor + ReactivateWithGuard SP. /// - V024 (scope delta): global seed PricePerUnit reset to 0.0000 (opt-in billing). /// /// After GREEN all tests pass. SqlTestFixture applies V023+V024 during initialization. /// [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 (use ProductTypeId IS NULL after V023 refactor) await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'@'"); var p = new DynamicParameters(); p.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId 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 ProductTypeId IS NULL"); } [Fact] public async Task usp_InsertWithClose_HappyPath_ClosesAndCreates() { // Seed primer activo await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'€'"); var p1 = new DynamicParameters(); p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId 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("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId 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 ProductTypeId IS NULL"); } [Fact] public async Task usp_InsertWithClose_ForwardOnlyViolation_Throws50409() { await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'£'"); var p1 = new DynamicParameters(); p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId 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("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId 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 ProductTypeId IS NULL"); } [Fact] public async Task usp_InsertWithClose_ProductTypeNull_GlobalFallback_Works() { // V023: global fallback is now ProductTypeId IS NULL (was MedioId IS NULL) await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'¥'"); var p = new DynamicParameters(); p.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId 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 (ProductTypeId NULL) debe funcionar"); // Cleanup await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND ProductTypeId IS NULL"); } [Fact] public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow() { await _connection.ExecuteAsync( "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'#'"); var p1 = new DynamicParameters(); p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId 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("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId 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 ProductTypeId IS NULL"); } // ── V023 scope delta: SP — usp_ChargeableCharConfig_GetActiveForProductType ──────────── [Fact] public async Task V023_SP_GetActiveForProductType_Exists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.objects WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType') AND type = 'P'"); exists.Should().Be(1, "V023 debe crear usp_ChargeableCharConfig_GetActiveForProductType (renamed from GetActiveForMedio)"); } [Fact] public async Task V023_SP_GetActiveForMedio_NoLongerExists() { 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(0, "V023 debe eliminar usp_ChargeableCharConfig_GetActiveForMedio (renamed to GetActiveForProductType)"); } [Fact] public async Task V023_SP_ReactivateWithGuard_Exists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.objects WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard') AND type = 'P'"); exists.Should().Be(1, "V023 debe crear usp_ChargeableCharConfig_ReactivateWithGuard (new SP for feature 3)"); } // ── V023 scope delta: column ProductTypeId replaces MedioId ───────── [Fact] public async Task V023_Column_ProductTypeId_Exists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.columns WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND name = 'ProductTypeId'"); exists.Should().Be(1, "V023 debe agregar columna ProductTypeId a dbo.ChargeableCharConfig"); } [Fact] public async Task V023_Column_MedioId_NoLongerExists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.columns WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND name = 'MedioId'"); exists.Should().Be(0, "V023 debe eliminar columna MedioId de dbo.ChargeableCharConfig"); } [Fact] public async Task V023_FK_ProductType_Exists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.foreign_keys WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND referenced_object_id = OBJECT_ID('dbo.ProductType')"); exists.Should().Be(1, "V023 debe crear FK de ChargeableCharConfig a ProductType"); } [Fact] public async Task V023_Check_Price_NonNegative_Exists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.check_constraints WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative' AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); exists.Should().Be(1, "V023 debe crear CK_ChargeableCharConfig_Price_NonNegative (>= 0 para opt-in billing)"); } [Fact] public async Task V023_Check_Price_Positive_NoLongerExists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM sys.check_constraints WHERE name = 'CK_ChargeableCharConfig_Price_Positive' AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); exists.Should().Be(0, "V023 debe eliminar CK_ChargeableCharConfig_Price_Positive (reemplazado por NonNegative)"); } // ── 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 (after V023 refactor: use ProductTypeId IS NULL) ───── [Fact] public async Task V022_Seeds_AtLeastFourGlobalRows() { var count = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM dbo.ChargeableCharConfig WHERE ProductTypeId 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: $, %, !, ¡ (global = ProductTypeId IS NULL tras V023)"); } [Fact] public async Task V022_AllSeedRowsHaveIsActive_True() { var inactiveCount = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM dbo.ChargeableCharConfig WHERE ProductTypeId 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"); } // ── V024 scope delta: global seed prices = 0.0000 (opt-in billing) ─────── [Fact] public async Task V024_GlobalSeedRows_HaveZeroPrice() { var nonZeroCount = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') AND ValidTo IS NULL AND PricePerUnit <> 0.0000"); nonZeroCount.Should().Be(0, "V024 debe resetear todas las filas de seed global a PricePerUnit = 0.0000 (opt-in billing)"); } [Fact] public async Task V024_GlobalSeedRows_AllHaveZeroPriceExact() { var rows = await _connection.QueryAsync(@" SELECT PricePerUnit FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') AND ValidTo IS NULL"); rows.Should().AllSatisfy(price => price.Should().Be(0.0000m, "V024 seed: cada fila global debe tener PricePerUnit = 0.0000 exacto")); } }