diff --git a/src/api/SIGCM2.Api/Controllers/RubrosController.cs b/src/api/SIGCM2.Api/Controllers/RubrosController.cs
new file mode 100644
index 0000000..af587f7
--- /dev/null
+++ b/src/api/SIGCM2.Api/Controllers/RubrosController.cs
@@ -0,0 +1,151 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using SIGCM2.Api.Authorization;
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Rubros.Create;
+using SIGCM2.Application.Rubros.Deactivate;
+using SIGCM2.Application.Rubros.Dtos;
+using SIGCM2.Application.Rubros.GetById;
+using SIGCM2.Application.Rubros.GetTree;
+using SIGCM2.Application.Rubros.Move;
+using SIGCM2.Application.Rubros.Update;
+
+namespace SIGCM2.Api.Controllers;
+
+///
+/// CAT-001: Rubro N-ary tree management.
+/// Read endpoints at /api/v1/rubros — require authentication (any role).
+/// Write endpoints at /api/v1/admin/rubros — require 'catalogo:rubros:gestionar'.
+///
+[ApiController]
+public sealed class RubrosController : ControllerBase
+{
+ private readonly IDispatcher _dispatcher;
+
+ public RubrosController(IDispatcher dispatcher)
+ {
+ _dispatcher = dispatcher;
+ }
+
+ // ── READ endpoints ─────────────────────────────────────────────────────────
+
+ /// Returns the full Rubro tree. Requires authentication.
+ [HttpGet("api/v1/rubros/tree")]
+ [Authorize]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task GetRubroTree([FromQuery] bool incluirInactivos = false)
+ {
+ var query = new GetRubroTreeQuery(incluirInactivos);
+ var result = await _dispatcher.Send>(query);
+ return Ok(result);
+ }
+
+ /// Returns a single Rubro by id. Requires authentication.
+ [HttpGet("api/v1/rubros/{id:int}")]
+ [Authorize]
+ [ProducesResponseType(typeof(RubroDetailDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task GetRubroById([FromRoute] int id)
+ {
+ var query = new GetRubroByIdQuery(id);
+ var result = await _dispatcher.Send(query);
+ return Ok(result);
+ }
+
+ // ── WRITE endpoints ────────────────────────────────────────────────────────
+
+ /// Creates a new Rubro. Requires catalogo:rubros:gestionar.
+ [HttpPost("api/v1/admin/rubros")]
+ [RequirePermission("catalogo:rubros:gestionar")]
+ [ProducesResponseType(typeof(RubroCreatedDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task CreateRubro([FromBody] CreateRubroRequest request)
+ {
+ var command = new CreateRubroCommand(
+ Nombre: request.Nombre ?? string.Empty,
+ ParentId: request.ParentId,
+ TarifarioBaseId: request.TarifarioBaseId);
+
+ var result = await _dispatcher.Send(command);
+ return CreatedAtAction(nameof(GetRubroById), new { id = result.Id }, result);
+ }
+
+ /// Updates a Rubro's nombre. Requires catalogo:rubros:gestionar.
+ [HttpPut("api/v1/admin/rubros/{id:int}")]
+ [RequirePermission("catalogo:rubros:gestionar")]
+ [ProducesResponseType(typeof(RubroUpdatedDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task UpdateRubro([FromRoute] int id, [FromBody] UpdateRubroRequest request)
+ {
+ var command = new UpdateRubroCommand(
+ Id: id,
+ Nombre: request.Nombre ?? string.Empty);
+
+ var result = await _dispatcher.Send(command);
+ return Ok(result);
+ }
+
+ /// Soft-deletes (deactivates) a Rubro. Requires catalogo:rubros:gestionar.
+ [HttpDelete("api/v1/admin/rubros/{id:int}")]
+ [RequirePermission("catalogo:rubros:gestionar")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task DeactivateRubro([FromRoute] int id)
+ {
+ var command = new DeactivateRubroCommand(id);
+ await _dispatcher.Send(command);
+ return NoContent();
+ }
+
+ /// Moves a Rubro to a new parent. Requires catalogo:rubros:gestionar.
+ [HttpPatch("api/v1/admin/rubros/{id:int}/mover")]
+ [RequirePermission("catalogo:rubros:gestionar")]
+ [ProducesResponseType(typeof(RubroMovedDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task MoveRubro([FromRoute] int id, [FromBody] MoveRubroRequest request)
+ {
+ var command = new MoveRubroCommand(
+ Id: id,
+ NuevoParentId: request.NuevoParentId,
+ NuevoOrden: request.NuevoOrden);
+
+ var result = await _dispatcher.Send(command);
+ return Ok(result);
+ }
+}
+
+// ── Request body records ──────────────────────────────────────────────────────
+
+/// CAT-001: Create rubro request body.
+public sealed record CreateRubroRequest(
+ string? Nombre,
+ int? ParentId,
+ int? TarifarioBaseId);
+
+/// CAT-001: Update rubro request body.
+public sealed record UpdateRubroRequest(
+ string? Nombre);
+
+/// CAT-001: Move rubro request body.
+public sealed record MoveRubroRequest(
+ int? NuevoParentId,
+ int NuevoOrden);
diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs
index 171721a..7d7f85f 100644
--- a/src/api/SIGCM2.Application/DependencyInjection.cs
+++ b/src/api/SIGCM2.Application/DependencyInjection.cs
@@ -60,6 +60,13 @@ using SIGCM2.Application.Usuarios.Reactivate;
using SIGCM2.Application.Usuarios.ResetPassword;
using SIGCM2.Application.Usuarios.Permisos;
using SIGCM2.Application.Usuarios.Update;
+using SIGCM2.Application.Rubros.Create;
+using SIGCM2.Application.Rubros.Update;
+using SIGCM2.Application.Rubros.Deactivate;
+using SIGCM2.Application.Rubros.Move;
+using SIGCM2.Application.Rubros.GetTree;
+using SIGCM2.Application.Rubros.GetById;
+using SIGCM2.Application.Rubros.Dtos;
namespace SIGCM2.Application;
@@ -145,6 +152,14 @@ public static class DependencyInjection
services.AddScoped>, ListIngresosBrutosQueryHandler>();
services.AddScoped>, GetHistorialIngresosBrutosQueryHandler>();
+ // Rubros (CAT-001)
+ services.AddScoped, CreateRubroCommandHandler>();
+ services.AddScoped, UpdateRubroCommandHandler>();
+ services.AddScoped, DeactivateRubroCommandHandler>();
+ services.AddScoped, MoveRubroCommandHandler>();
+ services.AddScoped>, GetRubroTreeQueryHandler>();
+ services.AddScoped, GetRubroByIdQueryHandler>();
+
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining();
diff --git a/tests/SIGCM2.Api.Tests/Rubros/RubrosControllerTests.cs b/tests/SIGCM2.Api.Tests/Rubros/RubrosControllerTests.cs
new file mode 100644
index 0000000..9234b81
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Rubros/RubrosControllerTests.cs
@@ -0,0 +1,670 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Dapper;
+using Microsoft.Data.SqlClient;
+using SIGCM2.TestSupport;
+
+namespace SIGCM2.Api.Tests.Rubros;
+
+///
+/// CAT-001 — Integration tests for /api/v1/rubros and /api/v1/admin/rubros.
+/// Read endpoints require authentication (any role).
+/// Write endpoints require permission 'catalogo:rubros:gestionar'.
+/// Verifies audit events after each mutating operation.
+///
+[Collection("ApiIntegration")]
+public sealed class RubrosControllerTests : IAsyncLifetime
+{
+ private const string TestConnectionString =
+ "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
+
+ private const string ReadEndpoint = "/api/v1/rubros";
+ private const string AdminEndpoint = "/api/v1/admin/rubros";
+ private const string AdminUsername = "admin";
+ private const string AdminPassword = "@Diego550@";
+
+ private readonly HttpClient _client;
+
+ public RubrosControllerTests(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 async Task GetCajeroTokenAsync(string username)
+ {
+ var adminToken = await GetAdminTokenAsync();
+
+ using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new
+ {
+ username,
+ password = "Secure1234!",
+ nombre = "Cajero",
+ apellido = "Test",
+ email = (string?)null,
+ rol = "cajero"
+ }, adminToken);
+ var mkResp = await _client.SendAsync(mkUser);
+ if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict)
+ Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}");
+
+ var loginResp = await _client.PostAsJsonAsync("/api/v1/auth/login", new
+ {
+ username,
+ password = "Secure1234!"
+ });
+ loginResp.EnsureSuccessStatusCode();
+ var loginJson = await loginResp.Content.ReadFromJsonAsync();
+ return loginJson.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 CountAuditEventsAsync(string action, string targetType, string targetId)
+ {
+ await using var conn = new SqlConnection(TestConnectionString);
+ await conn.OpenAsync();
+ return await conn.QuerySingleAsync(
+ "SELECT COUNT(*) FROM dbo.AuditEvent WHERE Action = @Action AND TargetType = @TargetType AND TargetId = @TargetId",
+ new { Action = action, TargetType = targetType, TargetId = targetId });
+ }
+
+ private static async Task DeleteRubroIfExistsAsync(int id)
+ {
+ await using var conn = new SqlConnection(TestConnectionString);
+ await conn.OpenAsync();
+
+ // Need to disable system versioning to delete from history + main table
+ await conn.ExecuteAsync("ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = OFF)");
+ await conn.ExecuteAsync("DELETE FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id });
+ // Delete children first (recursive), then the target
+ await conn.ExecuteAsync("""
+ 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 conn.ExecuteAsync(
+ "ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Rubro_History, HISTORY_RETENTION_PERIOD = 10 YEARS))");
+ }
+
+ private static async Task DeleteUsuarioIfExistsAsync(string username)
+ {
+ await using var conn = new SqlConnection(TestConnectionString);
+ await conn.OpenAsync();
+ await conn.ExecuteAsync("""
+ DELETE rt FROM dbo.RefreshToken rt
+ INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId
+ WHERE u.Username = @Username
+ """, new { Username = username });
+ await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username });
+ }
+
+ // ── 401 / 403 guards on READ endpoints ────────────────────────────────────
+
+ [Fact]
+ public async Task GetTree_WithoutAuth_Returns401()
+ {
+ using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/tree");
+ var resp = await _client.SendAsync(req);
+ Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetById_WithoutAuth_Returns401()
+ {
+ using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999");
+ var resp = await _client.SendAsync(req);
+ Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
+ }
+
+ // ── 401 / 403 guards on WRITE endpoints ───────────────────────────────────
+
+ [Fact]
+ public async Task CreateRubro_WithoutAuth_Returns401()
+ {
+ using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Test", parentId = (int?)null });
+ var resp = await _client.SendAsync(req);
+ Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
+ }
+
+ [Fact]
+ public async Task CreateRubro_WithCajeroRole_Returns403()
+ {
+ const string username = "cat001_rubro_cajero_403";
+ try
+ {
+ var token = await GetCajeroTokenAsync(username);
+ using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Test403", parentId = (int?)null }, token);
+ var resp = await _client.SendAsync(req);
+ Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
+ }
+ finally
+ {
+ await DeleteUsuarioIfExistsAsync(username);
+ }
+ }
+
+ // ── GET /api/v1/rubros/tree ────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetTree_WithAdmin_Returns200WithTree()
+ {
+ var token = await GetAdminTokenAsync();
+
+ // Create a root rubro for the tree
+ using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
+ {
+ nombre = "TreeRoot_GetTree",
+ parentId = (int?)null,
+ tarifarioBaseId = (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();
+ Assert.Equal(JsonValueKind.Array, json.ValueKind);
+ // Should contain our created root
+ var nombres = json.EnumerateArray().Select(n => n.GetProperty("nombre").GetString()).ToList();
+ Assert.Contains("TreeRoot_GetTree", nombres);
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(rootId);
+ }
+ }
+
+ [Fact]
+ public async Task GetTree_IncluirInactivosTrue_IncludesInactivos()
+ {
+ var token = await GetAdminTokenAsync();
+
+ // Create then deactivate a rubro
+ using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
+ {
+ nombre = "RubroInactivo_GetTree",
+ parentId = (int?)null,
+ }, token);
+ var createResp = await _client.SendAsync(createReq);
+ Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
+ var created = await createResp.Content.ReadFromJsonAsync();
+ var rubroId = created.GetProperty("id").GetInt32();
+
+ try
+ {
+ // Deactivate it
+ using var deleteReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{rubroId}", bearerToken: token);
+ await _client.SendAsync(deleteReq);
+
+ // Without incluirInactivos → should not appear
+ using var req1 = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/tree", bearerToken: token);
+ var resp1 = await _client.SendAsync(req1);
+ var json1 = await resp1.Content.ReadFromJsonAsync();
+ var nombres1 = json1.EnumerateArray().Select(n => n.GetProperty("nombre").GetString()).ToList();
+ Assert.DoesNotContain("RubroInactivo_GetTree", nombres1);
+
+ // With incluirInactivos=true → should appear
+ using var req2 = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/tree?incluirInactivos=true", bearerToken: token);
+ var resp2 = await _client.SendAsync(req2);
+ var json2 = await resp2.Content.ReadFromJsonAsync();
+ var nombres2 = json2.EnumerateArray().Select(n => n.GetProperty("nombre").GetString()).ToList();
+ Assert.Contains("RubroInactivo_GetTree", nombres2);
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(rubroId);
+ }
+ }
+
+ // ── GET /api/v1/rubros/{id} ────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetById_ExistingRubro_Returns200()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
+ {
+ nombre = "RubroGetById",
+ parentId = (int?)null,
+ }, token);
+ var createResp = await _client.SendAsync(createReq);
+ Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
+ var created = await createResp.Content.ReadFromJsonAsync();
+ var rubroId = created.GetProperty("id").GetInt32();
+
+ try
+ {
+ using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/{rubroId}", bearerToken: token);
+ var resp = await _client.SendAsync(req);
+
+ Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
+ var json = await resp.Content.ReadFromJsonAsync();
+ Assert.Equal("RubroGetById", json.GetProperty("nombre").GetString());
+ Assert.Equal(rubroId, json.GetProperty("id").GetInt32());
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(rubroId);
+ }
+ }
+
+ [Fact]
+ public async Task GetById_NotFound_Returns404()
+ {
+ var token = await GetAdminTokenAsync();
+ using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999", bearerToken: token);
+ var resp = await _client.SendAsync(req);
+
+ Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
+ var json = await resp.Content.ReadFromJsonAsync();
+ Assert.Equal("rubro_not_found", json.GetProperty("error").GetString());
+ }
+
+ // ── POST /api/v1/admin/rubros ──────────────────────────────────────────────
+
+ [Fact]
+ public async Task CreateRubro_Root_Returns201WithAuditEvent()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
+ {
+ nombre = "RubroCreate201",
+ parentId = (int?)null,
+ tarifarioBaseId = (int?)null
+ }, token);
+ var resp = await _client.SendAsync(req);
+
+ Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
+ Assert.NotNull(resp.Headers.Location);
+
+ var json = await resp.Content.ReadFromJsonAsync();
+ var id = json.GetProperty("id").GetInt32();
+ Assert.True(id > 0);
+ Assert.Equal("RubroCreate201", json.GetProperty("nombre").GetString());
+ Assert.True(json.GetProperty("activo").GetBoolean());
+
+ try
+ {
+ var auditCount = await CountAuditEventsAsync("rubro.created", "Rubro", id.ToString());
+ Assert.Equal(1, auditCount);
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(id);
+ }
+ }
+
+ [Fact]
+ public async Task CreateRubro_Child_Returns201()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var parentReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ParentCreate", parentId = (int?)null }, token);
+ var parentResp = await _client.SendAsync(parentReq);
+ Assert.Equal(HttpStatusCode.Created, parentResp.StatusCode);
+ var parentJson = await parentResp.Content.ReadFromJsonAsync();
+ var parentId = parentJson.GetProperty("id").GetInt32();
+
+ try
+ {
+ using var childReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ChildCreate", parentId }, token);
+ var childResp = await _client.SendAsync(childReq);
+
+ Assert.Equal(HttpStatusCode.Created, childResp.StatusCode);
+ var childJson = await childResp.Content.ReadFromJsonAsync();
+ Assert.Equal(parentId, childJson.GetProperty("parentId").GetInt32());
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(parentId);
+ }
+ }
+
+ [Fact]
+ public async Task CreateRubro_DuplicateNombreUnderParent_Returns409()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var parentReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ParentDup409", parentId = (int?)null }, token);
+ var parentResp = await _client.SendAsync(parentReq);
+ Assert.Equal(HttpStatusCode.Created, parentResp.StatusCode);
+ var parentJson = await parentResp.Content.ReadFromJsonAsync();
+ var parentId = parentJson.GetProperty("id").GetInt32();
+
+ try
+ {
+ // First child
+ using var child1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Duplicado", parentId }, token);
+ var r1 = await _client.SendAsync(child1);
+ Assert.Equal(HttpStatusCode.Created, r1.StatusCode);
+
+ // Second child with same nombre
+ using var child2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Duplicado", parentId }, token);
+ var r2 = await _client.SendAsync(child2);
+ Assert.Equal(HttpStatusCode.Conflict, r2.StatusCode);
+ var json = await r2.Content.ReadFromJsonAsync();
+ Assert.Equal("rubro_nombre_duplicado", json.GetProperty("error").GetString());
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(parentId);
+ }
+ }
+
+ [Fact]
+ public async Task CreateRubro_ParentNotFound_Returns404()
+ {
+ var token = await GetAdminTokenAsync();
+ using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "OrphanChild", parentId = 999999 }, token);
+ var resp = await _client.SendAsync(req);
+ Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
+ }
+
+ // ── PUT /api/v1/admin/rubros/{id} ─────────────────────────────────────────
+
+ [Fact]
+ public async Task UpdateRubro_Returns200WithAuditEvent()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "OriginalNombre", parentId = (int?)null }, token);
+ var createResp = await _client.SendAsync(createReq);
+ Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
+ var created = await createResp.Content.ReadFromJsonAsync();
+ var id = created.GetProperty("id").GetInt32();
+
+ try
+ {
+ using var updateReq = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/{id}", new { nombre = "NombreActualizado" }, token);
+ var updateResp = await _client.SendAsync(updateReq);
+
+ Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode);
+ var updated = await updateResp.Content.ReadFromJsonAsync();
+ Assert.Equal("NombreActualizado", updated.GetProperty("nombre").GetString());
+
+ var auditCount = await CountAuditEventsAsync("rubro.updated", "Rubro", id.ToString());
+ Assert.Equal(1, auditCount);
+
+ // Verify Rubro_History row
+ await using var conn = new SqlConnection(TestConnectionString);
+ await conn.OpenAsync();
+ var histCount = await conn.QuerySingleAsync(
+ "SELECT COUNT(*) FROM dbo.Rubro_History WHERE Id = @Id", new { Id = id });
+ Assert.True(histCount >= 1, "Should have ≥1 row in Rubro_History after update");
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(id);
+ }
+ }
+
+ [Fact]
+ public async Task UpdateRubro_NotFound_Returns404()
+ {
+ var token = await GetAdminTokenAsync();
+ using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/999999", new { nombre = "Test" }, token);
+ var resp = await _client.SendAsync(req);
+ Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
+ }
+
+ [Fact]
+ public async Task UpdateRubro_DuplicateNombreSibling_Returns409()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var parent = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ParentUpdate409", parentId = (int?)null }, token);
+ var parentResp = await _client.SendAsync(parent);
+ var parentJson = await parentResp.Content.ReadFromJsonAsync();
+ var parentId = parentJson.GetProperty("id").GetInt32();
+
+ try
+ {
+ using var c1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Sibling1", parentId }, token);
+ var r1 = await _client.SendAsync(c1);
+ var j1 = await r1.Content.ReadFromJsonAsync();
+ var id1 = j1.GetProperty("id").GetInt32();
+
+ using var c2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Sibling2", parentId }, token);
+ var r2 = await _client.SendAsync(c2);
+ var j2 = await r2.Content.ReadFromJsonAsync();
+ // Try to rename Sibling1 → Sibling2 (conflict)
+ using var updateReq = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/{id1}", new { nombre = "Sibling2" }, token);
+ var updateResp = await _client.SendAsync(updateReq);
+ Assert.Equal(HttpStatusCode.Conflict, updateResp.StatusCode);
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(parentId);
+ }
+ }
+
+ // ── DELETE /api/v1/admin/rubros/{id} ──────────────────────────────────────
+
+ [Fact]
+ public async Task DeleteRubro_LeafRubro_Returns204WithAuditEvent()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "RubroToDelete", parentId = (int?)null }, token);
+ var createResp = await _client.SendAsync(createReq);
+ Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
+ var created = await createResp.Content.ReadFromJsonAsync();
+ var id = created.GetProperty("id").GetInt32();
+
+ try
+ {
+ using var deleteReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{id}", bearerToken: token);
+ var deleteResp = await _client.SendAsync(deleteReq);
+
+ Assert.Equal(HttpStatusCode.NoContent, deleteResp.StatusCode);
+
+ // Verify audit event (handler uses "rubro.deleted")
+ var auditCount = await CountAuditEventsAsync("rubro.deleted", "Rubro", id.ToString());
+ Assert.Equal(1, auditCount);
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(id);
+ }
+ }
+
+ [Fact]
+ public async Task DeleteRubro_WithActiveChildren_Returns409()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var parentReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ParentWithChildren", parentId = (int?)null }, token);
+ var parentResp = await _client.SendAsync(parentReq);
+ var parentJson = await parentResp.Content.ReadFromJsonAsync();
+ var parentId = parentJson.GetProperty("id").GetInt32();
+
+ try
+ {
+ // Add a child
+ using var childReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "ChildActive", parentId }, token);
+ await _client.SendAsync(childReq);
+
+ // Try to delete parent (has active children → 409)
+ using var deleteReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{parentId}", bearerToken: token);
+ var deleteResp = await _client.SendAsync(deleteReq);
+
+ Assert.Equal(HttpStatusCode.Conflict, deleteResp.StatusCode);
+ var json = await deleteResp.Content.ReadFromJsonAsync();
+ Assert.Equal("rubro_tiene_hijos_activos", json.GetProperty("error").GetString());
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(parentId);
+ }
+ }
+
+ [Fact]
+ public async Task DeleteRubro_NotFound_Returns404()
+ {
+ var token = await GetAdminTokenAsync();
+ using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/999999", bearerToken: token);
+ var resp = await _client.SendAsync(req);
+ Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
+ }
+
+ // ── PATCH /api/v1/admin/rubros/{id}/mover ─────────────────────────────────
+
+ [Fact]
+ public async Task MoveRubro_Returns200WithAuditEvent()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var p1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "MoveParent1", parentId = (int?)null }, token);
+ var p1Resp = await _client.SendAsync(p1);
+ var p1Json = await p1Resp.Content.ReadFromJsonAsync();
+ var parent1Id = p1Json.GetProperty("id").GetInt32();
+
+ try
+ {
+ using var p2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "MoveParent2", parentId = (int?)null }, token);
+ var p2Resp = await _client.SendAsync(p2);
+ var p2Json = await p2Resp.Content.ReadFromJsonAsync();
+ var parent2Id = p2Json.GetProperty("id").GetInt32();
+
+ using var childReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "MoveChild", parentId = parent1Id }, token);
+ var childResp = await _client.SendAsync(childReq);
+ var childJson = await childResp.Content.ReadFromJsonAsync();
+ var childId = childJson.GetProperty("id").GetInt32();
+
+ // Move child from parent1 to parent2
+ using var moveReq = BuildRequest(HttpMethod.Patch, $"{AdminEndpoint}/{childId}/mover", new
+ {
+ nuevoParentId = parent2Id,
+ nuevoOrden = 0
+ }, token);
+ var moveResp = await _client.SendAsync(moveReq);
+
+ Assert.Equal(HttpStatusCode.OK, moveResp.StatusCode);
+ var moved = await moveResp.Content.ReadFromJsonAsync();
+ Assert.Equal(parent2Id, moved.GetProperty("parentId").GetInt32());
+
+ var auditCount = await CountAuditEventsAsync("rubro.moved", "Rubro", childId.ToString());
+ Assert.Equal(1, auditCount);
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(parent1Id);
+ }
+ }
+
+ [Fact]
+ public async Task MoveRubro_CycleDetected_Returns400()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var rootReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "CycleRoot", parentId = (int?)null }, token);
+ var rootResp = await _client.SendAsync(rootReq);
+ var rootJson = await rootResp.Content.ReadFromJsonAsync();
+ var rootId = rootJson.GetProperty("id").GetInt32();
+
+ try
+ {
+ using var childReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "CycleChild", parentId = rootId }, token);
+ var childResp = await _client.SendAsync(childReq);
+ var childJson = await childResp.Content.ReadFromJsonAsync();
+ var childId = childJson.GetProperty("id").GetInt32();
+
+ // Try to move root under its own child → cycle
+ using var moveReq = BuildRequest(HttpMethod.Patch, $"{AdminEndpoint}/{rootId}/mover", new
+ {
+ nuevoParentId = childId,
+ nuevoOrden = 0
+ }, token);
+ var moveResp = await _client.SendAsync(moveReq);
+
+ Assert.Equal(HttpStatusCode.BadRequest, moveResp.StatusCode);
+ var json = await moveResp.Content.ReadFromJsonAsync();
+ Assert.Equal("rubro_cycle_detected", json.GetProperty("error").GetString());
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(rootId);
+ }
+ }
+
+ [Fact]
+ public async Task MoveRubro_DuplicateNombreUnderNewParent_Returns409()
+ {
+ var token = await GetAdminTokenAsync();
+
+ using var p1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "MoveDupParent1", parentId = (int?)null }, token);
+ var p1Resp = await _client.SendAsync(p1);
+ var p1Json = await p1Resp.Content.ReadFromJsonAsync();
+ var parent1Id = p1Json.GetProperty("id").GetInt32();
+
+ try
+ {
+ using var p2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "MoveDupParent2", parentId = (int?)null }, token);
+ var p2Resp = await _client.SendAsync(p2);
+ var p2Json = await p2Resp.Content.ReadFromJsonAsync();
+ var parent2Id = p2Json.GetProperty("id").GetInt32();
+
+ // Add "SameName" under parent1
+ using var c1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "SameName", parentId = parent1Id }, token);
+ var c1Resp = await _client.SendAsync(c1);
+ var c1Json = await c1Resp.Content.ReadFromJsonAsync();
+ var c1Id = c1Json.GetProperty("id").GetInt32();
+
+ // Add "SameName" under parent2 already
+ using var c2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "SameName", parentId = parent2Id }, token);
+ await _client.SendAsync(c2);
+
+ // Try to move c1 (SameName) under parent2 → duplicate
+ using var moveReq = BuildRequest(HttpMethod.Patch, $"{AdminEndpoint}/{c1Id}/mover", new
+ {
+ nuevoParentId = parent2Id,
+ nuevoOrden = 0
+ }, token);
+ var moveResp = await _client.SendAsync(moveReq);
+
+ Assert.Equal(HttpStatusCode.Conflict, moveResp.StatusCode);
+ }
+ finally
+ {
+ await DeleteRubroIfExistsAsync(parent1Id);
+ }
+ }
+}