From d7c6cbd4ffe1d3f1dc03aa621579a965a38040b6 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 21 Apr 2026 11:32:23 -0300 Subject: [PATCH] fix(backend+tests): reactivate endpoint 500 + test schema mismatches (PRC-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ChargeableCharConfigRepository.cs | 24 +++++++++++---- .../Exceptions/PricingExceptionTests.cs | 14 ++++----- .../ChargeableCharConfigHardeningTests.cs | 8 ++--- ...bleCharConfigRepositoryIntegrationTests.cs | 8 ++--- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 29 +++++++++++++++---- 5 files changed, 58 insertions(+), 25 deletions(-) diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs index dc5885e..5039a44 100644 --- a/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs +++ b/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs @@ -237,6 +237,11 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi long id, 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(); p.Add("@Id", id, DbType.Int64); @@ -270,11 +275,20 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi throw new ChargeableCharConfigReactivationNotAllowedException(id, "POSTERIOR_ROWS_EXIST"); } - // Fetch the reactivated row to return its current state. - var reactivated = await GetByIdAsync(id, ct); - return reactivated - ?? throw new ChargeableCharConfigInvalidException( - nameof(id), $"ChargeableCharConfig with Id={id} not found after reactivation (unexpected)."); + // Fetch the reactivated row on the SAME connection (avoids DTC promotion). + const string selectSql = """ + SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM dbo.ChargeableCharConfig + WHERE Id = @Id + """; + + var row = await connection.QuerySingleOrDefaultAsync( + 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); } /// diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs index a70b012..0a07afc 100644 --- a/tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs +++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs @@ -101,23 +101,23 @@ public sealed class PricingExceptionTests var activeVf = new DateOnly(2026, 4, 1); 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.NewValidFrom.Should().Be(newVf); ex.ActiveValidFrom.Should().Be(activeVf); } [Fact] - public void ChargeableCharConfigForwardOnlyException_NullMedioId_IsAllowed() + public void ChargeableCharConfigForwardOnlyException_NullProductTypeId_IsAllowed() { var ex = new ChargeableCharConfigForwardOnlyException( - medioId: null, symbol: "$", + productTypeId: null, symbol: "$", newValidFrom: new DateOnly(2026, 3, 1), activeValidFrom: new DateOnly(2026, 4, 1)); - ex.MedioId.Should().BeNull(); + ex.ProductTypeId.Should().BeNull(); } [Fact] @@ -127,7 +127,7 @@ public sealed class PricingExceptionTests var activeVf = new DateOnly(2026, 4, 1); 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-04-01"); @@ -137,7 +137,7 @@ public sealed class PricingExceptionTests public void ChargeableCharConfigForwardOnlyException_InheritsFromDomainException() { var ex = new ChargeableCharConfigForwardOnlyException( - medioId: null, symbol: "$", + productTypeId: null, symbol: "$", newValidFrom: new DateOnly(2026, 3, 1), activeValidFrom: new DateOnly(2026, 4, 1)); diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs index 8d06c96..e03fb0d 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs @@ -51,15 +51,15 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime // Seed two dedicated ProductTypes for override/fallback resolution tests. // V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id). _productType1Id = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) + INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages) OUTPUT INSERTED.Id - VALUES ('Hardening PT1 (override)', 'H_PT1', 1) + VALUES ('Hardening PT1 (override)', 0, 0, 0, 0, 0) """); _productType2Id = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) + INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages) OUTPUT INSERTED.Id - VALUES ('Hardening PT2 (fallback)', 'H_PT2', 1) + VALUES ('Hardening PT2 (fallback)', 0, 0, 0, 0, 0) """); } diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs index 8a70c9a..06a6ef1 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs @@ -58,9 +58,9 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime await conn.OpenAsync(); _productTypeId = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) + INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages) 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 conn.OpenAsync(); var otherPTId = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.ProductType (Nombre, Codigo, Activo) + INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages) 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); diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 4b07492..6bb7689 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -1522,11 +1522,30 @@ public sealed class SqlTestFixture : IAsyncLifetime 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); + + // Guard: only ALTER the V021-era SPs + seed with MedioId if the column still exists. + // If V023 already refactored the table in a prior run of the fixture on the same DB, + // MedioId is gone and these ALTERs would fail with "Invalid column name 'MedioId'". + // 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(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 // are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). }