103 lines
4.9 KiB
Python
103 lines
4.9 KiB
Python
"""Unit coverage for SPEC-015 web search via Exa (WEB-01..05)."""
|
|
|
|
import os
|
|
import unittest
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from fjerkroa_bot.openai_responder import OpenAIResponder
|
|
from fjerkroa_bot.websearch import WEB_SEARCH_TOOL, WebSearch, _format_results
|
|
|
|
CONFIG = {"openai-token": "t", "model": "m", "system": "s", "history-limit": 5}
|
|
|
|
|
|
def _tool_names(responder):
|
|
return [f["name"] for f in responder._available_tools()]
|
|
|
|
|
|
class TestToolOffered(unittest.TestCase):
|
|
def test_gate_needs_flag_and_key(self):
|
|
"""WEB-01: web_search offered only with enable-web-search AND a key."""
|
|
off = OpenAIResponder(CONFIG, "chat") # flag off -> absent even if env key exists
|
|
self.assertNotIn("web_search", _tool_names(off))
|
|
on = OpenAIResponder(dict(CONFIG, **{"enable-web-search": True, "exa-api-key": "k"}), "chat")
|
|
self.assertIn("web_search", _tool_names(on))
|
|
self.assertEqual(WEB_SEARCH_TOOL["name"], "web_search")
|
|
with patch.dict(os.environ, {"EXA_API_KEY": ""}):
|
|
nokey = OpenAIResponder(dict(CONFIG, **{"enable-web-search": True}), "chat")
|
|
self.assertNotIn("web_search", _tool_names(nokey))
|
|
|
|
|
|
class TestFormat(unittest.TestCase):
|
|
def test_results_sanitized_and_capped(self):
|
|
"""WEB-02: title/snippet sanitized + capped; non-dict rows skipped."""
|
|
data = {
|
|
"results": [
|
|
{"title": "@everyone Hi", "url": "https://x.com/a", "text": "@here " + "y" * 1000, "publishedDate": "2026-01-01"},
|
|
{"title": "T2", "url": "https://x.com/b", "text": "short"},
|
|
"not a dict",
|
|
]
|
|
}
|
|
rows = _format_results(data, 50)
|
|
self.assertEqual(len(rows), 2)
|
|
self.assertNotIn("@everyone", rows[0]["title"])
|
|
self.assertNotIn("@here", rows[0]["snippet"])
|
|
self.assertLessEqual(len(rows[0]["snippet"]), 50)
|
|
self.assertEqual(rows[0]["url"], "https://x.com/a")
|
|
self.assertEqual(rows[0]["published"], "2026-01-01")
|
|
|
|
|
|
class TestSearch(unittest.IsolatedAsyncioTestCase):
|
|
async def test_num_results_clamped(self):
|
|
"""WEB-03: numResults clamped to 1..10; 0 falls back to default."""
|
|
ws = WebSearch(lambda: {"exa-api-key": "k"})
|
|
with patch.object(ws, "_post", new=AsyncMock(return_value={"results": []})) as post:
|
|
await ws.search("hi", num_results=999)
|
|
self.assertEqual(post.await_args.args[0]["numResults"], 10)
|
|
await ws.search("hi", num_results=0)
|
|
self.assertEqual(post.await_args.args[0]["numResults"], 5)
|
|
|
|
async def test_no_key_returns_error(self):
|
|
"""WEB-04: no key -> error dict, no network call."""
|
|
with patch.dict(os.environ, {"EXA_API_KEY": ""}):
|
|
ws = WebSearch(lambda: {})
|
|
with patch.object(ws, "_post", new=AsyncMock()) as post:
|
|
result = await ws.search("hi")
|
|
post.assert_not_awaited()
|
|
self.assertIn("error", result)
|
|
|
|
async def test_api_failure_returns_error(self):
|
|
"""WEB-04: a raising request is caught, returns an error dict."""
|
|
ws = WebSearch(lambda: {"exa-api-key": "k"})
|
|
with patch.object(ws, "_post", new=AsyncMock(side_effect=RuntimeError("boom"))):
|
|
result = await ws.search("hi")
|
|
self.assertIn("error", result)
|
|
|
|
async def test_empty_query_no_call(self):
|
|
"""WEB-04: blank query returns empty results without a call."""
|
|
ws = WebSearch(lambda: {"exa-api-key": "k"})
|
|
with patch.object(ws, "_post", new=AsyncMock()) as post:
|
|
result = await ws.search(" ")
|
|
post.assert_not_awaited()
|
|
self.assertEqual(result["results"], [])
|
|
|
|
async def test_search_returns_formatted(self):
|
|
"""WEB-02: a successful search returns sanitized rows."""
|
|
ws = WebSearch(lambda: {"exa-api-key": "k"})
|
|
payload = {"results": [{"title": "Norge", "url": "https://ex.com/n", "text": "fakta"}]}
|
|
with patch.object(ws, "_post", new=AsyncMock(return_value=payload)):
|
|
result = await ws.search("norge")
|
|
self.assertEqual(result["results"][0]["title"], "Norge")
|
|
self.assertEqual(result["results"][0]["url"], "https://ex.com/n")
|
|
|
|
|
|
class TestPerUserCap(unittest.IsolatedAsyncioTestCase):
|
|
async def test_dispatch_caps_searches(self):
|
|
"""WEB-05: over web-daily-per-user, web_search refuses without calling the API."""
|
|
responder = OpenAIResponder(dict(CONFIG, **{"enable-web-search": True, "exa-api-key": "k", "web-daily-per-user": 2}), "chat")
|
|
responder.web_search.search = AsyncMock(return_value={"query": "x", "results": []})
|
|
for _ in range(2):
|
|
self.assertIn("results", await responder._dispatch_tool("web_search", {"query": "hi"}, "bob"))
|
|
blocked = await responder._dispatch_tool("web_search", {"query": "hi"}, "bob")
|
|
self.assertIn("error", blocked)
|
|
self.assertEqual(responder.web_search.search.await_count, 2)
|