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