"""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)