From a1ed6e518d1a3a1f248ee61dadcb646956c00663 Mon Sep 17 00:00:00 2001 From: marcos Date: Mon, 10 Aug 2026 07:45:40 -0600 Subject: [PATCH] test(crm): contrato del carril con EFC, afirmado desde el lado del CRM Las pruebas del cliente verifican que el CRM 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 produccion: los dos repos se despliegan por separado y nada obliga a que sus mitades evolucionen juntas. Se agrega el contrato como dato -tests/contracts/efc_crm_contract.json- y su afirmacion del lado CRM: que cada operacion del cliente pegue en la ruta declarada, que el alta de expediente mande exactamente las claves acordadas -ni una de mas, que el serializer de EFC ignoraria en silencio, ni una de menos-, que la subida vaya en multipart con sus campos, que toda peticion lleve el header de autenticacion, que el code que dispara el ensure-then-upload siga siendo el mismo, y que las rutas del API de usuario registradas sean exactamente las del contrato en las dos direcciones. Fija tambien dos invariantes que ya costaron decisiones: que la respuesta de un documento nunca exponga la copia local -se borra al confirmar la entrega, asi que seria una referencia que va a dejar de existir- y que el cliente NO tenga metodo de borrado hacia EFC, para que nadie lo llame "porque estaba ahi". EFC debe afirmar su mitad contra una copia identica de este JSON cuando aterricen sus fases 1-4; mientras tanto, el lado del CRM ya no puede derivar en silencio. Refs: T2026-08-046 (verificacion 15.2) --- backend/tests/contracts/efc_crm_contract.json | 182 +++++++++++ backend/tests/test_contrato_efc.py | 292 ++++++++++++++++++ 2 files changed, 474 insertions(+) create mode 100644 backend/tests/contracts/efc_crm_contract.json create mode 100644 backend/tests/test_contrato_efc.py diff --git a/backend/tests/contracts/efc_crm_contract.json b/backend/tests/contracts/efc_crm_contract.json new file mode 100644 index 0000000..2eb7768 --- /dev/null +++ b/backend/tests/contracts/efc_crm_contract.json @@ -0,0 +1,182 @@ +{ + "_meta": { + "nombre": "Contrato del carril CRM Agentes de Carga <-> EFC", + "ticket": "T2026-08-046", + "version": 1, + "por_que_existe": "CRM y EFC son dos repos con despliegue independiente. Nada obliga a que sus dos mitades del carril evolucionen juntas, y una ruta renombrada o una clave de payload que cambia solo se descubre en produccion, cuando un documento deja de llegar al expediente. Este archivo es la unica forma de que un cambio unilateral salga rojo en CI.", + "como_se_usa": "Cada repo afirma su lado contra ESTE archivo. El CRM en backend/tests/test_contrato_efc.py. EFC debe afirmar el suyo cuando aterricen sus fases 1-4, contra una copia identica byte a byte de este JSON; si las dos copias divergen, el contrato deja de servir para lo unico que sirve.", + "regla": "Cambiar algo aqui es cambiar el contrato: obliga a un PR en los DOS repos." + }, + + "carril_efc": { + "_nota": "Endpoints maquina-a-maquina que EXPONE EFC y CONSUME el CRM. Header obligatorio en todas: X-Api-Key.", + "header_autenticacion": "X-Api-Key", + "endpoints": { + "organizaciones_buscar": { + "metodo": "GET", + "path": "/api/v1/organization/integrations/crm/organizaciones/" + }, + "organizaciones_resolver": { + "metodo": "POST", + "path": "/api/v1/organization/integrations/crm/organizaciones/resolver/", + "request_claves": ["tenant_slug", "tenant_name"], + "response_claves": ["id", "nombre", "rfc", "hub_tenant_slug", "is_active", "created"] + }, + "expediente_crear": { + "metodo": "POST", + "path": "/api/v1/customs/integrations/crm/expedientes/", + "request_claves": [ + "crm_tenant_slug", + "crm_company_id", + "crm_expediente_id", + "folio", + "storage_token" + ], + "status_exito": [200, 201], + "_nota_status": "201 si el provisional es nuevo, 200 si ya existia. Las dos son exito: el carril es idempotente." + }, + "expediente_completar": { + "metodo": "POST", + "path": "/api/v1/customs/integrations/crm/expedientes/{folio}/completar/" + }, + "expediente_detalle": { + "metodo": "GET", + "path": "/api/v1/customs/integrations/crm/expedientes/{folio}/" + }, + "documento_subir": { + "metodo": "POST", + "path": "/api/v1/record/integrations/crm/documentos/", + "content_type": "multipart/form-data", + "_nota_content_type": "Multipart, nunca base64: EFC declara parser_classes = [MultiPartParser].", + "form_claves": [ + "organizacion_id", + "crm_company_id", + "crm_expediente_id", + "tipo", + "crm_document_ref" + ], + "archivo_campo": "file", + "status_exito": [200, 201] + }, + "documentos_listar": { + "metodo": "GET", + "path": "/api/v1/record/integrations/crm/documentos/list/" + }, + "documento_descargar": { + "metodo": "GET", + "path": "/api/v1/record/integrations/crm/documentos/{doc_id}/descargar/" + }, + "documento_eliminar": { + "metodo": "DELETE", + "path": "/api/v1/record/integrations/crm/documentos/{doc_id}/eliminar/" + }, + "documento_reemplazar": { + "metodo": "PUT", + "path": "/api/v1/record/integrations/crm/documentos/{doc_id}/reemplazar/" + } + }, + + "formato_error": { + "forma": {"error": {"code": "", "message": ""}}, + "_nota": "El worker del CRM ramifica por error.code, NUNCA por el texto del mensaje: un mensaje cambia con cualquier refactor del otro repo y con el se caeria la politica de reintentos sin que nada se vea roto." + }, + + "codigos_error": { + "400": [ + "payload_invalido", + "pedimento_app_reservado", + "storage_token_invalido", + "efc_pedimento_app_incompleto", + "tipo_invalido", + "archivo_faltante", + "extension_no_permitida", + "archivo_demasiado_grande", + "espacio_insuficiente" + ], + "403": ["documento_no_eliminable"], + "404": ["expediente_no_encontrado", "documento_no_encontrado"], + "409": [ + "organizacion_no_utilizable", + "licencia_sin_espacio", + "expediente_ya_completado", + "pedimento_real_ya_existe", + "conflicto" + ], + "502": ["error_storage"] + }, + + "codigos_con_significado_para_el_crm": { + "expediente_no_encontrado": { + "http": 404, + "efecto": "dispara el ensure-then-upload: el CRM crea el provisional y reintenta la subida UNA vez", + "_nota": "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 mas fragil del carril." + } + }, + + "reintentos": { + "reintentables": ["5xx", "timeout", "error_de_red"], + "no_reintentables": ["4xx"], + "_nota": "Un 4xx reintentado tres veces es tres veces el mismo error mas latencia. El corte esta en 500, no en 400." + } + }, + + "api_crm": { + "_nota": "Endpoints de usuario que expone el CRM. Todos con company_id obligatorio en query y usuario autenticado.", + "query_obligatorio": "company_id", + "endpoints": [ + {"metodo": "GET", "path": "/expedientes"}, + {"metodo": "GET", "path": "/expedientes/{expediente_id}"}, + {"metodo": "POST", "path": "/expedientes/ensure"}, + {"metodo": "POST", "path": "/expedientes/{expediente_id}/completar"}, + {"metodo": "DELETE", "path": "/expedientes/{expediente_id}"}, + {"metodo": "GET", "path": "/expedientes/{expediente_id}/documentos"}, + {"metodo": "POST", "path": "/expedientes/{expediente_id}/documentos"}, + {"metodo": "GET", "path": "/expedientes/{expediente_id}/documentos/{document_id}/archivo"}, + {"metodo": "DELETE", "path": "/expedientes/{expediente_id}/documentos/{document_id}"}, + {"metodo": "GET", "path": "/expediente-gateway/outbox"}, + {"metodo": "POST", "path": "/expediente-gateway/outbox/{outbox_id}/retry"}, + {"metodo": "GET", "path": "/expediente-gateway/metrics"} + ], + + "documento_response_prohibido": ["file_key", "file_url"], + "_nota_prohibido": "La copia local es de transito y se borra al confirmar la entrega a EFC. Exponerla invitaria al frontend a guardarse una referencia que va a dejar de existir; para abrir el archivo esta el proxy de descarga.", + + "documento_response_claves_minimas": [ + "expediente_id", + "doc_type", + "name", + "content_type", + "size_bytes", + "efc_sync_state", + "efc_document_ref" + ], + + "estados_sincronizacion": ["PENDING", "SYNCED", "FAILED"], + + "descarga_documento": { + "tipo_respuesta": "streaming", + "traduccion_errores": { + "404_de_efc": 404, + "cualquier_otro_fallo_de_efc": 502, + "documento_de_otro_tenant": 404, + "documento_aun_no_entregado": 409 + }, + "_nota": "La asimetria es deliberada: un 404 de EFC significa que el documento realmente no esta; cualquier otro fallo es de la integracion, no del usuario. Y la traduccion tiene que ocurrir ANTES de que la respuesta empiece a salir, o llega tarde." + }, + + "reintento_outbox": { + "exito": {"status": "requeued", "id": ""}, + "fila_inexistente_o_de_otro_tenant": 404, + "_nota": "404 y no un 200 silencioso: es contrato con el frontend, que distingue 'no se pudo reencolar' de 'reencolado'." + }, + + "metricas_outbox_claves": ["pending", "sent", "failed"], + + "folio": { + "formato": "EXP{YYYY}-{MM}-{NNN}", + "alcance_consecutivo": ["tenant", "company", "mes"], + "reinicia": "cada mes", + "_nota": "Al pasar de 999 crece a 4 digitos en vez de desbordar." + } + } +} diff --git a/backend/tests/test_contrato_efc.py b/backend/tests/test_contrato_efc.py new file mode 100644 index 0000000..6346731 --- /dev/null +++ b/backend/tests/test_contrato_efc.py @@ -0,0 +1,292 @@ +"""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 + + from api.v1.modules.crm.expedientes.folio import next_folio + from tests.conftest import COMPANY_ID, TENANT_ID + + folio, _, _, _ = next_folio(db, TENANT_ID, COMPANY_ID) + + 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']}"