feat(api): MediosController + SeccionesController + ExceptionFilter mappings — ADM-001 B6
- POST/GET/PUT + deactivate/reactivate endpoints for /api/v1/admin/medios - POST/GET/PUT + deactivate/reactivate endpoints for /api/v1/admin/secciones - ExceptionFilter: add Medio/Seccion 404+409 mappings after RolInUseException - Integration tests: 19 scenarios covering 401/403/201/404/409/idempotency/AuditEvent - All 166 Api.Tests + 458 Application.Tests passing
This commit is contained in:
173
src/api/SIGCM2.Api/Controllers/MediosController.cs
Normal file
173
src/api/SIGCM2.Api/Controllers/MediosController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// ADM-001: Medio management endpoints at /api/v1/admin/medios.
|
||||
/// All endpoints require permission 'administracion:medios:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/medios")]
|
||||
public sealed class MediosController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateMedioCommand> _createValidator;
|
||||
private readonly IValidator<UpdateMedioCommand> _updateValidator;
|
||||
|
||||
public MediosController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateMedioCommand> createValidator,
|
||||
IValidator<UpdateMedioCommand> updateValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new medio. Requires administracion:medios:gestionar.</summary>
|
||||
[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<IActionResult> 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<CreateMedioCommand, MedioCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetMedioById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>Lists medios with optional filters and pagination.</summary>
|
||||
[HttpGet]
|
||||
[RequirePermission("administracion:medios:gestionar")]
|
||||
[ProducesResponseType(typeof(PagedResult<MedioListItemDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> 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<ListMediosQuery, PagedResult<MedioListItemDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets a single medio by id.</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
[RequirePermission("administracion:medios:gestionar")]
|
||||
[ProducesResponseType(typeof(MedioDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetMedioById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetMedioByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetMedioByIdQuery, MedioDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a medio's editable fields.</summary>
|
||||
[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<IActionResult> 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<UpdateMedioCommand, MedioUpdatedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivates a medio (idempotent).</summary>
|
||||
[HttpPost("{id:int}/deactivate")]
|
||||
[RequirePermission("administracion:medios:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeactivateMedio([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateMedioCommand(id);
|
||||
await _dispatcher.Send<DeactivateMedioCommand, MedioStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Reactivates a medio (idempotent).</summary>
|
||||
[HttpPost("{id:int}/reactivate")]
|
||||
[RequirePermission("administracion:medios:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ReactivateMedio([FromRoute] int id)
|
||||
{
|
||||
var command = new ReactivateMedioCommand(id);
|
||||
await _dispatcher.Send<ReactivateMedioCommand, MedioStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>ADM-001: Create medio request body.</summary>
|
||||
public sealed record CreateMedioRequest(
|
||||
string? Codigo,
|
||||
string? Nombre,
|
||||
TipoMedio? Tipo,
|
||||
int? PlataformaEmpresaId);
|
||||
|
||||
/// <summary>ADM-001: Update medio request body.</summary>
|
||||
public sealed record UpdateMedioRequest(
|
||||
string? Nombre,
|
||||
TipoMedio? Tipo,
|
||||
int? PlataformaEmpresaId);
|
||||
172
src/api/SIGCM2.Api/Controllers/SeccionesController.cs
Normal file
172
src/api/SIGCM2.Api/Controllers/SeccionesController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// ADM-001: Seccion management endpoints at /api/v1/admin/secciones.
|
||||
/// All endpoints require permission 'administracion:secciones:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/secciones")]
|
||||
public sealed class SeccionesController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateSeccionCommand> _createValidator;
|
||||
private readonly IValidator<UpdateSeccionCommand> _updateValidator;
|
||||
|
||||
public SeccionesController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateSeccionCommand> createValidator,
|
||||
IValidator<UpdateSeccionCommand> updateValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new seccion. Requires administracion:secciones:gestionar.</summary>
|
||||
[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<IActionResult> 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<CreateSeccionCommand, SeccionCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetSeccionById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>Lists secciones with optional filters and pagination.</summary>
|
||||
[HttpGet]
|
||||
[RequirePermission("administracion:secciones:gestionar")]
|
||||
[ProducesResponseType(typeof(PagedResult<SeccionListItemDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> 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<ListSeccionesQuery, PagedResult<SeccionListItemDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets a single seccion by id.</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
[RequirePermission("administracion:secciones:gestionar")]
|
||||
[ProducesResponseType(typeof(SeccionDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSeccionById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetSeccionByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetSeccionByIdQuery, SeccionDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a seccion's editable fields.</summary>
|
||||
[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<IActionResult> 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<UpdateSeccionCommand, SeccionUpdatedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivates a seccion (idempotent).</summary>
|
||||
[HttpPost("{id:int}/deactivate")]
|
||||
[RequirePermission("administracion:secciones:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeactivateSeccion([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateSeccionCommand(id);
|
||||
await _dispatcher.Send<DeactivateSeccionCommand, SeccionStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Reactivates a seccion (idempotent).</summary>
|
||||
[HttpPost("{id:int}/reactivate")]
|
||||
[RequirePermission("administracion:secciones:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ReactivateSeccion([FromRoute] int id)
|
||||
{
|
||||
var command = new ReactivateSeccionCommand(id);
|
||||
await _dispatcher.Send<ReactivateSeccionCommand, SeccionStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>ADM-001: Create seccion request body.</summary>
|
||||
public sealed record CreateSeccionRequest(
|
||||
int? MedioId,
|
||||
string? Codigo,
|
||||
string? Nombre,
|
||||
string? Tipo);
|
||||
|
||||
/// <summary>ADM-001: Update seccion request body.</summary>
|
||||
public sealed record UpdateSeccionRequest(
|
||||
string? Nombre,
|
||||
string? Tipo);
|
||||
@@ -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
|
||||
|
||||
412
tests/SIGCM2.Api.Tests/Admin/MediosControllerTests.cs
Normal file
412
tests/SIGCM2.Api.Tests/Admin/MediosControllerTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// ADM-001 B6 — Integration tests for /api/v1/admin/medios.
|
||||
/// All endpoints require permission 'administracion:medios:gestionar'.
|
||||
/// </summary>
|
||||
[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<string> GetAdminTokenAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||
{
|
||||
username = AdminUsername,
|
||||
password = AdminPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<string> 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<JsonElement>();
|
||||
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<int?>(
|
||||
"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<int> CountAuditEventsAsync(string action, string targetType, string targetId)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
return await conn.QuerySingleAsync<int>(
|
||||
"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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<int>(
|
||||
"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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
429
tests/SIGCM2.Api.Tests/Admin/SeccionesControllerTests.cs
Normal file
429
tests/SIGCM2.Api.Tests/Admin/SeccionesControllerTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// ADM-001 B6 — Integration tests for /api/v1/admin/secciones.
|
||||
/// All endpoints require permission 'administracion:secciones:gestionar'.
|
||||
/// </summary>
|
||||
[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<string> GetAdminTokenAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||
{
|
||||
username = AdminUsername,
|
||||
password = AdminPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<string> 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<JsonElement>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Creates a Medio via the API and returns its id.</summary>
|
||||
private async Task<int> 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<JsonElement>();
|
||||
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<int?>(
|
||||
"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<int> CountAuditEventsAsync(string action, string targetType, string targetId)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
return await conn.QuerySingleAsync<int>(
|
||||
"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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user