Files
CRM_AGENTES_CARGA/backend/tests/test_efc_client.py
marcos 5c4df590d4 feat(crm): carril hacia EFC montado sobre el expediente existente (crm.cases)
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>
2026-08-10 10:35:44 -06:00

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"