diff --git a/src/api/SIGCM2.Api/Controllers/MediosController.cs b/src/api/SIGCM2.Api/Controllers/MediosController.cs new file mode 100644 index 0000000..9ebf5f1 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/MediosController.cs @@ -0,0 +1,173 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.Medios.Create; +using SIGCM2.Application.Medios.Deactivate; +using SIGCM2.Application.Medios.GetById; +using SIGCM2.Application.Medios.List; +using SIGCM2.Application.Medios.Reactivate; +using SIGCM2.Application.Medios.Update; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Api.Controllers; + +/// +/// ADM-001: Medio management endpoints at /api/v1/admin/medios. +/// All endpoints require permission 'administracion:medios:gestionar'. +/// +[ApiController] +[Route("api/v1/admin/medios")] +public sealed class MediosController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public MediosController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + /// Creates a new medio. Requires administracion:medios:gestionar. + [HttpPost] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(typeof(MedioCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreateMedio([FromBody] CreateMedioRequest request) + { + var command = new CreateMedioCommand( + Codigo: request.Codigo ?? string.Empty, + Nombre: request.Nombre ?? string.Empty, + Tipo: request.Tipo ?? TipoMedio.Diario, + PlataformaEmpresaId: request.PlataformaEmpresaId); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetMedioById), new { id = result.Id }, result); + } + + /// Lists medios with optional filters and pagination. + [HttpGet] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListMedios( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] bool? activo = null, + [FromQuery] TipoMedio? tipo = null, + [FromQuery] string? q = null) + { + if (page < 1) return BadRequest(new { error = "page must be >= 1" }); + if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); + + var query = new ListMediosQuery(page, pageSize, activo, tipo, q); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Gets a single medio by id. + [HttpGet("{id:int}")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(typeof(MedioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMedioById([FromRoute] int id) + { + var query = new GetMedioByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + /// Updates a medio's editable fields. + [HttpPut("{id:int}")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(typeof(MedioUpdatedDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateMedio([FromRoute] int id, [FromBody] UpdateMedioRequest request) + { + var command = new UpdateMedioCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + Tipo: request.Tipo ?? TipoMedio.Diario, + PlataformaEmpresaId: request.PlataformaEmpresaId); + + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Deactivates a medio (idempotent). + [HttpPost("{id:int}/deactivate")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateMedio([FromRoute] int id) + { + var command = new DeactivateMedioCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } + + /// Reactivates a medio (idempotent). + [HttpPost("{id:int}/reactivate")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ReactivateMedio([FromRoute] int id) + { + var command = new ReactivateMedioCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// ADM-001: Create medio request body. +public sealed record CreateMedioRequest( + string? Codigo, + string? Nombre, + TipoMedio? Tipo, + int? PlataformaEmpresaId); + +/// ADM-001: Update medio request body. +public sealed record UpdateMedioRequest( + string? Nombre, + TipoMedio? Tipo, + int? PlataformaEmpresaId); diff --git a/src/api/SIGCM2.Api/Controllers/SeccionesController.cs b/src/api/SIGCM2.Api/Controllers/SeccionesController.cs new file mode 100644 index 0000000..9ff6653 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/SeccionesController.cs @@ -0,0 +1,172 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.Secciones.Create; +using SIGCM2.Application.Secciones.Deactivate; +using SIGCM2.Application.Secciones.GetById; +using SIGCM2.Application.Secciones.List; +using SIGCM2.Application.Secciones.Reactivate; +using SIGCM2.Application.Secciones.Update; + +namespace SIGCM2.Api.Controllers; + +/// +/// ADM-001: Seccion management endpoints at /api/v1/admin/secciones. +/// All endpoints require permission 'administracion:secciones:gestionar'. +/// +[ApiController] +[Route("api/v1/admin/secciones")] +public sealed class SeccionesController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public SeccionesController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + /// Creates a new seccion. Requires administracion:secciones:gestionar. + [HttpPost] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(typeof(SeccionCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreateSeccion([FromBody] CreateSeccionRequest request) + { + var command = new CreateSeccionCommand( + MedioId: request.MedioId ?? 0, + Codigo: request.Codigo ?? string.Empty, + Nombre: request.Nombre ?? string.Empty, + Tipo: request.Tipo ?? string.Empty); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetSeccionById), new { id = result.Id }, result); + } + + /// Lists secciones with optional filters and pagination. + [HttpGet] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListSecciones( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] int? medioId = null, + [FromQuery] string? tipo = null, + [FromQuery] bool? activo = null, + [FromQuery] string? q = null) + { + if (page < 1) return BadRequest(new { error = "page must be >= 1" }); + if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); + + var query = new ListSeccionesQuery(page, pageSize, medioId, tipo, activo, q); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Gets a single seccion by id. + [HttpGet("{id:int}")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(typeof(SeccionDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetSeccionById([FromRoute] int id) + { + var query = new GetSeccionByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + /// Updates a seccion's editable fields. + [HttpPut("{id:int}")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(typeof(SeccionUpdatedDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateSeccion([FromRoute] int id, [FromBody] UpdateSeccionRequest request) + { + var command = new UpdateSeccionCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + Tipo: request.Tipo ?? string.Empty); + + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Deactivates a seccion (idempotent). + [HttpPost("{id:int}/deactivate")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateSeccion([FromRoute] int id) + { + var command = new DeactivateSeccionCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } + + /// Reactivates a seccion (idempotent). + [HttpPost("{id:int}/reactivate")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ReactivateSeccion([FromRoute] int id) + { + var command = new ReactivateSeccionCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// ADM-001: Create seccion request body. +public sealed record CreateSeccionRequest( + int? MedioId, + string? Codigo, + string? Nombre, + string? Tipo); + +/// ADM-001: Update seccion request body. +public sealed record UpdateSeccionRequest( + string? Nombre, + string? Tipo); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index f566bf3..77e481e 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -169,6 +169,56 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // ADM-001: Medio exceptions + case MedioCodigoDuplicadoException medioCodDupEx: + context.Result = new ObjectResult(new + { + error = "medio_codigo_duplicado", + message = medioCodDupEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case MedioNotFoundException medioNotFoundEx: + context.Result = new ObjectResult(new + { + error = "medio_not_found", + message = medioNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + // ADM-001: Seccion exceptions + case SeccionCodigoDuplicadoEnMedioException seccionCodDupEx: + context.Result = new ObjectResult(new + { + error = "seccion_codigo_duplicado_en_medio", + message = seccionCodDupEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case SeccionNotFoundException seccionNotFoundEx: + context.Result = new ObjectResult(new + { + error = "seccion_not_found", + message = seccionNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + // UDT-009: permiso override validation errors case InvalidPermisoCodesException ipce: context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails diff --git a/tests/SIGCM2.Api.Tests/Admin/MediosControllerTests.cs b/tests/SIGCM2.Api.Tests/Admin/MediosControllerTests.cs new file mode 100644 index 0000000..bb079a1 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Admin/MediosControllerTests.cs @@ -0,0 +1,412 @@ +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.Admin; + +/// +/// ADM-001 B6 — Integration tests for /api/v1/admin/medios. +/// All endpoints require permission 'administracion:medios:gestionar'. +/// +[Collection("ApiIntegration")] +public sealed class MediosControllerTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private const string Endpoint = "/api/v1/admin/medios"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public MediosControllerTests(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 DeleteMedioIfExistsAsync(string codigo) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + + var id = await conn.QuerySingleOrDefaultAsync( + "SELECT Id FROM dbo.Medio WHERE Codigo = @Codigo", new { Codigo = codigo }); + if (id is null) return; + + // Delete dependent secciones first (disable versioning to also clear history) + await conn.ExecuteAsync("ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.Seccion_History WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.Seccion WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Seccion_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + + // Delete the medio itself + await conn.ExecuteAsync("ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.Medio_History WHERE Id = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.Medio WHERE Id = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Medio_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 }); + } + + 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 }); + } + + // ── 401 / 403 guards ───────────────────────────────────────────────────── + + [Fact] + public async Task CreateMedio_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo = "TESTMEDIO401", + nombre = "Test Medio", + tipo = 1 + }); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task CreateMedio_WithCajeroRole_Returns403() + { + const string username = "adm001_medio_cajero_403"; + try + { + var token = await GetCajeroTokenAsync(username); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo = "TESTMEDIO403", + nombre = "Test Medio", + tipo = 1 + }, token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + } + finally + { + await DeleteUsuarioIfExistsAsync(username); + } + } + + // ── CREATE ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateMedio_WithAdmin_Returns201AndAuditEvent() + { + const string codigo = "TESTCREATE201"; + var token = await GetAdminTokenAsync(); + + try + { + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Test Create 201", + tipo = 1 + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + Assert.NotNull(resp.Headers.Location); + + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("id", out var id)); + Assert.True(id.GetInt32() > 0); + Assert.True(json.TryGetProperty("codigo", out var codigoEl)); + Assert.Equal(codigo, codigoEl.GetString()); + Assert.True(json.TryGetProperty("activo", out var activo)); + Assert.True(activo.GetBoolean()); + + // Verify AuditEvent was created + var medioId = id.GetInt32().ToString(); + var auditCount = await CountAuditEventsAsync("medio.create", "Medio", medioId); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(codigo); + } + } + + [Fact] + public async Task CreateMedio_DuplicateCodigo_Returns409() + { + const string codigo = "TESTDUPLICATE"; + var token = await GetAdminTokenAsync(); + + try + { + // First create + using var first = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Primer Medio", + tipo = 1 + }, token); + var firstResp = await _client.SendAsync(first); + Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode); + + // Second create with same codigo + using var second = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Segundo Medio", + tipo = 2 + }, token); + var secondResp = await _client.SendAsync(second); + + Assert.Equal(HttpStatusCode.Conflict, secondResp.StatusCode); + var json = await secondResp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("error", out var error)); + Assert.Equal("medio_codigo_duplicado", error.GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(codigo); + } + } + + // ── LIST ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetMedios_WithAdmin_Returns200WithSeedRows() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}?activo=true", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out var items), "Response must have 'items'"); + Assert.True(json.TryGetProperty("total", out _), "Response must have 'total'"); + + // The seed includes ELDIA and ELPLATA + var codigosInResponse = items.EnumerateArray() + .Select(i => i.GetProperty("codigo").GetString()) + .ToList(); + Assert.Contains("ELDIA", codigosInResponse); + Assert.Contains("ELPLATA", codigosInResponse); + } + + // ── GET BY ID ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GetMedioById_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/999999", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("error", out var error)); + Assert.Equal("medio_not_found", error.GetString()); + } + + // ── UPDATE ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateMedio_WithAdmin_Returns200AndAuditEventAndHistory() + { + const string codigo = "TESTUPDATEMEDIO"; + var token = await GetAdminTokenAsync(); + + try + { + // Create + using var createReq = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Medio Original", + tipo = 1 + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var medioId = created.GetProperty("id").GetInt32(); + + // Update + using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{medioId}", new + { + nombre = "Medio Actualizado", + tipo = 2 + }, token); + var updateResp = await _client.SendAsync(updateReq); + + Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode); + var updated = await updateResp.Content.ReadFromJsonAsync(); + Assert.Equal("Medio Actualizado", updated.GetProperty("nombre").GetString()); + + // Verify AuditEvent + var auditCount = await CountAuditEventsAsync("medio.update", "Medio", medioId.ToString()); + Assert.Equal(1, auditCount); + + // Verify Medio_History row exists + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + var histCount = await conn.QuerySingleAsync( + "SELECT COUNT(*) FROM dbo.Medio_History WHERE Id = @Id", + new { Id = medioId }); + Assert.True(histCount >= 1, "Should have at least one row in Medio_History after update"); + } + finally + { + await DeleteMedioIfExistsAsync(codigo); + } + } + + // ── DEACTIVATE ──────────────────────────────────────────────────────────── + + [Fact] + public async Task DeactivateMedio_WithAdmin_Returns204AndAuditEvent() + { + const string codigo = "TESTDEACTIVATE"; + var token = await GetAdminTokenAsync(); + + try + { + // Create + using var createReq = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Medio Para Desactivar", + tipo = 1 + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var medioId = created.GetProperty("id").GetInt32(); + + // Deactivate + using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{medioId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + // Verify AuditEvent + var auditCount = await CountAuditEventsAsync("medio.deactivate", "Medio", medioId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(codigo); + } + } + + [Fact] + public async Task DeactivateMedio_WhenAlreadyInactive_Returns204ButNoNewAuditEvent() + { + const string codigo = "TESTDEACTIVATEIDEMPOTENT"; + var token = await GetAdminTokenAsync(); + + try + { + // Create + using var createReq = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Medio Idempotente", + tipo = 1 + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var medioId = created.GetProperty("id").GetInt32(); + + // First deactivate + using var deact1 = BuildRequest(HttpMethod.Post, $"{Endpoint}/{medioId}/deactivate", bearerToken: token); + await _client.SendAsync(deact1); + + // Second deactivate (idempotent) + using var deact2 = BuildRequest(HttpMethod.Post, $"{Endpoint}/{medioId}/deactivate", bearerToken: token); + var deact2Resp = await _client.SendAsync(deact2); + + Assert.Equal(HttpStatusCode.NoContent, deact2Resp.StatusCode); + + // Should still be only 1 audit event (second call was idempotent — no new audit) + var auditCount = await CountAuditEventsAsync("medio.deactivate", "Medio", medioId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(codigo); + } + } +} diff --git a/tests/SIGCM2.Api.Tests/Admin/SeccionesControllerTests.cs b/tests/SIGCM2.Api.Tests/Admin/SeccionesControllerTests.cs new file mode 100644 index 0000000..d416eda --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Admin/SeccionesControllerTests.cs @@ -0,0 +1,429 @@ +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.Admin; + +/// +/// ADM-001 B6 — Integration tests for /api/v1/admin/secciones. +/// All endpoints require permission 'administracion:secciones:gestionar'. +/// +[Collection("ApiIntegration")] +public sealed class SeccionesControllerTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private const string Endpoint = "/api/v1/admin/secciones"; + private const string MediosEndpoint = "/api/v1/admin/medios"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public SeccionesControllerTests(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; + } + + /// Creates a Medio via the API and returns its id. + private async Task CreateMedioAsync(string codigo, string nombre, string token) + { + using var req = BuildRequest(HttpMethod.Post, MediosEndpoint, new + { + codigo, + nombre, + tipo = 1 + }, token); + var resp = await _client.SendAsync(req); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadFromJsonAsync(); + return json.GetProperty("id").GetInt32(); + } + + private static async Task DeleteMedioIfExistsAsync(string codigo) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + + var id = await conn.QuerySingleOrDefaultAsync( + "SELECT Id FROM dbo.Medio WHERE Codigo = @Codigo", new { Codigo = codigo }); + if (id is null) return; + + // Delete dependent secciones (disable versioning to clear history too) + await conn.ExecuteAsync("ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.Seccion_History WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.Seccion WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Seccion_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + + // Delete the medio itself + await conn.ExecuteAsync("ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.Medio_History WHERE Id = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.Medio WHERE Id = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Medio_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 }); + } + + 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 }); + } + + // ── 401 / 403 guards ───────────────────────────────────────────────────── + + [Fact] + public async Task CreateSeccion_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = 1, + codigo = "SEC401", + nombre = "Seccion Test", + tipo = "clasificados" + }); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task CreateSeccion_WithCajeroRole_Returns403() + { + const string username = "adm001_sec_cajero_403"; + try + { + var token = await GetCajeroTokenAsync(username); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = 1, + codigo = "SEC403", + nombre = "Seccion Test", + tipo = "clasificados" + }, token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + } + finally + { + await DeleteUsuarioIfExistsAsync(username); + } + } + + // ── CREATE ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateSeccion_WithAdmin_Returns201AndAuditEvent() + { + const string medioCodigo = "TESTSECMED201"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio Para Seccion 201", token); + + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + codigo = "SEC201", + nombre = "Seccion 201", + tipo = "clasificados" + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + Assert.NotNull(resp.Headers.Location); + + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("id", out var idEl)); + var secId = idEl.GetInt32(); + Assert.True(secId > 0); + Assert.Equal(medioId, json.GetProperty("medioId").GetInt32()); + Assert.Equal("SEC201", json.GetProperty("codigo").GetString()); + Assert.Equal("clasificados", json.GetProperty("tipo").GetString()); + + // Verify AuditEvent + var auditCount = await CountAuditEventsAsync("seccion.create", "Seccion", secId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + [Fact] + public async Task CreateSeccion_WithNonExistentMedioId_Returns404() + { + var token = await GetAdminTokenAsync(); + + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = 99999, + codigo = "SECNOTFOUND", + nombre = "Seccion Not Found", + tipo = "clasificados" + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("medio_not_found", json.GetProperty("error").GetString()); + } + + [Fact] + public async Task CreateSeccion_WithDuplicateCodigoInSameMedio_Returns409() + { + const string medioCodigo = "TESTSECDUP"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio Dup Test", token); + + // First seccion + using var first = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + codigo = "DUPCODE", + nombre = "Seccion Original", + tipo = "clasificados" + }, token); + var firstResp = await _client.SendAsync(first); + Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode); + + // Second with same medioId + codigo + using var second = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + codigo = "DUPCODE", + nombre = "Seccion Duplicada", + tipo = "notables" + }, token); + var secondResp = await _client.SendAsync(second); + + Assert.Equal(HttpStatusCode.Conflict, secondResp.StatusCode); + var json = await secondResp.Content.ReadFromJsonAsync(); + Assert.Equal("seccion_codigo_duplicado_en_medio", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + [Fact] + public async Task CreateSeccion_SameCodigoDifferentMedio_Returns201() + { + const string medio1Codigo = "TESTSECMULTI1"; + const string medio2Codigo = "TESTSECMULTI2"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId1 = await CreateMedioAsync(medio1Codigo, "Medio Multi 1", token); + var medioId2 = await CreateMedioAsync(medio2Codigo, "Medio Multi 2", token); + + using var req1 = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = medioId1, + codigo = "SHAREDCODE", + nombre = "Seccion en Medio 1", + tipo = "clasificados" + }, token); + var resp1 = await _client.SendAsync(req1); + Assert.Equal(HttpStatusCode.Created, resp1.StatusCode); + + // Same codigo but different medioId → should succeed (composite UQ) + using var req2 = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = medioId2, + codigo = "SHAREDCODE", + nombre = "Seccion en Medio 2", + tipo = "notables" + }, token); + var resp2 = await _client.SendAsync(req2); + Assert.Equal(HttpStatusCode.Created, resp2.StatusCode); + } + finally + { + await DeleteMedioIfExistsAsync(medio1Codigo); + await DeleteMedioIfExistsAsync(medio2Codigo); + } + } + + [Fact] + public async Task CreateSeccion_WithInactiveMedio_Returns404() + { + const string medioCodigo = "TESTSECDEACT"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio Para Desactivar", token); + + // Deactivate the medio + using var deactReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + // Try to create seccion in inactive medio + using var secReq = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + codigo = "SECINACTIVE", + nombre = "Seccion en Medio Inactivo", + tipo = "clasificados" + }, token); + var secResp = await _client.SendAsync(secReq); + + Assert.Equal(HttpStatusCode.NotFound, secResp.StatusCode); + var json = await secResp.Content.ReadFromJsonAsync(); + Assert.Equal("medio_not_found", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── LIST ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetSecciones_WithAdmin_Returns200PagedResult() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, Endpoint, bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "Response must have 'items'"); + Assert.True(json.TryGetProperty("total", out _), "Response must have 'total'"); + } + + // ── GET BY ID ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GetSeccionById_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/999999", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("seccion_not_found", json.GetProperty("error").GetString()); + } + + // ── DEACTIVATE ──────────────────────────────────────────────────────────── + + [Fact] + public async Task DeactivateSeccion_WithAdmin_Returns204AndAuditEvent() + { + const string medioCodigo = "TESTSECDEACT2"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio Para Sec Deactivate", token); + + using var createReq = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + codigo = "SECDEACT", + nombre = "Seccion Para Desactivar", + tipo = "clasificados" + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var secId = created.GetProperty("id").GetInt32(); + + using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{secId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + var auditCount = await CountAuditEventsAsync("seccion.deactivate", "Seccion", secId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } +}