diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index f43eac9..e936231 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -242,6 +242,31 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // CAT-002: Rubro Regla de Oro (rama vs hoja) + case RubroPadreEsHojaConAvisosException rubroPadreHojaEx: + context.Result = new ObjectResult(new + { + error = "rubro_padre_es_hoja_con_avisos", + message = rubroPadreHojaEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case RubroEsRamaConHijosActivosException rubroRamaHijosEx: + context.Result = new ObjectResult(new + { + error = "rubro_es_rama_con_hijos_activos", + message = rubroRamaHijosEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + // ADM-001: Medio exceptions case MedioCodigoDuplicadoException medioCodDupEx: context.Result = new ObjectResult(new diff --git a/tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs b/tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs new file mode 100644 index 0000000..97e788b --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs @@ -0,0 +1,177 @@ +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. +}