From 5e2323e0bc2e2af43515961a5fcd5cc0e4ea277e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sat, 18 Apr 2026 20:05:20 -0300 Subject: [PATCH] feat(api): RubrosController + integration tests e2e + audit verification (CAT-001) --- .../Controllers/RubrosController.cs | 151 ++++ .../SIGCM2.Application/DependencyInjection.cs | 15 + .../Rubros/RubrosControllerTests.cs | 670 ++++++++++++++++++ 3 files changed, 836 insertions(+) create mode 100644 src/api/SIGCM2.Api/Controllers/RubrosController.cs create mode 100644 tests/SIGCM2.Api.Tests/Rubros/RubrosControllerTests.cs 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); + } + } +}