2026-04-19 08:31:39 -03:00
|
|
|
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;
|
2026-04-19 16:59:53 -03:00
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
2026-04-19 08:31:39 -03:00
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
|
using SIGCM2.Api.Filters;
|
2026-04-19 16:59:53 -03:00
|
|
|
using SIGCM2.Application.Abstractions.Persistence;
|
2026-04-19 08:31:39 -03:00
|
|
|
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).
|
2026-04-19 16:59:53 -03:00
|
|
|
/// Integration: POST child under leaf with avisos → 409 (via per-test DI override, issue #36).
|
2026-04-19 08:31:39 -03:00
|
|
|
/// </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@";
|
|
|
|
|
|
2026-04-19 16:59:53 -03:00
|
|
|
private readonly TestWebAppFactory _factory;
|
2026-04-19 08:31:39 -03:00
|
|
|
private readonly HttpClient _client;
|
|
|
|
|
|
|
|
|
|
public RubrosReglaDeOroTests(TestWebAppFactory factory)
|
|
|
|
|
{
|
2026-04-19 16:59:53 -03:00
|
|
|
_factory = factory;
|
2026-04-19 08:31:39 -03:00
|
|
|
_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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 16:59:53 -03:00
|
|
|
// ── Integration: POST child under leaf with avisos → 409 (DI override) ─────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies the 409 guard path end-to-end via a per-test DI override: injects a stub
|
|
|
|
|
/// <see cref="IAvisoQueryRepository"/> that reports the parent Rubro has 1 aviso,
|
|
|
|
|
/// so no real Aviso row needs to exist in the database.
|
|
|
|
|
///
|
|
|
|
|
/// Uses <see cref="TestWebAppFactory.CreateClientWithOverrides"/> — the pattern enabled
|
|
|
|
|
/// by fixing issue #36 (RSA singleton / per-test DI override).
|
|
|
|
|
/// </summary>
|
|
|
|
|
[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<JsonElement>();
|
|
|
|
|
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<IAvisoQueryRepository>();
|
|
|
|
|
services.AddScoped<IAvisoQueryRepository>(_ =>
|
|
|
|
|
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<JsonElement>();
|
|
|
|
|
Assert.Equal("rubro_padre_es_hoja_con_avisos", body.GetProperty("error").GetString());
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
await DeleteRubroIfExistsAsync(parentId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Stub: always reports that a Rubro has 1 aviso (single query) and a non-empty batch dictionary.
|
|
|
|
|
/// Used by <see cref="RubrosReglaDeOroTests.CreateRubro_WhenParentHasAvisos_Returns409WithErrorCode"/>
|
|
|
|
|
/// via <see cref="TestWebAppFactory.CreateClientWithOverrides"/> to verify the 409 guard
|
|
|
|
|
/// without seeding real Aviso rows in the database (issue #36 DI override pattern).
|
|
|
|
|
/// </summary>
|
|
|
|
|
file sealed class AlwaysHasAvisosQueryRepository : IAvisoQueryRepository
|
|
|
|
|
{
|
|
|
|
|
public Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default)
|
|
|
|
|
=> Task.FromResult(1);
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
|
|
|
|
|
IReadOnlyCollection<int> rubroIds,
|
|
|
|
|
CancellationToken ct = default)
|
|
|
|
|
{
|
|
|
|
|
IReadOnlyDictionary<int, int> result = rubroIds.ToDictionary(id => id, _ => 1);
|
|
|
|
|
return Task.FromResult(result);
|
|
|
|
|
}
|
2026-04-19 08:31:39 -03:00
|
|
|
}
|
2026-04-19 16:59:53 -03:00
|
|
|
|