Rebase del lado emisor de T2026-08-046 sobre esta rama. La entrega anterior partia
de feature/crm-cumplimiento-pdf (16-jul), 40 commits atras, y por eso construyo un
expediente PARALELO -- crm.expedientes con su propio generador de folio y su propia
migracion -- que duplicaba el que ya existe aqui. Dos expedientes y dos secuencias
peleando por el mismo namespace EXP no se fusionan; se tira el nuestro.
La estructura del expediente es de esta rama y no se toca: crm.cases es el
expediente, su folio vive en `reference` y el consecutivo lo reserva
crm/common/folios.py con bloqueo de fila. Nuestro aporte es SOLO la conexion:
- crm.cases gana seis columnas efc_* (espejo de EFC, nunca el handle) y nada mas;
- crm.efc_sync_outbox y crm.efc_file_outbox, el outbox transaccional, con
expediente_ref -> crm.cases.id;
- core/efc_client.py y crm/expediente_gateway/ (outbox, reintentos, barridos),
clonados del gateway Anexo22 -> EFC que ya corre en produccion;
- las ocho variables EFC_* en config. EFC_API_URL vacia = carril apagado.
Verificado contra la base real: next_folio(...,'EXP',None,with_direction=False)
devuelve EXP2026-08-001, identico al formato que el contrato con EFC exige, y
storage_token da CRM-{company}-{folio} de 22 caracteres sobre los 25 de
pedimento_app.
Se corrige un error del docstring de storage_token: decia que cabian companies de
7 digitos y son 6 (4+7+1+14 = 26 > 25). Ahora valida y falla ruidosamente en vez de
entregar un token recortado, que apuntaria a la carpeta de otro expediente y
mezclaria documentos en silencio.
El revision id de la migracion tirada (e6f7a8b9c0d1) chocaba con crm_catalog_items
de esta rama: dos migraciones distintas con el mismo id habrian roto alembic al
fusionar. La nueva es c5d6e7f8a9b0, aditiva sobre d4e5f6a7b8c9.
PENDIENTE: falta el pegamento que invocaba el carril desde los flujos de la app
(alta del provisional al mintear el folio, subida de documento -> outbox, rutas en
el router y UI). Por eso test_efc_outbox, test_gateway_rutas y tres casos de
test_contrato_efc todavia no colectan. El carril no esta cableado al router, asi
que la app funciona igual: backend y frontend responden 200.
Ref: T2026-08-046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
387 lines
15 KiB
Python
387 lines
15 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.expedientes import service as expedientes_service
|
|
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 _expediente(db):
|
|
solicitud = sr_service.create_service_request(
|
|
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
|
)
|
|
return expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
|
|
|
|
|
def _fila_sync(db, expediente, **kwargs):
|
|
row = EfcSyncOutbox(
|
|
kind=kwargs.pop("kind", KIND_EXPEDIENTE),
|
|
payload=kwargs.pop("payload", {"folio": expediente.folio}),
|
|
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.folio
|
|
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 = sr_service.create_service_request(
|
|
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
|
)
|
|
|
|
assert solicitud.id is not None
|
|
assert expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) is not None
|
|
|
|
|
|
def test_no_se_encola_dos_veces_el_mismo_expediente(db, efc_encendido):
|
|
"""Primera guarda: ``ensure`` es idempotente y no debe generar una segunda réplica."""
|
|
expediente = _expediente(db)
|
|
solicitud_id = expediente.service_request_id
|
|
|
|
expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1")
|
|
expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1")
|
|
|
|
filas = db.query(EfcSyncOutbox).filter(
|
|
EfcSyncOutbox.expediente_ref == expediente.id,
|
|
EfcSyncOutbox.kind == KIND_EXPEDIENTE,
|
|
).all()
|
|
assert len(filas) == 1
|