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; /// /// 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). /// Integration: POST child under leaf with avisos → 409 (via per-test DI override, issue #36). /// [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 TestWebAppFactory _factory; private readonly HttpClient _client; public RubrosReglaDeOroTests(TestWebAppFactory factory) { _factory = 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 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); } }