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:
2026-04-19 16:59:53 -03:00
parent c5a8cd9edd
commit 0e363d1cfc
3 changed files with 218 additions and 16 deletions

View File

@@ -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).
/// <summary>
/// Verifies the 409 guard path via a per-test DI override: injects a stub
/// <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]
public async Task Deactivate_NotFound_Returns404()
@@ -263,3 +316,15 @@ public sealed class ProductTypesControllerTests : IAsyncLifetime
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);
}

View File

@@ -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).
/// </summary>
[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) ─────
/// <summary>
/// Verifies the 409 guard path end-to-end via a per-test DI override: injects a stub
/// <see cref="IAvisoQueryRepository"/> that reports the parent Rubro has 1 aviso,
/// so no real Aviso row 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 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);
}
}