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>
351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""Pruebas de la entrega de un archivo al expediente de EFC.
|
|
|
|
Cubren las tres cosas que hacen que este carril no pierda ni duplique archivos: el
|
|
**ensure-then-upload** cuando el provisional todavía no existe allá, el **corte directo**
|
|
(``delete_local``) que borra la copia local solo al confirmar, y la idempotencia por
|
|
``crm_document_ref`` cuando un timeout ambiguo hace reintentar.
|
|
"""
|
|
|
|
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,
|
|
MAX_ATTEMPTS,
|
|
SOURCE_CRM_DOCUMENTS,
|
|
STATUS_FAILED,
|
|
STATUS_PENDING,
|
|
STATUS_SENT,
|
|
EfcFileOutbox,
|
|
)
|
|
from api.v1.modules.crm.expedientes import service as expedientes_service
|
|
from api.v1.modules.crm.documents.models import Document
|
|
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
|
|
|
|
CONTENIDO = b"%PDF-1.4 guia madre"
|
|
S3_KEY = "tenants/1/companies/1/expedientes/1/guia.pdf"
|
|
|
|
|
|
class _ClienteFalso:
|
|
"""Doble del cliente de EFC. Registra qué se le pidió, para poder afirmarlo."""
|
|
|
|
is_configured = True
|
|
|
|
def __init__(self, *, upload_falla_con=None, falla_solo_la_primera=True):
|
|
self.uploads = []
|
|
self.ingests = []
|
|
self._upload_falla_con = upload_falla_con
|
|
self._falla_solo_la_primera = falla_solo_la_primera
|
|
|
|
def ingest_expediente(self, payload):
|
|
self.ingests.append(payload)
|
|
return {"status": "created", "efc": {"pedimento_id": "ped-1"}}
|
|
|
|
def upload_documento(self, org_id, company_id, expediente_id, tipo, filename, content,
|
|
content_type="application/octet-stream", crm_document_ref=None):
|
|
primera = not self.uploads
|
|
self.uploads.append({
|
|
"org_id": org_id, "company_id": company_id, "expediente_id": expediente_id,
|
|
"tipo": tipo, "filename": filename, "content": content,
|
|
"content_type": content_type, "crm_document_ref": crm_document_ref,
|
|
})
|
|
if self._upload_falla_con is not None and (primera or not self._falla_solo_la_primera):
|
|
raise self._upload_falla_con
|
|
return {"id": f"doc-{len(self.uploads)}"}
|
|
|
|
|
|
@pytest.fixture()
|
|
def entorno(db, monkeypatch):
|
|
"""EFC encendido, storage simulado y organización ya resuelta."""
|
|
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"))
|
|
monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1")
|
|
|
|
borrados = []
|
|
import core.storage_s3 as storage
|
|
|
|
monkeypatch.setattr(storage, "get_object_bytes", lambda key: CONTENIDO)
|
|
monkeypatch.setattr(storage, "delete_object_if_exists", lambda key: borrados.append(key))
|
|
|
|
solicitud = sr_service.create_service_request(
|
|
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
|
)
|
|
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
|
return {"db": db, "expediente": expediente, "borrados": borrados}
|
|
|
|
|
|
def _documento_local(db, expediente) -> Document:
|
|
doc = Document(
|
|
doc_type="MBL",
|
|
name="guia.pdf",
|
|
file_key=S3_KEY,
|
|
content_type="application/pdf",
|
|
size_bytes=len(CONTENIDO),
|
|
expediente_id=expediente.id,
|
|
efc_sync_state="PENDING",
|
|
tenant_id=TENANT_ID,
|
|
company_id=COMPANY_ID,
|
|
)
|
|
db.add(doc)
|
|
db.flush()
|
|
doc.efc_document_ref = f"CRMDOC-{COMPANY_ID}-{doc.id}"
|
|
db.commit()
|
|
return doc
|
|
|
|
|
|
def _fila(db, expediente, documento, **kwargs) -> EfcFileOutbox:
|
|
row = EfcFileOutbox(
|
|
kind=FILE_KIND_DOCUMENTO,
|
|
s3_key=S3_KEY,
|
|
file_name="guia.pdf",
|
|
content_type="application/pdf",
|
|
efc_tipo="MBL",
|
|
source_table=SOURCE_CRM_DOCUMENTS,
|
|
source_id=documento.id,
|
|
crm_document_ref=documento.efc_document_ref,
|
|
expediente_ref=expediente.id,
|
|
delete_local=kwargs.pop("delete_local", True),
|
|
status=kwargs.pop("status", STATUS_PENDING),
|
|
tenant_id=TENANT_ID,
|
|
company_id=COMPANY_ID,
|
|
**kwargs,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
# ── Camino feliz ─────────────────────────────────────────────────────────────
|
|
|
|
def test_entrega_feliz_marca_la_fila_y_el_documento(entorno):
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento)
|
|
cliente = _ClienteFalso()
|
|
|
|
gateway.deliver_file_row(db, row, cliente)
|
|
|
|
assert row.status == STATUS_SENT
|
|
assert row.efc_document_id == "doc-1"
|
|
assert row.sent_at is not None
|
|
assert documento.efc_sync_state == "SYNCED"
|
|
assert documento.efc_document_id == "doc-1"
|
|
assert documento.efc_synced_at is not None
|
|
|
|
|
|
def test_la_subida_lleva_el_crm_document_ref_y_el_contenido_leido_de_minio(entorno):
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento)
|
|
cliente = _ClienteFalso()
|
|
|
|
gateway.deliver_file_row(db, row, cliente)
|
|
|
|
assert len(cliente.uploads) == 1
|
|
subida = cliente.uploads[0]
|
|
assert subida["crm_document_ref"] == documento.efc_document_ref
|
|
assert subida["content"] == CONTENIDO
|
|
assert subida["tipo"] == "MBL"
|
|
assert subida["expediente_id"] == expediente.id
|
|
|
|
|
|
# ── Ensure-then-upload ───────────────────────────────────────────────────────
|
|
|
|
def test_un_404_de_expediente_crea_el_provisional_y_reintenta_una_vez(entorno):
|
|
"""La creación del provisional y la subida son colas distintas: la subida puede llegar antes.
|
|
|
|
Sin esto, el primer documento de cada expediente fallaría y esperaría al barrido.
|
|
"""
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento)
|
|
cliente = _ClienteFalso(
|
|
upload_falla_con=EfcClientError("no está", status_code=404, code="expediente_no_encontrado")
|
|
)
|
|
|
|
gateway.deliver_file_row(db, row, cliente)
|
|
|
|
assert len(cliente.ingests) == 1
|
|
assert cliente.ingests[0]["folio"] == expediente.folio
|
|
assert cliente.ingests[0]["storage_token"] == expediente.efc_storage_token
|
|
assert len(cliente.uploads) == 2 # el que falló + UNO de reintento
|
|
assert row.status == STATUS_SENT
|
|
|
|
|
|
def test_un_404_con_OTRO_code_no_dispara_el_ensure(entorno):
|
|
"""El ensure se dispara por el ``code``, no por el 404 a secas.
|
|
|
|
Si se disparara por cualquier 404, un documento no encontrado crearía provisionales espurios.
|
|
"""
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento)
|
|
cliente = _ClienteFalso(
|
|
upload_falla_con=EfcClientError("otro", status_code=404, code="documento_no_encontrado"),
|
|
falla_solo_la_primera=False,
|
|
)
|
|
|
|
gateway.deliver_file_row(db, row, cliente)
|
|
|
|
assert cliente.ingests == []
|
|
assert len(cliente.uploads) == 1
|
|
assert row.attempts == 1
|
|
|
|
|
|
def test_el_ensure_reintenta_UNA_vez_y_no_entra_en_bucle(entorno):
|
|
"""Si el reintento vuelve a dar 404, se registra el fallo. No se reintenta indefinidamente."""
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento)
|
|
cliente = _ClienteFalso(
|
|
upload_falla_con=EfcClientError("no está", status_code=404, code="expediente_no_encontrado"),
|
|
falla_solo_la_primera=False,
|
|
)
|
|
|
|
gateway.deliver_file_row(db, row, cliente)
|
|
|
|
assert len(cliente.ingests) == 1
|
|
assert len(cliente.uploads) == 2
|
|
assert row.attempts == 1
|
|
assert row.status == STATUS_FAILED # un 404 no es retryable
|
|
|
|
|
|
# ── Corte directo (delete_local) ─────────────────────────────────────────────
|
|
|
|
def test_al_confirmar_se_borra_la_copia_local(entorno):
|
|
"""«EFC es la fuente única» se cumple así: el objeto local se borra AL CONFIRMAR, no antes."""
|
|
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento, delete_local=True)
|
|
|
|
gateway.deliver_file_row(db, row, _ClienteFalso())
|
|
|
|
assert borrados == [S3_KEY]
|
|
# La key local se limpia: dejarla apuntaría a un objeto que ya no existe y la descarga se
|
|
# ramificaría por el camino equivocado.
|
|
assert documento.file_key is None
|
|
|
|
|
|
def test_con_delete_local_en_false_no_se_borra_nada(entorno):
|
|
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento, delete_local=False)
|
|
|
|
gateway.deliver_file_row(db, row, _ClienteFalso())
|
|
|
|
assert borrados == []
|
|
assert documento.file_key == S3_KEY
|
|
assert row.status == STATUS_SENT
|
|
|
|
|
|
def test_si_el_borrado_local_falla_la_entrega_sigue_siendo_valida(entorno, monkeypatch):
|
|
"""El archivo YA está en EFC. No poder borrar la copia local no invalida la entrega, y volver a
|
|
intentarla subiría el mismo documento otra vez."""
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
import core.storage_s3 as storage
|
|
|
|
def _revienta(key):
|
|
raise RuntimeError("MinIO no responde")
|
|
|
|
monkeypatch.setattr(storage, "delete_object_if_exists", _revienta)
|
|
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento, delete_local=True)
|
|
|
|
gateway.deliver_file_row(db, row, _ClienteFalso())
|
|
|
|
assert row.status == STATUS_SENT
|
|
assert documento.efc_sync_state == "SYNCED"
|
|
|
|
|
|
def test_el_borrado_local_ocurre_ANTES_de_marcar_enviada_pero_no_antes_de_subir(entorno):
|
|
"""Nunca se borra el original antes de confirmar la subida: si se borrara primero y la subida
|
|
fallara, el archivo se habría perdido."""
|
|
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento, delete_local=True)
|
|
cliente = _ClienteFalso(
|
|
upload_falla_con=EfcClientError("500", status_code=500, retryable=True),
|
|
falla_solo_la_primera=False,
|
|
)
|
|
|
|
gateway.deliver_file_row(db, row, cliente)
|
|
|
|
assert borrados == [] # la subida falló: el original sigue ahí
|
|
assert row.status == STATUS_PENDING
|
|
assert documento.file_key == S3_KEY
|
|
|
|
|
|
# ── Fallos ───────────────────────────────────────────────────────────────────
|
|
|
|
def test_un_fallo_de_efc_no_propaga_y_se_refleja_en_el_documento(entorno):
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento, attempts=MAX_ATTEMPTS - 1)
|
|
cliente = _ClienteFalso(
|
|
upload_falla_con=EfcClientError("EFC caído", status_code=503, retryable=True),
|
|
falla_solo_la_primera=False,
|
|
)
|
|
|
|
gateway.deliver_file_row(db, row, cliente) # no lanza
|
|
|
|
assert row.status == STATUS_FAILED
|
|
assert documento.efc_sync_state == "FAILED"
|
|
assert "EFC caído" in documento.efc_error_detail
|
|
assert documento.efc_attempts == MAX_ATTEMPTS
|
|
|
|
|
|
def test_una_fila_ya_enviada_no_vuelve_a_subir_el_archivo(entorno):
|
|
"""Segunda guarda de idempotencia. Un re-despacho tras un timeout ambiguo no duplica."""
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento, status=STATUS_SENT)
|
|
cliente = _ClienteFalso()
|
|
|
|
gateway.deliver_file_row(db, row, cliente)
|
|
|
|
assert cliente.uploads == []
|
|
|
|
|
|
def test_un_expediente_borrado_deja_la_fila_reintentable(entorno):
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento)
|
|
row.expediente_ref = 999999
|
|
db.commit()
|
|
|
|
gateway.deliver_file_row(db, row, _ClienteFalso())
|
|
|
|
assert row.status == STATUS_PENDING # retryable: el expediente puede reaparecer
|
|
assert row.attempts == 1
|
|
|
|
|
|
def test_un_reintento_tras_timeout_manda_el_mismo_ref_y_no_duplica(entorno):
|
|
"""El ``crm_document_ref`` es estable entre reintentos: EFC devuelve 200 con el que ya existía.
|
|
|
|
Es la tercera capa de idempotencia y la que cubre el timeout ambiguo —EFC commiteó y contestó
|
|
tarde—, donde el CRM no puede saber si el documento entró.
|
|
"""
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
documento = _documento_local(db, expediente)
|
|
row = _fila(db, expediente, documento)
|
|
|
|
primer_cliente = _ClienteFalso(
|
|
upload_falla_con=EfcClientError("timeout", retryable=True), falla_solo_la_primera=False
|
|
)
|
|
gateway.deliver_file_row(db, row, primer_cliente)
|
|
assert row.status == STATUS_PENDING
|
|
|
|
segundo_cliente = _ClienteFalso()
|
|
gateway.deliver_file_row(db, row, segundo_cliente)
|
|
|
|
assert primer_cliente.uploads[0]["crm_document_ref"] == segundo_cliente.uploads[0]["crm_document_ref"]
|
|
assert row.status == STATUS_SENT
|