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