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.Logging.Abstractions; using SIGCM2.Api.Filters; using SIGCM2.Domain.Exceptions; using SIGCM2.TestSupport; namespace SIGCM2.Api.Tests.Rubros; /// /// CAT-002 — Regla de Oro Rama vs Hoja. /// /// 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). /// [Collection("ApiIntegration")] public sealed class RubrosReglaDeOroTests : IAsyncLifetime { private const string AdminEndpoint = "/api/v1/admin/rubros"; private const string ReadEndpoint = "/api/v1/rubros"; private const string AdminUsername = "admin"; private const string AdminPassword = "@Diego550@"; private readonly HttpClient _client; public RubrosReglaDeOroTests(TestWebAppFactory factory) { _client = factory.CreateClient(); } public Task InitializeAsync() => Task.CompletedTask; public Task DisposeAsync() => Task.CompletedTask; // ── 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 HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null) { var request = new HttpRequestMessage(method, url); if (bearerToken is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); if (body is not null) request.Content = JsonContent.Create(body); return request; } private static async Task DeleteRubroIfExistsAsync(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, """ WITH ToDelete AS ( SELECT Id FROM dbo.Rubro WHERE Id = @Id UNION ALL SELECT r.Id FROM dbo.Rubro r INNER JOIN ToDelete t ON r.ParentId = t.Id ) DELETE r FROM dbo.Rubro r INNER JOIN ToDelete td ON r.Id = td.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))"); } // ── ExceptionFilter unit tests (no DB, no HTTP) ─────────────────────────── private static ExceptionContext MakeExceptionContext(Exception exception) { var httpContext = new 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); return new ExceptionContext(actionContext, new List()) { Exception = exception }; } [Fact] public void ExceptionFilter_MapsRubroPadreEsHojaConAvisos_To409() { var filter = new ExceptionFilter(NullLogger.Instance); var ctx = MakeExceptionContext(new RubroPadreEsHojaConAvisosException(parentId: 1, cantidadAvisos: 3)); 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_padre_es_hoja_con_avisos", json); } [Fact] public void ExceptionFilter_MapsRubroEsRamaConHijosActivos_To409() { var filter = new ExceptionFilter(NullLogger.Instance); var ctx = MakeExceptionContext(new RubroEsRamaConHijosActivosException(rubroId: 7, cantidadHijos: 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_es_rama_con_hijos_activos", json); } // ── Integration: GET /arbol includes tieneAvisos field (stub = false) ───── [Fact] public async Task GetTree_ResponseIncludesTieneAvisosField_FalseWithStub() { var token = await GetAdminTokenAsync(); // Create a root rubro to ensure tree is non-empty using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "TieneAvisosCheck_CAT002", parentId = (int?)null, }, token); var createResp = await _client.SendAsync(createReq); Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); var created = await createResp.Content.ReadFromJsonAsync(); var rootId = created.GetProperty("id").GetInt32(); try { using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/tree", bearerToken: token); var resp = await _client.SendAsync(req); Assert.Equal(HttpStatusCode.OK, resp.StatusCode); var json = await resp.Content.ReadFromJsonAsync(); var ourNode = json.EnumerateArray() .FirstOrDefault(n => n.GetProperty("id").GetInt32() == rootId); Assert.True(ourNode.ValueKind != JsonValueKind.Undefined, "Our rubro must appear in tree"); Assert.True(ourNode.TryGetProperty("tieneAvisos", out var tieneAvisos), "tieneAvisos must be present in every tree node (CAT-002 additive field)"); Assert.False(tieneAvisos.GetBoolean(), "Stub (NullAvisoQueryRepository) must always return false"); } finally { await DeleteRubroIfExistsAsync(rootId); } } // ── 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. }