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);
+ }
+ }
+}