Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Admin/PuntosDeVentaControllerTests.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

600 lines
25 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-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.
/// </summary>
[Collection("ApiIntegration")]
public sealed class PuntosDeVentaControllerTests : IAsyncLifetime
{
private const string TestConnectionString = TestConnectionStrings.ApiTestDb;
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<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;
}
/// <summary>Creates a Medio via the API and returns its id.</summary>
private async Task<int> 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<JsonElement>();
return json.GetProperty("id").GetInt32();
}
/// <summary>Creates a PuntoDeVenta via the API and returns its id.</summary>
private async Task<int> 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<JsonElement>();
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<int?>(
"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<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 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 ────────────────────────────────────────────────────────────────
/// <summary>T5.3 — Happy path: Create returns 201 + AuditEvent.</summary>
[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<JsonElement>();
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);
}
}
/// <summary>T5.3 — 409 medio_inactivo al crear con Medio inactivo.</summary>
[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<JsonElement>();
Assert.Equal("medio_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 numero_afip_duplicado al violar UNIQUE(MedioId, NumeroAFIP).</summary>
[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<JsonElement>();
Assert.Equal("numero_afip_duplicado", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — mismo NumeroAFIP en distinto Medio es permitido.</summary>
[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 ────────────────────────────────────────────────────────────
/// <summary>T5.3 — 404 cuando id inexistente.</summary>
[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<JsonElement>();
Assert.Equal("punto_de_venta_not_found", json.GetProperty("error").GetString());
}
// ── LIST ─────────────────────────────────────────────────────────────────
/// <summary>T5.3 — List returns 200 with paged result.</summary>
[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<JsonElement>();
Assert.True(json.TryGetProperty("items", out _), "Response must have 'items'");
Assert.True(json.TryGetProperty("total", out _), "Response must have 'total'");
}
/// <summary>T5.3 — List filtrado por medioId y activo.</summary>
[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<JsonElement>();
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 ────────────────────────────────────────────────────────────────
/// <summary>T5.3 — Happy path Update returns 200 + AuditEvent.</summary>
[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<JsonElement>();
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);
}
}
/// <summary>T5.3 — 409 medio_inactivo al actualizar PdV con Medio inactivo.</summary>
[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<JsonElement>();
Assert.Equal("medio_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── DEACTIVATE ────────────────────────────────────────────────────────────
/// <summary>T5.3 — Happy path Deactivate returns 204 + AuditEvent.</summary>
[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 ────────────────────────────────────────────────────────────
/// <summary>T5.3 — Happy path Reactivate returns 204 + AuditEvent.</summary>
[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<JsonElement>();
Assert.True(pdvJson.GetProperty("activo").GetBoolean());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 medio_inactivo al reactivar con Medio inactivo.</summary>
[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<JsonElement>();
Assert.Equal("medio_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
}