feat(bd): V021 crea dbo.ChargeableCharConfig + SPs + índices (PRC-001)
This commit is contained in:
@@ -52,8 +52,9 @@ public class AuthControllerTests
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||
Assert.Equal(27, permisos.GetArrayLength());
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27
|
||||
// V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total
|
||||
Assert.Equal(28, permisos.GetArrayLength());
|
||||
}
|
||||
|
||||
// Scenario: invalid credentials return 401 with opaque error
|
||||
|
||||
@@ -142,8 +142,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||
Assert.Equal(27, list.GetArrayLength());
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27
|
||||
// V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total
|
||||
Assert.Equal(28, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -199,8 +200,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||
Assert.Equal(27, list.GetArrayLength());
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27
|
||||
// V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total
|
||||
Assert.Equal(28, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -69,6 +69,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
// V019 (PRD-003): ensure dbo.ProductPrices + temporal + SP usp_AddProductPrice.
|
||||
await EnsureV019SchemaAsync();
|
||||
|
||||
// V020/V021/V022 (PRC-001): ensure dbo.ChargeableCharConfig + temporal + SPs + permission + seed.
|
||||
await EnsureV021SchemaAsync();
|
||||
|
||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
@@ -101,6 +104,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
new Respawn.Graph.Table("dbo", "Product_History"),
|
||||
// PRD-003 (V019): ProductPrices es temporal — history protegida por SYSTEM_VERSIONING.
|
||||
new Respawn.Graph.Table("dbo", "ProductPrices_History"),
|
||||
// PRC-001 (V021): ChargeableCharConfig es temporal — history protegida por SYSTEM_VERSIONING.
|
||||
new Respawn.Graph.Table("dbo", "ChargeableCharConfig_History"),
|
||||
]
|
||||
});
|
||||
|
||||
@@ -122,6 +127,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
await SeedRolPermisosCanonicalAsync();
|
||||
await SeedAdminAsync();
|
||||
await SeedMediosCanonicalAsync();
|
||||
await SeedChargeableCharConfigCanonicalAsync();
|
||||
}
|
||||
|
||||
private async Task SeedRolCanonicalAsync()
|
||||
@@ -227,7 +233,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
-- V017 (PRD-001): permiso para gestionar tipos de producto
|
||||
('catalogo:tipos:gestionar', N'Gestionar tipos de producto', N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)', 'catalogo'),
|
||||
-- V018 (PRD-002): permiso para gestionar productos del catálogo
|
||||
('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo')
|
||||
('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo'),
|
||||
-- V020 (PRC-001): permiso para gestionar caracteres tasables
|
||||
('tasacion:caracteres_especiales:gestionar', N'Gestionar caracteres tasables', N'Crear, editar precio y desactivar la configuracion de caracteres especiales para tasacion.', 'tasacion')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
@@ -279,6 +287,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
('admin', 'catalogo:tipos:gestionar'),
|
||||
-- V018 (PRD-002)
|
||||
('admin', 'catalogo:productos:gestionar'),
|
||||
-- V020 (PRC-001)
|
||||
('admin', 'tasacion:caracteres_especiales:gestionar'),
|
||||
('cajero', 'ventas:contado:crear'),
|
||||
('cajero', 'ventas:contado:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
@@ -563,6 +573,34 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
await _connection.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 (V022): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn.
|
||||
/// Mirrors V022__seed_chargeable_char_config.sql (MERGE idempotente).
|
||||
/// The table itself is never added to TablesToIgnore because per-medio test rows
|
||||
/// must be reset between test classes — only the 4 global defaults are reseeded.
|
||||
/// </summary>
|
||||
private async Task SeedChargeableCharConfigCanonicalAsync()
|
||||
{
|
||||
const string sql = """
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
MERGE dbo.ChargeableCharConfig AS t
|
||||
USING (VALUES
|
||||
(NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||
(NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||
(NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||
(NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE))
|
||||
) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||
ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||
END
|
||||
""";
|
||||
await _connection.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UDT-010 (V010): verifies that the audit infrastructure is present.
|
||||
/// Does NOT re-apply the migration (the ALTER DATABASE ADD FILEGROUP/FILE + partition
|
||||
@@ -1267,4 +1305,216 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
await _connection.ExecuteAsync(createSp);
|
||||
await _connection.ExecuteAsync(alterSp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 (V020/V021/V022): applies dbo.ChargeableCharConfig schema + SYSTEM_VERSIONING
|
||||
/// + filtered UX + SPs + permission 'tasacion:caracteres_especiales:gestionar' + seed data.
|
||||
/// Mirrors V020+V021+V022 migrations (idempotente).
|
||||
/// Permission y asignación a admin se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync.
|
||||
/// IMPORTANT: dbo.ChargeableCharConfig_History must be in TablesToIgnore — SYSTEM_VERSIONING
|
||||
/// prevents Respawn from directly truncating history tables (engine rejects).
|
||||
/// </summary>
|
||||
private async Task EnsureV021SchemaAsync()
|
||||
{
|
||||
const string createTable = """
|
||||
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.ChargeableCharConfig (
|
||||
Id BIGINT IDENTITY(1,1) NOT NULL
|
||||
CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY,
|
||||
MedioId INT NULL,
|
||||
Symbol NVARCHAR(4) NOT NULL,
|
||||
Category NVARCHAR(32) NOT NULL,
|
||||
PricePerUnit DECIMAL(18,4) NOT NULL,
|
||||
ValidFrom DATE NOT NULL,
|
||||
ValidTo DATE NULL,
|
||||
IsActive BIT NOT NULL
|
||||
CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1),
|
||||
FechaCreacion DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
CONSTRAINT FK_ChargeableCharConfig_Medio
|
||||
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT CK_ChargeableCharConfig_Price_Positive
|
||||
CHECK (PricePerUnit > 0),
|
||||
CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty
|
||||
CHECK (LEN(Symbol) > 0),
|
||||
CONSTRAINT CK_ChargeableCharConfig_ValidRange
|
||||
CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom)
|
||||
);
|
||||
END
|
||||
""";
|
||||
|
||||
const string addPeriod = """
|
||||
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
ADD
|
||||
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()),
|
||||
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
|
||||
END
|
||||
""";
|
||||
|
||||
const string setVersioning = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
END
|
||||
""";
|
||||
|
||||
const string createVigenteIndex = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente
|
||||
ON dbo.ChargeableCharConfig (MedioId, Symbol)
|
||||
WHERE ValidTo IS NULL;
|
||||
END
|
||||
""";
|
||||
|
||||
const string createQueryIndex = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_ChargeableCharConfig_Query
|
||||
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
|
||||
INCLUDE (PricePerUnit, IsActive, Category);
|
||||
END
|
||||
""";
|
||||
|
||||
const string createInsertSp = """
|
||||
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NULL
|
||||
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0');
|
||||
""";
|
||||
|
||||
const string alterInsertSp = """
|
||||
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
|
||||
@MedioId INT = NULL,
|
||||
@Symbol NVARCHAR(4),
|
||||
@Category NVARCHAR(32),
|
||||
@PricePerUnit DECIMAL(18,4),
|
||||
@ValidFrom DATE,
|
||||
@NewId BIGINT OUTPUT,
|
||||
@ClosedId BIGINT OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
IF @MedioId IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
THROW 50404, 'Medio not found', 1;
|
||||
END
|
||||
|
||||
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
|
||||
SELECT TOP 1
|
||||
@ActiveId = Id,
|
||||
@ActiveValidFrom = ValidFrom
|
||||
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
|
||||
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
|
||||
AND Symbol = @Symbol
|
||||
AND ValidTo IS NULL;
|
||||
|
||||
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
|
||||
END
|
||||
|
||||
IF @ActiveId IS NOT NULL
|
||||
BEGIN
|
||||
UPDATE dbo.ChargeableCharConfig
|
||||
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
|
||||
WHERE Id = @ActiveId;
|
||||
SET @ClosedId = @ActiveId;
|
||||
END
|
||||
ELSE
|
||||
SET @ClosedId = NULL;
|
||||
|
||||
INSERT INTO dbo.ChargeableCharConfig
|
||||
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES
|
||||
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
|
||||
SET @NewId = SCOPE_IDENTITY();
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||
THROW;
|
||||
END CATCH
|
||||
END
|
||||
""";
|
||||
|
||||
const string createGetActiveSp = """
|
||||
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NULL
|
||||
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0');
|
||||
""";
|
||||
|
||||
const string alterGetActiveSp = """
|
||||
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
|
||||
@MedioId INT,
|
||||
@AsOfDate DATE
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
WITH Candidates AS (
|
||||
SELECT
|
||||
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY Symbol
|
||||
ORDER BY
|
||||
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END,
|
||||
ValidFrom DESC
|
||||
) AS rn
|
||||
FROM dbo.ChargeableCharConfig
|
||||
WHERE IsActive = 1
|
||||
AND ValidFrom <= @AsOfDate
|
||||
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
|
||||
AND (MedioId = @MedioId OR MedioId IS NULL)
|
||||
)
|
||||
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||
FROM Candidates
|
||||
WHERE rn = 1;
|
||||
END
|
||||
""";
|
||||
|
||||
const string seedV022 = """
|
||||
MERGE dbo.ChargeableCharConfig AS t
|
||||
USING (VALUES
|
||||
(NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||
(NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||
(NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||
(NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE))
|
||||
) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||
ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||
""";
|
||||
|
||||
await _connection.ExecuteAsync(createTable);
|
||||
await _connection.ExecuteAsync(addPeriod);
|
||||
await _connection.ExecuteAsync(setVersioning);
|
||||
await _connection.ExecuteAsync(createVigenteIndex);
|
||||
await _connection.ExecuteAsync(createQueryIndex);
|
||||
await _connection.ExecuteAsync(createInsertSp);
|
||||
await _connection.ExecuteAsync(alterInsertSp);
|
||||
await _connection.ExecuteAsync(createGetActiveSp);
|
||||
await _connection.ExecuteAsync(alterGetActiveSp);
|
||||
await _connection.ExecuteAsync(seedV022);
|
||||
// Permission 'tasacion:caracteres_especiales:gestionar' and admin assignment
|
||||
// are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user