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.
+}