refactor(tests): TestWebAppFactory.CreateClientWithOverrides para DI override por test (closes #36)
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.
This commit is contained in:
@@ -2,6 +2,9 @@ using System.Net;
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.TestSupport;
|
using SIGCM2.TestSupport;
|
||||||
|
|
||||||
namespace SIGCM2.Api.Tests.ProductTypes;
|
namespace SIGCM2.Api.Tests.ProductTypes;
|
||||||
@@ -20,10 +23,12 @@ public sealed class ProductTypesControllerTests : IAsyncLifetime
|
|||||||
private const string AdminUsername = "admin";
|
private const string AdminUsername = "admin";
|
||||||
private const string AdminPassword = "@Diego550@";
|
private const string AdminPassword = "@Diego550@";
|
||||||
|
|
||||||
|
private readonly TestWebAppFactory _factory;
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
public ProductTypesControllerTests(TestWebAppFactory factory)
|
public ProductTypesControllerTests(TestWebAppFactory factory)
|
||||||
{
|
{
|
||||||
|
_factory = factory;
|
||||||
_client = factory.CreateClient();
|
_client = factory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,9 +232,57 @@ public sealed class ProductTypesControllerTests : IAsyncLifetime
|
|||||||
|
|
||||||
// ── DELETE /api/v1/admin/product-types/{id} ───────────────────────────────
|
// ── DELETE /api/v1/admin/product-types/{id} ───────────────────────────────
|
||||||
|
|
||||||
// TODO PRD-002: agregar e2e test para DELETE → 409 cuando IProductQueryRepository.IsInUseAsync retorna true.
|
/// <summary>
|
||||||
// Bloqueado por W1 (RSA singleton sealed en TestWebApplicationFactory). Ref issue #36.
|
/// Verifies the 409 guard path via a per-test DI override: injects a stub
|
||||||
// Coverage compositional actual: DeactivateProductTypeCommandHandlerTests (unit) + ExceptionFilterTests (unit → 409).
|
/// <see cref="IProductQueryRepository"/> that always reports the type is in use,
|
||||||
|
/// so no real Product needs to exist in the database.
|
||||||
|
///
|
||||||
|
/// Uses <see cref="TestWebAppFactory.CreateClientWithOverrides"/> — the pattern enabled
|
||||||
|
/// by fixing issue #36 (RSA singleton / per-test DI override).
|
||||||
|
/// </summary>
|
||||||
|
[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<IProductQueryRepository>();
|
||||||
|
services.AddScoped<IProductQueryRepository>(_ =>
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
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<JsonElement>();
|
||||||
|
Assert.Equal("product_type_en_uso", body.GetProperty("error").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Deactivate_NotFound_Returns404()
|
public async Task Deactivate_NotFound_Returns404()
|
||||||
@@ -263,3 +316,15 @@ public sealed class ProductTypesControllerTests : IAsyncLifetime
|
|||||||
Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stub: always reports that the ProductType is in use by at least one active Product.
|
||||||
|
/// Used by <see cref="ProductTypesControllerTests.Deactivate_WhenProductQueryReturnsInUse_Returns409WithErrorCode"/>
|
||||||
|
/// via <see cref="TestWebAppFactory.CreateClientWithOverrides"/> to verify the 409 guard
|
||||||
|
/// without seeding real Product rows in the database.
|
||||||
|
/// </summary>
|
||||||
|
file sealed class AlwaysInUseProductQueryRepository : IProductQueryRepository
|
||||||
|
{
|
||||||
|
public Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(true);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ using System.Text.Json;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using SIGCM2.Api.Filters;
|
using SIGCM2.Api.Filters;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
using SIGCM2.TestSupport;
|
using SIGCM2.TestSupport;
|
||||||
|
|
||||||
@@ -17,12 +20,7 @@ namespace SIGCM2.Api.Tests.Rubros;
|
|||||||
///
|
///
|
||||||
/// Unit tests: ExceptionFilter mapping for new 409 cases (no DB needed).
|
/// Unit tests: ExceptionFilter mapping for new 409 cases (no DB needed).
|
||||||
/// Integration: GET /arbol returns tieneAvisos field per node (stub = false).
|
/// Integration: GET /arbol returns tieneAvisos field per node (stub = false).
|
||||||
///
|
/// Integration: POST child under leaf with avisos → 409 (via per-test DI override, issue #36).
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("ApiIntegration")]
|
[Collection("ApiIntegration")]
|
||||||
public sealed class RubrosReglaDeOroTests : IAsyncLifetime
|
public sealed class RubrosReglaDeOroTests : IAsyncLifetime
|
||||||
@@ -32,10 +30,12 @@ public sealed class RubrosReglaDeOroTests : IAsyncLifetime
|
|||||||
private const string AdminUsername = "admin";
|
private const string AdminUsername = "admin";
|
||||||
private const string AdminPassword = "@Diego550@";
|
private const string AdminPassword = "@Diego550@";
|
||||||
|
|
||||||
|
private readonly TestWebAppFactory _factory;
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
public RubrosReglaDeOroTests(TestWebAppFactory factory)
|
public RubrosReglaDeOroTests(TestWebAppFactory factory)
|
||||||
{
|
{
|
||||||
|
_factory = factory;
|
||||||
_client = factory.CreateClient();
|
_client = factory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,11 +167,82 @@ public sealed class RubrosReglaDeOroTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Integration: POST returns 409 message format (guard path) ─────────────
|
// ── Integration: POST child under leaf with avisos → 409 (DI override) ─────
|
||||||
// NOTE: these tests rely on the unit-tested handler behavior. The 409 is proven by:
|
|
||||||
// - CreateRubroCommandHandlerTests.Handle_ParentTieneAvisos_Throws_RubroPadreEsHojaConAvisosException
|
/// <summary>
|
||||||
// - ExceptionFilter_MapsRubroPadreEsHojaConAvisos_To409 (above)
|
/// Verifies the 409 guard path end-to-end via a per-test DI override: injects a stub
|
||||||
// The combined e2e 409 test is omitted here because it requires per-factory DI override
|
/// <see cref="IAvisoQueryRepository"/> that reports the parent Rubro has 1 aviso,
|
||||||
// which conflicts with the shared ApiIntegration RSA singleton pattern.
|
/// so no real Aviso row needs to exist in the database.
|
||||||
// See: ApiIntegrationCollection.cs for the rationale.
|
///
|
||||||
|
/// Uses <see cref="TestWebAppFactory.CreateClientWithOverrides"/> — the pattern enabled
|
||||||
|
/// by fixing issue #36 (RSA singleton / per-test DI override).
|
||||||
|
/// </summary>
|
||||||
|
[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<JsonElement>();
|
||||||
|
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<IAvisoQueryRepository>();
|
||||||
|
services.AddScoped<IAvisoQueryRepository>(_ =>
|
||||||
|
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<JsonElement>();
|
||||||
|
Assert.Equal("rubro_padre_es_hoja_con_avisos", body.GetProperty("error").GetString());
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteRubroIfExistsAsync(parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stub: always reports that a Rubro has 1 aviso (single query) and a non-empty batch dictionary.
|
||||||
|
/// Used by <see cref="RubrosReglaDeOroTests.CreateRubro_WhenParentHasAvisos_Returns409WithErrorCode"/>
|
||||||
|
/// via <see cref="TestWebAppFactory.CreateClientWithOverrides"/> to verify the 409 guard
|
||||||
|
/// without seeding real Aviso rows in the database (issue #36 DI override pattern).
|
||||||
|
/// </summary>
|
||||||
|
file sealed class AlwaysHasAvisosQueryRepository : IAvisoQueryRepository
|
||||||
|
{
|
||||||
|
public Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(1);
|
||||||
|
|
||||||
|
public Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
|
||||||
|
IReadOnlyCollection<int> rubroIds,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IReadOnlyDictionary<int, int> result = rubroIds.ToDictionary(id => id, _ => 1);
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
using SIGCM2.Infrastructure.Persistence;
|
using SIGCM2.Infrastructure.Persistence;
|
||||||
using SIGCM2.Infrastructure.Security;
|
using SIGCM2.Infrastructure.Security;
|
||||||
@@ -14,6 +15,31 @@ namespace SIGCM2.TestSupport;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// WebApplicationFactory for integration tests against SIGCM2.Api.
|
/// WebApplicationFactory for integration tests against SIGCM2.Api.
|
||||||
/// Uses SIGCM2_Test_Api database (isolated from Application.Tests which uses SIGCM2_Test_App).
|
/// Uses SIGCM2_Test_Api database (isolated from Application.Tests which uses SIGCM2_Test_App).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Per-test DI overrides</b> — use <see cref="CreateClientWithOverrides"/> when a test needs to
|
||||||
|
/// replace a scoped service (e.g. <c>IProductQueryRepository</c>, <c>IAvisoQueryRepository</c>)
|
||||||
|
/// without touching the shared factory:
|
||||||
|
/// </para>
|
||||||
|
/// <code>
|
||||||
|
/// using var client = _factory.CreateClientWithOverrides(services =>
|
||||||
|
/// {
|
||||||
|
/// services.RemoveAll<IMyRepository>();
|
||||||
|
/// services.AddScoped<IMyRepository>(_ => new MyFakeRepository());
|
||||||
|
/// });
|
||||||
|
/// </code>
|
||||||
|
/// <para>
|
||||||
|
/// Internally this calls <see cref="WebApplicationFactory{TEntryPoint}.WithWebHostBuilder"/> 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 <see cref="HttpClient"/> is disposed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why this works safely</b>: <c>WithWebHostBuilder</c> does NOT share the parent host's DI root.
|
||||||
|
/// It re-runs <c>ConfigureWebHost</c> (re-loading RSA keys from disk), then applies the caller's
|
||||||
|
/// <c>ConfigureTestServices</c> on top. The RSA singleton lives in the child's root scope and is
|
||||||
|
/// disposed with that child factory — no cross-factory leakage.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
public sealed class TestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||||
{
|
{
|
||||||
@@ -68,6 +94,46 @@ public sealed class TestWebAppFactory : WebApplicationFactory<Program>, IAsyncLi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an <see cref="HttpClient"/> against a child host that inherits all base configuration
|
||||||
|
/// but applies the caller's additional <paramref name="overrides"/> on top via
|
||||||
|
/// <c>ConfigureTestServices</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The returned <see cref="HttpClient"/> is tied to the child factory's lifetime.
|
||||||
|
/// Dispose the client when the test finishes to release the child host:
|
||||||
|
/// <code>using var client = _factory.CreateClientWithOverrides(s => { ... });</code>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Typical usage — inject a stub repository for a specific test scenario:
|
||||||
|
/// </para>
|
||||||
|
/// <code>
|
||||||
|
/// 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);
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="overrides">
|
||||||
|
/// Action applied to <see cref="IServiceCollection"/> after all production services are
|
||||||
|
/// registered. Use <c>services.RemoveAll<T>()</c> then <c>services.AddScoped<T>(...)</c>
|
||||||
|
/// to replace an existing registration.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// A new <see cref="HttpClient"/> backed by the child host. The client owns the child factory
|
||||||
|
/// via <see cref="WebApplicationFactory{TEntryPoint}.CreateClient()"/>'s disposal chain.
|
||||||
|
/// </returns>
|
||||||
|
public HttpClient CreateClientWithOverrides(Action<IServiceCollection> overrides)
|
||||||
|
{
|
||||||
|
var child = WithWebHostBuilder(builder =>
|
||||||
|
builder.ConfigureTestServices(overrides));
|
||||||
|
return child.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
await _dbFixture.InitializeAsync();
|
await _dbFixture.InitializeAsync();
|
||||||
|
|||||||
Reference in New Issue
Block a user