fix(backend+tests): reactivate endpoint 500 + test schema mismatches (PRC-001)
Three bugs surfaced while user smoke-testing Reactivate: 1. ReactivateAsync opened a SECOND connection for GetByIdAsync after the SP call, inside the ambient TransactionScope. This promoted the tx to DTC (distributed) which requires MSDTC — typically not enabled on dev/prod servers. The API returned an opaque 500. Fix: run the post-SP SELECT on the SAME connection (local tx stays lightweight / LTM). 2. Agent 1's V023 test refactor wrote 'INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)' in 2 test files — but dbo.ProductType has no 'Codigo' or 'Activo' columns (schema is Nombre + IsActive + flags + multimedia limits). Fix: use '(Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)' matching the other test files (ProductQueryRepositoryTests, ProductRepositoryTests, ProductPriceRepositoryIntegrationTests). 3. SqlTestFixture.EnsureV021SchemaAsync unconditionally ALTERed the V021-era SPs with '@MedioId' body. On second fixture run after V023 had already refactored the table, ALTER PROCEDURE body referenced a MedioId column that no longer existed — 'Invalid column name MedioId'. Fix: guard the V021 SP ALTERs + seedV022 behind 'MedioId column exists' check. If V023 already dropped MedioId, skip V021 re-install; EnsureV023SchemaAsync still recreates SPs with @ProductTypeId. 4. PricingExceptionTests still used 'medioId:' named-arg + '.MedioId' — Agent 2 renamed the exception property but not these 6 test references. Tests: 1297/1297 Application.Tests green.
This commit is contained in:
@@ -237,6 +237,11 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
|||||||
long id,
|
long id,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// IMPORTANT: the SP invocation and the subsequent SELECT MUST run on the SAME connection.
|
||||||
|
// Opening a second connection within the ambient TransactionScope would promote the
|
||||||
|
// transaction to DTC (distributed) — and MSDTC is typically not enabled on dev/prod
|
||||||
|
// servers. That promotion surfaces as an opaque 500 at the API boundary. Keeping both
|
||||||
|
// commands on a single SqlConnection keeps the tx as a local LTM (lightweight transaction).
|
||||||
var p = new DynamicParameters();
|
var p = new DynamicParameters();
|
||||||
p.Add("@Id", id, DbType.Int64);
|
p.Add("@Id", id, DbType.Int64);
|
||||||
|
|
||||||
@@ -270,11 +275,20 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
|
|||||||
throw new ChargeableCharConfigReactivationNotAllowedException(id, "POSTERIOR_ROWS_EXIST");
|
throw new ChargeableCharConfigReactivationNotAllowedException(id, "POSTERIOR_ROWS_EXIST");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the reactivated row to return its current state.
|
// Fetch the reactivated row on the SAME connection (avoids DTC promotion).
|
||||||
var reactivated = await GetByIdAsync(id, ct);
|
const string selectSql = """
|
||||||
return reactivated
|
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||||
?? throw new ChargeableCharConfigInvalidException(
|
FROM dbo.ChargeableCharConfig
|
||||||
nameof(id), $"ChargeableCharConfig with Id={id} not found after reactivation (unexpected).");
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<ChargeableCharConfigRow>(
|
||||||
|
new CommandDefinition(selectSql, new { Id = id }, cancellationToken: ct));
|
||||||
|
|
||||||
|
return row is null
|
||||||
|
? throw new ChargeableCharConfigInvalidException(
|
||||||
|
nameof(id), $"ChargeableCharConfig with Id={id} not found after reactivation (unexpected).")
|
||||||
|
: MapRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
@@ -101,23 +101,23 @@ public sealed class PricingExceptionTests
|
|||||||
var activeVf = new DateOnly(2026, 4, 1);
|
var activeVf = new DateOnly(2026, 4, 1);
|
||||||
|
|
||||||
var ex = new ChargeableCharConfigForwardOnlyException(
|
var ex = new ChargeableCharConfigForwardOnlyException(
|
||||||
medioId: 5, symbol: "$", newValidFrom: newVf, activeValidFrom: activeVf);
|
productTypeId: 5, symbol: "$", newValidFrom: newVf, activeValidFrom: activeVf);
|
||||||
|
|
||||||
ex.MedioId.Should().Be(5);
|
ex.ProductTypeId.Should().Be(5);
|
||||||
ex.Symbol.Should().Be("$");
|
ex.Symbol.Should().Be("$");
|
||||||
ex.NewValidFrom.Should().Be(newVf);
|
ex.NewValidFrom.Should().Be(newVf);
|
||||||
ex.ActiveValidFrom.Should().Be(activeVf);
|
ex.ActiveValidFrom.Should().Be(activeVf);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ChargeableCharConfigForwardOnlyException_NullMedioId_IsAllowed()
|
public void ChargeableCharConfigForwardOnlyException_NullProductTypeId_IsAllowed()
|
||||||
{
|
{
|
||||||
var ex = new ChargeableCharConfigForwardOnlyException(
|
var ex = new ChargeableCharConfigForwardOnlyException(
|
||||||
medioId: null, symbol: "$",
|
productTypeId: null, symbol: "$",
|
||||||
newValidFrom: new DateOnly(2026, 3, 1),
|
newValidFrom: new DateOnly(2026, 3, 1),
|
||||||
activeValidFrom: new DateOnly(2026, 4, 1));
|
activeValidFrom: new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
ex.MedioId.Should().BeNull();
|
ex.ProductTypeId.Should().BeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -127,7 +127,7 @@ public sealed class PricingExceptionTests
|
|||||||
var activeVf = new DateOnly(2026, 4, 1);
|
var activeVf = new DateOnly(2026, 4, 1);
|
||||||
|
|
||||||
var ex = new ChargeableCharConfigForwardOnlyException(
|
var ex = new ChargeableCharConfigForwardOnlyException(
|
||||||
medioId: null, symbol: "$", newValidFrom: newVf, activeValidFrom: activeVf);
|
productTypeId: null, symbol: "$", newValidFrom: newVf, activeValidFrom: activeVf);
|
||||||
|
|
||||||
ex.Message.Should().Contain("2026-03-01");
|
ex.Message.Should().Contain("2026-03-01");
|
||||||
ex.Message.Should().Contain("2026-04-01");
|
ex.Message.Should().Contain("2026-04-01");
|
||||||
@@ -137,7 +137,7 @@ public sealed class PricingExceptionTests
|
|||||||
public void ChargeableCharConfigForwardOnlyException_InheritsFromDomainException()
|
public void ChargeableCharConfigForwardOnlyException_InheritsFromDomainException()
|
||||||
{
|
{
|
||||||
var ex = new ChargeableCharConfigForwardOnlyException(
|
var ex = new ChargeableCharConfigForwardOnlyException(
|
||||||
medioId: null, symbol: "$",
|
productTypeId: null, symbol: "$",
|
||||||
newValidFrom: new DateOnly(2026, 3, 1),
|
newValidFrom: new DateOnly(2026, 3, 1),
|
||||||
activeValidFrom: new DateOnly(2026, 4, 1));
|
activeValidFrom: new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
|||||||
@@ -51,15 +51,15 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
|
|||||||
// Seed two dedicated ProductTypes for override/fallback resolution tests.
|
// Seed two dedicated ProductTypes for override/fallback resolution tests.
|
||||||
// V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id).
|
// V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id).
|
||||||
_productType1Id = await conn.ExecuteScalarAsync<int>("""
|
_productType1Id = await conn.ExecuteScalarAsync<int>("""
|
||||||
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
|
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
|
||||||
OUTPUT INSERTED.Id
|
OUTPUT INSERTED.Id
|
||||||
VALUES ('Hardening PT1 (override)', 'H_PT1', 1)
|
VALUES ('Hardening PT1 (override)', 0, 0, 0, 0, 0)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
_productType2Id = await conn.ExecuteScalarAsync<int>("""
|
_productType2Id = await conn.ExecuteScalarAsync<int>("""
|
||||||
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
|
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
|
||||||
OUTPUT INSERTED.Id
|
OUTPUT INSERTED.Id
|
||||||
VALUES ('Hardening PT2 (fallback)', 'H_PT2', 1)
|
VALUES ('Hardening PT2 (fallback)', 0, 0, 0, 0, 0)
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
|
|||||||
await conn.OpenAsync();
|
await conn.OpenAsync();
|
||||||
|
|
||||||
_productTypeId = await conn.ExecuteScalarAsync<int>("""
|
_productTypeId = await conn.ExecuteScalarAsync<int>("""
|
||||||
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
|
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
|
||||||
OUTPUT INSERTED.Id
|
OUTPUT INSERTED.Id
|
||||||
VALUES ('RepoIntegration PT1', 'RI_PT1', 1)
|
VALUES ('RepoIntegration PT1', 0, 0, 0, 0, 0)
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,9 +263,9 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
|
|||||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
await conn.OpenAsync();
|
await conn.OpenAsync();
|
||||||
var otherPTId = await conn.ExecuteScalarAsync<int>("""
|
var otherPTId = await conn.ExecuteScalarAsync<int>("""
|
||||||
INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
|
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
|
||||||
OUTPUT INSERTED.Id
|
OUTPUT INSERTED.Id
|
||||||
VALUES ('RepoIntegration PT2 NoOverride', 'RI_PT2', 1)
|
VALUES ('RepoIntegration PT2 NoOverride', 0, 0, 0, 0, 0)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
var rows = await repo.GetActiveForProductTypeAsync((long)otherPTId, asOf);
|
var rows = await repo.GetActiveForProductTypeAsync((long)otherPTId, asOf);
|
||||||
|
|||||||
@@ -1522,11 +1522,30 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
await _connection.ExecuteAsync(setVersioning);
|
await _connection.ExecuteAsync(setVersioning);
|
||||||
await _connection.ExecuteAsync(createVigenteIndex);
|
await _connection.ExecuteAsync(createVigenteIndex);
|
||||||
await _connection.ExecuteAsync(createQueryIndex);
|
await _connection.ExecuteAsync(createQueryIndex);
|
||||||
await _connection.ExecuteAsync(createInsertSp);
|
|
||||||
await _connection.ExecuteAsync(alterInsertSp);
|
// Guard: only ALTER the V021-era SPs + seed with MedioId if the column still exists.
|
||||||
await _connection.ExecuteAsync(createGetActiveSp);
|
// If V023 already refactored the table in a prior run of the fixture on the same DB,
|
||||||
await _connection.ExecuteAsync(alterGetActiveSp);
|
// MedioId is gone and these ALTERs would fail with "Invalid column name 'MedioId'".
|
||||||
await _connection.ExecuteAsync(seedV022);
|
// EnsureV023SchemaAsync (called right after) will re-install the SPs with @ProductTypeId.
|
||||||
|
const string hasMedioIdCheck = """
|
||||||
|
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>(hasMedioIdCheck);
|
||||||
|
|
||||||
|
if (hasMedioId)
|
||||||
|
{
|
||||||
|
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
|
// Permission 'tasacion:caracteres_especiales:gestionar' and admin assignment
|
||||||
// are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
|
// are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user