Files
CRM_AGENTES_CARGA/backend/tests/test_expediente_upload.py
marcos d671558a59 fix(crm): el error de EFC al descargar un documento llega al usuario, y con su codigo
El proxy de descarga del expediente comprobaba el status de EFC DENTRO del generador
de streaming. Starlette manda la linea de estado en cuanto construye la
StreamingResponse -antes de pedir el primer trozo-, asi que cuando el generador
descubria el 404 la respuesta ya habia salido con 200: el usuario recibia un archivo
vacio o cortado en vez del error. La traduccion asimetrica que fija el ticket
-404 pasa como 404, todo lo demas como 502- estaba escrita pero nunca llegaba a
ocurrir.

Ahora la conexion se abre y su status se valida ANTES de construir la respuesta, con
client.send(peticion, stream=True). El streaming se conserva intacto: lo unico que se
adelanta es la cabecera de EFC, que por protocolo llega antes que el cuerpo. El
cliente httpx se sigue creando fuera de todo `async with` y cerrandose en el finally,
que era la parte que si estaba bien.

De paso, un fallo de red contra EFC (timeout, conexion rechazada) daba un 500 opaco y
dejaba el cliente httpx sin cerrar; ahora sale 502 y se cierra. Es el mismo camino de
codigo que el ticket manda cubrir.

Se agregan las pruebas del proxy que el ticket exigia y que no existian: streaming
real -el cliente sigue vivo mientras se consumen los trozos y se cierra al terminar-,
la pertenencia validada antes de tocar EFC, el organizacion_id del expediente en la
peticion, que ninguna URL de almacenamiento se filtre al cuerpo ni a los headers, y
la traduccion completa de errores. Todo contra httpx.MockTransport, sin red.

stream_document y su ruta pasan a async por el cambio; la prueba del 409 en
test_expediente_upload gana su await, sin el cual la corrutina no se ejecutaba y la
prueba pasaba sin probar nada.

Refs: T2026-08-046 (fase 7)
2026-08-10 07:35:25 -06:00

267 lines
10 KiB
Python

"""Pruebas de la subida de un paso al expediente.
Lo que se fija: que el tope de tamaño se aplique **antes** de consumir el cuerpo entero, que el
catálogo y la allowlist se validen antes de tocar el almacén, y que **un EFC caído siga devolviendo
201** con el documento en «Pendiente de enviar».
"""
import io
import pytest
from fastapi import HTTPException, UploadFile
from api.v1.modules.crm.expediente_gateway import service as gateway
from api.v1.modules.crm.expediente_gateway.models import EfcFileOutbox
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 api.v1.modules.crm.uploads.routes import MAX_UPLOAD_BYTES
from tests.conftest import COMPANY_ID, TENANT_ID
OTRO_TENANT = 99
class _ArchivoContado(io.BytesIO):
"""``BytesIO`` que cuenta cuántos bytes se le han leído.
Es lo que permite afirmar que el tope se aplicó **sin** haber leído el archivo completo: un
``await file.read()`` a secas lo bufferizaría entero antes de poder rechazarlo.
"""
def __init__(self, data: bytes):
super().__init__(data)
self.leidos = 0
def read(self, size=-1):
chunk = super().read(size)
self.leidos += len(chunk)
return chunk
def _upload(nombre: str, contenido: bytes, content_type: str = "application/pdf") -> UploadFile:
return UploadFile(filename=nombre, file=_ArchivoContado(contenido), headers=None)
@pytest.fixture()
def entorno(db, monkeypatch):
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"))
subidos = {}
import api.v1.modules.crm.expedientes.service as exp_service
monkeypatch.setattr(
exp_service, "put_object_bytes",
lambda key, body, content_type="application/octet-stream": subidos.update({key: body}),
)
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, "subidos": subidos}
async def _adjuntar(entorno, **kwargs):
return await expedientes_service.attach_document(
entorno["db"],
kwargs.pop("expediente_id", entorno["expediente"].id),
kwargs.pop("file", _upload("guia.pdf", b"%PDF-1.4 contenido")),
kwargs.pop("doc_type", "MBL"),
kwargs.pop("tenant_id", TENANT_ID),
kwargs.pop("company_id", COMPANY_ID),
kwargs.pop("name", None),
kwargs.pop("user_id", "user-1"),
)
# ── Camino feliz ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_la_subida_crea_el_documento_pendiente_y_su_fila_de_outbox(entorno):
documento = await _adjuntar(entorno)
assert documento.id is not None
assert documento.efc_sync_state == "PENDING"
assert documento.expediente_id == entorno["expediente"].id
assert documento.efc_document_ref == f"CRMDOC-{COMPANY_ID}-{documento.id}"
assert documento.content_sha256 is not None
filas = entorno["db"].query(EfcFileOutbox).filter(
EfcFileOutbox.source_id == documento.id
).all()
assert len(filas) == 1
assert filas[0].delete_local is True
assert filas[0].crm_document_ref == documento.efc_document_ref
@pytest.mark.asyncio
async def test_el_objeto_se_guarda_bajo_el_arbol_del_expediente(entorno):
documento = await _adjuntar(entorno)
keys = list(entorno["subidos"])
assert len(keys) == 1
assert keys[0] == documento.file_key
assert f"tenants/{TENANT_ID}/companies/{COMPANY_ID}/expedientes/{entorno['expediente'].id}/" in keys[0]
# ── Validaciones ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_un_doc_type_fuera_del_catalogo_da_422_y_no_sube_nada(entorno):
with pytest.raises(HTTPException) as exc:
await _adjuntar(entorno, doc_type="tipo_inventado")
assert exc.value.status_code == 422
assert exc.value.detail == "Ese tipo de documento no está en el catálogo."
assert entorno["subidos"] == {} # nada llegó al almacén
@pytest.mark.asyncio
async def test_una_extension_no_permitida_da_422_y_no_sube_nada(entorno):
with pytest.raises(HTTPException) as exc:
await _adjuntar(entorno, file=_upload("malicioso.exe", b"MZ"))
assert exc.value.status_code == 422
assert exc.value.detail == "Ese tipo de archivo no está permitido."
assert entorno["subidos"] == {}
@pytest.mark.asyncio
async def test_la_extension_se_valida_ANTES_de_leer_el_archivo(entorno):
"""Rechazar por extensión no debe costar leer el archivo: es gratis saberlo por el nombre."""
archivo = _upload("malicioso.exe", b"M" * (2 * 1024 * 1024))
with pytest.raises(HTTPException):
await _adjuntar(entorno, file=archivo)
assert archivo.file.leidos == 0
@pytest.mark.asyncio
async def test_un_archivo_por_encima_del_tope_da_422_SIN_haberlo_leido_completo(entorno):
"""**El defecto que esta fase corrige.** Con ``await file.read()`` a secas, un archivo de 2 GB
se bufferizaba entero en RAM solo para responder 422 después.
Aquí la lectura es por partes y aborta al pasar del tope, así que lo leído se queda cerca del
tope y no llega al tamaño total.
"""
tamano = MAX_UPLOAD_BYTES + (3 * 1024 * 1024)
archivo = _upload("enorme.pdf", b"x" * tamano)
with pytest.raises(HTTPException) as exc:
await _adjuntar(entorno, file=archivo)
assert exc.value.status_code == 422
assert exc.value.detail == "El archivo excede el tamaño máximo permitido."
assert archivo.file.leidos < tamano
assert entorno["subidos"] == {}
@pytest.mark.asyncio
async def test_un_expediente_de_otro_tenant_da_404(entorno):
with pytest.raises(HTTPException) as exc:
await _adjuntar(entorno, tenant_id=OTRO_TENANT)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_un_expediente_inexistente_da_404(entorno):
with pytest.raises(HTTPException) as exc:
await _adjuntar(entorno, expediente_id=999999)
assert exc.value.status_code == 404
# ── EFC caído ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_con_efc_apagado_la_subida_SIGUE_funcionando(entorno, monkeypatch):
"""El caso que justifica todo el carril: el usuario no pierde su trabajo porque EFC no esté.
Sin outbox habría que llamar a EFC dentro del request, y un EFC caído devolvería un error al
usuario con el archivo ya subido a medias.
"""
from core.config import settings
monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False)
documento = await _adjuntar(entorno)
assert documento.id is not None
assert documento.efc_sync_state == "PENDING"
assert documento.file_key is not None # la copia local es lo único que hay
# Sin integración no se encola: el barrido de huecos lo recogerá cuando se encienda.
assert entorno["db"].query(EfcFileOutbox).filter(
EfcFileOutbox.source_id == documento.id
).count() == 0
@pytest.mark.asyncio
async def test_si_el_despacho_al_broker_falla_la_subida_igual_responde(entorno, monkeypatch):
"""«Si el broker no responde, el sweep la recoge»: el despacho es best-effort."""
def _revienta(*a, **k):
raise RuntimeError("Valkey no responde")
monkeypatch.setattr(gateway, "_dispatch_file_delivery", _revienta)
with pytest.raises(RuntimeError):
await _adjuntar(entorno)
# La fila SÍ quedó encolada antes del despacho: el barrido la va a recoger.
assert entorno["db"].query(EfcFileOutbox).count() == 1
# ── Lectura y baja ───────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_el_listado_solo_devuelve_los_documentos_de_ese_expediente(entorno):
documento = await _adjuntar(entorno)
docs = expedientes_service.list_expediente_documents(
entorno["db"], entorno["expediente"].id, TENANT_ID, COMPANY_ID
)
assert [d.id for d in docs] == [documento.id]
@pytest.mark.asyncio
async def test_un_documento_de_otro_tenant_no_se_puede_leer(entorno):
documento = await _adjuntar(entorno)
with pytest.raises(HTTPException) as exc:
expedientes_service.get_expediente_document(
entorno["db"], entorno["expediente"].id, documento.id, OTRO_TENANT, COMPANY_ID
)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_la_baja_desasocia_y_no_borra_nada_en_efc(entorno):
documento = await _adjuntar(entorno)
expedientes_service.detach_document(
entorno["db"], entorno["expediente"].id, documento.id, TENANT_ID, COMPANY_ID
)
assert documento.deleted_at is not None
assert expedientes_service.list_expediente_documents(
entorno["db"], entorno["expediente"].id, TENANT_ID, COMPANY_ID
) == []
# La fila del outbox NO se toca: si ya se entregó, en EFC sigue estando.
assert entorno["db"].query(EfcFileOutbox).count() == 1
@pytest.mark.asyncio
async def test_descargar_un_documento_que_aun_no_llego_a_efc_da_409(entorno):
"""No es un 404: el documento existe, solo que todavía no está del otro lado."""
documento = await _adjuntar(entorno)
with pytest.raises(HTTPException) as exc:
await expedientes_service.stream_document(
entorno["db"], entorno["expediente"].id, documento.id, TENANT_ID, COMPANY_ID
)
assert exc.value.status_code == 409