se valida si la factura ya existe en SAP y se avisa cuando SAP está fuera de línea
- sapb1_manager.buscar_factura_existente busca la factura (proveedor, tipo, serie, número) en facturas de compra no anuladas y en drafts; si existe no se crea otro draft y se responde "ya está registrada" sin pisar el registro de una aceptación anterior. - SapNoDisponible (subclase de ConnectionError) distingue una caída (timeout, sin conexión, HTTP 5xx) de un rechazo normal de SAP (HTTP 4xx). Una caída de Service Layer con HANA sin resultados ya no se lee como "guía no existe". - Con SAP o HANA caídos se avisa una sola vez al proveedor con la plantilla "En cola" y el correo se reintenta en los ciclos siguientes (hasta 24), sin repetir avisos; al agotarse se avisa al equipo. - La lógica por factura pasa a _procesar_factura; lo ya resuelto dentro de un correo no se repite al reintentarlo. - seguimiento: columna intentos (con migración), registrar_intento y el estado de notificación en_espera. - database_utilities.DB_NAME pasa a ser absoluto (junto al código). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4deb9d4e4d
commit
f0ad6aa78e
20 files changed
+991
-130
No files matched your search
@@ -233,3 +233,48 @@ def test_valor_referencial_consulta_una_vez_por_guia(monkeypatch):
|
||||
|
||||
assert llamadas == [("T001", "1"), ("T001", "2")]
|
||||
assert [r["valor_sap"] for r in resultado] == [7.5, 7.5]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Caídas de SAP y errores de consulta
|
||||
# ============================================================================
|
||||
def test_validate_prices_deja_pasar_una_caida_de_sap(sap, monkeypatch):
|
||||
def cae(**k):
|
||||
raise draft_sap.sapb1_manager.SapNoDisponible("sin conexión")
|
||||
monkeypatch.setattr(draft_sap.sapb1_manager, "get_delivery_notes", cae)
|
||||
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": [_guia()]}]}
|
||||
|
||||
with pytest.raises(draft_sap.sapb1_manager.SapNoDisponible): # no se disfraza de "error de la guía"
|
||||
draft_sap.validate_prices(factura)
|
||||
|
||||
|
||||
def test_validate_prices_marca_el_error_de_consulta_de_la_guia(sap, monkeypatch):
|
||||
def falla(**k):
|
||||
raise ValueError("respuesta rara de SAP")
|
||||
monkeypatch.setattr(draft_sap.sapb1_manager, "get_delivery_notes", falla)
|
||||
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": [_guia()]}]}
|
||||
|
||||
_, resultados = draft_sap.validate_prices(factura)
|
||||
|
||||
assert resultados[0]["guias_lt"][0]["error"] == "respuesta rara de SAP"
|
||||
assert draft_sap.hay_error_de_consulta(resultados) == "respuesta rara de SAP"
|
||||
|
||||
|
||||
def test_hay_error_de_consulta_sin_errores():
|
||||
assert draft_sap.hay_error_de_consulta([]) is None
|
||||
assert draft_sap.hay_error_de_consulta([{"guias_lt": [{"guia": {}, "line_total": 0.0}]}, {"guias_lt": []}]) is None
|
||||
|
||||
|
||||
def test_hana_disponible(monkeypatch):
|
||||
cerrada = []
|
||||
|
||||
class Conexion:
|
||||
def close(self):
|
||||
cerrada.append(True)
|
||||
|
||||
monkeypatch.setattr(draft_sap.hana_manager, "get_connection", lambda: Conexion())
|
||||
assert draft_sap.hana_disponible() is True and cerrada == [True] # abre y cierra: no deja conexiones colgando
|
||||
|
||||
monkeypatch.setattr(draft_sap.hana_manager, "get_connection", lambda: None)
|
||||
assert draft_sap.hana_disponible() is False
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_motivos_de_correo_sin_comprobante():
|
||||
def test_todas_las_observaciones_tienen_titulo_y_como_corregir():
|
||||
muestras = [
|
||||
obs.precio_no_coincide(1, 1, 2, ["T001-1"], True), obs.guia_no_encontrada("T001-1"),
|
||||
obs.linea_sin_guia(1, ""), obs.cantidad_cero(1), obs.sin_pdf_xml(0), obs.falta_xml(1), obs.sin_factura([]),
|
||||
obs.linea_sin_guia(1, ""), obs.cantidad_cero(1), obs.factura_ya_registrada("F002-1"), obs.sin_pdf_xml(0), obs.falta_xml(1), obs.sin_factura([]),
|
||||
]
|
||||
for o in muestras:
|
||||
assert o.titulo and o.como_corregir and o.codigo
|
||||
@@ -96,3 +96,10 @@ def test_a_dict_es_serializable():
|
||||
d = obs.guia_no_encontrada("T001-9").a_dict()
|
||||
assert d == {"codigo": "guia_no_encontrada", "titulo": "No encontramos la guía T001-9",
|
||||
"filas": [{"etiqueta": "Guía", "valor": "T001-9"}], "como_corregir": d["como_corregir"]}
|
||||
|
||||
|
||||
def test_factura_ya_registrada():
|
||||
o = obs.factura_ya_registrada("F002-486")
|
||||
assert o.codigo == "factura_ya_registrada" and o.filas == (("Comprobante", "F002-486"),)
|
||||
assert "No necesita enviarla otra vez" in o.como_corregir
|
||||
|
||||
@@ -38,6 +38,9 @@ class Entorno:
|
||||
self.sap_lanza = None # excepción que lanza get_delivery_notes
|
||||
self.error_al_crear = None
|
||||
self.hana_disponible = True
|
||||
self.existente = None # dict: la factura ya existe en SAP
|
||||
self.sap_caido = False # SAP no responde (en la búsqueda de la factura)
|
||||
self.sap_caido_en_validacion = False
|
||||
self.archivos = None # None: usa los del caso 001; dict {nombre: bytes}: esos archivos
|
||||
self.drafts = []
|
||||
self.correos = []
|
||||
@@ -45,11 +48,12 @@ class Entorno:
|
||||
|
||||
@property
|
||||
def tecnicos(self):
|
||||
return [c for c in self.correos if c["asunto"].startswith("Draft SAP B1")]
|
||||
return [c for c in self.correos if "Draft SAP B1" in c["asunto"]]
|
||||
|
||||
@property
|
||||
def respuestas(self):
|
||||
return [c for c in self.correos if " · " in c["asunto"] and ("Aceptado" in c["asunto"] or "Rechazado" in c["asunto"])]
|
||||
return [c for c in self.correos if " · " in c["asunto"]
|
||||
and any(p in c["asunto"] for p in ("Aceptado", "Rechazado", "En cola"))]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -102,7 +106,18 @@ def entorno(tmp_path, monkeypatch):
|
||||
|
||||
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"}])
|
||||
def buscar_existente(ruc, tipo, serie, numero):
|
||||
if e.sap_caido:
|
||||
raise sapb1_manager.SapNoDisponible("No se pudo conectar con SAP B1")
|
||||
return e.existente
|
||||
|
||||
def zonas():
|
||||
if e.sap_caido_en_validacion:
|
||||
raise sapb1_manager.SapNoDisponible("Timeout en Drafts")
|
||||
return [{"Code": "Z1", "Name": "ZONA1"}]
|
||||
|
||||
monkeypatch.setattr(sapb1_manager, "buscar_factura_existente", buscar_existente)
|
||||
monkeypatch.setattr(sapb1_manager, "get_zonas", zonas)
|
||||
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)
|
||||
@@ -207,18 +222,20 @@ def test_guia_inexistente_rechaza_con_guia_no_encontrada(entorno, monkeypatch):
|
||||
assert entorno.drafts == []
|
||||
|
||||
|
||||
def test_guia_inexistente_con_hana_caido_es_error_interno_y_no_se_le_culpa_al_proveedor(entorno, monkeypatch):
|
||||
def test_guia_inexistente_con_hana_caido_es_servicio_fuera_de_linea_y_no_rechazo(entorno, monkeypatch):
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
entorno.guia_existe = False
|
||||
entorno.hana_disponible = False
|
||||
|
||||
_procesar(entorno)
|
||||
|
||||
assert entorno.respuestas == [] # nada al proveedor
|
||||
assert entorno.drafts == []
|
||||
(aviso,) = entorno.respuestas # no se rechaza: se avisa que está en cola
|
||||
assert aviso["asunto"].startswith("En cola · Factura F002-2450") and "No encontramos la guía" not in aviso["html"]
|
||||
(tecnico,) = entorno.tecnicos
|
||||
assert "HANA" in tecnico["html"]
|
||||
assert "HANA" in tecnico["html"] and tecnico["asunto"].startswith("SAP fuera de línea")
|
||||
(registro,) = _registros()
|
||||
assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida"
|
||||
assert registro["estado"] == "error" and registro["notificacion_estado"] == "en_espera"
|
||||
|
||||
|
||||
def test_error_consultando_sap_es_error_interno_y_no_rechazo(entorno, monkeypatch):
|
||||
@@ -413,3 +430,156 @@ def test_un_fallo_inesperado_en_una_factura_no_tumba_el_ciclo(entorno, monkeypat
|
||||
assert entorno.drafts == [] and entorno.respuestas == []
|
||||
assert "ZeroDivisionError" in entorno.tecnicos[0]["html"]
|
||||
assert _registros()[0]["estado"] == "error"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# La factura ya existe en SAP
|
||||
# ============================================================================
|
||||
def _ids_de_correos_guardados():
|
||||
conn = sqlite3.connect(database_utilities.DB_NAME)
|
||||
try:
|
||||
return [f[0] for f in conn.execute("SELECT id FROM emails")]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_factura_que_ya_existe_en_sap_no_crea_otro_draft_y_se_rechaza(entorno, monkeypatch):
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
entorno.existente = {"entidad": "PurchaseInvoices", "DocEntry": 55, "DocNum": 9}
|
||||
|
||||
_procesar(entorno)
|
||||
|
||||
assert entorno.drafts == []
|
||||
(respuesta,) = entorno.respuestas
|
||||
assert respuesta["para"] == [SENDER] and respuesta["asunto"].startswith("Rechazado · Factura F002-2450")
|
||||
assert "Esta factura ya está registrada en nuestro sistema" in respuesta["html"]
|
||||
(tecnico,) = entorno.tecnicos
|
||||
assert "ya existe en SAP" in tecnico["html"] and "PurchaseInvoices 55" in tecnico["html"]
|
||||
assert not any(n.startswith("draft_sapb1") for n in os.listdir(entorno.salida)) # ni se armó el draft
|
||||
assert _registros()[0]["estado"] == "rechazado"
|
||||
|
||||
|
||||
def test_el_reenvio_de_una_factura_aceptada_no_pisa_el_registro_original(entorno, monkeypatch):
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
_procesar(entorno) # se acepta: draft 777
|
||||
entorno.existente = {"entidad": "Drafts", "DocEntry": 777, "DocNum": 3}
|
||||
|
||||
_procesar(entorno, id_="otro-correo", carpeta=entorno.descarga.parent / "descarga2")
|
||||
|
||||
(registro,) = _registros()
|
||||
assert (registro["estado"], registro["doc_entry"], registro["veces_recibido"]) == ("aceptado", 777, 2)
|
||||
assert len(entorno.drafts) == 1 # no se creó un segundo draft
|
||||
assert "ya está registrada" in entorno.respuestas[-1]["html"]
|
||||
|
||||
|
||||
def test_reprocesar_el_mismo_correo_ya_resuelto_no_repite_nada(entorno, monkeypatch):
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
_procesar(entorno)
|
||||
enviados = len(entorno.correos)
|
||||
|
||||
_procesar(entorno) # mismo correo otra vez
|
||||
|
||||
assert len(entorno.drafts) == 1 and len(entorno.correos) == enviados
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SAP fuera de línea
|
||||
# ============================================================================
|
||||
def test_sap_fuera_de_linea_avisa_al_proveedor_y_deja_el_correo_en_cola(entorno, monkeypatch):
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
entorno.sap_caido = True
|
||||
|
||||
_procesar(entorno)
|
||||
|
||||
assert entorno.drafts == []
|
||||
(aviso,) = entorno.respuestas
|
||||
assert aviso["para"] == [SENDER] and aviso["asunto"].startswith("En cola · Factura F002-2450 · ABR-")
|
||||
assert "Recibimos su factura" in aviso["html"] and "temporalmente fuera de línea" in aviso["html"]
|
||||
assert "No necesita reenviarla" in aviso["html"] and "{{" not in aviso["html"]
|
||||
(tecnico,) = entorno.tecnicos
|
||||
assert tecnico["para"] == EQUIPO and tecnico["asunto"].startswith("SAP fuera de línea - Draft SAP B1")
|
||||
|
||||
(registro,) = _registros()
|
||||
assert (registro["estado"], registro["notificacion_estado"], registro["intentos"]) == ("error", "en_espera", 1)
|
||||
(obs,) = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"]
|
||||
assert obs["codigo"] == "servicio_no_disponible" and obs["intentos"] == 1
|
||||
assert "abc" not in _ids_de_correos_guardados() # olvidado: el próximo ciclo lo vuelve a tomar
|
||||
|
||||
|
||||
def test_los_reintentos_no_repiten_el_aviso_y_al_volver_sap_se_responde(entorno, monkeypatch):
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
entorno.sap_caido = True
|
||||
|
||||
_procesar(entorno)
|
||||
_procesar(entorno) # el ciclo siguiente: SAP sigue caído
|
||||
|
||||
assert len(entorno.respuestas) == 1 and len(entorno.tecnicos) == 1 # un solo aviso, no uno por ciclo
|
||||
assert _registros()[0]["intentos"] == 2 and "abc" not in _ids_de_correos_guardados()
|
||||
|
||||
entorno.sap_caido = False
|
||||
_procesar(entorno) # SAP volvió
|
||||
|
||||
asuntos = [c["asunto"] for c in entorno.respuestas]
|
||||
assert asuntos[0].startswith("En cola") and asuntos[1].startswith("Aceptado") and len(asuntos) == 2
|
||||
(registro,) = _registros()
|
||||
assert (registro["estado"], registro["doc_entry"], registro["notificacion_estado"]) == ("aceptado", 777, "enviada")
|
||||
assert len(entorno.drafts) == 1
|
||||
assert "abc" in _ids_de_correos_guardados() # ya resuelto: no se vuelve a tomar
|
||||
|
||||
|
||||
def test_se_agotan_los_reintentos_y_se_avisa_al_equipo(entorno, monkeypatch):
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
monkeypatch.setattr(pipeline, "MAX_REINTENTOS", 2)
|
||||
entorno.sap_caido = True
|
||||
|
||||
_procesar(entorno)
|
||||
_procesar(entorno)
|
||||
|
||||
assert [c["asunto"].split(" - ")[0] for c in entorno.tecnicos] == ["SAP fuera de línea", "Reintentos agotados"]
|
||||
assert len(entorno.respuestas) == 1 # el proveedor solo recibió el aviso inicial
|
||||
assert "abc" in _ids_de_correos_guardados() # deja de reintentarse
|
||||
registro = _registros()[0]
|
||||
assert registro["notificacion_estado"] == "omitida" and "reintentos" in registro["notificacion_error"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("como_falla", ["validacion", "crear_draft"])
|
||||
def test_la_caida_en_cualquier_paso_es_fuera_de_linea(entorno, monkeypatch, como_falla):
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
if como_falla == "validacion":
|
||||
entorno.sap_caido_en_validacion = True
|
||||
else:
|
||||
entorno.error_al_crear = sapb1_manager.SapNoDisponible("HTTP 503 - Service Unavailable")
|
||||
|
||||
_procesar(entorno)
|
||||
|
||||
assert entorno.drafts == []
|
||||
(aviso,) = entorno.respuestas
|
||||
assert aviso["asunto"].startswith("En cola · Factura F002-2450")
|
||||
assert "abc" not in _ids_de_correos_guardados()
|
||||
|
||||
|
||||
def test_un_rechazo_normal_de_sap_no_es_una_caida(entorno, monkeypatch):
|
||||
"""HTTP 400 al crear el draft: es un problema del dato o de SAP, no se reintenta ni se le dice nada al proveedor."""
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
entorno.error_al_crear = ConnectionError("Error en SAP POST Drafts: HTTP 400 - ItemCode inexistente")
|
||||
|
||||
_procesar(entorno)
|
||||
|
||||
assert entorno.respuestas == []
|
||||
assert "HTTP 400" in entorno.tecnicos[0]["html"]
|
||||
assert _registros()[0]["estado"] == "error" and "abc" in _ids_de_correos_guardados()
|
||||
|
||||
|
||||
def test_sin_seguimiento_una_caida_no_se_reintenta(entorno, monkeypatch):
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
entorno.sap_caido = True
|
||||
|
||||
def roto(*a, **k):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
monkeypatch.setattr(seguimiento, "registrar_comprobante", roto)
|
||||
|
||||
_procesar(entorno)
|
||||
|
||||
assert entorno.respuestas == [] and len(entorno.tecnicos) == 1
|
||||
assert "abc" in _ids_de_correos_guardados() # sin la cuenta de intentos no se arriesga un bucle
|
||||
@@ -114,7 +114,7 @@ def test_todos_los_escenarios_se_renderizan_completos(escenario):
|
||||
assert html.lstrip().startswith("<!DOCTYPE html>")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nombre", ["aceptado", "rechazado"])
|
||||
@pytest.mark.parametrize("nombre", ["aceptado", "rechazado", "fuera-de-linea"])
|
||||
def test_los_ejemplos_cubren_las_variables_de_la_plantilla(nombre):
|
||||
"""Si la plantilla pide algo nuevo (o un ejemplo sobra), el desfase se nota aquí."""
|
||||
usados = plantillas.nombres_usados(plantillas.cargar_plantilla(nombre))
|
||||
@@ -177,3 +177,12 @@ def test_rechazo_con_comprobante_si_muestra_guias_ruc_y_fila_comprobante():
|
||||
|
||||
assert "Guías revisadas" in html and "RUC 20537929520" in html and "Comprobante</td>" in html
|
||||
|
||||
|
||||
def test_fuera_de_linea_dice_que_quedo_en_cola_y_no_pide_reenviar():
|
||||
html = plantillas.renderizar_plantilla("fuera-de-linea", _ejemplos()["fuera_de_linea"]["contexto"])
|
||||
|
||||
assert "EN COLA" in html and "Recibimos su factura" in html
|
||||
assert "temporalmente fuera de línea" in html and "No necesita reenviarla" in html
|
||||
assert "Guías revisadas" not in html and "Pago estimado" not in html and "ACEPTADO" not in html
|
||||
assert "ABR-261019-00048" in html and "{{" not in html
|
||||
|
||||
@@ -268,3 +268,28 @@ def test_encabezado_tecnico(registro):
|
||||
def test_enviar_a_equipo(graph):
|
||||
assert rp.enviar_a_equipo("t", BUZON, "Asunto", "<p/>", ["a.xml"]) is True
|
||||
assert graph[0]["para"] == ["eq@aberio.com"]
|
||||
|
||||
|
||||
def test_contexto_fuera_de_linea_renderiza(registro):
|
||||
contexto = rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA)
|
||||
|
||||
assert contexto["asunto"] == f"En cola · Factura F002-486 · {registro['codigo_seguimiento']}"
|
||||
html = plantillas.renderizar_plantilla("fuera-de-linea", contexto)
|
||||
assert "{{" not in html and "SÁNCHEZ & HIJOS" in html
|
||||
|
||||
|
||||
def test_responder_con_estado_de_exito_en_espera(graph, registro, monkeypatch):
|
||||
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||
|
||||
estado = rp.responder("t", BUZON, EMAIL, "fuera-de-linea", rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA),
|
||||
registro, estado_exito="en_espera")
|
||||
|
||||
assert estado == "en_espera" and graph[0]["para"] == ["prov@empresa.com"]
|
||||
assert seguimiento.obtener(registro["codigo_seguimiento"])["notificacion_estado"] == "en_espera"
|
||||
|
||||
|
||||
def test_si_el_aviso_falla_queda_fallida_aunque_el_exito_sea_en_espera(graph, registro):
|
||||
graph.ok = False
|
||||
assert rp.responder("t", BUZON, EMAIL, "fuera-de-linea", rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA),
|
||||
registro, estado_exito="en_espera") == "fallida"
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
import hana_manager
|
||||
import sapb1_manager as s
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status=200, body=None, text=""):
|
||||
self.status_code, self._body, self.text = status, body if body is not None else {}, text
|
||||
self.content = b"x"
|
||||
self.cookies = {}
|
||||
|
||||
def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def sesion_abierta(monkeypatch):
|
||||
monkeypatch.setitem(s._sap_session, "headers", {"Cookie": "B1SESSION=x"})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Clasificación de errores: caída vs rechazo de SAP
|
||||
# ============================================================================
|
||||
@pytest.mark.parametrize("error", [requests.exceptions.ConnectTimeout("t"), requests.exceptions.ReadTimeout("t"),
|
||||
requests.exceptions.ConnectionError("c")])
|
||||
def test_timeout_o_sin_conexion_es_sap_no_disponible(monkeypatch, error):
|
||||
def falla(**k):
|
||||
raise error
|
||||
monkeypatch.setattr(s.requests, "request", lambda **k: falla(**k))
|
||||
with pytest.raises(s.SapNoDisponible):
|
||||
s.forward_to_sapb1("GET", "Drafts")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [500, 502, 503])
|
||||
def test_http_5xx_es_sap_no_disponible(monkeypatch, status):
|
||||
monkeypatch.setattr(s.requests, "request", lambda **k: _Resp(status, {"error": {"message": {"value": "caído"}}}))
|
||||
with pytest.raises(s.SapNoDisponible, match=f"HTTP {status}"):
|
||||
s.forward_to_sapb1("GET", "Drafts")
|
||||
|
||||
|
||||
def test_http_4xx_es_un_rechazo_no_una_caida(monkeypatch):
|
||||
monkeypatch.setattr(s.requests, "request", lambda **k: _Resp(400, {"error": {"message": {"value": "ItemCode inexistente"}}}))
|
||||
with pytest.raises(ConnectionError, match="ItemCode inexistente") as ex:
|
||||
s.forward_to_sapb1("POST", "Drafts", json_data={})
|
||||
assert not isinstance(ex.value, s.SapNoDisponible)
|
||||
|
||||
|
||||
def test_sap_no_disponible_sigue_siendo_connection_error():
|
||||
assert issubclass(s.SapNoDisponible, ConnectionError)
|
||||
|
||||
|
||||
def test_login_sin_conexion_es_caida(monkeypatch):
|
||||
def falla(*a, **k):
|
||||
raise requests.exceptions.ConnectTimeout("t")
|
||||
monkeypatch.setattr(s.requests, "post", falla)
|
||||
with pytest.raises(s.SapNoDisponible):
|
||||
s.login()
|
||||
|
||||
|
||||
def test_login_5xx_es_caida_pero_credenciales_malas_no(monkeypatch):
|
||||
monkeypatch.setattr(s.requests, "post", lambda *a, **k: _Resp(503, text="down"))
|
||||
with pytest.raises(s.SapNoDisponible):
|
||||
s.login()
|
||||
|
||||
monkeypatch.setattr(s.requests, "post", lambda *a, **k: _Resp(401, text="Invalid credentials"))
|
||||
with pytest.raises(ConnectionError) as ex:
|
||||
s.login()
|
||||
assert not isinstance(ex.value, s.SapNoDisponible)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Guías: una caída no se confunde con "no existe"
|
||||
# ============================================================================
|
||||
def _forward(respuestas):
|
||||
"""forward_to_sapb1 simulado: ``respuestas`` es una función (metodo, entidad) -> dict | excepción."""
|
||||
def forward(method, endpoint_path, **k):
|
||||
r = respuestas(method, endpoint_path)
|
||||
if isinstance(r, Exception):
|
||||
raise r
|
||||
return _Resp(200, r)
|
||||
return forward
|
||||
|
||||
|
||||
def test_sl_caido_y_hana_sin_resultados_es_caida_no_guia_inexistente(monkeypatch):
|
||||
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: s.SapNoDisponible("sin conexión")))
|
||||
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
|
||||
|
||||
with pytest.raises(s.SapNoDisponible):
|
||||
s.get_delivery_notes("09", "T001", "1")
|
||||
|
||||
|
||||
def test_sl_caido_pero_hana_encuentra_la_guia_sigue_adelante(monkeypatch):
|
||||
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: s.SapNoDisponible("sin conexión")))
|
||||
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [{"DocEntry": 7, "Tabla": "ODLN"}])
|
||||
|
||||
entradas = s._get_delivery_entries("09", "T001", "1")
|
||||
|
||||
assert entradas == [{"DocEntry": 7, "Tabla": "ODLN", "Entidad": "DeliveryNotes"}]
|
||||
|
||||
|
||||
def test_guia_que_de_verdad_no_existe_devuelve_lista_vacia(monkeypatch):
|
||||
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: {"value": []}))
|
||||
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
|
||||
|
||||
assert s.get_delivery_notes("09", "T001", "1") == []
|
||||
|
||||
|
||||
def test_un_rechazo_normal_de_sl_con_hana_vacio_no_es_caida(monkeypatch):
|
||||
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: ConnectionError("HTTP 400 - mal filtro")))
|
||||
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
|
||||
|
||||
assert s.get_delivery_notes("09", "T001", "1") == []
|
||||
|
||||
|
||||
def test_una_caida_al_leer_el_documento_se_propaga(monkeypatch):
|
||||
def respuestas(metodo, entidad):
|
||||
if entidad == "DeliveryNotes":
|
||||
return {"value": [{"DocEntry": 7}]}
|
||||
return s.SapNoDisponible("se cayó a mitad") # GET DeliveryNotes(7)
|
||||
|
||||
monkeypatch.setattr(s, "forward_to_sapb1", _forward(respuestas))
|
||||
|
||||
with pytest.raises(s.SapNoDisponible):
|
||||
s.get_delivery_notes("09", "T001", "1")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# buscar_factura_existente
|
||||
# ============================================================================
|
||||
class _Grabadora:
|
||||
def __init__(self, existentes):
|
||||
self.llamadas, self.existentes = [], existentes
|
||||
|
||||
def __call__(self, method, endpoint_path, **k):
|
||||
self.llamadas.append((endpoint_path, k["params"]))
|
||||
valor = self.existentes.get(endpoint_path)
|
||||
if isinstance(valor, Exception):
|
||||
raise valor
|
||||
return _Resp(200, {"value": [valor] if valor else []})
|
||||
|
||||
|
||||
def test_encuentra_la_factura_de_compra(monkeypatch):
|
||||
graba = _Grabadora({"PurchaseInvoices": {"DocEntry": 10, "DocNum": 5}})
|
||||
monkeypatch.setattr(s, "forward_to_sapb1", graba)
|
||||
|
||||
assert s.buscar_factura_existente("20537929520", "01", "F002", "486") == \
|
||||
{"entidad": "PurchaseInvoices", "DocEntry": 10, "DocNum": 5}
|
||||
|
||||
entidad, params = graba.llamadas[0]
|
||||
assert len(graba.llamadas) == 1 # no hace falta mirar los drafts
|
||||
for trozo in ("CardCode eq 'PL20537929520'", "U_BPP_MDTD eq '01'", "U_BPP_MDSD eq 'F002'",
|
||||
"U_BPP_MDCD eq '486'", "Cancelled eq 'tNO'"):
|
||||
assert trozo in params["$filter"]
|
||||
|
||||
|
||||
def test_si_no_es_factura_busca_en_los_drafts(monkeypatch):
|
||||
graba = _Grabadora({"Drafts": {"DocEntry": 3, "DocNum": 3}})
|
||||
monkeypatch.setattr(s, "forward_to_sapb1", graba)
|
||||
|
||||
assert s.buscar_factura_existente("20537929520", "01", "F002", "486")["entidad"] == "Drafts"
|
||||
assert [l[0] for l in graba.llamadas] == ["PurchaseInvoices", "Drafts"]
|
||||
assert "DocObjectCode eq 'oPurchaseInvoices'" in graba.llamadas[1][1]["$filter"]
|
||||
|
||||
|
||||
def test_si_no_existe_devuelve_none(monkeypatch):
|
||||
monkeypatch.setattr(s, "forward_to_sapb1", _Grabadora({}))
|
||||
assert s.buscar_factura_existente("20537929520", "01", "F002", "486") is None
|
||||
|
||||
|
||||
def test_las_comillas_se_escapan_en_el_filtro(monkeypatch):
|
||||
graba = _Grabadora({})
|
||||
monkeypatch.setattr(s, "forward_to_sapb1", graba)
|
||||
|
||||
s.buscar_factura_existente("20537929520", "01", "F0'02", "4' or '1'='1")
|
||||
|
||||
filtro = graba.llamadas[0][1]["$filter"]
|
||||
assert "U_BPP_MDSD eq 'F0''02'" in filtro and "U_BPP_MDCD eq '4'' or ''1''=''1'" in filtro
|
||||
|
||||
|
||||
def test_una_caida_al_buscar_se_propaga(monkeypatch):
|
||||
monkeypatch.setattr(s, "forward_to_sapb1", _Grabadora({"PurchaseInvoices": s.SapNoDisponible("caído")}))
|
||||
with pytest.raises(s.SapNoDisponible):
|
||||
s.buscar_factura_existente("20537929520", "01", "F002", "486")
|
||||
@@ -210,3 +210,45 @@ def test_init_db_agrega_la_columna_a_una_tabla_creada_antes():
|
||||
seguimiento.registrar_notificacion("ABR-261001-00001", "enviada", destinatario="a@x.com")
|
||||
assert seguimiento.obtener("ABR-261001-00001")["notificacion_destinatario"] == "a@x.com"
|
||||
|
||||
|
||||
def test_registrar_intento_cuenta_de_uno_en_uno():
|
||||
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||
assert r["intentos"] == 0
|
||||
|
||||
assert seguimiento.registrar_intento(r["codigo_seguimiento"]) == 1
|
||||
assert seguimiento.registrar_intento(r["codigo_seguimiento"]) == 2
|
||||
assert seguimiento.obtener(r["codigo_seguimiento"])["intentos"] == 2
|
||||
|
||||
|
||||
def test_registrar_intento_de_un_codigo_inexistente():
|
||||
with pytest.raises(LookupError):
|
||||
seguimiento.registrar_intento("ABR-000000-99999")
|
||||
|
||||
|
||||
def test_en_espera_es_un_estado_de_notificacion_valido():
|
||||
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
|
||||
seguimiento.registrar_notificacion(r["codigo_seguimiento"], "en_espera", destinatario="a@x.com")
|
||||
assert seguimiento.obtener(r["codigo_seguimiento"])["notificacion_estado"] == "en_espera"
|
||||
|
||||
|
||||
def test_init_db_agrega_la_columna_intentos_a_una_tabla_anterior():
|
||||
conn = sqlite3.connect(database_utilities.DB_NAME)
|
||||
conn.executescript("""
|
||||
DROP TABLE comprobantes;
|
||||
CREATE TABLE comprobantes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, codigo_seguimiento TEXT UNIQUE, email_id TEXT NOT NULL,
|
||||
ruc_emisor TEXT, tipo TEXT, serie TEXT, numero TEXT, estado TEXT NOT NULL DEFAULT 'pendiente',
|
||||
observaciones TEXT, doc_entry INTEGER, veces_recibido INTEGER NOT NULL DEFAULT 1,
|
||||
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente', notificacion_error TEXT, notificacion_fecha TEXT,
|
||||
notificacion_destinatario TEXT, creado_en TEXT NOT NULL, actualizado_en TEXT NOT NULL);
|
||||
INSERT INTO comprobantes (codigo_seguimiento, email_id, creado_en, actualizado_en)
|
||||
VALUES ('ABR-261001-00001', 'viejo', '2026-10-01 10:00:00', '2026-10-01 10:00:00');
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
database_utilities.init_db()
|
||||
|
||||
assert seguimiento.obtener("ABR-261001-00001")["intentos"] == 0 # el valor por defecto se aplica a lo existente
|
||||
assert seguimiento.registrar_intento("ABR-261001-00001") == 1
|
||||
|
||||
Reference in new issue
Block a user