""" Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y la BD simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de 624.38 con la guía T001-128912). """ import glob import logging import os import shutil import sqlite3 import pytest import database_utilities import hana_manager import outlook_utilities import pipeline import sapb1_manager import seguimiento from conftest import FIXTURES CASO = "001_correo" TOTAL_LINEA = 624.38 class Entorno: """Registra lo que el pipeline le pide a los servicios externos.""" def __init__(self): self.line_total_sap = TOTAL_LINEA self.error_al_crear = None self.drafts = [] self.correos = [] self.descargas = 0 @pytest.fixture def entorno(tmp_path, monkeypatch): e = Entorno() monkeypatch.setenv("RUTA_RED_FACTURACION", "") monkeypatch.setattr(database_utilities, "get_existing_ids", lambda ids: []) monkeypatch.setattr(database_utilities, "save_emails", lambda emails: None) def descargar(addr, id_, token, carpeta): e.descargas += 1 os.makedirs(carpeta, exist_ok=True) # como el download_attachments real nombres = [] for ruta in glob.glob(os.path.join(FIXTURES, CASO, "*")): if ruta.lower().endswith((".pdf", ".xml")): shutil.copy(ruta, carpeta) nombres.append(os.path.basename(ruta)) return nombres def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None): e.correos.append({"para": para, "asunto": asunto, "html": html, "cc": cc_recipients, "adjuntos": adjuntos}) return True def crear_draft(draft): if e.error_al_crear: raise e.error_al_crear e.drafts.append(draft) return {"DocEntry": 777} def delivery_notes(**k): return [{"Series": "1", "DocNum": "9", "U_IDC_DESTINO": "Z1", "DocumentLines": [ {"ItemCode": "FL10007", "LineTotal": e.line_total_sap, "PriceAfterVAT": 1.3396, "Quantity": 466, "WarehouseCode": "A-CENTRA", "Weight1": 1}]}] monkeypatch.setattr(outlook_utilities, "download_attachments", descargar) monkeypatch.setattr(outlook_utilities, "send_email", enviar) monkeypatch.setattr(sapb1_manager, "get_zonas", lambda: [{"Code": "Z1", "Name": "ZONA1"}]) monkeypatch.setattr(sapb1_manager, "get_delivery_notes", delivery_notes) monkeypatch.setattr(sapb1_manager, "obtener_condicion_pago", lambda c: {"NumberOfAdditionalDays": 30}) monkeypatch.setattr(sapb1_manager, "create_draft", crear_draft) monkeypatch.setattr(hana_manager, "obtener_valor_referencial", lambda s, n: 0.0) e.descarga = tmp_path / "descarga" e.salida = tmp_path / "salida" return e def _procesar(e, sender="proveedor@empresa.com"): email = {"id": "abc", "fecha": "2026-10-14 10:00:00", "subject": "Factura", "sender": sender} pipeline._procesar_un_correo(email, "token", "buzon@aberio.com", str(e.descarga), str(e.salida)) return email def test_precios_coinciden_crea_draft_y_notifica(entorno): _procesar(entorno) assert len(entorno.drafts) == 1 draft = entorno.drafts[0] assert draft["CardCode"] == "PL10060028686" assert draft["NumAtCard"] == "01F002-2450" assert draft["DocumentLines"][0]["U_IDC_GRFLETE"] == "09T001-128912" assert len(entorno.correos) == 1 correo = entorno.correos[0] assert correo["para"] == ["proveedor@empresa.com"] assert correo["asunto"] == "Draft SAP B1 - F002-2450" assert "creado exitosamente" in correo["html"] and "777" in correo["html"] assert correo["cc"] # siempre se copia al equipo assert any(a.endswith(".xml") for a in correo["adjuntos"]) def test_precios_no_coinciden_no_crea_draft_pero_notifica(entorno): entorno.line_total_sap = TOTAL_LINEA + 50 _procesar(entorno) assert entorno.drafts == [] assert len(entorno.correos) == 1 assert "Draft NO creado" in entorno.correos[0]["html"] def test_error_al_crear_draft_se_notifica(entorno): entorno.error_al_crear = RuntimeError("Service Layer caído") _procesar(entorno) assert entorno.drafts == [] assert len(entorno.correos) == 1 assert "Error al crear Draft" in entorno.correos[0]["html"] assert "Service Layer caído" in entorno.correos[0]["html"] def test_remitente_desconocido_no_envia_correo(entorno): _procesar(entorno, sender="Desconocido") assert len(entorno.drafts) == 1 # el draft se crea igual assert entorno.correos == [] def test_correo_ya_procesado_se_salta(entorno, monkeypatch): monkeypatch.setattr(database_utilities, "get_existing_ids", lambda ids: ["abc"]) _procesar(entorno) assert entorno.descargas == 0 assert entorno.drafts == [] and entorno.correos == [] def test_guarda_resultados_y_draft_en_disco(entorno): _procesar(entorno) nombres = os.listdir(entorno.salida) assert any(n.startswith("xml_resultado_F002-2450") for n in nombres) assert any(n.startswith("draft_sapb1_F002-2450") for n in nombres) # ============================================================================ # Seguimiento # ============================================================================ def _registros(): conn = sqlite3.connect(database_utilities.DB_NAME) conn.row_factory = sqlite3.Row try: return [dict(f) for f in conn.execute("SELECT * FROM comprobantes ORDER BY id")] finally: conn.close() def test_draft_creado_deja_el_comprobante_aceptado(entorno): _procesar(entorno) (registro,) = _registros() assert registro["estado"] == "aceptado" assert registro["doc_entry"] == 777 assert registro["codigo_seguimiento"].startswith("ABR-") assert (registro["serie"], registro["numero"], registro["tipo"]) == ("F002", "2450", "01") assert registro["ruc_emisor"] == "10060028686" def test_precios_que_no_coinciden_dejan_el_comprobante_rechazado(entorno): entorno.line_total_sap = TOTAL_LINEA + 50 _procesar(entorno) (registro,) = _registros() assert registro["estado"] == "rechazado" assert registro["doc_entry"] is None assert seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] == [{"codigo": "precios_no_coinciden"}] def test_error_al_crear_draft_deja_el_comprobante_en_error(entorno): entorno.error_al_crear = RuntimeError("Service Layer caído") _procesar(entorno) (registro,) = _registros() assert registro["estado"] == "error" obs = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] assert obs == [{"codigo": "error_sap", "detalle": "Service Layer caído"}] def test_comprobante_reenviado_conserva_el_codigo_y_se_advierte(entorno, caplog): _procesar(entorno) (primero,) = _registros() entorno.descarga = entorno.descarga.parent / "descarga2" # otra descarga, otro correo with caplog.at_level(logging.WARNING, logger="pipeline"): pipeline._procesar_un_correo( {"id": "otro-correo", "fecha": "2026-10-19 11:00:00", "subject": "Reenvío", "sender": "p@e.com"}, "token", "buzon@aberio.com", str(entorno.descarga), str(entorno.salida), ) (registro,) = _registros() # sigue habiendo una sola fila assert registro["codigo_seguimiento"] == primero["codigo_seguimiento"] assert registro["veces_recibido"] == 2 assert any("ya recibido antes" in r.getMessage() for r in caplog.records) def test_si_falla_el_seguimiento_igual_se_crea_el_draft(entorno, monkeypatch): def roto(*a, **k): raise sqlite3.OperationalError("database is locked") monkeypatch.setattr(seguimiento, "registrar_comprobante", roto) monkeypatch.setattr(seguimiento, "actualizar_resultado", roto) _procesar(entorno) assert len(entorno.drafts) == 1 assert len(entorno.correos) == 1