"""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