feat(bd): V021 crea dbo.ChargeableCharConfig + SPs + índices (PRC-001)

This commit is contained in:
2026-04-20 12:01:49 -03:00
parent dd4d4a1673
commit 9144c2e89e
8 changed files with 966 additions and 11 deletions

View File

@@ -0,0 +1,365 @@
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// <summary>
/// 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.
/// </summary>
[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<int>(
"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<int?>(@"
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<int>(
"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<int>(@"
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<int>(@"
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<int>(@"
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<long?>("@NewId");
var closedId = p.Get<long?>("@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<long?>("@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<long?>("@NewId")!.Value;
var closedId = p2.Get<long?>("@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<DateTime?>(
"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<SqlException>()
.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<long?>("@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<long?>("@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<int>(
"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<int>(@"
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<int>(@"
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<int>(@"
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<int>(@"
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");
}
}

View File

@@ -83,8 +83,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar'
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar'
// + V018 (PRD-002) adds 'catalogo:productos:gestionar' = 27 total
Assert.Equal(27, list.Count);
// + V018 (PRD-002) adds 'catalogo:productos:gestionar'
// + V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' = 28 total
Assert.Equal(28, list.Count);
}
[Fact]

View File

@@ -181,10 +181,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar'
// + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar'
// + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' = 27 total
// + 1 from V018 (PRD-002): 'catalogo:productos:gestionar'
// + 1 from V020 (PRC-001): 'tasacion:caracteres_especiales:gestionar' = 28 total
var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(27, permisos.Count);
Assert.Equal(28, permisos.Count);
}
[Fact]