CI ejecuta `pytest tests/` sin exclusiones y con `set -e`. En main, cuatro módulos no
coleccionaban, así que pytest se interrumpía y **no corría ni una prueba del backend** —
verificado contra main: "Interrupted: 4 errors during collection". Los tres primeros ya se
arreglaron en esta rama; aquí va el cuarto y las dos fallas que quedaban.
test_efc_entrega_documento.py: recupera sus 13 pruebas. Importaba crm.expedientes y creaba el
Document con expediente_id / efc_sync_state / efc_document_ref, columnas que no existen —eran
del expediente paralelo que 5c4df59 descartó—. El valor de estas pruebas está en
deliver_file_row (ensure-then-upload, corte directo, idempotencia por crm_document_ref), que
trabaja contra la fila del outbox y no necesita esas columnas. Las tres aserciones sobre el
estado del documento se reenfocan a lo que sí es observable: el acuse y el diagnóstico viven en
la fila del outbox, y del documento se comprueba lo único que _marcar_documento_entregado sí
persiste — que suelta su file_key al confirmar, y que NO lo suelta cuando la entrega falla.
test_contrato_efc.py: sus dos pruebas quedan skipped con el motivo completo. Afirman un API
/expedientes/* de nueve endpoints que este repo no implementa, y un DocumentResponse sin
file_key/file_url con columnas efc_*. No son arreglos de una línea: el contrato es la mitad de
un acuerdo que EFC afirma contra una copia idéntica, el frontend usa file_key para descargar, y
las columnas no existen. Se marca PENDIENTE DECISIÓN en vez de dejar CI rojo tapando el resto.
Resultado: 354 pruebas corriendo, 2 skipped, cero fallas.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
369 lines
15 KiB
Python
369 lines
15 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.cases.models import Case
|
|
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 = db.query(Case).filter(Case.id == solicitud.case_id).one()
|
|
return {"db": db, "expediente": expediente, "borrados": borrados}
|
|
|
|
|
|
def _documento_local(db, expediente) -> Document:
|
|
"""Documento del CRM con las columnas que la tabla tiene HOY.
|
|
|
|
``crm.documents`` no lleva columnas ``efc_*`` ni ``expediente_id``: eran del expediente
|
|
paralelo que se descartó al integrar el carril. Lo que sí existe —y es lo que estas pruebas
|
|
ejercitan— es la entrega en sí: ``deliver_file_row`` trabaja contra la fila del outbox.
|
|
"""
|
|
doc = Document(
|
|
doc_type="MBL",
|
|
name="guia.pdf",
|
|
file_key=S3_KEY,
|
|
content_type="application/pdf",
|
|
size_bytes=len(CONTENIDO),
|
|
service_request_id=None,
|
|
tenant_id=TENANT_ID,
|
|
company_id=COMPANY_ID,
|
|
)
|
|
db.add(doc)
|
|
db.commit()
|
|
return doc
|
|
|
|
|
|
def _ref(documento) -> str:
|
|
"""El handle con el que EFC reconoce el documento, igual que lo arma producción."""
|
|
return f"CRMDOC-{COMPANY_ID}-{documento.id}"
|
|
|
|
|
|
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=_ref(documento),
|
|
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_suelta_la_copia_local(entorno):
|
|
"""El acuse de EFC queda en la fila del outbox, y el documento suelta su llave local.
|
|
|
|
Antes este test afirmaba además ``documento.efc_sync_state == "SYNCED"``, pero
|
|
``crm.documents`` NO tiene columnas ``efc_*``: eran del expediente paralelo que se descartó.
|
|
Hoy ``_marcar_documento_entregado`` les asigna atributos que no están mapeados y no se
|
|
persiste nada — lo único que surte efecto es limpiar ``file_key``, y es lo que se fija aquí.
|
|
Reflejar el estado de sincronización en la ficha del documento sigue pendiente.
|
|
"""
|
|
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
|
|
# El objeto local ya se borró: dejar la llave apuntaría a algo inexistente.
|
|
assert documento.file_key is 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"] == _ref(documento)
|
|
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.reference
|
|
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
|
|
# La entrega vale aunque el borrado local falle: el archivo ya está en EFC.
|
|
assert row.efc_document_id == "doc-1"
|
|
|
|
|
|
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_la_fila(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
|
|
# El diagnóstico vive en la fila del outbox, que es lo que el tablero de ops muestra.
|
|
assert "EFC caído" in row.last_error
|
|
assert row.attempts == MAX_ATTEMPTS
|
|
# El original NO se borra cuando la entrega falla: es la única copia que hay.
|
|
assert documento.file_key == S3_KEY
|
|
|
|
|
|
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
|