refactor(bd): V023+V024 ChargeableCharConfig por ProductType + SP ReactivateWithGuard (PRC-001)

BREAKING: schema refactor pre-merge. Backend+frontend do not compile yet;
subsequent commits in this PR restore compilation. Acceptable only because
feature/PRC-001 is not yet merged to main.

- V023: drop MedioId + FK_Medio, add ProductTypeId + FK_ProductType, rename
  indexes, drop+create SPs InsertWithClose (now @ProductTypeId) and
  GetActiveForProductType (renamed from GetActiveForMedio). NEW SP
  ReactivateWithGuard (A+guard pattern for feature 3 of scope delta).
  Drop CK_Price_Positive, add CK_Price_NonNegative (>= 0 for opt-in billing).
- V024: reseed global rows with PricePerUnit = 0.0000 (opt-in billing).
- V023_ROLLBACK + V024_ROLLBACK scripts.
- SqlTestFixture: EnsureV023SchemaAsync, EnsureV024SeedAsync, renamed seed
  method signature (ProductTypeId=NULL + PricePerUnit=0), history table
  TablesToIgnore preserved. HardeningTests seeds dbo.ProductType (not Medio).
- MigrationTests: updated SP existence + column + FK + price assertions.
- RepositoryIntegrationTests + HardeningTests: SQL-level assertions updated;
  C# method/property renames deferred to Agent 2 (backend refactor).
This commit is contained in:
2026-04-21 10:35:38 -03:00
parent 5175cc1ece
commit 5c1675e59a
8 changed files with 1390 additions and 160 deletions

View File

@@ -72,6 +72,12 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V020/V021/V022 (PRC-001): ensure dbo.ChargeableCharConfig + temporal + SPs + permission + seed.
await EnsureV021SchemaAsync();
// V023 (PRC-001 scope delta): refactor ChargeableCharConfig — MedioId → ProductTypeId + ReactivateWithGuard SP.
await EnsureV023SchemaAsync();
// V024 (PRC-001 scope delta): reseed global rows with PricePerUnit = 0.0000 (opt-in billing).
await EnsureV024SeedAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
@@ -127,6 +133,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
await SeedRolPermisosCanonicalAsync();
await SeedAdminAsync();
await SeedMediosCanonicalAsync();
// PRC-001 scope delta: ChargeableCharConfig re-seeded with ProductTypeId-based canonical seed.
await SeedChargeableCharConfigCanonicalAsync();
}
@@ -574,28 +581,34 @@ public sealed class SqlTestFixture : IAsyncLifetime
}
/// <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
/// PRC-001 scope delta (V022+V024): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn.
/// Uses ProductTypeId NULL (global fallback) and PricePerUnit = 0.0000 (opt-in billing — V024 decision).
/// Mirrors V022 MERGE pattern, adapted for ProductTypeId column (V023 refactor).
/// The table itself is never added to TablesToIgnore because per-productType test rows
/// must be reset between test classes — only the 4 global defaults are reseeded.
/// NOTE: seed price is 0.0000 (not 1.0000). Tests asserting price must use 0.0000 unless
/// they have explicitly seeded their own rows at a different price.
/// </summary>
private async Task SeedChargeableCharConfigCanonicalAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
AND EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'ProductTypeId')
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)
(NULL, N'$', N'Currency', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
(NULL, N'%', N'Percentage', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
(NULL, N'!', N'Exclamation', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
(NULL, N'¡', N'Exclamation', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE))
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
ON (t.ProductTypeId IS NULL AND s.ProductTypeId 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);
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
END
""";
await _connection.ExecuteAsync(sql);
@@ -1517,4 +1530,373 @@ public sealed class SqlTestFixture : IAsyncLifetime
// Permission 'tasacion:caracteres_especiales:gestionar' and admin assignment
// are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
}
/// <summary>
/// PRC-001 scope delta (V023): refactors dbo.ChargeableCharConfig from MedioId to ProductTypeId.
/// Mirrors V023__refactor_chargeable_char_config_to_product_type.sql (idempotente).
///
/// Steps (only run if MedioId column still exists — guard for idempotence):
/// 1. SYSTEM_VERSIONING OFF
/// 2. Drop UX_Vigente + IX_Query (MedioId-based)
/// 3. Drop FK_ChargeableCharConfig_Medio
/// 4. Drop MedioId column from main + history
/// 5. Drop CK_Price_Positive; add CK_Price_NonNegative (>= 0 for opt-in billing)
/// 6. Add ProductTypeId column (nullable) to main + history
/// 7. Add FK_ChargeableCharConfig_ProductType
/// 8. Recreate UX_Vigente + IX_Query (ProductTypeId-based)
/// 9. SYSTEM_VERSIONING ON
/// 10. Drop+Create usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId)
/// 11. Drop usp_ChargeableCharConfig_GetActiveForMedio
/// 12. Create usp_ChargeableCharConfig_GetActiveForProductType
/// 13. Create usp_ChargeableCharConfig_ReactivateWithGuard (NEW)
/// </summary>
private async Task EnsureV023SchemaAsync()
{
// ── Guard: only run the ALTER block if MedioId still exists ──────────
// SPs are always idempotently recreated (create-if-not-exists + alter pattern).
const string checkMedioId = """
SELECT CAST(
CASE WHEN EXISTS (
SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'MedioId'
) THEN 1 ELSE 0 END
AS BIT)
""";
var hasMedioId = await _connection.ExecuteScalarAsync<bool>(checkMedioId);
if (hasMedioId)
{
// ── 1. SYSTEM_VERSIONING OFF ──────────────────────────────────────
await _connection.ExecuteAsync(
"ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF)");
// ── 2. Drop MedioId-based indexes ─────────────────────────────────
const string dropIndexes = """
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
""";
await _connection.ExecuteAsync(dropIndexes);
// ── 3. Drop FK to Medio ────────────────────────────────────────────
const string dropFkMedio = """
DECLARE @fk_name sysname;
SELECT @fk_name = name FROM sys.foreign_keys
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND referenced_object_id = OBJECT_ID('dbo.Medio');
IF @fk_name IS NOT NULL
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name);
""";
await _connection.ExecuteAsync(dropFkMedio);
// ── 4. Drop MedioId from main + history ────────────────────────────
const string dropMedioIdMain = """
DECLARE @df_medio sysname;
SELECT @df_medio = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND c.name = 'MedioId';
IF @df_medio IS NOT NULL
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio);
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId;
""";
await _connection.ExecuteAsync(dropMedioIdMain);
const string dropMedioIdHistory = """
IF EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND name = 'MedioId')
BEGIN
DECLARE @df_hist sysname;
SELECT @df_hist = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND c.name = 'MedioId';
IF @df_hist IS NOT NULL
EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist);
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId;
END
""";
await _connection.ExecuteAsync(dropMedioIdHistory);
// ── 5. Replace price check constraint ────────────────────────────────
const string replacePriceCheck = """
IF EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive;
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative CHECK (PricePerUnit >= 0);
""";
await _connection.ExecuteAsync(replacePriceCheck);
// ── 6. Add ProductTypeId to main + history ─────────────────────────
await _connection.ExecuteAsync(
"ALTER TABLE dbo.ChargeableCharConfig ADD ProductTypeId INT NULL");
await _connection.ExecuteAsync(
"ALTER TABLE dbo.ChargeableCharConfig_History ADD ProductTypeId INT NULL");
// ── 7. Add FK to ProductType ───────────────────────────────────────
await _connection.ExecuteAsync("""
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT FK_ChargeableCharConfig_ProductType
FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION
""");
// ── 8. Recreate ProductTypeId-based indexes ────────────────────────
await _connection.ExecuteAsync("""
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol)
WHERE ValidTo IS NULL
""");
await _connection.ExecuteAsync("""
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo)
INCLUDE (PricePerUnit, IsActive, Category)
""");
// ── 9. SYSTEM_VERSIONING ON ────────────────────────────────────────
await _connection.ExecuteAsync("""
ALTER TABLE dbo.ChargeableCharConfig
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
))
""");
}
// ── 10. Recreate InsertWithClose SP (always: idempotent via drop+create) ──
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');
""";
// Only ALTER if ProductTypeId column exists (meaning table was already refactored or we just did it)
const string alterInsertSp = """
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
@ProductTypeId 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 @ProductTypeId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId)
BEGIN
ROLLBACK;
THROW 50404, 'ProductType not found', 1;
END
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
SELECT TOP 1
@ActiveId = Id,
@ActiveValidFrom = ValidFrom
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL)
OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId))
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
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES
(@ProductTypeId, @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
""";
await _connection.ExecuteAsync(createInsertSp);
await _connection.ExecuteAsync(alterInsertSp);
// ── 11. Drop GetActiveForMedio SP ──────────────────────────────────────
await _connection.ExecuteAsync("""
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
""");
// ── 12. Create GetActiveForProductType SP ──────────────────────────────
const string createGetForPtSp = """
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForProductType', N'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType AS RETURN 0');
""";
const string alterGetForPtSp = """
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType
@ProductTypeId INT,
@AsOfDate DATE
AS
BEGIN
SET NOCOUNT ON;
WITH Candidates AS (
SELECT
Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
ROW_NUMBER() OVER (
PARTITION BY Symbol
ORDER BY
CASE WHEN ProductTypeId = @ProductTypeId 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 (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL)
)
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM Candidates
WHERE rn = 1;
END
""";
await _connection.ExecuteAsync(createGetForPtSp);
await _connection.ExecuteAsync(alterGetForPtSp);
// ── 13. Create ReactivateWithGuard SP (NEW) ────────────────────────────
const string createReactivateSp = """
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_ReactivateWithGuard', N'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard AS RETURN 0');
""";
const string alterReactivateSp = """
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard
@Id BIGINT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT;
SELECT @ProductTypeId = ProductTypeId,
@Symbol = Symbol,
@ValidTo = ValidTo,
@IsActive = IsActive
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK)
WHERE Id = @Id;
IF @@ROWCOUNT = 0
BEGIN
ROLLBACK TRANSACTION;
THROW 50404, 'ChargeableCharConfig row not found', 1;
END
IF @ValidTo IS NULL
BEGIN
ROLLBACK TRANSACTION;
THROW 50410, 'Row is already active reactivation not needed', 1;
END
IF EXISTS (
SELECT 1 FROM dbo.ChargeableCharConfig
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
AND Symbol = @Symbol
AND ValidTo IS NULL
)
BEGIN
ROLLBACK TRANSACTION;
THROW 50411, 'A current active row already exists for this ProductType/Symbol cannot reactivate', 1;
END
IF EXISTS (
SELECT 1 FROM dbo.ChargeableCharConfig
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
AND Symbol = @Symbol
AND ValidFrom > @ValidTo
AND Id <> @Id
)
BEGIN
ROLLBACK TRANSACTION;
THROW 50412, 'Posterior rows exist for this ProductType/Symbol reactivation not allowed', 1;
END
UPDATE dbo.ChargeableCharConfig
SET IsActive = 1,
ValidTo = NULL
WHERE Id = @Id;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
""";
await _connection.ExecuteAsync(createReactivateSp);
await _connection.ExecuteAsync(alterReactivateSp);
}
/// <summary>
/// PRC-001 scope delta (V024): reseeds global ChargeableCharConfig rows to PricePerUnit = 0.0000.
/// Direct UPDATE — V022 seed price 1.0000 was always a placeholder, no business history exists.
/// Safe to re-run: already-zero rows are unchanged.
/// Requires ProductTypeId column to exist (V023 must have run).
/// </summary>
private async Task EnsureV024SeedAsync()
{
const string sql = """
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
AND EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'ProductTypeId')
BEGIN
UPDATE dbo.ChargeableCharConfig
SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4))
WHERE ProductTypeId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL;
END
""";
await _connection.ExecuteAsync(sql);
}
}