Rebase del lado emisor de T2026-08-046 sobre esta rama. La entrega anterior partia
de feature/crm-cumplimiento-pdf (16-jul), 40 commits atras, y por eso construyo un
expediente PARALELO -- crm.expedientes con su propio generador de folio y su propia
migracion -- que duplicaba el que ya existe aqui. Dos expedientes y dos secuencias
peleando por el mismo namespace EXP no se fusionan; se tira el nuestro.
La estructura del expediente es de esta rama y no se toca: crm.cases es el
expediente, su folio vive en `reference` y el consecutivo lo reserva
crm/common/folios.py con bloqueo de fila. Nuestro aporte es SOLO la conexion:
- crm.cases gana seis columnas efc_* (espejo de EFC, nunca el handle) y nada mas;
- crm.efc_sync_outbox y crm.efc_file_outbox, el outbox transaccional, con
expediente_ref -> crm.cases.id;
- core/efc_client.py y crm/expediente_gateway/ (outbox, reintentos, barridos),
clonados del gateway Anexo22 -> EFC que ya corre en produccion;
- las ocho variables EFC_* en config. EFC_API_URL vacia = carril apagado.
Verificado contra la base real: next_folio(...,'EXP',None,with_direction=False)
devuelve EXP2026-08-001, identico al formato que el contrato con EFC exige, y
storage_token da CRM-{company}-{folio} de 22 caracteres sobre los 25 de
pedimento_app.
Se corrige un error del docstring de storage_token: decia que cabian companies de
7 digitos y son 6 (4+7+1+14 = 26 > 25). Ahora valida y falla ruidosamente en vez de
entregar un token recortado, que apuntaria a la carpeta de otro expediente y
mezclaria documentos en silencio.
El revision id de la migracion tirada (e6f7a8b9c0d1) chocaba con crm_catalog_items
de esta rama: dos migraciones distintas con el mismo id habrian roto alembic al
fusionar. La nueva es c5d6e7f8a9b0, aditiva sobre d4e5f6a7b8c9.
PENDIENTE: falta el pegamento que invocaba el carril desde los flujos de la app
(alta del provisional al mintear el folio, subida de documento -> outbox, rutas en
el router y UI). Por eso test_efc_outbox, test_gateway_rutas y tres casos de
test_contrato_efc todavia no colectan. El carril no esta cableado al router, asi
que la app funciona igual: backend y frontend responden 200.
Ref: T2026-08-046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
249 lines
8.1 KiB
Python
249 lines
8.1 KiB
Python
"""Pruebas del cliente HTTP hacia EFC.
|
|
|
|
**Existen porque el carril de referencia no las tiene.** Verificado: en el gateway de Anexo22 no hay
|
|
ni una prueba de ``EfcClient._request``, así que su bucle de reintentos, su backoff, su corte en 4xx
|
|
y su header nunca se ejercitan. Ese hueco no se clona.
|
|
|
|
Todo va contra ``httpx.MockTransport`` por el parámetro ``transport``, que existe justamente para
|
|
esto: **ninguna de estas pruebas toca la red**.
|
|
"""
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from core.efc_client import EfcClient, EfcClientError
|
|
|
|
BASE = "https://efc.example.test"
|
|
KEY = "llave-de-prueba"
|
|
|
|
|
|
def _client(handler, **kwargs) -> EfcClient:
|
|
return EfcClient(
|
|
base_url=kwargs.pop("base_url", BASE),
|
|
api_key=kwargs.pop("api_key", KEY),
|
|
timeout_ms=kwargs.pop("timeout_ms", 500),
|
|
upload_timeout_ms=kwargs.pop("upload_timeout_ms", 500),
|
|
verify_ssl=False,
|
|
transport=httpx.MockTransport(handler),
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def test_reintenta_un_500_y_devuelve_el_exito():
|
|
intentos = {"n": 0}
|
|
|
|
def handler(request):
|
|
intentos["n"] += 1
|
|
if intentos["n"] == 1:
|
|
return httpx.Response(500, json={"detail": "boom"})
|
|
return httpx.Response(200, json={"id": "org-1"})
|
|
|
|
resp = _client(handler).resolve_organizacion("temex")
|
|
assert resp == {"id": "org-1"}
|
|
assert intentos["n"] == 2
|
|
|
|
|
|
def test_un_500_permanente_hace_exactamente_tres_intentos_y_es_retryable():
|
|
"""``retries = 2`` significa 3 intentos: el original + 2. Ni 2 ni 4."""
|
|
intentos = {"n": 0}
|
|
|
|
def handler(request):
|
|
intentos["n"] += 1
|
|
return httpx.Response(500, json={"detail": "boom"})
|
|
|
|
with pytest.raises(EfcClientError) as exc:
|
|
_client(handler).resolve_organizacion("temex")
|
|
|
|
assert intentos["n"] == 3
|
|
assert exc.value.retryable is True
|
|
|
|
|
|
def test_un_timeout_permanente_hace_tres_intentos_y_es_retryable():
|
|
intentos = {"n": 0}
|
|
|
|
def handler(request):
|
|
intentos["n"] += 1
|
|
raise httpx.ConnectTimeout("se acabó el tiempo", request=request)
|
|
|
|
with pytest.raises(EfcClientError) as exc:
|
|
_client(handler).resolve_organizacion("temex")
|
|
|
|
assert intentos["n"] == 3
|
|
assert exc.value.retryable is True
|
|
|
|
|
|
def test_un_400_no_se_reintenta_y_extrae_el_code_del_cuerpo():
|
|
"""El corte en 4xx es lo que evita machacar a EFC con una petición que nunca va a pasar.
|
|
|
|
Y el ``code`` extraído es lo que permite al worker ramificar **por campo**, nunca parseando el
|
|
texto del mensaje: un texto cambia con cualquier refactor del otro repo.
|
|
"""
|
|
intentos = {"n": 0}
|
|
|
|
def handler(request):
|
|
intentos["n"] += 1
|
|
return httpx.Response(
|
|
400,
|
|
json={"error": {"code": "espacio_insuficiente", "message": "La licencia no tiene espacio"}},
|
|
)
|
|
|
|
with pytest.raises(EfcClientError) as exc:
|
|
_client(handler).resolve_organizacion("temex")
|
|
|
|
assert intentos["n"] == 1
|
|
assert exc.value.status_code == 400
|
|
assert exc.value.code == "espacio_insuficiente"
|
|
assert exc.value.retryable is False
|
|
assert "La licencia no tiene espacio" in str(exc.value)
|
|
|
|
|
|
@pytest.mark.parametrize("status_code", [401, 403])
|
|
def test_401_y_403_no_se_reintentan(status_code):
|
|
"""Una key mal configurada no mejora insistiendo: reintentarla solo gasta cuota y llena logs."""
|
|
intentos = {"n": 0}
|
|
|
|
def handler(request):
|
|
intentos["n"] += 1
|
|
return httpx.Response(status_code)
|
|
|
|
with pytest.raises(EfcClientError) as exc:
|
|
_client(handler).resolve_organizacion("temex")
|
|
|
|
assert intentos["n"] == 1
|
|
assert exc.value.retryable is False
|
|
|
|
|
|
def test_un_cuerpo_de_error_que_no_es_json_no_revienta():
|
|
def handler(request):
|
|
return httpx.Response(400, text="<html>502 Bad Gateway</html>")
|
|
|
|
with pytest.raises(EfcClientError) as exc:
|
|
_client(handler).resolve_organizacion("temex")
|
|
|
|
assert exc.value.code is None
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
def test_toda_llamada_manda_el_header_x_api_key():
|
|
visto = {}
|
|
|
|
def handler(request):
|
|
visto["key"] = request.headers.get("X-Api-Key")
|
|
return httpx.Response(200, json={"id": "org-1"})
|
|
|
|
_client(handler).resolve_organizacion("temex")
|
|
assert visto["key"] == KEY
|
|
|
|
|
|
def test_sin_url_configurada_no_toca_la_red_y_el_error_no_es_retryable():
|
|
"""``is_configured is False`` es lo que hace que todo el carril sea best-effort.
|
|
|
|
Si esto tocara la red, cada operación del CRM con EFC apagado pagaría un timeout.
|
|
"""
|
|
llamado = {"n": 0}
|
|
|
|
def handler(request):
|
|
llamado["n"] += 1
|
|
return httpx.Response(200, json={})
|
|
|
|
client = _client(handler, base_url="")
|
|
assert client.is_configured is False
|
|
|
|
with pytest.raises(EfcClientError) as exc:
|
|
client.resolve_organizacion("temex")
|
|
|
|
assert llamado["n"] == 0
|
|
assert exc.value.retryable is False
|
|
|
|
|
|
def test_sin_api_key_tampoco_esta_configurado():
|
|
def handler(request):
|
|
return httpx.Response(200, json={})
|
|
|
|
assert _client(handler, api_key="").is_configured is False
|
|
|
|
|
|
def test_la_base_url_con_y_sin_barra_final_dan_la_misma_url():
|
|
urls = []
|
|
|
|
def handler(request):
|
|
urls.append(str(request.url))
|
|
return httpx.Response(200, json={"id": "org-1"})
|
|
|
|
_client(handler, base_url=BASE).resolve_organizacion("temex")
|
|
_client(handler, base_url=BASE + "/").resolve_organizacion("temex")
|
|
|
|
assert urls[0] == urls[1]
|
|
assert "//organization" not in urls[0]
|
|
|
|
|
|
def test_la_subida_va_multipart_y_lleva_el_crm_document_ref():
|
|
"""El ref es la tercera capa de idempotencia: EFC devuelve 200 con el que ya existía."""
|
|
visto = {}
|
|
|
|
def handler(request):
|
|
visto["content_type"] = request.headers.get("Content-Type", "")
|
|
visto["body"] = request.content
|
|
return httpx.Response(201, json={"id": "doc-1"})
|
|
|
|
resp = _client(handler).upload_documento(
|
|
"org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4 contenido", "application/pdf",
|
|
crm_document_ref="SHPDOC-1-4471",
|
|
)
|
|
|
|
assert resp == {"id": "doc-1"}
|
|
assert visto["content_type"].startswith("multipart/form-data")
|
|
assert b"SHPDOC-1-4471" in visto["body"]
|
|
assert b"%PDF-1.4 contenido" in visto["body"]
|
|
# Nada de base64: el archivo viaja crudo dentro del multipart.
|
|
assert b"base64" not in visto["body"]
|
|
|
|
|
|
def test_la_subida_usa_el_timeout_largo_y_los_metadatos_el_corto():
|
|
"""Los 8 s de los metadatos no alcanzan para un archivo de 25 MB, y un timeout de subida
|
|
demasiado largo haría que el CRM vea un 504 opaco de nginx sin saber si el documento entró."""
|
|
client = _client(handler=lambda r: httpx.Response(200, json={}), timeout_ms=8000, upload_timeout_ms=55000)
|
|
assert client.timeout_s == 8.0
|
|
assert client.upload_timeout_s == 55.0
|
|
assert client.upload_timeout_s > client.timeout_s
|
|
|
|
|
|
def test_ensure_then_upload_puede_ramificar_por_el_code_del_404():
|
|
"""El 404 del expediente tiene que llegar al worker con su ``code`` y su ``status_code``.
|
|
|
|
Es lo que dispara el ensure-then-upload; sin el code, el worker tendría que adivinar de qué es
|
|
el 404 y crearía provisionales por cualquier ausencia.
|
|
"""
|
|
def handler(request):
|
|
return httpx.Response(
|
|
404, json={"error": {"code": "expediente_no_encontrado", "message": "no está"}}
|
|
)
|
|
|
|
with pytest.raises(EfcClientError) as exc:
|
|
_client(handler).upload_documento("org-1", 1, 42, "MBL", "g.pdf", b"x")
|
|
|
|
assert exc.value.status_code == 404
|
|
assert exc.value.code == "expediente_no_encontrado"
|
|
assert exc.value.retryable is False
|
|
|
|
|
|
def test_la_descarga_devuelve_contenido_y_nombre_del_content_disposition():
|
|
def handler(request):
|
|
return httpx.Response(
|
|
200,
|
|
content=b"contenido binario",
|
|
headers={"Content-Disposition": 'attachment; filename="factura.pdf"'},
|
|
)
|
|
|
|
contenido, nombre = _client(handler).download_documento("org-1", "doc-1")
|
|
assert contenido == b"contenido binario"
|
|
assert nombre == "factura.pdf"
|
|
|
|
|
|
def test_la_descarga_sin_content_disposition_cae_al_id_del_documento():
|
|
def handler(request):
|
|
return httpx.Response(200, content=b"x")
|
|
|
|
_contenido, nombre = _client(handler).download_documento("org-1", "doc-9")
|
|
assert nombre == "doc-9"
|