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();