Files
CRM_AGENTES_CARGA/backend/tests/test_contrato_efc.py
marcos bc50a7d099 feat(crm): el expediente pide su pedimento provisional a EFC al nacer el folio
Primera mitad del pegamento del carril (fase 7). El reflejo en EFC se pide en
create_case, en el instante en que se mintea el folio, porque el folio es la llave
con la que las dos mitades se reconocen: EFC no recibe ids del CRM como handle.

  - crm.cases nace con efc_storage_token = CRM-{company}-{folio}, fijado al nacer y
    nunca reescrito: es la carpeta de MinIO del lado de EFC, y que sea inmutable es
    lo que permite completar el provisional con la data aduanera real sin mover un
    solo archivo.
  - replicate_expediente_best_effort corre en la MISMA transaccion que el
    expediente. Con EFC_API_URL vacia es no-op; si el encolado o el despacho fallan
    no se propaga el error y el barrido del beat recoge lo pendiente. Un sistema de
    terceros caido no puede romper un alta.
  - El import del carril es diferido para no acoplar el arranque del modulo del
    expediente, que es de otra rama, a la integracion.
  - Se registra el tablero de ops del carril (outbox, metricas, reintento manual)
    en el router del CRM.

test_el_formato_del_folio_es_el_del_contrato se re-apunta a crm/common/folios.py y
queda VERDE: afirma contra la implementacion real que el folio del CRM tiene la forma
que EFC espera, que era el riesgo de haber rebasado sobre otro expediente.

Verificado en la base: create_case produce EXP2026-08-002 con storage_token
CRM-2-EXP2026-08-002 y link_state PENDING, 0 filas encoladas por carril apagado, y la
transaccion reversada NO deja hueco en el contador -- el with_for_update de
crm/common/folios.py revierte limpio.

PENDIENTE de la fase 7: documentos (subida de un paso, proxy de descarga, listado y
desvinculacion) y los 8 archivos del frontend. Por eso siguen rojas
test_efc_outbox, test_gateway_rutas, test_efc_entrega_documento, test_uploads_alcance
y dos de test_contrato_efc.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:04:12 -06:00

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