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