341 lines
13 KiB
Python
341 lines
13 KiB
Python
"""
|
|
Tests para la API de Validación Tributaria.
|
|
|
|
Incluye:
|
|
- Tests unitarios de lógica tributaria (RUT, IVA, honorarios, topes, F29)
|
|
- Tests de integración de endpoints con httpx AsyncClient
|
|
- Tests de validación de modelos Pydantic
|
|
|
|
Autor: José Nicolás Candia (@mechjook)
|
|
"""
|
|
|
|
import pytest
|
|
from httpx import AsyncClient, ASGITransport
|
|
|
|
from src.api import app
|
|
|
|
|
|
# ============================================================
|
|
# Tests unitarios — RUT
|
|
# ============================================================
|
|
|
|
class TestRut:
|
|
"""Tests para src/rut.py."""
|
|
|
|
def test_rut_valido(self):
|
|
from src.rut import validar_rut
|
|
result = validar_rut("12.345.678-5")
|
|
assert result["valido"] is True
|
|
assert result["digito_verificador"] == "5"
|
|
|
|
def test_rut_valido_sin_formato(self):
|
|
from src.rut import validar_rut
|
|
result = validar_rut("123456785")
|
|
assert result["valido"] is True
|
|
|
|
def test_rut_invalido(self):
|
|
from src.rut import validar_rut
|
|
result = validar_rut("12.345.678-0")
|
|
assert result["valido"] is False
|
|
assert result["digito_esperado"] == "5"
|
|
|
|
def test_rut_con_k(self):
|
|
from src.rut import validar_rut
|
|
result = validar_rut("44.444.446-0")
|
|
# Solo verificar que retorna resultado sin error
|
|
assert "valido" in result
|
|
|
|
def test_rut_vacio(self):
|
|
from src.rut import validar_rut
|
|
result = validar_rut("")
|
|
assert result["valido"] is False
|
|
assert "vacío" in result["mensaje"].lower()
|
|
|
|
def test_rut_corto(self):
|
|
from src.rut import validar_rut
|
|
result = validar_rut("5")
|
|
assert result["valido"] is False
|
|
|
|
def test_calcular_dv(self):
|
|
from src.rut import calcular_digito_verificador
|
|
assert calcular_digito_verificador(12345678) == "5"
|
|
|
|
def test_formatear_rut(self):
|
|
from src.rut import formatear_rut
|
|
assert formatear_rut("123456785") == "12.345.678-5"
|
|
|
|
|
|
# ============================================================
|
|
# Tests unitarios — IVA
|
|
# ============================================================
|
|
|
|
class TestIva:
|
|
"""Tests para src/iva.py."""
|
|
|
|
def test_iva_desde_neto(self):
|
|
from src.iva import calcular_iva_desde_neto
|
|
result = calcular_iva_desde_neto(1_000_000)
|
|
assert result["iva"] == 190_000
|
|
assert result["monto_bruto"] == 1_190_000
|
|
|
|
def test_neto_desde_bruto(self):
|
|
from src.iva import calcular_neto_desde_bruto
|
|
result = calcular_neto_desde_bruto(1_190_000)
|
|
assert result["monto_neto"] == 1_000_000
|
|
assert result["iva"] == 190_000
|
|
|
|
def test_iva_cero(self):
|
|
from src.iva import calcular_iva_desde_neto
|
|
result = calcular_iva_desde_neto(0)
|
|
assert result["iva"] == 0
|
|
|
|
def test_iva_negativo_error(self):
|
|
from src.iva import calcular_iva_desde_neto
|
|
with pytest.raises(ValueError):
|
|
calcular_iva_desde_neto(-100)
|
|
|
|
def test_desglose_factura(self):
|
|
from src.iva import desglose_factura
|
|
items = [
|
|
{"descripcion": "Item 1", "cantidad": 2, "precio_unitario_neto": 500_000},
|
|
{"descripcion": "Item 2", "cantidad": 1, "precio_unitario_neto": 300_000},
|
|
]
|
|
result = desglose_factura(items)
|
|
assert result["subtotal_neto"] == 1_300_000
|
|
assert result["iva_total"] == 247_000
|
|
assert result["total_bruto"] == 1_547_000
|
|
assert result["cantidad_items"] == 2
|
|
|
|
|
|
# ============================================================
|
|
# Tests unitarios — Honorarios
|
|
# ============================================================
|
|
|
|
class TestHonorarios:
|
|
"""Tests para src/honorarios.py."""
|
|
|
|
def test_retencion_2026(self):
|
|
from src.honorarios import calcular_retencion_desde_bruto
|
|
result = calcular_retencion_desde_bruto(1_000_000, anio=2026)
|
|
assert result["tasa_retencion"] == 0.1525
|
|
assert result["retencion"] == 152_500
|
|
assert result["monto_liquido"] == 847_500
|
|
|
|
def test_retencion_2027(self):
|
|
from src.honorarios import calcular_retencion_desde_bruto
|
|
result = calcular_retencion_desde_bruto(1_000_000, anio=2027)
|
|
assert result["tasa_retencion"] == 0.17
|
|
assert result["retencion"] == 170_000
|
|
|
|
def test_bruto_desde_liquido(self):
|
|
from src.honorarios import calcular_bruto_desde_liquido
|
|
result = calcular_bruto_desde_liquido(847_500, anio=2026)
|
|
assert result["monto_bruto"] == 1_000_000
|
|
assert result["monto_liquido"] == 847_500
|
|
|
|
def test_simulacion_anual(self):
|
|
from src.honorarios import simular_honorarios_anuales
|
|
result = simular_honorarios_anuales(1_000_000, meses=12, anio=2026)
|
|
assert result["total_bruto"] == 12_000_000
|
|
assert result["meses"] == 12
|
|
assert len(result["detalle_mensual"]) == 12
|
|
|
|
def test_honorarios_negativo_error(self):
|
|
from src.honorarios import calcular_retencion_desde_bruto
|
|
with pytest.raises(ValueError):
|
|
calcular_retencion_desde_bruto(-500_000)
|
|
|
|
|
|
# ============================================================
|
|
# Tests unitarios — Topes
|
|
# ============================================================
|
|
|
|
class TestTopes:
|
|
"""Tests para src/topes.py."""
|
|
|
|
def test_calcular_topes(self):
|
|
from src.topes import calcular_topes
|
|
result = calcular_topes(39842.0)
|
|
assert result["tope_afp_uf"] == 90.0
|
|
assert result["tope_afp_pesos"] == round(90 * 39842)
|
|
assert result["tope_cesantia_pesos"] == round(135.2 * 39842)
|
|
|
|
def test_cotizaciones_basicas(self):
|
|
from src.topes import calcular_cotizaciones
|
|
result = calcular_cotizaciones(1_500_000, afp="Habitat")
|
|
assert result["afp"] == "Habitat"
|
|
assert result["cotizaciones"]["afp"]["tasa"] == 0.1127
|
|
assert result["cotizaciones"]["salud"]["tasa"] == 0.07
|
|
assert result["renta_liquida_estimada"] < 1_500_000
|
|
|
|
def test_cotizaciones_sobre_tope(self):
|
|
from src.topes import calcular_cotizaciones
|
|
result = calcular_cotizaciones(5_000_000, afp="Habitat")
|
|
assert result["topes"]["renta_supera_tope_afp"] is True
|
|
|
|
def test_cotizaciones_afp_invalida(self):
|
|
from src.topes import calcular_cotizaciones
|
|
with pytest.raises(ValueError, match="AFP no válida"):
|
|
calcular_cotizaciones(1_000_000, afp="NoExiste")
|
|
|
|
def test_listar_afps(self):
|
|
from src.topes import listar_afps
|
|
afps = listar_afps()
|
|
assert len(afps) == 7
|
|
nombres = [a["nombre"] for a in afps]
|
|
assert "Habitat" in nombres
|
|
assert "Modelo" in nombres
|
|
|
|
|
|
# ============================================================
|
|
# Tests unitarios — F29
|
|
# ============================================================
|
|
|
|
class TestF29:
|
|
"""Tests para src/f29.py."""
|
|
|
|
def test_f29_iva_a_pagar(self):
|
|
from src.f29 import simular_f29
|
|
result = simular_f29(ventas_netas=10_000_000, compras_netas=6_000_000)
|
|
debito = round(10_000_000 * 0.19)
|
|
credito = round(6_000_000 * 0.19)
|
|
assert result["debito_fiscal"] == debito
|
|
assert result["credito_fiscal"] == credito
|
|
assert result["iva_a_pagar"] == debito - credito
|
|
assert result["resultado"] == "A PAGAR"
|
|
|
|
def test_f29_remanente(self):
|
|
from src.f29 import simular_f29
|
|
result = simular_f29(ventas_netas=2_000_000, compras_netas=8_000_000)
|
|
assert result["iva_a_pagar"] == 0
|
|
assert result["remanente_nuevo"] > 0
|
|
|
|
def test_f29_con_remanente_anterior(self):
|
|
from src.f29 import simular_f29
|
|
result = simular_f29(ventas_netas=5_000_000, compras_netas=3_000_000,
|
|
remanente_anterior=200_000)
|
|
credito_total = round(3_000_000 * 0.19) + 200_000
|
|
assert result["credito_fiscal_total"] == credito_total
|
|
|
|
def test_f29_ppm(self):
|
|
from src.f29 import simular_f29
|
|
result = simular_f29(ventas_netas=10_000_000, compras_netas=0)
|
|
assert result["ppm"]["monto"] == 100_000
|
|
assert result["ppm"]["tasa"] == 0.01
|
|
|
|
def test_f29_ventas_negativas_error(self):
|
|
from src.f29 import simular_f29
|
|
with pytest.raises(ValueError):
|
|
simular_f29(ventas_netas=-1, compras_netas=0)
|
|
|
|
|
|
# ============================================================
|
|
# Tests de integración — Endpoints API
|
|
# ============================================================
|
|
|
|
@pytest.fixture
|
|
def transport():
|
|
return ASGITransport(app=app)
|
|
|
|
|
|
class TestApiEndpoints:
|
|
"""Tests de endpoints con httpx."""
|
|
|
|
@pytest.mark.anyio
|
|
async def test_health(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.get("/api/health")
|
|
assert r.status_code == 200
|
|
assert r.json()["status"] == "ok"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_validar_rut_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/rut/validar", json={"rut": "12.345.678-5"})
|
|
assert r.status_code == 200
|
|
assert r.json()["valido"] is True
|
|
|
|
@pytest.mark.anyio
|
|
async def test_iva_desde_neto_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/iva/desde-neto", json={"monto_neto": 1000000})
|
|
assert r.status_code == 200
|
|
assert r.json()["iva"] == 190000
|
|
|
|
@pytest.mark.anyio
|
|
async def test_iva_desde_bruto_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/iva/desde-bruto", json={"monto_bruto": 1190000})
|
|
assert r.status_code == 200
|
|
assert r.json()["monto_neto"] == 1000000
|
|
|
|
@pytest.mark.anyio
|
|
async def test_factura_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/iva/factura", json={
|
|
"items": [{"descripcion": "Test", "cantidad": 1, "precio_unitario_neto": 500000}]
|
|
})
|
|
assert r.status_code == 200
|
|
assert r.json()["subtotal_neto"] == 500000
|
|
|
|
@pytest.mark.anyio
|
|
async def test_honorarios_retencion_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/honorarios/retencion", json={"monto_bruto": 1000000, "anio": 2026})
|
|
assert r.status_code == 200
|
|
assert r.json()["retencion"] == 152500
|
|
|
|
@pytest.mark.anyio
|
|
async def test_honorarios_liquido_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/honorarios/liquido", json={"monto_liquido": 847500, "anio": 2026})
|
|
assert r.status_code == 200
|
|
assert r.json()["monto_bruto"] == 1000000
|
|
|
|
@pytest.mark.anyio
|
|
async def test_honorarios_anual_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/honorarios/anual", json={"monto_mensual_bruto": 1000000, "meses": 12})
|
|
assert r.status_code == 200
|
|
assert r.json()["total_bruto"] == 12000000
|
|
|
|
@pytest.mark.anyio
|
|
async def test_topes_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/topes/calcular", json={"valor_uf": 39842})
|
|
assert r.status_code == 200
|
|
assert r.json()["tope_afp_uf"] == 90.0
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cotizaciones_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/topes/cotizaciones", json={"renta_bruta": 1500000, "afp": "Habitat"})
|
|
assert r.status_code == 200
|
|
assert r.json()["afp"] == "Habitat"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_afps_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.get("/api/topes/afps")
|
|
assert r.status_code == 200
|
|
assert len(r.json()) == 7
|
|
|
|
@pytest.mark.anyio
|
|
async def test_f29_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/f29/simular", json={"ventas_netas": 10000000, "compras_netas": 6000000})
|
|
assert r.status_code == 200
|
|
assert r.json()["resultado"] == "A PAGAR"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_validation_error(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/iva/desde-neto", json={"monto_neto": -100})
|
|
assert r.status_code == 422 # Pydantic validation error
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cotizaciones_afp_invalida_endpoint(self, transport):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
r = await client.post("/api/topes/cotizaciones", json={"renta_bruta": 1000000, "afp": "FakeAFP"})
|
|
assert r.status_code == 400
|