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>
297 lines
12 KiB
Python
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']}"
|