"""Afirmación del lado CRM del contrato con EFC. ``tests/contracts/efc_crm_contract.json`` es el contrato; esto comprueba que **este** repo lo cumple. EFC debe afirmar su mitad contra una copia idéntica del mismo archivo. Por qué existe, y por qué no basta con las otras pruebas: CRM y EFC se despliegan por separado. Las pruebas de `test_efc_client.py` verifican que el cliente se comporta bien contra el EFC que el cliente **cree** que existe; si EFC renombra una ruta o una clave del payload, esas pruebas siguen verdes y el carril se rompe en producción. Lo único que atrapa esa deriva es un contrato escrito aparte y afirmado desde los dos lados. Nada aquí toca la red: las peticiones se capturan con ``httpx.MockTransport``. """ import json from pathlib import Path import httpx import pytest from core.efc_client import EfcClient CONTRATO = json.loads( (Path(__file__).parent / "contracts" / "efc_crm_contract.json").read_text(encoding="utf-8") ) CARRIL = CONTRATO["carril_efc"] API_CRM = CONTRATO["api_crm"] @pytest.fixture() def capturadas(): """Cliente contra EFC simulado que va guardando las peticiones que salen.""" peticiones: list[httpx.Request] = [] def handler(request): peticiones.append(request) if request.method == "GET" and request.url.path.endswith("/list/"): return httpx.Response(200, json=[]) return httpx.Response(200, json={"id": "org-1"}) cliente = EfcClient( base_url="https://efc.example.test", api_key="llave-de-prueba", timeout_ms=500, upload_timeout_ms=500, verify_ssl=False, transport=httpx.MockTransport(handler), ) return cliente, peticiones # ── Las rutas del carril ───────────────────────────────────────────────────── @pytest.mark.parametrize( "nombre,llamada", [ ("organizaciones_buscar", lambda c: c.buscar_organizaciones("temex")), ("organizaciones_resolver", lambda c: c.resolve_organizacion("temex", "TEMEX")), ("expediente_crear", lambda c: c.ingest_expediente({"folio": "EXP2026-08-001"})), ("expediente_completar", lambda c: c.completar_expediente("EXP2026-08-001", {})), ("expediente_detalle", lambda c: c.get_expediente("EXP2026-08-001", "org-1")), ("documentos_listar", lambda c: c.list_documentos("org-1", 42)), ], ) def test_cada_llamada_del_cliente_pega_en_la_ruta_del_contrato(capturadas, nombre, llamada): cliente, peticiones = capturadas esperado = CARRIL["endpoints"][nombre] llamada(cliente) peticion = peticiones[-1] assert peticion.method == esperado["metodo"] assert peticion.url.path == _resolver(esperado["path"]) def test_la_descarga_apunta_a_la_ruta_del_contrato(capturadas): """``download_url`` la arma a mano para el proxy async, así que se comprueba aparte.""" cliente, _ = capturadas esperado = CARRIL["endpoints"]["documento_descargar"] url = httpx.URL(cliente.download_url("doc-1")) assert url.path == _resolver(esperado["path"], doc_id="doc-1") def test_la_subida_de_documento_pega_en_su_ruta_y_va_en_multipart(capturadas): cliente, peticiones = capturadas esperado = CARRIL["endpoints"]["documento_subir"] cliente.upload_documento("org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4", "application/pdf") peticion = peticiones[-1] assert peticion.method == esperado["metodo"] assert peticion.url.path == esperado["path"] assert esperado["content_type"] in peticion.headers["content-type"] def test_el_reemplazo_de_documento_pega_en_su_ruta(capturadas): cliente, peticiones = capturadas esperado = CARRIL["endpoints"]["documento_reemplazar"] cliente.replace_documento("org-1", "doc-1", "guia.pdf", b"%PDF-1.4") peticion = peticiones[-1] assert peticion.method == esperado["metodo"] assert peticion.url.path == _resolver(esperado["path"], doc_id="doc-1") def test_el_cliente_NO_sabe_borrar_documentos_en_efc(): """El endpoint de borrado existe en EFC y el CRM **deliberadamente no lo llama**. ``record.Document`` no tiene vigencia ni purga, así que la política implícita del sistema es conservar, y un documento que mañana puede ser parte del expediente de un pedimento real es riesgo de retención fiscal. La baja en el CRM es lógica. Que el método no exista es lo que impide que alguien lo llame "porque estaba ahí": esta prueba se pone roja si aparece. """ metodos = {m for m in dir(EfcClient) if "elimin" in m or "delete" in m or "borrar" in m} assert metodos == set(), f"apareció una operación de borrado hacia EFC: {metodos}" assert "documento_eliminar" in CARRIL["endpoints"], "el endpoint existe del lado de EFC" def _resolver(plantilla: str, **valores) -> str: """Rellena los marcadores de la plantilla con los valores que usan las pruebas.""" defaults = {"folio": "EXP2026-08-001", "doc_id": "doc-1"} defaults.update(valores) return plantilla.format(**defaults) # ── Las formas de los payloads ─────────────────────────────────────────────── def test_el_alta_de_expediente_manda_exactamente_las_claves_del_contrato(capturadas): """Ni una de más ni una de menos. Una clave de menos y EFC responde ``payload_invalido``; una de más y el serializer de EFC la ignora en silencio, que es peor: el dato se cree enviado y no lo está. """ cliente, peticiones = capturadas esperadas = set(CARRIL["endpoints"]["expediente_crear"]["request_claves"]) cliente.ingest_expediente({clave: "x" for clave in esperadas}) assert set(json.loads(peticiones[-1].content)) == esperadas def test_la_subida_manda_los_campos_de_formulario_del_contrato(capturadas): cliente, peticiones = capturadas esperados = set(CARRIL["endpoints"]["documento_subir"]["form_claves"]) archivo = CARRIL["endpoints"]["documento_subir"]["archivo_campo"] cliente.upload_documento( "org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4", "application/pdf", crm_document_ref="CRMDOC-1-7", ) cuerpo = peticiones[-1].content.decode("latin-1") faltantes = [c for c in esperados if f'name="{c}"' not in cuerpo] assert faltantes == [], f"el multipart no lleva {faltantes}" assert f'name="{archivo}"' in cuerpo def test_toda_peticion_del_carril_lleva_el_header_de_autenticacion(capturadas): cliente, peticiones = capturadas header = CARRIL["header_autenticacion"] cliente.resolve_organizacion("temex") cliente.ingest_expediente({"folio": "EXP2026-08-001"}) cliente.upload_documento("org-1", 1, 42, "MBL", "g.pdf", b"x") assert peticiones, "no salió ninguna petición" for peticion in peticiones: assert peticion.headers.get(header) == "llave-de-prueba" # ── El catálogo de errores ─────────────────────────────────────────────────── def test_el_code_que_dispara_el_ensure_then_upload_esta_en_el_catalogo(): """Si EFC renombra este code, el CRM deja de recuperarse solo y los documentos se quedan pendientes para siempre **sin que nada falle a gritos**. Es el code más frágil del carril.""" critico = CARRIL["codigos_con_significado_para_el_crm"]["expediente_no_encontrado"] assert critico["http"] == 404 assert "expediente_no_encontrado" in CARRIL["codigos_error"]["404"] def test_el_gateway_ramifica_por_el_code_exacto_del_contrato(): """El código del CRM tiene ese ``code`` escrito literal. Que coincida con el contrato es lo que esta prueba fija; que el contrato coincida con EFC lo fija la suite del otro repo.""" from pathlib import Path as _Path fuente = ( _Path(__file__).parent.parent / "api" / "v1" / "modules" / "crm" / "expediente_gateway" / "service.py" ).read_text(encoding="utf-8") assert '"expediente_no_encontrado"' in fuente def test_el_cliente_extrae_el_code_del_formato_de_error_del_contrato(): forma = CARRIL["formato_error"]["forma"] assert set(forma) == {"error"} assert set(forma["error"]) == {"code", "message"} def handler(request): return httpx.Response(400, json={"error": {"code": "espacio_insuficiente", "message": "m"}}) from core.efc_client import EfcClientError cliente = EfcClient( base_url="https://efc.example.test", api_key="k", timeout_ms=200, verify_ssl=False, transport=httpx.MockTransport(handler), ) with pytest.raises(EfcClientError) as exc: cliente.resolve_organizacion("temex") assert exc.value.code == "espacio_insuficiente" assert exc.value.code in CARRIL["codigos_error"]["400"] assert exc.value.retryable is False # ── El API de usuario del CRM ──────────────────────────────────────────────── def test_las_rutas_registradas_del_crm_son_las_del_contrato(): """Cubre las dos direcciones: ninguna del contrato sin registrar, y ninguna registrada de más en estos dos routers. Un endpoint que aparece sin estar en el contrato es un endpoint que nadie del otro lado sabe que existe.""" from api.v1.modules.crm.expediente_gateway.routes import router as gateway_router from api.v1.modules.crm.expedientes.routes import router as expedientes_router registradas = set() for router in (expedientes_router, gateway_router): for ruta in router.routes: # ``ruta.path`` ya trae el prefijo del router aplicado: concatenarlo lo duplicaría. for metodo in ruta.methods: if metodo in ("HEAD", "OPTIONS"): continue registradas.add((metodo, ruta.path)) del_contrato = {(e["metodo"], e["path"]) for e in API_CRM["endpoints"]} assert del_contrato - registradas == set(), "el contrato declara rutas que no existen" assert registradas - del_contrato == set(), "hay rutas fuera del contrato" def test_la_respuesta_de_un_documento_nunca_expone_la_copia_local(): """La copia local se borra al confirmar la entrega a EFC: una referencia expuesta al frontend es una referencia que va a dejar de existir.""" from api.v1.modules.crm.expedientes.dto import ExpedienteDocumentResponse campos = set(ExpedienteDocumentResponse.model_fields) for prohibido in API_CRM["documento_response_prohibido"]: assert prohibido not in campos, f"la respuesta expone '{prohibido}'" faltantes = [c for c in API_CRM["documento_response_claves_minimas"] if c not in campos] assert faltantes == [], f"la respuesta no lleva {faltantes}" def test_los_estados_de_sincronizacion_son_los_del_contrato(): from api.v1.modules.crm.expediente_gateway.models import ( STATUS_FAILED, STATUS_PENDING, STATUS_SENT, ) # Los del outbox son en minúsculas; los que ve el frontend en el documento, en mayúsculas. assert {STATUS_PENDING, STATUS_SENT, STATUS_FAILED} == {"pending", "sent", "failed"} assert set(API_CRM["estados_sincronizacion"]) == {"PENDING", "SYNCED", "FAILED"} def test_las_metricas_del_outbox_tienen_las_claves_del_contrato(db): from api.v1.modules.crm.expediente_gateway import service as gateway from tests.conftest import COMPANY_ID, TENANT_ID metricas = gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID) assert set(API_CRM["metricas_outbox_claves"]).issubset(set(metricas)) def test_el_formato_del_folio_es_el_del_contrato(db): """El folio es lo que el usuario ve al guardar y lo que enlaza al expediente con EFC: su forma es contrato, no detalle.""" import re # El generador es el del CRM (crm/common/folios.py), no uno del carril: el expediente y su # consecutivo son del CRM y esta prueba solo afirma que la FORMA que produce es la que EFC # espera. Por eso se llama a la implementación real y no se reimplementa el formato aquí. from api.v1.modules.crm.common.folios import next_folio from tests.conftest import COMPANY_ID, TENANT_ID # with_direction=False: el expediente no lleva sufijo I/E, a diferencia de la solicitud. folio = next_folio(db, TENANT_ID, COMPANY_ID, "EXP", None, with_direction=False) patron = ( API_CRM["folio"]["formato"] .replace("{YYYY}", r"\d{4}") .replace("{MM}", r"\d{2}") .replace("{NNN}", r"\d{3,}") ) assert re.fullmatch(patron, folio), f"{folio} no cumple {API_CRM['folio']['formato']}"