Clon del gateway Anexo22 -> EFC que ya esta en produccion, con los nombres
cambiados. No es una reinterpretacion: la maquina de reintentos de tres capas,
el corte en 4xx y las cuatro guardas de idempotencia se conservan tal cual.
Sin esto, "EFC es la fuente unica" obligaria a llamar a EFC dentro del request
del usuario, y un EFC caido le haria perder su trabajo. Con outbox + Celery, la
subida responde 201 siempre y el sistema entrega cuando EFC vuelve, sin
duplicar: el crm_document_ref viaja con la subida y EFC devuelve 200 con el
documento que ya existia en vez de crear otro. Es lo que cubre el timeout
ambiguo -EFC commiteo y contesto tarde-, donde el CRM no puede saber si entro.
TRES DESVIACIONES DELIBERADAS DEL ORIGINAL, las tres con su razon en el codigo:
1. SAVEPOINT en vez de db.rollback() en el except del encolado. En Anexo22 el
outbox vive en OTRA base que el pedimento, asi que su rollback solo revertia
la sesion del outbox. El CRM es mono-base y el encolado corre DENTRO de la
transaccion del usuario: heredar ese rollback tumbaba la solicitud y el
expediente recien creados -exactamente lo contrario de best-effort, y en
silencio-. Lo destapo un test y asi se manifestaba:
"InvalidRequestError: Instance '<ServiceRequest>' is not persistent within
this Session". Con el savepoint el fallo deshace solo la fila del outbox.
2. source_table junto a source_id en la guarda _ya_entregado. El CRM tiene DOS
tablas de documentos con secuencias independientes: crm.documents.id = 5 y
ops.shipment_documents.id = 5 son documentos distintos. Con el id solo, haber
entregado el primero haria que el segundo se saltara para siempre sin un solo
error visible. Hay test que lo fija.
3. EFC_UPLOAD_TIMEOUT_MS aparte de EFC_API_TIMEOUT_MS. Los 8 s de los metadatos
no alcanzan para un archivo de 25 MB, y el timeout debe quedar POR DEBAJO del
proxy_read_timeout del nginx de EFC: si el CRM esperara mas, veria un 504
opaco sin saber si el documento entro.
El cliente HTTP llega con las pruebas que el carril de referencia NO tiene
-verificado: en Anexo22 no hay ni un test de EfcClient._request-, asi que alli
el bucle de reintentos, el backoff y el corte en 4xx nunca se ejercitan. Ese
hueco no se clona: 16 casos contra httpx.MockTransport, sin tocar la red.
Las migraciones se GENERAN y se dejan SIN aplicar.
BLOQUEADO: docker-compose.prod.yml no se toco. El ticket pide las 8 variables en
api, worker y beat, pero Orquestacion.md 13.14 y 4.5 lo prohiben expresamente
("ni tocarlo"), y el orquestador manda sobre el ticket. Queda como paso manual
en el reporte; sin el, worker y beat no ven EFC_API_URL y el carril queda
apagado en produccion, que es degradar limpio y no romper.
Verificacion: pytest tests/ EXIT=0, 151 passed 1 skipped (baseline 70 passed).
Cuatro roturas deliberadas y restauradas: quitando source_table de la guarda el
test de la ambiguedad se puso rojo; reintentando los 4xx los tres tests del
corte dieron "assert 3 == 1"; borrando el objeto local antes de subir cayeron
los tres del corte directo; y disparando el ensure por cualquier 404 se rompio
el test del code.
Ticket: T2026-08-046 (fase 6 de 7)
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
|