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