Files
enlace/tests/test_search.py
T
msaldain 59b7c3acb6
Python / calidad (push) Successful in 3s
Python / tests (push) Successful in 11s
YAML / yaml (push) Successful in 4s
Un respaldo de búsqueda sin credencial se omite en vez de romper la carga
El CI, apenas empezó a ejecutarse de verdad, hizo fallar dos de sus tres
trabajos. La causa es un defecto de diseño, no del CI: la config del agente
declara a Brave como respaldo, y la validación exigía la credencial para poder
*leer* el archivo. Como la config vive en el repositorio y la credencial no, un
clon limpio quedaba sin poder cargar su propia configuración. Mis pruebas en
Gigastar no lo veían porque acá el archivo .env existe.

Ahora se distingue por rol, que es lo que corresponde:

- El primario sin credencial sigue siendo error fatal. Sin él no queda ninguna
  búsqueda en pie, y descubrirlo en la primera consulta real es tarde.
- Un respaldo sin credencial se cae de la cadena y el primario sigue andando.
  Degradarse es la respuesta correcta: tener red de seguridad es mejor que no
  tenerla, pero no tenerla es mejor que no arrancar.

Omitir no es esconder. La propiedad respaldos_omitidos deja el motivo a la
vista, y build_backend emite un aviso al construir la cadena, que es el punto
por el que pasa cualquier entrypoint que use búsqueda.

Verificado escondiendo .env y corriendo la suite y el validador como lo haría
un clon limpio: 124 tests en verde con credencial y sin ella.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 07:48:29 -03:00

458 lines
16 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_como_primario_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_respaldo_sin_credencial_se_omite_en_vez_de_romper():
"""La config vive en el repositorio y las credenciales no.
Exigir la clave para poder *leer* la config dejaba el proyecto sin poder
clonarse: el CI y cualquier máquina nueva fallaban antes de empezar. El
respaldo se cae de la cadena; el primario sigue funcionando.
"""
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"])
assert cfg.cadena == ["duckduckgo"]
def test_el_respaldo_omitido_queda_a_la_vista():
"""Omitir no es esconder: quedarse sin red de seguridad tiene que verse."""
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"])
assert cfg.respaldos_omitidos == [("brave", "ENLACE_BRAVE_API_KEY")]
def test_construir_la_cadena_avisa_del_respaldo_faltante():
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"])
with pytest.warns(RuntimeWarning, match="ENLACE_BRAVE_API_KEY"):
build_backend(cfg)
def test_con_credencial_el_respaldo_si_entra():
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"], brave_api_key="x")
assert cfg.cadena == ["duckduckgo", "brave"]
assert cfg.respaldos_omitidos == []
def test_el_primario_sin_credencial_si_es_fatal():
"""Sin primario no queda ninguna búsqueda en pie: eso no se degrada."""
with pytest.raises(ValueError, match="primario 'brave'"):
SearchConfig(backend="brave")
def test_la_config_del_repo_carga_sin_credenciales(monkeypatch):
"""Un clon limpio, sin .env, tiene que poder leer la config del repositorio.
Es exactamente lo que rompió en el CI: la validación exigía la clave de
Brave para poder cargar el archivo.
"""
monkeypatch.delenv("ENLACE_BRAVE_API_KEY", raising=False)
monkeypatch.setattr("enlace.config.load.load_dotenv", lambda *a, **k: [])
cfg = load_agent_config()
assert cfg.search.backend == "duckduckgo"
assert "brave" in cfg.search.fallbacks # la intención queda declarada
assert cfg.search.cadena == ["duckduckgo"] # pero no se usa sin credencial
# --- 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"