Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Admin/MediosControllerTests.cs
dmolinari e5b6c06f64 refactor(tests): Api.Tests apunta a SIGCM2_Test_Api via TestConnectionStrings
Todos los archivos de Api.Tests reemplazan la connection string hardcodeada
por TestConnectionStrings.ApiTestDb. Cada proyecto de tests ahora tiene su
propia base de datos aislada, eliminando la contención entre Application.Tests
y Api.Tests que causaba flakiness.
2026-04-18 21:44:40 -03:00

412 lines
16 KiB
C#

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