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