From 50a5118a78cf71b395249cdab7bdfe7e74f3ff3b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 17:08:42 -0300 Subject: [PATCH] feat(api): ExceptionFilter + e2e 409 para RubroConProductosActivos (closes #41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mapea RubroConProductosActivosException → HTTP 409 con error code rubro_con_productos_activos. Test e2e usa DI override (patrón issue #36) para stub IProductQueryRepository sin sembrar Products reales en DB. --- src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 12 ++ .../ProductTypesControllerTests.cs | 3 + .../Rubros/RubrosDeactivateGuardTests.cs | 160 ++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Rubros/RubrosDeactivateGuardTests.cs diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index decc84d..8fc1884 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -267,6 +267,18 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + case RubroConProductosActivosException rubroProductosEx: + context.Result = new ObjectResult(new + { + error = "rubro_con_productos_activos", + message = rubroProductosEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + // ADM-001: Medio exceptions case MedioCodigoDuplicadoException medioCodDupEx: context.Result = new ObjectResult(new diff --git a/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs b/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs index 76010e6..aa1890b 100644 --- a/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs @@ -327,4 +327,7 @@ file sealed class AlwaysInUseProductQueryRepository : IProductQueryRepository { public Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) => Task.FromResult(true); + + public Task CountActiveByRubroAsync(int rubroId, CancellationToken ct = default) + => Task.FromResult(0); } diff --git a/tests/SIGCM2.Api.Tests/Rubros/RubrosDeactivateGuardTests.cs b/tests/SIGCM2.Api.Tests/Rubros/RubrosDeactivateGuardTests.cs new file mode 100644 index 0000000..82b158f --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Rubros/RubrosDeactivateGuardTests.cs @@ -0,0 +1,160 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging.Abstractions; +using SIGCM2.Api.Filters; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Rubros; + +/// +/// Issue #41 — DeactivateRubroCommandHandler guard contra Products activos. +/// +/// Unit tests: ExceptionFilter mapping for RubroConProductosActivosException → 409. +/// E2E: DELETE /api/v1/admin/rubros/{id} with stub IProductQueryRepository returning count > 0 → 409. +/// +[Collection("ApiIntegration")] +public sealed class RubrosDeactivateGuardTests : IAsyncLifetime +{ + private const string AdminEndpoint = "/api/v1/admin/rubros"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly TestWebAppFactory _factory; + private readonly HttpClient _client; + + public RubrosDeactivateGuardTests(TestWebAppFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task DisposeAsync() => Task.CompletedTask; + + // ── ExceptionFilter unit test (no DB, no HTTP) ──────────────────────────── + + [Fact] + public void ExceptionFilter_MapsRubroConProductosActivos_To409() + { + var filter = new ExceptionFilter(NullLogger.Instance); + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + var routeData = new Microsoft.AspNetCore.Routing.RouteData(); + var actionDescriptor = new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor(); + var modelState = new Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary(); + var actionContext = new ActionContext(httpContext, routeData, actionDescriptor, modelState); + var ctx = new ExceptionContext(actionContext, new List()) + { + Exception = new RubroConProductosActivosException(rubroId: 5, productosActivosCount: 2) + }; + + filter.OnException(ctx); + + var result = Assert.IsType(ctx.Result); + Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode); + var json = System.Text.Json.JsonSerializer.Serialize(result.Value); + Assert.Contains("rubro_con_productos_activos", json); + } + + // ── E2E: DELETE with stub reporting active products → 409 ───────────────── + + /// + /// Verifies the 409 guard path end-to-end via a per-test DI override: injects a stub + /// IProductQueryRepository that reports the Rubro has 2 active products, + /// so no real Product row needs to exist in the database (issue #36 DI override pattern). + /// + [Fact] + public async Task DeactivateRubro_WhenHasActiveProducts_Returns409WithErrorCode() + { + // Arrange: get admin token from shared client (same DB) + var token = await GetAdminTokenAsync(); + + // Create a real Rubro to have a valid id to deactivate + using var createReq = new HttpRequestMessage(HttpMethod.Post, AdminEndpoint); + createReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + createReq.Content = JsonContent.Create(new + { + nombre = $"Rubro_Guard41_{Guid.NewGuid():N}"[..30], + parentId = (int?)null, + }); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var rubroId = created.GetProperty("id").GetInt32(); + + try + { + // Override IProductQueryRepository to report 2 active products for any rubroId + using var overrideClient = _factory.CreateClientWithOverrides(services => + { + services.RemoveAll(); + services.AddScoped(_ => new StubProductQueryRepository(activeCount: 2)); + }); + + var deleteReq = new HttpRequestMessage(HttpMethod.Delete, $"{AdminEndpoint}/{rubroId}"); + deleteReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var resp = await overrideClient.SendAsync(deleteReq); + + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("rubro_con_productos_activos", body.GetProperty("error").GetString()); + } + finally + { + await DeleteRubroDirectAsync(rubroId); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private static async Task DeleteRubroDirectAsync(int id) + { + await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.ApiTestDb); + await conn.OpenAsync(); + await Dapper.SqlMapper.ExecuteAsync(conn, "ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = OFF)"); + await Dapper.SqlMapper.ExecuteAsync(conn, "DELETE FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id }); + await Dapper.SqlMapper.ExecuteAsync(conn, "DELETE FROM dbo.Rubro WHERE Id = @Id", new { Id = id }); + await Dapper.SqlMapper.ExecuteAsync(conn, + "ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Rubro_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + } +} + +/// +/// Stub: always reports a fixed count of active products for any rubroId. +/// Used by RubrosDeactivateGuardTests to verify the 409 guard without seeding real Product rows. +/// +file sealed class StubProductQueryRepository : IProductQueryRepository +{ + private readonly int _activeCount; + + public StubProductQueryRepository(int activeCount) + { + _activeCount = activeCount; + } + + public Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) + => Task.FromResult(false); + + public Task CountActiveByRubroAsync(int rubroId, CancellationToken ct = default) + => Task.FromResult(_activeCount); +}