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 = TestConnectionStrings.ApiTestDb;
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);
}
}
}