se responde al remitente con las plantillas de aceptado/rechazado

El sistema contesta a quien envió el correo con la plantilla que corresponda.
Por defecto (NOTIFICACION_MODO=prueba) la respuesta va solo al equipo con el
asunto "[PRUEBA → remitente]"; en modo real va al remitente con copia oculta
al equipo. El correo técnico (tablas de SAP, JSON) pasa a ir solo al equipo.

- respuesta_proveedor.py: contextos de las plantillas, envío y protecciones
  (remitente desconocido o sin dominio, propio buzón, no-reply, mensajes
  automáticos, respuestas a notificaciones, límite de 3 por remitente/24 h).
- observaciones.py: motivos de rechazo con título, filas y cómo corregir
  (borrador de textos); también para correos sin comprobante válido.
- Un error de SAP/HANA o un fallo inesperado no se le comunica al proveedor y
  ya no tumba el ciclo; "guía no encontrada" exige que HANA responda.
- outlook_utilities: es_mensaje_automatico y copia oculta en send_email.
- seguimiento: notificacion_destinatario (con migración) y
  contar_respuestas_correo.
- config_validator anuncia el modo activo; documentación y pruebas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 08:55:54 -05:00
1 parent af9e3961e5
commit 4deb9d4e4d
22 files changed
+1660 -146

No files matched your search

+11
View File
@@ -23,3 +23,14 @@ def base_de_datos_temporal(tmp_path_factory, monkeypatch):
"""
monkeypatch.setattr(database_utilities, "DB_NAME", str(tmp_path_factory.mktemp("bd") / "emails_prueba.db"))
database_utilities.init_db()
@pytest.fixture(autouse=True)
def entorno_de_notificacion_aislado(monkeypatch):
"""
Los módulos cargan el ``.env`` real al importarse. Las pruebas no deben depender de él:
sin esto, un ``NOTIFICACION_MODO=real`` del .env haría que se probara (y enviara) en modo real.
"""
for variable in ("NOTIFICACION_MODO", "NOTIFICACION_EQUIPO", "NOTIFICACION_CONTACTO", "LOGO_URL"):
monkeypatch.delenv(variable, raising=False)
monkeypatch.setenv("EMAIL", "buzon@aberio.com")
+31 -1
View File
@@ -5,7 +5,8 @@ import pytest
import config_validator as cv
OBLIGATORIAS = [v for grupo in cv.OBLIGATORIAS.values() for v in grupo]
OPCIONALES = ["RUTA_RED_FACTURACION", "RUC_EMPRESA", "LOG_LEVEL", *cv.VERIFICACION_SSL]
OPCIONALES = ["RUTA_RED_FACTURACION", "RUC_EMPRESA", "LOG_LEVEL", "NOTIFICACION_MODO", "NOTIFICACION_EQUIPO", "LOGO_URL",
*cv.VERIFICACION_SSL]
@pytest.fixture
@@ -74,3 +75,32 @@ def test_verificar_termina_con_codigo_1_si_hay_errores(env_completo, monkeypatch
def test_verificar_no_termina_si_todo_esta_bien(env_completo):
cv.verificar_configuracion() # no debe lanzar SystemExit
def test_el_modo_de_notificacion_se_anuncia_al_arrancar(env_completo, monkeypatch):
_, advertencias = cv.validar_configuracion()
assert any("PRUEBA" in a for a in advertencias) and not any("REAL" in a for a in advertencias)
monkeypatch.setenv("NOTIFICACION_MODO", "Real")
_, advertencias = cv.validar_configuracion()
assert any("REAL" in a and "quien envía el correo" in a for a in advertencias)
assert not any("PRUEBA" in a for a in advertencias)
def test_modo_de_notificacion_invalido_cae_en_prueba(env_completo, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "produccion")
errores, advertencias = cv.validar_configuracion()
assert errores == []
assert any("produccion" in a and "se usará prueba" in a for a in advertencias)
def test_equipo_y_logo_sin_definir_solo_advierten(env_completo, monkeypatch):
errores, advertencias = cv.validar_configuracion()
assert errores == []
assert any("NOTIFICACION_EQUIPO" in a for a in advertencias) and any("LOGO_URL" in a for a in advertencias)
monkeypatch.setenv("NOTIFICACION_EQUIPO", "a@x.com")
monkeypatch.setenv("LOGO_URL", "https://x.com/logo.png")
_, advertencias = cv.validar_configuracion()
assert not any("NOTIFICACION_EQUIPO" in a or "LOGO_URL" in a for a in advertencias)
+98
View File
@@ -0,0 +1,98 @@
import observaciones as obs
def _item(numero, line_total=100.0, quantity=10.0, error=None):
item = {"guia": {"tipo": "09", "serie": "T001", "numero": numero}, "line_total": line_total, "quantity": quantity}
if error:
item["error"] = error
return item
def _resultado(ok, guias, sap_price=100.0, label="LineTotal (FL*) vs total_linea"):
return {"ok": ok, "guias_lt": guias, "sap_price": sap_price, "label": label}
FACTURA = {"lineas": [{"total_linea": 624.38, "descripcion": "Servicio A"}]}
def test_formato():
assert obs.moneda(1250) == "S/ 1,250.00"
assert obs.moneda(None) == "S/ 0.00"
assert obs.texto_observaciones(1) == "1 observación"
assert obs.texto_observaciones(3) == "3 observaciones"
def test_lineas_conformes_no_generan_observaciones():
assert obs.desde_validacion(FACTURA, [_resultado(True, [_item("1")])]) == []
def test_importe_distinto_muestra_ambos_importes():
(o,) = obs.desde_validacion(FACTURA, [_resultado(False, [_item("1"), _item("2")], sap_price=674.38)])
assert o.codigo == "precio_no_coincide"
assert dict(o.filas) == {"Guías": "T001-1, T001-2", "Importe en su factura": "S/ 624.38", "Importe esperado": "S/ 674.38"}
assert "Línea" not in dict(o.filas) # con una sola línea no hace falta numerarla
def test_con_varias_lineas_se_indica_cual():
factura = {"lineas": [{"total_linea": 10}, {"total_linea": 20}]}
resultados = [_resultado(True, [_item("1")]), _resultado(False, [_item("2")], sap_price=25.0)]
(o,) = obs.desde_validacion(factura, resultados)
assert dict(o.filas)["Línea"] == "2"
def test_guia_sin_datos_en_sap_es_guia_no_encontrada_y_no_importe():
guias = [_item("1", 0.0, 0.0), _item("2", 0.0, 0.0)]
observaciones = obs.desde_validacion(FACTURA, [_resultado(False, guias, 0.0, "Sin guías SAP")])
assert [o.codigo for o in observaciones] == ["guia_no_encontrada", "guia_no_encontrada"]
assert [o.titulo for o in observaciones] == ["No encontramos la guía T001-1", "No encontramos la guía T001-2"]
def test_una_guia_encontrada_y_otra_no_da_ambas_observaciones():
guias = [_item("1", 0.0, 0.0), _item("2", 40.0, 5.0)]
observaciones = obs.desde_validacion(FACTURA, [_resultado(False, guias, sap_price=40.0)])
assert [o.codigo for o in observaciones] == ["guia_no_encontrada", "precio_no_coincide"]
def test_una_guia_con_error_de_consulta_no_se_reporta_como_no_encontrada():
guias = [_item("1", 0.0, 0.0, error="timeout")]
assert obs.desde_validacion(FACTURA, [_resultado(False, guias, 0.0)]) == [
obs.precio_no_coincide(1, 624.38, 0.0, ["T001-1"], False)
]
def test_lineas_sin_guia_o_con_cantidad_cero():
factura = {"lineas": [{"descripcion": "Flete a Lima", "total_linea": 5}, {"total_linea": 7}]}
resultados = [_resultado(False, [], label="Sin guía de referencia"), _resultado(False, [], label="Cantidad cero, sin validar")]
sin_guia, cero = obs.desde_validacion(factura, resultados)
assert sin_guia.codigo == "linea_sin_guia" and dict(sin_guia.filas) == {"Línea": "1", "Descripción": "Flete a Lima"}
assert cero.codigo == "cantidad_cero" and dict(cero.filas) == {"Línea": "2"}
def test_motivos_de_correo_sin_comprobante():
assert obs.sin_pdf_xml(2).filas == (("Archivos recibidos", "2"),)
assert obs.falta_xml(1).codigo == "falta_xml"
assert obs.sin_factura(["Guía de Remisión"]).filas == (("Documentos recibidos", "Guía de Remisión"),)
assert obs.sin_factura([]).filas == (("Documentos recibidos", "ninguno reconocible"),)
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([]),
]
for o in muestras:
assert o.titulo and o.como_corregir and o.codigo
assert len({o.codigo for o in muestras}) == len(muestras)
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"]}
+97
View File
@@ -0,0 +1,97 @@
import outlook_utilities as o
# ============================================================================
# es_mensaje_automatico
# ============================================================================
def _h(**kv):
return [{"name": k.replace("_", "-"), "value": v} for k, v in kv.items()]
def test_mensajes_normales_no_son_automaticos():
assert o.es_mensaje_automatico(None) is False
assert o.es_mensaje_automatico([]) is False
assert o.es_mensaje_automatico(_h(Auto_Submitted="no", Content_Type="text/html", Precedence="normal")) is False
def test_mensajes_automaticos():
assert o.es_mensaje_automatico(_h(Auto_Submitted="auto-replied")) is True
assert o.es_mensaje_automatico(_h(Auto_Submitted="auto-generated")) is True
assert o.es_mensaje_automatico(_h(X_Auto_Response_Suppress="All")) is True
assert o.es_mensaje_automatico(_h(X_Autoreply="yes")) is True
assert o.es_mensaje_automatico(_h(Precedence="bulk")) is True
assert o.es_mensaje_automatico(_h(Precedence="auto_reply")) is True
assert o.es_mensaje_automatico(_h(Content_Type="multipart/report; report-type=delivery-status")) is True
def test_los_nombres_de_encabezado_no_distinguen_mayusculas():
assert o.es_mensaje_automatico([{"name": "AUTO-SUBMITTED", "value": "Auto-Replied"}]) is True
# ============================================================================
# send_email (payload hacia Graph)
# ============================================================================
class _Respuesta:
def __init__(self, status=202, text=""):
self.status_code, self.text = status, text
def test_send_email_incluye_destinatarios_copia_y_copia_oculta(monkeypatch):
enviado = {}
monkeypatch.setattr(o.requests, "post", lambda url, **k: enviado.update(url=url, **k) or _Respuesta())
ok = o.send_email("tok", "buzon@x.com", ["a@x.com"], "Asunto", "<p>hola</p>", None,
cc_recipients=["c@x.com"], bcc_recipients=["oculto@x.com"])
mensaje = enviado["json"]["message"]
assert ok is True and enviado["url"].endswith("/users/buzon@x.com/sendMail")
assert mensaje["toRecipients"] == [{"emailAddress": {"address": "a@x.com"}}]
assert mensaje["ccRecipients"] == [{"emailAddress": {"address": "c@x.com"}}]
assert mensaje["bccRecipients"] == [{"emailAddress": {"address": "oculto@x.com"}}]
assert mensaje["attachments"] == [] and mensaje["body"]["contentType"] == "HTML"
def test_send_email_sin_copia_oculta_envia_la_lista_vacia(monkeypatch):
enviado = {}
monkeypatch.setattr(o.requests, "post", lambda url, **k: enviado.update(**k) or _Respuesta())
o.send_email("tok", "b@x.com", ["a@x.com"], "A", "<p/>")
assert enviado["json"]["message"]["bccRecipients"] == []
def test_send_email_devuelve_false_si_graph_rechaza_o_falla(monkeypatch):
monkeypatch.setattr(o.requests, "post", lambda *a, **k: _Respuesta(400, "mal"))
assert o.send_email("tok", "b@x.com", ["a@x.com"], "A", "<p/>") is False
def explota(*a, **k):
raise ConnectionError("sin red")
monkeypatch.setattr(o.requests, "post", explota)
assert o.send_email("tok", "b@x.com", ["a@x.com"], "A", "<p/>") is False
# ============================================================================
# get_emails
# ============================================================================
def test_get_emails_pide_los_encabezados_y_marca_los_automaticos(monkeypatch):
pedido = {}
class Resp:
status_code = 200
def json(self):
return {"value": [
{"id": "1", "receivedDateTime": "2026-10-14T15:35:00Z", "hasAttachments": True, "subject": "Factura",
"from": {"emailAddress": {"address": "a@x.com"}}, "internetMessageHeaders": []},
{"id": "2", "receivedDateTime": "2026-10-14T16:00:00Z", "hasAttachments": False, "subject": "Auto",
"from": {"emailAddress": {"address": "b@x.com"}},
"internetMessageHeaders": [{"name": "Auto-Submitted", "value": "auto-replied"}]},
{"id": "3", "receivedDateTime": "2026-10-14T16:00:00Z", "subject": "Sin encabezados", "from": {}},
]}
monkeypatch.setattr(o.requests, "get", lambda url, **k: pedido.update(k) or Resp())
correos = o.get_emails("buzon@x.com", "tok", "2026-10-14", "2026-10-14")
assert "internetMessageHeaders" in pedido["params"]["$select"]
assert [c["es_automatico"] for c in correos] == [False, True, False]
assert correos[0]["fecha"] == "2026-10-14 10:35:00" # UTC-5
assert correos[2]["sender"] == "Desconocido"
+270 -79
View File
@@ -1,7 +1,11 @@
"""
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).
Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y Graph
simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de 624.38
con la guía T001-128912).
Cada factura genera dos correos: el **técnico**, solo para el equipo (asunto
"Draft SAP B1 - …"), y la **respuesta al proveedor** con la plantilla de aceptado o
rechazado (asunto "Aceptado · …" / "Rechazado · …").
"""
import glob
import logging
@@ -21,39 +25,60 @@ from conftest import FIXTURES
CASO = "001_correo"
TOTAL_LINEA = 624.38
SENDER = "proveedor@empresa.com"
EQUIPO = ["equipo1@aberio.com", "equipo2@aberio.com"]
class Entorno:
"""Registra lo que el pipeline le pide a los servicios externos."""
"""Registra lo que el pipeline le pide a los servicios externos y permite alterar sus respuestas."""
def __init__(self):
self.line_total_sap = TOTAL_LINEA
self.guia_existe = True # False: SAP no devuelve ningún documento para la guía
self.sap_lanza = None # excepción que lanza get_delivery_notes
self.error_al_crear = None
self.hana_disponible = True
self.archivos = None # None: usa los del caso 001; dict {nombre: bytes}: esos archivos
self.drafts = []
self.correos = []
self.descargas = 0
@property
def tecnicos(self):
return [c for c in self.correos if c["asunto"].startswith("Draft SAP B1")]
@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"])]
@pytest.fixture
def entorno(tmp_path, monkeypatch):
e = Entorno()
monkeypatch.setenv("RUTA_RED_FACTURACION", "")
monkeypatch.setenv("NOTIFICACION_EQUIPO", ",".join(EQUIPO))
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
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))
if e.archivos is None:
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))
else:
for nombre, contenido in e.archivos.items():
with open(os.path.join(carpeta, nombre), "wb") as f:
f.write(contenido)
nombres.append(nombre)
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})
def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None, bcc_recipients=None):
e.correos.append({"para": para, "asunto": asunto, "html": html, "cc": cc_recipients,
"bcc": bcc_recipients, "adjuntos": adjuntos})
return True
def crear_draft(draft):
@@ -63,10 +88,18 @@ def entorno(tmp_path, monkeypatch):
return {"DocEntry": 777}
def delivery_notes(**k):
if e.sap_lanza:
raise e.sap_lanza
if not e.guia_existe:
return []
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}]}]
class ConexionHana:
def close(self):
pass
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"}])
@@ -74,62 +107,229 @@ def entorno(tmp_path, monkeypatch):
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)
monkeypatch.setattr(hana_manager, "get_connection", lambda: ConexionHana() if e.hana_disponible else None)
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))
def _procesar(e, sender=SENDER, subject="Factura", id_="abc", es_automatico=False, carpeta=None):
email = {"id": id_, "fecha": "2026-10-14 10:35:00", "subject": subject, "sender": sender,
"has_attach": True, "es_automatico": es_automatico}
pipeline._procesar_un_correo(email, "token", "buzon@aberio.com", str(carpeta or e.descarga), str(e.salida))
return email
def test_precios_coinciden_crea_draft_y_notifica(entorno):
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()
# ============================================================================
# Aceptado
# ============================================================================
def test_aceptado_crea_draft_y_responde_con_la_plantilla(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_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 draft["CardCode"] == "PL10060028686" and draft["NumAtCard"] == "01F002-2450"
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"])
(respuesta,) = entorno.respuestas
assert respuesta["para"] == [SENDER] # al que envió el correo
assert respuesta["bcc"] == EQUIPO # el equipo en copia oculta
assert respuesta["cc"] is None and respuesta["adjuntos"] is None
assert respuesta["asunto"].startswith("Aceptado · Factura F002-2450 · ABR-")
assert "Registramos su factura" in respuesta["html"]
assert "09T001-128912" not in respuesta["html"] and "T001-128912" in respuesta["html"]
assert "S/ 624.38" in respuesta["html"]
assert "Pago estimado" in respuesta["html"] # hay condición de pago de 30 días
def test_precios_no_coinciden_no_crea_draft_pero_notifica(entorno):
def test_el_correo_tecnico_va_solo_al_equipo_nunca_al_remitente(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno)
(tecnico,) = entorno.tecnicos
assert tecnico["para"] == EQUIPO
assert SENDER not in tecnico["para"] and not tecnico["cc"] and not tecnico["bcc"]
assert any(a.endswith(".xml") for a in tecnico["adjuntos"]) # lleva los adjuntos internos
assert f"<b>Remitente:</b> {SENDER}" in tecnico["html"] and "ABR-" in tecnico["html"]
assert SENDER not in [d for c in entorno.respuestas for d in c["para"] if c["asunto"].startswith("[")]
def test_modo_prueba_es_el_predeterminado_y_no_escribe_al_remitente(entorno):
_procesar(entorno) # NOTIFICACION_MODO sin definir
(respuesta,) = entorno.respuestas
assert respuesta["para"] == EQUIPO
assert respuesta["bcc"] is None
assert respuesta["asunto"].startswith(f"[PRUEBA → {SENDER}] Aceptado · Factura F002-2450")
assert all(SENDER not in c["para"] for c in entorno.correos) # a nadie fuera del equipo
# ============================================================================
# Rechazado
# ============================================================================
def test_precios_distintos_rechaza_y_muestra_ambos_importes(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
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"]
(respuesta,) = entorno.respuestas
assert respuesta["para"] == [SENDER]
assert respuesta["asunto"].startswith("Rechazado · Factura F002-2450")
html = respuesta["html"]
assert "No pudimos registrar su factura" in html and "1 observación" in html
assert "El importe no coincide con nuestro registro" in html
assert "S/ 624.38" in html and "S/ 674.38" in html # importe de la factura y esperado
assert "No coincide" in html
def test_error_al_crear_draft_se_notifica(entorno):
def test_guia_inexistente_rechaza_con_guia_no_encontrada(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.guia_existe = False
_procesar(entorno)
(respuesta,) = entorno.respuestas
assert "No encontramos la guía T001-128912" in respuesta["html"]
assert entorno.drafts == []
def test_guia_inexistente_con_hana_caido_es_error_interno_y_no_se_le_culpa_al_proveedor(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.guia_existe = False
entorno.hana_disponible = False
_procesar(entorno)
assert entorno.respuestas == [] # nada al proveedor
(tecnico,) = entorno.tecnicos
assert "HANA" in tecnico["html"]
(registro,) = _registros()
assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida"
def test_error_consultando_sap_es_error_interno_y_no_rechazo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_lanza = RuntimeError("sin conexión")
_procesar(entorno)
assert entorno.respuestas == [] and entorno.drafts == []
assert len(entorno.tecnicos) == 1
assert _registros()[0]["estado"] == "error"
def test_error_al_crear_draft_avisa_solo_al_equipo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
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"]
assert entorno.respuestas == []
(tecnico,) = entorno.tecnicos
assert "Error al crear Draft" in tecnico["html"] and "Service Layer caído" in tecnico["html"]
(registro,) = _registros()
assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida"
def test_remitente_desconocido_no_envia_correo(entorno):
# ============================================================================
# Correos sin comprobante válido
# ============================================================================
def test_correo_con_adjuntos_que_no_son_pdf_ni_xml_se_rechaza(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.archivos = {"foto.png": b"x", "nota.docx": b"y"}
_procesar(entorno)
(respuesta,) = entorno.respuestas
assert respuesta["para"] == [SENDER]
assert respuesta["asunto"].startswith("Rechazado · Comprobante no recibido · ABR-")
html = respuesta["html"]
assert "No recibimos el XML ni el PDF de su comprobante" in html
assert "Guías revisadas" not in html # sin tabla de guías vacía
assert "RUC —" not in html and "Factura electrónica" not in html # sin datos de comprobante inventados
assert html.count("RUC") == 1 # solo el del pie (RUC de A. Berio)
assert "{{" not in html
assert entorno.tecnicos == [] and entorno.drafts == []
(registro,) = _registros()
assert registro["serie"] is None and registro["estado"] == "rechazado"
assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER
def test_solo_pdf_sin_xml_pide_el_xml(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
pdf = glob.glob(os.path.join(FIXTURES, "002_correo", "*.pdf"))[0]
entorno.archivos = {os.path.basename(pdf): open(pdf, "rb").read()}
_procesar(entorno)
(respuesta,) = entorno.respuestas
assert "Falta el archivo XML de su comprobante" in respuesta["html"]
def test_correo_sin_adjuntos_no_se_responde(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.archivos = {}
_procesar(entorno)
assert entorno.correos == [] and _registros() == []
@pytest.mark.parametrize("sender, subject, automatico, motivo", [
("no-reply@empresa.com", "Factura", False, "automática"),
("buzon@aberio.com", "Factura", False, "propio buzón"),
("persona@local", "Factura", False, "no válida"),
(SENDER, "Fuera de oficina", True, "automático"),
(SENDER, "Re: Aceptado · Factura F002-1 · ABR-261019-00042", False, "respuesta a una notificación"),
(SENDER, "RE: gracias", False, "respuesta a una notificación"),
])
def test_correos_sin_comprobante_a_los_que_no_se_responde(entorno, monkeypatch, sender, subject, automatico, motivo):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.archivos = {"foto.png": b"x"}
_procesar(entorno, sender=sender, subject=subject, es_automatico=automatico)
assert entorno.correos == []
(registro,) = _registros()
assert registro["notificacion_estado"] == "omitida" and motivo in registro["notificacion_error"]
def test_limite_de_respuestas_por_remitente(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.archivos = {"foto.png": b"x"}
for n in range(4):
_procesar(entorno, id_=f"correo-{n}", carpeta=entorno.descarga.parent / f"d{n}")
assert [r["notificacion_estado"] for r in _registros()] == ["enviada", "enviada", "enviada", "omitida"]
assert len(entorno.respuestas) == 3
# ============================================================================
# Remitente y registro
# ============================================================================
def test_remitente_desconocido_crea_el_draft_pero_no_responde(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno, sender="Desconocido")
assert len(entorno.drafts) == 1 # el draft se crea igual
assert entorno.correos == []
assert len(entorno.drafts) == 1
assert entorno.respuestas == [] and len(entorno.tecnicos) == 1
assert _registros()[0]["notificacion_estado"] == "omitida"
def test_correo_ya_procesado_se_salta(entorno, monkeypatch):
@@ -137,8 +337,7 @@ def test_correo_ya_procesado_se_salta(entorno, monkeypatch):
_procesar(entorno)
assert entorno.descargas == 0
assert entorno.drafts == [] and entorno.correos == []
assert entorno.descargas == 0 and entorno.drafts == [] and entorno.correos == []
def test_guarda_resultados_y_draft_en_disco(entorno):
@@ -149,69 +348,50 @@ def test_guarda_resultados_y_draft_en_disco(entorno):
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_aceptado_deja_el_resultado_y_la_notificacion_registrados(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
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"
assert (registro["estado"], registro["doc_entry"], registro["tipo"]) == ("aceptado", 777, "01")
assert (registro["serie"], registro["numero"], registro["ruc_emisor"]) == ("F002", "2450", "10060028686")
assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER
def test_precios_que_no_coinciden_dejan_el_comprobante_rechazado(entorno):
def test_modo_prueba_registra_a_quien_se_envio_de_verdad(entorno):
_procesar(entorno)
assert _registros()[0]["notificacion_destinatario"] == ", ".join(EQUIPO)
def test_rechazo_guarda_las_observaciones_completas(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"}]
(observacion,) = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"]
assert observacion["codigo"] == "precio_no_coincide"
assert {"etiqueta": "Importe esperado", "valor": "S/ 674.38"} in observacion["filas"]
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),
)
_procesar(entorno, id_="otro-correo", carpeta=entorno.descarga.parent / "descarga2")
(registro,) = _registros() # sigue habiendo una sola fila
(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 test_si_falla_el_seguimiento_igual_se_crea_el_draft_pero_no_se_responde(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
def roto(*a, **k):
raise sqlite3.OperationalError("database is locked")
@@ -221,4 +401,15 @@ def test_si_falla_el_seguimiento_igual_se_crea_el_draft(entorno, monkeypatch):
_procesar(entorno)
assert len(entorno.drafts) == 1
assert len(entorno.correos) == 1
assert len(entorno.tecnicos) == 1 and entorno.respuestas == [] # sin código no se responde al proveedor
def test_un_fallo_inesperado_en_una_factura_no_tumba_el_ciclo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
monkeypatch.setattr(pipeline.draft_sap, "build_sap_draft", lambda *a, **k: 1 / 0)
_procesar(entorno) # no debe lanzar excepción
assert entorno.drafts == [] and entorno.respuestas == []
assert "ZeroDivisionError" in entorno.tecnicos[0]["html"]
assert _registros()[0]["estado"] == "error"
+270
View File
@@ -0,0 +1,270 @@
import logging
import pytest
import observaciones as obs
import outlook_utilities
import plantillas
import respuesta_proveedor as rp
import seguimiento
FACTURA = {
"serie": "F002", "numero": "486", "tipo": "01", "ruc_emisor": "20537929520",
"razon_social_emisor": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"lineas": [
{"total_linea": 624.38, "guias_remision": [{"serie": "T001", "numero": "1"}, {"serie": "T001", "numero": "2"}]},
{"total_linea": 100.0, "guias_remision": [{"serie": "T001", "numero": "3"}]},
],
}
RESULTADOS = [
{"ok": True, "guias_lt": [{"guia": {"serie": "T001", "numero": "1"}}, {"guia": {"serie": "T001", "numero": "2"}}]},
{"ok": False, "guias_lt": [{"guia": {"serie": "T001", "numero": "3"}}]},
]
EMAIL = {"id": "c1", "fecha": "2026-10-14 10:35:00", "subject": "Factura", "sender": "prov@empresa.com"}
@pytest.fixture
def registro():
return seguimiento.registrar_comprobante("c1", "20537929520", "01", "F002", "486")
# ============================================================================
# Configuración
# ============================================================================
@pytest.mark.parametrize("valor, esperado", [(None, "prueba"), ("", "prueba"), ("real", "real"), (" REAL ", "real"),
("prueba", "prueba"), ("produccion", "prueba")])
def test_modo(monkeypatch, valor, esperado):
if valor is not None:
monkeypatch.setenv("NOTIFICACION_MODO", valor)
assert rp.modo() == esperado
def test_equipo(monkeypatch):
assert rp.equipo() == rp.EQUIPO_POR_DEFECTO
monkeypatch.setenv("NOTIFICACION_EQUIPO", " a@x.com ; b@x.com,c@x.com ,, ")
assert rp.equipo() == ["a@x.com", "b@x.com", "c@x.com"]
def test_contacto_usa_el_buzon_si_no_se_define(monkeypatch):
assert rp.contacto_correo() == "buzon@aberio.com"
monkeypatch.setenv("NOTIFICACION_CONTACTO", "ayuda@aberio.com")
assert rp.contacto_correo() == "ayuda@aberio.com"
# ============================================================================
# Protecciones
# ============================================================================
BUZON = "buzon@aberio.com"
def _correo(**kv):
return {**EMAIL, **kv}
@pytest.mark.parametrize("sender", [None, "", "Desconocido"])
def test_sin_remitente(sender):
assert "desconocido" in rp.motivo_omision(_correo(sender=sender), BUZON)
@pytest.mark.parametrize("sender", ["test@local", "sin-arroba", "a@b", "a b@c.com"])
def test_direcciones_no_validas(sender):
assert "no válida" in rp.motivo_omision(_correo(sender=sender), BUZON)
def test_el_propio_buzon_aunque_cambie_la_capitalizacion():
assert "propio buzón" in rp.motivo_omision(_correo(sender="Buzon@ABERIO.com"), BUZON)
@pytest.mark.parametrize("sender", ["no-reply@x.com", "noreply@x.com", "No_Reply@x.com", "donotreply@x.com",
"do-not-reply@x.com", "mailer-daemon@x.com", "postmaster@x.com", "bounces@x.com",
"no-responder@x.com"])
def test_direcciones_automaticas(sender):
assert "automática" in rp.motivo_omision(_correo(sender=sender), BUZON)
def test_una_direccion_normal_con_palabras_parecidas_si_se_responde():
assert rp.motivo_omision(_correo(sender="reply.compras@empresa.com"), BUZON) is None
assert rp.motivo_omision(_correo(sender="ventas@empresa.com"), BUZON) is None
def test_mensaje_automatico():
assert "automático" in rp.motivo_omision(_correo(es_automatico=True), BUZON)
def test_respuestas_a_notificaciones_solo_se_omiten_si_no_hay_comprobante():
respuesta = _correo(subject="Re: Aceptado · Factura F002-1 · ABR-261019-00042")
assert rp.motivo_omision(respuesta, BUZON, sin_comprobante=True) is not None
assert rp.motivo_omision(respuesta, BUZON, sin_comprobante=False) is None # con comprobante se procesa normal
assert rp.motivo_omision(_correo(subject="RV: documentos"), BUZON, sin_comprobante=True) is not None
assert rp.motivo_omision(_correo(subject="Factura ABR-261019-00042"), BUZON, sin_comprobante=True) is not None
assert rp.motivo_omision(_correo(subject="Factura de octubre"), BUZON, sin_comprobante=True) is None
# ============================================================================
# Contextos
# ============================================================================
def test_guias_el_importe_de_la_linea_va_una_vez():
filas = rp.guias_para_plantilla(FACTURA, RESULTADOS)
assert filas == [
{"guia": "T001-1", "importe_factura": "S/ 624.38", "conforme": True},
{"guia": "T001-2", "importe_factura": "incluida en la línea", "conforme": True},
{"guia": "T001-3", "importe_factura": "S/ 100.00", "conforme": False},
]
def test_guias_sin_resultado_de_validacion_usa_las_del_xml_como_no_conformes():
filas = rp.guias_para_plantilla({"lineas": [{"total_linea": 5, "guias_remision": [{"serie": "T001", "numero": "9"}]}]}, [])
assert filas == [{"guia": "T001-9", "importe_factura": "S/ 5.00", "conforme": False}]
def test_linea_sin_guias_no_genera_filas():
assert rp.guias_para_plantilla({"lineas": [{"total_linea": 5, "guias_remision": []}]}, [{"ok": False, "guias_lt": []}]) == []
@pytest.mark.parametrize("vence, esperado", [("2026-11-13", "13/11/2026"), ("2026-10-14", None), ("2026-10-01", None)])
def test_fecha_estimada_de_pago_solo_si_hay_plazo(registro, vence, esperado):
draft = {"DocDate": "2026-10-14", "DocDueDate": vence}
contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, draft)
assert contexto["fecha_estimada_pago"] == esperado
def test_contexto_aceptado(registro):
contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {})
assert contexto["asunto"] == f"Aceptado · Factura F002-486 · {registro['codigo_seguimiento']}"
assert contexto["recepcion_fecha"] == "14/10/2026" and contexto["recepcion_hora"] == "10:35"
assert contexto["comprobante_tipo"] == "Factura electrónica" and contexto["proveedor_ruc"] == "20537929520"
assert contexto["fecha_estimada_pago"] is None
def test_contexto_rechazo_numera_las_observaciones(registro):
lista = [obs.guia_no_encontrada("T001-3"), obs.precio_no_coincide(2, 100, 120, ["T001-3"], True)]
contexto = rp.contexto_rechazo_factura(EMAIL, registro, FACTURA, RESULTADOS, lista)
assert contexto["observaciones_texto"] == "2 observaciones"
assert [o["numero"] for o in contexto["observaciones"]] == [1, 2]
assert contexto["observaciones"][0]["filas"] == [{"etiqueta": "Guía", "valor": "T001-3"}]
assert contexto["tiene_guias"] is True and contexto["preheader"].startswith("Tiene 2 observaciones.")
def test_contexto_rechazo_de_correo_sin_comprobante(registro):
contexto = rp.contexto_rechazo_correo(EMAIL, registro, [obs.sin_pdf_xml(1)])
assert contexto["asunto"] == f"Rechazado · Comprobante no recibido · {registro['codigo_seguimiento']}"
assert contexto["comprobante_numero"] is None and contexto["proveedor_ruc"] is None
assert contexto["proveedor_razon_social"] == "prov@empresa.com"
assert contexto["guias"] == [] and contexto["tiene_guias"] is False
def test_los_tres_contextos_renderizan_sin_variables_faltantes(registro):
lista = [obs.sin_pdf_xml(1)]
for plantilla, contexto in [
("aceptado", rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {"DocDate": "2026-10-14", "DocDueDate": "2026-11-13"})),
("rechazado", rp.contexto_rechazo_factura(EMAIL, registro, FACTURA, RESULTADOS, lista)),
("rechazado", rp.contexto_rechazo_correo(EMAIL, registro, lista)),
]:
html = plantillas.renderizar_plantilla(plantilla, contexto)
assert "{{" not in html and registro["codigo_seguimiento"] in html
def test_razon_social_con_ampersand_sale_escapada(registro):
contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {})
assert "SÁNCHEZ &amp; HIJOS" in plantillas.renderizar_plantilla("aceptado", contexto)
# ============================================================================
# responder()
# ============================================================================
class _Enviados(list):
"""Correos que recibió Graph; ``ok`` controla si Graph los acepta."""
ok = True
@pytest.fixture
def graph(monkeypatch):
enviados = _Enviados()
def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None, bcc_recipients=None):
enviados.append({"para": para, "bcc": bcc_recipients, "asunto": asunto})
return enviados.ok
monkeypatch.setattr(outlook_utilities, "send_email", enviar)
monkeypatch.setenv("NOTIFICACION_EQUIPO", "eq@aberio.com")
return enviados
def _contexto(registro):
return rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {})
def test_responder_en_modo_real(graph, registro, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "enviada"
assert graph == [{"para": ["prov@empresa.com"], "bcc": ["eq@aberio.com"], "asunto": _contexto(registro)["asunto"]}]
fila = seguimiento.obtener(registro["codigo_seguimiento"])
assert (fila["notificacion_estado"], fila["notificacion_destinatario"]) == ("enviada", "prov@empresa.com")
def test_responder_en_modo_prueba(graph, registro):
assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "enviada"
(envio,) = graph
assert envio["para"] == ["eq@aberio.com"] and envio["bcc"] is None
assert envio["asunto"].startswith("[PRUEBA → prov@empresa.com] Aceptado")
def test_responder_omite_y_anota_el_motivo(graph, registro):
estado = rp.responder("t", BUZON, _correo(sender="no-reply@x.com"), "aceptado", _contexto(registro), registro)
assert estado == "omitida" and graph == []
fila = seguimiento.obtener(registro["codigo_seguimiento"])
assert fila["notificacion_estado"] == "omitida" and "automática" in fila["notificacion_error"]
def test_responder_si_graph_falla(graph, registro):
graph.ok = False
assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "fallida"
fila = seguimiento.obtener(registro["codigo_seguimiento"])
assert fila["notificacion_estado"] == "fallida" and "Graph" in fila["notificacion_error"]
def test_responder_si_la_plantilla_no_se_puede_armar_no_envia_nada(graph, registro):
contexto = _contexto(registro)
del contexto["codigo_seguimiento"]
assert rp.responder("t", BUZON, EMAIL, "aceptado", contexto, registro) == "fallida"
assert graph == []
assert "codigo_seguimiento" in seguimiento.obtener(registro["codigo_seguimiento"])["notificacion_error"]
def test_el_limite_solo_aplica_a_correos_sin_comprobante(graph, monkeypatch):
import database_utilities
monkeypatch.setenv("NOTIFICACION_MODO", "real")
database_utilities.save_emails([{"id": f"e{n}", "fecha": "2026-10-14 10:00:00", "sender": "prov@empresa.com",
"subject": "s", "has_attach": True} for n in range(5)])
estados = []
for n in range(5):
reg = seguimiento.registrar_correo(f"e{n}")
email = _correo(id=f"e{n}")
estados.append(rp.responder("t", BUZON, email, "rechazado", rp.contexto_rechazo_correo(email, reg, [obs.sin_pdf_xml(1)]),
reg, sin_comprobante=True))
assert estados == ["enviada"] * rp.MAX_RESPUESTAS_CORREO + ["omitida"] * (5 - rp.MAX_RESPUESTAS_CORREO)
def test_encabezado_tecnico(registro):
html = rp.encabezado_tecnico(EMAIL, registro)
assert "prov@empresa.com" in html and registro["codigo_seguimiento"] in html and "prueba" in html
assert "sin código" in rp.encabezado_tecnico(EMAIL, None)
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"]
+63
View File
@@ -147,3 +147,66 @@ def test_la_tabla_emails_existente_no_se_modifica():
database_utilities.init_db()
assert seguimiento.registrar_correo("correo-1")["codigo_seguimiento"].startswith("ABR-")
def test_registrar_notificacion_guarda_el_destinatario():
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
seguimiento.registrar_notificacion(r["codigo_seguimiento"], "enviada", destinatario="a@x.com, b@x.com")
assert seguimiento.obtener(r["codigo_seguimiento"])["notificacion_destinatario"] == "a@x.com, b@x.com"
def _correo_con_respuesta(email_id, sender, estado="enviada", comprobante=False):
database_utilities.save_emails([{"id": email_id, "fecha": "2026-10-14 10:00:00", "sender": sender,
"subject": "s", "has_attach": True}])
r = (seguimiento.registrar_comprobante(email_id, *COMPROBANTE[:2], "F002", email_id) if comprobante
else seguimiento.registrar_correo(email_id))
seguimiento.registrar_notificacion(r["codigo_seguimiento"], estado)
return r
def test_contar_respuestas_solo_cuenta_enviadas_a_correos_sin_comprobante_de_ese_remitente():
_correo_con_respuesta("e1", "Prov@Empresa.com") # cuenta (sin distinguir mayúsculas)
_correo_con_respuesta("e2", "prov@empresa.com") # cuenta
_correo_con_respuesta("e3", "prov@empresa.com", estado="omitida") # no: no se envió
_correo_con_respuesta("e4", "prov@empresa.com", estado="fallida") # no: no se envió
_correo_con_respuesta("e5", "otro@empresa.com") # no: otro remitente
_correo_con_respuesta("e6", "prov@empresa.com", comprobante=True) # no: tiene comprobante
assert seguimiento.contar_respuestas_correo("prov@empresa.com") == 2
assert seguimiento.contar_respuestas_correo("nadie@empresa.com") == 0
def test_contar_respuestas_ignora_las_anteriores_a_la_ventana():
r = _correo_con_respuesta("e1", "prov@empresa.com")
conn = sqlite3.connect(database_utilities.DB_NAME)
conn.execute("UPDATE comprobantes SET notificacion_fecha = '2020-01-01 00:00:00' WHERE codigo_seguimiento = ?",
(r["codigo_seguimiento"],))
conn.commit()
conn.close()
assert seguimiento.contar_respuestas_correo("prov@empresa.com", horas=24) == 0
assert seguimiento.contar_respuestas_correo("prov@empresa.com", horas=24 * 365 * 10) == 1
def test_init_db_agrega_la_columna_a_una_tabla_creada_antes():
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,
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")["email_id"] == "viejo" # el dato se conserva
seguimiento.registrar_notificacion("ABR-261001-00001", "enviada", destinatario="a@x.com")
assert seguimiento.obtener("ABR-261001-00001")["notificacion_destinatario"] == "a@x.com"