diff --git a/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs b/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs new file mode 100644 index 0000000..8e8b271 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.cs @@ -0,0 +1,876 @@ +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 SecuenciaComprobante for PuntosDeVenta of this Medio (no versioning) + await conn.ExecuteAsync(""" + DELETE sc FROM dbo.SecuenciaComprobante sc + INNER JOIN dbo.PuntoDeVenta pdv ON pdv.Id = sc.PuntoDeVentaId + WHERE pdv.MedioId = @id + """, new { id }); + + // 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 punto_de_venta_inactivo al actualizar PdV inactivo. + [Fact] + public async Task UpdatePdv_WhenPdvInactive_Returns409PdvInactivo() + { + const string medioCodigo = "ADMS08_MED_UPDI"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Update Inactivo", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Para Inactivar", token); + + // Deactivate the PdV + using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + // Try to update inactive PdV + using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{pdvId}", new + { + nombre = "PdV Inactivo Update", + numeroAFIP = (short)1 + }, token); + var updateResp = await _client.SendAsync(updateReq); + + Assert.Equal(HttpStatusCode.Conflict, updateResp.StatusCode); + var json = await updateResp.Content.ReadFromJsonAsync(); + Assert.Equal("punto_de_venta_inactivo", json.GetProperty("error").GetString()); + } + 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); + } + } + + // ── SECUENCIAS: RESERVAR ────────────────────────────────────────────────── + + /// T5.3 — Primera reserva inicializa en 1. + [Fact] + public async Task ReservarNumero_FirstReservation_Returns1() + { + const string medioCodigo = "ADMS08_MED_RSV1"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar 1", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar First", token); + + using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(1, json.GetProperty("numeroReservado").GetInt32()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — 409 punto_de_venta_inactivo al reservar en PdV inactivo. + [Fact] + public async Task ReservarNumero_WhenPdvInactive_Returns409PdvInactivo() + { + const string medioCodigo = "ADMS08_MED_RSVI"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar Inactivo", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar Inactivo", token); + + // Deactivate PdV + using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token); + await _client.SendAsync(deactReq); + + using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("punto_de_venta_inactivo", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — 409 medio_inactivo al reservar con Medio inactivo. + [Fact] + public async Task ReservarNumero_WhenMedioInactive_Returns409MedioInactivo() + { + const string medioCodigo = "ADMS08_MED_RSVMI"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar MedioInactivo", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar MedioInact", token); + + // Deactivate medio + using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token); + await _client.SendAsync(deactMedioReq); + + using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: 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); + } + } + + // ── SECUENCIAS: PROXIMO ─────────────────────────────────────────────────── + + /// T5.3 — GetProximo es read-only: no modifica UltimoNumero. + [Fact] + public async Task GetProximoNumero_DoesNotChangeState() + { + const string medioCodigo = "ADMS08_MED_PROX"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Proximo", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Proximo", token); + + // Reserve once to establish state + using var rsv = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); + await _client.SendAsync(rsv); + + // GetProximo twice — should return 2 both times + using var req1 = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaA/proximo", bearerToken: token); + var resp1 = await _client.SendAsync(req1); + Assert.Equal(HttpStatusCode.OK, resp1.StatusCode); + var json1 = await resp1.Content.ReadFromJsonAsync(); + Assert.Equal(2, json1.GetProperty("proximoNumero").GetInt32()); + + using var req2 = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaA/proximo", bearerToken: token); + var resp2 = await _client.SendAsync(req2); + var json2 = await resp2.Content.ReadFromJsonAsync(); + Assert.Equal(2, json2.GetProperty("proximoNumero").GetInt32()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + /// T5.3 — GetProximo para fila inexistente devuelve 1. + [Fact] + public async Task GetProximoNumero_WhenNoSequenceExists_Returns1() + { + const string medioCodigo = "ADMS08_MED_PROX1"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Proximo 1", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Proximo First", token); + + using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaB/proximo", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(1, json.GetProperty("proximoNumero").GetInt32()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── T5.4 — Concurrencia ─────────────────────────────────────────────────── + + /// + /// T5.4 — 50 tasks paralelas reservando para mismo PdV + TipoComprobante + /// deben producir 50 números distintos cubriendo {1..50}. + /// + [Fact] + public async Task ReservarNumero_50ConcurrentReservations_ProducesNoDuplicates() + { + const string medioCodigo = "ADMS08_CONC50"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Concurrencia 50", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Concurrencia 50", token); + + const int taskCount = 50; + var tasks = Enumerable.Range(0, taskCount) + .Select(_ => Task.Run(async () => + { + // Each task creates its own HttpClient to avoid sharing + // the shared _client which is not thread-safe for concurrent requests. + // Use BuildRequest on a shared client IS safe since HttpClient is thread-safe + // for concurrent operations as long as each message is distinct. + using var req = new HttpRequestMessage(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar"); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var resp = await _client.SendAsync(req); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadFromJsonAsync(); + return json.GetProperty("numeroReservado").GetInt32(); + })) + .ToList(); + + var results = await Task.WhenAll(tasks); + + // All 50 numbers must be present exactly once + Assert.Equal(taskCount, results.Length); + Assert.Equal(taskCount, results.Distinct().Count()); + + var expected = Enumerable.Range(1, taskCount).ToHashSet(); + var actual = results.ToHashSet(); + Assert.Equal(expected, actual); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── T5.5 — Secuencialidad ───────────────────────────────────────────────── + + /// + /// T5.5 — 100 reservas en serie para mismo PdV + TipoComprobante + /// deben devolver {1, 2, 3, ..., 100} en orden. + /// + [Fact] + public async Task ReservarNumero_100SerialReservations_ProducesSequentialNumbers() + { + const string medioCodigo = "ADMS08_SEQ100"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Secuencial 100", token); + var pdvId = await CreatePdvAsync(medioId, 1, "PdV Secuencial 100", token); + + const int count = 100; + var results = new List(count); + + for (int i = 0; i < count; i++) + { + using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token); + var resp = await _client.SendAsync(req); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadFromJsonAsync(); + results.Add(json.GetProperty("numeroReservado").GetInt32()); + } + + // Verify sequential: {1, 2, 3, ..., 100} + var expected = Enumerable.Range(1, count).ToList(); + Assert.Equal(expected, results); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } +}