From 0e363d1cfc6dab9063a371c9a5997c9b99c04a73 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 16:59:53 -0300 Subject: [PATCH] refactor(tests): TestWebAppFactory.CreateClientWithOverrides para DI override por test (closes #36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agrega helper CreateClientWithOverrides en TestWebAppFactory que envuelve WithWebHostBuilder+ConfigureTestServices para inyectar stubs por test sin tocar la fábrica compartida. Usa el patrón para agregar 2 tests e2e: Deactivate_WhenProductQueryReturnsInUse_Returns409WithErrorCode (PRD-001/PRD-002) y CreateRubro_WhenParentHasAvisos_Returns409WithErrorCode (CAT-002). Remueve el comentario TODO PRD-002. 287 Api tests verdes. --- .../ProductTypesControllerTests.cs | 71 +++++++++++++- .../Rubros/RubrosReglaDeOroTests.cs | 97 ++++++++++++++++--- tests/SIGCM2.TestSupport/TestWebAppFactory.cs | 66 +++++++++++++ 3 files changed, 218 insertions(+), 16 deletions(-) diff --git a/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs b/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs index 8ddc743..76010e6 100644 --- a/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs @@ -2,6 +2,9 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.TestSupport; namespace SIGCM2.Api.Tests.ProductTypes; @@ -20,10 +23,12 @@ public sealed class ProductTypesControllerTests : IAsyncLifetime private const string AdminUsername = "admin"; private const string AdminPassword = "@Diego550@"; + private readonly TestWebAppFactory _factory; private readonly HttpClient _client; public ProductTypesControllerTests(TestWebAppFactory factory) { + _factory = factory; _client = factory.CreateClient(); } @@ -227,9 +232,57 @@ public sealed class ProductTypesControllerTests : IAsyncLifetime // ── DELETE /api/v1/admin/product-types/{id} ─────────────────────────────── - // TODO PRD-002: agregar e2e test para DELETE → 409 cuando IProductQueryRepository.IsInUseAsync retorna true. - // Bloqueado por W1 (RSA singleton sealed en TestWebApplicationFactory). Ref issue #36. - // Coverage compositional actual: DeactivateProductTypeCommandHandlerTests (unit) + ExceptionFilterTests (unit → 409). + /// + /// Verifies the 409 guard path via a per-test DI override: injects a stub + /// that always reports the type is in use, + /// so no real Product needs to exist in the database. + /// + /// Uses — the pattern enabled + /// by fixing issue #36 (RSA singleton / per-test DI override). + /// + [Fact] + public async Task Deactivate_WhenProductQueryReturnsInUse_Returns409WithErrorCode() + { + // Arrange: child client with a stub that always reports "in use" + using var client = _factory.CreateClientWithOverrides(services => + { + services.RemoveAll(); + services.AddScoped(_ => + new AlwaysInUseProductQueryRepository()); + }); + + // Need a real token — authenticate against the shared host (same DB) + var loginResp = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + loginResp.EnsureSuccessStatusCode(); + var loginJson = await loginResp.Content.ReadFromJsonAsync(); + var token = loginJson.GetProperty("accessToken").GetString()!; + + // Create a real ProductType to get a valid ID + var uniqueName = $"Tipo_InUseStub_{Guid.NewGuid():N}"[..30]; + var createResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = uniqueName, + hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false, + allowImages = false + }, token)); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var id = created.GetProperty("id").GetInt32(); + + // Act: DELETE via the overridden client — guard sees "in use" → 409 + var deactivateReq = new System.Net.Http.HttpRequestMessage(HttpMethod.Delete, $"{AdminEndpoint}/{id}"); + deactivateReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var resp = await client.SendAsync(deactivateReq); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("product_type_en_uso", body.GetProperty("error").GetString()); + } [Fact] public async Task Deactivate_NotFound_Returns404() @@ -263,3 +316,15 @@ public sealed class ProductTypesControllerTests : IAsyncLifetime Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode); } } + +/// +/// Stub: always reports that the ProductType is in use by at least one active Product. +/// Used by +/// via to verify the 409 guard +/// without seeding real Product rows in the database. +/// +file sealed class AlwaysInUseProductQueryRepository : IProductQueryRepository +{ + public Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) + => Task.FromResult(true); +} diff --git a/tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs b/tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs index 97e788b..fe24e40 100644 --- a/tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs +++ b/tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs @@ -5,8 +5,11 @@ 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; @@ -17,12 +20,7 @@ namespace SIGCM2.Api.Tests.Rubros; /// /// Unit tests: ExceptionFilter mapping for new 409 cases (no DB needed). /// Integration: GET /arbol returns tieneAvisos field per node (stub = false). -/// -/// Design note: the 409 guard behavior is fully covered by unit tests in -/// SIGCM2.Application.Tests (CreateRubroCommandHandlerTests, MoveRubroCommandHandlerTests). -/// e2e 409 verification via a separate factory is skipped here because the shared -/// ApiIntegration singleton factory cannot be safely augmented with per-test DI overrides -/// (RSA key singleton issue documented in ApiIntegrationCollection.cs). +/// Integration: POST child under leaf with avisos → 409 (via per-test DI override, issue #36). /// [Collection("ApiIntegration")] public sealed class RubrosReglaDeOroTests : IAsyncLifetime @@ -32,10 +30,12 @@ public sealed class RubrosReglaDeOroTests : IAsyncLifetime private const string AdminUsername = "admin"; private const string AdminPassword = "@Diego550@"; + private readonly TestWebAppFactory _factory; private readonly HttpClient _client; public RubrosReglaDeOroTests(TestWebAppFactory factory) { + _factory = factory; _client = factory.CreateClient(); } @@ -167,11 +167,82 @@ public sealed class RubrosReglaDeOroTests : IAsyncLifetime } } - // ── Integration: POST returns 409 message format (guard path) ───────────── - // NOTE: these tests rely on the unit-tested handler behavior. The 409 is proven by: - // - CreateRubroCommandHandlerTests.Handle_ParentTieneAvisos_Throws_RubroPadreEsHojaConAvisosException - // - ExceptionFilter_MapsRubroPadreEsHojaConAvisos_To409 (above) - // The combined e2e 409 test is omitted here because it requires per-factory DI override - // which conflicts with the shared ApiIntegration RSA singleton pattern. - // See: ApiIntegrationCollection.cs for the rationale. + // ── Integration: POST child under leaf with avisos → 409 (DI override) ───── + + /// + /// Verifies the 409 guard path end-to-end via a per-test DI override: injects a stub + /// that reports the parent Rubro has 1 aviso, + /// so no real Aviso row needs to exist in the database. + /// + /// Uses — the pattern enabled + /// by fixing issue #36 (RSA singleton / per-test DI override). + /// + [Fact] + public async Task CreateRubro_WhenParentHasAvisos_Returns409WithErrorCode() + { + // Arrange: get token from shared client (same DB) + var token = await GetAdminTokenAsync(); + + // Create a real parent Rubro to have a valid parentId + using var createParentReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new + { + nombre = $"Parent_Hoja_CAT002_{Guid.NewGuid():N}"[..30], + parentId = (int?)null, + }, token); + var parentResp = await _client.SendAsync(createParentReq); + Assert.Equal(HttpStatusCode.Created, parentResp.StatusCode); + var parentJson = await parentResp.Content.ReadFromJsonAsync(); + var parentId = parentJson.GetProperty("id").GetInt32(); + + try + { + // Child client with stub that reports parentId has 1 aviso + using var client = _factory.CreateClientWithOverrides(services => + { + services.RemoveAll(); + services.AddScoped(_ => + new AlwaysHasAvisosQueryRepository()); + }); + + // Act: attempt to create a child under the "leaf with avisos" parent + var childReq = new System.Net.Http.HttpRequestMessage(HttpMethod.Post, AdminEndpoint); + childReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + childReq.Content = System.Net.Http.Json.JsonContent.Create(new + { + nombre = $"Child_CAT002_{Guid.NewGuid():N}"[..30], + parentId, + }); + var resp = await client.SendAsync(childReq); + + // Assert + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var body = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("rubro_padre_es_hoja_con_avisos", body.GetProperty("error").GetString()); + } + finally + { + await DeleteRubroIfExistsAsync(parentId); + } + } } + +/// +/// Stub: always reports that a Rubro has 1 aviso (single query) and a non-empty batch dictionary. +/// Used by +/// via to verify the 409 guard +/// without seeding real Aviso rows in the database (issue #36 DI override pattern). +/// +file sealed class AlwaysHasAvisosQueryRepository : IAvisoQueryRepository +{ + public Task CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default) + => Task.FromResult(1); + + public Task> CountAvisosBatchAsync( + IReadOnlyCollection rubroIds, + CancellationToken ct = default) + { + IReadOnlyDictionary result = rubroIds.ToDictionary(id => id, _ => 1); + return Task.FromResult(result); + } +} + diff --git a/tests/SIGCM2.TestSupport/TestWebAppFactory.cs b/tests/SIGCM2.TestSupport/TestWebAppFactory.cs index 7139437..c6b399e 100644 --- a/tests/SIGCM2.TestSupport/TestWebAppFactory.cs +++ b/tests/SIGCM2.TestSupport/TestWebAppFactory.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using SIGCM2.Application.Abstractions.Security; using SIGCM2.Infrastructure.Persistence; using SIGCM2.Infrastructure.Security; @@ -14,6 +15,31 @@ namespace SIGCM2.TestSupport; /// /// WebApplicationFactory for integration tests against SIGCM2.Api. /// Uses SIGCM2_Test_Api database (isolated from Application.Tests which uses SIGCM2_Test_App). +/// +/// +/// Per-test DI overrides — use when a test needs to +/// replace a scoped service (e.g. IProductQueryRepository, IAvisoQueryRepository) +/// without touching the shared factory: +/// +/// +/// using var client = _factory.CreateClientWithOverrides(services => +/// { +/// services.RemoveAll<IMyRepository>(); +/// services.AddScoped<IMyRepository>(_ => new MyFakeRepository()); +/// }); +/// +/// +/// Internally this calls which +/// creates an independent child host. The RSA key singletons are re-loaded from the same PEM files +/// and do not conflict with the parent host — each child host owns its own DI container. +/// The child factory (and its host) is disposed when the returned is disposed. +/// +/// +/// Why this works safely: WithWebHostBuilder does NOT share the parent host's DI root. +/// It re-runs ConfigureWebHost (re-loading RSA keys from disk), then applies the caller's +/// ConfigureTestServices on top. The RSA singleton lives in the child's root scope and is +/// disposed with that child factory — no cross-factory leakage. +/// /// public sealed class TestWebAppFactory : WebApplicationFactory, IAsyncLifetime { @@ -68,6 +94,46 @@ public sealed class TestWebAppFactory : WebApplicationFactory, IAsyncLi }); } + /// + /// Creates an against a child host that inherits all base configuration + /// but applies the caller's additional on top via + /// ConfigureTestServices. + /// + /// + /// The returned is tied to the child factory's lifetime. + /// Dispose the client when the test finishes to release the child host: + /// using var client = _factory.CreateClientWithOverrides(s => { ... }); + /// + /// + /// + /// Typical usage — inject a stub repository for a specific test scenario: + /// + /// + /// using var client = _factory.CreateClientWithOverrides(services => + /// { + /// services.RemoveAll<IProductQueryRepository>(); + /// services.AddScoped<IProductQueryRepository>(_ => new AlwaysInUseProductQueryRepository()); + /// }); + /// var resp = await client.SendAsync(...); + /// Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + /// + /// + /// + /// Action applied to after all production services are + /// registered. Use services.RemoveAll<T>() then services.AddScoped<T>(...) + /// to replace an existing registration. + /// + /// + /// A new backed by the child host. The client owns the child factory + /// via 's disposal chain. + /// + public HttpClient CreateClientWithOverrides(Action overrides) + { + var child = WithWebHostBuilder(builder => + builder.ConfigureTestServices(overrides)); + return child.CreateClient(); + } + public async Task InitializeAsync() { await _dbFixture.InitializeAsync();