From 4544a000ae9ab4eb900e8f19d65d640636786b0b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 18:39:55 -0300 Subject: [PATCH] =?UTF-8?q?test(adm-009):=20FiscalController=20integration?= =?UTF-8?q?=20tests=20with=20JWT=20auth=20(Red=E2=86=92Green)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/FiscalControllerTests.cs | 696 ++++++++++++++++++ 1 file changed, 696 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Admin/FiscalControllerTests.cs diff --git a/tests/SIGCM2.Api.Tests/Admin/FiscalControllerTests.cs b/tests/SIGCM2.Api.Tests/Admin/FiscalControllerTests.cs new file mode 100644 index 0000000..704b380 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Admin/FiscalControllerTests.cs @@ -0,0 +1,696 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Admin; + +/// +/// ADM-009 Batch 5 — Integration tests for /api/v1/admin/fiscal +/// Requires permission 'administracion:fiscal:gestionar'. +/// All tests use real JWT RS256 auth via TestWebAppFactory. +/// +[Collection("ApiIntegration")] +public sealed class FiscalControllerTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private const string IvaEndpoint = "/api/v1/admin/fiscal/iva"; + private const string IibbEndpoint = "/api/v1/admin/fiscal/iibb"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public FiscalControllerTests(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, + string contentType = "application/json") + { + var request = new HttpRequestMessage(method, url); + if (bearerToken is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + if (body is not null) + request.Content = JsonContent.Create(body); + return request; + } + + private HttpRequestMessage BuildRawRequest( + HttpMethod method, + string url, + string rawJson, + string? bearerToken = null) + { + var request = new HttpRequestMessage(method, url); + if (bearerToken is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + request.Content = new StringContent(rawJson, Encoding.UTF8, "application/json"); + return request; + } + + private async Task CreateTipoDeIvaAsync(string codigo, string descripcion, decimal porcentaje, bool aplicaIva, string vigenciaDesde, string token) + { + using var req = BuildRequest(HttpMethod.Post, IvaEndpoint, new + { + codigo, + descripcion, + porcentaje, + aplicaIVA = aplicaIva, + vigenciaDesde + }, token); + var resp = await _client.SendAsync(req); + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(); + Assert.Fail($"CreateTipoDeIva failed {resp.StatusCode}: {body}"); + } + var json = await resp.Content.ReadFromJsonAsync(); + return json.GetProperty("id").GetInt32(); + } + + private async Task CreateIngresosBrutosAsync(string provincia, string descripcion, decimal alicuota, string vigenciaDesde, string token) + { + using var req = BuildRequest(HttpMethod.Post, IibbEndpoint, new + { + provincia, + descripcion, + alicuota, + vigenciaDesde + }, token); + var resp = await _client.SendAsync(req); + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(); + Assert.Fail($"CreateIngresosBrutos failed {resp.StatusCode}: {body}"); + } + var json = await resp.Content.ReadFromJsonAsync(); + return json.GetProperty("id").GetInt32(); + } + + private static async Task DeleteTipoDeIvaByCodigoAsync(string codigo) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync("ALTER TABLE dbo.TipoDeIva SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.TipoDeIva_History WHERE Codigo = @Codigo", new { Codigo = codigo }); + await conn.ExecuteAsync("DELETE FROM dbo.TipoDeIva WHERE Codigo = @Codigo", new { Codigo = codigo }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.TipoDeIva SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.TipoDeIva_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + } + + private static async Task DeleteIngresosBrutosByProvinciaAsync(string provincia) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync("ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync( + "DELETE FROM dbo.IngresosBrutos_History WHERE Provincia = @Provincia", new { Provincia = provincia }); + await conn.ExecuteAsync( + "DELETE FROM dbo.IngresosBrutos WHERE Provincia = @Provincia", new { Provincia = provincia }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.IngresosBrutos_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 }); + } + + // ── AUTH / PERMISSION GUARDS ───────────────────────────────────────────── + + /// [REQ-FISCAL-AUTH-001] GET /iva sin auth → 401. + [Fact] + public async Task GetIva_WithoutAuth_Returns401() + { + using var req = new HttpRequestMessage(HttpMethod.Get, IvaEndpoint); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + /// [REQ-FISCAL-AUTH-001] GET /iva con cajero (sin permiso fiscal) → 403. + [Fact] + public async Task GetIva_WithCajeroRole_Returns403() + { + const string username = "adm009_fiscal_cajero_403"; + try + { + var token = await GetCajeroTokenAsync(username); + using var req = BuildRequest(HttpMethod.Get, IvaEndpoint, bearerToken: token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + } + finally + { + await DeleteUsuarioIfExistsAsync(username); + } + } + + /// [REQ-FISCAL-AUTH-002] GET /iva con admin (tiene permiso) → 200. + [Fact] + public async Task GetIva_WithAdmin_Returns200() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, IvaEndpoint, 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'"); + } + + // ── IVA: POST (CREATE) ──────────────────────────────────────────────────── + + /// POST /iva → 201 con id, campos correctos. + [Fact] + public async Task CreateIva_WithAdmin_Returns201() + { + // Codigo must match ^(EXENTO|NO_GRAVADO|IVA_\d+)$ + const string codigo = "IVA_9901"; + var token = await GetAdminTokenAsync(); + try + { + using var req = BuildRequest(HttpMethod.Post, IvaEndpoint, new + { + codigo, + descripcion = "IVA Test Creacion", + porcentaje = 15.5m, + aplicaIVA = true, + vigenciaDesde = "2025-01-01" + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.GetProperty("id").GetInt32() > 0); + Assert.Equal(codigo, json.GetProperty("codigo").GetString()); + Assert.Equal(15.5m, json.GetProperty("porcentaje").GetDecimal()); + Assert.True(json.GetProperty("activo").GetBoolean()); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + /// [REQ-TIPOIVA-CREATE-002] POST /iva con codigo duplicado en misma vigencia → 409 duplicate_codigo. + [Fact] + public async Task CreateIva_DuplicateCodigo_Returns409() + { + const string codigo = "IVA_9902"; + var token = await GetAdminTokenAsync(); + try + { + await CreateTipoDeIvaAsync(codigo, "IVA Original", 10m, true, "2025-01-01", token); + + using var req = BuildRequest(HttpMethod.Post, IvaEndpoint, new + { + codigo, + descripcion = "IVA Duplicado", + porcentaje = 12m, + aplicaIVA = true, + vigenciaDesde = "2025-01-01" + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("duplicate_codigo", json.GetProperty("error").GetString()); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + // ── IVA: GET BY ID ──────────────────────────────────────────────────────── + + /// GET /iva/{id} inexistente → 404 tipo_iva_not_found. + [Fact] + public async Task GetIvaById_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{IvaEndpoint}/999999", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("tipo_iva_not_found", json.GetProperty("error").GetString()); + } + + /// GET /iva/{id} existente → 200 con campos correctos. + [Fact] + public async Task GetIvaById_Existing_Returns200() + { + const string codigo = "IVA_9903"; + var token = await GetAdminTokenAsync(); + try + { + var id = await CreateTipoDeIvaAsync(codigo, "IVA GetById", 10m, true, "2025-01-01", token); + using var req = BuildRequest(HttpMethod.Get, $"{IvaEndpoint}/{id}", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(id, json.GetProperty("id").GetInt32()); + Assert.Equal(codigo, json.GetProperty("codigo").GetString()); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + // ── IVA: PATCH (UPDATE COSMETICO) ───────────────────────────────────────── + + /// PATCH /iva/{id} con campos cosméticos → 200 OK. + [Fact] + public async Task PatchIva_CosmeticFields_Returns200() + { + const string codigo = "IVA_9904"; + var token = await GetAdminTokenAsync(); + try + { + var id = await CreateTipoDeIvaAsync(codigo, "Descripcion Original", 10m, true, "2025-01-01", token); + + using var req = BuildRawRequest( + HttpMethod.Patch, + $"{IvaEndpoint}/{id}", + """{"codigo":"IVA_9904","descripcion":"Descripcion Actualizada","aplicaIVA":true,"activo":true}""", + token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("Descripcion Actualizada", json.GetProperty("descripcion").GetString()); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + /// [REQ-TIPOIVA-UPDATE-002] PATCH /iva/{id} con "porcentaje" en body → 409 inmutable_usar_nueva_version. + [Fact] + public async Task PatchIva_WithPorcentajeInBody_Returns409Inmutable() + { + const string codigo = "IVA_9905"; + var token = await GetAdminTokenAsync(); + try + { + var id = await CreateTipoDeIvaAsync(codigo, "IVA Patch Pct", 10m, true, "2025-01-01", token); + + using var req = BuildRawRequest( + HttpMethod.Patch, + $"{IvaEndpoint}/{id}", + """{"porcentaje":23.5}""", + token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("inmutable_usar_nueva_version", json.GetProperty("error").GetString()); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + // ── IVA: NUEVA VERSION ──────────────────────────────────────────────────── + + /// [REQ-TIPOIVA-NUEVAVER-001] POST /iva/{id}/nueva-version → 201 con predecesoraId+nuevaVersionId correctos. + [Fact] + public async Task NuevaVersionIva_HappyPath_Returns201WithChain() + { + const string codigo = "IVA_9906"; + var token = await GetAdminTokenAsync(); + try + { + var predecesoraId = await CreateTipoDeIvaAsync(codigo, "IVA Nueva Version", 10m, true, "2024-01-01", token); + + using var req = BuildRequest( + HttpMethod.Post, + $"{IvaEndpoint}/{predecesoraId}/nueva-version", + new { porcentaje = 12m, vigenciaDesde = "2025-01-01" }, + token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal(predecesoraId, json.GetProperty("predecesoraId").GetInt32()); + var nuevaVersionId = json.GetProperty("nuevaVersionId").GetInt32(); + Assert.True(nuevaVersionId > predecesoraId); + + // Verificar que la predecesora quedó cerrada (VigenciaHasta != null) + using var getReq = BuildRequest(HttpMethod.Get, $"{IvaEndpoint}/{predecesoraId}", bearerToken: token); + var getResp = await _client.SendAsync(getReq); + var predecesoraJson = await getResp.Content.ReadFromJsonAsync(); + Assert.NotEqual(JsonValueKind.Null, predecesoraJson.GetProperty("vigenciaHasta").ValueKind); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + /// [REQ-TIPOIVA-NUEVAVER-003] POST /iva/{id}/nueva-version sobre predecesora ya cerrada → 409 predecesora_ya_cerrada. + [Fact] + public async Task NuevaVersionIva_PredecesoraYaCerrada_Returns409() + { + const string codigo = "IVA_9907"; + var token = await GetAdminTokenAsync(); + try + { + var predecesoraId = await CreateTipoDeIvaAsync(codigo, "IVA Predecesora Cerrada", 10m, true, "2024-01-01", token); + + // Primera nueva version — cierra la predecesora + using var req1 = BuildRequest( + HttpMethod.Post, + $"{IvaEndpoint}/{predecesoraId}/nueva-version", + new { porcentaje = 12m, vigenciaDesde = "2025-01-01" }, + token); + var resp1 = await _client.SendAsync(req1); + Assert.Equal(HttpStatusCode.Created, resp1.StatusCode); + + // Segunda sobre la predecesora original (ya cerrada) + using var req2 = BuildRequest( + HttpMethod.Post, + $"{IvaEndpoint}/{predecesoraId}/nueva-version", + new { porcentaje = 15m, vigenciaDesde = "2026-01-01" }, + token); + var resp2 = await _client.SendAsync(req2); + + Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode); + var json = await resp2.Content.ReadFromJsonAsync(); + Assert.Equal("predecesora_ya_cerrada", json.GetProperty("error").GetString()); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + /// POST /iva/{id}/nueva-version con vigenciaDesde inválida → 400 vigencia_desde_invalida. + [Fact] + public async Task NuevaVersionIva_VigenciaDesdeInvalida_Returns400() + { + const string codigo = "IVA_9908"; + var token = await GetAdminTokenAsync(); + try + { + var predecesoraId = await CreateTipoDeIvaAsync(codigo, "IVA Vig Invalida", 10m, true, "2025-06-01", token); + + // vigenciaDesde anterior a la de la predecesora + using var req = BuildRequest( + HttpMethod.Post, + $"{IvaEndpoint}/{predecesoraId}/nueva-version", + new { porcentaje = 12m, vigenciaDesde = "2024-01-01" }, + token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + // Validate that the error body contains info about vigencia_desde_invalida + var bodyStr = json.GetRawText(); + Assert.Contains("vigencia_desde_invalida", bodyStr); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + // ── IVA: HISTORIAL ──────────────────────────────────────────────────────── + + /// GET /iva/{id}/historial → 200 con cadena ordenada. + [Fact] + public async Task GetHistorialIva_Returns200WithOrderedChain() + { + const string codigo = "IVA_9909"; + var token = await GetAdminTokenAsync(); + try + { + // Crear cadena de 2 versiones + var v1Id = await CreateTipoDeIvaAsync(codigo, "IVA Historial v1", 10m, true, "2023-01-01", token); + using var nvReq = BuildRequest( + HttpMethod.Post, + $"{IvaEndpoint}/{v1Id}/nueva-version", + new { porcentaje = 15m, vigenciaDesde = "2025-01-01" }, + token); + var nvResp = await _client.SendAsync(nvReq); + Assert.Equal(HttpStatusCode.Created, nvResp.StatusCode); + var nvJson = await nvResp.Content.ReadFromJsonAsync(); + var v2Id = nvJson.GetProperty("nuevaVersionId").GetInt32(); + + // GET historial desde v2 (la actual) + using var req = BuildRequest(HttpMethod.Get, $"{IvaEndpoint}/{v2Id}/historial", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var chain = await resp.Content.ReadFromJsonAsync(); + var items = chain.EnumerateArray().ToList(); + Assert.Equal(2, items.Count); + // Version 1 tiene version=1, version 2 tiene version=2 + Assert.Equal(1, items[0].GetProperty("version").GetInt32()); + Assert.Equal(2, items[1].GetProperty("version").GetInt32()); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + // ── IVA: DEACTIVATE / REACTIVATE ───────────────────────────────────────── + + /// POST /iva/{id}/deactivate → 200 con activo=false. + [Fact] + public async Task DeactivateIva_Returns200WithActivoFalse() + { + const string codigo = "IVA_9910"; + var token = await GetAdminTokenAsync(); + try + { + var id = await CreateTipoDeIvaAsync(codigo, "IVA Deactivate", 10m, true, "2025-01-01", token); + + using var req = BuildRequest(HttpMethod.Post, $"{IvaEndpoint}/{id}/deactivate", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.False(json.GetProperty("activo").GetBoolean()); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + /// POST /iva/{id}/reactivate → 200 con activo=true. + [Fact] + public async Task ReactivateIva_Returns200WithActivoTrue() + { + const string codigo = "IVA_9911"; + var token = await GetAdminTokenAsync(); + try + { + var id = await CreateTipoDeIvaAsync(codigo, "IVA Reactivate", 10m, true, "2025-01-01", token); + + // Deactivate primero + using var deactReq = BuildRequest(HttpMethod.Post, $"{IvaEndpoint}/{id}/deactivate", bearerToken: token); + await _client.SendAsync(deactReq); + + // Reactivate + using var reactReq = BuildRequest(HttpMethod.Post, $"{IvaEndpoint}/{id}/reactivate", bearerToken: token); + var reactResp = await _client.SendAsync(reactReq); + + Assert.Equal(HttpStatusCode.OK, reactResp.StatusCode); + var json = await reactResp.Content.ReadFromJsonAsync(); + Assert.True(json.GetProperty("activo").GetBoolean()); + } + finally + { + await DeleteTipoDeIvaByCodigoAsync(codigo); + } + } + + // ── IIBB: Tests espejo mínimos ──────────────────────────────────────────── + + /// [REQ-FISCAL-AUTH-001] GET /iibb sin auth → 401. + [Fact] + public async Task GetIibb_WithoutAuth_Returns401() + { + using var req = new HttpRequestMessage(HttpMethod.Get, IibbEndpoint); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + /// POST /iibb → 201 con id correcto. + [Fact] + public async Task CreateIibb_WithAdmin_Returns201() + { + // Usar una provincia que no tenga datos de test previos + // Nota: El seed tiene todas las provincias con Alicuota=0. + // Para crear un nuevo registro necesitamos una provincia+vigenciaDesde únicos. + // Los repos aceptan combinación (Provincia, VigenciaDesde) única. + // Usamos "Formosa" con una fecha específica de test. + const string provincia = "Formosa"; + const string vigenciaDesde = "2030-01-01"; // fecha futura para no colisionar con seed + var token = await GetAdminTokenAsync(); + try + { + using var req = BuildRequest(HttpMethod.Post, IibbEndpoint, new + { + provincia, + descripcion = "IIBB Formosa Test", + alicuota = 2.5m, + vigenciaDesde + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.GetProperty("id").GetInt32() > 0); + Assert.Equal(provincia, json.GetProperty("provincia").GetString()); + Assert.Equal(2.5m, json.GetProperty("alicuota").GetDecimal()); + } + finally + { + // Limpiar solo la fila con la fecha de test, no las del seed + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync("ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync( + "DELETE FROM dbo.IngresosBrutos_History WHERE Provincia = 'Formosa' AND VigenciaDesde = '2030-01-01'"); + await conn.ExecuteAsync( + "DELETE FROM dbo.IngresosBrutos WHERE Provincia = 'Formosa' AND VigenciaDesde = '2030-01-01'"); + await conn.ExecuteAsync( + "ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.IngresosBrutos_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + } + } + + /// PATCH /iibb/{id} con "alicuota" en body → 409 inmutable_usar_nueva_version. + [Fact] + public async Task PatchIibb_WithAlicuotaInBody_Returns409Inmutable() + { + const string provincia = "Jujuy"; + const string vigenciaDesde = "2030-02-01"; + var token = await GetAdminTokenAsync(); + try + { + var id = await CreateIngresosBrutosAsync(provincia, "IIBB Jujuy Test", 1.5m, vigenciaDesde, token); + + using var req = BuildRawRequest( + HttpMethod.Patch, + $"{IibbEndpoint}/{id}", + """{"alicuota":3.0}""", + token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("inmutable_usar_nueva_version", json.GetProperty("error").GetString()); + } + finally + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync("ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync( + "DELETE FROM dbo.IngresosBrutos_History WHERE Provincia = 'Jujuy' AND VigenciaDesde = '2030-02-01'"); + await conn.ExecuteAsync( + "DELETE FROM dbo.IngresosBrutos WHERE Provincia = 'Jujuy' AND VigenciaDesde = '2030-02-01'"); + await conn.ExecuteAsync( + "ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.IngresosBrutos_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + } + } + + /// GET /iibb/{id} inexistente → 404 ingresos_brutos_not_found. + [Fact] + public async Task GetIibbById_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{IibbEndpoint}/999999", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("ingresos_brutos_not_found", json.GetProperty("error").GetString()); + } + + /// GET /iibb con admin → 200 con paged result. + [Fact] + public async Task GetIibb_WithAdmin_Returns200PagedResult() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, IibbEndpoint, 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'"); + } +}