test_efc_outbox.py y test_gateway_rutas.py importaban crm.expedientes, el módulo del
expediente paralelo que 5c4df59 descartó al rebasar sobre crm.cases. Con el
ModuleNotFoundError, pytest ni siquiera las coleccionaba: 28 pruebas de la máquina de
reintentos y del tablero de ops llevaban sin correr, justo las del carril que se está
extendiendo.
Se traducen al modelo vigente preservando cada invariante:
- el expediente es crm.cases y se llega por la liga case_id de la solicitud, en vez de
find_by_service_request;
- el folio es Case.reference, no .folio;
- la idempotencia que se probaba vía ensure_expediente ahora se ejercita en
replicate_expediente_best_effort, que es donde vive la guarda _expediente_ya_encolado.
No se relaja ninguna aserción.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
404 lines
16 KiB
Python
404 lines
16 KiB
Python
"""Pruebas de la máquina de reintentos del outbox hacia EFC.
|
|
|
|
Lo que se fija aquí es la capa 2 de las tres del carril: el worker **nunca lanza**, registra el
|
|
fallo en la propia fila, y decide reintentar o rendirse por el campo ``retryable`` —nunca parseando
|
|
el texto del error—.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from api.v1.modules.crm.expediente_gateway import service as gateway
|
|
from api.v1.modules.crm.expediente_gateway.models import (
|
|
FILE_KIND_DOCUMENTO,
|
|
KIND_EXPEDIENTE,
|
|
MAX_ATTEMPTS,
|
|
SOURCE_CRM_DOCUMENTS,
|
|
SOURCE_OPS_SHIPMENT_DOCUMENTS,
|
|
STATUS_FAILED,
|
|
STATUS_PENDING,
|
|
STATUS_SENT,
|
|
EfcFileOutbox,
|
|
EfcSyncOutbox,
|
|
)
|
|
from api.v1.modules.crm.cases.models import Case
|
|
from api.v1.modules.crm.service_requests import service as sr_service
|
|
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
|
from core.efc_client import EfcClientError
|
|
from tests.conftest import COMPANY_ID, TENANT_ID
|
|
|
|
OTRO_TENANT = 99
|
|
|
|
|
|
@pytest.fixture()
|
|
def efc_encendido(monkeypatch):
|
|
"""Enciende la integración y evita que el encolado toque el broker o el tenant real."""
|
|
from core.config import settings
|
|
|
|
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
|
|
monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None)
|
|
monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None)
|
|
monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX"))
|
|
return settings
|
|
|
|
|
|
@pytest.fixture()
|
|
def efc_apagado(monkeypatch):
|
|
from core.config import settings
|
|
|
|
monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False)
|
|
return settings
|
|
|
|
|
|
def _solicitud(db):
|
|
return sr_service.create_service_request(
|
|
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
|
)
|
|
|
|
|
|
def _expediente(db):
|
|
"""El expediente que nace al crear una solicitud.
|
|
|
|
El expediente es ``crm.cases``: nace con la oportunidad o la solicitud y se hereda por
|
|
``case_id``. Se llega a él por esa liga, que es la que existe hoy — no hay una tabla de
|
|
expedientes con ``service_request_id`` propio.
|
|
"""
|
|
solicitud = _solicitud(db)
|
|
assert solicitud.case_id is not None, "crear una solicitud debe mintear su expediente"
|
|
return db.query(Case).filter(Case.id == solicitud.case_id).one()
|
|
|
|
|
|
def _fila_sync(db, expediente, **kwargs):
|
|
row = EfcSyncOutbox(
|
|
kind=kwargs.pop("kind", KIND_EXPEDIENTE),
|
|
payload=kwargs.pop("payload", {"folio": expediente.reference}),
|
|
expediente_ref=expediente.id,
|
|
status=kwargs.pop("status", STATUS_PENDING),
|
|
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
|
|
company_id=kwargs.pop("company_id", COMPANY_ID),
|
|
**kwargs,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
def _fila_archivo(db, expediente, **kwargs):
|
|
row = EfcFileOutbox(
|
|
kind=kwargs.pop("kind", FILE_KIND_DOCUMENTO),
|
|
s3_key=kwargs.pop("s3_key", "tenants/1/companies/1/expedientes/1/guia.pdf"),
|
|
file_name=kwargs.pop("file_name", "guia.pdf"),
|
|
content_type="application/pdf",
|
|
efc_tipo=kwargs.pop("efc_tipo", "MBL"),
|
|
source_table=kwargs.pop("source_table", SOURCE_CRM_DOCUMENTS),
|
|
source_id=kwargs.pop("source_id", 1),
|
|
crm_document_ref=kwargs.pop("crm_document_ref", "CRMDOC-1-1"),
|
|
expediente_ref=expediente.id,
|
|
status=kwargs.pop("status", STATUS_PENDING),
|
|
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
|
|
company_id=kwargs.pop("company_id", COMPANY_ID),
|
|
**kwargs,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
# ── _register_failure ────────────────────────────────────────────────────────
|
|
|
|
def test_un_fallo_retryable_suma_un_intento_y_deja_la_fila_pendiente(db, efc_encendido):
|
|
expediente = _expediente(db)
|
|
row = _fila_sync(db, expediente)
|
|
|
|
gateway._register_failure(db, row, EfcClientError("EFC no responde", retryable=True), True)
|
|
|
|
assert row.attempts == 1
|
|
assert row.status == STATUS_PENDING
|
|
assert "EFC no responde" in row.last_error
|
|
|
|
|
|
def test_un_fallo_no_retryable_marca_failed_de_inmediato(db, efc_encendido):
|
|
"""Un 400 no mejora insistiendo: reintentarlo ocho veces solo retrasa que alguien lo vea."""
|
|
expediente = _expediente(db)
|
|
row = _fila_sync(db, expediente)
|
|
|
|
gateway._register_failure(db, row, EfcClientError("tipo inválido", retryable=False), False)
|
|
|
|
assert row.attempts == 1
|
|
assert row.status == STATUS_FAILED
|
|
|
|
|
|
def test_al_llegar_a_max_attempts_la_fila_queda_failed(db, efc_encendido):
|
|
expediente = _expediente(db)
|
|
row = _fila_sync(db, expediente, attempts=MAX_ATTEMPTS - 1)
|
|
|
|
gateway._register_failure(db, row, EfcClientError("otra vez", retryable=True), True)
|
|
|
|
assert row.attempts == MAX_ATTEMPTS
|
|
assert row.status == STATUS_FAILED
|
|
|
|
|
|
def test_el_ultimo_error_se_trunca_a_2000_caracteres(db, efc_encendido):
|
|
"""``last_error`` es Text, pero un traceback de 5000 caracteres por fila llena la tabla de ruido."""
|
|
expediente = _expediente(db)
|
|
row = _fila_sync(db, expediente)
|
|
|
|
gateway._register_failure(db, row, Exception("x" * 5000), True)
|
|
|
|
assert len(row.last_error) == 2000
|
|
|
|
|
|
# ── deliver_row: no propaga ──────────────────────────────────────────────────
|
|
|
|
class _ClienteQueRevienta:
|
|
is_configured = True
|
|
|
|
def __init__(self, exc):
|
|
self._exc = exc
|
|
self.llamadas = 0
|
|
|
|
def ingest_expediente(self, payload):
|
|
self.llamadas += 1
|
|
raise self._exc
|
|
|
|
def completar_expediente(self, folio, payload):
|
|
self.llamadas += 1
|
|
raise self._exc
|
|
|
|
|
|
def test_deliver_row_no_propaga_la_excepcion_de_efc(db, efc_encendido, monkeypatch):
|
|
"""Si esto propagara, un EFC caído mataría al worker y se perdería la cola entera."""
|
|
expediente = _expediente(db)
|
|
row = _fila_sync(db, expediente)
|
|
monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1")
|
|
|
|
gateway.deliver_row(db, row, _ClienteQueRevienta(EfcClientError("caído", retryable=True)))
|
|
|
|
assert row.status == STATUS_PENDING
|
|
assert row.attempts == 1
|
|
|
|
|
|
def test_deliver_row_tampoco_propaga_una_excepcion_inesperada(db, efc_encendido, monkeypatch):
|
|
expediente = _expediente(db)
|
|
row = _fila_sync(db, expediente)
|
|
monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1")
|
|
|
|
gateway.deliver_row(db, row, _ClienteQueRevienta(RuntimeError("algo raro")))
|
|
|
|
assert row.attempts == 1
|
|
# Una excepción inesperada se trata como transitoria: no se sabe que sea permanente.
|
|
assert row.status == STATUS_PENDING
|
|
|
|
|
|
def test_una_fila_ya_enviada_no_vuelve_a_llamar_a_efc(db, efc_encendido):
|
|
"""Segunda guarda de idempotencia. Sin ella, un re-despacho duplicaría el expediente en EFC."""
|
|
expediente = _expediente(db)
|
|
row = _fila_sync(db, expediente, status=STATUS_SENT)
|
|
cliente = _ClienteQueRevienta(EfcClientError("no debería llamarse"))
|
|
|
|
gateway.deliver_row(db, row, cliente)
|
|
|
|
assert cliente.llamadas == 0
|
|
assert row.status == STATUS_SENT
|
|
|
|
|
|
# ── _ya_entregado: la ambigüedad de las dos secuencias ───────────────────────
|
|
|
|
def test_no_se_encola_dos_veces_el_mismo_archivo(db, efc_encendido):
|
|
expediente = _expediente(db)
|
|
_fila_archivo(db, expediente, source_id=7, status=STATUS_SENT)
|
|
|
|
assert gateway._ya_entregado(db, SOURCE_CRM_DOCUMENTS, 7, FILE_KIND_DOCUMENTO) is True
|
|
|
|
row = gateway.enqueue_file_best_effort(
|
|
db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None,
|
|
efc_tipo="MBL", source_table=SOURCE_CRM_DOCUMENTS, source_id=7,
|
|
crm_document_ref="CRMDOC-1-7", expediente_ref=expediente.id,
|
|
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
|
)
|
|
assert row is None
|
|
|
|
|
|
def test_el_mismo_id_en_otra_tabla_de_origen_SI_se_encola(db, efc_encendido):
|
|
"""La prueba de la ambigüedad de las dos secuencias.
|
|
|
|
``crm.documents.id = 7`` y ``ops.shipment_documents.id = 7`` son documentos DISTINTOS. Sin
|
|
``source_table`` en la guarda, entregar el primero haría que el segundo se saltara para
|
|
siempre — y nadie vería un error.
|
|
"""
|
|
expediente = _expediente(db)
|
|
_fila_archivo(db, expediente, source_id=7, source_table=SOURCE_CRM_DOCUMENTS, status=STATUS_SENT)
|
|
|
|
assert gateway._ya_entregado(db, SOURCE_OPS_SHIPMENT_DOCUMENTS, 7, FILE_KIND_DOCUMENTO) is False
|
|
|
|
row = gateway.enqueue_file_best_effort(
|
|
db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None,
|
|
efc_tipo="MBL", source_table=SOURCE_OPS_SHIPMENT_DOCUMENTS, source_id=7,
|
|
crm_document_ref="SHPDOC-1-7", expediente_ref=expediente.id,
|
|
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
|
)
|
|
assert row is not None
|
|
assert row.source_table == SOURCE_OPS_SHIPMENT_DOCUMENTS
|
|
|
|
|
|
# ── retry ────────────────────────────────────────────────────────────────────
|
|
|
|
def test_retry_resetea_la_fila_y_la_re_despacha(db, efc_encendido, monkeypatch):
|
|
despachos = []
|
|
monkeypatch.setattr(
|
|
gateway, "_dispatch_file_delivery", lambda oid, t, c: despachos.append((oid, t, c))
|
|
)
|
|
expediente = _expediente(db)
|
|
row = _fila_archivo(db, expediente, status=STATUS_FAILED, attempts=MAX_ATTEMPTS,
|
|
last_error="se acabaron los intentos")
|
|
|
|
ok = gateway.retry_outbox_row(db, row.id, TENANT_ID, COMPANY_ID, "file")
|
|
|
|
assert ok is True
|
|
assert row.status == STATUS_PENDING
|
|
assert row.attempts == 0
|
|
assert row.last_error is None
|
|
assert despachos == [(row.id, TENANT_ID, COMPANY_ID)]
|
|
|
|
|
|
def test_retry_de_otro_tenant_devuelve_false(db, efc_encendido):
|
|
"""Devuelve False y el llamador lo traduce a 404: un 200 le haría creer al frontend que se
|
|
reencoló algo que ni siquiera es suyo."""
|
|
expediente = _expediente(db)
|
|
row = _fila_archivo(db, expediente, status=STATUS_FAILED)
|
|
|
|
assert gateway.retry_outbox_row(db, row.id, OTRO_TENANT, COMPANY_ID, "file") is False
|
|
assert row.status == STATUS_FAILED # intacta
|
|
|
|
|
|
def test_retry_de_una_fila_inexistente_devuelve_false(db, efc_encendido):
|
|
assert gateway.retry_outbox_row(db, 999999, TENANT_ID, COMPANY_ID, "file") is False
|
|
|
|
|
|
# ── métricas y listado ───────────────────────────────────────────────────────
|
|
|
|
def test_las_metricas_cuentan_por_status_sumando_las_dos_tablas(db, efc_encendido):
|
|
expediente = _expediente(db)
|
|
_fila_sync(db, expediente, status=STATUS_SENT)
|
|
_fila_archivo(db, expediente, source_id=1, status=STATUS_PENDING)
|
|
_fila_archivo(db, expediente, source_id=2, status=STATUS_FAILED)
|
|
_fila_archivo(db, expediente, source_id=3, status=STATUS_FAILED)
|
|
|
|
metricas = gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID)
|
|
|
|
# El alta del expediente encoló su propia fila pendiente al crearse la solicitud.
|
|
assert metricas["failed"] == 2
|
|
assert metricas["sent"] == 1
|
|
assert metricas["pending"] >= 1
|
|
|
|
|
|
def test_las_metricas_no_ven_las_filas_de_otro_tenant(db, efc_encendido):
|
|
expediente = _expediente(db)
|
|
_fila_archivo(db, expediente, source_id=5, status=STATUS_FAILED, tenant_id=OTRO_TENANT)
|
|
|
|
assert gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID)["failed"] == 0
|
|
|
|
|
|
def test_el_listado_marca_de_que_tabla_viene_cada_fila(db, efc_encendido):
|
|
expediente = _expediente(db)
|
|
_fila_archivo(db, expediente, source_id=1)
|
|
|
|
filas = gateway.list_outbox(db, TENANT_ID, COMPANY_ID)
|
|
tablas = {f["tabla"] for f in filas}
|
|
assert tablas == {"sync", "file"}
|
|
|
|
solo_archivos = gateway.list_outbox(db, TENANT_ID, COMPANY_ID, tipo="file")
|
|
assert {f["tabla"] for f in solo_archivos} == {"file"}
|
|
|
|
|
|
# ── huecos ───────────────────────────────────────────────────────────────────
|
|
|
|
def test_find_expediente_gaps_encuentra_los_que_no_tienen_fila(db, efc_apagado):
|
|
"""Con EFC apagado no se encola nada, así que todos los expedientes son huecos.
|
|
|
|
Es exactamente el caso que el barrido cubre: lo creado ANTES de activar la integración.
|
|
"""
|
|
_expediente(db)
|
|
_expediente(db)
|
|
|
|
huecos = gateway.find_expediente_gaps(db)
|
|
assert len(huecos) == 2
|
|
|
|
|
|
def test_un_expediente_con_fila_failed_NO_es_un_hueco(db, efc_encendido):
|
|
"""Un ``failed`` existe como fila: es visible en el tablero y reintentable a mano.
|
|
|
|
Tratarlo como hueco lo re-encolaría en cada barrido y escondería el fallo.
|
|
"""
|
|
expediente = _expediente(db)
|
|
fila = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.expediente_ref == expediente.id).first()
|
|
assert fila is not None
|
|
fila.status = STATUS_FAILED
|
|
db.commit()
|
|
|
|
assert gateway.find_expediente_gaps(db) == []
|
|
|
|
|
|
# ── best-effort ──────────────────────────────────────────────────────────────
|
|
|
|
def test_con_efc_apagado_no_se_encola_nada(db, efc_apagado):
|
|
"""``EFC_API_URL`` vacía apaga el carril entero. El CRM sigue funcionando igual."""
|
|
expediente = _expediente(db)
|
|
|
|
assert db.query(EfcSyncOutbox).count() == 0
|
|
|
|
fila = gateway.enqueue_file_best_effort(
|
|
db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None,
|
|
efc_tipo="MBL", source_table=SOURCE_CRM_DOCUMENTS, source_id=1,
|
|
crm_document_ref="CRMDOC-1-1", expediente_ref=expediente.id,
|
|
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
|
)
|
|
assert fila is None
|
|
assert db.query(EfcFileOutbox).count() == 0
|
|
|
|
|
|
def test_con_efc_encendido_crear_una_solicitud_encola_su_expediente(db, efc_encendido):
|
|
expediente = _expediente(db)
|
|
|
|
filas = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.expediente_ref == expediente.id).all()
|
|
assert len(filas) == 1
|
|
assert filas[0].kind == KIND_EXPEDIENTE
|
|
assert filas[0].status == STATUS_PENDING
|
|
assert filas[0].payload["folio"] == expediente.reference
|
|
assert filas[0].payload["storage_token"] == expediente.efc_storage_token
|
|
|
|
|
|
def test_si_el_encolado_revienta_la_operacion_local_no_se_rompe(db, efc_encendido, monkeypatch):
|
|
"""La integración NUNCA puede tumbar el alta de una solicitud del usuario."""
|
|
def _revienta(*a, **k):
|
|
raise RuntimeError("la tabla del outbox no existe")
|
|
|
|
monkeypatch.setattr(gateway, "_expediente_ya_encolado", _revienta)
|
|
|
|
solicitud = _solicitud(db)
|
|
|
|
assert solicitud.id is not None
|
|
# La solicitud sigue en pie CON su expediente: el fallo del carril no deshace ninguno de los dos.
|
|
assert solicitud.case_id is not None
|
|
assert db.query(Case).filter(Case.id == solicitud.case_id).one_or_none() is not None
|
|
|
|
|
|
def test_no_se_encola_dos_veces_el_mismo_expediente(db, efc_encendido):
|
|
"""Primera guarda de idempotencia: replicar el mismo expediente no genera una segunda fila.
|
|
|
|
Se ejercita ``replicate_expediente_best_effort`` directamente, que es el punto de entrada del
|
|
carril y donde vive la guarda ``_expediente_ya_encolado``. Antes esto entraba por un
|
|
``ensure_expediente`` del módulo de expedientes, que ya no existe; el invariante es el mismo.
|
|
"""
|
|
expediente = _expediente(db)
|
|
|
|
# La primera réplica ya la hizo create_case al mintear el expediente.
|
|
gateway.replicate_expediente_best_effort(db, expediente)
|
|
gateway.replicate_expediente_best_effort(db, expediente)
|
|
db.commit()
|
|
|
|
filas = db.query(EfcSyncOutbox).filter(
|
|
EfcSyncOutbox.expediente_ref == expediente.id,
|
|
EfcSyncOutbox.kind == KIND_EXPEDIENTE,
|
|
).all()
|
|
assert len(filas) == 1
|