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)
267 lines
10 KiB
Python
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
|