Files
CRM_AGENTES_CARGA/backend/tests/test_contrato_efc.py
marcos a1ed6e518d 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)
2026-08-10 07:45:40 -06:00

293 lines
12 KiB
Python

"""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']}"