feat(api): ExceptionFilter 409 para regla de oro + DTO delta (CAT-002)
This commit is contained in:
@@ -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
|
||||
|
||||
177
tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs
Normal file
177
tests/SIGCM2.Api.Tests/Rubros/RubrosReglaDeOroTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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<string> GetAdminTokenAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||
{
|
||||
username = AdminUsername,
|
||||
password = AdminPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
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<IFilterMetadata>())
|
||||
{
|
||||
Exception = exception
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceptionFilter_MapsRubroPadreEsHojaConAvisos_To409()
|
||||
{
|
||||
var filter = new ExceptionFilter(NullLogger<ExceptionFilter>.Instance);
|
||||
var ctx = MakeExceptionContext(new RubroPadreEsHojaConAvisosException(parentId: 1, cantidadAvisos: 3));
|
||||
|
||||
filter.OnException(ctx);
|
||||
|
||||
var result = Assert.IsType<ObjectResult>(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<ExceptionFilter>.Instance);
|
||||
var ctx = MakeExceptionContext(new RubroEsRamaConHijosActivosException(rubroId: 7, cantidadHijos: 2));
|
||||
|
||||
filter.OnException(ctx);
|
||||
|
||||
var result = Assert.IsType<ObjectResult>(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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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.
|
||||
}
|
||||
Reference in New Issue
Block a user