Files
enlace/tests/test_search.py
T
msaldain 4048936067 Corregir lo que reportó ruff, que nunca se había ejecutado
ruff estaba configurado en pyproject.toml desde el primer commit y jamás se
había corrido. Tenía 15 hallazgos. Dos importan más allá del estilo:

- zip() sin strict= trunca en silencio al más corto. En las comparaciones de
  lotes eso significa que un test podía pasar sin haber comparado todo. Donde
  los largos deben coincidir ahora es strict=True; donde difieren a propósito
  (pares consecutivos) queda strict=False, que documenta la intención.
- Un import sin usar delataba algo peor: BraveBackend se había escrito sin una
  sola prueba. Se agregan ocho, contra una respuesta con la forma que devuelve
  la API, incluidas la limpieza de etiquetas, el caso de límite de tasa —que
  tiene que distinguirse de 'no respondió'— y que la credencial viaje en la
  cabecera y nunca en la URL. El respaldo tiene que funcionar justo cuando el
  primario ya falló; merecía la misma cobertura.

El resto es orden de imports, collections.abc y líneas largas.
2026-07-28 07:25:51 -03:00

427 lines
14 KiB
Python

"""Búsqueda web sin API key.
El parser se testea contra una respuesta real guardada en tests/fixtures/: es la
parte que se rompe cuando DuckDuckGo cambia el markup, y tiene que fallar acá y
no en producción. Nada en esta suite sale a internet.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from enlace.agent.tools.search import (
BraveBackend,
CadenaDeBackends,
DuckDuckGoBackend,
SearchError,
SearchResult,
SearxNGBackend,
WebSearch,
_unwrap_redirect,
build_backend,
parse_duckduckgo_html,
)
from enlace.config.load import ConfigError, load_agent_config
from enlace.config.schema import SearchConfig
FIXTURE = Path(__file__).parent / "fixtures" / "ddg-lite-es.html"
@pytest.fixture(scope="module")
def html_real() -> str:
return FIXTURE.read_text(encoding="utf-8")
class BackendFalso:
name = "falso"
def __init__(self, resultados: list[SearchResult]) -> None:
self.resultados = resultados
self.llamadas = 0
def search(self, query: str, max_results: int) -> list[SearchResult]:
self.llamadas += 1
return self.resultados[:max_results]
# --- parser ---------------------------------------------------------------
def test_extrae_titulo_url_y_snippet(html_real):
resultados = parse_duckduckgo_html(html_real, max_results=5)
assert len(resultados) == 5
primero = resultados[0]
assert primero.url.startswith("https://")
assert "Home Assistant" in primero.title
assert len(primero.snippet) > 40
def test_limpia_las_etiquetas_de_resaltado(html_real):
"""DuckDuckGo envuelve los términos buscados en <b>; eso no debe llegar al
contexto del modelo."""
resultados = parse_duckduckgo_html(html_real, max_results=10)
for r in resultados:
assert "<b>" not in r.snippet and "</b>" not in r.snippet
assert "<" not in r.title
def test_resuelve_entidades_html(html_real):
resultados = parse_duckduckgo_html(html_real, max_results=10)
texto = " ".join(r.title + r.snippet for r in resultados)
assert "&amp;" not in texto and "&nbsp;" not in texto and "&#" not in texto
def test_respeta_el_maximo_de_resultados(html_real):
assert len(parse_duckduckgo_html(html_real, max_results=3)) == 3
assert len(parse_duckduckgo_html(html_real, max_results=1)) == 1
def test_html_sin_resultados_devuelve_lista_vacia():
assert parse_duckduckgo_html("<html><body>nada</body></html>", 5) == []
def test_html_truncado_no_rompe(html_real):
"""Una respuesta cortada a la mitad tiene que degradar, no explotar."""
resultados = parse_duckduckgo_html(html_real[: len(html_real) // 2], max_results=5)
assert isinstance(resultados, list)
def test_faltan_snippets_pero_hay_enlaces():
html = (
"<a href='https://ejemplo.com/uno' class='result-link'>Uno</a>"
"<a href='https://ejemplo.com/dos' class='result-link'>Dos</a>"
)
resultados = parse_duckduckgo_html(html, max_results=5)
assert [r.title for r in resultados] == ["Uno", "Dos"]
assert all(r.snippet == "" for r in resultados)
def test_descarta_enlaces_que_no_son_http():
html = "<a href='javascript:void(0)' class='result-link'>Malo</a>"
assert parse_duckduckgo_html(html, max_results=5) == []
# --- redirecciones --------------------------------------------------------
def test_desenvuelve_la_redireccion_de_duckduckgo():
"""Guardar la redirección en vez del destino ensucia la memoria episódica:
dos búsquedas a la misma página parecerían páginas distintas."""
envuelto = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fejemplo.com%2Fpagina&rut=abc"
assert _unwrap_redirect(envuelto) == "https://ejemplo.com/pagina"
def test_deja_pasar_las_urls_directas():
assert _unwrap_redirect("https://ejemplo.com/x") == "https://ejemplo.com/x"
def test_completa_el_esquema_en_urls_relativas_al_protocolo():
assert _unwrap_redirect("//ejemplo.com/x") == "https://ejemplo.com/x"
# --- presupuesto de contexto ----------------------------------------------
def test_el_snippet_se_recorta_al_presupuesto():
r = SearchResult(title="T", url="https://e.com", snippet="x" * 1000)
assert len(r.render(max_chars=100)) < 160
def test_render_numera_los_resultados_para_poder_citarlos():
backend = BackendFalso(
[SearchResult(f"T{i}", f"https://e.com/{i}", f"cuerpo {i}") for i in range(3)]
)
salida = WebSearch(backend, max_results=3).render("algo")
assert salida.startswith("1. ") and "\n2. " in salida and "\n3. " in salida
def test_sin_resultados_lo_dice_en_vez_de_devolver_vacio():
"""El modelo tiene que poder decir que no encontró nada; una cadena vacía
lo empujaría a inventar."""
assert WebSearch(BackendFalso([])).render("algo") == "Sin resultados."
# --- caché ----------------------------------------------------------------
def test_la_cache_evita_repetir_la_consulta_externa():
backend = BackendFalso([SearchResult("T", "https://e.com", "c")])
tool = WebSearch(backend, cache_ttl=300.0)
tool.search("misma pregunta")
tool.search("misma pregunta")
assert backend.llamadas == 1
def test_la_cache_no_mezcla_consultas_distintas():
backend = BackendFalso([SearchResult("T", "https://e.com", "c")])
tool = WebSearch(backend, cache_ttl=300.0)
tool.search("una")
tool.search("otra")
assert backend.llamadas == 2
def test_cache_ttl_cero_siempre_consulta():
backend = BackendFalso([SearchResult("T", "https://e.com", "c")])
tool = WebSearch(backend, cache_ttl=0.0)
tool.search("q")
tool.search("q")
assert backend.llamadas == 2
def test_consulta_vacia_da_error_util():
with pytest.raises(SearchError, match="vacía"):
WebSearch(BackendFalso([])).search(" ")
# --- config ---------------------------------------------------------------
def test_el_primario_no_necesita_credenciales():
"""Lo que importa del default: la primera consulta sale sin credenciales.
La cadena puede tener respaldos que sí las exijan, pero el camino habitual
no le informa a ningún tercero qué busca la familia.
"""
cfg = load_agent_config()
assert cfg.search.backend == "duckduckgo"
assert cfg.search.region == "es-es"
backend = build_backend(cfg.search)
primero = backend.backends[0] if isinstance(backend, CadenaDeBackends) else backend
assert isinstance(primero, DuckDuckGoBackend)
def test_searxng_exige_url():
with pytest.raises(ValueError, match="searxng_url"):
SearchConfig(backend="searxng")
def test_searxng_con_url_construye_su_backend():
cfg = SearchConfig(backend="searxng", searxng_url="http://localhost:8888")
assert isinstance(build_backend(cfg), SearxNGBackend)
def test_el_presupuesto_de_contexto_tiene_un_tope_razonable():
"""max_results acotado no es un capricho: con seq_len 2048 los resultados
compiten con la memoria recuperada y el turno del usuario."""
with pytest.raises(ValueError):
SearchConfig(max_results=100)
def test_config_de_agente_inexistente_da_error_util(tmp_path):
with pytest.raises(ConfigError, match="no existe"):
load_agent_config(tmp_path / "no-existe.yaml")
# --- cadena de respaldo ----------------------------------------------------
class BackendQueFalla:
name = "roto"
def __init__(self, mensaje: str = "caído") -> None:
self.mensaje = mensaje
self.llamadas = 0
def search(self, query: str, max_results: int) -> list[SearchResult]:
self.llamadas += 1
raise SearchError(self.mensaje)
def test_la_cadena_usa_el_primario_cuando_anda():
primario = BackendFalso([SearchResult("T", "https://e.com", "c")])
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
cadena = CadenaDeBackends([primario, respaldo])
assert cadena.search("q", 5)[0].title == "T"
assert respaldo.llamadas == 0 # no se toca la cuota del respaldo
assert cadena.ultimo_backend == "falso"
def test_la_cadena_cae_al_respaldo_ante_un_error():
roto = BackendQueFalla("límite de tasa alcanzado")
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
cadena = CadenaDeBackends([roto, respaldo])
assert cadena.search("q", 5)[0].title == "R"
assert roto.llamadas == 1
def test_cero_resultados_NO_dispara_el_respaldo():
"""Si el primario respondió bien y no encontró nada, esa es la respuesta.
Encadenar al siguiente proveedor por esto gastaría cuota y devolvería
resultados peores para una consulta que genuinamente no tiene respuesta.
"""
vacio = BackendFalso([])
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
cadena = CadenaDeBackends([vacio, respaldo])
assert cadena.search("consulta sin resultados", 5) == []
assert respaldo.llamadas == 0
def test_si_fallan_todos_el_error_dice_por_que_cada_uno():
cadena = CadenaDeBackends([BackendQueFalla("429"), BackendQueFalla("timeout")])
with pytest.raises(SearchError) as exc:
cadena.search("q", 5)
assert "429" in str(exc.value) and "timeout" in str(exc.value)
def test_la_cadena_recuerda_quien_respondio():
"""Al depurar una respuesta rara, lo primero es saber de dónde salió."""
respaldo = BackendFalso([SearchResult("R", "https://r.com", "c")])
respaldo.name = "respaldo"
cadena = CadenaDeBackends([BackendQueFalla(), respaldo])
cadena.search("q", 5)
assert cadena.ultimo_backend == "respaldo"
def test_una_cadena_vacia_es_un_error():
with pytest.raises(SearchError, match="ningún backend"):
CadenaDeBackends([])
# --- construcción desde config ---------------------------------------------
def test_un_solo_backend_no_se_envuelve_en_cadena():
cfg = SearchConfig(backend="duckduckgo")
assert isinstance(build_backend(cfg), DuckDuckGoBackend)
def test_config_con_fallback_arma_la_cadena():
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"], brave_api_key="x")
backend = build_backend(cfg)
assert isinstance(backend, CadenaDeBackends)
assert [type(b).__name__ for b in backend.backends] == [
"DuckDuckGoBackend",
"BraveBackend",
]
def test_el_fallback_repetido_no_se_duplica():
cfg = SearchConfig(backend="duckduckgo", fallbacks=["duckduckgo", "brave"], brave_api_key="x")
assert cfg.cadena == ["duckduckgo", "brave"]
def test_un_fallback_sin_credencial_falla_al_arrancar():
"""No en la primera consulta real — que es justo cuando el primario ya
falló y el respaldo tiene que funcionar."""
with pytest.raises(ValueError, match="fallback 'brave'"):
SearchConfig(backend="duckduckgo", fallbacks=["brave"])
def test_brave_como_primario_tambien_exige_credencial():
with pytest.raises(ValueError, match="backend 'brave'"):
SearchConfig(backend="brave")
def test_la_config_del_repo_declara_brave_de_respaldo():
cfg = load_agent_config()
assert cfg.search.backend == "duckduckgo"
assert "brave" in cfg.search.fallbacks
# --- parser de Brave -------------------------------------------------------
#
# El respaldo tiene que funcionar justo cuando el primario ya falló, así que su
# parser merece la misma cobertura. La respuesta de abajo reproduce la forma que
# devuelve la API de Brave.
_BRAVE = {
"web": {
"results": [
{
"title": "Home Assistant",
"url": "https://www.home-assistant.io/",
"description": "Domótica <strong>libre</strong> y con privacidad.",
},
{
"title": "Home Assistant &mdash; Wikipedia",
"url": "https://es.wikipedia.org/wiki/Home_Assistant",
"description": "Software de automatización del hogar.",
},
{"title": "Sin url", "url": "", "description": "no debería aparecer"},
]
}
}
class _RespuestaFalsa:
def __init__(self, payload: dict) -> None:
self._cuerpo = json.dumps(payload).encode()
def read(self) -> bytes:
return self._cuerpo
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _brave_con(payload, monkeypatch):
backend = BraveBackend(api_key="clave-de-prueba")
monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _RespuestaFalsa(payload))
return backend
def test_brave_extrae_titulo_url_y_descripcion(monkeypatch):
resultados = _brave_con(_BRAVE, monkeypatch).search("home assistant", 5)
assert len(resultados) == 2 # el que no tiene url se descarta
assert resultados[0].title == "Home Assistant"
assert resultados[0].url == "https://www.home-assistant.io/"
def test_brave_limpia_etiquetas_y_entidades(monkeypatch):
resultados = _brave_con(_BRAVE, monkeypatch).search("q", 5)
assert "<strong>" not in resultados[0].snippet
assert "&mdash;" not in resultados[1].title
# Los espacios múltiples se colapsan, igual que en el parser de DuckDuckGo.
assert " " not in resultados[1].snippet
def test_brave_respeta_el_maximo(monkeypatch):
assert len(_brave_con(_BRAVE, monkeypatch).search("q", 1)) == 1
def test_brave_sin_resultados_devuelve_lista_vacia(monkeypatch):
assert _brave_con({"web": {"results": []}}, monkeypatch).search("q", 5) == []
def test_brave_respuesta_sin_la_clave_web_no_rompe(monkeypatch):
assert _brave_con({"type": "search"}, monkeypatch).search("q", 5) == []
def test_brave_distingue_el_limite_de_tasa(monkeypatch):
"""Si el respaldo también está limitado, el mensaje tiene que decirlo: es
un diagnóstico distinto de 'no respondió'."""
import urllib.error
def falla_429(*a, **k):
raise urllib.error.HTTPError("u", 429, "Too Many Requests", {}, None)
monkeypatch.setattr("urllib.request.urlopen", falla_429)
with pytest.raises(SearchError, match="límite de tasa"):
BraveBackend(api_key="x").search("q", 5)
def test_brave_manda_la_credencial_en_la_cabecera(monkeypatch):
"""La clave va en X-Subscription-Token, nunca en la URL: una credencial en
la query string termina en los registros de todos los intermediarios."""
capturado = {}
def espia(request, *a, **k):
capturado["url"] = request.full_url
capturado["headers"] = request.headers
return _RespuestaFalsa({"web": {"results": []}})
monkeypatch.setattr("urllib.request.urlopen", espia)
BraveBackend(api_key="secreta-123").search("q", 5)
assert "secreta-123" not in capturado["url"]
assert capturado["headers"]["X-subscription-token"] == "secreta-123"