"""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="502 Bad Gateway") 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"