Brave Search results are passed through LiteLLM (claude-haiku) when job config includes a `criteria` field. LLM returns indices of matching results, filtering out noise before posting to Matrix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
268 lines
10 KiB
Python
268 lines
10 KiB
Python
"""Tests for the Brave Search cron executor."""
|
|
|
|
import os
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from cron.brave_search import execute_brave_search
|
|
|
|
|
|
@pytest.fixture
|
|
def job():
|
|
return {
|
|
"id": "j1",
|
|
"name": "BMW Search",
|
|
"jobType": "brave_search",
|
|
"config": {"query": "BMW X3 damaged Cyprus", "maxResults": 5},
|
|
"targetRoom": "!room:cars",
|
|
"dedupKeys": ["https://old-result.com"],
|
|
}
|
|
|
|
|
|
class TestBraveSearchExecutor:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_error_without_api_key(self, job):
|
|
with patch.dict(os.environ, {"BRAVE_API_KEY": ""}, clear=False):
|
|
# Need to reload module to pick up empty env
|
|
import importlib
|
|
import cron.brave_search as bs
|
|
original_key = bs.BRAVE_API_KEY
|
|
bs.BRAVE_API_KEY = ""
|
|
try:
|
|
result = await execute_brave_search(job=job, send_text=AsyncMock())
|
|
assert result["status"] == "error"
|
|
assert "BRAVE_API_KEY" in result["error"]
|
|
finally:
|
|
bs.BRAVE_API_KEY = original_key
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_error_without_query(self):
|
|
job = {
|
|
"id": "j1",
|
|
"name": "Empty",
|
|
"jobType": "brave_search",
|
|
"config": {},
|
|
"targetRoom": "!room:test",
|
|
"dedupKeys": [],
|
|
}
|
|
import cron.brave_search as bs
|
|
original_key = bs.BRAVE_API_KEY
|
|
bs.BRAVE_API_KEY = "test-key"
|
|
try:
|
|
result = await execute_brave_search(job=job, send_text=AsyncMock())
|
|
assert result["status"] == "error"
|
|
assert "query" in result["error"].lower()
|
|
finally:
|
|
bs.BRAVE_API_KEY = original_key
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deduplicates_results(self, job):
|
|
"""Results with URLs already in dedupKeys should be filtered out."""
|
|
import cron.brave_search as bs
|
|
original_key = bs.BRAVE_API_KEY
|
|
bs.BRAVE_API_KEY = "test-key"
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"web": {
|
|
"results": [
|
|
{"title": "Old Result", "url": "https://old-result.com", "description": "Already seen"},
|
|
{"title": "New BMW", "url": "https://new-result.com", "description": "Fresh listing"},
|
|
]
|
|
}
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
send_text = AsyncMock()
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_cls:
|
|
mock_client = AsyncMock()
|
|
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
|
try:
|
|
result = await execute_brave_search(job=job, send_text=send_text)
|
|
finally:
|
|
bs.BRAVE_API_KEY = original_key
|
|
|
|
assert result["status"] == "success"
|
|
assert result["newDedupKeys"] == ["https://new-result.com"]
|
|
send_text.assert_called_once()
|
|
# Message should contain only the new result
|
|
msg = send_text.call_args[0][1]
|
|
assert "New BMW" in msg
|
|
assert "Old Result" not in msg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_results_status(self, job):
|
|
"""When API returns empty results, status should be no_results."""
|
|
import cron.brave_search as bs
|
|
original_key = bs.BRAVE_API_KEY
|
|
bs.BRAVE_API_KEY = "test-key"
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"web": {"results": []}}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_cls:
|
|
mock_client = AsyncMock()
|
|
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
|
try:
|
|
result = await execute_brave_search(job=job, send_text=AsyncMock())
|
|
finally:
|
|
bs.BRAVE_API_KEY = original_key
|
|
|
|
assert result["status"] == "no_results"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_results_already_seen(self, job):
|
|
"""When all results are already in dedupKeys, status should be no_results."""
|
|
import cron.brave_search as bs
|
|
original_key = bs.BRAVE_API_KEY
|
|
bs.BRAVE_API_KEY = "test-key"
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"web": {
|
|
"results": [
|
|
{"title": "Old", "url": "https://old-result.com", "description": "Seen"},
|
|
]
|
|
}
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_cls:
|
|
mock_client = AsyncMock()
|
|
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
|
try:
|
|
result = await execute_brave_search(job=job, send_text=AsyncMock())
|
|
finally:
|
|
bs.BRAVE_API_KEY = original_key
|
|
|
|
assert result["status"] == "no_results"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_llm_filter_keeps_matching_results(self):
|
|
"""LLM filter should only keep results that match criteria."""
|
|
import cron.brave_search as bs
|
|
orig_key, orig_url, orig_llm_key = bs.BRAVE_API_KEY, bs.LITELLM_URL, bs.LITELLM_KEY
|
|
bs.BRAVE_API_KEY = "test-key"
|
|
bs.LITELLM_URL = "http://llm:4000/v1"
|
|
bs.LITELLM_KEY = "sk-test"
|
|
|
|
job = {
|
|
"id": "j1",
|
|
"name": "BMW Search",
|
|
"jobType": "brave_search",
|
|
"config": {"query": "BMW X3 damaged", "maxResults": 5, "criteria": "Must be BMW X3, petrol, <=2019, damaged"},
|
|
"targetRoom": "!room:cars",
|
|
"dedupKeys": [],
|
|
}
|
|
|
|
brave_resp = MagicMock()
|
|
brave_resp.json.return_value = {"web": {"results": [
|
|
{"title": "BMW X3 2018 Unfallwagen Benzin", "url": "https://a.com", "description": "Damaged"},
|
|
{"title": "Toyota Corolla 2020", "url": "https://b.com", "description": "Not a BMW"},
|
|
{"title": "BMW X3 2017 Diesel crash", "url": "https://c.com", "description": "Diesel"},
|
|
]}}
|
|
brave_resp.raise_for_status = MagicMock()
|
|
|
|
llm_resp = MagicMock()
|
|
llm_resp.json.return_value = {"choices": [{"message": {"content": "[0]"}}]}
|
|
llm_resp.raise_for_status = MagicMock()
|
|
|
|
send_text = AsyncMock()
|
|
|
|
with patch("httpx.AsyncClient") as mock_cls:
|
|
mock_client = AsyncMock()
|
|
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(return_value=brave_resp)
|
|
mock_client.post = AsyncMock(return_value=llm_resp)
|
|
|
|
try:
|
|
result = await execute_brave_search(job=job, send_text=send_text)
|
|
finally:
|
|
bs.BRAVE_API_KEY, bs.LITELLM_URL, bs.LITELLM_KEY = orig_key, orig_url, orig_llm_key
|
|
|
|
assert result["status"] == "success"
|
|
assert result["newDedupKeys"] == ["https://a.com"]
|
|
msg = send_text.call_args[0][1]
|
|
assert "Unfallwagen" in msg
|
|
assert "Toyota" not in msg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_llm_filter_no_matches_returns_no_results(self):
|
|
"""When LLM filter rejects all results, status should be no_results."""
|
|
import cron.brave_search as bs
|
|
orig_key, orig_url, orig_llm_key = bs.BRAVE_API_KEY, bs.LITELLM_URL, bs.LITELLM_KEY
|
|
bs.BRAVE_API_KEY = "test-key"
|
|
bs.LITELLM_URL = "http://llm:4000/v1"
|
|
bs.LITELLM_KEY = "sk-test"
|
|
|
|
job = {
|
|
"id": "j1", "name": "Search", "jobType": "brave_search",
|
|
"config": {"query": "test", "criteria": "Must be exactly X"},
|
|
"targetRoom": "!room:test", "dedupKeys": [],
|
|
}
|
|
|
|
brave_resp = MagicMock()
|
|
brave_resp.json.return_value = {"web": {"results": [{"title": "Nope", "url": "https://x.com", "description": "No"}]}}
|
|
brave_resp.raise_for_status = MagicMock()
|
|
|
|
llm_resp = MagicMock()
|
|
llm_resp.json.return_value = {"choices": [{"message": {"content": "[]"}}]}
|
|
llm_resp.raise_for_status = MagicMock()
|
|
|
|
send_text = AsyncMock()
|
|
|
|
with patch("httpx.AsyncClient") as mock_cls:
|
|
mock_client = AsyncMock()
|
|
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(return_value=brave_resp)
|
|
mock_client.post = AsyncMock(return_value=llm_resp)
|
|
|
|
try:
|
|
result = await execute_brave_search(job=job, send_text=send_text)
|
|
finally:
|
|
bs.BRAVE_API_KEY, bs.LITELLM_URL, bs.LITELLM_KEY = orig_key, orig_url, orig_llm_key
|
|
|
|
assert result["status"] == "no_results"
|
|
send_text.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_criteria_skips_llm_filter(self, job):
|
|
"""Without criteria, results pass through without LLM call."""
|
|
import cron.brave_search as bs
|
|
orig_key = bs.BRAVE_API_KEY
|
|
bs.BRAVE_API_KEY = "test-key"
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"web": {"results": [{"title": "R", "url": "https://new.com", "description": "D"}]}}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
send_text = AsyncMock()
|
|
|
|
with patch("httpx.AsyncClient") as mock_cls:
|
|
mock_client = AsyncMock()
|
|
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
|
|
try:
|
|
result = await execute_brave_search(job=job, send_text=send_text)
|
|
finally:
|
|
bs.BRAVE_API_KEY = orig_key
|
|
|
|
assert result["status"] == "success"
|
|
mock_client.post.assert_not_called()
|