"""Entrega al expediente de EFC de los dos XML del timbrado. Lo que se fija aquí es el encolado: qué se manda, con qué tipo, y —lo más frágil— que los DOS XML del mismo timbre lleguen. Comparten ``source_id`` (el id del intento), así que un solo ``kind`` para ambos haría que la guarda de idempotencia se comiera el segundo. No se prueba la subida a EFC: eso es ``deliver_file_row``, ya cubierto en las pruebas del outbox. """ import pytest from api.v1.modules.crm.cases import service as cases_service from api.v1.modules.crm.expediente_gateway.models import ( FILE_KIND_CFDI_REQUEST, FILE_KIND_CFDI_RESPONSE, SOURCE_FIN_INVOICE_STAMPS, STATUS_SENT, EfcFileOutbox, ) from api.v1.modules.fin.invoices.models import Invoice from api.v1.modules.fin.stamping import service as stamping from api.v1.modules.fin.stamping.models import STATUS_STAMPED, InvoiceStamp from tests.conftest import COMPANY_ID, TENANT_ID UUID = "11111111-2222-3333-4444-555555555555" KEY_REQUEST = "tenants/1/companies/1/fin-invoices/7/stamps/attempts/3-request.xml" KEY_RESPONSE = "tenants/1/companies/1/fin-invoices/7/stamps/attempts/3-response.xml" @pytest.fixture() def efc_encendido(monkeypatch): from core.config import settings monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False) return settings def _expediente(db): """Expediente como el que nace con la oportunidad, sin tocar el carril hacia EFC.""" from api.v1.modules.crm.expediente_gateway import service as gateway # ``create_case`` replica el expediente a EFC; aquí solo interesa la fila local. original = gateway.replicate_expediente_best_effort gateway.replicate_expediente_best_effort = lambda *a, **k: None try: case = cases_service.create_case( db, TENANT_ID, COMPANY_ID, title="Importación de prueba", user_id="user-1" ) finally: gateway.replicate_expediente_best_effort = original db.commit() return case def _factura_timbrada(db, *, case_id, request_key=KEY_REQUEST, response_key=KEY_RESPONSE): invoice = Invoice( reference="FAC-001", case_id=case_id, currency="MXN", status="emitida", tenant_id=TENANT_ID, company_id=COMPANY_ID, ) db.add(invoice) db.flush() stamp = InvoiceStamp( invoice_id=invoice.id, mode="pruebas", status=STATUS_STAMPED, uuid=UUID, request_xml_file_key=request_key, response_xml_file_key=response_key, tenant_id=TENANT_ID, company_id=COMPANY_ID, ) db.add(stamp) db.flush() return invoice, stamp def _filas(db): return db.query(EfcFileOutbox).order_by(EfcFileOutbox.id).all() # ── El caso normal ─────────────────────────────────────────────────────────── def test_encola_los_dos_xml_del_timbre(db, efc_encendido): case = _expediente(db) invoice, stamp = _factura_timbrada(db, case_id=case.id) ids = stamping._encolar_xml_al_expediente(db, invoice, stamp) db.commit() assert len(ids) == 2 filas = _filas(db) assert [f.kind for f in filas] == [FILE_KIND_CFDI_REQUEST, FILE_KIND_CFDI_RESPONSE] assert [f.s3_key for f in filas] == [KEY_REQUEST, KEY_RESPONSE] assert [f.file_name for f in filas] == [ f"CFDI-{UUID}-envio.xml", f"CFDI-{UUID}-respuesta.xml", ] for fila in filas: assert fila.expediente_ref == case.id assert fila.source_table == SOURCE_FIN_INVOICE_STAMPS assert fila.source_id == stamp.id assert fila.content_type == "application/xml" assert fila.efc_tipo == "factura_venta" def test_el_tipo_es_uno_que_efc_acepta(db, efc_encendido): """El tipo viaja al catálogo GLOBAL de EFC: una clave inventada lo contamina.""" from api.v1.modules.crm.expediente_gateway.doc_types import is_valid_doc_type assert is_valid_doc_type(stamping._EFC_TIPO_CFDI) def test_no_borra_la_copia_local(db, efc_encendido): """El XML timbrado es el comprobante fiscal y el CRM lo sirve por /stamp/xml-url. Con ``delete_local=True`` la entrega borraría el objeto de MinIO y esos endpoints quedarían apuntando a algo inexistente. """ case = _expediente(db) invoice, stamp = _factura_timbrada(db, case_id=case.id) stamping._encolar_xml_al_expediente(db, invoice, stamp) db.commit() assert [f.delete_local for f in _filas(db)] == [False, False] def test_referencias_distintas_por_xml(db, efc_encendido): """``crm_document_ref`` es la idempotencia del lado de EFC: un UNIQUE por (org, ref). Si los dos XML compartieran la referencia, EFC devolvería el primero al entregar el segundo y el expediente se quedaría con la mitad del par. """ case = _expediente(db) invoice, stamp = _factura_timbrada(db, case_id=case.id) stamping._encolar_xml_al_expediente(db, invoice, stamp) db.commit() refs = [f.crm_document_ref for f in _filas(db)] assert refs == [f"CFDIREQ-{COMPANY_ID}-{stamp.id}", f"CFDIRES-{COMPANY_ID}-{stamp.id}"] assert len(set(refs)) == 2 # ── La colisión de kinds ───────────────────────────────────────────────────── def test_el_xml_ya_entregado_no_bloquea_al_otro(db, efc_encendido): """La razón de que los kinds sean dos y no uno. La guarda es ``(source_table, source_id, kind)`` y los dos XML comparten source_id. Con un kind común, tener el de envío en 'sent' haría que el de respuesta no se encolara nunca — y el expediente se quedaría a medias sin un solo error visible. """ case = _expediente(db) invoice, stamp = _factura_timbrada(db, case_id=case.id) db.add( EfcFileOutbox( kind=FILE_KIND_CFDI_REQUEST, s3_key=KEY_REQUEST, file_name="x.xml", content_type="application/xml", efc_tipo="factura_venta", source_table=SOURCE_FIN_INVOICE_STAMPS, source_id=stamp.id, expediente_ref=case.id, status=STATUS_SENT, delete_local=False, tenant_id=TENANT_ID, company_id=COMPANY_ID, ) ) db.commit() stamping._encolar_xml_al_expediente(db, invoice, stamp) db.commit() nuevas = [f for f in _filas(db) if f.status != STATUS_SENT] assert [f.kind for f in nuevas] == [FILE_KIND_CFDI_RESPONSE] # ── Los casos en que no hay nada que entregar ──────────────────────────────── def test_factura_sin_expediente_no_encola(db, efc_encendido): """Una factura capturada fuera del ciclo comercial no tiene expediente. No es un error.""" invoice, stamp = _factura_timbrada(db, case_id=None) assert stamping._encolar_xml_al_expediente(db, invoice, stamp) == [] assert _filas(db) == [] def test_sin_xml_guardado_se_encola_solo_el_que_existe(db, efc_encendido): """Si el almacenamiento falló al guardar el intento, no hay objeto que subir.""" case = _expediente(db) invoice, stamp = _factura_timbrada(db, case_id=case.id, response_key=None) ids = stamping._encolar_xml_al_expediente(db, invoice, stamp) db.commit() assert len(ids) == 1 assert [f.kind for f in _filas(db)] == [FILE_KIND_CFDI_REQUEST] def test_un_fallo_del_carril_no_tumba_el_timbre(db, efc_encendido, monkeypatch): """El CFDI ya es válido ante el SAT: nada del carril hacia EFC puede propagar.""" from api.v1.modules.crm.expediente_gateway import service as gateway def _explota(*_a, **_k): raise RuntimeError("EFC en llamas") monkeypatch.setattr(gateway, "enqueue_file_best_effort", _explota) case = _expediente(db) invoice, stamp = _factura_timbrada(db, case_id=case.id) assert stamping._encolar_xml_al_expediente(db, invoice, stamp) == [] def test_con_efc_apagado_no_encola(db, monkeypatch): """Sin ``EFC_API_URL`` el carril es un no-op y el timbrado sigue igual.""" from core.config import settings monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False) case = _expediente(db) invoice, stamp = _factura_timbrada(db, case_id=case.id) assert stamping._encolar_xml_al_expediente(db, invoice, stamp) == [] assert _filas(db) == []