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-008 B5 — Integration tests for /api/v1/admin/puntos-de-venta. /// All endpoints require permission 'administracion:puntos_de_venta:gestionar'. /// Tests: T5.3 CRUD, T5.4 concurrencia, T5.5 secuencialidad. /// [Collection("ApiIntegration")] public sealed class PuntosDeVentaControllerTests : IAsyncLifetime { private const string TestConnectionString = "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; private const string Endpoint = "/api/v1/admin/puntos-de-venta"; private const string MediosEndpoint = "/api/v1/admin/medios"; private const string AdminUsername = "admin"; private const string AdminPassword = "@Diego550@"; private readonly HttpClient _client; public PuntosDeVentaControllerTests(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(); } /// Creates a PuntoDeVenta via the API and returns its id. private async Task CreatePdvAsync(int medioId, short numeroAFIP, string nombre, string token) { using var req = BuildRequest(HttpMethod.Post, Endpoint, new { medioId, numeroAFIP, nombre, descripcion = (string?)null }, 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 PuntosDeVenta (disable versioning to also clear history) await conn.ExecuteAsync("ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = OFF)"); await conn.ExecuteAsync("DELETE FROM dbo.PuntoDeVenta_History WHERE MedioId = @id", new { id }); await conn.ExecuteAsync("DELETE FROM dbo.PuntoDeVenta WHERE MedioId = @id", new { id }); await conn.ExecuteAsync( "ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.PuntoDeVenta_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); // Delete dependent Secciones 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 CreatePdv_WithoutAuth_Returns401() { using var req = BuildRequest(HttpMethod.Post, Endpoint, new { medioId = 1, numeroAFIP = 1, nombre = "PdV Test" }); var resp = await _client.SendAsync(req); Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); } [Fact] public async Task CreatePdv_WithCajeroRole_Returns403() { const string username = "adm008_pdv_cajero_403"; try { var token = await GetCajeroTokenAsync(username); using var req = BuildRequest(HttpMethod.Post, Endpoint, new { medioId = 1, numeroAFIP = 1, nombre = "PdV Test 403" }, token); var resp = await _client.SendAsync(req); Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); } finally { await DeleteUsuarioIfExistsAsync(username); } } // ── CREATE ──────────────────────────────────────────────────────────────── /// T5.3 — Happy path: Create returns 201 + AuditEvent. [Fact] public async Task CreatePdv_WithAdmin_Returns201AndAuditEvent() { const string medioCodigo = "ADMS08_MED_C201"; var token = await GetAdminTokenAsync(); try { var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Create 201", token); using var req = BuildRequest(HttpMethod.Post, Endpoint, new { medioId, numeroAFIP = (short)1, nombre = "PdV Central Create", descripcion = "Test desc" }, 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 pdvId = idEl.GetInt32(); Assert.True(pdvId > 0); Assert.Equal(medioId, json.GetProperty("medioId").GetInt32()); Assert.Equal(1, json.GetProperty("numeroAFIP").GetInt16()); Assert.Equal("PdV Central Create", json.GetProperty("nombre").GetString()); Assert.True(json.GetProperty("activo").GetBoolean()); var auditCount = await CountAuditEventsAsync("punto_de_venta.create", "PuntoDeVenta", pdvId.ToString()); Assert.Equal(1, auditCount); } finally { await DeleteMedioIfExistsAsync(medioCodigo); } } /// T5.3 — 409 medio_inactivo al crear con Medio inactivo. [Fact] public async Task CreatePdv_WithInactiveMedio_Returns409MedioInactivo() { const string medioCodigo = "ADMS08_MED_INACT"; var token = await GetAdminTokenAsync(); try { var medioId = await CreateMedioAsync(medioCodigo, "Medio Inactivo PDV", 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 PdV in inactive medio using var req = BuildRequest(HttpMethod.Post, Endpoint, new { medioId, numeroAFIP = (short)1, nombre = "PdV en Medio Inactivo" }, token); var resp = await _client.SendAsync(req); Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); var json = await resp.Content.ReadFromJsonAsync(); Assert.Equal("medio_inactivo", json.GetProperty("error").GetString()); } finally { await DeleteMedioIfExistsAsync(medioCodigo); } } /// T5.3 — 409 numero_afip_duplicado al violar UNIQUE(MedioId, NumeroAFIP). [Fact] public async Task CreatePdv_DuplicateNumeroAFIPInSameMedio_Returns409() { const string medioCodigo = "ADMS08_MED_DUP"; var token = await GetAdminTokenAsync(); try { var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Dup", token); // First create using var first = BuildRequest(HttpMethod.Post, Endpoint, new { medioId, numeroAFIP = (short)1, nombre = "PdV Original" }, token); var firstResp = await _client.SendAsync(first); Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode); // Second with same medioId + numeroAFIP using var second = BuildRequest(HttpMethod.Post, Endpoint, new { medioId, numeroAFIP = (short)1, nombre = "PdV Duplicado" }, token); var secondResp = await _client.SendAsync(second); Assert.Equal(HttpStatusCode.Conflict, secondResp.StatusCode); var json = await secondResp.Content.ReadFromJsonAsync(); Assert.Equal("numero_afip_duplicado", json.GetProperty("error").GetString()); } finally { await DeleteMedioIfExistsAsync(medioCodigo); } } /// T5.3 — mismo NumeroAFIP en distinto Medio es permitido. [Fact] public async Task CreatePdv_SameNumeroAFIPDifferentMedio_Returns201() { const string medio1Codigo = "ADMS08_M1_MULTI"; const string medio2Codigo = "ADMS08_M2_MULTI"; var token = await GetAdminTokenAsync(); try { var medioId1 = await CreateMedioAsync(medio1Codigo, "Medio Multi 1 PDV", token); var medioId2 = await CreateMedioAsync(medio2Codigo, "Medio Multi 2 PDV", token); using var req1 = BuildRequest(HttpMethod.Post, Endpoint, new { medioId = medioId1, numeroAFIP = (short)1, nombre = "PdV Medio 1" }, token); var resp1 = await _client.SendAsync(req1); Assert.Equal(HttpStatusCode.Created, resp1.StatusCode); // Same numeroAFIP in different medio → should succeed using var req2 = BuildRequest(HttpMethod.Post, Endpoint, new { medioId = medioId2, numeroAFIP = (short)1, nombre = "PdV Medio 2" }, token); var resp2 = await _client.SendAsync(req2); Assert.Equal(HttpStatusCode.Created, resp2.StatusCode); } finally { await DeleteMedioIfExistsAsync(medio1Codigo); await DeleteMedioIfExistsAsync(medio2Codigo); } } // ── GET BY ID ──────────────────────────────────────────────────────────── /// T5.3 — 404 cuando id inexistente. [Fact] public async Task GetPdvById_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("punto_de_venta_not_found", json.GetProperty("error").GetString()); } // ── LIST ───────────────────────────────────────────────────────────────── /// T5.3 — List returns 200 with paged result. [Fact] public async Task ListPdvs_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'"); } /// T5.3 — List filtrado por medioId y activo. [Fact] public async Task ListPdvs_FilterByMedioAndActivo_ReturnsMatchingItems() { const string medioCodigo = "ADMS08_MED_LIST"; var token = await GetAdminTokenAsync(); try { var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV List", token); await CreatePdvAsync(medioId, 1, "PdV Lista 1", token); await CreatePdvAsync(medioId, 2, "PdV Lista 2", token); // Filter by medioId using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}?medioId={medioId}&activo=true", bearerToken: token); var resp = await _client.SendAsync(req); Assert.Equal(HttpStatusCode.OK, resp.StatusCode); var json = await resp.Content.ReadFromJsonAsync(); var items = json.GetProperty("items").EnumerateArray().ToList(); Assert.Equal(2, items.Count); Assert.All(items, item => Assert.Equal(medioId, item.GetProperty("medioId").GetInt32())); } finally { await DeleteMedioIfExistsAsync(medioCodigo); } } // ── UPDATE ──────────────────────────────────────────────────────────────── /// T5.3 — Happy path Update returns 200 + AuditEvent. [Fact] public async Task UpdatePdv_WithAdmin_Returns200AndAuditEvent() { const string medioCodigo = "ADMS08_MED_UPD"; var token = await GetAdminTokenAsync(); try { var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Update", token); var pdvId = await CreatePdvAsync(medioId, 1, "PdV Original", token); using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{pdvId}", new { nombre = "PdV Actualizado", numeroAFIP = (short)2, descripcion = "Nueva desc" }, token); var updateResp = await _client.SendAsync(updateReq); Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode); var updated = await updateResp.Content.ReadFromJsonAsync(); Assert.Equal("PdV Actualizado", updated.GetProperty("nombre").GetString()); Assert.Equal(2, updated.GetProperty("numeroAFIP").GetInt16()); var auditCount = await CountAuditEventsAsync("punto_de_venta.update", "PuntoDeVenta", pdvId.ToString()); Assert.Equal(1, auditCount); } finally { await DeleteMedioIfExistsAsync(medioCodigo); } } /// T5.3 — 409 medio_inactivo al actualizar PdV con Medio inactivo. [Fact] public async Task UpdatePdv_WhenMedioInactive_Returns409MedioInactivo() { const string medioCodigo = "ADMS08_MED_UPDMI"; var token = await GetAdminTokenAsync(); try { var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Update MedioInact", token); var pdvId = await CreatePdvAsync(medioId, 1, "PdV Update Medio Inactivo", token); // Deactivate the medio using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token); var deactMedioResp = await _client.SendAsync(deactMedioReq); Assert.Equal(HttpStatusCode.NoContent, deactMedioResp.StatusCode); // Try to update PdV with inactive medio using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{pdvId}", new { nombre = "PdV Medio Inactivo", numeroAFIP = (short)1 }, token); var updateResp = await _client.SendAsync(updateReq); Assert.Equal(HttpStatusCode.Conflict, updateResp.StatusCode); var json = await updateResp.Content.ReadFromJsonAsync(); Assert.Equal("medio_inactivo", json.GetProperty("error").GetString()); } finally { await DeleteMedioIfExistsAsync(medioCodigo); } } // ── DEACTIVATE ──────────────────────────────────────────────────────────── /// T5.3 — Happy path Deactivate returns 204 + AuditEvent. [Fact] public async Task DeactivatePdv_WithAdmin_Returns204AndAuditEvent() { const string medioCodigo = "ADMS08_MED_DEACT"; var token = await GetAdminTokenAsync(); try { var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Deactivate", token); var pdvId = await CreatePdvAsync(medioId, 1, "PdV Para Desactivar", token); using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); var deactResp = await _client.SendAsync(deactReq); Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); var auditCount = await CountAuditEventsAsync("punto_de_venta.deactivate", "PuntoDeVenta", pdvId.ToString()); Assert.Equal(1, auditCount); } finally { await DeleteMedioIfExistsAsync(medioCodigo); } } // ── REACTIVATE ──────────────────────────────────────────────────────────── /// T5.3 — Happy path Reactivate returns 204 + AuditEvent. [Fact] public async Task ReactivatePdv_WithAdmin_Returns204AndAuditEvent() { const string medioCodigo = "ADMS08_MED_REACT"; var token = await GetAdminTokenAsync(); try { var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reactivate", token); var pdvId = await CreatePdvAsync(medioId, 1, "PdV Para Reactivar", token); // Deactivate first using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); var deactResp = await _client.SendAsync(deactReq); Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); // Reactivate using var reactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/reactivate", bearerToken: token); var reactResp = await _client.SendAsync(reactReq); Assert.Equal(HttpStatusCode.NoContent, reactResp.StatusCode); var auditCount = await CountAuditEventsAsync("punto_de_venta.reactivate", "PuntoDeVenta", pdvId.ToString()); Assert.Equal(1, auditCount); // Verify it's active again via GET using var getReq = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}", bearerToken: token); var getResp = await _client.SendAsync(getReq); Assert.Equal(HttpStatusCode.OK, getResp.StatusCode); var pdvJson = await getResp.Content.ReadFromJsonAsync(); Assert.True(pdvJson.GetProperty("activo").GetBoolean()); } finally { await DeleteMedioIfExistsAsync(medioCodigo); } } /// T5.3 — 409 medio_inactivo al reactivar con Medio inactivo. [Fact] public async Task ReactivatePdv_WhenMedioInactive_Returns409MedioInactivo() { const string medioCodigo = "ADMS08_MED_REACTMI"; var token = await GetAdminTokenAsync(); try { var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reactivate Inactivo", token); var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reactivate Medio Inactivo", token); // Deactivate PdV while medio is active using var deactPdvReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); await _client.SendAsync(deactPdvReq); // Deactivate medio using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token); var deactMedioResp = await _client.SendAsync(deactMedioReq); Assert.Equal(HttpStatusCode.NoContent, deactMedioResp.StatusCode); // Try to reactivate PdV with inactive medio using var reactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/reactivate", bearerToken: token); var reactResp = await _client.SendAsync(reactReq); Assert.Equal(HttpStatusCode.Conflict, reactResp.StatusCode); var json = await reactResp.Content.ReadFromJsonAsync(); Assert.Equal("medio_inactivo", json.GetProperty("error").GetString()); } finally { await DeleteMedioIfExistsAsync(medioCodigo); } } }