Clon del gateway Anexo22 -> EFC que ya esta en produccion, con los nombres
cambiados. No es una reinterpretacion: la maquina de reintentos de tres capas,
el corte en 4xx y las cuatro guardas de idempotencia se conservan tal cual.
Sin esto, "EFC es la fuente unica" obligaria a llamar a EFC dentro del request
del usuario, y un EFC caido le haria perder su trabajo. Con outbox + Celery, la
subida responde 201 siempre y el sistema entrega cuando EFC vuelve, sin
duplicar: el crm_document_ref viaja con la subida y EFC devuelve 200 con el
documento que ya existia en vez de crear otro. Es lo que cubre el timeout
ambiguo -EFC commiteo y contesto tarde-, donde el CRM no puede saber si entro.
TRES DESVIACIONES DELIBERADAS DEL ORIGINAL, las tres con su razon en el codigo:
1. SAVEPOINT en vez de db.rollback() en el except del encolado. En Anexo22 el
outbox vive en OTRA base que el pedimento, asi que su rollback solo revertia
la sesion del outbox. El CRM es mono-base y el encolado corre DENTRO de la
transaccion del usuario: heredar ese rollback tumbaba la solicitud y el
expediente recien creados -exactamente lo contrario de best-effort, y en
silencio-. Lo destapo un test y asi se manifestaba:
"InvalidRequestError: Instance '<ServiceRequest>' is not persistent within
this Session". Con el savepoint el fallo deshace solo la fila del outbox.
2. source_table junto a source_id en la guarda _ya_entregado. El CRM tiene DOS
tablas de documentos con secuencias independientes: crm.documents.id = 5 y
ops.shipment_documents.id = 5 son documentos distintos. Con el id solo, haber
entregado el primero haria que el segundo se saltara para siempre sin un solo
error visible. Hay test que lo fija.
3. EFC_UPLOAD_TIMEOUT_MS aparte de EFC_API_TIMEOUT_MS. Los 8 s de los metadatos
no alcanzan para un archivo de 25 MB, y el timeout debe quedar POR DEBAJO del
proxy_read_timeout del nginx de EFC: si el CRM esperara mas, veria un 504
opaco sin saber si el documento entro.
El cliente HTTP llega con las pruebas que el carril de referencia NO tiene
-verificado: en Anexo22 no hay ni un test de EfcClient._request-, asi que alli
el bucle de reintentos, el backoff y el corte en 4xx nunca se ejercitan. Ese
hueco no se clona: 16 casos contra httpx.MockTransport, sin tocar la red.
Las migraciones se GENERAN y se dejan SIN aplicar.
BLOQUEADO: docker-compose.prod.yml no se toco. El ticket pide las 8 variables en
api, worker y beat, pero Orquestacion.md 13.14 y 4.5 lo prohiben expresamente
("ni tocarlo"), y el orquestador manda sobre el ticket. Queda como paso manual
en el reporte; sin el, worker y beat no ven EFC_API_URL y el carril queda
apagado en produccion, que es degradar limpio y no romper.
Verificacion: pytest tests/ EXIT=0, 151 passed 1 skipped (baseline 70 passed).
Cuatro roturas deliberadas y restauradas: quitando source_table de la guarda el
test de la ambiguedad se puso rojo; reintentando los 4xx los tres tests del
corte dieron "assert 3 == 1"; borrando el objeto local antes de subir cayeron
los tres del corte directo; y disparando el ensure por cualquier 404 se rompio
el test del code.
Ticket: T2026-08-046 (fase 6 de 7)
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"
|